@@ -204,6 +204,68 @@ def test_approved_repo_executed_bare_refuses(self):
204204 )
205205
206206
207+ class TestMatchHeadCommitDroppedConstraint :
208+ """--match-head-commit <sha> is a value-carrying SAFETY constraint (merge only
209+ if HEAD is still <sha>) — the canonical DROPPED-CONSTRAINT case set-equality
210+ catches that a subset rule (executed ⊆ approved) would silently ALLOW. Bound
211+ value-taking on the merge op-class (R4 denylist add).
212+
213+ NON-VACUITY (essential): the approval token is SCANNER-DERIVED via
214+ extract_command_context, NOT hand-built. A hand-built
215+ {--match-head-commit=ABC123} token would REFUSE the bare execute REGARDLESS of
216+ whether --match-head-commit is in the denylist — the token/cmd asymmetry would
217+ drive the refusal, not the binding (vacuous w.r.t. the R4 config add). By
218+ deriving the token through the live scanner, a SOURCE-ONLY revert of the R4
219+ denylist add makes the scanner bind [] for --match-head-commit → the approval
220+ token's bound_flags collapse to {} → {} == {} → the dropped constraint
221+ AUTHORIZES → these REFUSE tests flip RED (the dropped-constraint, added, and
222+ different-value cases; the identical-match positive stays GREEN)."""
223+
224+ _APPROVAL = "gh pr merge 5 --match-head-commit ABC123"
225+
226+ @staticmethod
227+ def _scanner_token (approval_command : str ) -> dict :
228+ """Build the approval token by SCANNING the approval command through the
229+ production SSOT, so bound_flags reflect the LIVE denylist (couples the
230+ dropped-constraint refusal to the --match-head-commit binding)."""
231+ from shared .merge_guard_common import extract_command_context
232+
233+ return {"context" : extract_command_context (approval_command )}
234+
235+ def test_dropped_constraint_refuses (self ):
236+ """Approve WITH the safety constraint, execute WITHOUT it → REFUSE — the
237+ dropped-constraint direction a subset rule would silently allow."""
238+ from merge_guard_pre import _token_matches_command
239+
240+ assert not _token_matches_command (self ._scanner_token (self ._APPROVAL ), "gh pr merge 5" )
241+
242+ def test_added_constraint_refuses (self ):
243+ """Approve bare, execute WITH --match-head-commit → REFUSE (added direction;
244+ the executed side is scanned live)."""
245+ from merge_guard_pre import _token_matches_command
246+
247+ assert not _token_matches_command (
248+ self ._scanner_token ("gh pr merge 5" ), self ._APPROVAL
249+ )
250+
251+ def test_different_constraint_value_refuses (self ):
252+ """Approve --match-head-commit ABC123, execute --match-head-commit DEF456 →
253+ REFUSE — a DIFFERENT head-sha is a different binding (the VALUE is bound,
254+ not merely the flag's presence)."""
255+ from merge_guard_pre import _token_matches_command
256+
257+ assert not _token_matches_command (
258+ self ._scanner_token (self ._APPROVAL ), "gh pr merge 5 --match-head-commit DEF456"
259+ )
260+
261+ def test_identical_constraint_authorizes (self ):
262+ """Approve and execute with the SAME constraint → AUTHORIZE — a faithful
263+ re-execution is not over-blocked."""
264+ from merge_guard_pre import _token_matches_command
265+
266+ assert _token_matches_command (self ._scanner_token (self ._APPROVAL ), self ._APPROVAL )
267+
268+
207269# ════════════════════════════════════════════════════════════════════════════
208270# READ ARM — bounded over-block: an EXACT (form-invariant) match AUTHORIZES
209271# ════════════════════════════════════════════════════════════════════════════
@@ -305,6 +367,9 @@ class TestScannerCanonicalForms:
305367 # cluster with an UNBOUND short (-s ignored), bound -d kept
306368 ("gh pr merge 5 -sd" , "merge" , ["--delete-branch" ]),
307369 ("gh pr merge 5 -d" , "merge" , ["--delete-branch" ]),
370+ # --match-head-commit (value-taking SAFETY constraint, R4): spaced + =-joined
371+ ("gh pr merge 5 --match-head-commit ABC123" , "merge" , ["--match-head-commit=ABC123" ]),
372+ ("gh pr merge 5 --match-head-commit=ABC123" , "merge" , ["--match-head-commit=ABC123" ]),
308373 # benign: no bound flag → empty set
309374 ("gh pr merge 5" , "merge" , []),
310375 # close op-class: -R bound; --delete-branch is the op-trigger (NOT bound)
0 commit comments