From df67d33e9e4889474caa0b2c1332d215f98099c5 Mon Sep 17 00:00:00 2001 From: Steve C Date: Sun, 4 Oct 2026 09:14:18 +0000 Subject: [PATCH 1/2] test: preserve qualified merge repair across admission transport --- tests/test_qualified_merge_reland.py | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/test_qualified_merge_reland.py diff --git a/tests/test_qualified_merge_reland.py b/tests/test_qualified_merge_reland.py new file mode 100644 index 0000000..bf1b554 --- /dev/null +++ b/tests/test_qualified_merge_reland.py @@ -0,0 +1,37 @@ +"""An admission owner's qualified merge repair survives transport to the train.""" +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest + +from svrf.admission import Admission +from svrf import rules + + +class QualifiedMergeReland(unittest.TestCase): + def read(self, lines): + with tempfile.TemporaryDirectory() as temp: + git = SimpleNamespace(root=Path(temp), is_ancestor=lambda *a: False, + merge_preview=lambda *a: {'status': 'CLEAN', 'conflicts': []}, + changed_paths=lambda *a: ['src/runtime.py']) + command = "printf '%s\\n' " + ' '.join("'"+line+"'" for line in lines) + '; exit 1' + return Admission(git, command=command)('head', 'base') + + def test_qualified_command_result_relands_the_same_tree(self): + line = 'reland:REFUSED:MERGE_LOSS' + value = self.read([line]) + self.assertEqual(value['residuals'], [line]) + self.assertEqual(rules.admission_decision(value), ('RELAND', 'MERGE_LOSS')) + self.assertEqual(rules.admission_decision(value, reland=False), ('HOLD', None)) + + def test_unqualified_loss_and_other_failures_remain_held(self): + for line in ('history:REFUSED:MERGE_LOSS', 'dispositions:REFUSED:MERGE_LOSS', + 'check:MERGE_LOSS', 'MERGE_LOSS'): + with self.subTest(line=line): + self.assertIsNone(rules.reland_class([line])) + self.assertEqual(rules.admission_decision(self.read([line])), ('HOLD', None)) + self.assertEqual(rules.admission_decision(self.read([ + 'reland:REFUSED:MERGE_LOSS', 'source mismatch'])), ('HOLD', None)) + + +if __name__ == '__main__': unittest.main() From 23bfbe1ea1bc3c86d0a8a81ecb2edc54e69341b2 Mon Sep 17 00:00:00 2001 From: Steve C Date: Sun, 4 Oct 2026 09:22:15 +0000 Subject: [PATCH 2/2] fix: preserve externally qualified merge reconstruction requests --- src/svrf/rules.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/svrf/rules.py b/src/svrf/rules.py index 8331e08..1fd8f5d 100644 --- a/src/svrf/rules.py +++ b/src/svrf/rules.py @@ -180,9 +180,11 @@ def admission(row: dict, held: dict | None, watch_digest: str | None = None, car MERGE_CONFLICT = "merge:CONFLICT:" STALE_LINES = ("github:NOT_MERGEABLE",) # `history:` is the built-in tests-first check's own prefix; `reland:` is the one an -# external admission.command uses to report the same order-only refusal (see -# admission.py's PASSTHROUGH_PREFIXES). Both name the identical class of refusal. -RELAND_LINE = re.compile(r"^(?:history|reland):REFUSED:(UNORDERED|MIXED)$") +# external admission.command uses to report a qualified order-only refusal. +# MERGE_LOSS requires that external qualification: the command must receive +# preservation of the final tree before requesting its ordered reconstruction. +RELAND_LINE = re.compile( + r"^(?:(?:history|reland):REFUSED:(UNORDERED|MIXED)|reland:REFUSED:(MERGE_LOSS))$") def repair_class(lines: list[str], is_union: Callable[[str], bool]) -> str | None: @@ -200,15 +202,17 @@ def repair_class(lines: list[str], is_union: Callable[[str], bool]) -> str | Non def reland_class(lines: list[str]) -> str | None: - """UNORDERED or MIXED when every residual is a history-order refusal of one class: - the order of commits, not their content, which the train fixes by re-landing the same - final tree as an ordered history. Any other residual stays held for a person.""" + """One received history-order class, including externally qualified merge repair. + + The train re-lands the same final tree. Unqualified loss and mixed residual + classes retain their holds. + """ if not lines: return None matches = [RELAND_LINE.match(line) for line in lines] if not all(matches): return None - classes = {m.group(1) for m in matches} + classes = {m.group(1) or m.group(2) for m in matches} return classes.pop() if len(classes) == 1 else None