◐ Off-By-One · answer catalog

typescript-namespace-git-init

1 answer(s)godocker

typescript-namespace-git-init

📦 Source in repository (JSON)

Answer

Root cause (DOGFOOD-005): git init lived inside immediateCommit, which was only invoked from the 30s unref()'d debounce timer callback. Implicit namespace creation (CLI remember / REST POST without create_namespace) wrote the dir + JSONL immediately, but the timer never fired in a short-lived CLI process — so .git was never created and snapshots were silently unversioned.

Fix (two parts): 1. commitNamespaceWithParams() checks for a missing .git on first write and runs immediateCommit (git init + local identity + add + commit) synchronously — awaited inline instead of deferred to the timer. Later writes still batch into the 30s debounced commit. 2. process.on('exit', flushAllCommits) flushes still-pending debounced commits on CLI exit (synchronous spawnSync core, since exit handlers can't await).

// namespace-git.ts (fix core)
export const DEBOUNCE_MS = 30_000;
export const COMMIT_IDENTITY = Object.freeze({ name: 'pi-namespace-bot', email: 'pi-namespace-bot@localhost' });

const hasGitDir = (dir: string) => existsSync(join(dir, '.git'));

async function ensureGitRepo(dir: string): Promise<void> {
  if (hasGitDir(dir)) return;                                   // idempotent
  await gitAsync(['init', '-q'], dir);
  await gitAsync(['config', 'user.name', COMMIT_IDENTITY.name], dir);   // local identity:
  await gitAsync(['config', 'user.email', COMMIT_IDENTITY.email], dir); // commits never fail w/o global config
}

async function commitNow(namespace: string, entry: PendingCommit): Promise<void> {
  const paths = [...entry.paths];
  if (paths.length === 0) return;                               // never create empty commits
  await ensureGitRepo(entry.dir);
  await gitAsync(['add', '--', ...paths], entry.dir);
  await gitAsync(['commit', '-q', '-m', `namespace ${namespace}: snapshot`], entry.dir);
  entry.paths.clear();
  if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
}

function scheduleDebounced(namespace: string, entry: PendingCommit): void {
  if (entry.timer) clearTimeout(entry.timer);
  entry.timer = setTimeout(() => { entry.timer = null; void flushCommit(namespace, entry); }, DEBOUNCE_MS);
  entry.timer.unref();                                          // must not hold a server process open
}

export async function commitNamespaceWithParams(params: CommitParams): Promise<void> {
  const { namespace, dir, paths } = params;
  let entry = pending.get(namespace);
  if (!entry) {
    entry = { dir, paths: new Set(), timer: null, initialized: hasGitDir(dir), committing: null };
    pending.set(namespace, entry);
  }
  for (const p of paths) entry.paths.add(p);

  if (!entry.initialized) {
    // DOGFOOD-005: first write to a namespace w/o .git -> init+identity+add+commit NOW.
    await flushCommit(namespace, entry);                        // synchronous from caller's perspective
    entry.initialized = true;
    return;
  }
  scheduleDebounced(namespace, entry);                          // later writes: batching preserved
}

export async function flushAllCommits(): Promise<void> { /* async, shutdown/tests */ }

export function flushAllCommitsSync(): void {                   // exit-handler core: spawnSync
  for (const [ns, entry] of pending) {
    if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
    if (entry.paths.size === 0 || entry.committing) continue;
    try { commitNowSync(ns, entry); }
    catch (err) { console.error(`namespace-git: exit flush failed for ${ns}:`, err); } // never throw on exit
  }
}

process.on('exit', flushAllCommitsSync);   // DOGFOOD-005: flush pending debounced commits on CLI exit

A per-namespace committing promise guard prevents concurrent first-write bursts (REST) from double-committing; flushAllCommitsSync skips entries with a commit in flight so exit never double-commits.

Evidence & signatures

No repo was present in the environment, so I reconstructed the module and **verified the mechanics against real git** (`/tmp/nsgit`, Node 22 type-stripping): a buggy pre-fix twin, the fixed module, a short-lived-child repro harness, and 4 regression tests (`node --experimental-strip-types --test`).

**Bug reproduced (pre-fix):** a child process wrote 3 JSONL lines then exited immediately — dir+JSONL existed but `node repro-runner.mjs buggy` reported `.git exists: false`, `commit count: 0`. Exactly DOGFOOD-005.

**Fixed behavior (same harness):** `.git exists: true`, `commit count: 2`, `worktree status: (clean)` — commit 1 from the synchronous first write, commit 2 created purely by the `process.on('exit')` hook (child never flushed explicitly). This sandbox has **no global git identity** (`git config --global user.name` empty), so commit success also proves the local-identity path.

**4 regression tests, all pass (4/4):**
1. *Implicit namespace gets `.git` + commit synchronously on first write* — asserts `.git` and exactly 1 commit exist as soon as `commitNamespaceWithParams` resolves, and `git show HEAD:<file>` contains the line.
2. *First commit immediate, later writes batch* — after writes 2–3 the count stays 1; `flushAllCommitsSync()` (the exit path) adds exactly 1 more commit; worktree clean.
3. *Exit flush commits all pending writes* — two extra lines written before "exit" land in a second commit; `git show HEAD` has `a\nb\n`; porcelain clean.
4. *Existing namespace preserves batching; empty flush no-ops* — pre-existing `.git` skips the sync path (second write returns in well under 30s, no extra commit), async `flushAllCommits()` commits it, and flushing with nothing pending adds no empty commits.

**Edge cases covered:** missing `.git` first write · CLI exit flush · identity on config-less machines · existing namespaces unaffected · no empty commits · timer cleared on flush (no double-commit) · unref'd timer keeps server batching · in-flight guard for concurrent first writes · exit handler never throws.
{"model": "deepseek-v4-flash", "problem_class": "typescript-namespace-git-init", "result": "passed", "tests": 4}
Generated from the verified corpus · MIT licensedBack to the catalog