Files
hermes-agent/tests/hermes_cli/test_shallow_boundary_repair.py
kshitijk4poor 966fb375fc fix(cli): make shallow-boundary repair survive the prune and the graph-safety review findings
Rework of the repair pass from #108361 (salvage) addressing the blocking
review findings, verified with real-git probes:

- Sequencing: prune_stale_shallow_grafts' fail-safe now also walks
  rev-list --all --reflog, so a boundary the repair just restored (one a
  reflog-only commit still needs) is never dropped again; previously the
  production repair->prune sequence re-broke the repo on every update run.
- Header-only parent parsing: a "parent <sha>" line inside a commit
  message body is prose; _batch_missing_parents stops at the blank line
  ending the commit header, so healthy history is never truncated.
- Candidates restricted to fetch-recorded tips (refs/remotes/* reflogs),
  not --batch-all-objects: unrelated object loss (a deleted parent of a
  locally-created commit) is no longer re-labelled as shallow history;
  fsck keeps reporting it.
- Concurrent-writer safety: both .git/shallow writers now hold git's own
  shallow.lock, so a depth-1 fetch between read and write fails fast
  instead of being clobbered (or clobbering us).
- Cheap gate: repair runs its subprocess fan-out only when
  rev-list --all --reflog already fails; healthy updates pay one probe.
- --batch-check returncode is now checked; shared helpers
  (_shallow_file_path, _ShallowLock) replace the copy-pasted plumbing;
  test file footguns fixed (encoding=, as_uri()) and the missing
  repair->prune end-to-end regression added, mutation-checked.
2026-09-12 15:00:37 +05:30

147 lines
6.7 KiB
Python

"""Repair of shallow boundaries dropped by the stale-graft prune (#108286).
``prune_stale_shallow_grafts()`` dropped ``.git/shallow`` grafts that reflog-only
commits still needed, leaving shallow installer checkouts with commits whose parent
objects were never downloaded — ``git gc`` / ``fsck`` / ``fetch`` all fail, and the
corruption cannot self-heal because reflog expiry happens during ``git gc``, which is
exactly what broke. ``repair_broken_shallow_boundaries()`` re-appends the missing
boundaries; the updater calls it before the prune at both call sites.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import hermes_cli.gitlock as gitlock
def git(repo, *args, check=True):
return subprocess.run(
["git", *args], cwd=repo, capture_output=True, text=True,
encoding="utf-8", errors="replace", check=check,
)
def fixture(tmp_path):
origin = tmp_path / "origin"; origin.mkdir()
git(origin, "init", "-q", "-b", "main")
git(origin, "config", "user.email", "t@example.com"); git(origin, "config", "user.name", "t")
git(origin, "commit", "--allow-empty", "-qm", "c0")
clone = tmp_path / "clone"
subprocess.run(["git", "clone", "-q", "--depth", "1", origin.as_uri(), str(clone)], check=True)
git(origin, "commit", "--allow-empty", "-qm", "c1")
git(origin, "commit", "--allow-empty", "-qm", "c2")
git(clone, "fetch", "-q", "--depth", "1", "origin", "main")
git(origin, "commit", "--allow-empty", "-qm", "c3")
git(clone, "fetch", "-q", "--depth", "1", "origin", "main")
return clone
def corrupt_fixture(clone):
path = clone / ".git" / "shallow"
lines = path.read_text(encoding="utf-8").splitlines()
for removed in lines:
path.write_text("\n".join(x for x in lines if x != removed) + "\n", encoding="utf-8")
fsck = git(clone, "fsck", "--connectivity-only", check=False)
if "broken link" in (fsck.stdout + fsck.stderr) or "missing commit" in (fsck.stdout + fsck.stderr):
return removed
raise AssertionError("fixture did not create a broken shallow boundary")
def _walks(clone):
return git(clone, "rev-list", "--count", "--all", "--reflog", check=False).returncode == 0
def test_repair_restores_boundary_for_reflog_only_commit_with_unfetched_parent(tmp_path):
clone = fixture(tmp_path)
corrupt_fixture(clone)
assert not _walks(clone)
assert "broken link" in git(clone, "fsck", "--connectivity-only", check=False).stdout
assert gitlock.repair_broken_shallow_boundaries(clone) >= 1
assert _walks(clone)
fsck = git(clone, "fsck", "--connectivity-only")
assert "broken link" not in (fsck.stdout + fsck.stderr)
assert git(clone, "gc", "-q").returncode == 0
def test_repair_then_prune_leaves_repo_walkable(tmp_path):
"""The production updater sequence: repair runs, then the prune immediately after.
Without the prune's ``--reflog`` fail-safe walk, the prune dropped the boundary
repair had just restored, re-breaking the repo on every ``hermes update`` run.
"""
clone = fixture(tmp_path)
corrupt_fixture(clone)
assert gitlock.repair_broken_shallow_boundaries(clone) >= 1
assert _walks(clone)
gitlock.prune_stale_shallow_grafts(clone)
assert _walks(clone)
fsck = git(clone, "fsck", "--connectivity-only", check=False)
assert "broken link" not in (fsck.stdout + fsck.stderr)
def test_repair_is_noop_on_healthy_shallow_checkout(tmp_path):
clone = fixture(tmp_path); path = clone / ".git" / "shallow"; before = path.read_bytes()
assert gitlock.repair_broken_shallow_boundaries(clone) == 0
assert path.read_bytes() == before
def test_repair_is_noop_on_full_clone_without_shallow_file(tmp_path):
repo = tmp_path / "repo"; repo.mkdir(); git(repo, "init", "-q")
assert gitlock.repair_broken_shallow_boundaries(repo) == 0
def test_repair_never_raises_on_broken_repo(tmp_path):
assert gitlock.repair_broken_shallow_boundaries(tmp_path / "missing") == 0
def test_repair_does_not_touch_reflogs(tmp_path):
clone = fixture(tmp_path); corrupt_fixture(clone)
before = git(clone, "reflog", "show", "--all").stdout
gitlock.repair_broken_shallow_boundaries(clone)
assert git(clone, "reflog", "show", "--all").stdout == before
def test_repair_is_idempotent(tmp_path):
clone = fixture(tmp_path); corrupt_fixture(clone)
assert gitlock.repair_broken_shallow_boundaries(clone) >= 1
assert gitlock.repair_broken_shallow_boundaries(clone) == 0
def test_repair_ignores_parent_lines_inside_commit_messages(tmp_path):
"""A ``parent <sha>`` line in a commit *message body* is prose, not an edge:
the parent parser must read only the commit header, so a healthy commit whose
message mentions ``parent <sha>`` is never shallow-marked."""
origin = tmp_path / "origin"; origin.mkdir()
git(origin, "init", "-q", "-b", "main")
git(origin, "config", "user.email", "t@example.com"); git(origin, "config", "user.name", "t")
git(origin, "commit", "--allow-empty", "-qm", "c0")
git(origin, "commit", "--allow-empty", "-qm", "c1")
clone = tmp_path / "clone"
subprocess.run(["git", "clone", "-q", "--depth", "2", origin.as_uri(), str(clone)], check=True)
git(clone, "config", "user.email", "t@example.com"); git(clone, "config", "user.name", "t")
git(clone, "commit", "--allow-empty", "-m", "subject\n\nbody line\n\nparent ffffffffffffffffffffffffffffffffffffffff")
head = git(clone, "rev-parse", "HEAD").stdout.strip()
# HEAD's real parent exists locally; the message-body "parent" line names an
# absent object and must NOT register as a missing edge (header-only parsing).
assert gitlock._batch_missing_parents(clone, [head]) == set()
assert git(clone, "rev-list", "--count", "HEAD").stdout.strip() == "3"
def test_repair_does_not_mask_unrelated_object_loss(tmp_path):
"""Missing objects that are NOT reflog-only boundary commits must stay visible
to fsck — repair must not relabel arbitrary object loss as shallow history."""
clone = fixture(tmp_path)
git(clone, "config", "user.email", "t@example.com"); git(clone, "config", "user.name", "t")
git(clone, "commit", "--allow-empty", "-qm", "local1")
git(clone, "commit", "--allow-empty", "-qm", "local2")
# Drop HEAD's parent object from the object store.
victim = git(clone, "rev-parse", "HEAD~1").stdout.strip()
loose = clone / ".git" / "objects" / victim[:2] / victim[2:]
assert loose.is_file(), "expected a loose object to delete"
loose.unlink()
assert gitlock.repair_broken_shallow_boundaries(clone) == 0
fsck = git(clone, "fsck", "--connectivity-only", check=False)
assert "missing" in (fsck.stdout + fsck.stderr) or "broken link" in (fsck.stdout + fsck.stderr)