142 lines
5.4 KiB
Python
142 lines
5.4 KiB
Python
#!/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/<name> 存在时解析为 origin/<name>。
|
||
|
||
改动路径 = `git diff <merge-base>...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 <targets>` 执行,退出码随 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())
|