Files
Agent-System-Dev/tests/test_task_recovery.py
T

678 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Local recovery is task-scoped and cannot grant delivery readiness."""
import json
import os
import shlex
import subprocess
import sys
import unittest
# 允许 `python3 -m unittest tests/test_task_recovery.py` 直接运行(与 discover 模式一致地导入同目录夹具)。
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_task as lifecycle # noqa: E402
class TaskRecoveryTests(unittest.TestCase):
def setUp(self):
self.fixture = lifecycle.TaskLifecycleTests(methodName="runTest")
self.fixture.setUp()
self.addCleanup(self.fixture.doCleanups)
self.wt = self.fixture.worktree()
self.card = self.fixture.record_task_card(self.wt)
self.receipt_number = 0
def precheck(self, mode="local", cwd=None):
f = self.fixture
return f.task(cwd or self.wt, "precheck", check=False,
env=f.env | {"TASK_WORK_MODE": mode})
def capture(self, mode="local"):
f = self.fixture
self.receipt_number += 1
path = f.root / f"recovery-{self.receipt_number}.json"
result = f.run_cmd(self.wt, "bash", "scripts/evidence.sh", check=False,
env=f.env | {"TASK_WORK_MODE": mode, "RECEIPT": str(path),
"CMD": "make precheck"})
self.assertEqual(result.returncode, 0, result.stdout)
return path
def resume(self, receipt, mode="local", cwd=None):
f = self.fixture
return f.task(cwd or self.wt, "resume", check=False,
env=f.env | {"TASK_WORK_MODE": mode, "PRECHECK_RECEIPT": str(receipt)})
def save_card_change(self):
f = self.fixture
f.git(self.wt, "add", str(self.card.relative_to(self.wt)))
f.git(self.wt, "commit", "-m", "Update fixture registration")
def local_authorization_scope(self):
"""Capture the explicit registered in-progress task used as local-mode authorization.
The card carries the task scope (worktree path, branch, target branch, and R0
initial baseline) that authorize LOCAL_READY under a registered task, regardless
of whether the authoritative origin is currently reachable. This small synthetic
fixture exposes that scope as a single value so offline-local tests can name it."""
f = self.fixture
branch = f.git(self.wt, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
return {
"wt": self.wt,
"card": self.card,
"branch": branch,
"target_branch": "main",
"r0": r0,
}
def test_local_precheck_allows_known_lag_without_changing_head(self):
f = self.fixture
head = f.git(self.wt, "rev-parse", "HEAD").stdout
f.advance_remote()
strict = self.precheck(mode="delivery")
self.assertNotEqual(strict.returncode, 0)
self.assertIn("SYNC_REQUIRED", strict.stdout)
local = self.precheck()
self.assertEqual(local.returncode, 0, local.stdout)
self.assertIn("[verdict] LOCAL_READY", local.stdout)
self.assertNotIn("[verdict] READY", local.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
def test_local_precheck_never_advertises_delivery_even_when_current(self):
result = self.precheck()
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("[verdict] LOCAL_READY", result.stdout)
self.assertNotIn("[verdict] READY", result.stdout)
def test_local_precheck_rejects_main_and_missing_registration(self):
result = self.precheck(cwd=self.fixture.repo)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.card.unlink()
self.save_card_change()
result = self.precheck()
self.assertNotEqual(result.returncode, 0, result.stdout)
def test_local_precheck_rejects_foreign_registration(self):
self.card.write_text(self.card.read_text().replace(str(self.wt), str(self.fixture.repo)))
self.save_card_change()
result = self.precheck()
self.assertNotEqual(result.returncode, 0, result.stdout)
def test_local_precheck_rejects_wrong_branch_registration(self):
self.card.write_text(self.card.read_text().replace("分支:feature/test", "分支:feature/other"))
self.save_card_change()
self.assertNotEqual(self.precheck().returncode, 0)
def test_local_precheck_rejects_invalid_baseline(self):
f = self.fixture
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
self.card.write_text(self.card.read_text().replace(r0, "0" * 40))
self.save_card_change()
self.assertNotEqual(self.precheck().returncode, 0)
def test_local_precheck_distinguishes_dirty_local_ready_and_remote_unverified(self):
"""A registered in-progress task with explicit authorization and scope distinguishes
SHARED_WORKSPACE_BUSY (dirty), LOCAL_READY (offline, registered, HEAD unchanged),
and REMOTE_UNVERIFIED (delivery, origin unavailable)."""
f = self.fixture
scope = self.local_authorization_scope()
self.assertEqual(scope["branch"], "feature/test")
self.assertTrue(scope["card"].is_file())
self.assertEqual(scope["target_branch"], "main")
# (1) Dirty workspace still yields SHARED_WORKSPACE_BUSY under the registered scope
# even when the authoritative origin is reachable. Clean before (2).
(self.wt / "tracked").write_text("preserve current edit\n")
dirty = self.precheck()
self.assertNotEqual(dirty.returncode, 0, dirty.stdout)
self.assertIn("[verdict] SHARED_WORKSPACE_BUSY", dirty.stdout)
self.assertNotIn("[verdict] LOCAL_READY", dirty.stdout)
f.git(self.wt, "commit", "-am", "Save owner edit")
head_before = f.git(self.wt, "rev-parse", "HEAD").stdout
f.git(self.wt, "remote", "set-url", "origin", str(f.root / "missing.git"))
# (2) Same unavailable origin; local mode against a registered in-progress task
# returns LOCAL_READY and leaves HEAD untouched.
local = self.precheck(mode="local")
self.assertEqual(local.returncode, 0, local.stdout)
self.assertIn("[verdict] LOCAL_READY", local.stdout)
self.assertNotIn("[verdict] REMOTE_UNVERIFIED", local.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before)
# (3) Same unavailable origin; delivery mode continues to refuse with
# REMOTE_UNVERIFIED because no read has refreshed the authoritative target.
delivery = self.precheck(mode="delivery")
self.assertNotEqual(delivery.returncode, 0, delivery.stdout)
self.assertIn("[verdict] REMOTE_UNVERIFIED", delivery.stdout)
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_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)
def test_local_resume_imports_original_receipt_without_sync(self):
f = self.fixture
receipt = self.capture()
original = receipt.read_bytes()
head = f.git(self.wt, "rev-parse", "HEAD").stdout
f.advance_remote()
result = self.resume(receipt)
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("[resumed]", result.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
self.assertEqual(receipt.read_bytes(), original)
self.assertIn(json.loads(original)["receipt_sha256"], self.card.read_text())
current = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
self.assertNotEqual(json.loads(original)["after"]["target_head"], current)
def assert_history_change_rejected(self, receipt):
f = self.fixture
before = self.card.read_bytes()
original = receipt.read_bytes()
head = f.git(self.wt, "rev-parse", "HEAD").stdout
result = self.resume(receipt)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("Precheck target history has rewound or diverged", result.stdout)
self.assertNotIn("[resumed]", result.stdout)
self.assertEqual(self.card.read_bytes(), before)
self.assertEqual(receipt.read_bytes(), original)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
def test_local_resume_rejects_target_rewind_before_import(self):
f = self.fixture
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
f.advance_remote()
receipt = self.capture()
observed = json.loads(receipt.read_text())["after"]["target_head"]
self.assertNotEqual(observed, r0)
# Only this test's disposable bare remote is rewritten.
f.git(f.remote, "update-ref", "refs/heads/main", r0, observed)
# Reproduce recovery after another client operation refreshed the target.
f.git(self.wt, "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main")
self.assert_history_change_rejected(receipt)
def test_local_resume_rejects_divergent_target_before_import(self):
f = self.fixture
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
f.advance_remote()
receipt = self.capture()
observed = json.loads(receipt.read_text())["after"]["target_head"]
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()
f.git(f.repo, "push", "origin", "HEAD:refs/heads/divergent")
self.assertEqual(f.git(f.repo, "merge-base", "--is-ancestor", observed,
divergent, check=False).returncode, 1)
# Both targets retain R0; only normal ancestry may permit recovery.
f.git(f.remote, "update-ref", "refs/heads/main", divergent, observed)
f.git(self.wt, "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main")
self.assert_history_change_rejected(receipt)
def test_make_resume_exports_the_selected_mode_and_receipt(self):
f = self.fixture
receipt = self.capture()
result = f.run_cmd(self.wt, "make", "-f", str(lifecycle.SOURCE / "Makefile"),
"task-resume", check=False,
env=f.env | {"TASK_WORK_MODE": "local", "PRECHECK_RECEIPT": str(receipt)})
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("[resumed]", result.stdout)
def test_local_receipt_cannot_start_a_task_or_resume_for_delivery(self):
f = self.fixture
receipt = self.capture()
before = self.card.read_bytes()
delivery = self.resume(receipt, mode="delivery")
self.assertNotEqual(delivery.returncode, 0, delivery.stdout)
self.assertEqual(self.card.read_bytes(), before)
start = f.task(self.wt, "start", "feature/bypass", check=False,
env=f.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_WORK_MODE": "local",
"VALIDATE_RECEIPT_PURPOSE": "resume-local",
"TRIAGE_RECEIPT": str(f.triage_receipt())})
self.assertNotEqual(start.returncode, 0, start.stdout)
self.assertFalse((f.root / "repo-wt-feature-bypass").exists())
def test_strict_resume_rejects_target_advance(self):
f = self.fixture
receipt = self.capture(mode="delivery")
head = f.git(self.wt, "rev-parse", "HEAD").stdout
f.advance_remote()
rejected = self.resume(receipt, mode="delivery")
self.assertNotEqual(rejected.returncode, 0)
self.assertNotIn("[resumed]", rejected.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
def test_strict_resume_imports_current_ready_receipt(self):
receipt = self.capture(mode="delivery")
result = self.resume(receipt, mode="delivery")
self.assertEqual(result.returncode, 0, result.stdout)
def test_resume_rejects_changed_source_head(self):
receipt = self.capture()
f = self.fixture
(self.wt / "tracked").write_text("new task revision\n")
f.git(self.wt, "commit", "-am", "Change source after precheck")
before = self.card.read_bytes()
result = self.resume(receipt)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.card.read_bytes(), before)
def test_resume_rejects_another_worktree_with_same_head(self):
f = self.fixture
other = f.root / "other-worktree"
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
other_card = self.wt / ".task/feature-other.md"
other_card.write_text(
"# Other task\n任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n"
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n"
f"- 工作区:{other}(worktree);分支:feature/other\n")
f.git(self.wt, "add", ".task/feature-other.md")
f.git(self.wt, "commit", "-m", "Register second fixture task")
f.git(f.repo, "worktree", "add", "-b", "feature/other", str(other), "HEAD")
# The main checkout has an older HEAD; align this owned fixture branch to the task HEAD.
f.git(other, "merge", "--ff-only", "feature/test")
receipt = self.capture(mode="delivery")
self.assertEqual(f.git(other, "rev-parse", "HEAD").stdout,
f.git(self.wt, "rev-parse", "HEAD").stdout)
result = self.resume(receipt, mode="delivery", cwd=other)
self.assertNotEqual(result.returncode, 0)
def test_unknown_validation_purpose_is_rejected(self):
receipt = self.capture(mode="delivery")
f = self.fixture
result = f.run_cmd(self.wt, "bash", "scripts/evidence.sh", check=False,
env=f.env | {"VALIDATE_RECEIPT": str(receipt),
"VALIDATE_RECEIPT_PURPOSE": "ignore"})
self.assertNotEqual(result.returncode, 0, result.stdout)
def test_local_precheck_rejects_reachable_uncomparable_target(self):
"""Origin advertises a target SHA but the advertised object is
unavailable locally, so ancestry cannot be compared against the
cached refs/remotes/origin/main. Local precheck must not advertise
LOCAL_READY under that unverifiable state — the cached target
cannot be trusted as authoritative without an ancestry check.
A disposable test-only git wrapper fails `fetch`, makes
`ls-remote origin refs/heads/main` advertise a synthetic SHA, and
makes `cat-file -e <synthetic>^{commit}` fail; all other git calls
delegate to the real binary so the registered in-progress task
context stays observable.
The current implementation lets target_rewound() bail out at the
cat-file -e step (return 1), then unconditionally prints
LOCAL_READY in local mode because the `&& target_rewound` guard
short-circuits. The verdict is wrong; this test records the
expected red result."""
f = self.fixture
head_before = f.git(self.wt, "rev-parse", "HEAD").stdout
cached_before = f.git(self.wt, "rev-parse", "refs/remotes/origin/main").stdout
# Find the real git to delegate everything but the three intercepted calls.
real_git = subprocess.check_output(
["bash", "-c", "command -v git"], env=f.env, text=True).strip()
synthetic = "1" * 40
bin_dir = f.root / "uncomparable-bin"
bin_dir.mkdir(exist_ok=True)
wrapper = bin_dir / "git"
wrapper.write_text(
"#!/usr/bin/env bash\n"
"# Disposable fixture wrapper for the reachable-but-uncomparable\n"
"# scenario: `fetch` fails, `ls-remote origin refs/heads/main`\n"
"# advertises a synthetic SHA, `cat-file -e <synthetic>^{commit}`\n"
"# fails. Anything else delegates to the real git so the\n"
"# registered task context is observable normally.\n"
f"REAL_GIT={shlex.quote(real_git)}\n"
f"SYNTHETIC={shlex.quote(synthetic)}\n"
"WRAPPER_BIN=$(cd \"$(dirname \"$0\")\" && pwd)\n"
"stripped=\"\"\n"
"old_ifs=\"$IFS\"\n"
"IFS=':'\n"
"for p in $PATH; do\n"
" [ \"$p\" = \"$WRAPPER_BIN\" ] && continue\n"
" if [ -z \"$stripped\" ]; then stripped=\"$p\"; else stripped=\"$stripped:$p\"; fi\n"
"done\n"
"IFS=\"$old_ifs\"\n"
"export PATH=\"$stripped\"\n"
"case \"$1\" in\n"
" fetch)\n"
" echo 'fatal: unable to access origin' >&2\n"
" exit 128 ;;\n"
" ls-remote)\n"
" if [ \"${2:-}\" = origin ] && [ \"${3:-}\" = refs/heads/main ]; then\n"
" printf '%s\\trefs/heads/main\\n' \"$SYNTHETIC\"\n"
" exit 0\n"
" fi\n"
" exec \"$REAL_GIT\" \"$@\" ;;\n"
" cat-file)\n"
" if [ \"${2:-}\" = -e ] && [ \"${3:-}\" = \"${SYNTHETIC}^{commit}\" ]; then\n"
" echo \"fatal: Not a valid object name ${SYNTHETIC}^{commit}.\" >&2\n"
" exit 1\n"
" fi\n"
" exec \"$REAL_GIT\" \"$@\" ;;\n"
"esac\n"
"exec \"$REAL_GIT\" \"$@\"\n")
wrapper.chmod(0o755)
wrapper_path = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}"
wrapper_env = f.env | {"PATH": wrapper_path}
local = f.task(self.wt, "precheck", check=False,
env=wrapper_env | {"TASK_WORK_MODE": "local"})
self.assertNotEqual(local.returncode, 0, local.stdout)
self.assertNotIn("[verdict] LOCAL_READY", local.stdout)
self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before)
self.assertEqual(f.git(self.wt, "rev-parse", "refs/remotes/origin/main").stdout,
cached_before)
def write_no_cache_wrapper(self, bin_dir, advertised, log_name="git.log"):
"""Create a disposable git wrapper for the missing-tracking-ref scenarios.
Intercepts:
fetch -> fail (origin unreachable; the wrapper itself exits 128
and writes the standard "unable to access origin" stderr line
so the precheck captures the same `fetch_err` it would observe
against a real unreachable remote)
ls-remote origin refs/heads/main -> configured ADVERTISED (or
nothing when ADVERTISED is empty, simulating an origin that
has no configured target branch)
branch --merged origin/main -> empty output (the cached
tracking ref is deleted locally; the real git would otherwise
fail the pipeline under set -e/pipefail and the precheck would
silently exit before the verdict is printed)
All other git calls delegate to the real binary via the resolved
REAL_GIT absolute path (and a stripped PATH so the wrapper cannot
recurse into itself) so the registered task context stays
observable normally.
Returns (bin_dir, wrapper_log_path)."""
f = self.fixture
real_git = subprocess.check_output(
["bash", "-c", "command -v git"], env=f.env, text=True).strip()
bin_dir.mkdir(exist_ok=True)
wrapper_log = bin_dir / log_name
wrapper = bin_dir / "git"
wrapper.write_text(
"#!/usr/bin/env bash\n"
"# Disposable fixture wrapper for the missing-tracking-ref\n"
"# scenarios:\n"
"# fetch -> fail (128)\n"
"# ls-remote origin refs/heads/main -> $ADVERTISED\n"
"# branch --merged origin/main -> empty\n"
"# Anything else delegates to the real binary so the registered\n"
"# task context (HEAD, branch, R0, worktree, .task/) stays\n"
"# observable normally.\n"
f"REAL_GIT={shlex.quote(real_git)}\n"
f"LOG={shlex.quote(str(wrapper_log))}\n"
f"ADVERTISED={shlex.quote(advertised)}\n"
"WRAPPER_BIN=$(cd \"$(dirname \"$0\")\" && pwd)\n"
"echo \"configured advertised-target=$ADVERTISED\" >> \"$LOG\"\n"
"stripped=\"\"\n"
"old_ifs=\"$IFS\"\n"
"IFS=':'\n"
"for p in $PATH; do\n"
" [ \"$p\" = \"$WRAPPER_BIN\" ] && continue\n"
" if [ -z \"$stripped\" ]; then stripped=\"$p\"; else stripped=\"$stripped:$p\"; fi\n"
"done\n"
"IFS=\"$old_ifs\"\n"
"export PATH=\"$stripped\"\n"
"case \"$1\" in\n"
" fetch)\n"
" echo \"fetch-intercepted\" >> \"$LOG\"\n"
" echo 'fatal: unable to access origin' >&2\n"
" exit 128 ;;\n"
" ls-remote)\n"
" if [ \"${2:-}\" = origin ] && [ \"${3:-}\" = refs/heads/main ]; then\n"
" echo \"ls-remote-served advertised-target=$ADVERTISED\" >> \"$LOG\"\n"
" if [ -n \"$ADVERTISED\" ]; then\n"
" printf '%s\\trefs/heads/main\\n' \"$ADVERTISED\"\n"
" fi\n"
" exit 0\n"
" fi\n"
" exec \"$REAL_GIT\" \"$@\" ;;\n"
" branch)\n"
" # refs/remotes/origin/main is deleted locally; the real git\n"
" # `git branch --merged origin/main` would fail and the\n"
" # precheck pipeline (with pipefail + set -e) would silently\n"
" # exit. Stub it so the verdict is actually emitted.\n"
" if [ \"${2:-}\" = --merged ] && [ \"${3:-}\" = origin/main ]; then\n"
" echo \"branch-merged-stub no-merged-branches\" >> \"$LOG\"\n"
" exit 0\n"
" fi\n"
" exec \"$REAL_GIT\" \"$@\" ;;\n"
"esac\n"
"exec \"$REAL_GIT\" \"$@\"\n")
wrapper.chmod(0o755)
return bin_dir, wrapper_log
def test_local_precheck_without_cached_ref_rejects_uncomparable_remote(self):
"""Cached refs/remotes/origin/main is absent; origin advertises a
SHA whose object is unavailable locally. Local precheck must not
advertise LOCAL_READY because ancestry cannot be compared against
any locally known target — the cached target cannot be trusted
as authoritative without an ancestry check, and the advertised
SHA is not locally reachable so no forward-descendant
relationship can be established either.
The current implementation lets target_history() short-circuit at
the missing cached-ref step (return "ok") and unconditionally
print LOCAL_READY in local mode. The verdict is wrong; this test
records the expected red result."""
f = self.fixture
head_before = f.git(self.wt, "rev-parse", "HEAD").stdout
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
# Detach the registered branch from its (now stale) upstream so
# no in-process code accidentally queries HEAD@{u} / remote.merge
# against the deleted cached ref.
f.git(self.wt, "branch", "--unset-upstream", "feature/test")
f.git(self.wt, "update-ref", "-d", "refs/remotes/origin/main")
synthetic = "1" * 40
bin_dir, wrapper_log = self.write_no_cache_wrapper(
f.root / "no-cache-uncomparable-bin", synthetic)
path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}"
wrapper_env = f.env | {"PATH": path_env}
local = f.task(self.wt, "precheck", check=False,
env=wrapper_env | {"TASK_WORK_MODE": "local"})
# Proof: the wrapper was loaded (configured marker) and served
# the failure case (fetch intercepted). ls-remote is served by
# the wrapper if and when the post-fix implementation queries it
# for the missing cached-ref path; the current implementation
# short-circuits before ls-remote, so the assertion below does
# not require an ls-remote entry.
log = wrapper_log.read_text()
self.assertIn(f"configured advertised-target={synthetic}", log)
self.assertIn("fetch-intercepted", log)
self.assertNotEqual(local.returncode, 0, local.stdout)
self.assertNotIn("[verdict] LOCAL_READY", local.stdout)
self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before)
def test_local_precheck_without_cached_ref_rejects_missing_remote_target(self):
"""Cached refs/remotes/origin/main is absent; origin returns no
configured target branch (ls-remote outputs nothing). Local
precheck must not advertise LOCAL_READY because nothing can be
verified against the registered task's R0 — the cached target
is the only handle on what origin claimed, and without it (and
without an ls-remote response) the precheck has no
authoritative signal to confirm the target even exists.
The current implementation lets target_history() short-circuit at
the missing cached-ref step (return "ok") and unconditionally
print LOCAL_READY in local mode. The verdict is wrong; this test
records the expected red result."""
f = self.fixture
head_before = f.git(self.wt, "rev-parse", "HEAD").stdout
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
f.git(self.wt, "branch", "--unset-upstream", "feature/test")
f.git(self.wt, "update-ref", "-d", "refs/remotes/origin/main")
bin_dir, wrapper_log = self.write_no_cache_wrapper(
f.root / "no-cache-missing-target-bin", "")
path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}"
wrapper_env = f.env | {"PATH": path_env}
local = f.task(self.wt, "precheck", check=False,
env=wrapper_env | {"TASK_WORK_MODE": "local"})
log = wrapper_log.read_text()
# Empty advertised value means the wrapper emits no ls-remote line.
self.assertIn("configured advertised-target=", log)
self.assertIn("fetch-intercepted", log)
self.assertNotEqual(local.returncode, 0, local.stdout)
self.assertNotIn("[verdict] LOCAL_READY", local.stdout)
self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before)
def test_local_precheck_without_cached_ref_allows_comparable_forward_target(self):
"""Cached refs/remotes/origin/main is absent; origin advertises
the task's R0 (or a forward descendant that is locally
available). Local precheck must continue to advertise
LOCAL_READY — the explicit missing-tracking-ref continuation
contract — because the registered task context is the same as
the cached ref would have represented, and the ls-remote
response can be compared against the registered baseline to
confirm origin is at or past the task's initial state.
The current implementation short-circuits at the missing
cached-ref step (return "ok"), which coincidentally returns
LOCAL_READY for any registered task. The verdict is right; this
test records the expected green result so the post-fix
implementation preserves the contract when the ls-remote check
is added against the registered R0."""
f = self.fixture
head_before = f.git(self.wt, "rev-parse", "HEAD").stdout
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
f.git(self.wt, "branch", "--unset-upstream", "feature/test")
f.git(self.wt, "update-ref", "-d", "refs/remotes/origin/main")
bin_dir, wrapper_log = self.write_no_cache_wrapper(
f.root / "no-cache-comparable-bin", r0)
path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}"
wrapper_env = f.env | {"PATH": path_env}
local = f.task(self.wt, "precheck", check=False,
env=wrapper_env | {"TASK_WORK_MODE": "local"})
log = wrapper_log.read_text()
self.assertIn(f"configured advertised-target={r0}", log)
self.assertIn("fetch-intercepted", log)
self.assertEqual(local.returncode, 0, local.stdout)
self.assertIn("[verdict] LOCAL_READY", local.stdout)
self.assertNotIn("[verdict] REMOTE_UNVERIFIED", local.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before)
if __name__ == "__main__":
unittest.main()