Ctrl K
ComunidadGuías…

tests/test_agent_worktree.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager, redirect_stdout
from pathlib import Path
from threading import Event
from unittest import mock

from scripts import agent_worktree, check_public_text, release_tag
from scripts.agent_worktree import (
    WorkspaceError, _git, _lines, create, integrate, processes_under, prune, ready,
)
from scripts.version_bump import read_version
from support.gitrepo import seed_repository

#: 仓库里版本化的 git hook,测试原样装进临时仓库。
HOOKS = Path(__file__).resolve().parents[1] / agent_worktree.HOOKS_PATH
AUTHOR = "Co-Authored-By: Codex (GPT-6) <noreply@openai.com>"


def install_commit_hooks(repo: Path) -> None:
    hooks = repo / agent_worktree.HOOKS_PATH
    hooks.mkdir(parents=True)
    sources = list(HOOKS.iterdir())
    sources += [HOOKS.parent / name for name in
                ("check_commit_message.py", "check_readme_impact.py", "co_author.py")
                if (HOOKS.parent / name).is_file()]
    for source in sources:
        relative = source.relative_to(HOOKS.parent.parent)
        target = repo / relative
        shutil.copyfile(source, target)
        target.chmod(0o755)
        _git(repo, "add", relative.as_posix())
    _git(repo, "commit", "-m", "hooks")

#: 版本号的唯一来源,`src/peach/__init__.py` 在测试仓库里的最小复刻。
VERSION_SEED = b'"""Peach application package."""\n\n__version__ = "0.7.14"\n'


class StuckRemoval:
    """复刻 2026-09-01 Windows 上的实测:目录句柄被别的进程占着,
    `git worktree remove` 报 Permission denied。

    `residue`:git 已经把文件删光、注册也摘掉了,只剩一个空目录删不掉。
    `stuck`:注册还在,工作树整个没摘掉。
    """

    def __init__(self, real, modes: dict[Path, str]):
        self.real = real
        self.modes = {path.resolve(): mode for path, mode in modes.items()}

    def __call__(self, repo, *args, **kwargs):
        mode = None
        if args[:2] == ("worktree", "remove"):
            mode = self.modes.get(Path(args[-1]).resolve())
        if mode is None:
            return self.real(repo, *args, **kwargs)
        path = Path(args[-1])
        if mode == "residue":
            self.real(repo, *args, check=False)
            path.mkdir(parents=True, exist_ok=True)
        return subprocess.CompletedProcess(
            list(args), 1, "", f"fatal: failed to delete '{path}': Permission denied")


def commit(repo: Path, message: str) -> None:
    _git(repo, "add", "tracked.txt", "worker.txt")
    _git(repo, "commit", "-m", message)


class _WorktreeCase(unittest.TestCase):
    mock_pull_request = True

    def setUp(self):
        self.verification = mock.patch.object(agent_worktree, "require_verified")
        self.verification.start()
        self.addCleanup(self.verification.stop)
        if self.mock_pull_request:
            pull_request = mock.patch.object(agent_worktree, "require_pull_request", side_effect=
                lambda worker, branch, target, head: {"url": "https://github.com/example/peach/pull/1",
                                                      "head": head, "base": target, "refs": []})
            pull_request.start()
            self.addCleanup(pull_request.stop)
        self.tmp = tempfile.TemporaryDirectory()
        # 先 resolve:prune 报告的是 git worktree list 给出的真实路径,而 CI runner 的
        # 临时目录是别名(macOS /var 软链到 /private/var,Windows 的 RUNNER~1 短名展开
        # 成 runneradmin)。拿未 resolve 的路径去比对,本机全绿、CI 全红。
        self.root = Path(self.tmp.name).resolve()
        self.repo = seed_repository(self.root / "repo", {
            ".gitignore": "build/\n",
            "tracked.txt": "base\n",
            "worker.txt": "base\n",
            "src/peach/__init__.py": VERSION_SEED,
        }, "base")

    def tearDown(self):
        self.tmp.cleanup()

class AgentWorktreeTests(_WorktreeCase):
    def test_document_integration_keeps_its_checks_without_waiting_for_full_suite(self):
        result = create(self.repo, "Codex", "readme", self.root / "worktrees")
        worker = Path(result["path"])
        (worker / "README.md").write_text("# Example\n", encoding="utf-8")
        _git(worker, "add", "README.md")
        _git(worker, "commit", "-m", "docs: example")
        folder = agent_worktree.test_evidence.evidence_dir(self.repo)
        with agent_worktree.test_evidence.held(folder / "full-suite.lock", scope="full"):
            report = integrate(self.repo, str(result["branch"]), lock_timeout=0)
        self.assertEqual(report["files"], ["README.md"])
        agent_worktree.require_verified.assert_called_once_with(worker, "master", {"README.md"})
        self.assertEqual((self.repo / "README.md").read_text(encoding="utf-8"), "# Example\n")

    def test_other_document_paths_use_the_same_checks_boundary(self):
        for index, name in enumerate(("CONTRIBUTING.md", ".github/PULL_REQUEST_TEMPLATE.md")):
            with self.subTest(path=name):
                result = create(self.repo, "Codex", f"document-{index}", self.root / "worktrees")
                worker = Path(result["path"])
                (worker / name).parent.mkdir(parents=True, exist_ok=True)
                (worker / name).write_text("# Example\n", encoding="utf-8")
                _git(worker, "add", name)
                _git(worker, "commit", "-m", "docs: example")
                folder = agent_worktree.test_evidence.evidence_dir(self.repo)
                with agent_worktree.test_evidence.held(folder / "full-suite.lock", scope="full"):
                    report = integrate(self.repo, str(result["branch"]), lock_timeout=0)
                self.assertEqual(report["files"], [name])

    def test_runtime_markdown_still_waits_for_full_suite(self):
        result = create(self.repo, "Codex", "runtime-document", self.root / "worktrees")
        worker = Path(result["path"])
        name = "frontend/src/react/boardui/ORIGIN.md"
        (worker / name).parent.mkdir(parents=True)
        (worker / name).write_text("# Origin\n", encoding="utf-8")
        _git(worker, "add", name)
        _git(worker, "commit", "-m", "docs: origin")
        folder = agent_worktree.test_evidence.evidence_dir(self.repo)
        with agent_worktree.test_evidence.held(folder / "full-suite.lock", scope="full"), \
                self.assertRaisesRegex(WorkspaceError, "全量验证"):
            integrate(self.repo, str(result["branch"]), lock_timeout=0)
        self.assertFalse((self.repo / name).exists())

    def test_waiting_code_integration_does_not_block_documents(self):
        """文档先合进去,排队的代码集成拿到锁后 rebase 到新的 master 上再合,不必回工作树重来。"""
        code = create(self.repo, "Codex", "queued-code", self.root / "worktrees")
        worker = Path(code["path"])
        (worker / "worker.txt").write_text("code\n", encoding="utf-8")
        commit(worker, "worker change")
        origin = self.root / "origin.git"
        subprocess.run(["git", "init", "--bare", "-q", str(origin)], check=True)
        _git(self.repo, "remote", "add", "origin", str(origin))
        _git(worker, "push", "-q", "origin", str(code["branch"]))
        for name in ("_verify_worker", "_await_pull_request_head"):
            patcher = mock.patch.object(agent_worktree, name)
            patcher.start()
            self.addCleanup(patcher.stop)
        docs = create(self.repo, "Codex", "parallel-docs", self.root / "worktrees")
        (Path(docs["path"]) / "README.md").write_text("# Example\n", encoding="utf-8")
        _git(Path(docs["path"]), "add", "README.md")
        _git(Path(docs["path"]), "commit", "-m", "docs: example")
        folder = agent_worktree.test_evidence.evidence_dir(self.repo)
        waiting = Event()
        held = agent_worktree.test_evidence.held

        @contextmanager
        def observed(lock, **kwargs):
            if kwargs.get("branch") == code["branch"] and lock.name == "full-suite.lock":
                waiting.set()
            with held(lock, **kwargs):
                yield

        with ThreadPoolExecutor() as executor, mock.patch.object(
                agent_worktree.test_evidence, "held", observed):
            with held(folder / "full-suite.lock", scope="full"):
                future = executor.submit(integrate, self.repo, str(code["branch"]), lock_timeout=120)
                self.assertTrue(waiting.wait(5), "代码集成尚未进入全量锁等待")
                report = integrate(self.repo, str(docs["branch"]), lock_timeout=0)
                self.assertEqual(report["files"], ["README.md"])
                self.assertFalse(future.done())
            queued = future.result(timeout=60)
            self.assertEqual(queued["files"], ["worker.txt"])
            self.assertIsNotNone(queued["refreshed"], "排队期间 master 前进,拿到锁后自动 rebase")
        self.assertEqual((self.repo / "worker.txt").read_text(encoding="utf-8"), "code\n")
        self.assertEqual((self.repo / "README.md").read_text(encoding="utf-8"), "# Example\n")

    def test_code_integration_waits_for_full_suite_without_changing_master(self):
        result = create(self.repo, "Codex", "code", self.root / "worktrees")
        worker = Path(result["path"])
        (worker / "worker.txt").write_text("worker\n", encoding="utf-8")
        (worker / "README.md").write_text("# Example\n", encoding="utf-8")
        _git(worker, "add", "README.md")
        commit(worker, "worker change")
        before = _git(self.repo, "rev-parse", "HEAD").stdout.strip()
        folder = agent_worktree.test_evidence.evidence_dir(self.repo)
        with agent_worktree.test_evidence.held(folder / "full-suite.lock", scope="full"), \
                self.assertRaisesRegex(WorkspaceError, "全量验证"):
            integrate(self.repo, str(result["branch"]), lock_timeout=0)
        self.assertEqual(_git(self.repo, "rev-parse", "HEAD").stdout.strip(), before)
        self.assertEqual((self.repo / "worker.txt").read_text(encoding="utf-8"), "base\n")
        self.assertFalse((self.repo / "README.md").exists())

    def test_create_ready_and_integrate_non_overlapping_branch(self):
        result = create(self.repo, "Claude", "metadata batch", self.root / "worktrees")
        worker = Path(result["path"])
        (worker / "worker.txt").write_text("worker\n", encoding="utf-8")
        commit(worker, "worker change")
        report = ready(worker)
        self.assertEqual(report["same_file_changes"], [])
        merged = integrate(self.repo, str(result["branch"]))
        self.assertIn("worker.txt", merged["files"])
        self.assertEqual((self.repo / "worker.txt").read_text(encoding="utf-8"), "worker\n")

    def test_ready_rejects_dirty_worker(self):
        result = create(self.repo, "Codex", "dirty task", self.root / "worktrees")
        worker = Path(result["path"])
        (worker / "worker.txt").write_text("dirty\n", encoding="utf-8")
        with self.assertRaisesRegex(WorkspaceError, "dirty"):
            ready(worker)

    def test_integrate_refuses_same_file_changed_on_both_branches(self):
        result = create(self.repo, "Claude", "overlap", self.root / "worktrees")
        worker = Path(result["path"])
        (worker / "tracked.txt").write_text("worker\n", encoding="utf-8")
        commit(worker, "worker overlap")
        (self.repo / "tracked.txt").write_text("main\n", encoding="utf-8")
        commit(self.repo, "main overlap")
        with self.assertRaisesRegex(WorkspaceError, "same-file"):
            integrate(self.repo, str(result["branch"]))
        self.assertEqual(_git(worker, "log", "-1", "--format=%s").stdout.strip(), "worker overlap",
                         "有重叠时不自动 rebase")


