Skip to content

Commit 413c5fa

Browse files
committed
feat(docs): doc-truth-up FF-merge helper + safety-gate tests
Completes the kit: FF-merge each reconciliation branch into the branch it was cut from, then safe-delete it. plan_merge() is pure inspection and refuses anything that isn't 'base + exactly one doc-only commit': - branch must exist and the current branch must be its ANCESTOR (merge-base == cur) — a diverged base means ff-only is unsafe, so SKIP (e.g. MCPAudit). - exactly 1 commit ahead of the base branch, else SKIP. - that commit re-verified doc-only here (defense in depth over the runner). - coincidental same-named branches with 0 commits ahead SKIP (e.g. SignalDecay). merge_one() then does git merge --ff-only (never a merge commit, never loses history, fails closed) + git branch -d (safe delete). Never pushes. Tests exercise the gate against a real temp git repo: happy ff, non-doc commit, diverged base, multi-commit, missing branch, plus end-to-end merge+delete.
1 parent 117b784 commit 413c5fa

2 files changed

Lines changed: 304 additions & 0 deletions

File tree

scripts/doc_truth_up_merge.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env python3
2+
"""Fast-forward-merge each `/doc-truth-up` reconciliation branch into the branch it
3+
was cut from, then delete the reconciliation branch. Never pushes.
4+
5+
A reconciliation is, by construction, ``base + exactly one doc-only commit``. This
6+
merger re-establishes that contract independently of the runner before touching
7+
anything, so a corrupt or hand-edited branch can never slip through:
8+
9+
1. The reconciliation branch exists and is exactly ONE commit ahead of ``branch~1``.
10+
2. That commit is doc-only (re-verified here — defense in depth over the runner's
11+
own backstop).
12+
3. The repo is currently on the branch the reconciliation was cut from, and that
13+
branch's tip still equals ``branch~1`` (i.e. the base has NOT moved). Otherwise a
14+
fast-forward would be unsafe / would fold in divergence — SKIP and report.
15+
16+
Only when all three hold does it ``git merge --ff-only`` (never a merge commit, never
17+
loses history, fails closed if the base moved) and then ``git branch -d`` (safe delete,
18+
refuses an unmerged branch). It NEVER pushes and NEVER force-anything.
19+
20+
DEFAULT IS DRY-RUN. Pass ``--execute`` to actually merge.
21+
22+
Usage:
23+
python scripts/doc_truth_up_merge.py # dry-run plan
24+
python scripts/doc_truth_up_merge.py --repo ArguMap # dry-run one repo
25+
python scripts/doc_truth_up_merge.py --execute # merge + delete all
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import argparse
31+
import json
32+
import subprocess
33+
from datetime import datetime
34+
from pathlib import Path
35+
36+
REPO_ROOT = Path(__file__).resolve().parent.parent
37+
DEFAULT_TARGETS = REPO_ROOT / "output" / "doc-truth-up-targets.json"
38+
ALLOWED_DOC_FILES = {"README.md", "CLAUDE.md", "AGENTS.md", "DOC-RECONCILIATION.md"}
39+
40+
41+
def _git(repo: str, *args: str) -> subprocess.CompletedProcess:
42+
return subprocess.run(
43+
["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=60
44+
)
45+
46+
47+
def _doc_only(changed: list[str]) -> list[str]:
48+
"""Return the subset of changed paths that are NOT allowed documentation."""
49+
return [
50+
f for f in changed if not (f in ALLOWED_DOC_FILES or f == "docs" or f.startswith("docs/"))
51+
]
52+
53+
54+
def plan_merge(repo: str, branch: str) -> dict:
55+
"""Decide — by inspection only, no mutation — whether ``branch`` can be safely
56+
fast-forwarded into the branch it was cut from.
57+
58+
Returns a dict with ``action`` ∈ {merge, skip} plus the reason (skip) or the
59+
fast-forward target + verified facts (merge).
60+
"""
61+
res: dict = {"branch": branch}
62+
if _git(repo, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").returncode != 0:
63+
return {**res, "action": "skip", "reason": "no reconciliation branch"}
64+
65+
cur = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
66+
if cur == "HEAD":
67+
return {**res, "action": "skip", "reason": "detached HEAD — cannot infer base branch"}
68+
69+
# The base (current branch) must be an ANCESTOR of the reconciliation branch —
70+
# i.e. the branch was cut from here and `cur` has not diverged. Then a
71+
# fast-forward is provably clean (no merge commit, no lost history).
72+
cur_sha = _git(repo, "rev-parse", cur).stdout.strip()
73+
if _git(repo, "merge-base", cur, branch).stdout.strip() != cur_sha:
74+
return {
75+
**res,
76+
"action": "skip",
77+
"reason": f"base branch '{cur}' diverged from reconciliation (ff-only unsafe)",
78+
}
79+
80+
# Exactly one commit ahead — a reconciliation is base + ONE doc-only commit.
81+
count = _git(repo, "rev-list", "--count", f"{cur}..{branch}").stdout.strip()
82+
if count != "1":
83+
return {
84+
**res,
85+
"action": "skip",
86+
"reason": f"expected exactly 1 commit ahead of '{cur}', found {count}",
87+
}
88+
89+
changed = _git(repo, "diff", "--name-only", f"{cur}..{branch}").stdout.split()
90+
violations = _doc_only(changed)
91+
if violations:
92+
return {**res, "action": "skip", "reason": "non-doc files in commit", "non_doc": violations}
93+
94+
return {**res, "action": "merge", "target": cur, "changed": changed}
95+
96+
97+
def merge_one(repo: str, branch: str, execute: bool) -> dict:
98+
plan = plan_merge(repo, branch)
99+
if plan["action"] != "merge" or not execute:
100+
return plan
101+
# plan_merge verified the repo is on the target branch and it is an ancestor of
102+
# the reconciliation branch, so --ff-only is a guaranteed-clean fast-forward.
103+
m = _git(repo, "merge", "--ff-only", branch)
104+
if m.returncode != 0:
105+
return {**plan, "action": "error", "reason": f"ff-merge failed: {m.stderr.strip()[:200]}"}
106+
d = _git(repo, "branch", "-d", branch)
107+
if d.returncode != 0:
108+
return {
109+
**plan,
110+
"action": "merged_kept_branch",
111+
"reason": f"merged, but safe-delete failed: {d.stderr.strip()[:200]}",
112+
}
113+
return {**plan, "action": "merged"}
114+
115+
116+
def main() -> None:
117+
ap = argparse.ArgumentParser(description="FF-merge /doc-truth-up reconciliation branches.")
118+
ap.add_argument("--targets", default=str(DEFAULT_TARGETS))
119+
ap.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"))
120+
ap.add_argument("--repo", default="", help="Merge a single repo by project_key.")
121+
ap.add_argument("--execute", action="store_true", help="Actually merge (default: dry-run).")
122+
args = ap.parse_args()
123+
124+
branch = f"docs/truth-up-{args.date}"
125+
targets = json.loads(Path(args.targets).read_text())
126+
if args.repo:
127+
targets = [t for t in targets if t["project_key"] == args.repo]
128+
129+
print(f"doc-truth-up merge · branch={branch} · {'EXECUTE' if args.execute else 'DRY-RUN'}\n")
130+
results = []
131+
for t in targets:
132+
repo = t["abs_path"]
133+
if not (Path(repo) / ".git").is_dir() or Path(repo).resolve() == REPO_ROOT:
134+
continue
135+
if _git(repo, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").returncode != 0:
136+
continue # no reconciliation here — silent, keeps the report focused
137+
r = merge_one(repo, branch, args.execute)
138+
r["project_key"] = t["project_key"]
139+
results.append(r)
140+
tail = f" → {r['target']}" if r.get("target") else ""
141+
reason = f" ({r['reason']})" if r.get("reason") else ""
142+
verb = "would merge" if (r["action"] == "merge" and not args.execute) else r["action"]
143+
print(f" {t['project_key']:46} {verb}{tail}{reason}")
144+
145+
by_action: dict[str, int] = {}
146+
for r in results:
147+
by_action[r["action"]] = by_action.get(r["action"], 0) + 1
148+
print(f"\n{by_action}")
149+
if not args.execute:
150+
print("(dry-run — pass --execute to merge + delete. Nothing is ever pushed.)")
151+
152+
153+
if __name__ == "__main__":
154+
main()

tests/test_doc_truth_up_merge.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Tests for the doc-truth-up FF-merge helper's safety gate.
2+
3+
``plan_merge`` is the decision that guards a 49-repo branch mutation, so it is
4+
exercised against a real temporary git repo for each branch: the happy
5+
fast-forward, a non-doc commit (must skip), a moved base (must skip), and a
6+
missing branch (must skip). ``merge_one`` is then run end-to-end to confirm the
7+
fast-forward lands and the reconciliation branch is safe-deleted.
8+
9+
The helper lives in ``scripts/`` (not the ``src`` package); load it by file path.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import importlib.util
15+
import subprocess
16+
from pathlib import Path
17+
18+
_SPEC = importlib.util.spec_from_file_location(
19+
"doc_truth_up_merge",
20+
Path(__file__).resolve().parent.parent / "scripts" / "doc_truth_up_merge.py",
21+
)
22+
merge = importlib.util.module_from_spec(_SPEC)
23+
_SPEC.loader.exec_module(merge)
24+
25+
BR = "docs/truth-up-2026-05-30"
26+
27+
28+
def _git(repo: Path, *args: str) -> None:
29+
subprocess.run(
30+
["git", "-C", str(repo), *args],
31+
check=True,
32+
capture_output=True,
33+
text=True,
34+
)
35+
36+
37+
def _init_repo(tmp_path: Path) -> Path:
38+
repo = tmp_path / "repo"
39+
repo.mkdir()
40+
_git(repo, "init", "-b", "main")
41+
_git(repo, "config", "user.email", "t@t.test")
42+
_git(repo, "config", "user.name", "Test")
43+
(repo / "README.md").write_text("# original\n")
44+
_git(repo, "add", "README.md")
45+
_git(repo, "commit", "-m", "base")
46+
return repo
47+
48+
49+
def _make_recon_branch(repo: Path, *, files: dict[str, str]) -> None:
50+
"""Create the reconciliation branch with one commit touching `files`, then
51+
return the repo to `main` (mirrors run_one restoring to the origin branch)."""
52+
_git(repo, "checkout", "-b", BR)
53+
for name, content in files.items():
54+
p = repo / name
55+
p.parent.mkdir(parents=True, exist_ok=True)
56+
p.write_text(content)
57+
_git(repo, "add", name)
58+
_git(repo, "commit", "-m", "docs: reconcile")
59+
_git(repo, "checkout", "main")
60+
61+
62+
class TestPlanMerge:
63+
def test_happy_doc_only_fast_forward(self, tmp_path: Path):
64+
repo = _init_repo(tmp_path)
65+
_make_recon_branch(
66+
repo, files={"README.md": "# reconciled\n", "DOC-RECONCILIATION.md": "log\n"}
67+
)
68+
plan = merge.plan_merge(str(repo), BR)
69+
assert plan["action"] == "merge"
70+
assert plan["target"] == "main"
71+
72+
def test_skips_when_no_branch(self, tmp_path: Path):
73+
repo = _init_repo(tmp_path)
74+
plan = merge.plan_merge(str(repo), BR)
75+
assert plan["action"] == "skip"
76+
assert plan["reason"] == "no reconciliation branch"
77+
78+
def test_skips_when_commit_touches_non_doc(self, tmp_path: Path):
79+
repo = _init_repo(tmp_path)
80+
_make_recon_branch(repo, files={"README.md": "# r\n", "src/main.py": "print(1)\n"})
81+
plan = merge.plan_merge(str(repo), BR)
82+
assert plan["action"] == "skip"
83+
assert plan["reason"] == "non-doc files in commit"
84+
assert plan["non_doc"] == ["src/main.py"]
85+
86+
def test_skips_when_base_moved(self, tmp_path: Path):
87+
repo = _init_repo(tmp_path)
88+
_make_recon_branch(repo, files={"README.md": "# reconciled\n"})
89+
# main advances after the reconciliation was cut → ff-only is unsafe.
90+
(repo / "OTHER.md").write_text("later\n")
91+
_git(repo, "add", "OTHER.md")
92+
_git(repo, "commit", "-m", "later work on main")
93+
plan = merge.plan_merge(str(repo), BR)
94+
assert plan["action"] == "skip"
95+
assert "diverged" in plan["reason"]
96+
97+
def test_skips_when_more_than_one_commit_ahead(self, tmp_path: Path):
98+
repo = _init_repo(tmp_path)
99+
_git(repo, "checkout", "-b", BR)
100+
for i in range(2):
101+
(repo / "CLAUDE.md").write_text(f"rev {i}\n")
102+
_git(repo, "add", "CLAUDE.md")
103+
_git(repo, "commit", "-m", f"docs {i}")
104+
_git(repo, "checkout", "main")
105+
plan = merge.plan_merge(str(repo), BR)
106+
assert plan["action"] == "skip"
107+
assert "expected exactly 1 commit" in plan["reason"]
108+
109+
110+
class TestMergeOne:
111+
def test_executes_fast_forward_and_deletes_branch(self, tmp_path: Path):
112+
repo = _init_repo(tmp_path)
113+
_make_recon_branch(repo, files={"README.md": "# reconciled\n"})
114+
recon_tip = subprocess.run(
115+
["git", "-C", str(repo), "rev-parse", BR], capture_output=True, text=True
116+
).stdout.strip()
117+
118+
r = merge.merge_one(str(repo), BR, execute=True)
119+
120+
assert r["action"] == "merged"
121+
# main fast-forwarded to the reconciliation commit
122+
main_tip = subprocess.run(
123+
["git", "-C", str(repo), "rev-parse", "main"], capture_output=True, text=True
124+
).stdout.strip()
125+
assert main_tip == recon_tip
126+
# reconciliation branch deleted
127+
assert (
128+
subprocess.run(
129+
["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"refs/heads/{BR}"],
130+
capture_output=True,
131+
).returncode
132+
!= 0
133+
)
134+
# the reconciled content is on main, no merge commit (linear history)
135+
assert (repo / "README.md").read_text() == "# reconciled\n"
136+
137+
def test_dry_run_does_not_mutate(self, tmp_path: Path):
138+
repo = _init_repo(tmp_path)
139+
_make_recon_branch(repo, files={"README.md": "# reconciled\n"})
140+
r = merge.merge_one(str(repo), BR, execute=False)
141+
assert r["action"] == "merge" # planned, not performed
142+
# branch still exists, main untouched
143+
assert (
144+
subprocess.run(
145+
["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"refs/heads/{BR}"],
146+
capture_output=True,
147+
).returncode
148+
== 0
149+
)
150+
assert (repo / "README.md").read_text() == "# original\n"

0 commit comments

Comments
 (0)