Files
Agent-System-Dev/scripts/check-worktree.py
T
weichen b450e51c40 Add safe component editing and explicit legacy workspace transition
Why: Preserve a single Dev entry and independent repository ownership while allowing active feature branches to migrate without losing source or Git state.

Evidence: SELF 29 real Git/Go workspace tests and ordinary component evidence/lock tests passed; detailed RED/GREEN runs are recorded by make evidence in the task card.

Impact: Add fixed cloning, selected-version baselines, development overlays, npm source resolution, and independently preserved legacy snapshots. No other active worktree is modified.

Refs: Versioned dependencies U2/U5; R15; feature/dependency-workspace
2026-10-13 15:58:23 +00:00

207 lines
10 KiB
Python

#!/usr/bin/env python3
"""Fail closed before task synchronization, verification, or worktree disposal."""
import os
import json
from pathlib import Path
import subprocess
import sys
def git(repo, *args):
result = subprocess.run(["git", "-C", str(repo), *args], stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
if result.returncode:
raise RuntimeError(f"Git check failed in {repo}: {' '.join(args)}")
return result.stdout
def text(repo, *args):
return os.fsdecode(git(repo, *args)).strip()
def check_operations(repo):
for marker in ("index.lock", "HEAD.lock", "MERGE_HEAD", "CHERRY_PICK_HEAD",
"REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG"):
path = Path(text(repo, "rev-parse", "--path-format=absolute", "--git-path", marker))
if path.exists():
raise RuntimeError(f"active Git operation/lock in {repo}: {marker}; owner must finish it")
def check_clean(repo, ignored=False, allow_gitlink_drift=False):
args = ["status", "--porcelain=v1", "--untracked-files=all",
"--ignore-submodules=all" if allow_gitlink_drift else "--ignore-submodules=none"]
if ignored:
args.append("--ignored=matching")
status = git(repo, *args)
if status:
raise RuntimeError(f"unsaved, untracked{', or ignored' if ignored else ''} files in {repo}; preserve them before continuing")
def submodules(repo):
for item in git(repo, "ls-files", "--stage", "-z").split(b"\0"):
if not item:
continue
meta, path = item.split(b"\t", 1)
mode, sha, stage = meta.split()
if stage != b"0":
raise RuntimeError(f"unmerged index in {repo}; resolve conflicts first")
if mode == b"160000":
yield repo / os.fsdecode(path), sha.decode("ascii")
def worktrees(repo):
records = git(repo, "worktree", "list", "--porcelain", "-z").split(b"\0\0")
parsed = [record.split(b"\0") for record in records if record]
if not parsed or any(not record[0].startswith(b"worktree ") for record in parsed):
raise RuntimeError(f"cannot identify registered worktrees in {repo}")
return parsed
def check_component_worktrees(repo, removal_roots):
common = Path(text(repo, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
if not any(common == root or root in common.parents for root in removal_roots):
return
current = Path(text(repo, "rev-parse", "--absolute-git-dir")).resolve()
for index, record in enumerate(worktrees(repo)):
other = Path(os.fsdecode(record[0][9:])).resolve()
# Git may report an absorbed submodule's primary checkout as its common
# directory. It is the current checkout only when no linked gitdir is used.
if other != repo.resolve() and not (index == 0 and current == common):
raise RuntimeError(f"registered component worktree {other} depends on metadata in {common}; preserve or relocate it before removal")
def check_children(repo, mode, removal_roots=()):
for child, expected in submodules(repo):
if child.is_symlink():
raise RuntimeError(f"submodule path is a symbolic link: {child}")
if not (child / ".git").exists():
if mode in ("verify", "after-sync", "finish"):
raise RuntimeError(f"uninitialized submodule: {child}; initialize and inspect it first")
continue
if mode == "finish":
check_component_worktrees(child, removal_roots)
actual = text(child, "rev-parse", "HEAD")
check_operations(child)
check_clean(child, ignored=(mode == "finish"), allow_gitlink_drift=(mode == "before-update"))
branch = text(child, "branch", "--show-current")
if mode == "before-update" and branch and actual != expected:
raise RuntimeError(f"submodule {child} owns branch {branch}; keep that work and explicitly reconcile pin {expected} before retrying task-sync")
if mode in ("verify", "after-sync", "finish") and actual != expected:
raise RuntimeError(f"submodule SHA drift: {child}: expected {expected}, actual {actual}")
if mode == "finish" and local_work_outside(child, expected):
raise RuntimeError(f"submodule has local work outside its pinned history: {child}; preserve branches, stash and reflogs before removal")
check_children(child, mode, removal_roots)
def materialize(repo):
"""Check out one level, then inspect the newly exposed nested pins before moving them."""
for child, expected in submodules(repo):
check_children(repo, "before-update")
result = subprocess.run(["git", "-C", str(repo), "submodule", "update", "--init",
"--checkout", "--", str(child.relative_to(repo))])
if result.returncode:
raise RuntimeError(f"component materialization failed: {child}; preserve and reconcile before retrying")
materialize(child)
def ordinary_components(repo, mode, removal_roots=()):
manifest = repo / 'components.json'
if not manifest.exists():
return
document = json.loads(manifest.read_text())
if document.get('schema') != 1 or not isinstance(document.get('components'), list):
raise RuntimeError('invalid component manifest')
for item in document['components']:
name = item.get('id')
relative = item.get('path')
if not isinstance(name, str) or relative != '.workspace/' + name:
raise RuntimeError('invalid component identity/path')
child = repo / relative
if child.resolve() != child.absolute() or repo not in child.resolve().parents:
raise RuntimeError('component path escapes its owner')
if not child.exists():
continue
if not (child / '.git').exists() or Path(text(child, 'rev-parse', '--show-toplevel')).resolve() != child:
raise RuntimeError('component has borrowed or missing Git identity')
check_operations(child)
if mode == 'finish':
check_clean(child, ignored=True)
check_component_worktrees(child, removal_roots)
head = text(child, 'rev-parse', 'HEAD')
if local_work_outside(child, head):
raise RuntimeError('component has unpreserved local refs; preserve its source before removal')
def local_work_outside(child, expected):
"""2026-10-05 修复:pinned-history 检查此前用 --all --reflog,把 refs/remotes 也
计入——pin 在 feature 顶端的子仓因 origin/main 不在 pin 历史内而永远无法 finish
(实测被迫删远端引用+清 reflog 绕过,损害现场)。只认「本地独有」工作:
不在任何远端跟踪引用历史内的提交才算。克隆默认分支停在 origin/main 顶端、
reflog 里的检出轨迹等「远端有副本」的状态一律不算;stash 恒为本地独有。"""
candidates = git(child, "rev-list", "--branches", "--not", expected).split()
for line in git(child, "reflog", "show", "HEAD", "--format=%H").splitlines():
tip = line.strip()
if tip:
candidates.append(tip)
for tip in dict.fromkeys(candidates): # 去重保序
# 远端任一引用可达 ⇒ 已保全,不算本地独有
if git(child, "rev-list", "-1", tip, "--not", "--remotes").strip():
return True
if git(child, "stash", "list").strip():
return True
return False
def check_finish_root(repo):
if Path.cwd().resolve() != repo:
raise RuntimeError("task-finish must run at the registered worktree root, not a subdirectory")
parsed = worktrees(repo)
main = Path(os.fsdecode(parsed[0][0][9:])).resolve()
if main == repo:
raise RuntimeError("task-finish must run in a linked task worktree, never the main checkout")
matches = [r for r in parsed if r[0].startswith(b"worktree ")
and Path(os.fsdecode(r[0][9:])).resolve() == repo]
if len(matches) != 1:
raise RuntimeError("worktree is not registered exactly once; preserve it for owner inspection")
if any(field.startswith((b"locked", b"prunable", b"bare", b"detached")) for field in matches[0][1:]):
raise RuntimeError("locked, detached, bare, or prunable worktree must be preserved")
common = Path(text(repo, "rev-parse", "--path-format=absolute", "--git-common-dir"))
# 2026-10-05 修复:rglob("*.lock") 把 .git/cursor/ 等 IDE 产物当 Git 锁
#(实测五天陈锁阻断 worktree 回收)。收窄到 Git 真实锁语义:公共区锁 +
# 本 worktree git-path 锁/活动操作标记(与 check_operations 同源清单)。
shared_locks = [common / "config.lock", common / "shallow.lock"]
for marker in ("index.lock", "HEAD.lock", "MERGE_HEAD", "CHERRY_PICK_HEAD",
"REVERT_HEAD", "BISECT_LOG", "gc.pid"):
shared_locks.append(Path(text(repo, "rev-parse", "--path-format=absolute", "--git-path", marker)))
for lock in shared_locks:
if lock.exists():
raise RuntimeError(f"Git lock exists in shared repository metadata ({lock.name}); wait for its owner")
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "verify"
if mode not in ("verify", "before-sync", "before-update", "materialize", "after-sync", "finish"):
raise RuntimeError("usage: check-worktree.py [verify|before-sync|before-update|materialize|after-sync|finish]")
repo = Path(text(Path.cwd(), "rev-parse", "--show-toplevel")).resolve()
check_operations(repo)
removal_roots = ()
if mode == "finish":
check_finish_root(repo)
removal_roots = (repo, Path(text(repo, "rev-parse", "--absolute-git-dir")).resolve())
if mode in ("before-sync", "after-sync", "finish"):
check_clean(repo, ignored=(mode == "finish"))
if mode == "materialize":
materialize(repo)
else:
check_children(repo, mode, removal_roots)
ordinary_components(repo, mode, removal_roots)
print(f"[worktree] {mode}: recursive component checks passed")
if __name__ == "__main__":
try:
main()
except RuntimeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)