class QueuedRebaseTests(_WorktreeCase):
    """排队等锁期间目标分支前进:拿到锁后把工作者分支 rebase 上去、补验、推送,再合入。"""

    def setUp(self):
        super().setUp()
        self.verifier = mock.patch.object(agent_worktree, "_verify_worker")
        self.verify = self.verifier.start()
        self.addCleanup(self.verifier.stop)
        head = mock.patch.object(agent_worktree, "_await_pull_request_head")
        self.head = head.start()
        self.addCleanup(head.stop)
        self.origin = self.root / "origin.git"
        subprocess.run(["git", "init", "--bare", "-q", str(self.origin)], check=True)
        _git(self.repo, "remote", "add", "origin", str(self.origin))

    def worker(self, name: str, path: str, text: str) -> tuple[Path, str]:
        result = create(self.repo, "Claude", name, self.root / "worktrees")
        worker = Path(result["path"])
        (worker / path).parent.mkdir(parents=True, exist_ok=True)
        (worker / path).write_text(text, encoding="utf-8")
        _git(worker, "add", path)
        _git(worker, "commit", "-m", f"fix(tooling): {name}")
        _git(worker, "push", "-q", "origin", str(result["branch"]))
        return worker, str(result["branch"])

    def test_a_target_that_moved_while_queued_is_rebased_verified_and_pushed(self):
        worker, branch = self.worker("queued", "worker.txt", "worker\n")
        before = _git(worker, "rev-parse", "HEAD").stdout.strip()
        (self.repo / "tracked.txt").write_text("main moved\n", encoding="utf-8")
        commit(self.repo, "main moved")
        report = integrate(self.repo, branch)
        after = _git(worker, "rev-parse", "HEAD").stdout.strip()
        self.assertEqual(report["refreshed"], {"from": before, "to": after})
        self.assertNotEqual(before, after)
        self.verify.assert_called_once_with(self.repo, worker, frontend=False)
        self.head.assert_called_once_with(worker, branch, after)
        self.assertEqual(_git(self.origin, "rev-parse", branch).stdout.strip(), after, "PR 分支推到 rebase 后的提交")
        self.assertEqual((self.repo / "worker.txt").read_text(encoding="utf-8"), "worker\n")
        self.assertEqual((self.repo / "tracked.txt").read_text(encoding="utf-8"), "main moved\n")

    def test_a_failed_reverification_merges_nothing(self):
        worker, branch = self.worker("red-after-rebase", "worker.txt", "worker\n")
        (self.repo / "tracked.txt").write_text("main moved\n", encoding="utf-8")
        commit(self.repo, "main moved")
        master = _git(self.repo, "rev-parse", "HEAD").stdout.strip()
        self.verify.side_effect = WorkspaceError("rebase 后补验未通过")
        with self.assertRaisesRegex(WorkspaceError, "补验未通过"):
            integrate(self.repo, branch)
        self.assertEqual(_git(self.repo, "rev-parse", "HEAD").stdout.strip(), master)
        self.assertEqual(_git(worker, "status", "--porcelain").stdout, "")
        self.head.assert_not_called()

    def test_an_up_to_date_branch_and_a_documents_branch_are_not_rebased(self):
        worker, branch = self.worker("current", "worker.txt", "worker\n")
        self.assertIsNone(integrate(self.repo, branch)["refreshed"])
        _, docs = self.worker("docs", "README.md", "# Example\n")
        (self.repo / "tracked.txt").write_text("main moved\n", encoding="utf-8")
        commit(self.repo, "main moved")
        self.assertIsNone(integrate(self.repo, docs)["refreshed"], "文档集成不持全量锁,不在锁内补验")
        self.verify.assert_not_called()

    def test_reverification_follows_the_test_entry_and_keeps_the_full_lock(self):
        self.verifier.stop()
        worker = self.root / "worker"
        calls = []
        with mock.patch.object(agent_worktree.subprocess, "run",
                               side_effect=lambda command, **kwargs: calls.append((command, kwargs))
                               or subprocess.CompletedProcess(command, 0)):
            agent_worktree._verify_worker(self.repo, worker, frontend=True)
        commands = [command for command, _ in calls]
        self.assertIn("scripts/test_environment.py", commands[0])
        self.assertEqual(commands[1][-3:], ["--prefix", "frontend", "ci"])
        self.assertEqual(commands[2][1:], ["scripts/test_runner.py", "--scope", "auto", "--jobs", "auto",
                                           "--full-lock-held"])
        self.assertTrue(all(kwargs["cwd"] == worker for _, kwargs in calls))
        self.assertEqual(calls[2][1]["env"]["PYTHONPATH"], str(worker / "src"))
        with mock.patch.object(agent_worktree.subprocess, "run",
                               return_value=subprocess.CompletedProcess([], 1)), \
                self.assertRaisesRegex(WorkspaceError, "integrate-verify.log"):
            agent_worktree._verify_worker(self.repo, worker, frontend=False)
        self.verifier.start()


