B1 Build-time index
Make page history a constant-cost build step. This is the on-ramp to the whole experiment: the consuming site has history switched off because of this cost.
T1.1 Benchmark harness
- Wave: 1
- Depends on: none
- Size: M
- Design: build-index.md (observability)
- Touches:
scripts/bench-history.js(new),git.js(stats only),test/bench.test.js(new),package.json(script) - Why: the gate is a bound on git invocations. The harness measures it the same way before and after T1.2.
Steps
git.js: add a module-level_stats = { gitCalls: 0, indexSource: 'none', indexPaths: 0, indexMs: 0 }. IncrementgitCallsinside thegit()helper. Export_getGitStats()returning a copy and_resetGitState()that resets stats and forces the next call to re-runinit(set_ready = false). Do not change behaviour.scripts/bench-history.jswith flags--pages N(default 300),--commits C(default 1200),--seed S(default 1),--no-index(passesindex: false, meaningful after T1.2),--keep(do not delete the temp repo; print its path).- Create a temp git repository with
docs/page-0001.md…docs/page-N.md. - Make C commits; each touches between 1 and 5 pages chosen by a seeded
PRNG (implement a 32-bit LCG; do not add a dependency). Every 200th
commit renames one page with
git mv. Commit with-c commit.gpgsign=false, fixed author, deterministic dates (GIT_AUTHOR_DATE/GIT_COMMITTER_DATEstepping one hour per commit from2026-01-01T00:00:00Z). - Call
_resetGitState(), thengetGitMeta(path, { cwd, cacheFile, silent: true, contentRoots: ['docs'] })for every current page path, timing the loop. - Print exactly one JSON line:
{ "pages", "commits", "gitCalls", "ms", "indexSource", "indexed": indexSource !== 'none', "cacheFile" }. --warmruns the loop a second time after_resetGitState()with the samecacheFileand prints the second run’s JSON.
- Create a temp git repository with
package.jsonscript"bench:history": "node scripts/bench-history.js".
Acceptance
test/bench.test.js:
benchmark harness produces a deterministic repository(run twice with--pages 5 --commits 12 --seed 7 --keep, assert the twogit log --format=%Houtputs are equal)benchmark harness prints one json line with the expected keys(--pages 20 --commits 30, parse stdout, assert keys andpages === 20)
npm run bench:history -- --pages 50 --commits 100 # one JSON line; gitCalls will be > 150 before T1.2
T1.2 Build-time history index
- Wave: 2
- Depends on: T1.1
- Size: L
- Design: build-index.md
- Touches:
git.js,index.js(passindexandcontentRootsthrough; nothing else),index.d.ts(options andlocalCommits),test/git-index.test.js(new),README.md(options table under “Consumer build requirements”) - Why: the core of B1.
Steps
- Keep the existing per-path implementation; rename the body of
getGitMetatogetGitMetaByWalk(repoPath, candidates, opts)and leave it intact. - In
init(opts): after the existing rev-parse calls, whenopts.index !== false, build the index as the design specifies: onegit log, onegit status --porcelain, onegit ls-files, withcontentRoots(default['.']) as pathspecs. Store_index = { rows: Map<path, Row[]>, tracked: Set, dirty: Map<path, status>, roots }. RecordindexSource = 'walk',indexPaths,indexMs, and print the summary line unlesssilent. - New
getGitMeta: resolve candidates as today; if the index exists and the first candidate that is intrackedorrowsis undercontentRoots, derive the result from rows as the design describes and attachlocalCommits. Otherwise callgetGitMetaByWalk. index.js:serializableIntegrationOptionsalready serialises arbitrary options; nothing to change except the JSDoc. Updateindex.d.ts.
Acceptance
test/git-index.test.js (build temp repositories as test/git.test.js does):
index returns identical metadata to the per-path walk(6 files, 8 commits including onegit mvfollowed by an edit; for every path comparecreated,updated,revisions,contributors,authors,signed,statebetween{ index: true }and{ index: false })index git call count is independent of page count(repo A with 3 files, repo B with 60 files; query every file;gitCallsequal for A and B and<= 8)index attaches local commits newest first(localCommits[0].shaequalsgit rev-parse HEADfor a file touched by the last commit)paths outside content roots fall back to the per-path walk(contentRoots: ['docs'], ask forREADME.md; result matches the walk;indexSourcestayswalk)dirty and untracked states survive indexing(modify a tracked file →dirty: true; add a new file →state: 'untracked';git addanother →state: 'staged')
npm test 2>&1 | grep -c "^✔ index \|^✔ paths outside\|^✔ dirty and untracked" # prints 5
npm run bench:history -- --pages 300 --commits 1200 # gitCalls <= 8, indexed true
npm run bench:history -- --pages 3 --commits 1200 # gitCalls equal to the line above
T1.3 Warm index cache
- Wave: 3
- Depends on: T1.2
- Size: S
- Design: build-index.md (cache)
- Touches:
git.js,test/git-index.test.js - Why: rebuilds on the same commit should not walk history at all.
Steps
- On a walk, store
__index: { head, roots, rows }in the cache object and mark it dirty soflush()writes it. - On init, if the cache has
__indexwithhead === git rev-parse HEADand equalroots, load rows from it and setindexSource = 'cache'; skip thegit log. - Rows must round-trip through JSON (plain objects, no
Mapin the file).
Acceptance
Add to test/git-index.test.js:
warm index skips the history walk(second_resetGitState()+ query with the samecacheFile:indexSource === 'cache',gitCalls <= 7, results deep-equal the cold run)index cache is invalidated by a new commit(commit again;indexSource === 'walk')
npm run bench:history -- --pages 300 --commits 1200 --warm # second line: indexSource "cache", gitCalls <= 7
T1.4 GitLab enrichment from the index
- Wave: 3
- Depends on: T1.2
- Size: L
- Design: build-index.md (
localCommits) - Touches:
gitlab.js,index.d.ts,test/gitlab-index.test.js(new),README.md(one paragraph under “Limits, caching, and retries”) - Why: after T1.2 the only per-page remote call left is
repository/commits?path=…. With local commits known, the remote work becomes one signature lookup per unique commit and one user lookup per unique email, both already cached.
Steps
- In
getGitMetaEnhanced, whenlocal.localCommitsexists,local.state === 'committed'and!local.historyIncomplete: do not callgitlabPaged(... repository/commits ...). BuildrawCommitsfromlocalCommits(firstmaxCommits) as objects with the fieldsmapCommitreads:id,short_id(first 8),title,message(title),author_name,author_email,authored_date,committed_date(both the row date),web_url(${buildProjectUrl(client)}/-/commit/${sha}whenprojectPathis known, else undefined),stats: null.complete = true. - Everything downstream (
mapCommit, signatures, user resolution, tags, in-flight) stays as it is. - Signature responses are immutable; give them a cache TTL of 30 days in the
response cache regardless of
cacheTtlMs. Keep the user lookup TTL. trackingSourcefor this path is'mixed'.
Acceptance
test/gitlab-index.test.js (temp repo with 3 pages sharing 2 commits by 1 author; counting fake fetch built from test/git.test.js’s createGitLabFetch pattern):
enriched history uses local commits and makes no per-path commits request(zero requests whose path ends in/repository/commitsand carry apathquery)enriched history requests one signature per unique commit and one user per unique email(signature requests=== 2,users?searchrequests=== 1across the three pages)enriched history links commits to the project when the project path is known(meta.gitlab.commits[0].url === 'https://gitlab.example.test/group/project/-/commit/<sha>')enriched history falls back to the commits request for a shallow clone(historyIncomplete: trueinput → one/repository/commitsrequest withpath)
npm test 2>&1 | grep -c "^✔ enriched history" # prints 4