Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions __tests__/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
84 changes: 84 additions & 0 deletions src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import * as fs from 'fs';
import * as path from 'path';
import { execFileSync } from 'child_process';

/**
* CodeGraph directory name
Expand Down Expand Up @@ -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;
}

/**
Expand Down