Add global gitignore support to file exclusion logic

This commit is contained in:
Chris Wanstrath 2026-02-22 07:48:41 -08:00
parent 520606ccb9
commit 365b5d2365
2 changed files with 98 additions and 4 deletions

View File

@ -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 { mkdirSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { loadGitignore } from './gitignore' import { _resetGlobalCache, loadGitignore } from './gitignore'
const TEST_DIR = '/tmp/toes-gitignore-test' const TEST_DIR = '/tmp/toes-gitignore-test'
beforeEach(() => { beforeEach(() => {
_resetGlobalCache()
mkdirSync(TEST_DIR, { recursive: true }) mkdirSync(TEST_DIR, { recursive: true })
}) })
@ -171,4 +172,64 @@ describe('loadGitignore', () => {
expect(checker.shouldExclude('build/output')).toBe(true) 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 }))
})
})
}) })

View File

@ -1,4 +1,6 @@
import { spawnSync } from 'child_process'
import { existsSync, readFileSync } from 'fs' import { existsSync, readFileSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
const ALWAYS_EXCLUDE = [ const ALWAYS_EXCLUDE = [
@ -24,15 +26,46 @@ export interface GitignoreChecker {
export function loadGitignore(appPath: string): GitignoreChecker { export function loadGitignore(appPath: string): GitignoreChecker {
const gitignorePath = join(appPath, '.gitignore') const gitignorePath = join(appPath, '.gitignore')
const globalPatterns = loadGlobalPatterns()
const patterns = existsSync(gitignorePath) const patterns = existsSync(gitignorePath)
? [...ALWAYS_EXCLUDE, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))] ? [...ALWAYS_EXCLUDE, ...globalPatterns, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))]
: DEFAULT_PATTERNS : [...DEFAULT_PATTERNS, ...globalPatterns]
return { return {
shouldExclude: (path: string) => matchesPattern(path, patterns), 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[] { function parseGitignore(content: string): string[] {
return content return content
.split('\n') .split('\n')