Skip to content

fix: validate the .git marker, and stop serving stale traversal caches - #17

Merged
maxgfr merged 2 commits into
mainfrom
fix-codex-findings
Sep 3, 2026
Merged

maxgfr merged 2 commits into
mainfrom
fix-codex-findings

Conversation

@maxgfr

@maxgfr maxgfr commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Two regressions that #16 introduced, found by an independent audit (Codex CLI) and confirmed by a second review (Fable) that also tightened the first fix. Both were reproduced against the published 2.28.1 bundle before being fixed.

1. walk: the repository boundary triggered on the name .git

A file named .git holding anything other than a gitdir: line — a truncated write, an unrelated file carrying the name, a dangling symlink — made the walker skip its entire subtree, silently. That is precisely the failure the walk's "never a silent truncation" doctrine forbids.

Now a .git directory is a git dir, and a .git file is a marker only when it opens with exactly gitdir: . That prefix is git's own rule (read_gitfile_gently compares the first 8 bytes), verified against real git:

.git file body real git before after
gitdir: <path> repo boundary boundary
gitdir: <path> (no trailing newline) repo boundary boundary
gitdir:<path> (no space) invalid gitfile format boundary walked
gitdir: <path> (leading space) invalid gitfile format boundary walked
junk\ngitdir: <path> (2nd line) invalid gitfile format boundary walked
garbage / empty invalid gitfile format boundary walked

A symlinked .git resolves through its target (git supports it); a dangling one is not a repository. The read is capped at 4 KiB, so a stray 200 MB file carrying the name is no longer read whole (156 ms) just to be rejected. A .git directory is still decided on its dirent alone, with no syscall.

Deliberate deviation, now documented as one: git additionally requires the target to look like a repository and reports "not a git repository" otherwise. This walk stops at a well-formed marker whatever its target — a stale gitfile from a pruned or moved worktree still sits on a full checkout, and indexing it would duplicate the parent's sources, which is what the boundary exists to prevent. The previous comment claimed to follow git's rule outright; it now states the difference.

2. traverse: the adjacency cache served stale answers

The cache was keyed on the edge array's identity and length. Retargeting an edge in place — same array, same length — left both unchanged, so impactOf/neighborsOf kept answering with the pre-edit graph. Graph.fileEdges is public and mutable, and the engine is vendored by consumers, so a cache that silently answers on the old graph is the wrong default.

A snapshot of exactly the fields a traversal reads (from, to, kind, weight, dangling, confidence) is taken when a view is built and checked on every hit; values are copied by reference, so a check is a few pointer comparisons per edge. It also catches an edge object replaced in the array, which the old check missed too.

Measuring the check changed the design. It is not free, so reverseClosure is back to its pre-PR shape — uncached, and sorting only the buckets the walk visits. An impact closure reaches a handful of nodes, so both eager sorting and cache validation cost more than the walk itself. bfs keeps the checked cache, where two maps, two sorts and a degree distribution per call still pay for it.

200 calls, 9 153-edge graph pre-PR (2.28.1) #16 (unsafe cache) this PR
impactOf 69 ms 28 ms 70 ms
neighborsOf 161 ms 5 ms 41 ms

Verification

  • pnpm typecheck, pnpm test (1 290 passed, 50 skipped), pnpm run check:build all pass; the committed bundle matches a fresh rebuild.
  • graph.json / symbols.json byte-identical to perf: walker boundaries, batched worker dispatch, hot-path memos (+ deadcode fix) #16's output on tests/fixtures/mini-repo and on a real 2 924-file repo (still 2 924 files).
  • 19 075 impactOf/neighborsOf answers match the 2.28.1 bundle on that repo — and another 19 075 match after retargeting every 7th edge in place, the case that used to go stale.
  • Both regressions have a test that is red without its fix.
  • Gitfile acceptance was checked against real git rev-parse rather than assumed, for all eight spellings above.

Audit notes

Codex additionally stress-tested #16's worker dispatcher — 280 synthetic runs over 1–513 jobs and 2–64 workers, plus twelve 2 358-job real-repo runs, every record accounted for, fault injection falling back correctly — and found no defect there. The dead-code fix from #16 was confirmed on the real repo: 170 removals, all verified to be genuinely called, no additions.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8

…traversals

Two regressions from #16, found by an independent audit and reproduced
against the 2.28.1 bundle before fixing.

walk: the nested-repository boundary triggered on the NAME `.git`, so a
file named `.git` holding anything else — a truncated write, an
unrelated file — silently dropped its entire subtree from the index.
Git's own rule is now applied: a DIRECTORY is a git dir, a FILE marks a
repo only when it reads "gitdir: <path>" (git rejects the rest with
"fatal: invalid gitfile format"). A symlinked `.git` resolves through
its target. Valid markers keep behaving exactly as before, and the
marker itself is still never indexed as source.

traverse: the adjacency cache keyed on the edge array's identity and
length, so retargeting an edge IN PLACE — same array, same length —
kept answering with the pre-edit graph. A snapshot of the fields a
traversal reads is now taken when a view is built and checked on every
hit; values are copied by reference, so a check is a few pointer
comparisons per edge.

That check is not free, and measuring it changed the design:
reverseClosure is back to its pre-PR shape, uncached and sorting only
the buckets the walk visits — an impact closure reaches a handful of
nodes, so both eager sorting and cache validation cost more than the
walk (200 impactOf calls over a 9 153-edge graph: 78 ms eager, 70 ms
now, 69 ms pre-PR). bfs keeps its checked cache, where two maps, two
sorts and a degree distribution per call still pay for it: 161 ms
pre-PR, 41 ms now.

19 075 impactOf/neighborsOf answers match the 2.28.1 bundle on a real
repo, and another 19 075 match after mutating every 7th edge in place —
the case that used to go stale. graph.json/symbols.json unchanged.

Claude-Session: https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8
Follow-up to the previous commit, from a second review. The gitfile
regex accepted `gitdir:` with no space and on any line of the file;
real git (read_gitfile_gently) compares the first 8 bytes against
exactly `gitdir: ` and rejects the rest as "invalid gitfile format" —
verified against git itself for no-space, leading-whitespace and
second-line variants. Parsing is now that prefix test plus a trailing
whitespace trim, which is both stricter and simpler than the regex.

The read is bounded: a 4 KiB cap means a stray archive that merely
carries the name `.git` is no longer read whole (200 MB cost 156 ms)
just to discover it is not a gitfile. A `.git` directory is still
decided on its dirent alone, with no syscall.

What git ALSO does and this walk deliberately does not: validate that
the target looks like a repository. A stale gitfile from a pruned or
moved worktree still sits on a full checkout, so it stays a boundary —
now stated as the deviation it is, where the comment previously claimed
to follow git's rule outright.

Tests: the git-rejected spellings, a gitfile with no trailing newline
(git accepts it), a symlinked `.git` resolving through a directory and
through a gitfile, a dangling `.git` symlink keeping its subtree, the
size cap, and an edge REPLACED in the array rather than edited in place.

Claude-Session: https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8
@maxgfr
maxgfr merged commit 9ff08f6 into main Sep 3, 2026
2 checks passed
@maxgfr
maxgfr deleted the fix-codex-findings branch September 3, 2026 04:48
github-actions Bot pushed a commit that referenced this pull request Sep 3, 2026
## [2.28.3](v2.28.2...v2.28.3) (2026-09-03)

### Bug Fixes

* validate the .git marker, and stop serving stale traversal caches ([#17](#17)) ([9ff08f6](9ff08f6)), closes [#16](#16)
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.28.3 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant