Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/glob-ignore-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix glob file searches so broad patterns continue to respect ignore files.
58 changes: 46 additions & 12 deletions packages/agent-core/src/tools/builtin/file/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
*/

import type { Kaos } from '@moonshot-ai/kaos';
import { normalize, resolve } from 'pathe';
import { dirname, join, normalize, resolve } from 'pathe';
import { z } from 'zod';

import type { BuiltinTool } from '../../../agent/tool';
Expand Down Expand Up @@ -201,17 +201,17 @@ export class GlobTool implements BuiltinTool<GlobInput> {
// Running from the search root makes glob matching relative to it.
const execKaos = this.kaos.withCwd(searchRoot);

let runResult = await runRipgrepOnce(
execKaos,
buildRgArgs(rgPath, args),
signal,
{ abortedMessage: 'Glob aborted' },
);
const insideGitRepo =
args.include_ignored === true ? true : await isInsideGitRepo(this.kaos, searchRoot);

let runResult = await runRipgrepOnce(execKaos, buildRgArgs(rgPath, args, insideGitRepo), signal, {
abortedMessage: 'Glob aborted',
});
if (runResult.kind === 'tool-error') return runResult.result;
if (shouldRetryRipgrepEagain(runResult)) {
runResult = await runRipgrepOnce(
execKaos,
buildRgArgs(rgPath, args, true),
buildRgArgs(rgPath, args, insideGitRepo, true),
signal,
{ abortedMessage: 'Glob aborted' },
);
Expand Down Expand Up @@ -312,16 +312,27 @@ export class GlobTool implements BuiltinTool<GlobInput> {
}
}

function buildRgArgs(rgPath: string, args: GlobInput, singleThreaded = false): string[] {
function buildRgArgs(
rgPath: string,
args: GlobInput,
insideGitRepo: boolean,
singleThreaded = false,
): string[] {
const cmd: string[] = [rgPath];
if (singleThreaded) cmd.push('-j', '1');
cmd.push('--files', '--hidden', '--sortr=modified');
if (!insideGitRepo && args.include_ignored !== true) {
cmd.push('--no-require-git');
}
for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
cmd.push('--glob', `!${dir}`);
}
// Positive pattern first, then sensitive-file exclusions so a broad
// pattern cannot re-include a sensitive path.
cmd.push('--glob', args.pattern);
// Positive pattern before sensitive-file exclusions unless it is broad
// enough to enumerate everything. Broad positive globs re-include files
// that ignore files exclude, so let `rg --files` walk those directly.
if (!isBroadPattern(args.pattern)) {
cmd.push('--glob', args.pattern);
}
Comment thread
morluto marked this conversation as resolved.
Outdated
for (const glob of SENSITIVE_GLOBS_TO_EXCLUDE) {
cmd.push('--glob', `!${glob}`);
}
Expand All @@ -332,6 +343,29 @@ function buildRgArgs(rgPath: string, args: GlobInput, singleThreaded = false): s
return cmd;
}

function isBroadPattern(pattern: string): boolean {
return pattern === '*' || pattern === '**' || pattern === '**/*';
Comment thread
morluto marked this conversation as resolved.
Outdated
}

async function isInsideGitRepo(kaos: Kaos, searchRoot: string): Promise<boolean> {
let current = kaos.normpath(searchRoot);
for (;;) {
if (await pathExists(kaos, join(current, '.git'))) return true;
const parent = dirname(current);
if (parent === current) return false;
current = parent;
}
}

async function pathExists(kaos: Kaos, path: string): Promise<boolean> {
try {
await kaos.stat(path);
return true;
} catch {
return false;
}
}

function formatGlobError(searchRoot: string, stderr: string): string {
const trimmed = stderr.trim();
if (/no such file or directory/i.test(trimmed)) {
Expand Down
63 changes: 63 additions & 0 deletions packages/agent-core/test/tools/glob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,44 @@ describe('GlobTool', () => {
expect(execArgs(exec)).not.toContain('--no-ignore');
});

it('does not emit a positive --glob for broad all-file patterns', async () => {
for (const pattern of ['*', '**', '**/*'] as const) {
const exec = execReturning('/workspace/a.ts\n');
const tool = new GlobTool(kaosWithExec(exec), workspace);

await executeTool(tool, context({ pattern }));

const args = execArgs(exec);
expect(args).not.toContain(pattern);
expect(args).toContain('--glob');
expect(args.some((arg) => arg.startsWith('!'))).toBe(true);
}
});

it('keeps a positive --glob for anchored patterns', async () => {
const exec = execReturning('/workspace/src/a.ts\n');
const tool = new GlobTool(kaosWithExec(exec), workspace);

await executeTool(tool, context({ pattern: 'src/**/*.ts' }));

expect(execArgs(exec)).toContain('src/**/*.ts');
});

it('adds --no-require-git when the search root is outside a git repo', async () => {
const exec = execReturning('/workspace/a.ts\n');
const stat = vi.fn(async (candidate: string) => {
if (candidate.endsWith('/.git')) {
throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
}
return dirStat();
});
const tool = new GlobTool(createFakeKaos({ exec, stat }), workspace);

await executeTool(tool, context({ pattern: '*.ts', path: '/workspace' }));

expect(execArgs(exec)).toContain('--no-require-git');
});

it('caps returned matches and surfaces the truncation header', async () => {
const stdout =
Array.from({ length: MAX_MATCHES + 1 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') +
Expand Down Expand Up @@ -727,4 +765,29 @@ describe('GlobTool integration (real ripgrep)', () => {
await fs.rm(externalDir, { recursive: true, force: true });
}
});

it('respects .gitignore by default for broad patterns in a git repo', async () => {
await touch('kept.ts', new Date('2024-01-01T00:00:00Z'));
await touch('ignored.log', new Date('2024-01-01T00:00:00Z'));
await fs.writeFile(path.join(tmpDir!, '.gitignore'), '*.log\n');
await fs.mkdir(path.join(tmpDir!, '.git'), { recursive: true });
const tool = new GlobTool(kaos, ws());

const result = await executeTool(tool, context({ pattern: '*', path: tmpDir! }));

expect(result.output).toContain('kept.ts');
expect(result.output).not.toContain('ignored.log');
});

it('respects .gitignore by default in a non-git directory', async () => {
await touch('kept.ts', new Date('2024-01-01T00:00:00Z'));
await touch('ignored.log', new Date('2024-01-01T00:00:00Z'));
await fs.writeFile(path.join(tmpDir!, '.gitignore'), '*.log\n');
const tool = new GlobTool(kaos, ws());

const result = await executeTool(tool, context({ pattern: '*', path: tmpDir! }));

expect(result.output).toContain('kept.ts');
expect(result.output).not.toContain('ignored.log');
});
});