Skip to content

Commit fc69dcc

Browse files
authored
Merge pull request #1622 from Samsen879/fix-ignore-gitignore-conflict
fix(file): keep ignored .gitignore rules active
2 parents d629056 + 6571860 commit fc69dcc

3 files changed

Lines changed: 181 additions & 11 deletions

File tree

src/core/file/fileSearch.ts

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Stats } from 'node:fs';
22
import fs from 'node:fs/promises';
33
import path from 'node:path';
44
import { type Options as GlobbyOptions, type GlobEntry, globby } from 'globby';
5+
import { minimatch } from 'minimatch';
56
import type { RepomixConfigMerged } from '../../config/configSchema.js';
67
import { defaultIgnoreList } from '../../config/defaultIgnore.js';
78
import { mapWithConcurrency } from '../../shared/asyncMap.js';
@@ -20,6 +21,7 @@ export interface FileSearchResult {
2021
// than awaiting serially. The cap protects very large repos from EMFILE / file
2122
// descriptor exhaustion that unbounded `Promise.all` could cause.
2223
const EMPTY_DIR_CHECK_CONCURRENCY = 20;
24+
const IGNORE_CONTROL_FILE_NAMES = new Set(['.gitignore', '.ignore', '.repomixignore']);
2325

2426
// No per-directory ignore-pattern check is needed here. The `directories` array
2527
// comes from globby with the same `ignore` patterns (e.g. `dist/**`), which
@@ -86,6 +88,41 @@ export const normalizeGlobPattern = (pattern: string): string => {
8688
return pattern;
8789
};
8890

91+
const toPosixPath = (value: string): string => value.replace(/\\/g, '/');
92+
93+
// Canonical posix form of a deferred ignore pattern: forward slashes and no
94+
// trailing slash. Detection (isIgnoreControlFilePattern) and post-filtering
95+
// (filterDeferredIgnoredFiles) must share this so a pattern that is deferred is
96+
// also matched by the filter. Otherwise e.g. `**/.gitignore/` would be deferred
97+
// (dropped from globby's ignore) yet never matched here, leaking the file.
98+
const toPosixIgnorePattern = (pattern: string): string => toPosixPath(pattern).replace(/\/+$/, '');
99+
100+
const isIgnoreControlFilePattern = (pattern: string): boolean => {
101+
const normalizedPattern = toPosixIgnorePattern(pattern);
102+
if (normalizedPattern.startsWith('!')) {
103+
return false;
104+
}
105+
return IGNORE_CONTROL_FILE_NAMES.has(path.posix.basename(normalizedPattern));
106+
};
107+
108+
const filterDeferredIgnoredFiles = (filePaths: string[], deferredIgnorePatterns: string[]): string[] => {
109+
if (deferredIgnorePatterns.length === 0) {
110+
return filePaths;
111+
}
112+
const posixPatterns = deferredIgnorePatterns.map(toPosixIgnorePattern);
113+
return filePaths.filter((filePath) => {
114+
const normalizedPath = toPosixPath(filePath);
115+
// Match the control file itself, and — for the pathological case of a
116+
// directory literally named `.gitignore` — its descendants too. globby
117+
// previously normalized `**/.gitignore` to `**/.gitignore/**` (which excludes
118+
// both), so matching `${pattern}/**` here preserves that behavior.
119+
return !posixPatterns.some(
120+
(pattern) =>
121+
minimatch(normalizedPath, pattern, { dot: true }) || minimatch(normalizedPath, `${pattern}/**`, { dot: true }),
122+
);
123+
});
124+
};
125+
89126
// Get all file paths considering the config
90127
export const searchFiles = async (
91128
rootDir: string,
@@ -140,10 +177,14 @@ export const searchFiles = async (
140177
}
141178

142179
try {
143-
const { adjustedIgnorePatterns, ignoreFilePatterns } = await prepareIgnoreContext(rootDir, config);
180+
const { adjustedIgnorePatterns, ignoreFilePatterns, deferredIgnorePatterns } = await prepareIgnoreContext(
181+
rootDir,
182+
config,
183+
);
144184

145185
logger.trace('Ignore patterns:', adjustedIgnorePatterns);
146186
logger.trace('Ignore file patterns:', ignoreFilePatterns);
187+
logger.trace('Deferred ignore patterns:', deferredIgnorePatterns);
147188

148189
// Start with configured include patterns
149190
let includePatterns = config.include.map((pattern) => escapeGlobPattern(pattern));
@@ -221,7 +262,7 @@ export const searchFiles = async (
221262
directories.push(entry.path);
222263
}
223264
}
224-
filePaths = files;
265+
filePaths = filterDeferredIgnoredFiles(files, deferredIgnorePatterns);
225266

226267
const globbyElapsedTime = Date.now() - globbyStartTime;
227268
logger.debug(
@@ -233,10 +274,13 @@ export const searchFiles = async (
233274
const filterTime = Date.now() - filterStartTime;
234275
logger.debug(`[empty dirs] Filtered to ${emptyDirPaths.length} empty directories in ${filterTime}ms`);
235276
} else {
236-
filePaths = await globby(includePatterns, {
237-
...createBaseGlobbyOptions(rootDir, config, adjustedIgnorePatterns, ignoreFilePatterns),
238-
onlyFiles: true,
239-
}).catch(handleGlobbyError);
277+
filePaths = filterDeferredIgnoredFiles(
278+
await globby(includePatterns, {
279+
...createBaseGlobbyOptions(rootDir, config, adjustedIgnorePatterns, ignoreFilePatterns),
280+
onlyFiles: true,
281+
}).catch(handleGlobbyError),
282+
deferredIgnorePatterns,
283+
);
240284

241285
const globbyElapsedTime = Date.now() - globbyStartTime;
242286
logger.debug(`[globby] Completed in ${globbyElapsedTime}ms, found ${filePaths.length} files`);
@@ -288,14 +332,25 @@ export const parseIgnoreContent = (content: string): string[] => {
288332
const prepareIgnoreContext = async (
289333
rootDir: string,
290334
config: RepomixConfigMerged,
291-
): Promise<{ adjustedIgnorePatterns: string[]; ignoreFilePatterns: string[] }> => {
335+
): Promise<{ adjustedIgnorePatterns: string[]; ignoreFilePatterns: string[]; deferredIgnorePatterns: string[] }> => {
292336
const [ignorePatterns, ignoreFilePatterns] = await Promise.all([
293337
getIgnorePatterns(rootDir, config),
294338
getIgnoreFilePatterns(config),
295339
]);
296340

341+
// Keep ignore-control files visible to globby so their rules are loaded, then filter them from final file lists.
342+
const deferredIgnorePatterns: string[] = [];
343+
const globbyIgnorePatterns: string[] = [];
344+
for (const pattern of ignorePatterns) {
345+
if (isIgnoreControlFilePattern(pattern)) {
346+
deferredIgnorePatterns.push(pattern);
347+
} else {
348+
globbyIgnorePatterns.push(pattern);
349+
}
350+
}
351+
297352
// Normalize ignore patterns to handle trailing slashes consistently
298-
const normalizedIgnorePatterns = ignorePatterns.map(normalizeGlobPattern);
353+
const normalizedIgnorePatterns = globbyIgnorePatterns.map(normalizeGlobPattern);
299354

300355
// Check if .git is a worktree reference
301356
const gitPath = path.join(rootDir, '.git');
@@ -312,7 +367,7 @@ const prepareIgnoreContext = async (
312367
}
313368
}
314369

315-
return { adjustedIgnorePatterns, ignoreFilePatterns };
370+
return { adjustedIgnorePatterns, ignoreFilePatterns, deferredIgnorePatterns };
316371
};
317372

318373
/**
@@ -433,12 +488,15 @@ export const listDirectories = async (rootDir: string, config: RepomixConfigMerg
433488
* @returns Array of file paths relative to rootDir
434489
*/
435490
export const listFiles = async (rootDir: string, config: RepomixConfigMerged): Promise<string[]> => {
436-
const { adjustedIgnorePatterns, ignoreFilePatterns } = await prepareIgnoreContext(rootDir, config);
491+
const { adjustedIgnorePatterns, ignoreFilePatterns, deferredIgnorePatterns } = await prepareIgnoreContext(
492+
rootDir,
493+
config,
494+
);
437495

438496
const files = await globby(['**/*'], {
439497
...createBaseGlobbyOptions(rootDir, config, adjustedIgnorePatterns, ignoreFilePatterns),
440498
onlyFiles: true,
441499
});
442500

443-
return sortPaths(files);
501+
return sortPaths(filterDeferredIgnoredFiles(files, deferredIgnorePatterns));
444502
};

tests/core/file/dotIgnoreSpec.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,32 @@ describe('repomix dot-ignore spec', () => {
6262
expect(filePaths).not.toContain('pkg/generated/bundle.data');
6363
});
6464

65+
it.each([
66+
{ fileName: '.ignore', ignorePattern: '**/.ignore' },
67+
{ fileName: '.repomixignore', ignorePattern: '**/.repomixignore' },
68+
])('still applies nested $fileName rules when the file itself is ignored', async ({ fileName, ignorePattern }) => {
69+
await writeFixture(tmpDir, {
70+
[`pkg/${fileName}`]: 'generated.data\n',
71+
'pkg/src.ts': 'export {};\n',
72+
'pkg/generated.data': 'generated\n',
73+
});
74+
75+
const { filePaths } = await searchFiles(
76+
tmpDir,
77+
createMockConfig({
78+
include: ['pkg/**'],
79+
ignore: {
80+
useDefaultPatterns: false,
81+
customPatterns: [ignorePattern],
82+
},
83+
}),
84+
);
85+
86+
expect(filePaths).toContain('pkg/src.ts');
87+
expect(filePaths).not.toContain(`pkg/${fileName}`);
88+
expect(filePaths).not.toContain('pkg/generated.data');
89+
});
90+
6591
it('honors .ignore files when `useDotIgnore` is on (the default)', async () => {
6692
await writeFixture(tmpDir, {
6793
'.ignore': '*.draft\n',

tests/core/file/fileSearch.gitignoreSpec.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,92 @@ describe('fileSearch gitignore spec', () => {
5454
expect(filePaths).not.toContain('noisy.draft');
5555
});
5656

57+
it.each([
58+
'browser/.gitignore',
59+
'**/.gitignore',
60+
// A trailing slash must behave identically: the pattern is still deferred so
61+
// globby loads the rules, and the post-filter (sharing the same normalization)
62+
// still removes the file rather than leaking it.
63+
'**/.gitignore/',
64+
])('still applies nested .gitignore rules when the nested .gitignore file itself is ignored by `%s`', async (ignorePattern) => {
65+
await writeFixture(tmpDir, {
66+
'browser/.gitignore': '*.draft\n',
67+
'browser/src/index.ts': 'export {};\n',
68+
'browser/noisy.draft': 'noisy\n',
69+
});
70+
71+
const { filePaths } = await searchFiles(
72+
tmpDir,
73+
createMockConfig({
74+
include: ['browser/**'],
75+
ignore: {
76+
useDefaultPatterns: false,
77+
customPatterns: [ignorePattern],
78+
},
79+
}),
80+
);
81+
82+
expect(filePaths).toContain('browser/src/index.ts');
83+
expect(filePaths).not.toContain('browser/.gitignore');
84+
expect(filePaths).not.toContain('browser/noisy.draft');
85+
});
86+
87+
it('filters root and nested .gitignore files matched by `**/.gitignore` while still applying their rules', async () => {
88+
await writeFixture(tmpDir, {
89+
'.gitignore': 'root-noisy.draft\n',
90+
'keep.ts': 'export {};\n',
91+
'root-noisy.draft': 'noisy\n',
92+
'src/.gitignore': 'generated.ts\n',
93+
'src/keep.ts': 'export {};\n',
94+
'src/generated.ts': 'generated\n',
95+
});
96+
await fs.mkdir(path.join(tmpDir, 'empty'), { recursive: true });
97+
98+
const { filePaths, emptyDirPaths } = await searchFiles(
99+
tmpDir,
100+
createMockConfig({
101+
output: { includeEmptyDirectories: true },
102+
ignore: {
103+
useDefaultPatterns: false,
104+
customPatterns: ['**/.gitignore'],
105+
},
106+
}),
107+
);
108+
109+
expect(filePaths).toContain('keep.ts');
110+
expect(filePaths).toContain('src/keep.ts');
111+
expect(filePaths).not.toContain('.gitignore');
112+
expect(filePaths).not.toContain('src/.gitignore');
113+
expect(filePaths).not.toContain('root-noisy.draft');
114+
expect(filePaths).not.toContain('src/generated.ts');
115+
expect(emptyDirPaths).toContain('empty');
116+
});
117+
118+
it('excludes the contents of a directory literally named `.gitignore` when ignored by `**/.gitignore`', async () => {
119+
// Pathological but valid: `.gitignore` as a directory name. The old globby
120+
// behavior (where `**/.gitignore` normalized to `**/.gitignore/**`) excluded
121+
// its contents, so the post-filter must drop descendants too, not just a
122+
// file named `.gitignore`.
123+
await writeFixture(tmpDir, {
124+
'proj/.gitignore/inside.txt': 'x\n',
125+
'proj/keep.txt': 'x\n',
126+
});
127+
128+
const { filePaths } = await searchFiles(
129+
tmpDir,
130+
createMockConfig({
131+
include: ['proj/**'],
132+
ignore: {
133+
useDefaultPatterns: false,
134+
customPatterns: ['**/.gitignore'],
135+
},
136+
}),
137+
);
138+
139+
expect(filePaths).toContain('proj/keep.txt');
140+
expect(filePaths).not.toContain('proj/.gitignore/inside.txt');
141+
});
142+
57143
it('applies slash-less patterns recursively to all subdirectories', async () => {
58144
await writeFixture(tmpDir, {
59145
'.gitignore': '*.draft\nsecret.data\n',

0 commit comments

Comments
 (0)