/**
* A FAILED git walk must not be cached as "this repo has 0 commits".
*
* commitStats runs ONE `git log --all --format=%at|%H` through execGit, which
* resolves on 'close' REGARDLESS of exit code, with stdio[2] set to 'ignore'
* so stderr is discarded. A git failure therefore produces '' -- which parses
* to { commits: 0, lastCommit: null, activity: all-zero }, byte-identical to a
* legitimately empty repo. computeRepoDetails then caches that keyed on the
* pack-dir mtime, and flushRepoActivityCache persists it, so a single
* transient failure is served for the life of the pack.
*
* Production, stortron 2026-09-02: repo-activity-cache.json held
* "dc-terrain": { cachedAt 11:07:39, mtime 10:59:12,
* data: { commits: 0, lastCommit: null, activity: [0,...] } }
* while `git -C <bare> log --all` -- the daemon's exact invocation -- returned
* 514 commits, exit 0, empty stderr, and tabertron reported 514 for the same
* repo at the same HEAD. The zero survived two daemon restarts.
*
* The trigger was unrecoverable precisely BECAUSE the exit code and stderr
* were thrown away. That is the bug: a failure has to be distinguishable from
* an empty repo, and must not be cached.
*/
import test from 'brittle'
import { mkdtempSync, mkdirSync, writeFileSync, statSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { execFile } from '../lib/_compat-exec.js'
import {
configureRepoActivityCache,
enrichRepos,
getRepoActivityStats,
resetRepoActivityCacheForTests
} from '../lib/services/repo-activity.js'
let n = 0
const tmpDir = () => mkdtempSync(join(tmpdir(), 'pgv3-activity-fail-' + (n++) + '-'))
const statusFor = (name) => ({ repos: [{ name, branches: 0, packSize: 0, head: null, serving: true, lastUpdated: 0 }] })
test('a failed git walk is not cached as commits:0', async (t) => {
t.timeout(60000)
// A directory that is NOT a git repo — `git log --all` exits non-zero.
const notARepo = join(tmpDir(), 'not-a-repo')
mkdirSync(notARepo, { recursive: true })
writeFileSync(join(notARepo, 'placeholder'), 'x')
resetRepoActivityCacheForTests()
const reposMap = new Map([['broken', { path: notARepo }]])
const first = await enrichRepos(statusFor('broken'), reposMap)
t.is(first[0].commits, 0, 'a failed walk still renders as 0 for display')
const afterFirst = getRepoActivityStats().computes
const second = await enrichRepos(statusFor('broken'), reposMap)
t.is(second[0].commits, 0, 'still 0 on the second pass')
t.is(getRepoActivityStats().computes, afterFirst + 1,
'the failure was RECOMPUTED, not served from cache — a transient git failure must not stick')
t.is(getRepoActivityStats().cached, 0,
'nothing failed was written to the cache, so nothing can be persisted')
})
test('a successful walk IS still cached (no over-correction)', async (t) => {
t.timeout(60000)
const repo = join(tmpDir(), 'real.git')
await execFile('git', ['init', '--bare', '-q', repo])
// Give it a real commit so the walk returns something.
const work = join(tmpDir(), 'work')
mkdirSync(work, { recursive: true })
await execFile('git', ['init', '-q', work])
await execFile('git', ['-C', work, 'config', 'user.email', 't@t'])
await execFile('git', ['-C', work, 'config', 'user.name', 't'])
writeFileSync(join(work, 'f.txt'), 'hello')
await execFile('git', ['-C', work, 'add', 'f.txt'])
await execFile('git', ['-C', work, 'commit', '-qm', 'one'])
await execFile('git', ['-C', work, 'push', '-q', repo, 'HEAD:refs/heads/master'])
resetRepoActivityCacheForTests()
const reposMap = new Map([['real', { path: repo }]])
const first = await enrichRepos(statusFor('real'), reposMap)
t.is(first[0].commits, 1, 'counts the real commit')
const afterFirst = getRepoActivityStats().computes
const second = await enrichRepos(statusFor('real'), reposMap)
t.is(second[0].commits, 1, 'same answer')
t.is(getRepoActivityStats().computes, afterFirst,
'a SUCCESSFUL result is served from cache — caching still works')
})
test('a zero-commit entry already on disk is distrusted at boot and recomputed', async (t) => {
// The fix above stops NEW poison. An entry written by an older build is
// still on disk and, because the cache key is the pack-dir mtime, would be
// served for the life of that pack — across restarts, since it is persisted.
//
// So a persisted { commits: 0, lastCommit: null } is distrusted on load. It
// is indistinguishable from a genuinely empty repo, and that is fine: the
// only cost of dropping a real empty repo's entry is ONE cheap recompute
// that writes the same zero straight back.
t.timeout(60000)
const storage = tmpDir()
const repo = join(tmpDir(), 'poisoned.git')
await execFile('git', ['init', '--bare', '-q', repo])
const work = join(tmpDir(), 'w2')
mkdirSync(work, { recursive: true })
await execFile('git', ['init', '-q', work])
await execFile('git', ['-C', work, 'config', 'user.email', 't@t'])
await execFile('git', ['-C', work, 'config', 'user.name', 't'])
writeFileSync(join(work, 'a.txt'), 'x')
await execFile('git', ['-C', work, 'add', 'a.txt'])
await execFile('git', ['-C', work, 'commit', '-qm', 'one'])
await execFile('git', ['-C', work, 'push', '-q', repo, 'HEAD:refs/heads/master'])
// Hand-write the exact shape stortron had for dc-terrain: a real repo, a
// zeroed entry, keyed on the CURRENT pack mtime so the key still matches.
let packMtime = 0
try { packMtime = statSync(join(repo, 'objects', 'pack')).mtimeMs } catch {}
writeFileSync(join(storage, 'repo-activity-cache.json'), JSON.stringify({
version: 1,
entries: {
poisoned: {
cachedAt: Date.now(),
mtime: packMtime,
data: { description: '', commits: 0, lastCommit: null, activity: new Array(365).fill(0) }
}
}
}))
resetRepoActivityCacheForTests()
await configureRepoActivityCache(storage)
const out = await enrichRepos(statusFor('poisoned'), new Map([['poisoned', { path: repo }]]))
t.is(out[0].commits, 1,
'the persisted zero was distrusted and recomputed from the real repo')
})