class PullRequestIntegrationTests(_WorktreeCase):
    mock_pull_request = False

    def setUp(self):
        super().setUp()
        result = create(self.repo, "Codex", "pr-boundary", self.root / "worktrees")
        self.worker = Path(result["path"])
        self.branch = result["branch"]
        (self.worker / "worker.txt").write_text("worker\n", encoding="utf-8")
        commit(self.worker, "fix(tooling): 示例变更")
        self.head = _git(self.worker, "rev-parse", "HEAD").stdout.strip()
        self.pr = {"url": "https://github.com/example/peach/pull/12", "state": "OPEN",
                   "headRefOid": self.head, "baseRefName": "master"}

    @contextmanager
    def github(self, stdout=None, returncode=0, stderr=""):
        run = subprocess.run
        query = mock.Mock(return_value=subprocess.CompletedProcess(
            ["gh"], returncode, json.dumps(self.pr) if stdout is None else stdout, stderr))

        def call(command, *args, **kwargs):
            return query(command, *args, **kwargs) if command[0] == "gh" else run(command, *args, **kwargs)

        with mock.patch.object(agent_worktree.subprocess, "run", side_effect=call):
            yield query

    def assert_refused(self, message):
        before = _git(self.repo, "rev-parse", "HEAD").stdout.strip()
        with self.assertRaisesRegex(WorkspaceError, message):
            integrate(self.repo, self.branch)
        self.assertEqual(_git(self.repo, "rev-parse", "HEAD").stdout.strip(), before)
        self.assertEqual((self.repo / "worker.txt").read_text(encoding="utf-8"), "base\n")
        entry = next(row for row in agent_worktree._worktree_entries(self.repo) if row.get("branch") == self.branch)
        self.assertIn("locked", entry)

    def test_missing_pull_request_preserves_master(self):
        with self.github(returncode=1, stderr="no pull requests found for branch"):
            self.assert_refused("未取得 PR")

    def test_open_matching_pull_request_is_bound_to_the_merge(self):
        with self.github() as query:
            result = integrate(self.repo, self.branch)
        self.assertEqual(result["pull_request"],
                         {"url": self.pr["url"], "head": self.head, "base": "master", "refs": [], "body": ""})
        self.assertEqual((self.repo / "worker.txt").read_text(encoding="utf-8"), "worker\n")
        command = query.call_args.args[0]
        self.assertEqual(command[:4], ["gh", "pr", "view", self.branch])
        self.assertEqual(query.call_args.kwargs["cwd"], self.worker)
        self.assertEqual(_git(self.repo, "log", "-1", "--format=%B").stdout.strip(),
                         f"Merge branch '{self.branch}' (#12)")

    def test_issue_link_lines_in_the_body_follow_into_the_merge_commit(self):
        body = ("排查记录里顺带提到 #3,不算关联。\n\nRefs #45\r\n"
                "  Refs peach-mitao/peach-roadmap#7\n"
                "Fixes https://github.com/example/peach/issues/8\n"
                "Refs Telegram 用户反馈(非 GitHub issue)\n")
        with self.github(json.dumps({**self.pr, "body": body})):
            result = integrate(self.repo, self.branch)
        refs = ["Refs #45", "Refs peach-mitao/peach-roadmap#7",
                "Fixes https://github.com/example/peach/issues/8"]
        self.assertEqual(result["pull_request"]["refs"], refs)
        self.assertEqual(_git(self.repo, "log", "-1", "--format=%B").stdout.strip(),
                         f"Merge branch '{self.branch}' (#12)\n\n" + "\n".join(refs))

    def test_library_lists_in_the_body_or_a_commit_message_are_refused(self):
        """PR 正文和提交说明公开在 GitHub 上,成串列出本机馆藏的不合入;问题需要的一两个例子照常合入。"""
        library = check_public_text.Library(codes={"ZZQ-101", "ZZQ-102", "ZZQ-103"}, names={"测试女优甲", "测试女优乙"})
        listed = "剩下要人判的:ZZQ-101、ZZQ-102、ZZQ-103。"
        with mock.patch.object(check_public_text, "load_library", return_value=library):
            with self.github(json.dumps({**self.pr, "body": listed})):
                self.assert_refused("PR 正文(番号 3 个")
            (self.worker / "worker.txt").write_text("worker again\n", encoding="utf-8")
            commit(self.worker, "fix(tooling): 测试女优甲与测试女优乙的别名")
            self.pr["headRefOid"] = _git(self.worker, "rev-parse", "HEAD").stdout.strip()
            with self.github(json.dumps({**self.pr, "body": "只修 ZZQ-101 的前缀。"})):
                self.assertRaisesRegex(WorkspaceError, "提交 [0-9a-f]+(人物 2 个", integrate, self.repo, self.branch)

    def test_insider_terms_in_the_pr_body_are_refused_but_commit_messages_may_name_the_tooling(self):
        with mock.patch.object(check_public_text, "load_library", return_value=None):
            self.assertRaisesRegex(WorkspaceError, "内部用语.*合批", agent_worktree.require_public_text,
                                   self.repo, {"PR 正文": "合批验证:两处反馈修复。"})
            agent_worktree.require_public_text(self.repo, {"PR 正文": "两处反馈修复一起合入。",
                                                           "提交 abc1234": "fix(worktree): 工作树残留检查跳过空目录"})

    def test_a_single_example_or_a_machine_without_a_ledger_still_integrates(self):
        library = check_public_text.Library(codes={"ZZQ-101", "ZZQ-102", "ZZQ-103"})
        for loaded, body in ((library, "只修 ZZQ-101 的前缀。"), (None, "ZZQ-101、ZZQ-102、ZZQ-103")):
            with self.subTest(body=body), mock.patch.object(check_public_text, "load_library", return_value=loaded):
                agent_worktree.require_public_text(self.repo, {"PR 正文": body})

    def test_an_older_remote_head_and_another_base_are_refused(self):
        for field, value, message in (("headRefOid", "0" * 40, "提交.*不一致"),
                                      ("baseRefName", "other", "目标分支"),
                                      ("state", "CLOSED", "必须开放"),
                                      ("state", "MERGED", "必须开放")):
            with self.subTest(field=field, value=value), self.github(json.dumps({**self.pr, field: value})):
                self.assert_refused(message)

    def test_unreadable_or_invalid_receipts_never_count_as_a_pull_request(self):
        cases = (("", "有效 JSON"), ("{", "有效 JSON"), ("[]", "字段无效"),
                 (json.dumps({**self.pr, "headRefOid": None}), "字段无效"),
                 (json.dumps({**self.pr, "url": "file:///private"}), "链接无效"))
        for stdout, message in cases:
            with self.subTest(stdout=stdout), self.github(stdout):
                self.assert_refused(message)

    def test_an_unavailable_client_and_a_timeout_preserve_master(self):
        for error in (FileNotFoundError("gh"), subprocess.TimeoutExpired(["gh"], 60)):
            with self.subTest(error=type(error).__name__), self.github() as query:
                query.side_effect = error
                self.assert_refused("未取得 PR")

    def test_a_worker_change_during_the_remote_read_prevents_merge(self):
        before = _git(self.repo, "rev-parse", "HEAD").stdout.strip()
        with self.github() as query:
            reply = query.return_value

            def advance(*_args, **_kwargs):
                (self.worker / "worker.txt").write_text("parallel\n", encoding="utf-8")
                commit(self.worker, "fix(tooling): 并行变更")
                return reply

            query.side_effect = advance
            with self.assertRaisesRegex(WorkspaceError, "工作者分支改变"):
                integrate(self.repo, self.branch)
        self.assertEqual(_git(self.repo, "rev-parse", "HEAD").stdout.strip(), before)
        self.assertNotEqual(_git(self.worker, "rev-parse", "HEAD").stdout.strip(), self.head)

    def test_the_requested_target_branch_is_preserved(self):
        _git(self.repo, "branch", "-m", "master", "develop")
        with self.github(json.dumps({**self.pr, "baseRefName": "develop"})):
            result = integrate(self.repo, self.branch, "develop")
        self.assertEqual(result["pull_request"]["base"], "develop")


