"""按改动选检查(scripts/test_affected.py):临时 Git 仓库 + 本地裸 origin,全部离线。""" import os import subprocess import sys import tempfile import unittest from pathlib import Path SOURCE = Path(__file__).resolve().parents[1] SCRIPT = SOURCE / "scripts" / "test_affected.py" class TestAffectedTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory(prefix="test-affected-") self.addCleanup(self.tmp.cleanup) root = Path(self.tmp.name) self.repo = root / "repo" self.env = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "MAKE")) and k not in {"BASE", "TASK_TARGET_BRANCH"}} self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_TERMINAL_PROMPT": "0", "HOME": str(root / "no-home"), "GIT_AUTHOR_NAME": "Affected Test", "GIT_AUTHOR_EMAIL": "affected@example.invalid", "GIT_COMMITTER_NAME": "Affected Test", "GIT_COMMITTER_EMAIL": "affected@example.invalid"}) self.git(root, "init", "-q", "--bare", "--initial-branch=main", str(root / "origin.git")) self.git(root, "init", "-q", "--initial-branch=main", str(self.repo)) self.git(self.repo, "remote", "add", "origin", str(root / "origin.git")) self.write("README.md", "fixture\n") self.write("modules/ui/notes.txt", "ui\n") self.commit("Create fixture") self.git(self.repo, "push", "-q", "origin", "main") self.git(self.repo, "checkout", "-q", "-b", "feature/work") def git(self, cwd, *args): return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, text=True, check=True).stdout.strip() def write(self, rel, text): path = self.repo / rel path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") def commit(self, message, gitlinks=()): self.git(self.repo, "add", "-A") sha = self.git(self.repo, "rev-parse", "HEAD") if gitlinks else None for path in gitlinks: self.git(self.repo, "update-index", "--add", "--cacheinfo", f"160000,{sha},{path}") self.git(self.repo, "commit", "-q", "-m", message) def run_affected(self, *args, base=None): env = self.env | ({"BASE": base} if base is not None else {}) result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo, env=env, capture_output=True, text=True, timeout=60) return result.returncode, result.stdout + result.stderr def summary(self, out): lines = [line for line in out.splitlines() if line.startswith("[affected] base=")] self.assertEqual(len(lines), 1, out) return dict(part.split("=", 1) for part in lines[0].split()[1:]) def test_mapping_dry_run_orders_and_dedupes_targets(self): self.write("scripts/tool.py", "print('x')\n") self.write("tests/test_tool.py", "\n") self.write("modules/core/a.go", "package a\n") self.write("deps/agent-native/x.go", "package x\n") self.write("deps/agent-squad/y.go", "package y\n") self.write("docs/guide.md", "doc\n") self.write(".task/card.md", "card\n") self.commit("Branch work", gitlinks=["deps/agent-command"]) self.write("modules/ui/notes.txt", "uncommitted tracked change\n") code, out = self.run_affected("--dry-run") self.assertEqual(code, 0, out) info = self.summary(out) self.assertEqual(info["base"], "origin/main") self.assertEqual(info["changed"], "9", out) self.assertEqual(info["targets"], "test-worktree,test-governance,test-boundaries,test-pins,test-libs," "test-core,test-ui,test-agentcmd", out) self.assertIn("[affected] dry-run: make test-worktree test-governance", out) def test_gitlink_and_gitmodules_select_pins_and_module_target(self): self.write(".gitmodules", "[submodule \"modules/backend\"]\n\tpath = modules/backend\n") self.commit("Bump backend", gitlinks=["modules/backend"]) code, out = self.run_affected("--dry-run") self.assertEqual(code, 0, out) self.assertEqual(self.summary(out)["targets"], "test-worktree,test-boundaries,test-pins,test-backend", out) def test_lint_boundaries_and_governance_docs_select_targets(self): self.write("scripts/lint-boundaries.sh", "#!/bin/sh\n") self.write("AGENT-EXECUTION.md", "x\n") self.commit("Lint") code, out = self.run_affected("--dry-run") self.assertEqual(code, 0, out) self.assertEqual(self.summary(out)["targets"], "test-worktree,test-governance,test-boundaries", out) def test_docs_only_change_runs_nothing(self): self.write("docs/guide.md", "doc\n") self.write(".task/card.md", "card\n") self.write("NOTES.md", "notes\n") self.commit("Docs only") self.write("README.md", "edited\n") code, out = self.run_affected() self.assertEqual(code, 0, out) info = self.summary(out) self.assertEqual((info["changed"], info["targets"]), ("4", "none"), out) self.assertIn("[affected] nothing to test", out) self.assertNotIn("dry-run", out) def test_base_env_resolves_bare_branch_to_origin(self): self.write("scripts/tool.py", "x\n") self.commit("Tool") code, out = self.run_affected("--dry-run", base="main") self.assertEqual(code, 0, out) self.assertEqual(self.summary(out)["base"], "origin/main") def test_base_env_local_branch_limits_changes_since_that_base(self): self.write("modules/core/a.go", "package a\n") self.commit("Core") self.git(self.repo, "branch", "local-base") self.write("docs/later.md", "later\n") self.commit("Later docs") code, out = self.run_affected("--dry-run", base="local-base") self.assertEqual(code, 0, out) info = self.summary(out) self.assertEqual((info["base"], info["changed"], info["targets"]), ("local-base", "1", "none"), out) code, out = self.run_affected("--dry-run", "--base", "origin/main", base="local-base") self.assertEqual(self.summary(out)["base"], "origin/main", out) def test_unresolvable_base_is_a_usage_error(self): code, out = self.run_affected("--dry-run", base="no-such-branch") self.assertEqual(code, 2, out) self.assertIn("[affected] ERROR base no-such-branch does not resolve", out) def test_default_base_is_card_immediate_target(self): self.git(self.repo, "push", "-q", "origin", "main:feature/init") self.git(self.repo, "fetch", "-q", "origin") self.write(".task/child.md", "# child\n- 工作区:/tmp/x;分支:feature/work\n" "- 分支类型:child;initiative_id:init;root_target:main;immediate_target:feature/init\n") self.commit("Card") code, out = self.run_affected("--dry-run") self.assertEqual(code, 0, out) self.assertEqual(self.summary(out)["base"], "origin/feature/init", out) self.git(self.repo, "checkout", "-q", "--detach") code, out = self.run_affected("--dry-run") self.assertEqual(self.summary(out)["base"], "origin/main", out) if __name__ == "__main__": unittest.main()