Why: Replace repeated submodule source pointers with consumer-owned language versions and fixed application sources, keeping independent repository edits and existing branches intact. Evidence: SELF task receipts contain startup/sync evidence, real Go and npm consumption, Native overlay/unlink, safe transition/ref preservation, credential boundary and historical build regressions. Exact committed full composition and RDM checks follow this checkpoint; formal acceptance and GATE CI remain pending. Impact: Add isolated build/test composition, source provenance and safe owner refresh, adapt governance and service build callers, remove replaced index links while retaining original Git metadata, and adopt reusable dependency standards. Refs: Documents approved dependency-workspace r1/R1-R15 and U1-U7 implementation plan; feature/dependency-workspace task card.
341 lines
16 KiB
Python
341 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail closed before task synchronization, verification, or worktree disposal."""
|
|
import os
|
|
import hashlib
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
from workspace.common import WorkspaceError
|
|
|
|
|
|
def git(repo, *args):
|
|
command = ["git", "-C", str(repo)]
|
|
# Retired absorbed metadata may outlive its configured work tree. Inspect
|
|
# its refs/registrations without chdir to that absent checkout.
|
|
if (repo / "HEAD").is_file() and (repo / "config").is_file() and not (repo / ".git").exists():
|
|
command += ["--git-dir=" + str(repo), "--work-tree=" + str(repo)]
|
|
environment = os.environ.copy()
|
|
environment["GIT_OPTIONAL_LOCKS"] = "0"
|
|
for variable in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"):
|
|
environment.pop(variable, None)
|
|
result = subprocess.run([*command, *args], stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, env=environment)
|
|
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 component_manifest(repo):
|
|
from workspace.manifest import Manifest
|
|
return Manifest(repo) if (repo / "components.json").exists() else None
|
|
|
|
|
|
def prepare_rules(repo):
|
|
"""Resolve startup reading before task writes, using an external fixed cache."""
|
|
from workspace.cli import process_environment
|
|
from workspace.git import Git
|
|
manifest = component_manifest(repo)
|
|
if manifest is None:
|
|
return
|
|
rules = [entry for entry in manifest.select() if entry["role"] == "guidelines"]
|
|
if len(rules) != 1 or rules[0]["id"] not in manifest.lock:
|
|
raise RuntimeError("exactly one fixed guidelines source is required before READY")
|
|
component = rules[0]
|
|
commit = manifest.lock[component["id"]]["commit"]
|
|
cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "agent-system" / "rules"
|
|
if not cache.is_absolute() or cache.resolve().is_relative_to(repo):
|
|
raise RuntimeError("rules cache must be an absolute external directory")
|
|
for parent in (cache, *cache.parents):
|
|
if parent.is_symlink():
|
|
raise RuntimeError("rules cache must not traverse symbolic links")
|
|
cache.mkdir(parents=True, exist_ok=True)
|
|
identity = hashlib.sha256((component["repo"] + "\n" + commit).encode()).hexdigest()
|
|
source = cache / identity
|
|
lock = cache / (identity + ".lock")
|
|
try:
|
|
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
except FileExistsError as error:
|
|
raise RuntimeError("fixed rules cache has an active owner; retry without removing its lock") from error
|
|
os.close(descriptor)
|
|
try:
|
|
helper = Git(process_environment(repo))
|
|
if source.exists():
|
|
actual = helper.safe(source, component["repo"])
|
|
if actual["head"] != commit or actual["branch"]:
|
|
raise RuntimeError("fixed rules cache differs from its declared source; preserve it for its owner")
|
|
else:
|
|
helper.call(cache, "clone", "--no-checkout", "--", component["repo"], str(source))
|
|
helper.call(source, "fetch", "--no-tags", "origin", commit)
|
|
helper.call(source, "checkout", "--detach", commit)
|
|
helper.safe(source, component["repo"])
|
|
for required in ("core/baseline.md", "workflows/delivery.md"):
|
|
path = source / required
|
|
if not path.is_file() or path.is_symlink() or not path.resolve().is_relative_to(source):
|
|
raise RuntimeError(f"fixed rules source is missing required reading: {required}")
|
|
print(f"[rules] path={source} commit={commit}; required-reading root verified")
|
|
finally:
|
|
lock.unlink()
|
|
|
|
|
|
def prepare_sources(repo):
|
|
result = subprocess.run([sys.executable, str(repo / "scripts/workspace.py"), "--root", str(repo), "prepare"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
if result.returncode:
|
|
raise RuntimeError(result.stderr.strip() or "fixed source preparation failed; owner must reconcile")
|
|
print("[workspace] fixed application, documentation and guidelines sources prepared; edit branches preserved")
|
|
|
|
|
|
def inspect_source(child, mode, removal_roots):
|
|
if child.is_symlink() or (child / ".git").is_symlink():
|
|
raise RuntimeError(f"component Git identity is a symbolic link: {child}")
|
|
if not (child / ".git").exists() or Path(text(child, "rev-parse", "--show-toplevel")).resolve() != child:
|
|
raise RuntimeError(f"component has borrowed or missing Git identity: {child}")
|
|
check_operations(child)
|
|
if mode == "finish":
|
|
check_component_worktrees(child, removal_roots)
|
|
check_clean(child, ignored=True)
|
|
if local_work_outside(child, text(child, "rev-parse", "HEAD")):
|
|
raise RuntimeError(f"component has unpreserved local refs: {child}; preserve its source before removal")
|
|
|
|
|
|
def inspect_source_tree(root, mode, removal_roots, seen):
|
|
for current, directories, files in os.walk(root, followlinks=False):
|
|
child = Path(current)
|
|
directories[:] = [name for name in directories if name != ".git"]
|
|
if ".git" in files or (child / ".git").exists():
|
|
if child not in seen:
|
|
seen.add(child)
|
|
inspect_source(child, mode, removal_roots)
|
|
|
|
|
|
def legacy_components(repo, manifest, mode, removal_roots):
|
|
"""Keep retired physical roots and nested repositories visible after gitlink removal."""
|
|
from workspace.common import safe_path
|
|
seen = set()
|
|
for component in manifest.select():
|
|
relative = component.get("legacy_path")
|
|
if not relative:
|
|
continue
|
|
root = safe_path(repo, relative)
|
|
if not root.exists():
|
|
continue
|
|
if mode == "materialize":
|
|
print(f"[transition] retained legacy source {relative}; inspect make workspace-transition NAME={component['id']} before explicit ARGS=--apply")
|
|
inspect_source_tree(root, mode, removal_roots, seen)
|
|
|
|
|
|
def check_retained_metadata(repo, removal_roots):
|
|
"""An absent legacy checkout can still own external worktrees via absorbed metadata."""
|
|
modules = Path(text(repo, "rev-parse", "--absolute-git-dir")) / "modules"
|
|
if not modules.exists():
|
|
return
|
|
for current, directories, files in os.walk(modules, followlinks=False):
|
|
directories[:] = [name for name in directories if name not in
|
|
{"objects", "refs", "logs", "hooks", "worktrees", "lfs", "info"}]
|
|
metadata = Path(current)
|
|
if "HEAD" not in files or "config" not in files:
|
|
continue
|
|
for record in worktrees(metadata):
|
|
other = Path(os.fsdecode(record[0][9:])).resolve()
|
|
if not any(other == root or root in other.parents for root in removal_roots):
|
|
raise RuntimeError(f"registered component worktree {other} depends on retained metadata in {metadata}; preserve it before removal")
|
|
check_operations(metadata)
|
|
if local_work_outside(metadata, text(metadata, "rev-parse", "HEAD")):
|
|
raise RuntimeError(f"retained component metadata has unpreserved local refs: {metadata}")
|
|
|
|
|
|
def ordinary_components(repo, mode, removal_roots=()):
|
|
manifest = component_manifest(repo)
|
|
if manifest is None:
|
|
return
|
|
from workspace.common import safe_path
|
|
operation = safe_path(repo, ".workspace/.operation.lock")
|
|
if operation.exists():
|
|
raise RuntimeError("active workspace operation lock; preserve it for its owner")
|
|
seen = set()
|
|
for item in manifest.select():
|
|
child = safe_path(repo, item["path"])
|
|
if child.exists():
|
|
inspect_source(child, mode, removal_roots)
|
|
seen.add(child)
|
|
inspect_source_tree(child, mode, removal_roots, seen)
|
|
legacy_components(repo, manifest, mode, removal_roots)
|
|
|
|
|
|
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 row in git(child, "for-each-ref", "--format=%(objectname) %(refname)").splitlines():
|
|
tip, ref = row.split(b" ", 1)
|
|
if not ref.startswith(b"refs/remotes/"):
|
|
candidates.append(tip)
|
|
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 ("rules", "verify", "before-sync", "before-update", "materialize", "after-sync", "finish"):
|
|
raise RuntimeError("usage: check-worktree.py [rules|verify|before-sync|before-update|materialize|after-sync|finish]")
|
|
repo = Path(text(Path.cwd(), "rev-parse", "--show-toplevel")).resolve()
|
|
if mode == "rules":
|
|
prepare_rules(repo)
|
|
return
|
|
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"):
|
|
check_clean(repo)
|
|
manifest = component_manifest(repo)
|
|
if mode == "materialize":
|
|
if manifest is None:
|
|
materialize(repo)
|
|
else:
|
|
prepare_sources(repo)
|
|
elif manifest is None:
|
|
check_children(repo, mode, removal_roots)
|
|
ordinary_components(repo, mode, removal_roots)
|
|
if mode == "finish":
|
|
check_retained_metadata(repo, removal_roots)
|
|
check_clean(repo, ignored=True)
|
|
print(f"[worktree] {mode}: recursive component checks passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (RuntimeError, WorkspaceError, ValueError, OSError) as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|