class PreflightTests(_WorktreeCase):
    def worker(self):
        item = create(self.repo, "Codex", "preflight", self.root / "worktrees")
        worker = Path(item["path"])
        path = worker / "src/peach/module.py"
        path.write_text("VALUE = 1\n", encoding="utf-8")
        _git(worker, "add", "src/peach/module.py")
        _git(worker, "commit", "-m", "fix(tooling): 示例规则", "-m", AUTHOR)
        return worker

    def test_the_cli_checks_metadata_without_reading_or_signing_test_proof(self):
        worker = self.worker()
        output = io.StringIO()
        with mock.patch.object(sys, "argv", ["agent_worktree.py", "--repo", str(worker), "preflight"]), \
                mock.patch.object(agent_worktree.test_evidence, "key") as key, redirect_stdout(output):
            self.assertEqual(agent_worktree.main(), 0)
        result = json.loads(output.getvalue())
        self.assertEqual((result["action"], result["metadata"], result["tests"]),
                         ("preflight", "passed", "not_checked"))
        self.assertEqual(result["head"], _git(worker, "rev-parse", "HEAD").stdout.strip())
        key.assert_not_called()
        agent_worktree.require_verified.assert_not_called()
        agent_worktree.require_pull_request.assert_not_called()
        self.assertFalse((self.repo / "build/agent-verification").exists())

    def test_single_readme_change_passes_preflight_with_authorship_only(self):
        worker = self.worker()
        (worker / 'README.md').write_text('# Example\n', encoding='utf-8')
        _git(worker, 'add', 'README.md')
        _git(worker, 'commit', '-m', 'docs: 更新说明', '-m', AUTHOR)
        with mock.patch.object(agent_worktree.test_evidence, "key") as key:
            result = agent_worktree.preflight(worker)
        self.assertEqual(result['metadata'], 'passed')
        key.assert_not_called()

    def test_dirty_content_and_a_newer_target_require_preparation(self):
        worker = self.worker()
        path = worker / "src/peach/module.py"
        path.write_text("VALUE = 2\n", encoding="utf-8")
        with self.assertRaisesRegex(WorkspaceError, "dirty"):
            agent_worktree.preflight(worker)
        path.write_text("VALUE = 1\n", encoding="utf-8")
        (self.repo / "tracked.txt").write_text("parallel\n", encoding="utf-8")
        commit(self.repo, "docs(tooling): 并行变更")
        with self.assertRaisesRegex(WorkspaceError, "已前进"):
            agent_worktree.preflight(worker)


class MasterCommitGuardTests(_WorktreeCase):
    """主检出的 master 只收 `integrate` 与 `release_tag.py` 的提交。

    2026-09 进 master 的五个红提交,四个是在主检出上直接提交,一个是手工 merge,
    都没经过 ready / integrate 的检查。这里用仓库里那份 hook 原样装进测试仓库。
    """

    def setUp(self):
        super().setUp()
        install_commit_hooks(self.repo)
        environment = mock.patch.dict(os.environ, {"PATH": str(Path(sys.executable).parent)
                                                  + os.pathsep + os.environ.get("PATH", "")})
        environment.start()
        self.addCleanup(environment.stop)
        result = create(self.repo, "Claude", "guarded", self.root / "worktrees")
        self.worker, self.branch = Path(result["path"]), str(result["branch"])
        (self.worker / "worker.txt").write_text("worker\n", encoding="utf-8")
        commit(self.worker, "test: worker change\n\n" + AUTHOR)

    def test_create_points_the_repository_at_the_versioned_hooks(self):
        self.assertEqual(_git(self.repo, "config", "core.hooksPath").stdout.strip(),
                         agent_worktree.HOOKS_PATH)

    def test_a_direct_commit_on_master_in_the_main_checkout_is_refused(self):
        before = _git(self.repo, "rev-parse", "HEAD").stdout
        (self.repo / "tracked.txt").write_text("main\n", encoding="utf-8")
        with self.assertRaisesRegex(WorkspaceError, "主检出只做集成"):
            commit(self.repo, "direct")
        self.assertEqual(_git(self.repo, "rev-parse", "HEAD").stdout, before)

    def test_a_hand_made_merge_into_master_is_refused(self):
        before = _git(self.repo, "rev-parse", "HEAD").stdout
        with self.assertRaisesRegex(WorkspaceError, "主检出只做集成"):
            _git(self.repo, "merge", "--no-ff", "-m", "merge by hand", self.branch)
        self.assertEqual(_git(self.repo, "rev-parse", "HEAD").stdout, before)

    def test_integrate_still_merges_the_worker_branch(self):
        self.assertEqual(_git(self.worker, "status", "--porcelain").stdout, "")
        integrate(self.repo, self.branch)
        self.assertEqual((self.repo / "worker.txt").read_text(encoding="utf-8"), "worker\n")

    def test_the_release_commit_carries_the_pass(self):
        """`release_tag.py` 发出的实际参数由 `test_scripts.ShipTests` 钉住。"""
        (self.repo / "tracked.txt").write_text("release\n", encoding="utf-8")
        _git(self.repo, "add", "tracked.txt")
        _git(self.repo, "-c", f"{release_tag.MASTER_WRITER}=release", "commit", "-m", "release")
        self.assertEqual(_git(self.repo, "log", "-1", "--format=%s").stdout.strip(), "release")


