workflow

git clone https://git.godosa.eu/workflow

master

raw · 10331 bytes

"""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"),
    )