OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / repo-activity-failure-not-cached.test.js

143 lines · javascript ref:
/**
 * 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')
})