diff --git a/browse/src/config.ts b/browse/src/config.ts index fc4c97b958..f348239dee 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -78,6 +78,20 @@ export function resolveConfig( }; } +function isIgnoredByGit(projectDir: string, relPath: string): boolean { + try { + const proc = Bun.spawnSync(['git', 'check-ignore', '-q', '--', relPath], { + cwd: projectDir, stdout: 'pipe', stderr: 'pipe', + timeout: 2_000, + }); + return proc.exitCode === 0; + } catch { + // git not found, timed out, or not a repo (exit 128). Fall through to + // the text-check path — appending is the safe default when unsure. + return false; + } +} + /** * Create the .gstack/ state directory if it doesn't exist. * Throws with a clear message on permission errors. @@ -96,6 +110,9 @@ export function ensureStateDir(config: BrowseConfig): void { } // Ensure .gstack/ is in the project's .gitignore + // First, check if git already ignores .gstack/ (via global excludes, .git/info/exclude, or parent .gitignore) + if (isIgnoredByGit(config.projectDir, '.gstack/')) return; + const gitignorePath = path.join(config.projectDir, '.gitignore'); try { const content = fs.readFileSync(gitignorePath, 'utf-8'); diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index 8daa27c3eb..5f8cd55353 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -124,6 +124,41 @@ describe('config', () => { expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false); fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + test('leaves .gitignore alone when git already ignores .gstack/ globally', () => { + const { spawnSync } = require('child_process'); + const tmpDir = path.join(os.tmpdir(), `browse-gitignore-global-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + + // Set up a real git repo + spawnSync('git', ['init', '-q'], { cwd: tmpDir }); + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir }); + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir }); + + // Write a global excludes file that ignores .gstack/ + const excludesFile = path.join(tmpDir, 'global-gitignore'); + fs.writeFileSync(excludesFile, '.gstack/\n'); + spawnSync('git', ['config', 'core.excludesFile', excludesFile], { cwd: tmpDir }); + + // .gitignore exists but does NOT contain .gstack/ + fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n'); + spawnSync('git', ['add', '.gitignore'], { cwd: tmpDir }); + spawnSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir }); + + // Verify git knows .gstack/ is ignored + const check = spawnSync('git', ['check-ignore', '-q', '.gstack/'], { cwd: tmpDir }); + expect(check.status).toBe(0); + + const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); + ensureStateDir(config); + + // .gitignore must NOT have been modified + const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + expect(content).toBe('node_modules/\n'); + expect(fs.existsSync(path.join(tmpDir, '.gstack'))).toBe(true); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); }); describe('getRemoteSlug', () => {