Files
Agent-System-Dev/tests/test_task.py
T
weichen ab622e5bc8 Delegate reviewed PR merges to agents after conflict and CI checks
Why: make the agent responsible for merging reviewed PRs without another human merge step.

Evidence: SELF governance and generated-card checks are captured in .task/feature-agent-pr-merge.md; CI remains required on the final PR HEAD.

Impact: update project delivery policy, agent entry points, task metadata and closure guidance; preserve platform protection and scoped authorization.

Refs: .task/feature-agent-pr-merge.md; user instruction on 2026-10-12
2026-10-12 12:35:31 +00:00

924 lines
50 KiB
Python
Raw Permalink 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"})
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"):
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 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_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")
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 / "precheck.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("交付模式:manual(前序人工决策,智能体 Review/合入)", card)
self.assertIn("authorized-continuous", card)
self.assertIn("流程依据:仓库根目录 policy/delivery.md", card)
for field in ("授权范围", "授权来源", "授权终点", "停止条件"):
self.assertIn(f"{field}:无", card)
for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"):
self.assertIn(field + ":", card)
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 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)
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)