1916 lines
107 KiB
Python
1916 lines
107 KiB
Python
"""Lifecycle regressions against real disposable Git repositories and remotes."""
|
||
import os
|
||
import json
|
||
import re
|
||
import hashlib
|
||
import http.server
|
||
import threading
|
||
from pathlib import Path
|
||
import shutil
|
||
import shlex
|
||
import subprocess
|
||
import tempfile
|
||
import unittest
|
||
import urllib.parse
|
||
|
||
|
||
SOURCE = Path(__file__).resolve().parents[1]
|
||
TRIAGE_COMMANDS = ("start", "triage", "triage-decide")
|
||
ROUTING_LINE = ("路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;"
|
||
"每次尝试一行,原始日志留在源码外收据路径。")
|
||
LITE_SECTIONS = ["## 目标与非目标", "## Agent 实施收据", "## 同步记录", "## 证据(Evidence)",
|
||
"## 结果与验证(完成后填写)", "## 合入与清理"]
|
||
|
||
|
||
class FakeGitea:
|
||
"""Gitea pulls API 桩(进程内 127.0.0.1):校验 token 请求头,按页返回 self.prs;从不联网。"""
|
||
|
||
PAGE = 50
|
||
|
||
def __init__(self, token="dummy"):
|
||
self.token = token
|
||
self.prs = []
|
||
self.posted = []
|
||
stub = self
|
||
|
||
class Handler(http.server.BaseHTTPRequestHandler):
|
||
def log_message(self, *args):
|
||
pass
|
||
|
||
def _send(self, status, obj):
|
||
body = json.dumps(obj).encode()
|
||
self.send_response(status)
|
||
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):
|
||
if self.headers.get("Authorization") != f"token {stub.token}":
|
||
return self._send(401, {"message": "unauthorized"})
|
||
parsed = urllib.parse.urlsplit(self.path)
|
||
if not parsed.path.startswith("/api/v1/repos/") or not parsed.path.endswith("/pulls"):
|
||
return self._send(404, {"message": "not found"})
|
||
page = int(urllib.parse.parse_qs(parsed.query).get("page", ["1"])[0])
|
||
start = (page - 1) * stub.PAGE
|
||
return self._send(200, stub.prs[start:start + stub.PAGE])
|
||
|
||
def do_POST(self):
|
||
if self.headers.get("Authorization") != f"token {stub.token}":
|
||
return self._send(401, {"message": "unauthorized"})
|
||
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))) or b"{}")
|
||
stub.posted.append(body)
|
||
return self._send(201, {"html_url": f"{stub.web}/fixture/repo/pulls/{len(stub.posted)}"})
|
||
|
||
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||
self.web = f"http://127.0.0.1:{self.server.server_address[1]}"
|
||
self.url = f"{self.web}/api/v1"
|
||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||
|
||
def close(self):
|
||
self.server.shutdown()
|
||
self.server.server_close()
|
||
|
||
@staticmethod
|
||
def pr(number, branch, sha, base="main", state="open"):
|
||
return {"number": number, "state": state, "base": {"ref": base},
|
||
"head": {"ref": branch, "sha": sha}}
|
||
|
||
|
||
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.gitea = FakeGitea()
|
||
self.addCleanup(self.gitea.close)
|
||
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",
|
||
# task-start 自动 precheck 收据落在 TMPDIR:限定在夹具目录内随测试清理。
|
||
"TMPDIR": str(self.root),
|
||
"AGENT_EVIDENCE_DIR": str(self.root / "evidence"),
|
||
# 桩位于 127.0.0.1:禁止经代理转发,保证测试不触达外部网络。
|
||
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"})
|
||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL",
|
||
"GITEA_REPO", "TRIAGE_RECEIPT", "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"}
|
||
if command in TRIAGE_COMMANDS:
|
||
# 分流只读远端:注入桩地址与测试专用 token,绝不读取真实 ~/.secrets。
|
||
kwargs["env"] = self.gitea_env() | kwargs.get("env", self.env)
|
||
return self.run_cmd(cwd, "bash", str(self.repo / "scripts/task.sh"), command, *args, **kwargs)
|
||
|
||
def gitea_env(self):
|
||
return {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo", "GITEA_TOKEN": self.gitea.token}
|
||
|
||
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_recycles_submodule_when_index_listing_exceeds_pipe_buffer(self):
|
||
self.add_submodule()
|
||
# Entries sorting after the gitlink must outlast a 64 KiB pipe once an early-exit reader stops.
|
||
bulk = self.repo / "zz-bulk"
|
||
bulk.mkdir()
|
||
for i in range(2000):
|
||
(bulk / f"entry-{i:05d}-{'p' * 40}").write_text("")
|
||
self.git(self.repo, "add", "zz-bulk")
|
||
self.git(self.repo, "commit", "-m", "Add bulk entries")
|
||
self.git(self.repo, "push", "origin", "main")
|
||
wt = self.worktree()
|
||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||
listing = self.git(wt, "ls-files", "--stage").stdout
|
||
before_gitlink, after_gitlink = listing.split("\tcomponent\n", 1)
|
||
self.assertTrue(before_gitlink.rsplit("\n", 1)[-1].startswith("160000 "), listing[:200])
|
||
self.assertGreater(len(listing.encode()), 64 * 1024)
|
||
self.assertGreater(len(after_gitlink.encode()), 2 * 64 * 1024)
|
||
result = self.task(wt, "finish")
|
||
self.assertFalse(wt.exists(), result.stdout)
|
||
|
||
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)
|
||
# The card holds a summary; rewrite its full receipt back into the card as a legacy block.
|
||
receipt = json.loads(Path(json.loads(record)["receipt_path"]).read_text())
|
||
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 triage_receipt(self, join=None, reason="fixture decision"):
|
||
"""经真实 task.sh triage 生成分流收据(对 Gitea 桩)。候选存在时按 join(分支名)或 NEW 显式决策。"""
|
||
receipt = self.root / f"triage-{len(list(self.root.glob('triage*.json')))}.json"
|
||
result = self.task(self.repo, "triage", "fixture requirement", check=False,
|
||
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
|
||
if result.returncode == 5:
|
||
decide_env = self.env | {"TRIAGE_RECEIPT": str(receipt), "REASON": reason,
|
||
"VERDICT": "JOIN" if join else "NEW"}
|
||
if join:
|
||
decide_env["INITIATIVE"] = join
|
||
self.task(self.repo, "triage-decide", env=decide_env)
|
||
else:
|
||
self.assertEqual(result.returncode, 0, result.stdout)
|
||
return receipt
|
||
|
||
def publish_initiative(self, branch):
|
||
"""把 initiative 分支推到 origin 并登记 open PR(模拟远端接纳中的大任务)。"""
|
||
self.git(self.repo, "push", "origin", f"refs/heads/{branch}:refs/heads/{branch}")
|
||
sha = self.git(self.repo, "rev-parse", f"refs/heads/{branch}").stdout.strip()
|
||
known = [pr for pr in self.gitea.prs if pr["head"]["ref"] == branch]
|
||
if known:
|
||
known[0]["head"]["sha"] = sha
|
||
else:
|
||
self.gitea.prs.append(FakeGitea.pr(len(self.gitea.prs) + 1, branch, sha))
|
||
|
||
def test_start_without_precheck_receipt_runs_and_imports_auto_precheck(self):
|
||
triage = self.triage_receipt()
|
||
result = self.task(self.repo, "start", "feature/new",
|
||
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
|
||
line = next(row for row in result.stdout.splitlines() if row.startswith("[precheck] auto receipt="))
|
||
receipt = Path(line.split("=", 1)[1])
|
||
self.assertEqual(receipt.parent, self.root)
|
||
self.assertIn("feature-new", receipt.name)
|
||
data = json.loads(receipt.read_text())
|
||
self.assertEqual(data["command"], "make precheck")
|
||
self.assertIn("[verdict] READY", data["output"])
|
||
card = (self.root / "repo-wt-feature-new/.task/feature-new.md").read_text()
|
||
self.assertIn(data["receipt_sha256"], card)
|
||
|
||
def test_start_refuses_busy_auto_precheck_without_side_effects(self):
|
||
triage = self.triage_receipt()
|
||
(self.repo / "stray").write_text("unfinished\n")
|
||
result = self.task(self.repo, "start", "feature/new", check=False,
|
||
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("SHARED_WORKSPACE_BUSY", result.stdout)
|
||
self.assertIn("auto precheck is not READY", result.stdout)
|
||
self.assertFalse((self.root / "repo-wt-feature-new").exists(), result.stdout)
|
||
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/new").stdout.strip(), "")
|
||
|
||
def test_start_preserves_merged_local_and_remote_branch_names(self):
|
||
receipt = self.capture_precheck()
|
||
triage = self.triage_receipt()
|
||
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),
|
||
"TRIAGE_RECEIPT": str(triage)})
|
||
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()
|
||
triage = self.triage_receipt()
|
||
self.task(self.repo, "start", "feature/new",
|
||
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
|
||
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("- 需求分流:verdict=NEW;selected_initiative=无;requirement_sha256=", card)
|
||
self.assertIn(f"triage_receipt={triage.resolve()};receipt_sha256=", card)
|
||
branch_line = card.index("- 分支类型:standalone")
|
||
self.assertEqual(card.index("- 需求分流:"), card.index("\n", branch_line) + 1)
|
||
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(ROUTING_LINE, agent_receipts)
|
||
self.assertNotIn("Cursor、Antigravity(agy)和 PI", agent_receipts)
|
||
self.assertNotIn("## 子任务登记", card)
|
||
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)
|
||
triage = self.triage_receipt()
|
||
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
|
||
"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):
|
||
triage = self.triage_receipt()
|
||
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
|
||
result = self.task(self.repo, "start", "feature/offline", check=False,
|
||
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
|
||
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()
|
||
triage = self.triage_receipt()
|
||
self.advance_remote()
|
||
result = self.task(self.repo, "start", "feature/stale", check=False,
|
||
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
|
||
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()
|
||
triage = self.triage_receipt()
|
||
self.task(self.repo, "start", name,
|
||
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
|
||
"TASK_BRANCH_KIND": "initiative"})
|
||
return self.root / ("repo-wt-" + name.replace("/", "-"))
|
||
|
||
def start_child(self, name="feature/alpha-login", initiative="feature/alpha", check=True):
|
||
# 远端须已登记该 initiative(PR+卡片),子任务才能经真实 JOIN 收据启动。
|
||
if initiative:
|
||
self.publish_initiative(initiative)
|
||
triage = self.triage_receipt(join=initiative)
|
||
else:
|
||
triage = self.root / "missing-triage.json"
|
||
receipt = self.capture_precheck()
|
||
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
|
||
"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_without_receipt_is_refused(self):
|
||
result = self.task(self.repo, "triage", "add login", check=False)
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("TRIAGE_RECEIPT required", result.stdout)
|
||
|
||
def test_triage_ignores_local_cards_and_records_new_without_remote_initiative(self):
|
||
# main 上的本地卡片不是权威来源:远端没有 PR/分支时必须是 NEW,而不是按本地计数 JOIN。
|
||
self.write_initiative_card("feature/local-only")
|
||
receipt = self.triage_receipt()
|
||
data = json.loads(receipt.read_text())
|
||
self.assertEqual((data["discovery_verdict"], data["final_verdict"]), ("NEW", "NEW"))
|
||
self.assertEqual(data["candidates"], [])
|
||
self.assertIsNone(data["decision"]["selected_initiative"])
|
||
|
||
def test_remote_initiative_needs_explicit_decision_and_never_auto_joins(self):
|
||
self.start_initiative("feature/alpha")
|
||
self.publish_initiative("feature/alpha")
|
||
receipt = self.root / "single-candidate.json"
|
||
result = self.task(self.repo, "triage", "add login", check=False,
|
||
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
|
||
self.assertEqual(result.returncode, 5, result.stdout)
|
||
self.assertIn("[candidate] branch=feature/alpha", result.stdout)
|
||
self.assertIn("[verdict] DECISION_REQUIRED", result.stdout)
|
||
self.assertNotIn("[verdict] JOIN", result.stdout)
|
||
data = json.loads(receipt.read_text())
|
||
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
|
||
self.assertIsNone(data["decision"])
|
||
|
||
def test_multiple_remote_candidates_stop_at_decision_required(self):
|
||
self.start_initiative("feature/alpha")
|
||
self.publish_initiative("feature/alpha")
|
||
self.start_initiative("feature/beta")
|
||
self.publish_initiative("feature/beta")
|
||
receipt = self.root / "multi-candidate.json"
|
||
result = self.task(self.repo, "triage", "add login", check=False,
|
||
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
|
||
self.assertEqual(result.returncode, 5, result.stdout)
|
||
self.assertIn("[candidate] branch=feature/alpha", result.stdout)
|
||
self.assertIn("[candidate] branch=feature/beta", result.stdout)
|
||
self.assertIn("[verdict] DECISION_REQUIRED", result.stdout)
|
||
self.assertNotIn("[verdict] JOIN", result.stdout)
|
||
self.assertEqual(json.loads(receipt.read_text())["final_verdict"], "DECISION_REQUIRED")
|
||
|
||
def test_precheck_reports_active_initiatives(self):
|
||
result = self.task(self.repo, "precheck", check=False)
|
||
self.assertIn("[initiatives] local cards (not authoritative): 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] local cards (not authoritative): 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()
|
||
triage = self.triage_receipt()
|
||
self.task(self.repo, "start", "feature/solo",
|
||
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
|
||
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")
|
||
self.assertIn("- 需求分流:verdict=JOIN;selected_initiative=feature/alpha;", text)
|
||
self.assert_lite_card(text, "feature/alpha-login")
|
||
|
||
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 register_child(self, parent, child, initiative="feature/alpha", publish=True):
|
||
card = parent / ".task" / (initiative.replace("/", "-") + ".md")
|
||
text = card.read_text()
|
||
self.assertIn("| 待登记 | 否 | — | — |", text)
|
||
card.write_text(text.replace("| 待登记 | 否 | — | — |", f"| `{child}` | 是 | 进行中 | — |", 1))
|
||
self.git(parent, "add", ".task")
|
||
self.git(parent, "commit", "-m", f"Register {child}")
|
||
if publish:
|
||
self.publish_initiative(initiative)
|
||
return self.git(parent, "rev-parse", "HEAD").stdout.strip()
|
||
|
||
def planned_child_env(self):
|
||
return self.env | {"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
|
||
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
|
||
|
||
def assert_lite_card(self, text, name):
|
||
self.assertEqual([line for line in text.splitlines() if line.startswith("## ")], LITE_SECTIONS)
|
||
self.assertEqual(text.splitlines()[0], f"# {name} 任务卡")
|
||
self.assertRegex(text, r"(?m)^任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:.+$")
|
||
self.assertRegex(text, r"(?m)^- 权威仓:.+;目标分支:main;R0(initial_base_sha)= [0-9a-f]{40}$")
|
||
self.assertRegex(text, rf"(?m)^- 工作区:.+(worktree);分支:{re.escape(name)}$")
|
||
for prefix in ("- 分支类型:", "- 需求分流:", "- 授权与交付终点:",
|
||
"- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)",
|
||
"- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)",
|
||
"- 合入执行:agent(",
|
||
"- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)"):
|
||
self.assertRegex(text, "(?m)^" + re.escape(prefix))
|
||
self.assertIn(ROUTING_LINE, text)
|
||
self.assertIn("| 待定义 | — | NOT_RUN | NOT_RUN |", text)
|
||
self.assertRegex(text, r"(?m)^\| 启动 \| [0-9a-f]{40} \| [0-9a-f]{40} \| ")
|
||
self.assertIn("| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |", text)
|
||
for field in ("Review 记录", "冲突记录", "CI 记录", "合入记录", "清理记录"):
|
||
self.assertIn(field + ":NOT_RUN", text)
|
||
self.assertNotIn("## 阶段记录", text)
|
||
|
||
def test_start_planned_child_without_triage_when_registered_on_origin(self):
|
||
parent = self.start_initiative()
|
||
parent_head = self.register_child(parent, "feature/alpha-login")
|
||
result = self.task(self.repo, "start", "feature/alpha-login", env=self.planned_child_env())
|
||
self.assertIn("[precheck] auto receipt=", result.stdout)
|
||
wt = self.root / "repo-wt-feature-alpha-login"
|
||
card = wt / ".task/feature-alpha-login.md"
|
||
text = card.read_text()
|
||
self.assertIn("- 需求分流:免(父 initiative 子任务登记已列出 feature/alpha-login;"
|
||
f"parent_head_sha={parent_head})", text)
|
||
self.assertIn(f"parent_head_sha:{parent_head};parent_pr:https://example.invalid/repo/pulls/7;"
|
||
"parent_source:origin", text)
|
||
self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD")
|
||
self.assert_lite_card(text, "feature/alpha-login")
|
||
self.assertEqual(self.card_helper("card_immediate_target", card), "feature/alpha")
|
||
|
||
def test_start_planned_child_refuses_unregistered_child_without_side_effects(self):
|
||
parent = self.start_initiative()
|
||
self.publish_initiative("feature/alpha")
|
||
for case in ("placeholder-only", "registered-locally-only"):
|
||
with self.subTest(case=case):
|
||
if case == "registered-locally-only":
|
||
self.register_child(parent, "feature/alpha-login", publish=False)
|
||
result = self.task(self.repo, "start", "feature/alpha-login", check=False,
|
||
env=self.planned_child_env())
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("not registered in feature/alpha", result.stdout)
|
||
self.assertIn("provide TRIAGE_RECEIPT", result.stdout)
|
||
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
|
||
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "")
|
||
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin",
|
||
"feature/alpha-login").stdout.strip(), "")
|
||
|
||
def test_start_initiative_card_is_charter_with_child_registry(self):
|
||
wt = self.start_initiative()
|
||
card = wt / ".task/feature-alpha.md"
|
||
text = card.read_text()
|
||
goal = text.split("## 目标与非目标\n", 1)[1].split("\n## ", 1)[0]
|
||
for item in ("用途与目标", "接纳范围与非目标", "方案/计划链接", "分级测试策略", "验收条件",
|
||
"追加领域规则", "生命周期"):
|
||
self.assertIn(item, goal)
|
||
sections = [line for line in text.splitlines() if line.startswith("## ")]
|
||
self.assertEqual(sections[sections.index("## 目标与非目标") + 1], "## 子任务登记")
|
||
self.assertIn("## 阶段记录", sections)
|
||
self.assertIn(ROUTING_LINE, text)
|
||
rows = self.run_cmd(self.repo, "python3", "-c",
|
||
"import json, sys; sys.path.insert(0, 'scripts');"
|
||
"from task_pr_check import child_registry;"
|
||
"print(json.dumps(child_registry(open(sys.argv[1]).read()), ensure_ascii=False))",
|
||
str(card)).stdout
|
||
self.assertEqual(json.loads(rows), [{"子任务分支": "待登记", "必需": "否", "状态": "—", "PR": "—"}])
|
||
|
||
def test_start_fix_and_hotfix_cards_are_lite(self):
|
||
triage = self.triage_receipt()
|
||
for name in ("fix/typo", "hotfix/urgent"):
|
||
with self.subTest(name=name):
|
||
self.task(self.repo, "start", name, env=self.env | {"TRIAGE_RECEIPT": str(triage)})
|
||
slug = name.replace("/", "-")
|
||
text = (self.root / f"repo-wt-{slug}/.task/{slug}.md").read_text()
|
||
self.assert_lite_card(text, name)
|
||
self.assertIn("- 分支类型:standalone;root_target:main;", text)
|
||
self.assertIn("- 需求分流:verdict=NEW;", text)
|
||
|
||
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")
|
||
self.publish_initiative("feature/alpha") # delivery 只认远端父分支头
|
||
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")
|
||
self.publish_initiative("feature/alpha") # delivery 的 finish 只认远端父分支已含子分支
|
||
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")
|
||
receipt = self.triage_receipt()
|
||
self.assertEqual(json.loads(receipt.read_text())["final_verdict"], "NEW")
|
||
|
||
def test_start_without_triage_receipt_creates_no_worktree_or_branch(self):
|
||
receipt = self.capture_precheck()
|
||
for kind in ("standalone", "initiative"):
|
||
with self.subTest(kind=kind):
|
||
result = self.task(self.repo, "start", "feature/untriaged", check=False,
|
||
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": kind})
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("TRIAGE_RECEIPT required", result.stdout)
|
||
self.assertFalse((self.root / "repo-wt-feature-untriaged").exists())
|
||
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/untriaged").stdout.strip(), "")
|
||
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin",
|
||
"feature/untriaged").stdout.strip(), "")
|
||
|
||
def test_start_child_refuses_non_join_receipt_without_side_effects(self):
|
||
self.start_initiative("feature/alpha")
|
||
self.publish_initiative("feature/alpha")
|
||
new_receipt = self.triage_receipt() # 远端存在候选,显式 NEW 决策(非适配)
|
||
receipt = self.capture_precheck()
|
||
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(new_receipt),
|
||
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
|
||
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
|
||
result = self.task(self.repo, "start", "feature/alpha-login", check=False, env=env)
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("triage receipt rejected", result.stdout)
|
||
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
|
||
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "")
|
||
|
||
def test_start_refuses_receipt_when_remote_initiative_moved_after_triage(self):
|
||
self.start_initiative("feature/alpha")
|
||
self.publish_initiative("feature/alpha")
|
||
join = self.triage_receipt(join="feature/alpha")
|
||
parent = self.root / "repo-wt-feature-alpha"
|
||
(parent / "moved").write_text("later initiative work\n")
|
||
self.git(parent, "add", "moved")
|
||
self.git(parent, "commit", "-m", "Move initiative after triage")
|
||
self.publish_initiative("feature/alpha")
|
||
receipt = self.capture_precheck()
|
||
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(join),
|
||
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
|
||
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
|
||
result = self.task(self.repo, "start", "feature/alpha-login", check=False, env=env)
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("remote initiative candidates changed", result.stdout)
|
||
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
|
||
|
||
def close_env(self):
|
||
# 测试 token 与本地 Gitea 桩:建 PR 只能落到桩上,绝不触达真实 Gitea。
|
||
return self.env | {"GITEA_TOKEN": self.gitea.token, "TASK_CARD_WEB_BASE": self.gitea.web}
|
||
|
||
def child_env(self, receipt, triage):
|
||
return self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
|
||
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
|
||
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
|
||
|
||
def test_delivery_child_ignores_ahead_local_parent(self):
|
||
parent = self.start_initiative()
|
||
self.publish_initiative("feature/alpha")
|
||
remote_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
|
||
(parent / "unadmitted").write_text("local only\n")
|
||
self.git(parent, "add", "unadmitted")
|
||
self.git(parent, "commit", "-m", "Unpublished initiative work")
|
||
triage = self.triage_receipt(join="feature/alpha")
|
||
receipt = self.capture_precheck()
|
||
self.task(self.repo, "start", "feature/alpha-login", env=self.child_env(receipt, triage))
|
||
wt = self.root / "repo-wt-feature-alpha-login"
|
||
card = (wt / ".task/feature-alpha-login.md").read_text()
|
||
self.assertIn(f"parent_head_sha:{remote_head}", card)
|
||
self.assertIn("parent_source:origin", card)
|
||
self.assertFalse((wt / "unadmitted").exists())
|
||
|
||
def test_delivery_child_ignores_stale_local_parent(self):
|
||
self.start_initiative()
|
||
self.publish_initiative("feature/alpha")
|
||
other = self.root / "other"
|
||
self.git(self.root, "clone", "-q", str(self.remote), str(other))
|
||
self.git(other, "checkout", "-q", "feature/alpha")
|
||
(other / "remote-admitted").write_text("admitted\n")
|
||
self.git(other, "add", "remote-admitted")
|
||
self.git(other, "commit", "-m", "Admitted initiative work")
|
||
self.git(other, "push", "-q", "origin", "feature/alpha")
|
||
remote_head = self.git(other, "rev-parse", "HEAD").stdout.strip()
|
||
triage = self.triage_receipt(join="feature/alpha")
|
||
receipt = self.capture_precheck()
|
||
self.task(self.repo, "start", "feature/alpha-login", env=self.child_env(receipt, triage))
|
||
wt = self.root / "repo-wt-feature-alpha-login"
|
||
self.assertIn(f"parent_head_sha:{remote_head}", (wt / ".task/feature-alpha-login.md").read_text())
|
||
self.assertEqual((wt / "remote-admitted").read_text(), "admitted\n")
|
||
|
||
def test_delivery_child_fails_when_remote_parent_is_deleted_after_triage(self):
|
||
self.start_initiative()
|
||
self.publish_initiative("feature/alpha")
|
||
triage = self.triage_receipt(join="feature/alpha")
|
||
self.git(self.repo, "push", "origin", "--delete", "feature/alpha")
|
||
receipt = self.capture_precheck()
|
||
result = self.task(self.repo, "start", "feature/alpha-login", check=False,
|
||
env=self.child_env(receipt, triage))
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
|
||
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "")
|
||
|
||
def test_local_mode_child_uses_local_parent_and_marks_local_source(self):
|
||
parent = self.start_initiative()
|
||
self.publish_initiative("feature/alpha")
|
||
(parent / "local-admission").write_text("local only\n")
|
||
self.git(parent, "add", "local-admission")
|
||
self.git(parent, "commit", "-m", "Local initiative work")
|
||
local_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
|
||
triage = self.triage_receipt(join="feature/alpha")
|
||
receipt = self.capture_precheck()
|
||
self.task(self.repo, "start", "feature/alpha-login",
|
||
env=self.child_env(receipt, triage) | {"TASK_WORK_MODE": "local"})
|
||
wt = self.root / "repo-wt-feature-alpha-login"
|
||
card = (wt / ".task/feature-alpha-login.md").read_text()
|
||
self.assertIn(f"parent_head_sha:{local_head}", card)
|
||
self.assertIn("parent_source:local(非正式准入)", card)
|
||
self.assertEqual((wt / "local-admission").read_text(), "local only\n")
|
||
|
||
def test_resume_child_reports_parent_as_immediate_target(self):
|
||
parent = self.start_initiative()
|
||
wt, _ = self.start_child()
|
||
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
|
||
receipt = self.capture_precheck(cwd=wt)
|
||
result = self.task(wt, "resume", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
|
||
self.assertIn(f"observed_target={parent_head} immediate_target=feature/alpha", result.stdout)
|
||
|
||
def test_resume_child_rejects_rewound_immediate_target(self):
|
||
parent = self.start_initiative()
|
||
wt, _ = self.start_child()
|
||
receipt = self.capture_precheck(cwd=wt)
|
||
self.git(parent, "reset", "--hard", "origin/main")
|
||
self.git(parent, "push", "-f", "origin", "feature/alpha")
|
||
result = self.task(wt, "resume", check=False, env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("no longer contains recorded parent head", result.stdout)
|
||
self.assertNotIn("[resumed]", result.stdout)
|
||
|
||
def test_close_archives_child_merged_into_initiative_and_names_it(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")
|
||
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m",
|
||
"Merge pull request 'feature/alpha-login' (#5) from feature/alpha-login into feature/alpha")
|
||
self.publish_initiative("feature/alpha")
|
||
closed = self.task(parent, "close", "--fold", env=self.close_env())
|
||
self.assertIn("[closed] 1 张已合入卡片", closed.stdout)
|
||
self.assertEqual(self.gitea.posted[-1]["head"], "feature/alpha")
|
||
archived = self.git(parent, "show", "HEAD:.task/archive/feature-alpha-login.md").stdout
|
||
self.assertIn("已包含于 origin/feature/alpha", archived)
|
||
self.assertIn("状态:已完成(回填)", archived)
|
||
self.assertIn("PR #5", archived)
|
||
self.assertFalse((parent / ".task/feature-alpha-login.md").exists())
|
||
|
||
def test_close_child_still_closable_after_initiative_merged_and_deleted(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")
|
||
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m",
|
||
"Merge pull request 'feature/alpha-login' (#5) from feature/alpha-login into feature/alpha")
|
||
self.publish_initiative("feature/alpha")
|
||
self.git(self.repo, "fetch", "-q", "origin")
|
||
self.git(self.repo, "merge", "--no-ff", "origin/feature/alpha", "-m",
|
||
"Merge pull request 'feature/alpha' (#3) from feature/alpha into main")
|
||
self.git(self.repo, "push", "origin", "main")
|
||
self.git(self.repo, "push", "origin", "--delete", "feature/alpha")
|
||
closed = self.task(self.repo, "close", env=self.close_env())
|
||
self.assertIn("[closed] 2 张已合入卡片", closed.stdout)
|
||
self.assertIn("[pr] " + self.gitea.web, closed.stdout)
|
||
self.assertEqual(self.gitea.posted[-1]["base"], "main")
|
||
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-alpha-login.md").stdout
|
||
self.assertIn("已包含于 origin/main", archived)
|
||
self.assertIn("PR #5", archived)
|
||
|
||
# ---- close --self:合入前在任务分支内闭合本卡 ----
|
||
|
||
def self_close_env(self):
|
||
return self.close_env() | {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo"}
|
||
|
||
def start_standalone(self, name="feature/solo"):
|
||
receipt = self.capture_precheck()
|
||
triage = self.triage_receipt()
|
||
self.task(self.repo, "start", name,
|
||
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
|
||
return self.root / ("repo-wt-" + name.replace("/", "-"))
|
||
|
||
def open_pr(self, wt, branch, base="main"):
|
||
self.git(wt, "push", "-q", "-u", "origin", branch)
|
||
number = len(self.gitea.prs) + 1
|
||
sha = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||
self.gitea.prs.append(FakeGitea.pr(number, branch, sha, base=base))
|
||
return number
|
||
|
||
def test_close_self_archives_own_card_inside_its_pr(self):
|
||
wt = self.start_standalone()
|
||
(wt / "work").write_text("task result\n")
|
||
self.git(wt, "add", "work")
|
||
self.git(wt, "commit", "-m", "Task work")
|
||
number = self.open_pr(wt, "feature/solo")
|
||
result = self.task(wt, "close", "--self", env=self.self_close_env())
|
||
self.assertIn(f"[closed] .task/feature-solo.md 已在 PR #{number} 合入前闭合并归档;合入后无需 task-close",
|
||
result.stdout)
|
||
self.assertNotIn(self.gitea.token, result.stdout)
|
||
self.assertFalse((wt / ".task/feature-solo.md").exists())
|
||
text = (wt / ".task/archive/feature-solo.md").read_text()
|
||
self.assertIn(";状态:已完成(合入前闭合);", text)
|
||
self.assertIn(f"任务主记录:PR #{number} {self.gitea.web}/{self.root.name}/remote/pulls/{number};", text)
|
||
self.assertIn("## 结果与验证\n", text)
|
||
self.assertNotIn("## 结果与验证(完成后填写)", text)
|
||
self.assertIn("## 闭合记录(合入前折叠)", text)
|
||
self.assertIn(f"PR #{number}", text.split("## 闭合记录(合入前折叠)", 1)[1])
|
||
self.assertEqual(self.git(wt, "log", "-1", "--format=%s").stdout.strip(),
|
||
"Fold-close the task card before merge")
|
||
body = self.git(wt, "log", "-1", "--format=%B").stdout
|
||
for field in ("Why:", "Evidence:", "Impact:", "Refs: task.sh close --self"):
|
||
self.assertIn(field, body)
|
||
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
|
||
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||
self.assertEqual(self.git(self.remote, "rev-parse", "refs/heads/feature/solo").stdout.strip(), head)
|
||
self.assertEqual(self.git(self.remote, "show", "feature/solo:.task/archive/feature-solo.md").stdout, text)
|
||
again = self.task(wt, "close", "--self", check=False, env=self.self_close_env())
|
||
self.assertNotEqual(again.returncode, 0, again.stdout)
|
||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||
|
||
def test_close_self_accepts_explicit_pr_number_without_lookup(self):
|
||
wt = self.start_standalone()
|
||
env = self.close_env() | {"TASK_PR": "42", "GITEA_API_URL": "http://127.0.0.1:9/api/v1"}
|
||
result = self.task(wt, "close", "--self", env=env)
|
||
self.assertIn("PR #42 合入前闭合", result.stdout)
|
||
self.assertIn("任务主记录:PR #42 ", (wt / ".task/archive/feature-solo.md").read_text())
|
||
|
||
def test_close_self_refuses_main_detached_dirty_and_missing_pr(self):
|
||
main_head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
|
||
on_main = self.task(self.repo, "close", "--self", check=False, env=self.self_close_env())
|
||
self.assertNotEqual(on_main.returncode, 0, on_main.stdout)
|
||
self.assertIn("不能在 main 上运行", on_main.stdout)
|
||
self.assertEqual(self.git(self.repo, "rev-parse", "HEAD").stdout.strip(), main_head)
|
||
wt = self.start_standalone()
|
||
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||
card = wt / ".task/feature-solo.md"
|
||
original = card.read_text()
|
||
|
||
def assert_refused(result, reason):
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn(reason, result.stdout)
|
||
self.assertNotIn(self.gitea.token, result.stdout)
|
||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||
self.assertEqual(card.read_text(), original)
|
||
self.assertFalse((wt / ".task/archive").exists())
|
||
|
||
(wt / "stray").write_text("uncommitted\n")
|
||
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()), "不干净")
|
||
(wt / "stray").unlink()
|
||
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()),
|
||
"需要恰好一个 head 为 feature/solo 的开放 PR")
|
||
assert_refused(self.task(wt, "close", "--self", check=False,
|
||
env=self.self_close_env() | {"TASK_PR": "7; rm"}), "TASK_PR")
|
||
self.git(wt, "checkout", "-q", "--detach")
|
||
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()), "detached HEAD")
|
||
|
||
def test_close_self_child_keeps_initiative_target_from_archive(self):
|
||
parent = self.start_initiative()
|
||
wt, _ = self.start_child()
|
||
number = self.open_pr(wt, "feature/alpha-login", base="feature/alpha")
|
||
self.task(wt, "close", "--self", env=self.self_close_env())
|
||
archived = wt / ".task/archive/feature-alpha-login.md"
|
||
self.assertIn("immediate_target:feature/alpha", archived.read_text())
|
||
self.assertIn(f"PR #{number}", archived.read_text())
|
||
(parent / "parent-later").write_text("later initiative work\n")
|
||
self.git(parent, "add", "parent-later")
|
||
self.git(parent, "commit", "-m", "Later initiative work")
|
||
self.publish_initiative("feature/alpha")
|
||
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
|
||
synced = self.task(wt, "sync")
|
||
self.assertIn("sync_target=feature/alpha", synced.stdout)
|
||
self.assertIn(f"sync_base={parent_head}", synced.stdout)
|
||
self.assertIn(f"| 同步(记录前对象) | {parent_head} |", archived.read_text())
|
||
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative")
|
||
self.publish_initiative("feature/alpha")
|
||
finished = self.task(wt, "finish")
|
||
self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha",
|
||
finished.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",
|
||
"AGENT_EVIDENCE_DIR": self.root + "/evidence",
|
||
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"})
|
||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL",
|
||
"GITEA_REPO", "TRIAGE_RECEIPT", "PRECHECK_RECEIPT"):
|
||
self.env.pop(key, None)
|
||
self.gitea = FakeGitea()
|
||
self.addCleanup(self.gitea.close)
|
||
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)
|
||
triage = Path(self.root) / "triage.json"
|
||
triage_env = self.env | {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo",
|
||
"GITEA_TOKEN": self.gitea.token}
|
||
self.task(self.repo, "triage", "guard fixture", env=triage_env | {"TRIAGE_RECEIPT": str(triage)})
|
||
self.task(self.repo, "start", "feature/guard",
|
||
env=triage_env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
|
||
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)
|