fix(directory): self-heal a stale .codegraph/.gitignore so daemon.pid is ignored (#788) (#802)

Versions <= 0.9.9 wrote an explicit-allowlist .codegraph/.gitignore
(*.db, cache/, .dirty, ...) that never listed daemon.pid or the socket,
so the daemon's runtime pidfile got committed. The wildcard rewrite in
#654/#492/#484 fixed new inits, but the file is only written when
absent, so existing installs kept their stale file forever — the fix
never reached the people hitting it.

Make the gitignore self-heal: ensureGitignore() writes the file if
absent and upgrades a stale CodeGraph-generated default in place,
leaving a user-authored file untouched. A "stale default" is one that
carries our `# CodeGraph data files` header but predates the wildcard
ignore (no bare `*` line) — a header match heals every historical
variant (v0.7.x..0.9.9, all verified to share it) and is idempotent.
validateDirectory() runs on every open()/openSync(), so existing repos
heal on the next codegraph command after upgrading. The duplicated
template (previously inlined in two formats) is consolidated into one
GITIGNORE_CONTENT constant.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-11 11:09:05 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c39b4b938e
commit 9a0f144770
3 changed files with 107 additions and 21 deletions
+66 -21
View File
@@ -129,6 +129,61 @@ export function findNearestCodeGraphRoot(startPath: string): string | null {
return null;
}
/**
* Contents of `.codegraph/.gitignore`. A single wildcard ignore keeps every
* transient file in the index dir — the database, `daemon.pid`, the socket,
* logs, cache, and anything future versions add — out of git, without having
* to enumerate each name (issues #788, #492, #484). Older versions wrote an
* explicit allowlist that never listed `daemon.pid` or the socket, so those
* runtime files were silently committed.
*/
const GITIGNORE_CONTENT = `# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
`;
/** Header line that prefixes every .gitignore CodeGraph has auto-generated. */
const GITIGNORE_MARKER = '# CodeGraph data files';
/**
* Is `content` a stale CodeGraph-generated `.gitignore` that should be
* regenerated in place? True when it carries our header but predates the
* wildcard ignore (it has no bare `*` line) — i.e. one of the old explicit
* allowlists (`*.db`, `cache/`, `.dirty`, …) that never ignored `daemon.pid`
* or the socket (issue #788). A file WITHOUT our header is user-authored and
* is left untouched; one that already has the wildcard is current. Matching
* on the header (not a byte-exact list of past defaults) heals every old
* variant — v0.7.x through 0.9.9 — and is idempotent once upgraded.
*/
function isStaleDefaultGitignore(content: string): boolean {
if (!content.trimStart().startsWith(GITIGNORE_MARKER)) return false;
return !content.split('\n').some((line) => line.trim() === '*');
}
/**
* Write `.codegraph/.gitignore` if it's absent, or upgrade a stale
* CodeGraph-generated default in place; a user-customized file is left alone.
* Best-effort — returns `false` only if a needed write failed.
*/
function ensureGitignore(gitignorePath: string): boolean {
let existing: string | null;
try {
existing = fs.readFileSync(gitignorePath, 'utf-8');
} catch {
existing = null; // absent (ENOENT) or unreadable — (re)create below
}
// Current default or a user-authored file: nothing to do.
if (existing !== null && !isStaleDefaultGitignore(existing)) return true;
try {
fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
return true;
} catch {
return false;
}
}
/**
* Create the .codegraph directory structure
* Note: Only throws if codegraph.db already exists, not just if .codegraph/ exists.
@@ -146,18 +201,9 @@ export function createDirectory(projectRoot: string): void {
// Create main directory (if it doesn't exist)
fs.mkdirSync(codegraphDir, { recursive: true });
// Create .gitignore inside .codegraph (if it doesn't exist)
const gitignorePath = path.join(codegraphDir, '.gitignore');
if (!fs.existsSync(gitignorePath)) {
const gitignoreContent = `# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
`;
fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8');
}
// Write .gitignore inside .codegraph (create if absent, upgrade a stale
// pre-wildcard default left by an older version — issue #788).
ensureGitignore(path.join(codegraphDir, '.gitignore'));
}
/**
@@ -296,16 +342,15 @@ export function validateDirectory(projectRoot: string): {
return { valid: false, errors };
}
// Auto-repair missing .gitignore (non-critical file)
// Auto-repair / upgrade .gitignore (non-critical file). A missing one is
// recreated; a stale pre-wildcard default that never ignored daemon.pid is
// regenerated in place (issue #788); a user-authored file is left alone.
const gitignorePath = path.join(codegraphDir, '.gitignore');
if (!fs.existsSync(gitignorePath)) {
try {
const gitignoreContent = `# CodeGraph data files — local to each machine, not for committing.\n# Ignore everything in .codegraph/ except this file itself, so transient\n# files (the database, daemon.pid, sockets, logs) never show up in git.\n*\n!.gitignore\n`;
fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8');
} catch {
// Non-fatal: warn but don't block
errors.push('.gitignore missing in .codegraph directory and could not be created');
}
const existedBefore = fs.existsSync(gitignorePath);
if (!ensureGitignore(gitignorePath) && !existedBefore) {
// Only a missing-and-uncreatable file is surfaced; a failed in-place
// upgrade of an existing file is non-fatal — the index still works.
errors.push('.gitignore missing in .codegraph directory and could not be created');
}
return {