class CommitMessageGuardTests(_WorktreeCase):
    def setUp(self):
        super().setUp()
        install_commit_hooks(self.repo)
        environment = mock.patch.dict(os.environ, {"PATH": str(Path(sys.executable).parent)
                                                  + os.pathsep + os.environ.get("PATH", "")})
        environment.start()
        self.addCleanup(environment.stop)
        result = create(self.repo, "codex", "message", self.root / "worktrees with spaces")
        self.worker = Path(result["path"])
        (self.worker / "worker.txt").write_text("worker\n", encoding="utf-8")
        _git(self.worker, "add", "worker.txt")

    def attempt(self, message):
        path = self.worker / "message.txt"
        path.write_text(message, encoding="utf-8")
        return _git(self.worker, "commit", "-F", str(path), check=False)

    def test_a_missing_or_invalid_author_refuses_the_commit_and_keeps_the_index(self):
        before = _git(self.worker, "rev-parse", "HEAD").stdout
        for trailer in ("", "Co-Authored-By: Codex <noreply@openai.com>",
                        "Co-Authored-By: Codex (GPT-6) <wrong@example.com>"):
            with self.subTest(trailer=trailer):
                result = self.attempt("test: check\n\n" + trailer)
                self.assertNotEqual(result.returncode, 0)
                self.assertIn("署名" if trailer else "Co-Authored-By", result.stderr)
                self.assertEqual(_git(self.worker, "rev-parse", "HEAD").stdout, before)
                self.assertEqual(_git(self.worker, "diff", "--cached", "--name-only").stdout.strip(),
                                 "worker.txt")

    def test_optional_readme_trailers_are_accepted_as_commit_text(self):
        for declaration in ("README-Impact: none; 内部测试\n\n", "README-Impact: none\n",
                            "README-Impact: none; 内部测试\nREADME-Impact: updated; 文档\n",
                            "README-Reviewed: README.zh-CN.md; 已核对\n\n",
                            "README-Reviewed: README.zh-CN.md;\n", "README-Reviewed: unknown.md; 已核对\n"):
            with self.subTest(declaration=declaration):
                self.assertEqual(check_commit_message.check(self.worker, "test: check\n\n" + declaration + AUTHOR), [])

    def test_valid_trailers_and_amend_preserve_the_message(self):
        message = ("test: 中文提交\n\nREADME-Impact: updated; 两种语言\n" + AUTHOR
                   + "\nCo-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>\n")
        result = self.attempt(message)
        self.assertEqual(result.returncode, 0, result.stderr)
        amended = _git(self.worker, "commit", "--amend", "--no-edit", check=False)
        self.assertEqual(amended.returncode, 0, amended.stderr)
        self.assertEqual(_git(self.worker, "show", "-s", "--format=%B").stdout.strip(), message.strip())

    def test_human_and_agent_trailers_are_preserved_in_the_commit(self):
        message = ("test: 共同作者署名\n\n" + AUTHOR
                   + "\nCo-Authored-By: Contributor <contributor@example.invalid>\n")
        result = self.attempt(message)
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(_git(self.worker, "show", "-s", "--format=%B").stdout.strip(), message.strip())

    def test_commit_accepts_authorship_without_readme_trailers(self):
        result = self.attempt("test: intermediate\n\n" + AUTHOR)
        self.assertEqual(result.returncode, 0, result.stderr)

    def test_author_lines_separated_into_different_blocks_are_rejected(self):
        result = self.attempt("test: authors\n\n" + AUTHOR + "\n\n"
                              "Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>")
        self.assertNotEqual(result.returncode, 0)
        self.assertIn("Co-Authored-By", result.stderr)

    def test_git_comment_lines_do_not_count_as_declarations(self):
        result = self.attempt("test: comments\n\n" + AUTHOR + "\n\n# Co-Authored-By: example\n")
        self.assertEqual(result.returncode, 0, result.stderr)

    def test_merge_messages_are_exempt_from_authorship_trailers(self):
        result = self.attempt("test: worker\n\n" + AUTHOR)
        self.assertEqual(result.returncode, 0, result.stderr)
        _git(self.repo, "switch", "-c", "parallel")
        (self.repo / "tracked.txt").write_text("parallel\n", encoding="utf-8")
        _git(self.repo, "add", "tracked.txt")
        _git(self.repo, "commit", "-m", "test: parallel")
        merged = _git(self.worker, "merge", "--no-ff", "-m", "Merge parallel", "parallel", check=False)
        self.assertEqual(merged.returncode, 0, merged.stderr)


if __name__ == "__main__":
    unittest.main()


class PruneTests(_WorktreeCase):
    """回收已经并入 master 的隔离工作树。

    `create` 一直有人用,回收却从来没有入口——2026-08-29 清到 3 个,两天后长回 74 个、
    占 868 MB。一次性清理解决不了这个问题,所以把回收做成命令,并在这里锁住它的边界。
    """

    def _integrated(self, name: str) -> Path:
        result = create(self.repo, "Codex", name, self.root / "worktrees")
        worker = Path(result["path"])
        (worker / "worker.txt").write_text(name + " done\n", encoding="utf-8")
        commit(worker, f"{name} change")
        integrate(self.repo, str(result["branch"]))
        return worker

    def test_reporting_does_not_remove_anything(self):
        """默认只报告。回收不可逆,不该是运行一下就顺手做了的事。"""
        worker = self._integrated("reportable")
        report = prune(self.repo)
        self.assertIn(str(worker), report["reclaimed"])
        self.assertFalse(report["applied"])
        self.assertTrue(worker.is_dir(), "没给 --apply 就不该动手")

    def test_apply_reclaims_the_worktree_and_its_branch(self):
        worker = self._integrated("reclaimable")
        branch = _git(self.repo, "branch", "--list", "agent/codex/reclaimable").stdout
        self.assertIn("reclaimable", branch)
        prune(self.repo, apply=True)
        self.assertFalse(worker.exists(), "工作树应当被回收")
        self.assertNotIn("reclaimable",
                         _git(self.repo, "branch", "--list", "agent/codex/reclaimable").stdout,
                         "已并入的分支也应一并删掉")

    def test_a_worktree_with_files_under_build_is_kept_even_when_clean_and_merged(self):
        """`build/` 被 Git 忽略,`status` 看不见它;里面却是会话的日志、报告和派单,删了没有备份。"""
        (self.repo / ".git" / "info").mkdir(exist_ok=True)
        (self.repo / ".git" / "info" / "exclude").write_text("build/\n", encoding="utf-8")
        worker = self._integrated("has-build")
        (worker / "build").mkdir()
        (worker / "build" / "report.md").write_text("复盘报告\n", encoding="utf-8")
        report = prune(self.repo, apply=True)
        self.assertTrue((worker / "build" / "report.md").is_file())
        self.assertEqual(report["reclaimed"], [])
        self.assertIn("build/", next(row["why"] for row in report["kept"] if row["path"] == str(worker)))

    def test_a_worktree_a_process_is_working_in_is_kept(self):
        """会话、终端或预览服务还开在里面的工作树不回收:分支合入了,人还在用。"""
        worker = self._integrated("in-use")
        process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"], cwd=worker)
        try:
            report = prune(self.repo, apply=True)
        finally:
            process.kill()
            process.wait(timeout=15)
        self.assertTrue(worker.is_dir())
        why = next(row["why"] for row in report["kept"] if row["path"] == str(worker))
        self.assertIn(str(process.pid), why)

    def test_a_dirty_worktree_is_refused_even_when_its_branch_is_merged(self):
        """分支已合入不等于工作区里没东西。

        实测就有这样的工作树:分支早已并入 master,里面却躺着一份成形的未提交改动
        (按钮等待态改用 aria-busy,还配了对应的测试)。所以脏的一律拒收并单独列出,
        交给人看,不给 --force 这个口子。
        """
        worker = self._integrated("has-wip")
        (worker / "worker.txt").write_text("未提交的后续改动\n", encoding="utf-8")
        report = prune(self.repo, apply=True)
        self.assertTrue(worker.is_dir(), "脏工作树不能被回收")
        self.assertEqual([row["path"] for row in report["dirty"]], [str(worker)])
        self.assertEqual(report["reclaimed"], [])

    def test_an_unmerged_branch_is_kept_with_a_reason(self):
        result = create(self.repo, "Codex", "still-open", self.root / "worktrees")
        worker = Path(result["path"])
        (worker / "worker.txt").write_text("still open wip\n", encoding="utf-8")
        commit(worker, "still open")
        report = prune(self.repo, apply=True)
        self.assertTrue(worker.is_dir())
        self.assertEqual([row["branch"] for row in report["kept"]], ["agent/codex/still-open"])

    def test_the_main_worktree_is_never_a_candidate(self):
        self._integrated("bystander")
        report = prune(self.repo)
        listed = report["reclaimed"] + [row["path"] for row in report["dirty"] + report["kept"]]
        self.assertNotIn(str(self.repo), listed, "主检出不能出现在回收清单里")

    def test_a_directory_git_could_not_delete_is_deleted_by_the_script(self):
        """git 摘了注册却删不掉目录时,脚本自己把目录删掉,不留给人。

        2026-09-01 实测:连跑 5 次 prune --apply 才把 5 个工作树摘完,5 条分支一条没删,
        最后是手工 `git branch -d` 收的尾——因为 remove 一失败就抛错中止了整轮。分支的
        那半边当时补上了,目录这半边还写着「待人工清理」,于是残留目录照样留在原地:
        长得和真工作树一样,在里面跑 git 全作用于主检出的 master。
        """
        worker = self._integrated("stuck-handle")
        with mock.patch.object(agent_worktree, "_git",
                               StuckRemoval(agent_worktree._git, {worker: "residue"})):
            report = prune(self.repo, apply=True)
        self.assertTrue(report["ok"], "一个目录删不掉不该把整轮判成失败")
        self.assertFalse(worker.exists(), "git 删不掉的空目录由脚本删掉")
        self.assertEqual(report["residue"], [], "删掉了就不是残留")
        self.assertEqual(report["reclaimed"], [str(worker)])
        self.assertNotIn("stuck-handle",
                         _git(self.repo, "branch", "--list", "agent/codex/stuck-handle").stdout,
                         "注册已经摘掉了,分支就该跟着删")

    def test_a_directory_nobody_can_delete_is_reported_with_the_reason(self):
        """脚本也删不掉才算残留:报出原因交给人,分支照删。"""
        worker = self._integrated("locked-handle")
        with mock.patch.object(agent_worktree, "_git",
                               StuckRemoval(agent_worktree._git, {worker: "residue"})), \
                mock.patch.object(agent_worktree, "_delete_tree",
                                  return_value="PermissionError: 目录被别的进程占着"):
            report = prune(self.repo, apply=True)
        self.assertTrue(worker.is_dir(), "删不掉就还在原地")
        self.assertEqual([row["path"] for row in report["residue"]], [str(worker)])
        self.assertIn("目录被别的进程占着", report["residue"][0]["why"])
        self.assertEqual(report["reclaimed"], [])
        self.assertNotIn("locked-handle",
                         _git(self.repo, "branch", "--list", "agent/codex/locked-handle").stdout)

    def test_one_stuck_worktree_does_not_block_the_others(self):
        """回收失败只波及它自己,剩下的工作树照常处理,不用人反复重跑。"""
        stuck = self._integrated("stuck-first")
        other = self._integrated("second-in-line")
        with mock.patch.object(agent_worktree, "_git",
                               StuckRemoval(agent_worktree._git, {stuck: "stuck"})):
            report = prune(self.repo, apply=True)
        self.assertEqual(report["reclaimed"], [str(other)])
        self.assertFalse(other.exists())
        self.assertNotIn("second-in-line",
                         _git(self.repo, "branch", "--list", "agent/codex/second-in-line").stdout)
        self.assertEqual([row["branch"] for row in report["failed"]], ["agent/codex/stuck-first"])
        self.assertTrue(stuck.is_dir())
        self.assertIn("stuck-first",
                      _git(self.repo, "branch", "--list", "agent/codex/stuck-first").stdout,
                      "注册还在就是没回收成,分支不能删")


