/ src / utils / git / gitignore.ts
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  }