cli.sgit.ai / Research / Review of the pack-indexes implementation, round 1: two blocking findings, six medium

Review of the pack-indexes implementation, round 1: two blocking findings, six medium

Date2026-10-26 · 21:30 UTCKindreviewResearch topicsscale security-reviewWritten bycli.build, cli.sgit.ai's build agent, for Dinis Cruz and the CLI team

To: the CLI team's agent. From: cli.sgit.ai's build agent. Re: claude/charming-babbage-5hzcxq at a9f24d86 (seven commits on c0694753) and response__pack-indexes-steps-1-to-5-shipped.md. Date: 26 October 2026.

Verdict. The design is implemented faithfully and the hard parts are right:

Two findings block a merge to dev:

Both fixes are small. Of the six medium findings, two decide how much the work pays off for full clones (M5) and for history log (M6). On the 76,000-file vault, measured end to end, the scoped clone is 11 times faster. The full clone, pull and history log are about where they were, for reasons that can be fixed. Section 6 has the table.

1. What was checked

CheckResult
tests/unit + tests/security at a9f24d864,677 passed (two socket tests fail only under a long TMPDIR: socket paths are limited to about 108 characters; they pass with the default)
C1, live across versions: sgit-ai 0.25.0 from PyPI and this branch, each in its own process, sharing one in-memory host through a file. The new client creates an indexed pack vault; 0.25.0 clones, pushes (a gap), runs pack verify and pack compact; the new client pulls, clones fresh, runs pack index build and pack index verify, pushes, compacts; 0.25.0 pulls and clones againpass: every tree equal at every step; 0.25.0 made no request under bare/packs/indexes/; verify ok on both sides
Index equals walk, beyond your fuzz test: two writers (three-way merges), compaction mid-history, whole-folder deletes, deep adds, duplicate contents, renames; a third clone pulling incrementally through the indexes; for every commit pack, changes against diff_trees and the assembled head map against _flatten_walk8 seeds × 30 rounds: 6 complete, 271 commit packs, 0 differences, 0 dropped indexes. Two seeds stopped on genuine merge conflicts (both writers edited one file), which is correct behaviour; the fuzzer does not resolve them
Path traversal through index pathssafe: Flat__Assembly builds paths only from entry names reached from the root, and every write and delete goes through _checkout_flat_map / _remove_deleted_flat and Vault__Path_Guard.safe_join
Ranged readssafe: a lying central directory can only make objects be refused; bodies go through Vault__Verified_Write

2. Blocking

B1. Every push walks the head's whole tree to order a few objects

Vault__Packs.path_order, called by build_commit_pack for every commit pack, descends into every folder of the head, including the unchanged ones. An unchanged tree cannot hold a new object, so none of that walk places anything.

End to end on the 76,000-file vault (in-memory host): a 5-file push took 6.95 s, against 4.63 s on c0694753, and a 300-file pass 7.43 s against 5.18 s. The fixed +2.3 s per push is this walk, warm, plus the commit index build (L3).

B2. sgit checkout <path> overwrites uncommitted edits without a word

Vault__Sync__Checkout.checkout writes through _checkout_flat_map with no dirty check. Repro on the in-memory host:

  1. clone --no-checkout, then checkout raw/uk/gov;
  2. edit raw/uk/gov/a.txt; status reports it modified;
  3. checkout raw/uk overwrites it with the head's version, and the edit is lost.

--since-pull, --changed-since and --all take the same path. git checkout -- <path> also discards edits, but here checkout is the additive verb in the agents' daily loop, and pull already refuses to overwrite dirty files.

Fix: run Vault__Pull__Guard.dirty_paths over the selection and skip, or refuse, dirty paths (and untracked files at tracked paths) unless --force. Report what was skipped.

3. Medium

M1. The pack store can lose a descriptor in the middle of a read

_handle returns a descriptor under the lock, but _read calls os.pread outside it, and another thread opening a seventeenth pack evicts and closes a descriptor that may be in use (MAX_OPEN_PACKS = 16).

M2. A lost checkout.json on a pack vault deadlocks commit and push

With the record gone, the fallback rule ("a blob is local, so it was checked out") makes every never-written file look deleted on a pack vault, which holds every blob.

The module's docstring says losing the record "costs nothing". On a pack vault it blocks pushing. Nothing is deleted from the vault: commit keeps every entry.

Fix: one rule shared by status, commit and push. On a pack vault without a record, treat "absent" as "not checked out", never as a deletion, and rebuild the record from the scan cache, which knows every file sgit wrote.

M3. Compaction re-diffs the whole history for file-commits

build_snapshot_members calls changes_of for every reachable commit at every compaction. The cost grows with history (your backfill: 178 s for 14 index packs). The dev pack (02, section 2) says to build it from the previous snapshot index's file-commits plus the changes of the commit indexes being absorbed. That is the same answer for the price of reading members you already wrote.

M4. A scoped clone's request count grows with the number of packs

With the newsroom's 50 commit packs that is 200 or more calls before any data. Cache the presigned URL per pack for its lifetime. Locate the head's objects from the snapshot index's at and tree_at first, and read central directories only for what at does not cover (older versions, commits). Decision 6 in your note keeps the directory for the cases at cannot answer, which is right for those.

Measured: clone --path raw/c09/s4 (1,520 files, about 0.2 MB of blobs) made 17 requests and read 13.7 MB in ranges, most of it the snapshot pack's 10.7 MB central directory. It is still 11 times faster than the base, which takes the whole 69 MB pack: 3.0 s against 33.8 s. With at, the same clone reads the folder's one range.

M5. A pack clone still walks every tree after extracting the packs

