#!/usr/bin/env python3 """按分支改动选择最小直接检查:host 实施中的 make 目标子集(完整套件仍以 CI 为准)。 用法:test_affected.py [--base REF] [--dry-run] base:--base,否则环境变量 BASE,否则当前分支卡片的 immediate_target(task_pr_check 助手), 再否则 origin/main;裸分支名在 origin/ 存在时解析为 origin/。 改动路径 = `git diff ...HEAD` ∪ 未提交的已跟踪改动(相对 HEAD)。路径到目标: scripts/** tests/** .gitea/** policy/** Makefile AGENTS.md AGENT-EXECUTION.md PROJECT.md → test-governance modules/{core,backend,frontend,ui} → test-{core,backend,frontend,ui};deps/agent-command → test-agentcmd; deps/agent-{intent,native,squad} → test-libs;gitlink 本身或 .gitmodules → test-pins; modules/** 或 scripts/lint-boundaries.sh → test-boundaries;其余(.task/**、docs/**、其他 *.md)不选。 选中任一目标时先跑 test-worktree;单次 `make ` 执行,退出码随 make。 退出码:make 的退出码;无目标或 --dry-run 为 0;用法/ref 错误为 2。仅使用标准库。 """ import argparse import os import subprocess import sys DEFAULT_BASE = "origin/main" EXIT_USAGE = 2 GITLINK_MODE = "160000" ORDER = ("test-governance", "test-boundaries", "test-pins", "test-libs", "test-core", "test-backend", "test-frontend", "test-ui", "test-agentcmd") GOVERNANCE_DIRS = ("scripts/", "tests/", ".gitea/", "policy/") GOVERNANCE_FILES = ("Makefile", "AGENTS.md", "AGENT-EXECUTION.md", "PROJECT.md") COMPONENTS = { "modules/core": "test-core", "modules/backend": "test-backend", "modules/frontend": "test-frontend", "modules/ui": "test-ui", "deps/agent-command": "test-agentcmd", "deps/agent-intent": "test-libs", "deps/agent-native": "test-libs", "deps/agent-squad": "test-libs", } class Stop(Exception): pass def git(*args): proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace") if proc.returncode != 0: raise Stop(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.returncode}") return proc.stdout def ref_exists(ref): return subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], capture_output=True).returncode == 0 def card_target(): """当前分支卡片的 immediate_target;无分支/无助手/无卡时返回 None。""" try: sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import task_pr_check meta, text = task_pr_check.branch_meta(task_pr_check.current_branch(), "main") except Exception: return None return meta.get("immediate_target") if text is not None else None def resolve_base(requested): name = (requested or os.environ.get("BASE", "")).strip() or card_target() or DEFAULT_BASE if not name.startswith("origin/") and ref_exists(f"refs/remotes/origin/{name}"): name = f"origin/{name}" if not ref_exists(name): raise Stop(f"base {name} does not resolve to a commit") return name def raw_changes(*diff_args): """{path: is_gitlink},取自 `git diff --raw -z --no-renames`。""" out = git("diff", "--raw", "-z", "--no-renames", *diff_args) fields = out.split("\0") changes = {} for meta, path in zip(fields[0::2], fields[1::2]): modes = meta.lstrip(":").split()[:2] changes[path] = changes.get(path, False) or GITLINK_MODE in modes return changes def changed_paths(base): merge_base = git("merge-base", base, "HEAD").strip() changes = raw_changes(merge_base, "HEAD") for path, gitlink in raw_changes("HEAD").items(): changes[path] = changes.get(path, False) or gitlink return changes def targets_for(path, gitlink): found = set() if path.startswith(GOVERNANCE_DIRS) or path in GOVERNANCE_FILES: found.add("test-governance") for prefix, target in COMPONENTS.items(): if path == prefix or path.startswith(prefix + "/"): found.add(target) if gitlink or path == ".gitmodules": found.add("test-pins") if path.startswith("modules/") or path == "scripts/lint-boundaries.sh": found.add("test-boundaries") return found def select(changes): found = set() for path, gitlink in changes.items(): found |= targets_for(path, gitlink) return ["test-worktree", *(t for t in ORDER if t in found)] if found else [] def main(argv=None): parser = argparse.ArgumentParser(prog="test_affected.py", description="按分支改动运行最小直接检查") parser.add_argument("--base") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args(argv) try: os.chdir(git("rev-parse", "--show-toplevel").strip()) base = resolve_base(args.base) changes = changed_paths(base) except Stop as stop: print(f"[affected] ERROR {stop}") return EXIT_USAGE targets = select(changes) print(f"[affected] base={base} changed={len(changes)} targets={','.join(targets) or 'none'}", flush=True) if not targets: print("[affected] nothing to test") return 0 if args.dry_run: print(f"[affected] dry-run: make {' '.join(targets)}") return 0 return subprocess.run(["make", *targets]).returncode if __name__ == "__main__": sys.exit(main())