Fix two finish-guard false positives: IDE locks and remote-only refs
ci / test (pull_request) Successful in 2m21s
ci / test (pull_request) Successful in 2m21s
Why: 2026-10-05 两次实测踩到——①rglob("*.lock") 把 .git/cursor/ IDE 产物当 Git 锁
(五天陈锁阻断 worktree 回收);②pinned-history 用 --all --reflog 把 refs/remotes
计入,pin 在 feature 顶端的子仓永远无法 finish(被迫删远端引用+清 reflog 损害现场)。
Evidence: FinishGuardFixTests 四例全绿(误报修复+真实锁仍拦+独有提交仍保);
全套治理回归 91 例 OK;make test 全链证据入卡。
Impact: 仅 finish 模式两处判定;IDE 产物与远端引用不再误伤,本地独有工作保全语义不变。
Refs: 本仓 governance 收尾实录(EXT-1 与 governance-reliability 两轮)。
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -87,8 +87,8 @@ def check_children(repo, mode, removal_roots=()):
|
||||
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 git(child, "rev-list", "--all", "--reflog", "--not", expected).strip():
|
||||
raise RuntimeError(f"submodule has commits outside its pinned history: {child}; preserve branches, stash and reflogs before removal")
|
||||
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)
|
||||
|
||||
|
||||
@@ -103,6 +103,23 @@ def materialize(repo):
|
||||
materialize(child)
|
||||
|
||||
|
||||
def local_work_outside(child, expected):
|
||||
"""2026-10-05 修复:pinned-history 检查此前用 --all --reflog,把 refs/remotes 也
|
||||
计入——pin 在 feature 顶端的子仓因 origin/main 不在 pin 历史内而永远无法 finish
|
||||
(实测被迫删远端引用+清 reflog 绕过,损害现场)。只认「本地独有」工作:
|
||||
本地分支、stash、以及 HEAD 的 reflog(最近检出轨迹里的独有提交);
|
||||
refs/remotes 一律不算——远端有副本即已保全。"""
|
||||
if git(child, "rev-list", "--branches", "--not", expected).strip():
|
||||
return True
|
||||
if git(child, "stash", "list").strip():
|
||||
return True
|
||||
for line in git(child, "reflog", "show", "HEAD", "--format=%H").splitlines():
|
||||
tip = line.strip()
|
||||
if tip and git(child, "rev-list", "-1", tip, "--not", expected).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")
|
||||
@@ -117,8 +134,16 @@ def check_finish_root(repo):
|
||||
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"))
|
||||
if any(common.rglob("*.lock")):
|
||||
raise RuntimeError("Git lock exists in shared repository metadata; wait for its owner")
|
||||
# 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():
|
||||
|
||||
@@ -633,3 +633,153 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class FinishGuardFixTests(unittest.TestCase):
|
||||
"""2026-10-05 两处 finish 守卫误报的回归:IDE 产物锁不再误报、真实 Git 锁仍拦;
|
||||
子仓远端跟踪引用不再卡 pinned-history,本地独有工作仍保全。"""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="finish-guard-")
|
||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||
self.repo = Path(self.root) / "repo"
|
||||
shutil.copytree(SOURCE / "scripts", self.repo / "scripts")
|
||||
(self.repo / "Makefile").write_text("precheck:\n\t@bash scripts/task.sh precheck\n")
|
||||
self.env = os.environ.copy()
|
||||
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_AUTHOR_NAME": "Guard Test", "GIT_AUTHOR_EMAIL": "guard@example.invalid",
|
||||
"GIT_COMMITTER_NAME": "Guard Test", "GIT_COMMITTER_EMAIL": "guard@example.invalid",
|
||||
"TASK_WRITERS_STOPPED": "1"})
|
||||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT"):
|
||||
self.env.pop(key, None)
|
||||
self.git(self.root, "init", "--bare", "-b", "main", self.root + "/origin.git")
|
||||
self.git(self.root, "init", "-b", "main", str(self.repo))
|
||||
(self.repo / "tracked").write_text("base\n")
|
||||
self.git(self.repo, "add", ".")
|
||||
self.git(self.repo, "commit", "-q", "-m", "base")
|
||||
self.git(self.repo, "remote", "add", "origin", self.root + "/origin.git")
|
||||
self.git(self.repo, "push", "-q", "origin", "main")
|
||||
|
||||
def git(self, cwd, *args):
|
||||
return subprocess.run(["git", "-C", str(cwd), *args], check=True,
|
||||
capture_output=True, text=True, env=self.env)
|
||||
|
||||
def task(self, cwd, *args, check=True, env=None):
|
||||
return subprocess.run(["bash", str(self.repo / "scripts" / "task.sh"), *args],
|
||||
cwd=str(cwd), capture_output=True, text=True,
|
||||
env=env or self.env, check=check)
|
||||
|
||||
def worktree(self):
|
||||
receipt = Path(self.root) / "precheck.json"
|
||||
subprocess.run(
|
||||
["bash", str(self.repo / "scripts" / "evidence.sh")], cwd=str(self.repo),
|
||||
capture_output=True, text=True,
|
||||
env=self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"}, check=True)
|
||||
self.task(self.repo, "start", "feature/guard",
|
||||
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
|
||||
wt = self.root + "/repo-wt-feature-guard"
|
||||
assert Path(wt).is_dir(), "worktree not created"
|
||||
return Path(wt)
|
||||
|
||||
def add_submodule_pinned_off_main(self):
|
||||
"""pin 在 feature 顶端(origin/main 不在 pin 历史内)——修复前的永久卡死场景。"""
|
||||
child = Path(self.root) / "component"
|
||||
self.git(self.root, "init", "-b", "main", str(child))
|
||||
(child / "content").write_text("v1\n")
|
||||
self.git(child, "add", ".")
|
||||
self.git(child, "-c", "user.name=T", "-c", "user.email=t@t", "commit", "-q", "-m", "v1")
|
||||
self.git(self.root, "init", "--bare", "-b", "main", self.root + "/component-origin.git")
|
||||
self.git(child, "remote", "add", "origin", self.root + "/component-origin.git")
|
||||
self.git(child, "push", "-q", "origin", "main")
|
||||
self.git(child, "switch", "-c", "feature/pin-tip")
|
||||
(child / "content").write_text("v2 pin tip\n")
|
||||
self.git(child, "commit", "-am", "v2")
|
||||
pin_tip = subprocess.run(["git", "-C", str(child), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, check=True, env=self.env).stdout.strip()
|
||||
# 把 pin-tip 推上远端,子仓 add 时按该分支检出
|
||||
self.git(child, "push", "-q", "origin", "feature/pin-tip")
|
||||
self.git(self.repo, "-c", "protocol.file.allow=always",
|
||||
"submodule", "add", "-b", "feature/pin-tip",
|
||||
self.root + "/component-origin.git", "component")
|
||||
self.git(self.repo, "commit", "-q", "-am", "Pin component at feature tip")
|
||||
self.git(self.repo, "push", "-q", "origin", "main")
|
||||
# 让子仓本地带上 origin/main 的远端跟踪引用(不在 pin 历史内)
|
||||
self.git(child, "fetch", "origin")
|
||||
return child, pin_tip
|
||||
|
||||
def test_ide_lock_in_git_dir_does_not_block_finish(self):
|
||||
wt = self.worktree()
|
||||
(self.repo / "tracked").write_text("implementation done\n")
|
||||
self.git(self.repo, "add", "-A")
|
||||
self.git(self.repo, "commit", "-q", "-m", "done")
|
||||
self.git(self.repo, "push", "-q", "origin", "feature/guard")
|
||||
self.git(self.repo, "merge", "--no-ff", "-q", "-m", "Merge feature/guard", "feature/guard")
|
||||
self.git(self.repo, "push", "-q", "origin", "main")
|
||||
ide = Path(subprocess.run(["git", "-C", str(self.repo), "rev-parse",
|
||||
"--path-format=absolute", "--git-common-dir"],
|
||||
capture_output=True, text=True, check=True).stdout.strip()) / "cursor" / "crepe"
|
||||
ide.mkdir(parents=True, exist_ok=True)
|
||||
stale = ide / "build.lock"
|
||||
stale.write_text("stale IDE artifact")
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertFalse(wt.exists())
|
||||
self.assertTrue(stale.is_file(), "IDE artifact must be preserved untouched")
|
||||
|
||||
def test_real_git_lock_still_blocks_finish(self):
|
||||
wt = self.worktree()
|
||||
(self.repo / "tracked").write_text("implementation done\n")
|
||||
self.git(self.repo, "add", "-A")
|
||||
self.git(self.repo, "commit", "-q", "-m", "done")
|
||||
self.git(self.repo, "push", "-q", "origin", "feature/guard")
|
||||
self.git(self.repo, "merge", "--no-ff", "-q", "-m", "Merge feature/guard", "feature/guard")
|
||||
self.git(self.repo, "push", "-q", "origin", "main")
|
||||
common = Path(subprocess.run(["git", "-C", str(self.repo), "rev-parse",
|
||||
"--path-format=absolute", "--git-common-dir"],
|
||||
capture_output=True, text=True, check=True).stdout.strip())
|
||||
(common / "config.lock").write_text("live writer")
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(wt.exists())
|
||||
|
||||
def test_remote_tracking_refs_do_not_block_finish(self):
|
||||
self.add_submodule_pinned_off_main()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
(self.repo / "tracked").write_text("implementation done\n")
|
||||
self.git(self.repo, "add", "-A")
|
||||
self.git(self.repo, "commit", "-q", "-m", "done")
|
||||
self.git(self.repo, "push", "-q", "origin", "feature/guard")
|
||||
self.git(self.repo, "merge", "--no-ff", "-q", "-m", "Merge feature/guard", "feature/guard")
|
||||
self.git(self.repo, "push", "-q", "origin", "main")
|
||||
# 模拟实际场景:子仓检出于 pin 顶端且带有远端跟踪引用与克隆 reflog
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertFalse(wt.exists())
|
||||
|
||||
def test_local_unique_commit_still_preserved(self):
|
||||
self.add_submodule_pinned_off_main()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
child = wt / "component"
|
||||
pinned = subprocess.run(["git", "-C", str(child), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
self.git(child, "switch", "-c", "feature/unique")
|
||||
(child / "content").write_text("unique local work\n")
|
||||
self.git(child, "commit", "-am", "unique")
|
||||
unique = subprocess.run(["git", "-C", str(child), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
self.git(child, "checkout", "--detach", pinned)
|
||||
(self.repo / "tracked").write_text("done\n")
|
||||
self.git(self.repo, "add", "-A")
|
||||
self.git(self.repo, "commit", "-q", "-m", "done")
|
||||
self.git(self.repo, "push", "-q", "origin", "feature/guard")
|
||||
self.git(self.repo, "merge", "--no-ff", "-q", "-m", "Merge feature/guard", "feature/guard")
|
||||
self.git(self.repo, "push", "-q", "origin", "main")
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(wt.exists())
|
||||
kept = subprocess.run(["git", "-C", str(child), "rev-parse", "feature/unique"],
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
self.assertEqual(kept, unique)
|
||||
|
||||
Reference in New Issue
Block a user