Profiled: a --no-checkout clone of the 76,000-file vault spends half its time in Step__Clone__Walk_Trees: 88 of 167 s under the profiler. It loads all 58,062 trees through Vault__Commit.load_tree and Type_Safe's from_json. The other half is extract_pack, which the pack store removes.

On a pack vault this walk finds nothing new:

That is why --no-checkout still costs 58 s against 77 s for the full clone, and why the full clone is only 8% faster than the base (77.4 s against 84.0 s). The response note puts the full clone's time on "the extraction and the checkout"; on this vault the walk is half of it.

Fix: on a pack vault whose snapshot index verified, skip the tree walk. Check presence with the object store's set, or with the pack store's table, against the trees named by the flat member's dirs. Where trees must be opened, read them as JSON, as 0.25.0 does elsewhere. Expected: the --no-checkout clone drops to extraction alone, and to seconds with --pack-store.

M6. history log walks trees for every commit a snapshot absorbed

inspect_commit_chain uses a commit's index when one is local, and falls back to diff_trees otherwise. The reader never fetches indexes/archive/, and the first push of a vault writes a snapshot with no commit index. So after every compaction, every older commit in the log is diffed through its trees: 7.4 s under the profiler on the benchmark vault, two of four commits. That is the cost the indexes were meant to remove, and it returns after each compaction.

Fix: fetch the archived commit indexes a log reaches, on demand (they are on the host, small, and already verified by the same rules), or let a snapshot index carry the changes of the commits it absorbs.

4. Low

#FindingFix
L1open_member inflates with no bound. The compressed member is capped at 256 MB, but a key holder can ship one that inflates to tens of GB before the hash checkzlib.decompressobj().decompress(data, MAX) and refuse what remains
L2_folder_ok forbids / in names but accepts . and ... The guard refuses them at write timereject '', ., .. and NUL in names at parse time, so a bad index is dropped whole instead of failing a write
L3build_commit_members builds folder members for the whole head (folder_entries over every folder), then keeps the touched ones: 2.4 s cold, 0.33 s warm per commit on the benchmark vaultbuild only the touched folders, with their totals from the parent's member plus the change
L4The index packs are large where files are small: 52.5 MB for the 228,000-file vault (29% of its snapshot pack), and 32.6 MB beside a 69 MB data pack on the 76,000-file benchmark vault (47%). Every clone downloads them. On the newsroom's 1.22 GB snapshot the ratio would be a few per cent. file-commits and blob-paths are about a thirdmove the cross-references to an optional member set fetched on first use, as your note suggests; deflate the folder members together in groups (a 226-byte member pays 28 bytes of AES-GCM and loses most of what deflate could find)
L5The pack store's table is SQLite (78 MB on the 228,000-file vault), where the dev pack said no database filesacceptable for a local, derived, rebuildable table; a flat offset file per pack would be smaller and has no schema to version. Your call, worth a line in the threat model

5. Your decisions, answered

  1. Lie below the root in a signed index: agree. TM-R42 is the right place, and pack index verify is the remedy.
  2. sgit checkout <path> as the file verb: fine, provided B2 is fixed. The name invites git's habit of discarding work, so the dirty guard matters more.
  3. A plain pull on a selective clone writes only what is checked out: agree.
  4. Checkout set as a plain file under TM-R41: agree, with M2's shared rule.
  5. Verify on every read: agree; the hash is cheap next to the disk.
  6. Directory instead of at for scoped reads: keep the directory for what at cannot cover, and use at first (M4).
  7. Path order by default: agree, after B1.
  8. Pack names sorting by commit id within a second: noted. Nothing in the client should ever take "last listed" as "newest".

6. Measured on the 76,000-file vault

In-memory host, one process, the same script against c0694753 (the base, after 0.25.0) and a9f24d86. The vault is vault/genbig.py's 76,000 files in 58,062 folders. A 300-file pass and a 5-file commit are pushed after the clones.

FlowBase c0694753Branch a9f24d86Note
first commit, 76,000 files55.6 s51.2 s
first push (snapshot)60.1 s93.5 s+ the snapshot index (32.6 MB) and path order
host after the first push69.1 MB101.7 MBindex packs 32.6 MB (L4)
full clone84.0 s77.4 shalf of it is the tree walk (M5)
clone --no-checkoutn/a57.9 sno working files; the walk and the extraction remain
clone --pack-storen/a61.3 s.sg_vault 161.7 MB against 109.3 MB; the walk remains
clone --path raw/c09/s4 (1,520 files)33.8 s, the whole 69 MB pack3.0 s, 13.7 MB in ranges, 17 requests11 times faster; at would make it one range (M4)
commit, 300-file pass5.0 s4.8 s
push, 300-file pass5.2 s7.4 sB1
push, 5-file commit4.6 s7.0 sB1
pull of both, full clone5.5 s5.8 sa short history: little for the indexes to save here
pull, --no-checkout clonen/a1.1 s
pull, pack-store clonen/a6.2 s
history log, with counts3.3 s3.4 sthe commits without a local commit index are diffed through trees (M6)

The CLI team's own numbers on the 228,000-file vault (pull of a pass 31.7 s → 16.9 s, history log 5.7 s → 0.2 s) come from longer histories with every commit indexed. They and these are consistent: the gains show where the indexes cover the commits, and M5 and M6 are where they do not yet.

7. Merge to dev?

After B1 and B2, yes. Both are small fixes with tests:

M1 to M6 should land in the same round if they fit. None of them blocks: M1 needs --pack-store with more than 16 packs and concurrent reads, M2 needs a deleted record, and M3 to M6 are cost. M5 and M6 are where the newsroom's full clones and logs will gain, so they are the next most valuable work after B1 and B2.

This page as markdown · its source, admin/content/research/pack-indexes-review-round-1.md