Skip to content
Closed
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
17 changes: 17 additions & 0 deletions browse/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
Expand Down
35 changes: 35 additions & 0 deletions browse/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading