diff --git a/review_proofs.py b/review_proofs.py index b9a6646..9cd7e8c 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -3099,3 +3099,119 @@ def assess_email_disclosure( for email in emails ], } + + +# --------------------------------------------------------------------------- +# Git ref mutations (#297) +# --------------------------------------------------------------------------- + +_GIT_REF_MUTATIONS_FIELD_RE = re.compile( + r"^\s*git ref mutations\s*:\s*(.+?)\s*$", re.I | re.M) +_GIT_WORKTREE_MUTATIONS_NONE_RE = re.compile( + r"^\s*git/worktree mutations\s*:\s*none\b", re.I | re.M) + +_GIT_REF_MUTATING_FIRST_WORDS = frozenset( + {"fetch", "pull", "push", "merge", "update-ref"}) +_GIT_REF_MUTATING_PREFIXES = ("remote update", "branch -d", "branch -D", + "reset --hard") + + +def _command_text(entry): + if isinstance(entry, dict): + return str(entry.get("command") or "").strip() + return str(entry or "").strip() + + +def _git_subcommand_line(command): + tokens = command.split() + if not tokens or tokens[0] != "git": + return None + return " ".join(tokens[1:]) + + +def git_ref_mutating_commands(command_log): + """Return logged commands that update git refs (#297). + + ``git fetch`` and friends edit no files but move refs; they are never + read-only diagnostics. + """ + out = [] + for entry in command_log or []: + command = _command_text(entry) + rest = _git_subcommand_line(command) + if rest is None: + continue + first = rest.split()[0] if rest.split() else "" + if first in _GIT_REF_MUTATING_FIRST_WORDS: + out.append(command) + continue + if any(rest.startswith(prefix) for prefix in _GIT_REF_MUTATING_PREFIXES): + out.append(command) + return out + + +def _fetch_targets(ref_commands): + """Extract ``/`` targets from git fetch commands.""" + targets = [] + for command in ref_commands: + rest = _git_subcommand_line(command) or "" + tokens = rest.split() + if not tokens or tokens[0] != "fetch": + continue + args = [t for t in tokens[1:] if not t.startswith("-")] + if len(args) >= 2: + targets.append(f"{args[0]}/{args[1]}") + return targets + + +def assess_git_ref_mutation_report(report_text, *, command_log=None): + """#297: ref updates are Git ref mutations, not read-only diagnostics. + + When the command log holds ref-updating commands, the report must carry + a non-none ``Git ref mutations`` entry (naming ``fetched + /`` for targeted fetches), may not claim ``Git/worktree + mutations: None``, and may not list the command as read-only. + """ + text = report_text or "" + lower = text.lower() + reasons = [] + ref_commands = git_ref_mutating_commands(command_log) + fetch_targets = _fetch_targets(ref_commands) + + if ref_commands: + field = _GIT_REF_MUTATIONS_FIELD_RE.search(text) + value = field.group(1).strip().lower() if field else None + if not value or value == "none": + reasons.append( + "ref-updating commands ran but the report has no " + "'Git ref mutations' entry" + ) + for target in fetch_targets: + if "fetched" not in lower or target.lower() not in lower: + reasons.append( + "git fetch ran; report must state " + f"'Git ref mutations: fetched {target}'" + ) + if _GIT_WORKTREE_MUTATIONS_NONE_RE.search(text): + reasons.append( + "'Git/worktree mutations: None' rejected: ref-updating " + "commands ran" + ) + for line in text.splitlines(): + if "read-only" not in line.lower(): + continue + for command in ref_commands: + if command.lower() in line.lower(): + reasons.append( + "read-only diagnostics may not include ref-updating " + f"command '{command}'" + ) + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "ref_mutating_commands": ref_commands, + "fetch_targets": fetch_targets, + } diff --git a/tests/test_git_ref_mutation_report.py b/tests/test_git_ref_mutation_report.py new file mode 100644 index 0000000..c8ce29c --- /dev/null +++ b/tests/test_git_ref_mutation_report.py @@ -0,0 +1,101 @@ +"""Git ref mutations must be reported, never hidden as diagnostics (#297). + +``git fetch`` updates remote-tracking refs even though it edits no files. +Reports that ran fetch (or any ref-updating command) must carry a +``Git ref mutations`` entry and may not claim ``Git/worktree mutations: +None`` or classify the fetch as read-only diagnostics. +""" +import sys +import unittest + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import review_proofs + + +def _assess(report, commands): + return review_proofs.assess_git_ref_mutation_report( + report, command_log=commands) + + +class TestGitRefMutationDetection(unittest.TestCase): + def test_fetch_only_review_with_proper_ledger_passes(self): + report = "\n".join([ + "Git ref mutations: fetched prgs/master", + "Review decision: APPROVE", + ]) + res = _assess(report, ["git fetch prgs master"]) + self.assertTrue(res["proven"], res["reasons"]) + self.assertEqual(res["ref_mutating_commands"], ["git fetch prgs master"]) + + def test_fetch_without_ref_mutation_line_is_blocked(self): + report = "Review decision: APPROVE\nMutations: None\n" + res = _assess(report, ["git fetch prgs master"]) + self.assertFalse(res["proven"]) + self.assertTrue( + any("git ref mutations" in r.lower() for r in res["reasons"])) + + def test_fetch_with_remote_branch_requires_fetched_target_in_report(self): + report = "Git ref mutations: some refs updated\n" + res = _assess(report, ["git fetch prgs master"]) + self.assertFalse(res["proven"]) + self.assertTrue( + any("prgs/master" in r for r in res["reasons"])) + + def test_git_worktree_mutations_none_rejected_when_fetch_occurred(self): + report = "\n".join([ + "Git ref mutations: fetched prgs/master", + "Git/worktree mutations: None", + ]) + res = _assess(report, ["git fetch prgs master"]) + self.assertFalse(res["proven"]) + self.assertTrue( + any("git/worktree mutations" in r.lower() for r in res["reasons"])) + + def test_fetch_classified_as_read_only_diagnostics_rejected(self): + report = "\n".join([ + "Git ref mutations: fetched prgs/master", + "Read-only diagnostics: git fetch prgs master, git status", + ]) + res = _assess(report, ["git fetch prgs master"]) + self.assertFalse(res["proven"]) + self.assertTrue( + any("read-only" in r.lower() for r in res["reasons"])) + + def test_worktree_creation_is_not_a_ref_mutation(self): + report = "Worktree mutations: created branches/review-pr9\n" + res = _assess(report, ["git worktree add branches/review-pr9 prgs/master"]) + self.assertTrue(res["proven"], res["reasons"]) + self.assertEqual(res["ref_mutating_commands"], []) + + def test_merge_command_counts_as_ref_mutation(self): + report = "Review decision: APPROVE\n" + res = _assess(report, ["git merge --no-ff feat/x"]) + self.assertFalse(res["proven"]) + self.assertIn("git merge --no-ff feat/x", res["ref_mutating_commands"]) + + def test_cleanup_branch_delete_counts_as_ref_mutation(self): + report = "Cleanup mutations: deleted branch feat/x\n" + res = _assess(report, ["git branch -d feat/x"]) + self.assertFalse(res["proven"]) + self.assertIn("git branch -d feat/x", res["ref_mutating_commands"]) + + def test_no_mutation_blocked_handoff_passes(self): + report = "\n".join([ + "Git/worktree mutations: None", + "Current status: blocked handoff, no mutations performed", + ]) + res = _assess(report, ["git status --porcelain", "git log --oneline -1"]) + self.assertTrue(res["proven"], res["reasons"]) + self.assertEqual(res["ref_mutating_commands"], []) + + def test_command_log_dict_entries_supported(self): + report = "Git ref mutations: fetched prgs/master\n" + res = _assess(report, [{"command": "git fetch prgs master"}]) + self.assertTrue(res["proven"], res["reasons"]) + self.assertEqual( + res["ref_mutating_commands"], ["git fetch prgs master"]) + + +if __name__ == "__main__": + unittest.main()