typescript-namespace-git-init
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.
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}