Files
Agent-System-Dev/tests/test_task.py
T

1375 lines
76 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Lifecycle regressions against real disposable Git repositories and remotes."""
import os
import json
import hashlib
from pathlib import Path
import shutil
import shlex
import subprocess
import tempfile
import unittest
SOURCE = Path(__file__).resolve().parents[1]
class TaskLifecycleTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="task-lifecycle-")
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.repo = self.root / "repo"
self.remote = self.root / "remote.git"
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": "Lifecycle Test", "GIT_AUTHOR_EMAIL": "test@example.invalid",
"GIT_COMMITTER_NAME": "Lifecycle Test", "GIT_COMMITTER_EMAIL": "test@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT",
"TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH",
"TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE", "TASK_BRANCH_KIND", "TASK_INITIATIVE"):
self.env.pop(key, None)
self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote))
# receive-pack 默认在后台跑 gc --auto,与 TemporaryDirectory 清理竞态
# (CI 实测 OSError 39 remote.git not empty);夹具远端无留存价值,直接关。
self.git(self.remote, "config", "gc.auto", "0")
self.git(self.remote, "config", "receive.autogc", "false")
self.git(self.root, "init", "--initial-branch=main", str(self.repo))
shutil.copytree(SOURCE / "scripts", self.repo / "scripts")
(self.repo / "Makefile").write_text("precheck:\n\t@bash scripts/task.sh precheck\n")
(self.repo / "tracked").write_text("saved\n")
(self.repo / ".gitignore").write_text("cache/\n")
self.git(self.repo, "add", ".")
self.git(self.repo, "commit", "-m", "Create fixture")
self.git(self.repo, "remote", "add", "origin", str(self.remote))
self.git(self.repo, "push", "-u", "origin", "main")
def run_cmd(self, cwd, *args, check=True, env=None):
result = subprocess.run(args, cwd=cwd, env=env or self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=45)
if check:
self.assertEqual(result.returncode, 0, result.stdout)
return result
def git(self, cwd, *args, **kwargs):
return self.run_cmd(cwd, "git", *args, **kwargs)
def task(self, cwd, command, *args, **kwargs):
if command == "finish" and "env" not in kwargs:
kwargs["env"] = self.env | {"TASK_WRITERS_STOPPED": "1"}
return self.run_cmd(cwd, "bash", str(self.repo / "scripts/task.sh"), command, *args, **kwargs)
def worktree(self, name="feature/test"):
wt = self.root / "worktree"
self.git(self.repo, "worktree", "add", "-b", name, str(wt), "origin/main")
return wt
def advance_remote(self):
(self.repo / "new-main").write_text("new main\n")
self.git(self.repo, "add", "new-main")
self.git(self.repo, "commit", "-m", "Advance main")
self.git(self.repo, "push", "origin", "main")
def record_task_card(self, wt):
branch = self.git(wt, "branch", "--show-current").stdout.strip()
r0 = self.git(wt, "rev-parse", "origin/main").stdout.strip()
card = wt / ".task" / (branch.replace("/", "-") + ".md")
card.parent.mkdir(exist_ok=True)
card.write_text(
f"# {branch}\n任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n"
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n"
f"- 工作区:{wt}(worktree);分支:{branch}\n"
)
self.git(wt, "add", str(card.relative_to(wt)))
self.git(wt, "commit", "-m", "Record fixture task")
return card
def add_submodule(self):
child = self.root / "component"
self.git(self.root, "init", "--initial-branch=main", str(child))
(child / "content").write_text("v1\n")
self.git(child, "add", ".")
self.git(child, "commit", "-m", "Component v1")
self.git(self.repo, "submodule", "add", str(child), "component")
self.git(self.repo, "commit", "-m", "Pin component")
self.git(self.repo, "push", "origin", "main")
return child
def test_precheck_rejects_unreachable_authority(self):
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.task(self.repo, "precheck", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
def test_precheck_feature_behind_requires_sync(self):
wt = self.worktree()
self.advance_remote()
result = self.task(wt, "precheck", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("SYNC_REQUIRED", result.stdout)
def test_precheck_routes_existing_project_delivery_policy(self):
result = self.task(self.repo, "precheck")
self.assertNotIn("policy/delivery.md", result.stdout)
policy = self.repo / "policy/delivery.md"
policy.parent.mkdir()
policy.write_text("# Project delivery policy\n")
self.git(self.repo, "add", "policy")
self.git(self.repo, "commit", "-m", "Adopt project delivery policy")
result = self.task(self.repo, "precheck")
reading = next(line for line in result.stdout.splitlines() if line.startswith("[required-reading]"))
self.assertIn("policy/delivery.md", reading)
def test_precheck_facts_row_with_escaped_pipe_stays_fresh(self):
facts = self.repo / "policy/facts.md"
facts.parent.mkdir(exist_ok=True)
facts.write_text(
"| ID | 事实 | 验证命令 | last_verified | TTL(天) |\n"
"|---|---|---|---|---|\n"
"| F1 | 含转义竖线的验证命令 | `echo a\\|b` | 2026-10-13 | 9999 |\n"
)
self.git(self.repo, "add", "policy")
self.git(self.repo, "commit", "-m", "Add facts registry with escaped pipe")
result = self.task(self.repo, "precheck")
line = next(l for l in result.stdout.splitlines() if l.startswith("[facts]"))
self.assertIn("均在有效期内", line)
self.assertNotIn("日期格式", line)
def test_sync_same_base_preserves_head_and_card(self):
wt = self.worktree()
card = self.record_task_card(wt)
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
content = card.read_bytes()
index = self.git(wt, "ls-files", "--stage").stdout
for _ in range(2):
result = self.task(wt, "sync")
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(card.read_bytes(), content)
self.assertEqual(self.git(wt, "ls-files", "--stage").stdout, index)
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
self.assertIn("changed=0", result.stdout)
self.assertIn(f"tested_head={head}", result.stdout)
def test_sync_new_base_reports_actual_final_head(self):
wt = self.worktree()
card = self.record_task_card(wt)
self.advance_remote()
base = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
result = self.task(wt, "sync")
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.git(wt, "merge-base", "--is-ancestor", base, head)
self.assertIn(f"sync_base={base}", result.stdout)
self.assertIn(f"tested_head={head}", result.stdout)
self.assertIn("changed=1", result.stdout)
content = card.read_bytes()
self.task(wt, "sync")
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(card.read_bytes(), content)
def test_sync_preserves_dirty_parent_and_staged_changes(self):
for staged in (False, True):
with self.subTest(staged=staged):
wt = self.worktree(name="feature/staged" if staged else "feature/dirty")
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
(wt / "tracked").write_text("keep owner work\n")
if staged:
self.git(wt, "add", "tracked")
index = self.git(wt, "ls-files", "--stage").stdout
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(self.git(wt, "ls-files", "--stage").stdout, index)
self.assertEqual((wt / "tracked").read_text(), "keep owner work\n")
self.git(self.repo, "worktree", "remove", "--force", str(wt))
def test_sync_rejects_unreachable_target_without_changing_head(self):
wt = self.worktree()
self.record_task_card(wt)
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.git(wt, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
def test_sync_conflict_is_not_reported_as_synced(self):
wt = self.worktree()
self.record_task_card(wt)
(wt / "tracked").write_text("task choice\n")
self.git(wt, "commit", "-am", "Task edit")
(self.repo / "tracked").write_text("target choice\n")
self.git(self.repo, "commit", "-am", "Target edit")
self.git(self.repo, "push", "origin", "main")
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("[synced]", result.stdout)
self.assertIn("UU tracked", self.git(wt, "status", "--porcelain").stdout)
def test_sync_materializes_updated_component(self):
child = self.add_submodule()
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
(child / "content").write_text("v2\n")
self.git(child, "commit", "-am", "Component v2")
latest = self.git(child, "rev-parse", "HEAD").stdout.strip()
self.git(self.repo / "component", "fetch", "origin")
self.git(self.repo / "component", "checkout", "--detach", latest)
self.git(self.repo, "commit", "-am", "Update component")
self.git(self.repo, "push", "origin", "main")
result = self.task(wt, "sync")
self.assertIn("[synced]", result.stdout)
self.assertEqual((wt / "component/content").read_text(), "v2\n")
self.assertEqual(self.git(wt / "component", "rev-parse", "HEAD").stdout.strip(), latest)
def test_sync_preserves_component_feature_branch(self):
child = self.add_submodule()
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
self.git(wt / "component", "switch", "-c", "feature/owned")
before = self.git(wt / "component", "rev-parse", "HEAD").stdout
(child / "content").write_text("v2\n")
self.git(child, "commit", "-am", "Component v2")
latest = self.git(child, "rev-parse", "HEAD").stdout.strip()
self.git(self.repo / "component", "fetch", "origin")
self.git(self.repo / "component", "checkout", "--detach", latest)
self.git(self.repo, "commit", "-am", "Update component")
self.git(self.repo, "push", "origin", "main")
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertNotIn("[synced]", result.stdout)
self.assertEqual(self.git(wt / "component", "rev-parse", "HEAD").stdout, before)
self.assertEqual(self.git(wt / "component", "branch", "--show-current").stdout.strip(), "feature/owned")
def test_sync_keeps_unchanged_component_branch(self):
self.add_submodule()
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
self.git(wt / "component", "switch", "-c", "feature/owned")
self.task(wt, "sync")
self.assertEqual(self.git(wt / "component", "branch", "--show-current").stdout.strip(), "feature/owned")
def test_sync_preserves_dirty_child(self):
self.add_submodule()
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
(wt / "component/content").write_text("unsaved\n")
self.advance_remote()
before = self.git(wt, "rev-parse", "HEAD").stdout
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout, before)
self.assertEqual((wt / "component/content").read_text(), "unsaved\n")
def test_finish_preserves_modified_untracked_and_ignored_files(self):
for kind, rel in (("tracked", "tracked"), ("untracked", "unique"), ("ignored", "cache/unique")):
with self.subTest(kind=kind):
wt = self.root / f"worktree-{kind}"
self.git(self.repo, "worktree", "add", "-b", f"feature/{kind}", str(wt), "origin/main")
path = wt / rel
path.parent.mkdir(exist_ok=True)
path.write_text("unique user content\n")
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(path.exists(), result.stdout)
self.assertEqual(path.read_text(), "unique user content\n")
def test_finish_rejects_subdirectory_without_deleting_it(self):
wt = self.worktree()
result = self.task(wt / "scripts", "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue((wt / "scripts/task.sh").is_file(), result.stdout)
def test_finish_preserves_locked_worktree(self):
wt = self.worktree()
self.git(self.repo, "worktree", "lock", "--reason", "active owner", str(wt))
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(wt.exists(), result.stdout)
def test_finish_preserves_component_untracked_file(self):
self.add_submodule()
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
unique = wt / "component/unique"
unique.write_text("cannot regenerate\n")
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(unique.is_file(), result.stdout)
def test_finish_preserves_component_commits_off_the_pinned_head(self):
self.add_submodule()
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
child = wt / "component"
pinned = self.git(child, "rev-parse", "HEAD").stdout.strip()
self.git(child, "switch", "-c", "feature/unique-history")
(child / "content").write_text("unique committed work\n")
self.git(child, "commit", "-am", "Preserve this commit")
unique = self.git(child, "rev-parse", "HEAD").stdout.strip()
self.git(child, "checkout", "--detach", pinned)
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(wt.exists(), result.stdout)
self.assertEqual(self.git(child, "rev-parse", "feature/unique-history").stdout.strip(), unique)
def test_finish_preserves_linked_component_worktrees_at_the_pinned_head(self):
self.add_submodule()
for state in ("clean", "staged", "unstaged"):
with self.subTest(state=state):
wt = self.root / f"worktree-{state}"
self.git(self.repo, "worktree", "add", "-b", f"feature/{state}", str(wt), "origin/main")
self.git(wt, "submodule", "update", "--init", "--recursive")
child = wt / "component"
sibling = self.root / f"component-{state}"
self.git(child, "worktree", "add", "--detach", str(sibling), "HEAD")
head = self.git(sibling, "rev-parse", "HEAD").stdout
if state != "clean":
(sibling / "content").write_text("unique sibling work\n")
if state == "staged":
self.git(sibling, "add", "content")
status = self.git(sibling, "status", "--porcelain").stdout
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(wt.exists(), result.stdout)
self.assertEqual(self.git(child, "rev-parse", "HEAD").stdout, head)
self.assertEqual(self.git(child, "status", "--porcelain").stdout, "")
self.assertEqual(self.git(sibling, "rev-parse", "HEAD").stdout, head)
self.assertEqual(self.git(sibling, "status", "--porcelain").stdout, status)
self.assertEqual((sibling / "content").read_text(),
"v1\n" if state == "clean" else "unique sibling work\n")
def nested_component_update(self, attached):
child = self.add_submodule()
inner = self.root / "inner"
self.git(self.root, "init", "--initial-branch=main", str(inner))
(inner / "inner-content").write_text("inner v1\n")
self.git(inner, "add", ".")
self.git(inner, "commit", "-m", "Inner v1")
self.git(child, "submodule", "add", str(inner), "inner")
self.git(child, "commit", "-m", "Pin inner")
self.git(self.repo / "component", "fetch", "origin")
self.git(self.repo / "component", "checkout", "--detach", "origin/main")
self.git(self.repo, "commit", "-am", "Pin nested component")
self.git(self.repo, "push", "origin", "main")
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
nested = wt / "component/inner"
if attached:
self.git(nested, "switch", "-c", "feature/owned-inner")
before = self.git(nested, "rev-parse", "HEAD").stdout.strip()
(inner / "inner-content").write_text("inner v2\n")
self.git(inner, "commit", "-am", "Inner v2")
latest = self.git(inner, "rev-parse", "HEAD").stdout.strip()
self.git(child / "inner", "fetch", "origin")
self.git(child / "inner", "checkout", "--detach", latest)
self.git(child, "commit", "-am", "Update inner pin")
self.git(self.repo / "component", "fetch", "origin")
self.git(self.repo / "component", "checkout", "--detach", "origin/main")
self.git(self.repo, "commit", "-am", "Update nested component")
self.git(self.repo, "push", "origin", "main")
return wt, nested, before, latest
def test_sync_preserves_nested_feature_branch_when_parent_pin_changes(self):
wt, nested, before, _ = self.nested_component_update(attached=True)
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertNotIn("[synced]", result.stdout)
self.assertEqual(self.git(nested, "rev-parse", "HEAD").stdout.strip(), before)
self.assertEqual(self.git(nested, "branch", "--show-current").stdout.strip(), "feature/owned-inner")
def test_sync_materializes_nested_detached_components(self):
wt, nested, _, latest = self.nested_component_update(attached=False)
self.task(wt, "sync")
self.assertEqual(self.git(nested, "rev-parse", "HEAD").stdout.strip(), latest)
self.assertEqual((nested / "inner-content").read_text(), "inner v2\n")
def test_finish_preserves_linked_nested_component_worktree(self):
wt, nested, before, _ = self.nested_component_update(attached=False)
sibling = self.root / "nested-sibling"
self.git(nested, "worktree", "add", "--detach", str(sibling), "HEAD")
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(wt.exists(), result.stdout)
self.assertEqual(self.git(nested, "rev-parse", "HEAD").stdout.strip(), before)
self.assertEqual(self.git(sibling, "rev-parse", "HEAD").stdout.strip(), before)
self.assertEqual(self.git(sibling, "status", "--porcelain").stdout, "")
self.assertEqual((nested / "inner-content").read_text(), "inner v1\n")
self.assertEqual((sibling / "inner-content").read_text(), "inner v1\n")
def test_finish_clean_worktree_has_accurate_remote_result(self):
wt = self.worktree()
# A missing remote branch is not a successful remote deletion.
result = self.task(wt, "finish")
self.assertFalse(wt.exists(), result.stdout)
self.assertNotIn("本地+远端", result.stdout)
def test_finish_recycles_clean_submodule_without_shared_config_changes(self):
self.add_submodule()
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
result = self.task(wt, "finish")
self.assertFalse(wt.exists(), result.stdout)
self.assertTrue((self.repo / "component/content").is_file(), result.stdout)
self.assertEqual(self.git(self.repo, "config", "--get", "submodule.component.url").stdout.strip(), str(self.root / "component"))
def test_finish_requires_writer_exit_confirmation(self):
wt = self.worktree()
result = self.task(wt, "finish", check=False, env=self.env)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(wt.exists(), result.stdout)
def unmerged_task(self):
wt = self.worktree()
(wt / "tracked").write_text("unmerged task work\n")
self.git(wt, "commit", "-am", "Keep unmerged work")
self.git(wt, "push", "-u", "origin", "HEAD")
return wt, self.git(wt, "rev-parse", "HEAD").stdout.strip()
def test_finish_abandon_retains_local_remote_tips_and_reason(self):
wt, head = self.unmerged_task()
reason = "superseded by a smaller task"
result = self.task(wt, "finish", "--abandon", reason)
self.assertFalse(wt.exists(), result.stdout)
self.assertEqual(self.git(self.repo, "rev-parse", "refs/heads/feature/test").stdout.strip(), head)
self.assertEqual(self.git(self.remote, "rev-parse", "refs/heads/feature/test").stdout.strip(), head)
note = self.git(self.repo, "notes", "--ref=refs/notes/task-abandon", "show", head).stdout
self.assertIn(reason, note)
self.assertIn("feature/test", note)
self.assertEqual(self.git(self.repo, "show", f"{head}:tracked").stdout, "unmerged task work\n")
def test_finish_abandon_without_reason_preserves_checkout_and_refs(self):
wt, head = self.unmerged_task()
result = self.task(wt, "finish", "--abandon", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(wt.exists(), result.stdout)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(self.git(self.repo, "rev-parse", "refs/heads/feature/test").stdout.strip(), head)
self.assertEqual(self.git(self.remote, "rev-parse", "refs/heads/feature/test").stdout.strip(), head)
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
self.assertNotEqual(self.git(self.repo, "notes", "--ref=refs/notes/task-abandon", "show", head,
check=False).returncode, 0)
def test_finish_deletes_only_merged_remote_branch(self):
wt = self.worktree()
self.git(wt, "push", "-u", "origin", "HEAD")
result = self.task(wt, "finish")
self.assertIn("remote=DELETED", result.stdout)
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin", "refs/heads/feature/test").stdout, "")
def test_finish_preserves_remote_unmerged_tip(self):
wt = self.worktree()
self.git(self.repo, "switch", "-c", "feature/remote-update")
(self.repo / "remote-work").write_text("another writer")
self.git(self.repo, "add", "remote-work")
self.git(self.repo, "commit", "-m", "Unmerged work")
self.git(self.repo, "push", "origin", "HEAD:refs/heads/feature/test")
self.git(self.repo, "switch", "main")
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(wt.exists(), result.stdout)
self.assertTrue(self.git(self.repo, "ls-remote", "--heads", "origin", "refs/heads/feature/test").stdout)
def test_finish_remote_deletion_lease_preserves_concurrent_update(self):
wt = self.worktree()
old = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.git(wt, "push", "-u", "origin", "HEAD")
self.git(self.repo, "switch", "-c", "feature/racing")
(self.repo / "race-work").write_text("concurrent remote work")
self.git(self.repo, "add", "race-work")
self.git(self.repo, "commit", "-m", "Concurrent remote work")
latest = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
self.git(self.repo, "push", "origin", "feature/racing")
self.git(self.repo, "switch", "main")
hook = self.repo / ".git/hooks/pre-push"
hook.write_text("#!/bin/sh\n" + shlex.join(["git", "--git-dir", str(self.remote), "update-ref",
"refs/heads/feature/test", latest, old]) + "\n")
hook.chmod(0o755)
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("[partial]", result.stdout)
current = self.git(self.repo, "ls-remote", "--heads", "origin", "refs/heads/feature/test").stdout
self.assertTrue(current.startswith(latest), current)
def test_sync_commit_failure_never_reports_synced(self):
wt = self.worktree()
(wt / ".task").mkdir()
(wt / ".task/feature-test.md").write_text("# Task\n")
self.git(wt, "add", ".task")
self.git(wt, "commit", "-m", "Create task card")
self.advance_remote()
hook = self.repo / ".git/hooks/pre-commit"
hook.write_text("#!/bin/sh\nexit 1\n")
hook.chmod(0o755)
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertNotIn("[synced]", result.stdout)
self.assertIn(".task/feature-test.md", self.git(wt, "status", "--porcelain").stdout)
def task_card(self, wt):
card = wt / ".task/feature-test.md"
card.parent.mkdir(exist_ok=True)
card.write_text("# Task\n")
self.git(wt, "add", ".task")
self.git(wt, "commit", "-m", "Create task card")
return card
def capture_evidence(self, wt, command="printf 'checked\\n'"):
card = self.task_card(wt)
self.run_cmd(wt, "bash", "scripts/evidence.sh", env=self.env | {"CMD": command})
return card
def commit_evidence(self, wt):
self.git(wt, "add", ".task")
self.git(wt, "commit", "-m", "Record evidence")
def test_sync_warns_when_json_evidence_predates_noncard_changes(self):
wt = self.worktree()
self.capture_evidence(wt)
self.commit_evidence(wt)
self.advance_remote()
result = self.task(wt, "sync")
self.assertIn("[evidence] WARN", result.stdout)
self.assertIn("重跑", result.stdout)
def test_sync_keeps_json_evidence_fresh_after_metadata_only_changes(self):
wt = self.worktree()
self.capture_evidence(wt)
# A second run sees only the first run's uncommitted card receipt.
self.run_cmd(wt, "bash", "scripts/evidence.sh", env=self.env | {"CMD": "true"})
self.commit_evidence(wt)
(self.repo / ".task").mkdir()
(self.repo / ".task/other.md").write_text("Other task metadata\n")
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", "Record other task")
self.git(self.repo, "push", "origin", "main")
result = self.task(wt, "sync")
self.assertNotIn("[evidence] WARN", result.stdout)
def test_sync_warns_when_json_before_state_contains_uncommitted_code(self):
wt = self.worktree()
(wt / "tracked").write_text("uncommitted code\n")
self.capture_evidence(wt, command="git restore tracked")
self.commit_evidence(wt)
result = self.task(wt, "sync")
self.assertIn("[evidence] WARN", result.stdout)
self.assertIn("unverified", result.stdout)
def test_sync_warns_when_json_after_state_contains_uncommitted_code(self):
wt = self.worktree()
self.capture_evidence(wt, command="printf 'uncommitted code\\n' > tracked")
self.git(wt, "restore", "tracked")
self.commit_evidence(wt)
result = self.task(wt, "sync")
self.assertIn("[evidence] WARN", result.stdout)
self.assertIn("unverified", result.stdout)
def test_sync_does_not_execute_json_receipt_command(self):
wt = self.worktree()
sentinel = self.root / "command-was-executed"
self.capture_evidence(wt, command="printf ran > " + shlex.quote(str(sentinel)))
self.assertTrue(sentinel.is_file())
sentinel.unlink()
self.commit_evidence(wt)
self.task(wt, "sync")
self.assertFalse(sentinel.exists())
def rewrite_receipt(self, card, change):
prefix, block = card.read_text().split("```json\n", 1)
record, suffix = block.split("\n```", 1)
receipt = json.loads(record)
change(receipt)
receipt.pop("receipt_sha256")
serialized = json.dumps(receipt, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
receipt["receipt_sha256"] = hashlib.sha256(serialized.encode()).hexdigest()
card.write_text(prefix + "```json\n" + json.dumps(receipt) + "\n```" + suffix)
def test_sync_warns_for_unresolved_json_evidence_head(self):
wt = self.worktree()
card = self.capture_evidence(wt)
self.rewrite_receipt(card, lambda receipt: receipt["before"].update(head="0" * 40))
self.commit_evidence(wt)
result = self.task(wt, "sync")
self.assertIn("[evidence] WARN", result.stdout)
self.assertIn("unverified", result.stdout)
def test_sync_warns_for_missing_json_receipt_fields_even_with_valid_checksum(self):
wt = self.worktree()
card = self.capture_evidence(wt)
self.rewrite_receipt(card, lambda receipt: receipt.pop("command"))
self.commit_evidence(wt)
result = self.task(wt, "sync")
self.assertIn("[evidence] WARN", result.stdout)
self.assertIn("unverified", result.stdout)
def test_sync_warns_for_malformed_json_evidence(self):
wt = self.worktree()
card = self.task_card(wt)
card.write_text(card.read_text() + '\n```json\n{"schema": 1, invalid}\n```\n')
self.commit_evidence(wt)
result = self.task(wt, "sync")
self.assertIn("[evidence] WARN", result.stdout)
self.assertIn("unverified", result.stdout)
def test_sync_warns_for_unresolved_legacy_evidence_head(self):
wt = self.worktree()
card = self.task_card(wt)
card.write_text(card.read_text() + "\n| 2026-10-05T00:00:00Z | check | `0000000000` | 0 | SELF | abc |\n")
self.commit_evidence(wt)
result = self.task(wt, "sync")
self.assertIn("[evidence] WARN", result.stdout)
self.assertIn("unverified", result.stdout)
def test_sync_still_warns_for_stale_legacy_table_evidence(self):
wt = self.worktree()
card = self.task_card(wt)
head = self.git(wt, "rev-parse", "--short=10", "HEAD").stdout.strip()
card.write_text(card.read_text() + f"\n| 2026-10-05T00:00:00Z | check | `{head}` | 0 | SELF | abc |\n")
self.commit_evidence(wt)
self.advance_remote()
result = self.task(wt, "sync")
self.assertIn("[evidence] WARN", result.stdout)
def test_sync_reads_legacy_commands_with_escaped_pipes(self):
wt = self.worktree()
card = self.task_card(wt)
head = self.git(wt, "rev-parse", "--short=10", "HEAD").stdout.strip()
card.write_text(card.read_text() +
f"\n| 2026-10-05T00:00:00Z | `false \\|\\| true` | `{head}` | 0 | SELF | abc |\n")
self.commit_evidence(wt)
self.advance_remote()
result = self.task(wt, "sync")
self.assertIn("[synced]", result.stdout)
self.assertIn("[evidence] WARN", result.stdout)
self.assertNotIn("unverified", result.stdout)
def test_finish_preserves_main_and_active_index_lock(self):
result = self.task(self.repo, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue((self.repo / "tracked").is_file())
wt = self.worktree()
index_lock = Path(self.git(wt, "rev-parse", "--path-format=absolute", "--git-path", "index.lock").stdout.strip())
index_lock.write_text("another writer")
result = self.task(wt, "finish", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(wt.exists(), result.stdout)
self.assertEqual(index_lock.read_text(), "another writer")
def test_verify_rejects_recursive_drift_even_when_root_has_local_edits(self):
child = self.add_submodule()
wt = self.worktree()
self.git(wt, "submodule", "update", "--init", "--recursive")
(wt / "tracked").write_text("root implementation edit\n")
self.run_cmd(wt, "python3", "scripts/check-worktree.py", "verify")
(child / "content").write_text("v2\n")
self.git(child, "commit", "-am", "Component v2")
self.git(wt / "component", "fetch", "origin")
self.git(wt / "component", "checkout", "--detach", "origin/main")
result = self.run_cmd(wt, "python3", "scripts/check-worktree.py", "verify", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
def capture_precheck(self, cwd=None):
receipt = self.root / f"precheck-{len(list(self.root.glob('precheck*.json')))}.json"
env = self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"}
self.run_cmd(cwd or self.repo, "bash", str(self.repo / "scripts/evidence.sh"), env=env)
return receipt
def test_start_requires_precheck_receipt_before_creating_worktree(self):
result = self.task(self.repo, "start", "feature/new", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse((self.root / "repo-wt-feature-new").exists(), result.stdout)
def test_start_preserves_merged_local_and_remote_branch_names(self):
receipt = self.capture_precheck()
head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
for kind in ("local", "remote"):
with self.subTest(kind=kind):
name = "feature/merged-" + kind
if kind == "local":
self.git(self.repo, "branch", name, head)
ref = "refs/heads/" + name
else:
self.git(self.repo, "push", "origin", head + ":refs/heads/" + name)
ref = "refs/remotes/origin/" + name
self.git(self.repo, "fetch", "origin", "refs/heads/" + name + ":" + ref)
result = self.task(self.repo, "start", name, check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("already merged", result.stdout)
self.assertFalse((self.root / ("repo-wt-" + name.replace("/", "-"))).exists())
self.assertEqual(self.git(self.repo, "rev-parse", ref).stdout.strip(), head)
def test_start_imports_precheck_and_creates_four_part_commit(self):
receipt = self.capture_precheck()
self.task(self.repo, "start", "feature/new", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
wt = self.root / "repo-wt-feature-new"
self.assertTrue((wt / ".task/feature-new.md").is_file())
card = (wt / ".task/feature-new.md").read_text()
self.assertIn("make precheck", card)
self.assertIn("交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)", card)
self.assertIn("实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)", card)
self.assertIn("人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收", card)
self.assertNotIn("authorized-continuous", card)
self.assertNotIn("cursor-per-unit", card)
self.assertNotIn("Cursor unavailable means blocker only", card)
self.assertNotIn("不得交回主模型代写", card)
self.assertNotIn("never return to host", card)
self.assertIn("流程依据:仓库根目录 policy/delivery.md", card)
self.assertIn("## Agent 实施收据\n", card)
self.assertNotIn("## Cursor 实施收据\n", card)
agent_receipts = card.split("## Agent 实施收据\n", 1)[1].split("\n## ", 1)[0]
self.assertIn(
"| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |",
agent_receipts,
)
self.assertIn(
"| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |",
agent_receipts,
)
self.assertIn("Cursor、Antigravity(agy)和 PI", agent_receipts)
self.assertIn("最多 3 次全新尝试", agent_receipts)
self.assertIn("一次全新的 Codex CLI 紧急尝试", agent_receipts)
self.assertIn("规划、方案设计、实施计划或独立 Review", agent_receipts)
self.assertIn("显式 fail-stop", agent_receipts)
self.assertIn("不因推理时长、工具次数、规划方式或首次写文件时间判定失败", agent_receipts)
self.assertIn("stdin/stdout/stderr 均无活动的空闲超时", agent_receipts)
self.assertIn("自主检查、计划、测试并迭代", agent_receipts)
self.assertIn("scripts/agent_exec.py apply", agent_receipts)
self.assertIn("pre-commit admit", agent_receipts)
self.assertIn(".task/**", agent_receipts)
self.assertIn("docs/superpowers/specs/**", agent_receipts)
self.assertIn("docs/superpowers/plans/**", agent_receipts)
self.assertIn("fallback 列只记录该行是否为 Codex 紧急尝试", agent_receipts)
self.assertIn("不授权 host 直接撰写该单元", agent_receipts)
self.assertIn("安全耗尽不发出 host-fallback-authorized", agent_receipts)
self.assertIn("终态为 unavailable 或 blocked", agent_receipts)
for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"):
self.assertIn(field + ":", card)
self.assertIn("## 交付调度\n", card)
queue = card.split("## 交付调度\n", 1)[1].split("\n## ", 1)[0]
self.assertIn("调度状态:未入队", queue)
for field in ("调度记录", "entry_id", "attempt_id", "窗口执行者", "固定候选"):
self.assertIn(field + ":无", queue)
self.assertIn("## 关联方向与规划\n", card)
planning = card.split("## 关联方向与规划\n", 1)[1].split("\n## ", 1)[0]
for field in ("适用状态", "主记录路径/版本", "对应里程碑"):
self.assertIn(field + ":待核对", planning)
self.assertIn("无适用规划时说明原因", planning)
self.assertIn("路径按仓库根目录定位,归档后不变", planning)
self.assertIn("只引用唯一正文", planning)
self.assertIn("| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 |", card)
reviews = card.split("## 阶段记录\n", 1)[1].split("\n## ", 1)[0]
self.assertIn("| 阶段 | 对象版本 | 状态 | 决策/执行者 | 时间 | 依据 |", reviews)
for stage in ("需求拆解", "需求评估", "需求表确认", "方案设计", "实施计划", "实施与自检",
"正式测试与验收", "代码 Review", "合入", "合入核验与收口"):
row = next(line for line in reviews.splitlines() if line.startswith(f"| {stage} |"))
self.assertEqual(row.split("|")[3].strip(), "待执行")
self.assertNotIn("通过", reviews)
self.assertIn("## 探索试验与迭代", card)
for field in ("假设", "范围", "判断和停止条件", "授权/决策依据", "结果", "变更影响", "需重审补测"):
self.assertIn(field, card.split("## 探索试验与迭代\n", 1)[1].split("\n## ", 1)[0])
self.assertIn("## 规划进展与下一步\n", card)
next_step = card.split("## 规划进展与下一步\n", 1)[1].split("\n## ", 1)[0]
for field in ("本次推进/剩余", "首选下一任务", "前置条件/待确认", "重复任务核对"):
self.assertIn(field + ":待核对", next_step)
self.assertIn("推荐依据:待填写", next_step)
self.assertIn("预期产物/验收条件:待定义", next_step)
self.assertIn("无适用规划、已完成或受阻时如实说明,不虚构任务", next_step)
self.assertIn("已有/在途任务、PR 与规划记录", next_step)
self.assertIn("启动授权:未授权", next_step)
self.assertIn("建议不自动启动新任务,仍须明确授权", next_step)
self.assertIn("## 合入与清理", card)
for field in ("合入记录", "清理记录"):
self.assertIn(field + ":NOT_RUN", card)
body = self.git(wt, "log", "-1", "--format=%B").stdout
for field in ("Why:", "Evidence:", "Impact:", "Refs:"):
self.assertIn(field, body)
def install_post_merge_hook(self):
hook = self.repo / ".git/hooks/post-merge"
shutil.copyfile(SOURCE / "scripts/hooks/post-merge", hook)
hook.chmod(0o755)
def publish_merged_task(self):
publisher = self.root / "publisher"
self.git(self.root, "clone", str(self.remote), str(publisher))
self.git(publisher, "switch", "-c", "feature/merged")
card = publisher / ".task/feature-merged.md"
card.parent.mkdir()
card.write_text("# Merged task\n任务主记录:待回填;状态:进行中;owner:test\n"
"- 工作区:fixture;分支:feature/merged\n")
self.git(publisher, "add", ".task")
self.git(publisher, "commit", "-m", "Record merged task")
self.git(publisher, "switch", "main")
self.git(publisher, "merge", "--no-ff", "feature/merged", "-m",
"Merge pull request 'Task' (#7) from feature/merged into main")
self.git(publisher, "push", "origin", "main")
return self.git(publisher, "rev-parse", "HEAD").stdout.strip(), card.read_text()
def test_long_merge_history_keeps_merged_card_visible_and_closable(self):
# A recent match makes grep -q/head exit before git log has finished
# writing a large history. With pipefail, its SIGPIPE used to hide the
# card from precheck and leave task-close with nothing to archive.
base = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
tree = self.git(self.repo, "rev-parse", "HEAD^{tree}").stdout.strip()
side = self.git(self.repo, "commit-tree", tree, "-p", base,
"-m", "Fixture side commit").stdout.strip()
tip = base
for i in range(220):
tip = self.git(self.repo, "commit-tree", tree, "-p", tip, "-p", side,
"-m", f"Older fixture merge {i} " + "x" * 512).stdout.strip()
self.git(self.repo, "update-ref", "refs/heads/main", tip)
self.git(self.repo, "push", "origin", "main")
actual_merge, _ = self.publish_merged_task()
self.git(self.repo, "fetch", "origin")
self.git(self.repo, "merge", "--ff-only", "origin/main")
precheck = self.task(self.repo, "precheck")
self.assertIn("[cards] 已合入未闭合任务卡:feature-merged.md", precheck.stdout)
closed = self.task(self.repo, "close")
self.assertIn("[closed] 1 张已合入卡片", closed.stdout)
refs = self.git(self.repo, "for-each-ref", "--format=%(refname:short)",
"refs/heads/feature/card-close-*").stdout.splitlines()
self.assertEqual(len(refs), 1)
archived = self.git(self.repo, "show", f"{refs[0]}:.task/archive/feature-merged.md").stdout
self.assertIn("状态:已完成(回填)", archived)
self.assertIn(actual_merge, archived)
def _stub_gitea(self, get_reply, post_reply=None):
"""Stub Gitea API:GET 按路径返回 get_reply(path),POST 返回建 PR 应答。"""
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, obj):
body = json.dumps(obj).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._send(get_reply(self.path))
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length") or 0))
self._send(post_reply or {"html_url": "http://stub.example/pr/1"})
server = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.shutdown)
return f"http://127.0.0.1:{server.server_port}", server
def test_close_backfills_card_landed_as_squash(self):
# squash 落地没有 "from <branch> into main" 合并提交:card_merge 须经 Gitea API 识别,
# 否则任务卡永远留在 .task/ 根目录(2026-10-14 #457 实测漏检)。
wt = self.worktree("feature/squashy")
self.record_task_card(wt)
branch = self.git(wt, "branch", "--show-current").stdout.strip()
self.git(wt, "push", "-u", "origin", branch)
self.git(self.repo, "fetch", "origin", branch)
self.git(self.repo, "merge", "--squash", "FETCH_HEAD")
self.git(self.repo, "commit", "-m", "Squash the fixture task")
squash_sha = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
self.git(self.repo, "push", "origin", "main")
base, _ = self._stub_gitea(
lambda path: [{
"number": 41, "title": "Decoy other branch", "state": "closed",
"merged": True, "head": {"ref": "feature/other", "label": "feature/other"},
"merge_commit_sha": squash_sha,
}, {
# head 分支已删的真实形态:ref 换成 pull 引用,原始分支名在 label
"number": 42, "title": "Fixture squash landing", "state": "closed",
"merged": True, "head": {"ref": "refs/pull/42/head", "label": branch},
"merge_commit_sha": squash_sha,
}] if "/pulls" in path else [],
post_reply={"html_url": "http://stub.example/pr/42"})
env = self.env | {"GITEA_TOKEN": "stub-token", "TASK_CARD_WEB_BASE": base}
closed = self.task(self.repo, "close", env=env)
self.assertIn("[closed] 1 张已合入卡片", closed.stdout)
refs = self.git(self.repo, "for-each-ref", "--format=%(refname:short)",
"refs/heads/feature/card-close-*").stdout.splitlines()
self.assertEqual(len(refs), 1)
archived = self.git(self.repo, "show", f"{refs[0]}:.task/archive/feature-squashy.md").stdout
self.assertIn("状态:已完成(回填)", archived)
self.assertIn(squash_sha, archived)
self.assertIn("PR #42", archived)
commit_msg = self.git(self.repo, "log", "-1", "--format=%B", refs[0]).stdout
self.assertIn("Gitea API squash lookup", commit_msg)
def test_close_ignores_squash_claim_when_sha_not_on_main(self):
# API 声称 merged 但 merge_commit_sha 不在 origin/main(伪造或陈旧):不可核即不闭合。
wt = self.worktree("feature/phantom")
self.record_task_card(wt)
branch = self.git(wt, "branch", "--show-current").stdout.strip()
self.git(wt, "push", "-u", "origin", branch)
tip = self.git(wt, "rev-parse", "HEAD").stdout.strip()
base, _ = self._stub_gitea(
lambda path: [{
"number": 43, "title": "Phantom squash", "state": "closed",
"merged": True, "head": {"ref": branch},
"merge_commit_sha": tip,
}] if "/pulls" in path else [])
env = self.env | {"GITEA_TOKEN": "stub-token", "TASK_CARD_WEB_BASE": base}
closed = self.task(self.repo, "close", env=env)
self.assertIn("无已合入未闭合的任务卡", closed.stdout)
def assert_post_merge_only_reminds_owner(self, operation):
expected_head, expected_card = self.publish_merged_task()
self.git(self.repo, "fetch", "origin")
old_head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
local_heads = self.git(self.repo, "show-ref", "--heads").stdout
worktrees = self.git(self.repo, "worktree", "list", "--porcelain").stdout
remote_refs = self.git(self.remote, "show-ref").stdout
self.install_post_merge_hook()
result = self.git(self.repo, *operation)
self.assertIn("make task-close", result.stdout)
self.assertIn("授权", result.stdout)
self.assertNotIn("[close]", result.stdout)
self.assertEqual(self.git(self.repo, "branch", "--show-current").stdout.strip(), "main")
self.assertEqual(self.git(self.repo, "rev-parse", "HEAD").stdout.strip(), expected_head)
self.assertEqual(self.git(self.repo, "show-ref", "--heads").stdout,
local_heads.replace(old_head, expected_head))
self.assertEqual(self.git(self.repo, "worktree", "list", "--porcelain").stdout,
worktrees.replace(old_head, expected_head))
self.assertEqual(self.git(self.remote, "show-ref").stdout, remote_refs)
self.assertEqual(self.git(self.repo, "status", "--porcelain").stdout, "")
self.assertEqual((self.repo / ".task/feature-merged.md").read_text(), expected_card)
self.assertFalse((self.repo / ".task/archive").exists())
def test_post_merge_main_merge_only_reminds_owner(self):
self.assert_post_merge_only_reminds_owner(("merge", "--ff-only", "origin/main"))
def test_post_merge_main_pull_only_reminds_owner(self):
self.assert_post_merge_only_reminds_owner(("pull", "--ff-only"))
def test_post_merge_is_silent_outside_main_checkout(self):
wt = self.worktree()
self.install_post_merge_hook()
self.advance_remote()
result = self.git(wt, "merge", "--ff-only", "origin/main")
self.assertNotIn("make task-close", result.stdout)
self.assertNotIn("[close]", result.stdout)
self.git(self.repo, "switch", "-c", "feature/primary")
self.git(wt, "switch", "-c", "feature/advance")
(wt / "feature-work").write_text("another task\n")
self.git(wt, "add", "feature-work")
self.git(wt, "commit", "-m", "Advance feature")
result = self.git(self.repo, "merge", "--ff-only", "feature/advance")
self.assertNotIn("make task-close", result.stdout)
self.assertNotIn("[close]", result.stdout)
def test_start_stacked_branch_records_parent_and_main_baseline(self):
base = self.git(self.repo, "rev-parse", "origin/main").stdout.strip()
parent = self.worktree("feature/parent")
(parent / "parent-change").write_text("parent feature")
self.git(parent, "add", "parent-change")
self.git(parent, "commit", "-m", "Parent change")
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
receipt = self.capture_precheck(parent)
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_PARENT_BRANCH": "feature/parent",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/42"}
self.task(parent, "start", "feature/parent-child", env=env)
wt = self.root / "repo-wt-feature-parent-child"
card = (wt / ".task/feature-parent-child.md").read_text()
self.assertIn(f"initial_base_sha)= {base}", card)
self.assertIn(f"parent_head_sha={parent_head}", card)
self.assertIn("https://example.invalid/repo/pulls/42", card)
self.assertEqual((wt / "parent-change").read_text(), "parent feature")
def test_start_offline_does_not_create_worktree(self):
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.task(self.repo, "start", "feature/offline", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-offline").exists())
def test_start_rejects_receipt_when_authoritative_base_changed(self):
receipt = self.capture_precheck()
self.advance_remote()
result = self.task(self.repo, "start", "feature/stale", check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse((self.root / "repo-wt-feature-stale").exists())
def test_verify_rejects_uninitialized_component(self):
self.add_submodule()
wt = self.worktree()
result = self.run_cmd(wt, "python3", "scripts/check-worktree.py", "verify", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
# ---- 两级分支:initiative -> child -> main 与新需求强制分诊 ----
def write_initiative_card(self, branch, status="进行中", accepting="true", root="main",
kind="initiative", commit=True):
slug = branch.replace("/", "-")
card = self.repo / ".task" / f"{slug}.md"
card.parent.mkdir(exist_ok=True)
card.write_text(
f"# {branch}\n任务主记录:fixture;状态:{status};owner:Lifecycle Test\n"
f"- 工作区:fixture(worktree);分支:{branch}\n"
f"- 分支类型:{kind};initiative_id:{slug};root_target:{root};"
f"immediate_target:{root};accepting_requirements:{accepting}\n"
)
if commit:
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Record {branch} card")
return card
def card_helper(self, func, card):
source = (self.repo / "scripts/task.sh").read_text()
lib = self.root / "task-lib.sh"
lib.write_text(source[:source.rindex('\ncase "${1:-}" in')] + "\n")
return self.run_cmd(self.repo, "bash", "-c", f'source {shlex.quote(str(lib))}; {func} "$1"',
"_", str(card)).stdout.strip()
def start_initiative(self, name="feature/alpha"):
receipt = self.capture_precheck()
self.task(self.repo, "start", name,
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": "initiative"})
return self.root / ("repo-wt-" + name.replace("/", "-"))
def start_child(self, name="feature/alpha-login", initiative="feature/alpha", check=True):
receipt = self.capture_precheck()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": "child",
"TASK_INITIATIVE": initiative,
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
result = self.task(self.repo, "start", name, env=env, check=check)
return self.root / ("repo-wt-" + name.replace("/", "-")), result
def test_triage_reports_new_without_active_initiatives(self):
result = self.task(self.repo, "triage", "add login")
self.assertIn("[triage] requirement: add login", result.stdout)
self.assertIn("[triage] active initiatives: 0", result.stdout)
self.assertIn("[verdict] NEW", result.stdout)
self.assertNotIn("[initiative]", result.stdout)
def test_triage_joins_single_active_initiative(self):
self.write_initiative_card("feature/alpha")
result = self.task(self.repo, "triage", "add login")
self.assertIn("[triage] active initiatives: 1", result.stdout)
self.assertIn("[initiative] branch=feature/alpha initiative_id=feature-alpha", result.stdout)
self.assertIn("[verdict] JOIN", result.stdout)
self.assertIn("INITIATIVE=feature/alpha", result.stdout)
def test_triage_requires_decision_for_multiple_active_initiatives(self):
self.write_initiative_card("feature/alpha")
self.write_initiative_card("feature/beta")
result = self.task(self.repo, "triage", check=False)
self.assertEqual(result.returncode, 5, result.stdout)
self.assertIn("[triage] active initiatives: 2", result.stdout)
self.assertIn("[initiative] branch=feature/alpha", result.stdout)
self.assertIn("[initiative] branch=feature/beta", result.stdout)
self.assertIn("[verdict] DECISION_REQUIRED", result.stdout)
def test_triage_ignores_closed_merged_pending_and_non_accepting_initiatives(self):
self.write_initiative_card("feature/active")
self.write_initiative_card("feature/done", status="已完成(回填)")
self.write_initiative_card("feature/closed-intake", accepting="false")
self.write_initiative_card("feature/plain", kind="standalone")
self.write_initiative_card("feature/other-root", root="release")
self.write_initiative_card("feature/merged-init")
self.git(self.repo, "switch", "-c", "feature/merged-init")
(self.repo / "merged-work").write_text("landed\n")
self.git(self.repo, "add", "merged-work")
self.git(self.repo, "commit", "-m", "Merged initiative work")
self.git(self.repo, "switch", "main")
self.git(self.repo, "merge", "--no-ff", "feature/merged-init", "-m",
"Merge pull request 'Init' (#9) from feature/merged-init into main")
self.write_initiative_card("feature/pending-init")
self.git(self.repo, "switch", "-c", "feature/card-close-1")
self.git(self.repo, "mv", ".task/feature-pending-init.md", ".task/archive-tmp.md")
(self.repo / ".task/archive").mkdir()
self.git(self.repo, "mv", ".task/archive-tmp.md", ".task/archive/feature-pending-init.md")
self.git(self.repo, "commit", "-m", "Close pending initiative card")
self.git(self.repo, "switch", "main")
self.git(self.repo, "push", "origin", "main", "feature/card-close-1")
result = self.task(self.repo, "triage")
self.assertIn("[triage] active initiatives: 1", result.stdout)
self.assertIn("[initiative] branch=feature/active", result.stdout)
for branch in ("done", "closed-intake", "plain", "other-root", "merged-init", "pending-init"):
self.assertNotIn(f"branch=feature/{branch} ", result.stdout)
self.assertIn("[verdict] JOIN", result.stdout)
def test_precheck_reports_active_initiatives(self):
result = self.task(self.repo, "precheck", check=False)
self.assertIn("[initiatives] active: none", result.stdout)
self.write_initiative_card("feature/alpha")
self.write_initiative_card("feature/beta")
self.git(self.repo, "push", "origin", "main")
result = self.task(self.repo, "precheck", check=False)
self.assertIn("[initiatives] active: feature/alpha feature/beta", result.stdout)
def test_start_initiative_records_branch_metadata(self):
wt = self.start_initiative()
card = wt / ".task/feature-alpha.md"
text = card.read_text()
self.assertIn("- 分支类型:initiative;initiative_id:feature-alpha;root_target:main;"
"immediate_target:main;accepting_requirements:true", text)
self.assertEqual(self.git(wt, "merge-base", "HEAD", "origin/main").stdout.strip(),
self.git(self.repo, "rev-parse", "origin/main").stdout.strip())
self.assertEqual(self.card_helper("card_branch_kind", card), "initiative")
self.assertEqual(self.card_helper("card_accepting_requirements", card), "true")
def test_start_standalone_records_default_metadata(self):
receipt = self.capture_precheck()
self.task(self.repo, "start", "feature/solo", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
text = (self.root / "repo-wt-feature-solo/.task/feature-solo.md").read_text()
self.assertIn("- 分支类型:standalone;root_target:main;immediate_target:main;"
"accepting_requirements:false", text)
def test_start_child_records_parent_head_and_immediate_target(self):
parent = self.start_initiative()
(parent / "parent-change").write_text("initiative work\n")
self.git(parent, "add", "parent-change")
self.git(parent, "commit", "-m", "Initiative work")
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
wt, _ = self.start_child()
card = wt / ".task/feature-alpha-login.md"
text = card.read_text()
self.assertIn("- 分支类型:child;initiative_id:feature-alpha;root_target:main;"
"immediate_target:feature/alpha;accepting_requirements:false;"
f"parent_branch:feature/alpha;parent_head_sha:{parent_head};"
"parent_pr:https://example.invalid/repo/pulls/7", text)
self.assertEqual((wt / "parent-change").read_text(), "initiative work\n")
self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD")
self.assertEqual(self.card_helper("card_immediate_target", card), "feature/alpha")
self.assertEqual(self.card_helper("card_initiative_id", card), "feature-alpha")
self.assertEqual(self.card_helper("card_root_target", card), "main")
def test_start_child_rejects_missing_initiative_or_wrong_slug(self):
self.start_initiative()
wt, result = self.start_child(name="feature/other-login", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("parent initiative slug", result.stdout)
self.assertFalse(wt.exists())
wt, result = self.start_child(initiative="", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse(wt.exists())
def test_sync_child_merges_new_parent_initiative_commits(self):
parent = self.start_initiative()
wt, _ = self.start_child()
(parent / "parent-later").write_text("later initiative work\n")
self.git(parent, "add", "parent-later")
self.git(parent, "commit", "-m", "Later initiative work")
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
result = self.task(wt, "sync")
self.assertIn("changed=1", result.stdout)
self.assertIn("sync_target=feature/alpha", result.stdout)
self.assertIn(f"sync_base={parent_head}", result.stdout)
self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD")
self.assertEqual((wt / "parent-later").read_text(), "later initiative work\n")
self.assertIn(f"| 同步(记录前对象) | {parent_head} |",
(wt / ".task/feature-alpha-login.md").read_text())
again = self.task(wt, "sync")
self.assertIn("changed=0", again.stdout)
def test_finish_recycles_child_merged_into_parent_initiative(self):
parent = self.start_initiative()
wt, _ = self.start_child()
(wt / "child-work").write_text("child result\n")
self.git(wt, "add", "child-work")
self.git(wt, "commit", "-m", "Child work")
refused = self.task(wt, "finish", check=False)
self.assertNotEqual(refused.returncode, 0, refused.stdout)
self.assertIn("has not merged into feature/alpha", refused.stdout)
self.assertTrue(wt.exists())
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative")
result = self.task(wt, "finish")
self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha",
result.stdout)
self.assertFalse(wt.exists(), result.stdout)
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "")
self.assertTrue(parent.exists())
self.assertEqual(self.git(parent, "branch", "--show-current").stdout.strip(), "feature/alpha")
def test_legacy_card_defaults_to_standalone_main(self):
wt = self.worktree()
card = self.record_task_card(wt)
self.assertEqual(self.card_helper("card_branch_kind", card), "standalone")
self.assertEqual(self.card_helper("card_initiative_id", card), "")
self.assertEqual(self.card_helper("card_root_target", card), "main")
self.assertEqual(self.card_helper("card_immediate_target", card), "main")
self.assertEqual(self.card_helper("card_accepting_requirements", card), "false")
result = self.task(self.repo, "triage")
self.assertIn("[verdict] NEW", result.stdout)
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_default_branch_at_origin_main_does_not_block(self):
"""克隆默认分支停在 origin/main 顶端(pin 为 feature 顶端时不在其历史内)
且 HEAD reflog 含克隆/检出轨迹——均为远端有副本的状态,不得阻断 finish。"""
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")
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)