mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 03:08:20 +08:00
The generated `.codegraph/.gitignore` ignores everything but itself, so the rule can be committed and shared. For someone using CodeGraph alone, that leaves `.codegraph/` showing as untracked in `git status`, with a file to commit that nobody else needs. `codegraph init --local-only` makes the file the wildcard alone, so it ignores itself too and nothing of CodeGraph's shows in git. It works on a new project and on one that's already initialized, where it converts the file without indexing again. Plain `codegraph init` is unchanged. Only a file CodeGraph generated is replaced. A file with the user's own rules is left alone, and so is a default the repository has committed, because rewriting a tracked file would show up as a change, the opposite of what the flag is for. Both cases say why they were left. The local-only file carries no generated header, so every later open leaves it alone. A flag rather than the proposed CODEGRAPH_LOCAL_GITIGNORE env var: the file is only written when it is missing, so an env var does nothing for a project that already has one, which is the reporter's case; an env var in a shell profile would also silently stop every new project sharing the rule, team repositories included. The flag is also what the issue proposed. Validation: 22 new tests, the CLI ones against real git repositories. They cover `git status --porcelain` staying empty, the default unchanged without the flag, conversion leaving the database untouched, and the file surviving sync, status, index and a plain init. Reimplements PR 1013 by @jiangyq9. Fixes #910 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
9.3 KiB
TypeScript
276 lines
9.3 KiB
TypeScript
/**
|
|
* `codegraph init --local-only` (#910): a `.codegraph/.gitignore` that ignores
|
|
* itself, so a project's CodeGraph data leaves no trace in `git status`.
|
|
*
|
|
* The default stays as it was, because a committed `.gitignore` is how a team
|
|
* shares the rule. So these also pin what must NOT be rewritten: a file with the
|
|
* user's own rules, and a file a repository has already committed.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { execFileSync, spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { getCodeGraphDir, makeGitignoreLocalOnly } from '../src/directory';
|
|
import CodeGraph from '../src/index';
|
|
|
|
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
|
|
|
// These index a project and spawn the real CLI, up to several times a test.
|
|
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
|
|
|
|
const LOCAL_ONLY = '*\n';
|
|
const DEFAULT = `# 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
|
|
`;
|
|
const STALE_DEFAULT = `# CodeGraph data files
|
|
*.db
|
|
*.db-wal
|
|
*.db-shm
|
|
cache/
|
|
.dirty
|
|
`;
|
|
|
|
describe('local-only .codegraph/.gitignore', () => {
|
|
let dir: string;
|
|
let gitignore: string;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-local-only-'));
|
|
gitignore = path.join(dir, '.codegraph', '.gitignore');
|
|
fs.writeFileSync(path.join(dir, 'a.ts'), 'export const a = 1;\n');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
function seedGitignore(content: string): void {
|
|
fs.mkdirSync(path.dirname(gitignore), { recursive: true });
|
|
fs.writeFileSync(gitignore, content);
|
|
}
|
|
|
|
function git(...args: string[]): string {
|
|
return execFileSync('git', args, {
|
|
cwd: dir,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
GIT_AUTHOR_NAME: 'CodeGraph Test',
|
|
GIT_AUTHOR_EMAIL: 'codegraph-test@example.invalid',
|
|
GIT_COMMITTER_NAME: 'CodeGraph Test',
|
|
GIT_COMMITTER_EMAIL: 'codegraph-test@example.invalid',
|
|
},
|
|
});
|
|
}
|
|
|
|
function initRepo(): void {
|
|
git('init', '-q');
|
|
git('add', 'a.ts');
|
|
git('commit', '-qm', 'baseline');
|
|
}
|
|
|
|
function codegraph(...args: string[]): { code: number | null; output: string } {
|
|
const run = spawnSync(process.execPath, [BIN, ...args], {
|
|
cwd: dir,
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', NO_COLOR: '1', DO_NOT_TRACK: '1', CODEGRAPH_TELEMETRY: '0' },
|
|
});
|
|
return { code: run.status, output: run.stdout + run.stderr };
|
|
}
|
|
|
|
describe('makeGitignoreLocalOnly', () => {
|
|
it('writes the wildcard alone when there is no file', () => {
|
|
fs.mkdirSync(path.dirname(gitignore));
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('written');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
|
|
});
|
|
|
|
it('writes into the directory the project really uses', () => {
|
|
const custom = process.env.CODEGRAPH_DIR;
|
|
process.env.CODEGRAPH_DIR = '.cg-custom';
|
|
try {
|
|
fs.mkdirSync(getCodeGraphDir(dir));
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('written');
|
|
expect(fs.readFileSync(path.join(dir, '.cg-custom', '.gitignore'), 'utf-8')).toBe(LOCAL_ONLY);
|
|
} finally {
|
|
if (custom === undefined) delete process.env.CODEGRAPH_DIR;
|
|
else process.env.CODEGRAPH_DIR = custom;
|
|
}
|
|
});
|
|
|
|
it('replaces the default CodeGraph generates, as the code generates it', () => {
|
|
CodeGraph.initSync(dir).close();
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(DEFAULT);
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('written');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
|
|
});
|
|
|
|
it('replaces a default checked out with Windows line endings', () => {
|
|
seedGitignore(DEFAULT.replace(/\n/g, '\r\n'));
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('written');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
|
|
});
|
|
|
|
it('replaces a default from before the wildcard', () => {
|
|
seedGitignore(STALE_DEFAULT);
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('written');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
|
|
});
|
|
|
|
it.each([
|
|
['with a newline', '*\n'],
|
|
['with a Windows newline', '*\r\n'],
|
|
])('leaves a file that is already local-only, %s', (_label, content) => {
|
|
seedGitignore(content);
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('unchanged');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(content);
|
|
});
|
|
|
|
it.each([
|
|
['the user wrote', 'codegraph.db\n'],
|
|
['we generated and the user added to', `${DEFAULT}!shared-notes.md\n`],
|
|
])('leaves a file %s', (_label, content) => {
|
|
seedGitignore(content);
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('customized');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(content);
|
|
});
|
|
|
|
it('leaves a default the repository has committed', () => {
|
|
seedGitignore(DEFAULT);
|
|
initRepo();
|
|
git('add', '.codegraph/.gitignore');
|
|
git('commit', '-qm', 'share the rule');
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('tracked');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(DEFAULT);
|
|
expect(git('status', '--porcelain')).toBe('');
|
|
});
|
|
|
|
it('replaces a default that is only untracked', () => {
|
|
seedGitignore(DEFAULT);
|
|
initRepo();
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('written');
|
|
});
|
|
|
|
it('works outside a git repository', () => {
|
|
seedGitignore(DEFAULT);
|
|
|
|
expect(makeGitignoreLocalOnly(dir)).toBe('written');
|
|
});
|
|
});
|
|
|
|
describe('codegraph init', () => {
|
|
it('leaves nothing in git status with --local-only', () => {
|
|
initRepo();
|
|
|
|
const { code, output } = codegraph('init', '-y', '--local-only');
|
|
|
|
expect(code).toBe(0);
|
|
expect(output).toContain('.codegraph/.gitignore now ignores itself');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
|
|
expect(git('status', '--porcelain')).toBe('');
|
|
});
|
|
|
|
it('still builds the index', () => {
|
|
codegraph('init', '-y', '--local-only');
|
|
|
|
expect(fs.existsSync(path.join(dir, '.codegraph', 'codegraph.db'))).toBe(true);
|
|
expect(codegraph('status').output).toContain('typescript');
|
|
});
|
|
|
|
it('keeps its default without the flag', () => {
|
|
initRepo();
|
|
|
|
const { code, output } = codegraph('init', '-y');
|
|
|
|
expect(code).toBe(0);
|
|
expect(output).not.toContain('ignores itself');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(DEFAULT);
|
|
expect(git('status', '--porcelain')).toContain('.codegraph/');
|
|
});
|
|
|
|
it('converts an initialized project, without indexing it again', () => {
|
|
initRepo();
|
|
codegraph('init', '-y');
|
|
const db = path.join(dir, '.codegraph', 'codegraph.db');
|
|
const indexed = fs.statSync(db).mtimeMs;
|
|
|
|
const { code, output } = codegraph('init', '-y', '--local-only');
|
|
|
|
expect(code).toBe(0);
|
|
expect(output).toContain('Already initialized');
|
|
expect(output).toContain('.codegraph/.gitignore now ignores itself');
|
|
expect(fs.statSync(db).mtimeMs).toBe(indexed);
|
|
expect(git('status', '--porcelain')).toBe('');
|
|
});
|
|
|
|
it('says so when there is nothing to convert', () => {
|
|
codegraph('init', '-y', '--local-only');
|
|
|
|
expect(codegraph('init', '-y', '--local-only').output).toContain('.codegraph/.gitignore already ignores itself');
|
|
});
|
|
|
|
it('survives everything that opens the index afterwards', () => {
|
|
codegraph('init', '-y', '--local-only');
|
|
|
|
expect(codegraph('sync').code).toBe(0);
|
|
expect(codegraph('status').code).toBe(0);
|
|
expect(codegraph('index', '--quiet').code).toBe(0);
|
|
expect(codegraph('init', '-y').code).toBe(0);
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
|
|
});
|
|
|
|
it('says why it left a file with the user\'s own rules', () => {
|
|
seedGitignore('codegraph.db\n');
|
|
|
|
const { code, output } = codegraph('init', '-y', '--local-only');
|
|
|
|
expect(code).toBe(0);
|
|
expect(output).toContain('Left .codegraph/.gitignore as it is because it has your own rules');
|
|
expect(fs.readFileSync(gitignore, 'utf-8')).toBe('codegraph.db\n');
|
|
});
|
|
|
|
it('says why it left a committed file, and shows no change in git', () => {
|
|
seedGitignore(DEFAULT);
|
|
initRepo();
|
|
git('add', '.codegraph/.gitignore');
|
|
git('commit', '-qm', 'share the rule');
|
|
|
|
const { code, output } = codegraph('init', '-y', '--local-only');
|
|
|
|
expect(code).toBe(0);
|
|
expect(output).toContain('Left .codegraph/.gitignore as it is because it is committed');
|
|
expect(git('status', '--porcelain')).toBe('');
|
|
});
|
|
|
|
it('refuses an unsafe root before touching anything', () => {
|
|
const home = spawnSync(process.execPath, [BIN, 'init', '-y', '--local-only'], {
|
|
cwd: os.homedir(),
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', NO_COLOR: '1' },
|
|
});
|
|
|
|
expect(home.status).toBe(1);
|
|
expect(fs.existsSync(path.join(os.homedir(), '.codegraph', '.gitignore'))).toBe(false);
|
|
});
|
|
|
|
it('offers the flag in --help', () => {
|
|
expect(codegraph('init', '--help').output).toContain('--local-only');
|
|
});
|
|
});
|
|
});
|