diff --git a/packages/core/src/utils/filesearch/crawler.test.ts b/packages/core/src/utils/filesearch/crawler.test.ts index 4d52bf99f52..3e602f52257 100644 --- a/packages/core/src/utils/filesearch/crawler.test.ts +++ b/packages/core/src/utils/filesearch/crawler.test.ts @@ -848,6 +848,179 @@ describe('crawler', () => { ); }); + it('should preserve non-ASCII tracked paths from git output', async () => { + tmpDir = await createTmpDir({ + 'café.txt': '', + '文档.md': '', + plain: ['nested.txt'], + }); + await initGitRepo(tmpDir); + + const ignore = loadIgnoreRules({ + projectRoot: tmpDir, + useGitignore: false, + useQwenignore: false, + ignoreDirs: [], + }); + + const results = await crawl({ + crawlDirectory: tmpDir, + cwd: tmpDir, + ignore, + cache: false, + cacheTtl: 0, + }); + + expect(results).toEqual( + expect.arrayContaining(['café.txt', '文档.md', 'plain/nested.txt']), + ); + }); + + it('should recurse into tracked submodules on the git path', async () => { + tmpDir = await createTmpDir({}); + const parentRepo = path.join(tmpDir, 'parent'); + const submoduleSource = path.join(tmpDir, 'submodule-source'); + + await fs.mkdir(parentRepo); + await fs.mkdir(submoduleSource); + await fs.writeFile(path.join(submoduleSource, 'inner.txt'), 'submodule'); + await initGitRepo(submoduleSource); + + await fs.writeFile(path.join(parentRepo, 'root.txt'), 'root'); + await initGitRepo(parentRepo); + await runExecFile( + 'git', + [ + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + submoduleSource, + 'vendor/lib', + ], + parentRepo, + ); + await runExecFile( + 'git', + [ + '-c', + 'user.name=Qwen Test', + '-c', + 'user.email=qwen-test@example.com', + 'commit', + '--no-gpg-sign', + '-m', + 'add submodule', + ], + parentRepo, + ); + + const ignore = loadIgnoreRules({ + projectRoot: parentRepo, + useGitignore: false, + useQwenignore: false, + ignoreDirs: [], + }); + + const results = await crawl({ + crawlDirectory: parentRepo, + cwd: parentRepo, + ignore, + cache: false, + cacheTtl: 0, + }); + + expect(results).toContain('vendor/lib/inner.txt'); + }, 15_000); + + it('should skip missing tracked paths from submodule indexes', async () => { + tmpDir = await createTmpDir({}); + await fs.mkdir(path.join(tmpDir, 'vendor', 'lib'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'vendor', 'lib', 'alive.txt'), ''); + + __setCommandRunnerForTests(async (command, args) => { + if (command !== 'git') { + return { success: false, lines: [] }; + } + if (args.includes('rev-parse') && args.includes('--show-toplevel')) { + return { success: true, lines: [tmpDir] }; + } + if (args.includes('ls-files') && args.includes('--others')) { + return { success: true, lines: [] }; + } + if (args.includes('ls-files') && args.includes('--deleted')) { + return { success: true, lines: [] }; + } + if (args.includes('ls-files') && args.includes('--cached')) { + return { + success: true, + lines: ['H vendor/lib/alive.txt', 'H vendor/lib/deleted.txt'], + }; + } + return { success: false, lines: [] }; + }); + + const ignore = loadIgnoreRules({ + projectRoot: tmpDir, + useGitignore: false, + useQwenignore: false, + ignoreDirs: [], + }); + + const results = await crawl({ + crawlDirectory: tmpDir, + cwd: tmpDir, + ignore, + cache: false, + cacheTtl: 0, + }); + + expect(results).toContain('vendor/lib/alive.txt'); + expect(results).not.toContain('vendor/lib/deleted.txt'); + }); + + it('should skip cached gitlink directories from uninitialized submodules', async () => { + tmpDir = await createTmpDir({}); + await fs.mkdir(path.join(tmpDir, 'vendor', 'lib'), { recursive: true }); + + __setCommandRunnerForTests(async (command, args) => { + if (command !== 'git') { + return { success: false, lines: [] }; + } + if (args.includes('rev-parse') && args.includes('--show-toplevel')) { + return { success: true, lines: [tmpDir] }; + } + if (args.includes('ls-files') && args.includes('--others')) { + return { success: true, lines: [] }; + } + if (args.includes('ls-files') && args.includes('--deleted')) { + return { success: true, lines: [] }; + } + if (args.includes('ls-files') && args.includes('--cached')) { + return { success: true, lines: ['H vendor/lib'] }; + } + return { success: false, lines: [] }; + }); + + const ignore = loadIgnoreRules({ + projectRoot: tmpDir, + useGitignore: false, + useQwenignore: false, + ignoreDirs: [], + }); + + const results = await crawl({ + crawlDirectory: tmpDir, + cwd: tmpDir, + ignore, + cache: false, + cacheTtl: 0, + }); + + expect(results).not.toContain('vendor/lib'); + expect(results).not.toContain('vendor/lib/'); + }); + it('should resolve the git root from a subdirectory crawl', async () => { tmpDir = await createTmpDir({ src: ['file2.js'], diff --git a/packages/core/src/utils/filesearch/crawler.ts b/packages/core/src/utils/filesearch/crawler.ts index 6f0f2a4c25d..06f9942b1e4 100644 --- a/packages/core/src/utils/filesearch/crawler.ts +++ b/packages/core/src/utils/filesearch/crawler.ts @@ -172,6 +172,8 @@ function withSafeGitConfig(args: string[]): string[] { 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', + '-c', + 'core.quotePath=false', ...args, ]; } @@ -1036,7 +1038,12 @@ async function crawlWithGitLsFiles( // Avoid `-z` with `-t`: record shape for `ls-files -t` + `-z` is not stable across Git // versions; newline-delimited output is fine here (index paths cannot contain newlines). - const trackedArgs = ['--literal-pathspecs', 'ls-files', '--cached']; + const trackedArgs = [ + '--literal-pathspecs', + 'ls-files', + '--cached', + '--recurse-submodules', + ]; trackedArgs.push('-t'); if (relativeToGitRoot && relativeToGitRoot !== '.') { trackedArgs.push(relativeToGitRoot); @@ -1077,6 +1084,16 @@ async function crawlWithGitLsFiles( return true; } + let stat: fs.Stats; + try { + stat = fs.lstatSync(path.join(gitRoot, ...normalizedFile.split('/'))); + } catch { + return true; + } + if (stat.isDirectory()) { + return true; + } + if ( relativeToGitRoot && relativeToGitRoot !== '.' &&