diff --git a/tests/test_evidence.py b/tests/test_evidence.py index f37c1209..fac6bae3 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -659,6 +659,34 @@ class EvidenceTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn(message, result.stderr) + def test_local_continuation_rejects_receipt_target_branch_mismatch(self): + """Resume-local must refuse a receipt whose before.target_branch or + after.target_branch points at a different branch than the registered + task target. Re-sign the fixture receipt with the existing helper so + the digest still validates, and assert the existing production + rejection that flags the receipt as belonging to a different task + branch (or, when the after snapshot already disagrees, the + authoritative-target-changed guard that fires first).""" + wt = self.local_task() + original = self.capture("make precheck", cwd=wt) + cases = ( + ("before.target_branch", + "Precheck belongs to a different task branch", + lambda d: d["before"].update(target_branch="feature/other")), + ("after.target_branch", + "Precheck authoritative target has changed", + lambda d: d["after"].update(target_branch="feature/other")), + ) + for name, message, change in cases: + with self.subTest(case=name): + data = json.loads(json.dumps(original)) + change(data) + data["output_sha256"] = hashlib.sha256(data["output"].encode()).hexdigest() + self.resign(data) + result = self.validate(wt) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_task_recovery.py b/tests/test_task_recovery.py index 86ed8b96..57cdfbfe 100644 --- a/tests/test_task_recovery.py +++ b/tests/test_task_recovery.py @@ -194,6 +194,61 @@ class TaskRecoveryTests(unittest.TestCase): # so a follow-up run can repeat the same check. self.assertEqual(f.git(self.wt, "rev-parse", "origin/main").stdout.strip(), forward) + def test_local_precheck_rejects_reachable_target_divergence(self): + """Origin is reachable but its target was replaced by a divergent commit + that shares R0 with the cached forward target without descending from it. + Local precheck must refuse LOCAL_READY and surface the divergence so the + owner can reconcile the target history instead of trusting the cache. + + Both the cached forward target and the divergent commit are locally + available so target_rewound() can compare them via merge-base.""" + f = self.fixture + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # (1) Advance the remote and let local precheck refresh the cached origin/main. + f.advance_remote() + refresh = self.precheck() + self.assertEqual(refresh.returncode, 0, refresh.stdout) + forward = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + self.assertNotEqual(forward, r0) + # (2) Build a divergent commit on top of R0 in the main checkout; it shares + # R0 with the cached forward target but does not descend from it. + f.git(f.repo, "switch", "--detach", r0) + (f.repo / "divergent-main").write_text("different target history\n") + f.git(f.repo, "add", "divergent-main") + f.git(f.repo, "commit", "-m", "Create divergent fixture history") + divergent = f.git(f.repo, "rev-parse", "HEAD").stdout.strip() + # Both targets must share R0 (otherwise they would just be unrelated refs). + self.assertEqual(f.git(f.repo, "merge-base", forward, divergent).stdout.strip(), r0) + # The cached forward target must NOT be an ancestor of the divergent commit. + self.assertEqual(f.git(f.repo, "merge-base", "--is-ancestor", forward, + divergent, check=False).returncode, 1) + # Both commits must be locally available so target_rewound() can inspect them. + self.assertEqual(f.git(self.wt, "cat-file", "-e", forward + "^{commit}", + check=False).returncode, 0) + self.assertEqual(f.git(self.wt, "cat-file", "-e", divergent + "^{commit}", + check=False).returncode, 0) + # (3) Push the divergent commit so the bare remote's object database sees it, + # then replace refs/heads/main on the bare remote with the divergent commit. + f.git(f.repo, "push", "origin", "HEAD:refs/heads/divergent") + f.git(f.remote, "update-ref", "refs/heads/main", divergent, forward) + self.assertEqual(f.git(f.remote, "rev-parse", "refs/heads/main").stdout.strip(), divergent) + # The worktree cache still points at the forward descendant because we have + # not fetched since. + self.assertEqual(f.git(self.wt, "rev-parse", "origin/main").stdout.strip(), forward) + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + # (4) Local precheck: fetch_target() refuses the non-fast-forward update to + # the divergent commit, target_rewound() then surfaces the divergence, and + # the precheck must not advertise LOCAL_READY. + local = self.precheck() + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout) + self.assertIn("rewound or diverged", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + # After the failed fetch the cache still points at the forward descendant + # so a follow-up run can repeat the same check. + self.assertEqual(f.git(self.wt, "rev-parse", "origin/main").stdout.strip(), forward) + def test_unknown_work_mode_is_rejected(self): result = self.precheck(mode="anything") self.assertNotEqual(result.returncode, 0, result.stdout)