diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 78ebfce4..e859970e 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { execFileSync } from 'child_process'; import { CodeGraph } from '../src'; import { Node, Edge } from '../src/types'; import { isInitialized, getCodeGraphDir, validateDirectory } from '../src/directory'; @@ -59,6 +60,63 @@ describe('CodeGraph Foundation', () => { cg.close(); }); + it('should add .codegraph to the local git exclude file', () => { + execFileSync('git', ['init', '-q'], { cwd: tempDir, stdio: 'ignore' }); + + const cg = CodeGraph.initSync(tempDir); + + const excludePath = path.join(tempDir, '.git', 'info', 'exclude'); + const content = fs.readFileSync(excludePath, 'utf-8'); + expect(content).toContain('# CodeGraph local index'); + expect(content).toContain('.codegraph/'); + + const status = execFileSync('git', ['status', '--short'], { + cwd: tempDir, + encoding: 'utf8', + }); + expect(status).not.toContain('.codegraph'); + + cg.close(); + }); + + it('should not duplicate an existing .codegraph git exclude entry', () => { + execFileSync('git', ['init', '-q'], { cwd: tempDir, stdio: 'ignore' }); + const excludePath = path.join(tempDir, '.git', 'info', 'exclude'); + fs.appendFileSync(excludePath, '\n.codegraph/\n'); + + const cg = CodeGraph.initSync(tempDir); + + const content = fs.readFileSync(excludePath, 'utf-8'); + const occurrences = content.split('.codegraph/').length - 1; + expect(occurrences).toBe(1); + + cg.close(); + }); + + it('should add a relative local git exclude entry for a nested project', () => { + execFileSync('git', ['init', '-q'], { cwd: tempDir, stdio: 'ignore' }); + const nestedDir = path.join(tempDir, 'packages', 'app'); + fs.mkdirSync(nestedDir, { recursive: true }); + + const cg = CodeGraph.initSync(nestedDir); + + const excludePath = path.join(tempDir, '.git', 'info', 'exclude'); + const content = fs.readFileSync(excludePath, 'utf-8'); + expect(content).toContain('packages/app/.codegraph/'); + expect(content).not.toContain('\n.codegraph/\n'); + + cg.close(); + }); + + it('should initialize outside git without a local exclude file', () => { + const cg = CodeGraph.initSync(tempDir); + + expect(CodeGraph.isInitialized(tempDir)).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.git', 'info', 'exclude'))).toBe(false); + + cg.close(); + }); + it('should throw if already initialized', () => { const cg = CodeGraph.initSync(tempDir); cg.close(); diff --git a/src/directory.ts b/src/directory.ts index 588911c1..70eb999b 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { execFileSync } from 'child_process'; /** * CodeGraph directory name @@ -103,6 +104,89 @@ cache/ fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8'); } + + ignoreCodeGraphDirectory(projectRoot); +} + +/** + * Add .codegraph/ to this repository's local excludes. + * + * We use .git/info/exclude instead of the project's .gitignore because the + * CodeGraph index is local machine state. This keeps it out of commits without + * changing a tracked file just because someone ran `codegraph init`. + */ +export function ignoreCodeGraphDirectory(projectRoot: string): void { + const exclude = gitExclude(projectRoot); + if (!exclude) return; + + let content = ''; + try { + content = fs.existsSync(exclude.path) ? fs.readFileSync(exclude.path, 'utf-8') : ''; + } catch { + return; + } + + if (hasIgnoreEntry(content, exclude.entry)) return; + + const nextContent = appendIgnoreEntry(content, exclude.entry); + try { + fs.mkdirSync(path.dirname(exclude.path), { recursive: true }); + fs.writeFileSync(exclude.path, nextContent, 'utf-8'); + } catch { + // Local exclude is a convenience. Initialization should still succeed if + // git metadata is read-only or otherwise inaccessible. + } +} + +function gitExclude(projectRoot: string): { path: string; entry: string } | null { + try { + const inside = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: projectRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }).trim(); + if (inside !== 'true') return null; + + const worktreeRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: projectRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }).trim(); + if (!worktreeRoot) return null; + + const gitPath = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { + cwd: projectRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }).trim(); + if (!gitPath) return null; + + const excludePath = path.isAbsolute(gitPath) ? gitPath : path.resolve(projectRoot, gitPath); + const relativeProject = path.relative(worktreeRoot, projectRoot).split(path.sep).join('/'); + const entry = relativeProject + ? `${relativeProject}/${CODEGRAPH_DIR}/` + : `${CODEGRAPH_DIR}/`; + + return { path: excludePath, entry }; + } catch { + return null; + } +} + +function hasIgnoreEntry(content: string, entry: string): boolean { + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .some((line) => line === entry || line === entry.replace(/\/$/, '')); +} + +function appendIgnoreEntry(content: string, entry: string): string { + const trimmed = content.replace(/\s*$/, ''); + const block = `# CodeGraph local index\n${entry}\n`; + return trimmed.length > 0 ? `${trimmed}\n\n${block}` : block; } /**