scripts.check_worktree_envs
Reference › scripts › check_worktree_envs
The worktree-environment gate, ruling RUL-021: every feature branch gets a full environment of its own. It inspects the repository's git worktrees and fails when one lacks its own environment or shares the primary worktree's. tests/scripts/test_check_worktree_envs.py imports it.
Invocation: main takes --repo-root (any directory inside the repository; default the current directory), --fix-wheels, --json and --checks, a comma-separated subset of the checks. An unknown check name is a parser error. The gate battery passes only the venv-symlink check, which cannot flake, so a worktree that is still being provisioned does not fail a battery run.
list_worktrees runs git worktree list and parse_worktree_list turns the output into WorktreeInfo records. For each non-primary worktree the checks are: check_venv_symlink (the virtual environment must not be a symlink into another tree), check_venv_missing, check_env_missing, evaluate_vendor_wheels (vendored wheels must be present, and --fix-wheels hardlinks missing ones from the primary worktree) and check_merged_branch (a worktree whose branch is already merged is reported). Results are Violation records.
At a glance
| Source | scripts/check_worktree_envs.py |
| Lines of code | 527 |
| Classes | 3 |
| Functions | 15 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | tests/scripts/test_check_worktree_envs.py |
Class diagram
Dependency diagram
No internal imports and no importers.
API
Constants
| Name | Type | Value |
|---|---|---|
ALL_CHECKS |
tuple[str, ...] |
('venv-symlink', 'venv-missing', 'env-missing', 'vendor-wheels', 'merged-branch… |
Classes
WorktreeDiscoveryError
Bases: Exception · Line 58
git worktree list failed — not a git repo, or git is broken.
WorktreeInfo (dataclass)
Decorators: dataclass · Line 63
| Attribute | Type | Default |
|---|---|---|
path |
Path |
|
head |
str |
|
branch |
str | None |
|
detached |
bool |
Violation (dataclass)
Decorators: dataclass · Line 71
| Attribute | Type | Default |
|---|---|---|
worktree |
str |
|
check |
str |
|
message |
str |
Functions
| Function | Signature | Summary |
|---|---|---|
_to_worktree_info |
_to_worktree_info(entry: dict[str, str]) -> WorktreeInfo |
|
parse_worktree_list |
parse_worktree_list(output: str) -> list[WorktreeInfo] |
Parse git worktree list --porcelain output. |
list_worktrees |
list_worktrees(cwd: Path) -> list[WorktreeInfo] |
Run git worktree list --porcelain from cwd (any worktree of a repo reports every worktree, since .git is shared) and parse it. |
check_venv_symlink |
check_venv_symlink(wt: WorktreeInfo) -> Violation | None |
|
check_venv_missing |
check_venv_missing(wt: WorktreeInfo) -> Violation | None |
|
check_env_missing |
check_env_missing(wt: WorktreeInfo) -> Violation | None |
RUL-021 ("every damn feature branch gets a full env") + the 2026-08-12 measured defect: a worktree with a branch checked out but no .env fails every test that reads it from disk (not… |
_relative_vendor_paths |
_relative_vendor_paths(pyproject: dict) -> list[str] |
Every [tool.uv.sources] entry pinning a relative path under vendor/ — the class of pin that resolves against the WORKTREE, not the repo, and silently falls back to an sdist build when… |
_hardlink_missing |
_hardlink_missing(wt: WorktreeInfo, primary: WorktreeInfo, missing: list[str]) -> list[str] |
--fix-wheels: hardlink each missing vendored path from the primary worktree. |
evaluate_vendor_wheels |
evaluate_vendor_wheels(wt: WorktreeInfo, primary: WorktreeInfo, fix: bool) -> tuple[Violation | None, list[str]] |
Check 3: read THIS worktree's own pyproject.toml (branches differ) for relative vendor/ uv-source pins that don't resolve. |
check_merged_branch |
check_merged_branch(wt: WorktreeInfo, primary: WorktreeInfo) -> tuple[Violation | None, str | None] |
Check 4: a branch whose work is already in master is stale. |
_evaluate_worktree |
_evaluate_worktree(wt: WorktreeInfo, primary: WorktreeInfo, fix_wheels: bool, checks: frozenset[str]) -> tuple[list[Violation], str | None, list[str]] |
|
_evaluate |
_evaluate(worktrees: list[WorktreeInfo], fix_wheels: bool, checks: frozenset[str]) -> tuple[int, dict] |
|
run_check |
run_check(cwd: Path, fix_wheels: bool = False, checks: frozenset[str] | None = None) -> tuple[int, dict] |
Injection seam: tests pass a throwaway tmp_path repo root here — never chdir the test process, never touch the live repo. checks narrows to a subset of :data:ALL_CHECKS (None = all of them). |
_print_text_report |
_print_text_report(report: dict) -> None |
|
main |
main(argv: list[str] | None = None) -> int |
Source
#!/usr/bin/env python3
"""Worktree-environment gate — RUL-021 ("every damn feature branch gets a full
env until we merge then it gets deleted", verbatim, 2026-07-27).
See ``~/workspace/knowledge-wiki/rulings/RUL-021-worktree-owns-its-env.md``
for the ruling text and the incident (``wt-QWEN``'s ``.venv`` was a symlink
to the main checkout's virtualenv, and its ``vendor/wheels/`` was empty, so
the relative ``[tool.uv.sources]`` grpcio pin resolved to nothing and ``uv``
fell back to a from-sdist build — one that re-enables the GIL process-wide
on import, a SPEC-023 violation, in a virtualenv every other checkout used).
For every NON-PRIMARY worktree in ``git worktree list --porcelain`` this
checks:
1. ``.venv`` is a symlink -> violation (must be a real, independently-owned
virtualenv).
2. ``.venv`` is missing while the worktree has a branch checked out ->
violation.
3. A ``[tool.uv.sources]`` entry whose ``path`` is relative and lives under
``vendor/`` does not resolve inside THAT worktree's own
``pyproject.toml`` -> violation (branches differ, so each worktree's own
file is read, never the primary's).
4. The worktree's branch is already merged into ``master`` -> violation:
stale, worktree and env are due for deletion. A detached-HEAD worktree is
informational only, never a violation under this check.
Exit 0 clean, exit 1 on any violation. A directory that is not a git
repository (or has no worktrees) is a SKIP (exit 0, reason printed) — same
honesty rule as ``rulings_lint.py``'s missing-``RULINGS_DIR`` skip.
Flags:
--fix-wheels Populate missing vendored wheels by HARDLINK from the
primary worktree (falls back to a copy across
filesystems). The only auto-fix: creating virtualenvs and
deleting worktrees stay manual, per the ruling.
--json Machine-readable output.
--repo-root Directory to gate from (default: cwd). Never hardcode an
absolute path here — the primary worktree is always
derived from ``git worktree list``, not a literal path.
Usage:
python scripts/check_worktree_envs.py [--repo-root PATH] [--fix-wheels] [--json]
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import tomllib
from dataclasses import asdict, dataclass
from pathlib import Path, PurePosixPath
class WorktreeDiscoveryError(Exception):
"""``git worktree list`` failed — not a git repo, or git is broken."""
@dataclass
class WorktreeInfo:
path: Path
head: str
branch: str | None
detached: bool
@dataclass
class Violation:
worktree: str
check: str
message: str
def _to_worktree_info(entry: dict[str, str]) -> WorktreeInfo:
branch_ref = entry.get("branch")
branch = branch_ref.removeprefix("refs/heads/") if branch_ref else None
return WorktreeInfo(
path=Path(entry["worktree"]),
head=entry.get("HEAD", ""),
branch=branch,
detached="detached" in entry,
)
def parse_worktree_list(output: str) -> list[WorktreeInfo]:
"""Parse ``git worktree list --porcelain`` output. Pure, no subprocess —
exercised directly by tests against captured/fabricated porcelain text."""
entries: list[dict[str, str]] = []
current: dict[str, str] = {}
for raw_line in output.splitlines():
line = raw_line.rstrip()
if line.startswith("worktree "):
if current:
entries.append(current)
current = {"worktree": line[len("worktree ") :]}
elif line.startswith("HEAD "):
current["HEAD"] = line[len("HEAD ") :]
elif line.startswith("branch "):
current["branch"] = line[len("branch ") :]
elif line == "detached":
current["detached"] = "true"
elif line == "bare":
current["bare"] = "true"
if current:
entries.append(current)
return [_to_worktree_info(e) for e in entries if "bare" not in e]
def list_worktrees(cwd: Path) -> list[WorktreeInfo]:
"""Run ``git worktree list --porcelain`` from ``cwd`` (any worktree of a
repo reports every worktree, since ``.git`` is shared) and parse it."""
try:
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=cwd,
capture_output=True,
text=True,
timeout=15,
)
except subprocess.TimeoutExpired as exc:
raise WorktreeDiscoveryError(f"git worktree list timed out: {exc}") from exc
if result.returncode != 0:
raise WorktreeDiscoveryError(
result.stderr.strip() or "git worktree list failed"
)
return parse_worktree_list(result.stdout)
def check_venv_symlink(wt: WorktreeInfo) -> Violation | None:
venv = wt.path / ".venv"
if not venv.is_symlink():
return None
try:
target = os.readlink(venv)
except OSError:
target = "<unreadable>"
return Violation(
str(wt.path),
"venv-symlink",
f"RUL-021: .venv is a symlink to {target!r} — every worktree must own "
f"a real, independent virtualenv, never a shared one. Remedy: "
f"rm {venv} && (cd {wt.path} && uv sync --dev) to build its own.",
)
def check_venv_missing(wt: WorktreeInfo) -> Violation | None:
venv = wt.path / ".venv"
if wt.branch is None or venv.exists():
return None
return Violation(
str(wt.path),
"venv-missing",
f"RUL-021: .venv is missing while branch {wt.branch!r} is checked out "
f"— run `uv sync --dev` inside {wt.path} to create it.",
)
def check_env_missing(wt: WorktreeInfo) -> Violation | None:
"""RUL-021 ("every damn feature branch gets a full env") + the
2026-08-12 measured defect: a worktree with a branch checked out but no
``.env`` fails every test that reads it from disk (not ``os.environ``)
and gets misdiagnosed as a product bug rather than a missing file (see
scripts/provision_worktree_env.py's module docstring for the incident).
"""
env_file = wt.path / ".env"
if wt.branch is None or env_file.exists():
return None
return Violation(
str(wt.path),
"env-missing",
f"RUL-021: .env is missing while branch {wt.branch!r} is checked out "
f"— tests that read it from disk fail and look like product bugs, not "
f"a missing file. Remedy: python scripts/secrets_cli.py decrypt --dest "
f"{env_file} (needs the age identity) or python "
f"scripts/provision_worktree_env.py --worktree {wt.path}.",
)
def _relative_vendor_paths(pyproject: dict) -> list[str]:
"""Every ``[tool.uv.sources]`` entry pinning a relative path under
``vendor/`` — the class of pin that resolves against the WORKTREE, not
the repo, and silently falls back to an sdist build when unpopulated."""
sources = pyproject.get("tool", {}).get("uv", {}).get("sources", {})
if not isinstance(sources, dict):
return []
paths: list[str] = []
for spec in sources.values():
if not isinstance(spec, dict):
continue
path = spec.get("path")
if not isinstance(path, str):
continue
parts = PurePosixPath(path).parts
if PurePosixPath(path).is_absolute() or "vendor" not in parts:
continue
paths.append(path)
return paths
def _hardlink_missing(
wt: WorktreeInfo, primary: WorktreeInfo, missing: list[str]
) -> list[str]:
"""``--fix-wheels``: hardlink each missing vendored path from the
primary worktree. Falls back to a copy if hardlinking crosses a
filesystem boundary. Returns the subset actually fixed."""
fixed: list[str] = []
for rel in missing:
src = primary.path / rel
if not src.exists():
continue
dst = wt.path / rel
# `exist_ok=True` only tolerates an existing DIRECTORY. A vendor path
# left behind as a symlink -- including a broken one -- makes mkdir
# raise FileExistsError and crash the whole gate. Observed live on
# 2026-07-27: wt-B1/wt-B2 carried `vendor/wheels` symlinks that were
# one ".." short and resolved nowhere. Replace a non-directory here;
# never let a repairable filesystem state abort the run.
parent = dst.parent
if parent.is_symlink() or (parent.exists() and not parent.is_dir()):
try:
parent.unlink()
except OSError:
continue
try:
parent.mkdir(parents=True, exist_ok=True)
except OSError:
continue
try:
os.link(src, dst)
except OSError:
try:
shutil.copy2(src, dst)
except OSError:
continue
fixed.append(rel)
return fixed
def evaluate_vendor_wheels(
wt: WorktreeInfo, primary: WorktreeInfo, fix: bool
) -> tuple[Violation | None, list[str]]:
"""Check 3: read THIS worktree's own ``pyproject.toml`` (branches
differ) for relative ``vendor/`` uv-source pins that don't resolve."""
pyproject_path = wt.path / "pyproject.toml"
if not pyproject_path.exists():
return None, []
try:
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError) as exc:
return (
Violation(str(wt.path), "vendor-wheels-unreadable", str(exc)),
[],
)
missing = [r for r in _relative_vendor_paths(data) if not (wt.path / r).exists()]
fixed: list[str] = []
if fix and missing:
fixed = _hardlink_missing(wt, primary, missing)
missing = [r for r in missing if r not in fixed]
if not missing:
return None, fixed
return (
Violation(
str(wt.path),
"vendor-wheels",
"RUL-021: [tool.uv.sources] pins relative vendor path(s) that don't "
f"resolve in this worktree: {', '.join(missing)} — run with "
"--fix-wheels to hardlink from the primary worktree, or populate "
"manually before `uv sync`.",
),
fixed,
)
def check_merged_branch(
wt: WorktreeInfo, primary: WorktreeInfo
) -> tuple[Violation | None, str | None]:
"""Check 4: a branch whose work is already in master is stale. Detached
HEAD is informational, never a violation.
A branch sitting exactly AT master is NOT stale — that is the signature of
a branch just created for new work, and reporting it as "due for deletion"
is wrong. Observed live on 2026-07-27: this check told the operator to
delete wt-FT, wt-GUARD and wt-ISO while builders were actively working in
all three, because each branch had been created from master and had not
committed yet. Staleness requires that the branch HAVE commits and that
master already contain them."""
if wt.detached or wt.branch is None:
return (
None,
f"{wt.path}: detached HEAD — informational, not a RUL-021 violation",
)
try:
result = subprocess.run(
["git", "merge-base", "--is-ancestor", wt.branch, "master"],
cwd=primary.path,
capture_output=True,
timeout=15,
)
except OSError, subprocess.TimeoutExpired:
return None, None
if result.returncode != 0:
return None, None
# Contained in master — but bare ancestry is NOT staleness. A FRESH
# branch (level with master, or merely left behind after master moved)
# is also an ancestor. The 2026-07-27 incident flagged three
# actively-worked worktrees for deletion this way, and the earlier
# level-with-master guard still broke the moment master advanced.
# Staleness = a merge commit reachable from master carries this tip as
# a parent (our --no-ff merge convention's signature).
try:
tip_res = subprocess.run(
["git", "rev-parse", wt.branch],
cwd=primary.path,
capture_output=True,
text=True,
timeout=15,
)
merges = subprocess.run(
["git", "rev-list", "--merges", "--parents", "master"],
cwd=primary.path,
capture_output=True,
text=True,
timeout=60,
)
except OSError, subprocess.TimeoutExpired:
return None, None
if tip_res.returncode != 0 or merges.returncode != 0:
return None, None
tip = tip_res.stdout.strip()
referenced = any(
tip in line.split()[1:]
for line in merges.stdout.splitlines()
if len(line.split()) >= 3
)
if not referenced:
return (
None,
f"{wt.path}: branch {wt.branch!r} tip is contained in master but "
f"no merge commit references it (fresh branch, or ff/squash "
f"merge) — not called stale",
)
return (
Violation(
str(wt.path),
"merged-branch",
f"RUL-021: branch {wt.branch!r} is already merged into master — "
f"stale worktree and environment, due for deletion "
f"(git worktree remove {wt.path} && git branch -d {wt.branch}).",
),
None,
)
# Every check this gate knows, in evaluation order. `--checks` selects a
# subset. The split exists because the checks differ in DETERMINISM, not
# importance: venv-symlink is the poison class (a shared editable install
# re-pointed by bare `uv run` — ship bonr4xuj5) and is never legitimately
# true, while venv-missing / env-missing are NORMAL transient states for a
# worktree mid-provision (an agent worktree between `git worktree add` and
# its `uv sync`), so a battery run that raced one would flake. The battery
# wires `--checks venv-symlink`; the full set stays the standalone default.
ALL_CHECKS: tuple[str, ...] = (
"venv-symlink",
"venv-missing",
"env-missing",
"vendor-wheels",
"merged-branch",
)
def _evaluate_worktree(
wt: WorktreeInfo,
primary: WorktreeInfo,
fix_wheels: bool,
checks: frozenset[str],
) -> tuple[list[Violation], str | None, list[str]]:
violations: list[Violation] = []
info: str | None = None
fixed: list[str] = []
venv_violation = check_venv_symlink(wt) if "venv-symlink" in checks else None
if venv_violation is not None:
violations.append(venv_violation)
elif "venv-missing" in checks:
missing_violation = check_venv_missing(wt)
if missing_violation is not None:
violations.append(missing_violation)
if "env-missing" in checks:
env_violation = check_env_missing(wt)
if env_violation is not None:
violations.append(env_violation)
if "vendor-wheels" in checks:
vendor_violation, fixed = evaluate_vendor_wheels(wt, primary, fix_wheels)
if vendor_violation is not None:
violations.append(vendor_violation)
if "merged-branch" in checks:
merged_violation, info = check_merged_branch(wt, primary)
if merged_violation is not None:
violations.append(merged_violation)
return violations, info, fixed
def _evaluate(
worktrees: list[WorktreeInfo], fix_wheels: bool, checks: frozenset[str]
) -> tuple[int, dict]:
primary, others = worktrees[0], worktrees[1:]
all_violations: list[Violation] = []
informational: list[str] = []
fixed_wheels: dict[str, list[str]] = {}
for wt in others:
violations, info, fixed = _evaluate_worktree(wt, primary, fix_wheels, checks)
all_violations.extend(violations)
if info is not None:
informational.append(info)
if fixed:
fixed_wheels[str(wt.path)] = fixed
report = {
"status": "pass" if not all_violations else "fail",
"primary_worktree": str(primary.path),
"checked": len(others),
"violations": [asdict(v) for v in all_violations],
"informational": informational,
"fixed_wheels": fixed_wheels,
}
return (0 if not all_violations else 1), report
def run_check(
cwd: Path,
fix_wheels: bool = False,
checks: frozenset[str] | None = None,
) -> tuple[int, dict]:
"""Injection seam: tests pass a throwaway tmp_path repo root here — never
chdir the test process, never touch the live repo. ``checks`` narrows to a
subset of :data:`ALL_CHECKS` (``None`` = all of them)."""
selected = frozenset(ALL_CHECKS) if checks is None else checks
unknown = selected - frozenset(ALL_CHECKS)
if unknown:
raise ValueError(f"unknown check(s): {sorted(unknown)}")
try:
worktrees = list_worktrees(cwd)
except WorktreeDiscoveryError as exc:
return 0, {
"status": "skipped",
"reason": f"not a git repository at {cwd}: {exc}",
}
except FileNotFoundError as exc:
return 0, {
"status": "skipped",
"reason": f"could not run git (missing executable or cwd {cwd}): {exc}",
}
if not worktrees:
return 0, {"status": "skipped", "reason": f"no worktrees reported for {cwd}"}
return _evaluate(worktrees, fix_wheels, selected)
def _print_text_report(report: dict) -> None:
if report["status"] == "skipped":
print(f"[worktree-envs] SKIP — {report['reason']}", flush=True)
return
print(f"[worktree-envs] primary={report['primary_worktree']}", flush=True)
print(f"[worktree-envs] checked {report['checked']} worktree(s)", flush=True)
for note in report["informational"]:
print(f"[worktree-envs] info: {note}", flush=True)
for wt, fixed in report["fixed_wheels"].items():
print(
f"[worktree-envs] fixed (hardlink) in {wt}: {', '.join(fixed)}", flush=True
)
if report["status"] == "pass":
print("[worktree-envs] OK — RUL-021 satisfied", flush=True)
return
print(
f"[worktree-envs] FAIL — {len(report['violations'])} violation(s):", flush=True
)
for v in report["violations"]:
print(f" {v['worktree']} [{v['check']}]: {v['message']}", flush=True)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repo-root",
default=None,
help="Directory inside the repo to gate (default: cwd)",
)
parser.add_argument(
"--fix-wheels",
action="store_true",
help="Hardlink missing vendored wheels from the primary worktree",
)
parser.add_argument("--json", action="store_true", help="Machine-readable output")
parser.add_argument(
"--checks",
default=None,
help=(
"Comma-separated subset of checks to run "
f"(default: all of {','.join(ALL_CHECKS)}). The gate battery "
"passes 'venv-symlink' — the deterministic poison class — so a "
"worktree racing its own provision (venv/env not yet created) "
"never flakes a battery run."
),
)
args = parser.parse_args(argv)
checks: frozenset[str] | None = None
if args.checks is not None:
checks = frozenset(c.strip() for c in args.checks.split(",") if c.strip())
unknown = checks - frozenset(ALL_CHECKS)
if unknown:
parser.error(
f"unknown check(s) {sorted(unknown)}; valid: {', '.join(ALL_CHECKS)}"
)
repo_root = Path(args.repo_root).resolve() if args.repo_root else Path.cwd()
exit_code, report = run_check(repo_root, fix_wheels=args.fix_wheels, checks=checks)
if args.json:
print(json.dumps(report, indent=2, default=str))
else:
_print_text_report(report)
return exit_code
if __name__ == "__main__":
sys.exit(main())