Repository navigation
fix(cli): repair shallow boundaries already dropped by the stale-graft prune - #108361
JoaoMarcos44 wants to merge 1 commit into
Conversation
A reflog-only commit can remain present after stale-graft pruning drops the shallow boundary it needs, while its parent was never fetched. That leaves git gc, fsck, and rev-list unable to traverse the repository. Prevention alone is insufficient because a broken gc walk prevents reflogs from expiring. Repair scans local commit objects without graph traversal, identifies commits with missing parents, and atomically restores their shallow boundaries. It only updates .git/shallow and never expires reflogs, prunes, or deletes objects, so the operation is non-destructive and idempotent. This complements PR NousResearch#108290, which owns the prevention half. Refs NousResearch#108286
ehz0ah
left a comment
There was a problem hiding this comment.
Reviewed exact head 8500773a6a814186c9bf4b47771cc7e66486dbbb for #108286.
The standalone repair succeeds on the reported broken-reflog fixture, but the production updater immediately calls the current prune and reintroduces the corruption. I also reproduced unsafe boundary inference from commit messages and unrelated object loss, plus a concurrent depth-one fetch overwrite. The new regression file fails the repository's blocking Windows-footgun scan.
Validation: 67 focused and adjacent tests passed through scripts/run_tests.sh. Ruff, ty check hermes_cli/gitlock.py, and git diff --check passed. Real Git probes reproduced each graph-safety finding. The Windows-footgun scan failed on three new test lines. No Windows execution was performed. No hosted checks were available.
The findings below are blocking.
| # merge-base / the orphan-divergence heuristic keep working (#105951). | ||
| from hermes_cli.gitlock import prune_stale_shallow_grafts | ||
| from hermes_cli.gitlock import repair_broken_shallow_boundaries, prune_stale_shallow_grafts | ||
| repaired = repair_broken_shallow_boundaries(_m().PROJECT_ROOT) |
There was a problem hiding this comment.
[Bug] (blocking) The production sequence undoes this repair. Both call sites invoke this function and then call the current reflog-blind prune. In a real corrupted shallow fixture, repair returned 1 and restored rev-list --all --reflog and fsck, but the immediately following prune returned 1, removed the same reflog-only boundary, and restored exit codes 128 and 2. The new tests call repair alone, so they miss the actual updater behavior. Repair and prune need to enforce one reachability rule, with an end-to-end regression for this sequence.
| fields[1] | ||
| for line in decoded.splitlines() | ||
| for fields in [line.split()] | ||
| if len(fields) >= 2 and fields[0] == "parent" |
There was a problem hiding this comment.
[Bug] (blocking) This scans the commit message as if it were part of the commit header. In a healthy depth-two shallow clone, a local commit whose message contained parent ffff... made repair add HEAD to .git/shallow and reduced rev-list --count HEAD from 2 to 1. A normal message can therefore truncate visible history. Parent parsing must stop at the blank line that ends the commit headers.
| for line in check.stdout.decode(errors="replace").splitlines() | ||
| if line.endswith(" missing") | ||
| } | ||
| return {commit for commit, commit_parents in parents_by_commit.items() if commit_parents & missing} |
There was a problem hiding this comment.
[Bug] (blocking) A missing parent alone does not prove that #108286 dropped a shallow boundary. This scan covers every local commit object, so unrelated object loss is converted into valid shallow history. After deleting a referenced commit's parent object, repair returned 1, marked the child shallow, and made fsck pass although the parent remained absent. The repair needs evidence that a candidate was a legitimate former shallow boundary instead of hiding arbitrary repository corruption.
| return 0 | ||
| tmp_path = shallow_path.with_name(shallow_path.name + ".hermes-repair") | ||
| tmp_path.write_text("\n".join(sorted(existing | repaired)) + "\n", encoding="utf-8") | ||
| os.replace(tmp_path, shallow_path) |
There was a problem hiding this comment.
[Bug] (blocking) This replace publishes a stale .git/shallow snapshot without Git's shallow.lock. I injected a real depth-one fetch after the initial read and before this replace. The fetch succeeded and added its new boundary, then repair overwrote it. Validation failed and rollback wrote the same stale original, leaving rev-list at exit 128 and fsck at exit 2. Publication and rollback must use Git's shallow lock protocol and preserve any concurrent writer's state.
|
|
||
|
|
||
| def git(repo, *args, check=True): | ||
| return subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=check) |
There was a problem hiding this comment.
[Bug] (blocking) The repository's blocking Windows-footgun scan fails this new helper because text=True has no explicit UTF-8 encoding. It also flags the new read_text() and write_text() calls on lines 28 and 30. Add explicit encodings to all three calls so these tests do not decode or write with cp936 or cp1252 on Windows.
…h-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.
|
Merged via #108888 with your authorship preserved (commit on main). The repair approach shipped essentially as designed — see #108888 for the review-finding rework that landed on top (prune reflog fail-safe, header-only parent parsing, refs/remotes-only candidates, shallow.lock protocol, cheap healthy-path gate). Thank you for the excellent investigation on #108286! 🙏 |
|
Correction — the preserved commit on main is |
|
All five blocking findings from this review were independently reproduced, then fixed in #108888 (merged; your review directly shaped the rework — thank you):
|
Summary
prune_stale_shallow_grafts()(landed via #108053) dropped.git/shallowgrafts that reflog-only commits still needed, leaving shallow installer checkouts with a commit whose parent object was never downloaded and whose shallow boundary is gone.git gc,git fsck --connectivity-onlyandgit fetch's auto maintenance then fail (#108286).This PR deliberately does NOT touch
prune_stale_shallow_grafts(). The prevention half of #108286 is owned by @KoNit-K in #108290 (fix(cli): preserve reflog-reachable shallow grafts), which addsgit rev-list --all --reflogto the keep-set and fixes the fail-safe. This PR is the strictly complementary repair half, which #108290's own body declares out of scope:git diff origin/main...HEAD -- hermes_cli/gitlock.pyis purely additive — zero lines changed insideprune_stale_shallow_grafts().Why prevention alone is not enough
Every shallow install that ran
hermes updateafter #108053 landed is corrupted right now, and it cannot self-heal. The corruption is only cleared when the offending reflog entries expire, and reflog expiry happens duringgit gc— which is exactly the command the corruption breaks. Without an explicit repair those installs stay broken indefinitely, withhermes updatefailing onerror: failed to perform geometric repack.Investigation
Reproduced on current
main(git 2.53.0.windows.2), isolated fixture, no dependency on this repo's history:rev-list --count HEAD/--count --all(the existing fail-safe)rev-list --count --all --reflogfatal: Failed to traverse parents of commit 76b673ffsck --connectivity-onlybroken link ... missing commit a70b5degit gcfatal: failed to run repackRoot cause:
git fetch --depth 1records the fetched tip inrefs/remotes/<remote>/<branch>'s reflog. When a later fetch supersedes that tip it becomes reflog-only — no ref points at it — so the old keep-set (HEAD + FETCH_HEAD +for-each-ref) dropped its graft. The commit is still reachable via the reflog, its parent was never downloaded, and nothing protects the boundary any more.Alternatives evaluated and rejected
Delegation to git's own
prune_shallow()(shallow.c, invoked bygit pruneaftermark_reachable_objects(..., mark_reflog=1)) was measured as a candidate root-cause design. Rejected on evidence:git prune --expire=nowdeletes unreachable objects (verified:git cat-file -e <sha>→ rc 1 afterwards). Unacceptable on an installer checkout insidehermes update --check..git/shallowwhile reflogs still retain the commits (21 → 21 grafts on a 20-fetch fixture); only a destructivegit reflog expire --expire-unreachable=now --allmakes it shrink, which destroys the reflog data backing the documentedgit reflog && git reset --hard <sha>recovery path inupdate_cmd.py.Reflog expiry of
refs/remotes/*was also evaluated: it does not restore a missing graft either, and it discards data. The chosen approach is the only validated one that repairs without destroying anything.The fix
repair_broken_shallow_boundaries(repo_root) -> intinhermes_cli/gitlock.py, called beforeprune_stale_shallow_grafts()at both existing updater call sites (_cmd_update_check,_cmd_update_impl) so a corrupted repo is healed first and the prune then operates on a consistent one.Algorithm — walk-free by necessity:
.git/shallowviagit rev-parse --git-path shallow; no-op if absent/empty.git cat-file --batch-all-objects --batch-check(typecommit) plusgit reflog show --all --format=%H. This must not usegit rev-list --reflog --all: on an already-corrupted repo that walk is precisely what fails, returning a truncated candidate set that omits the broken commit. This was an actual bug caught during implementation — the first version usedrev-listand could not repair the very repos it targets.git cat-file --batchover all candidates reads parent edges; onegit cat-file --batch-checkresolves which parents aremissing..git/shallowatomically (temp file +os.replace, matching the neighbouring prune).git rev-list --count --all --reflog; on failure restore the original file byte-for-byte.logger.debug(..., exc_info=True)on failure,logger.infoon success.Non-destructive by construction: it only ever appends lines to
.git/shallow. It never expires a reflog, never runsgit prune, never deletes an object, never touches the working tree.Bounded cost: a small constant number of
gitsubprocesses regardless of repository size. Thecat-file --batchoutput is parsed size-driven over bytes (<sha> <type> <size>\n<body>\n), not by scanning for blank lines — a commit message containing blank lines would desynchronise a line-based cursor.Tests
New file
tests/hermes_cli/test_shallow_boundary_repair.py(separate fromtest_shallow_graft_prune.py, which #108290 also edits, to avoid conflicts). Behaviour contracts, no change-detectors:test_repair_restores_boundary_for_reflog_only_commit_with_unfetched_parent— builds the real corruption fixture and asserts the precondition (fsckreally reportsbroken link) so it can never silently pass on a healthy fixture; then assertsrev-list --all --reflogrc 0,fsckclean, andgit gc -qrc 0 after repair.test_repair_does_not_touch_reflogs—git reflog show --allbyte-identical before/after. This is the safety contract separating this approach from the destructive alternatives.test_repair_uses_a_bounded_number_of_git_subprocesses— countssubprocess.runcalls on a large (40-commit) and a small fixture and asserts the count is bounded and does not scale with candidate count.test_repair_handles_commit_messages_containing_blank_lines— pins the byte-framed parser against messages with blank lines and header-lookalike text.RED-before-GREEN verified by stubbing
repair_broken_shallow_boundariestoreturn 0: the corruption andrev-listassertions fail; restoring the implementation turns them green.Results
scripts/run_tests.shhas no venv in the isolated worktree, so the repo venv's pytest was used:Per file: repair 8, graft prune 3, tmp packs 5, update check 4, cmd update 47. The existing
test_shallow_graft_prune.pypasses unmodified, confirming no interference with #108290's territory.Scope
hermes_cli/gitlock.py(additive only),hermes_cli/update_cmd.py(repair call + a message printed only when N > 0, matching the existing(pruned N stale shallow graft(s) ...)style), and the new test file. Nothing else.Fixes #108286. Complements #108290 (prevention), which should land independently; the two are orthogonal and conflict-free.