class UnregisteredLeftoverTests(_WorktreeCase):
    """清掉两个工作树落点里已经不登记的残留目录。

    `create` 的落点和 Claude Code 内置的落点都会留下这种目录:它不在
    `git worktree list` 里,所以按登记项遍历的那轮回收永远碰不到它;人却会走进去当
    工作树用,而在里面跑 git 全部作用于主检出的 master。两处同一条规则,只盖其中
    一处的话,另一处的残留就只能靠人想起来。
    """

    def roots(self) -> tuple[Path, ...]:
        """`create` 建在主检出旁边,Claude Code 建在主检出里。"""
        return (self.repo.parent / agent_worktree.WORKTREE_ROOT,
                self.repo / agent_worktree.BUILTIN_WORKTREES)

    def leftover(self, root: Path, name: str) -> Path:
        path = root / name
        path.mkdir(parents=True)
        return path

    def test_an_unregistered_empty_leftover_is_swept(self):
        for root in self.roots():
            with self.subTest(root=root.name):
                empty = self.leftover(root, "distracted-lamarr")
                report = prune(self.repo, apply=True)
                self.assertEqual(report["swept"], [str(empty)])
                self.assertFalse(empty.exists())

    def test_reporting_lists_the_leftover_without_deleting_it(self):
        for root in self.roots():
            with self.subTest(root=root.name):
                empty = self.leftover(root, "reportable-leftover")
                report = prune(self.repo)
                self.assertEqual(report["swept"], [str(empty)])
                self.assertTrue(empty.is_dir(), "没给 --apply 就不该动手")
                shutil.rmtree(empty)

    def test_a_leftover_that_still_holds_files_is_left_alone(self):
        """空目录才扫。里面还有文件就可能是别人正开着的检出或没提交的东西。"""
        for root in self.roots():
            with self.subTest(root=root.name):
                used = self.leftover(root, "sweet-newton")
                (used / "worker.txt").write_text("别人正在用\n", encoding="utf-8")
                report = prune(self.repo, apply=True)
                self.assertEqual(report["swept"], [])
                self.assertTrue(used.is_dir())
                self.assertEqual([row["path"] for row in report["kept"]], [str(used)])
                shutil.rmtree(used)

    def test_a_registered_worktree_under_either_root_is_not_swept(self):
        """登记着的工作树归上面那轮按分支状态处理,扫残留这步不许碰。"""
        for index, root in enumerate(self.roots()):
            with self.subTest(root=root.name):
                task = f"in-place-{index}"
                result = create(self.repo, "Claude", task, root)
                worker = Path(result["path"])
                (worker / "worker.txt").write_text("还在做\n", encoding="utf-8")
                commit(worker, f"{task} wip")
                report = prune(self.repo, apply=True)
                self.assertEqual(report["swept"], [])
                self.assertTrue(worker.is_dir())
                self.assertIn(f"agent/claude/{task}",
                              [row["branch"] for row in report["kept"]])


class LeftoverProcessTests(_WorktreeCase):
    """找出工作树落点下还活着的进程:谁占着删不掉的目录、机器变卡时谁是自己的残留。"""

    def sleeper(self, cwd: Path) -> subprocess.Popen:
        cwd.mkdir(parents=True, exist_ok=True)
        child = subprocess.Popen([sys.executable, "-c", "import sys; sys.stdin.read()"],
                                 cwd=cwd, stdin=subprocess.PIPE)
        deadline = time.monotonic() + 10
        while time.monotonic() < deadline:
            rows = processes_under([cwd])
            if any(row["pid"] == child.pid for row in rows):
                break
            time.sleep(0.1)
        return child

    def stop(self, child: subprocess.Popen) -> None:
        # EOF 让解释器自己退出;Windows venv 启动器也会等其子进程结束。
        child.communicate(timeout=10)

    def test_a_process_working_in_a_worktree_is_listed_with_its_details(self):
        child = self.sleeper(self.repo.parent / agent_worktree.WORKTREE_ROOT / "busy")
        try:
            report = agent_worktree.processes(self.repo)
        finally:
            self.stop(child)
        row = next(row for row in report["processes"] if row["pid"] == child.pid)
        self.assertFalse(row["orphan"], "父进程(测试本身)还在")
        self.assertFalse(row["caller"])
        self.assertGreaterEqual(row["age_minutes"], 0)
        self.assertIn("cpu_percent", row)
        self.assertIn("memory_percent", report["machine"])

    def test_the_caller_and_processes_elsewhere_are_told_apart(self):
        child = self.sleeper(self.root / "elsewhere")
        try:
            rows = processes_under([self.root / "elsewhere", Path(os.getcwd())])
        finally:
            self.stop(child)
        self.assertIn(child.pid, [row["pid"] for row in rows])
        self.assertNotIn(child.pid, [row["pid"] for row in processes_under([self.repo])])
        mine = [row for row in rows if row["pid"] == os.getpid()]
        self.assertEqual([row["caller"] for row in mine], [True], "调用者自己这条链要标出来")

    def test_a_directory_nobody_can_delete_names_the_process_holding_it(self):
        held = self.repo / agent_worktree.BUILTIN_WORKTREES / "held-open"
        child = self.sleeper(held)
        try:
            with mock.patch.object(agent_worktree, "_delete_tree",
                                   return_value="PermissionError: 目录被别的进程占着"):
                report = prune(self.repo, apply=True)
        finally:
            self.stop(child)
        self.assertEqual([row["path"] for row in report["residue"]], [str(held)])
        self.assertIn(child.pid, [row["pid"] for row in report["residue"][0]["holders"]])

    def test_without_psutil_the_scan_says_it_was_not_obtained(self):
        with mock.patch.dict(sys.modules, {"psutil": None}):
            self.assertTrue(processes_under([self.repo]).startswith("未取得"))
            report = create(self.repo, "Codex", "no-psutil", self.root / "worktrees")
        self.assertTrue(report["orphan_processes"].startswith("未取得"))


