|
| 1 | +"""Per-cycle git worktree isolation for Do/Check (issue #94). |
| 2 | +
|
| 3 | +A cycle's Do (builder edits the target in place) and Check (gates run against the |
| 4 | +working tree) otherwise mutate the host's **primary checkout**, leaving it dirty and |
| 5 | +colliding with any human work there. Instead, the harness runs Do/Check in a |
| 6 | +dedicated git **worktree** off the target's base branch, so the primary checkout is |
| 7 | +never touched. The worktree path is exposed to the builder and gate commands as |
| 8 | +``$PDCA_WORKTREE``. |
| 9 | +
|
| 10 | +On by default (``[driver].worktree``); **best-effort** — a target that is missing, |
| 11 | +not a git checkout, or whose base can't be resolved silently falls back to in-place |
| 12 | +(returns ``None``), so enabling it never breaks a cycle. The worktree is **reset and |
| 13 | +reused** per cycle (reset to the base before each Do), keyed by lane slot so concurrent |
| 14 | +lanes get private worktrees (never ``cp`` a worktree — its ``.git`` is an absolute |
| 15 | +pointer; each is created in place by ``git worktree add``). |
| 16 | +""" |
| 17 | + |
| 18 | +from __future__ import annotations |
| 19 | + |
| 20 | +import subprocess |
| 21 | +import sys |
| 22 | +from pathlib import Path |
| 23 | + |
| 24 | +from . import lane |
| 25 | +from .config import Config |
| 26 | + |
| 27 | + |
| 28 | +def _git(repo: Path, *args: str) -> int: |
| 29 | + """Run ``git -C repo args``, quietly; return the exit code (no raise).""" |
| 30 | + return subprocess.run(["git", "-C", str(repo), *args], |
| 31 | + capture_output=True, text=True).returncode |
| 32 | + |
| 33 | + |
| 34 | +def _target(d: Path, cfg: Config) -> tuple[Path, str] | None: |
| 35 | + """``(primary_checkout, base_ref)`` for bundle ``d``, or None if it can't be resolved. |
| 36 | +
|
| 37 | + Single-sourced from the brief's "Repo + branch target" via the same resolution |
| 38 | + publish uses; ``base_ref`` is ``<base_remote>/<base>`` (the remote-tracking base the |
| 39 | + worktree branches off), falling back to the bare base / default branch. |
| 40 | + """ |
| 41 | + from . import publish # lazy: publish imports leaves→worktree; avoid an import cycle |
| 42 | + try: |
| 43 | + repo_spec, base, _slug = publish._resolve_target(d) |
| 44 | + except Exception: # noqa: BLE001 — resolution is best-effort |
| 45 | + return None |
| 46 | + if not repo_spec: |
| 47 | + return None |
| 48 | + primary = publish._checkout_path(cfg, repo_spec) |
| 49 | + if not (primary / ".git").exists(): # not a git checkout → can't worktree |
| 50 | + return None |
| 51 | + base_ref = f"{cfg.base_remote}/{base}" if base else cfg.default_branch |
| 52 | + return primary, base_ref |
| 53 | + |
| 54 | + |
| 55 | +def _wt_dir(primary: Path) -> Path: |
| 56 | + """The worktree directory for the current lane slot — a sibling of the primary |
| 57 | + checkout (``<name>.pdca-wt`` / ``<name>.pdca-wt-l<lane>`` under concurrency).""" |
| 58 | + slot = lane.current() |
| 59 | + suffix = ".pdca-wt" + (f"-l{slot}" if slot is not None else "") |
| 60 | + return primary.parent / (primary.name + suffix) |
| 61 | + |
| 62 | + |
| 63 | +def path(d: Path, cfg: Config) -> Path | None: |
| 64 | + """The active worktree for this bundle/lane if one exists on disk, else None. |
| 65 | +
|
| 66 | + Read-only (no git): Do calls :func:`ensure` to create/reset it; Check (gates) and |
| 67 | + the builder env read this. Returns None when worktree isolation is off or the target |
| 68 | + isn't resolvable, so callers fall back to the primary checkout. |
| 69 | + """ |
| 70 | + if not cfg.worktree: |
| 71 | + return None |
| 72 | + tgt = _target(d, cfg) |
| 73 | + if tgt is None: |
| 74 | + return None |
| 75 | + wt = _wt_dir(tgt[0]) |
| 76 | + return wt if (wt / ".git").exists() else None |
| 77 | + |
| 78 | + |
| 79 | +def ensure(d: Path, cfg: Config) -> Path | None: |
| 80 | + """Create or reset the per-cycle worktree off the target base; return its path. |
| 81 | +
|
| 82 | + Reset-and-reused: an existing worktree is hard-reset to the base and cleaned; a new |
| 83 | + one is added off the base. Best-effort — disabled, unresolved target, non-git |
| 84 | + checkout, or any git failure returns None (the cycle then runs in place, unchanged). |
| 85 | + The primary checkout is never modified (worktrees are separate working trees). |
| 86 | + """ |
| 87 | + if not cfg.worktree: |
| 88 | + return None |
| 89 | + tgt = _target(d, cfg) |
| 90 | + if tgt is None: |
| 91 | + return None |
| 92 | + primary, base_ref = tgt |
| 93 | + wt = _wt_dir(primary) |
| 94 | + try: |
| 95 | + _git(primary, "fetch", cfg.base_remote) # refresh the base; best-effort |
| 96 | + if (wt / ".git").exists(): |
| 97 | + # Reuse: drop the prior cycle's edits, return to a clean base. |
| 98 | + if _git(wt, "reset", "--hard", base_ref) != 0 or _git(wt, "clean", "-fdq") != 0: |
| 99 | + print(f"worktree: could not reset {wt} to {base_ref}; running in place", |
| 100 | + file=sys.stderr) |
| 101 | + return None |
| 102 | + return wt |
| 103 | + # Create off the base. --force tolerates the base branch being checked out elsewhere. |
| 104 | + if _git(primary, "worktree", "add", "--force", str(wt), base_ref) != 0: |
| 105 | + print(f"worktree: could not create {wt} off {base_ref}; running in place", |
| 106 | + file=sys.stderr) |
| 107 | + return None |
| 108 | + return wt |
| 109 | + except Exception as exc: # noqa: BLE001 — isolation is best-effort, never fatal |
| 110 | + print(f"worktree: isolation unavailable for {d.name} ({exc}); running in place", |
| 111 | + file=sys.stderr) |
| 112 | + return None |
0 commit comments