From 365b5d236548e7c345a66cde5122e6f7018911e5 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 22 Feb 2026 07:48:41 -0800 Subject: [PATCH] Add global gitignore support to file exclusion logic --- src/shared/gitignore.test.ts | 65 ++++++++++++++++++++++++++++++++++-- src/shared/gitignore.ts | 37 ++++++++++++++++++-- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/shared/gitignore.test.ts b/src/shared/gitignore.test.ts index 337a6db..0d4d498 100644 --- a/src/shared/gitignore.test.ts +++ b/src/shared/gitignore.test.ts @@ -1,11 +1,12 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdirSync, rmSync, writeFileSync } from 'fs' import { join } from 'path' -import { loadGitignore } from './gitignore' +import { _resetGlobalCache, loadGitignore } from './gitignore' const TEST_DIR = '/tmp/toes-gitignore-test' beforeEach(() => { + _resetGlobalCache() mkdirSync(TEST_DIR, { recursive: true }) }) @@ -171,4 +172,64 @@ describe('loadGitignore', () => { expect(checker.shouldExclude('build/output')).toBe(true) }) }) + + describe('global gitignore', () => { + const GLOBAL_IGNORE = '/tmp/toes-global-gitignore-test' + + beforeEach(() => { + mkdirSync(GLOBAL_IGNORE, { recursive: true }) + }) + + afterEach(() => { + rmSync(GLOBAL_IGNORE, { recursive: true, force: true }) + }) + + test('should include patterns from global gitignore', () => { + const globalIgnorePath = join(GLOBAL_IGNORE, 'ignore') + writeFileSync(globalIgnorePath, '*.bak\n.idea/') + + const origSpawnSync = require('child_process').spawnSync + const spawnSyncMock = mock((cmd: string, args: string[], opts: any) => { + if (cmd === 'git' && args.includes('core.excludesFile')) { + return { status: 0, stdout: globalIgnorePath + '\n', stderr: '' } + } + return origSpawnSync(cmd, args, opts) + }) + mock.module('child_process', () => ({ spawnSync: spawnSyncMock })) + _resetGlobalCache() + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('backup.bak')).toBe(true) + expect(checker.shouldExclude('src/old.bak')).toBe(true) + expect(checker.shouldExclude('.idea')).toBe(true) + expect(checker.shouldExclude('src/index.ts')).toBe(false) + + mock.module('child_process', () => ({ spawnSync: origSpawnSync })) + }) + + test('should merge global and local patterns', () => { + const globalIgnorePath = join(GLOBAL_IGNORE, 'ignore') + writeFileSync(globalIgnorePath, '*.bak') + writeFileSync(join(TEST_DIR, '.gitignore'), '*.log') + + const origSpawnSync = require('child_process').spawnSync + const spawnSyncMock = mock((cmd: string, args: string[], opts: any) => { + if (cmd === 'git' && args.includes('core.excludesFile')) { + return { status: 0, stdout: globalIgnorePath + '\n', stderr: '' } + } + return origSpawnSync(cmd, args, opts) + }) + mock.module('child_process', () => ({ spawnSync: spawnSyncMock })) + _resetGlobalCache() + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('backup.bak')).toBe(true) + expect(checker.shouldExclude('debug.log')).toBe(true) + expect(checker.shouldExclude('src/index.ts')).toBe(false) + + mock.module('child_process', () => ({ spawnSync: origSpawnSync })) + }) + }) }) diff --git a/src/shared/gitignore.ts b/src/shared/gitignore.ts index 1f5bc5c..8d351d5 100644 --- a/src/shared/gitignore.ts +++ b/src/shared/gitignore.ts @@ -1,4 +1,6 @@ +import { spawnSync } from 'child_process' import { existsSync, readFileSync } from 'fs' +import { homedir } from 'os' import { join } from 'path' const ALWAYS_EXCLUDE = [ @@ -24,15 +26,46 @@ export interface GitignoreChecker { export function loadGitignore(appPath: string): GitignoreChecker { const gitignorePath = join(appPath, '.gitignore') + const globalPatterns = loadGlobalPatterns() const patterns = existsSync(gitignorePath) - ? [...ALWAYS_EXCLUDE, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))] - : DEFAULT_PATTERNS + ? [...ALWAYS_EXCLUDE, ...globalPatterns, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))] + : [...DEFAULT_PATTERNS, ...globalPatterns] return { shouldExclude: (path: string) => matchesPattern(path, patterns), } } +export const _resetGlobalCache = () => { + cachedGlobalPatterns = undefined +} + +let cachedGlobalPatterns: string[] | undefined + +const expandHome = (filepath: string) => + filepath.startsWith('~/') ? join(homedir(), filepath.slice(2)) : filepath + +function getGlobalExcludesPath(): string | null { + const result = spawnSync('git', ['config', '--global', 'core.excludesFile'], { + encoding: 'utf-8', + }) + if (result.status === 0 && result.stdout.trim()) { + return expandHome(result.stdout.trim()) + } + const xdgHome = process.env.XDG_CONFIG_HOME || join(homedir(), '.config') + const defaultPath = join(xdgHome, 'git', 'ignore') + if (existsSync(defaultPath)) return defaultPath + return null +} + +function loadGlobalPatterns(): string[] { + if (cachedGlobalPatterns !== undefined) return cachedGlobalPatterns + const path = getGlobalExcludesPath() + cachedGlobalPatterns = + path && existsSync(path) ? parseGitignore(readFileSync(path, 'utf-8')) : [] + return cachedGlobalPatterns +} + function parseGitignore(content: string): string[] { return content .split('\n')