class IntegrationVersionTests(_WorktreeCase):
    """集成报告版本号,但把推进它的权力留给发布入口。

    一天几十次集成共用一个版本号:跑着的是哪一份由 commit 认定,`build-info.json`
    随包走,托盘按它算自己落后多少个提交。版本号一次发布推一格,于是每个 `X.Y.Z`
    都对应一份能下载到的制品(ADR-0012)。
    """

    def worker_touching(self, name: str, path: str, text: str = "改了\n", *,
                        subject: str | None = None) -> str:
        result = create(self.repo, "Claude", name, self.root / "worktrees")
        worker = Path(result["path"])
        target = worker / path
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(text, encoding="utf-8")
        _git(worker, "add", path)
        _git(worker, "commit", "-m", subject or f"fix: {name}")
        return str(result["branch"])

    def test_a_runtime_fix_merges_without_touching_the_version(self):
        branch = self.worker_touching("runtime-change", "src/peach/module.py")
        report = integrate(self.repo, branch)
        self.assertEqual(report["version"], "0.7.14")
        self.assertEqual(read_version(self.repo), "0.7.14")
        self.assertEqual(_git(self.repo, "log", "-1", "--format=%s").stdout.strip(),
                         f"Merge branch '{branch}' (#1)")
        touched = _lines(_git(self.repo, "show", "--name-only", "--format=", "HEAD"))
        self.assertNotIn("src/peach/__init__.py", touched)

    def test_a_feature_commit_stays_on_the_same_version_too(self):
        """`feat` 决定的是发布时推哪一位,不是集成时推不推。"""
        branch = self.worker_touching("feature-work", "src/peach/module.py", subject="feat: 新功能")
        report = integrate(self.repo, branch)
        self.assertEqual(report["version"], "0.7.14")
        self.assertEqual(read_version(self.repo), "0.7.14")

    def test_a_new_migration_stays_on_the_same_version_too(self):
        branch = self.worker_touching("schema", "migrations/0099_new.sql", subject="fix: 补一列")
        self.assertEqual(integrate(self.repo, branch)["version"], "0.7.14")
        self.assertEqual(read_version(self.repo), "0.7.14")

    def test_integration_never_creates_a_version_tag_itself(self):
        """打标签只有 `scripts/release_tag.py` 一个入口,它遇到同名本地标签就拒绝。

        集成这一步再造一个本地标签,等于把唯一的发布入口挡在门外。
        """
        branch = self.worker_touching("taggable", "src/peach/module.py")
        report = integrate(self.repo, branch)
        self.assertEqual(report["release_tag_entry"], "scripts/release_tag.py")
        self.assertEqual(_lines(_git(self.repo, "tag", "--list")), [])

    def test_every_integration_reports_whether_a_release_is_due(self):
        """判断挂在集成的输出里,不靠谁记着去查——「记着」正是它要替掉的东西。"""
        branch = self.worker_touching("user-visible", "src/peach/module.py",
                                      subject="fix(web): 修一处使用者看得见的")
        report = integrate(self.repo, branch)
        self.assertEqual(report["release"]["entries"], 1)
        self.assertEqual(report["release"]["groups"], {"修复": 1})
        self.assertIn("due", report["release"])

    def test_development_only_work_never_asks_for_a_release(self):
        branch = self.worker_touching("inside-only", "src/peach/module.py",
                                      subject="refactor(web): 只动开发过程")
        report = integrate(self.repo, branch)
        self.assertEqual((report["release"]["entries"], report["release"]["due"]), (0, False))


from scripts.check_readme_impact import git


from scripts import check_commit_message, co_author


class CoAuthorTests(unittest.TestCase):
    """交付提交的署名。追责要落到具体的模型,所以工具名单独一个词不够。"""

    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.addCleanup(self.temp.cleanup)
        self.repo = Path(self.temp.name).resolve()
        git(self.repo, "init", "-b", "master")
        git(self.repo, "config", "user.name", "Test")
        git(self.repo, "config", "user.email", "test@example.invalid")
        git(self.repo, "commit", "--allow-empty", "-m", "base")
        git(self.repo, "checkout", "-b", "worker")

    def signed(self, *trailers):
        git(self.repo, "commit", "--allow-empty", "-m", "测试", "-m", "\n".join(trailers))
        return co_author.check(self.repo, "master")

    def test_an_unsigned_commit_cannot_be_traced_back_to_a_model(self):
        self.assertTrue(self.signed())

    def test_the_tool_alone_does_not_say_which_model_wrote_it(self):
        self.assertTrue(self.signed("Co-Authored-By: Claude Code <noreply@anthropic.com>"))

    def test_each_registered_tool_passes_with_its_own_vendor(self):
        self.assertEqual(
            self.signed("Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>"), [])
        self.assertEqual(
            self.signed("Co-Authored-By: Codex (GPT-5.5) <noreply@openai.com>"), [])
        self.assertEqual(
            self.signed("Co-Authored-By: opencode (GLM 5.3 Flash) <noreply@opencode.ai>"), [])

    def test_an_address_that_does_not_belong_to_the_tool_is_rejected(self):
        self.assertTrue(self.signed("Co-Authored-By: Codex (GPT-5.5) <noreply@anthropic.com>"))

    def test_an_unregistered_tool_waits_until_someone_registers_it(self):
        """名单在 `co_author.VENDORS`:换用别的智能体是要动一行代码的决定,不是随手写。"""
        self.assertTrue(self.signed("Co-Authored-By: Gemini CLI (3.5) <noreply@google.com>"))

    def test_two_agents_writing_one_commit_may_both_sign(self):
        self.assertEqual(
            self.signed("Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>",
                        "Co-Authored-By: Codex (GPT-5.5) <noreply@openai.com>"), [])

    def test_human_and_agent_coauthors_share_one_commit(self):
        human = "Co-Authored-By: Contributor <contributor@example.invalid>"
        agent = "Co-Authored-By: Codex (GPT-6) <noreply@openai.com>"
        for signatures in ((human, agent), (agent, human)):
            with self.subTest(signatures=signatures):
                self.assertEqual(self.signed(*signatures), [])

    def test_human_coauthor_requires_an_agent_signature(self):
        self.assertTrue(self.signed("Co-Authored-By: Contributor <contributor@example.invalid>"))

    def contributed(self, *trailers):
        """采纳的社区补丁:作者是贡献者本人,不是本机提交者。"""
        git(self.repo, "commit", "--allow-empty", "-m", "测试", "-m", "\n".join(trailers) or "说明",
            "--author", "Contributor <contributor@example.invalid>")
        return co_author.check(self.repo, "master")

    def test_a_contributor_commit_is_not_given_an_agent_signature_it_never_had(self):
        """外部贡献者是否用了 AI 由本人说明;门槛不能逼维护者替他补一行 AI 署名。"""
        self.assertEqual(self.contributed(), [])

    def test_a_contributor_may_name_a_tool_the_maintainer_has_not_registered(self):
        """工具名单约束的是本机智能体,贡献者用什么工具是他自己的事。"""
        self.assertEqual(self.contributed("Co-Authored-By: Qoder (Qwen 3) <noreply@qoder.com>"), [])

    def test_a_contributor_signature_still_has_to_be_well_formed(self):
        self.assertTrue(self.contributed("Co-Authored-By: Codex (GPT-6) <noreply@anthropic.com>"))
        self.assertTrue(co_author.problems("测试", ["Codex <someone@example.invalid>"], own=False))

    def test_the_local_committer_signs_even_beside_an_adopted_commit(self):
        self.contributed()
        self.assertEqual(len(self.signed()), 1)

    def test_an_unknown_local_identity_is_treated_as_the_local_committer(self):
        """取不到本机邮箱时无法判断作者是谁,按本机提交从严检查。"""
        self.assertTrue(co_author.is_own("contributor@example.invalid", ""))
        self.assertFalse(co_author.is_own("contributor@example.invalid", "test@example.invalid"))
        self.assertTrue(co_author.is_own("Test@Example.invalid", "test@example.invalid"))

    def test_the_commit_hook_reads_the_author_actually_used(self):
        self.assertEqual(check_commit_message.author_mail(self.repo), "test@example.invalid")
        self.assertEqual(check_commit_message.check(self.repo, "测试\n", own=False), [])
        self.assertTrue(check_commit_message.check(self.repo, "测试\n", own=True))

    def test_human_coauthor_requires_a_name_and_email(self):
        agent = "Co-Authored-By: Codex (GPT-6) <noreply@openai.com>"
        for human in ("Contributor", "Contributor <invalid>", "<contributor@example.invalid>"):
            with self.subTest(human=human):
                self.assertTrue(co_author.problems("测试", [agent, human]))

    def test_registered_agent_and_vendor_addresses_require_model_metadata(self):
        agent = "Codex (GPT-6) <noreply@openai.com>"
        for signature in ("Codex <someone@example.invalid>",
                          "Contributor <noreply@openai.com>"):
            with self.subTest(signature=signature):
                self.assertTrue(co_author.problems("测试", [agent, signature]))

    def test_every_commit_on_the_branch_is_checked_not_only_head(self):
        """一次交付常常是好几个提交。只判尖端等于只判收尾那一次签得对不对,
        而要追的恰恰是写出某一行的那个提交,所以问题里要报出它的短 hash 和主题。
        """
        self.signed("Co-Authored-By: Gemini CLI (3.5) <noreply@google.com>")
        problems = self.signed(
            "Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>")
        self.assertEqual(len(problems), 1)
        self.assertIn(git(self.repo, "log", "-1", "--format=%h", "HEAD~1").strip(),
                      problems[0])
        self.assertIn("Gemini CLI", problems[0])

    def test_merges_from_the_target_branch_are_not_the_worker_s_commits(self):
        """目标分支上的提交由它自己那次交付负责,合进来不等于这个分支重写了一遍。"""
        git(self.repo, "checkout", "master")
        git(self.repo, "commit", "--allow-empty", "-m", "上游的提交")
        git(self.repo, "checkout", "worker")
        self.signed("Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>")
        git(self.repo, "merge", "--no-ff", "-m", "Merge branch 'master' into worker",
            "master")
        self.assertEqual(co_author.check(self.repo, "master"), [])

    def test_a_commit_already_on_the_remote_master_cannot_be_signed_any_more(self):
        """网页上直接改的那条已经在 `origin/master` 上,分支并回它时改写 hash 只能强推;
        没有远端引用时什么都不豁免。"""
        git(self.repo, "commit", "--allow-empty", "-m", "Remove section from README")
        published = git(self.repo, "rev-parse", "HEAD").strip()
        self.signed("Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>")
        self.assertEqual(len(co_author.check(self.repo, "master")), 1)
        git(self.repo, "update-ref", "refs/remotes/origin/master", published)
        self.assertEqual(co_author.check(self.repo, "master"), [])

    def test_the_signature_gate_runs_before_the_test_evidence_is_read(self):
        """署名在读取测试记录前检查。"""
        (self.repo / "tests").mkdir()
        (self.repo / "tests" / "example.py").write_text("x = 1\n", encoding="utf-8")
        git(self.repo, "add", "--", "tests/example.py")
        git(self.repo, "commit", "-m", "测试")
        with mock.patch.object(agent_worktree.test_evidence, "key") as key:
            with self.assertRaisesRegex(agent_worktree.WorkspaceError, "Co-Authored-By"):
                agent_worktree.require_verified(self.repo, "master", {"tests/example.py"})
            key.assert_not_called()


