raw · 10331 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | """Find the project (nearest ancestor with workflow.toml) and load its config.""" from __future__ import annotations import tomllib from dataclasses import dataclass, field from pathlib import Path from . import lanes as lanes_mod NAME = "workflow.toml" WF_HOME = ".wf-home" FORMAT = 1 KEYS = {"format", "tasks", "archive", "docs", "verify", "done", "ledgers", "anchors", "ctx_hint", "worktree_setup", "quick_gate", "slice_above", "lanes", "areas", "code_root", "area_stale_commits", "area_ignore", "cloud", "cloud_include", "cloud_note", "push"} AREA_IGNORE = ("tests", "test", "docs", "doc") ANCHOR_KEYS = {"index", "index_section", "specs"} class ConfigError(Exception): pass @dataclass class Config: root: Path format: int tasks: Path archive: Path docs: list[Path] = field(default_factory=list) verify: list[str] = field(default_factory=list) done: list[str] = field(default_factory=list) ledgers: Path | None = None anchors_index: Path | None = None anchors_section: str | None = None anchors_specs: Path | None = None ctx_hint: int = 100_000 # tokens; wf done/next suggest /clear above it (0 = off) worktree_setup: list[str] = field(default_factory=list) # `wf setup`: cwd = worktree, env WF_MAIN quick_gate: list[str] = field(default_factory=list) # `wf gate`: worker runs it before `wf done` slice_above: str = "1h" lanes: tuple = lanes_mod.DEFAULT_LANES areas: Path | None = None # area notes file; None = CLAUDE.md code_root: Path | None = None # repo area anchors / Checked / staleness refer to; None = root area_stale_commits: int = 20 area_ignore: list[str] = field(default_factory=lambda: list(AREA_IGNORE)) # never an uncovered area local: Path | None = None # lane worktree's copy of root whose LOCAL_KEYS were used cloud: bool = False # cloud lane opt-in (wf cloud send) cloud_include: list[str] = field(default_factory=list) # git-ignored paths force-added to the snapshot cloud_note: str | None = None # appended to the cloud prompt push: list[str] = field(default_factory=list) # wf finish/merge/push: run in root after a merge (else push home) @property def areas_file(self) -> Path: return self.areas or (self.local or self.root) / "CLAUDE.md" @property def split(self) -> bool: """code_root is another git repo than root: private home repo + code repo (split project).""" return bool(self.code_root) and git_top(self.code_root) not in (None, git_top(self.root)) @property def code(self) -> Path: return self.code_root or self.root def rel(self, path: Path) -> str: try: return str(path.relative_to(self.root)) except ValueError: return str(path) @property def areas_rel(self) -> str: """areas_file relative to its project folder (the lane worktree's copy or root).""" try: return str(self.areas_file.relative_to(self.local or self.root)) except ValueError: return str(self.areas_file) def git_top(folder: Path) -> Path | None: """The work tree top holding folder (.git dir or file), or None.""" for f in (folder, *folder.parents): if (f / ".git").exists(): return f return None def git_branch(gitdir: Path) -> str | None: try: head = (gitdir / "HEAD").read_text().strip() except OSError: return None return head.removeprefix("ref: refs/heads/") if head.startswith("ref: refs/heads/") else None def linked_worktree(folder: Path) -> tuple[Path, Path, Path] | None: """(worktree top, its gitdir, main tree top) when folder is in a linked git worktree.""" top = git_top(folder) if not top or not (top / ".git").is_file(): return None try: line = (top / ".git").read_text().strip() gitdir = (top / line.removeprefix("gitdir:").strip()).resolve() common = (gitdir / (gitdir / "commondir").read_text().strip()).resolve() except OSError: return None if common.name != ".git" or not line.startswith("gitdir:"): return None return top, gitdir, common.parent def find_root(start: Path) -> Path: """Folder with workflow.toml at or above start; in a linked git worktree the main tree's same folder, when it is a project too (one TASKS.md for all worktrees). A code worktree of a split project: its .wf-home.""" start = start.resolve() top = git_top(start) if top and (top / WF_HOME).is_file(): home = Path((top / WF_HOME).read_text().strip()) if not (home / NAME).is_file(): raise ConfigError(f"{WF_HOME} points to {home}: no {NAME} there (fix or delete {top / WF_HOME})") return home for folder in (start, *start.parents): if (folder / NAME).is_file(): wt = linked_worktree(folder) if wt: main = wt[2] / folder.relative_to(wt[0]) if (main / NAME).is_file(): return main return folder raise ConfigError(f"no {NAME} in {start} or above: not a wf project (wf init makes one)") def code_main(cfg: "Config") -> Path | None: """Main tree top of the code repo of a split project, else None.""" return git_top(cfg.code_root) if cfg.split else None def find_local(start: Path) -> Path | None: """In a linked worktree whose project root is the main tree's (find_root): the worktree's own copy of that folder when it has a workflow.toml, else None.""" start = start.resolve() wt = linked_worktree(start) if not wt: return None root = find_root(start) try: local = wt[0] / root.relative_to(wt[2]) except ValueError: return None return local if local != root and (local / NAME).is_file() else None def load_at(start: Path) -> "Config": """Config for a command run in start: the main tree's books, branch-testable keys (LOCAL_KEYS: worktree_setup, quick_gate, areas file, area settings) from a lane worktree's own workflow.toml.""" return load(find_root(start), find_local(start)) def _strings(data: dict, key: str) -> list[str]: value = data.get(key, []) if not isinstance(value, list) or not all(isinstance(v, str) for v in value): raise ConfigError(f"{NAME}: '{key}' must be a list of strings") return value def _string(data: dict, key: str, shown: str | None = None) -> str | None: value = data.get(key) if value is not None and not isinstance(value, str): raise ConfigError(f"{NAME}: '{shown or key}' must be a string") return value LOCAL_KEYS = ("worktree_setup", "quick_gate", "areas", "area_stale_commits", "area_ignore") def _read(path: Path) -> dict: try: return tomllib.loads(path.read_text(encoding="utf-8")) except (tomllib.TOMLDecodeError, OSError) as e: raise ConfigError(f"{path.name}: {e}") from None def load(root: Path, local: Path | None = None) -> Config: """root's workflow.toml; with local (a lane worktree's copy of root), LOCAL_KEYS come from local's workflow.toml and the areas file resolves there (a branch can test and --mark them before merge).""" data = _read(root / NAME) if local: mine = _read(local / NAME) for key in LOCAL_KEYS: data.pop(key, None) if key in mine: data[key] = mine[key] for key in data: if key not in KEYS: raise ConfigError(f"{NAME}: unknown key '{key}'") anchors = data.get("anchors", {}) if not isinstance(anchors, dict): raise ConfigError(f"{NAME}: 'anchors' must be a table") for key in anchors: if key not in ANCHOR_KEYS: raise ConfigError(f"{NAME}: unknown key 'anchors.{key}'") for key in ("format", "tasks", "archive"): if key not in data: raise ConfigError(f"{NAME}: missing '{key}'") if not isinstance(data["format"], int) or isinstance(data["format"], bool): raise ConfigError(f"{NAME}: 'format' must be a number") ctx_hint = data.get("ctx_hint", 100_000) if not isinstance(ctx_hint, int) or isinstance(ctx_hint, bool) or ctx_hint < 0: raise ConfigError(f"{NAME}: 'ctx_hint' must be a number of tokens (0 = off)") index = _string(anchors, "index", "anchors.index") specs = _string(anchors, "specs", "anchors.specs") ledgers = _string(data, "ledgers") stale = data.get("area_stale_commits", 20) if not isinstance(stale, int) or isinstance(stale, bool) or stale < 1: raise ConfigError(f"{NAME}: 'area_stale_commits' must be a number ≥ 1") slice_above = _string(data, "slice_above") or "1h" try: lane_list = lanes_mod.parse_lanes(data.get("lanes"), slice_above) except ValueError as e: raise ConfigError(f"{NAME}: {e}") from None areas = _string(data, "areas") code_root = _string(data, "code_root") if not isinstance(data.get("cloud", False), bool): raise ConfigError(f"{NAME}: 'cloud' must be true or false") return Config( root=root, format=data["format"], tasks=root / _string(data, "tasks"), archive=root / _string(data, "archive"), docs=[root / d for d in _strings(data, "docs")], verify=_strings(data, "verify"), done=_strings(data, "done"), ledgers=root / ledgers if ledgers else None, anchors_index=root / index if index else None, anchors_section=_string(anchors, "index_section", "anchors.index_section"), anchors_specs=root / specs if specs else None, ctx_hint=ctx_hint, worktree_setup=_strings(data, "worktree_setup"), quick_gate=_strings(data, "quick_gate"), slice_above=slice_above, lanes=lane_list, areas=(local or root) / areas if areas else None, code_root=(root / code_root).resolve() if code_root else None, area_stale_commits=stale, area_ignore=_strings(data, "area_ignore") if "area_ignore" in data else list(AREA_IGNORE), local=local, cloud=data.get("cloud", False), cloud_include=_strings(data, "cloud_include"), cloud_note=_string(data, "cloud_note"), push=_strings(data, "push"), ) |