gitignore.ts
1 import { appendFile, mkdir, readFile, writeFile } from 'fs/promises' 2 import { homedir } from 'os' 3 import { dirname, join } from 'path' 4 import { getCwd } from '../cwd.js' 5 import { getErrnoCode } from '../errors.js' 6 import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' 7 import { dirIsInGitRepo } from '../git.js' 8 import { logError } from '../log.js' 9 10 /** 11 * Checks if a path is ignored by git (via `git check-ignore`). 12 * 13 * This consults all applicable gitignore sources: repo `.gitignore` files 14 * (nested), `.git/info/exclude`, and the global gitignore — with correct 15 * precedence, because git itself resolves it. 16 * 17 * Exit codes: 0 = ignored, 1 = not ignored, 128 = not in a git repo. 18 * Returns `false` for 128, so callers outside a git repo fail open. 19 * 20 * @param filePath The path to check (absolute or relative to cwd) 21 * @param cwd The working directory to run git from 22 */ 23 export async function isPathGitignored( 24 filePath: string, 25 cwd: string, 26 ): Promise<boolean> { 27 const { code } = await execFileNoThrowWithCwd( 28 'git', 29 ['check-ignore', filePath], 30 { 31 preserveOutputOnError: false, 32 cwd, 33 }, 34 ) 35 36 return code === 0 37 } 38 39 /** 40 * Gets the path to the global gitignore file (.config/git/ignore) 41 * @returns The path to the global gitignore file 42 */ 43 export function getGlobalGitignorePath(): string { 44 return join(homedir(), '.config', 'git', 'ignore') 45 } 46 47 /** 48 * Adds a file pattern to the global gitignore file (.config/git/ignore) 49 * if it's not already ignored by existing patterns in any gitignore file 50 * @param filename The filename to add to gitignore 51 * @param cwd The current working directory (optional) 52 */ 53 export async function addFileGlobRuleToGitignore( 54 filename: string, 55 cwd: string = getCwd(), 56 ): Promise<void> { 57 try { 58 if (!(await dirIsInGitRepo(cwd))) { 59 return 60 } 61 62 // First check if the pattern is already ignored by any gitignore file (including global) 63 const gitignoreEntry = `**/${filename}` 64 // For directory patterns (ending with /), check with a sample file inside 65 const testPath = filename.endsWith('/') 66 ? `${filename}sample-file.txt` 67 : filename 68 if (await isPathGitignored(testPath, cwd)) { 69 // File is already ignored by existing patterns (local or global) 70 return 71 } 72 73 // Use the global gitignore file in .config/git/ignore 74 const globalGitignorePath = getGlobalGitignorePath() 75 76 // Create the directory if it doesn't exist 77 const configGitDir = dirname(globalGitignorePath) 78 await mkdir(configGitDir, { recursive: true }) 79 80 // Add the entry to the global gitignore 81 try { 82 const content = await readFile(globalGitignorePath, { encoding: 'utf-8' }) 83 if (content.includes(gitignoreEntry)) { 84 return // Pattern already exists, don't add again 85 } 86 await appendFile(globalGitignorePath, `\n${gitignoreEntry}\n`) 87 } catch (e: unknown) { 88 const code = getErrnoCode(e) 89 if (code === 'ENOENT') { 90 // Create global gitignore with entry 91 await writeFile(globalGitignorePath, `${gitignoreEntry}\n`, 'utf-8') 92 } else { 93 throw e 94 } 95 } 96 } catch (error) { 97 logError(error) 98 } 99 }