from scripts import commit_subject


class CommitSubjectTests(unittest.TestCase):
    """提交主题的形状。变更日志按它分组,写歪的提交不报错,只是静默漏掉。"""

    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.addCleanup(self.temp.cleanup)
        self.repo = Path(self.temp.name).resolve()
        git(self.repo, "init", "-b", "master")
        git(self.repo, "config", "user.name", "Test")
        git(self.repo, "config", "user.email", "test@example.invalid")
        git(self.repo, "commit", "--allow-empty", "-m", "base")
        git(self.repo, "checkout", "-b", "worker")

    def titled(self, *subjects):
        for subject in subjects:
            git(self.repo, "commit", "--allow-empty", "-m", subject)
        return commit_subject.check(self.repo, "master")

    def test_a_conventional_subject_passes_with_or_without_scope(self):
        self.assertEqual(self.titled("fix(web): 补齐图标声明", "docs: 补记四个版本",
                                     "refactor(media)!: 删掉旧快照根"), [])

    def test_a_bare_sentence_is_not_a_subject(self):
        self.assertTrue(self.titled("修复关注页按手柄查不到"))

    def test_the_space_after_the_colon_is_part_of_the_shape(self):
        self.assertTrue(self.titled("fix(web):保留必需凭据"))

    def test_an_unlisted_type_waits_until_someone_lists_it(self):
        """清单在 `commit_subject.TYPES`:加类型要先想清它在变更日志里归哪一组。"""
        problems = self.titled("release(app): 版本 0.30.0")
        self.assertEqual(len(problems), 1)
        self.assertIn("release", problems[0])

    def test_every_commit_on_the_branch_is_checked_not_only_head(self):
        problems = self.titled("补齐 JAV 元数据", "fix(web): 后面这条是对的")
        self.assertEqual(len(problems), 1)
        self.assertIn("补齐 JAV 元数据", problems[0])

    def test_merges_from_the_target_branch_are_not_the_worker_s_subjects(self):
        git(self.repo, "checkout", "master")
        git(self.repo, "commit", "--allow-empty", "-m", "chore(deps-dev): bump vitest")
        git(self.repo, "checkout", "worker")
        git(self.repo, "commit", "--allow-empty", "-m", "fix(web): 自己的提交")
        git(self.repo, "merge", "--no-ff", "-m", "Merge branch 'master' into worker", "master")
        self.assertEqual(commit_subject.check(self.repo, "master"), [])

    def test_an_adopted_community_commit_keeps_its_own_subject(self):
        """`merge -s ours` 记入的社区提交原样保留,主题门槛只管本机提交者写的。"""
        git(self.repo, "checkout", "-b", "community", "master")
        git(self.repo, "-c", "user.email=someone@users.noreply.github.com", "commit", "--allow-empty",
            "-m", "Translate frontend UI to English")
        git(self.repo, "checkout", "worker")
        git(self.repo, "merge", "-s", "ours", "--no-ff", "-m", "chore(i18n): 记入社区译法", "community")
        self.assertEqual(commit_subject.check(self.repo, "master"), [])
        self.assertTrue(self.titled("Translate it myself"))

    def test_a_subject_already_on_the_remote_master_is_not_the_worker_s_to_fix(self):
        problems = self.titled("Remove section from README", "docs(readme): 并回网页上的改动")
        self.assertEqual(len(problems), 1)
        git(self.repo, "update-ref", "refs/remotes/origin/master",
            git(self.repo, "rev-parse", "HEAD~1").strip())
        self.assertEqual(commit_subject.check(self.repo, "master"), [])

    def test_the_subject_gate_runs_before_the_test_evidence_is_read(self):
        (self.repo / "tests").mkdir()
        (self.repo / "tests" / "example.py").write_text("x = 1\n", encoding="utf-8")
        git(self.repo, "add", "--", "tests/example.py")
        git(self.repo, "commit", "-m", "加个测试", "-m",
            "Co-Authored-By: Claude Code (Opus 5) <noreply@anthropic.com>")
        with mock.patch.object(agent_worktree.test_evidence, "key") as key:
            with self.assertRaisesRegex(agent_worktree.WorkspaceError, "提交主题"):
                agent_worktree.require_verified(self.repo, "master", {"tests/example.py"})
            key.assert_not_called()