diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 827c91ad..f37c1209 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -526,6 +526,36 @@ class EvidenceTests(unittest.TestCase): self.assertIn("local continuation validated", result.stdout) self.assertEqual(self.git("rev-parse", "HEAD", cwd=wt), task_head) + def test_local_continuation_accepts_when_target_ref_reappears(self): + """A resume-local receipt captured with no remote-tracking target ref + validates after a forward target ref reappears, provided R0 stays locally + reachable and the reappeared target is a forward descendant of R0. + + The receipt records target_head=None; the rewind check is skipped because + either side is missing, and the reappeared ref (a forward descendant of R0) + must satisfy the ancestry guard that the registered task is still current.""" + wt = self.local_task() + head_before = self.git("rev-parse", "HEAD", cwd=wt) + # Drop refs/remotes/origin/main BEFORE capture so the receipt records + # target_head=None. R0 (the recorded initial baseline) must remain + # reachable as a local object. + self.git("update-ref", "-d", "refs/remotes/origin/main") + self.git("cat-file", "-e", self.head) + self.capture("make precheck", cwd=wt) + captured = json.loads(self.receipt.read_text()) + self.assertIsNone(captured["after"]["target_head"]) + # Recreate refs/remotes/origin/main as a forward descendant of R0. + forward = self.git("commit-tree", "-p", self.head, + "-m", "Forward target ref reappeared", self.head + "^{tree}") + self.git("update-ref", "refs/remotes/origin/main", forward) + # Validation must succeed: receipt has no target_head (rewind check is + # skipped) and the reappeared target descends from R0 (ancestry guard + # accepts a forward target ref). + result = self.validate(wt) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("local continuation validated", result.stdout) + self.assertEqual(self.git("rev-parse", "HEAD", cwd=wt), head_before) + def test_local_precheck_captures_without_remote_tracking_ref(self): wt = self.local_task() head_before = self.git("rev-parse", "HEAD", cwd=wt) diff --git a/tests/test_task_recovery.py b/tests/test_task_recovery.py index 50ac0cfa..86ed8b96 100644 --- a/tests/test_task_recovery.py +++ b/tests/test_task_recovery.py @@ -144,6 +144,56 @@ class TaskRecoveryTests(unittest.TestCase): self.assertNotIn("[verdict] LOCAL_READY", delivery.stdout) self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + def test_local_precheck_rejects_closed_task(self): + """A closed registered task (已完成(合入前闭合)) must not advertise LOCAL_READY. + + The current local precheck emits LOCAL_READY for any registered in-progress + card text and never inspects the card status, so this test records the + expected red result: the verdict is wrong and the exit code is wrong.""" + f = self.fixture + self.card.write_text(self.card.read_text().replace("状态:进行中", + "状态:已完成(合入前闭合)")) + self.save_card_change() + self.assertIn("已完成(合入前闭合)", self.card.read_text()) + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + local = self.precheck() + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + + def test_local_precheck_rejects_reachable_target_rewind(self): + """Origin is reachable but its target was rewound after the task cached a + newer descendant. Local precheck must not emit LOCAL_READY under that + divergence — the cached refs/remotes/origin/main is no longer trustworthy. + + The current implementation lets fetch_target() surface a non-FFREMOTE_UNVERIFIED + line, then unconditionally prints LOCAL_READY in local mode. The exit code + is wrong and the verdict is wrong; this test records both expected failures.""" + 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) Rewind the bare remote target back to R0; the worktree cache still + # points at the forward descendant because we have not fetched since. + f.git(f.remote, "update-ref", "refs/heads/main", r0, forward) + self.assertEqual(f.git(f.remote, "rev-parse", "refs/heads/main").stdout.strip(), r0) + self.assertEqual(f.git(self.wt, "rev-parse", "origin/main").stdout.strip(), forward) + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + # (3) Local precheck sees a reachable origin whose target is older than the + # cache. fetch_target() refuses the non-fast-forward update; the precheck + # must still refuse LOCAL_READY. + local = self.precheck() + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", 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)