kimi-code/packages/agent-core/test/session/git-context.test.ts
2026-05-22 15:54:50 +08:00

178 lines
6.1 KiB
TypeScript

import { Readable } from 'node:stream';
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
import { describe, expect, it, vi } from 'vitest';
import { collectGitContext, parseProjectName, sanitizeRemoteUrl } from '../../src/session/git-context';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
function fakeProcess(stdout: string, exitCode = 0): KaosProcess {
return {
stdin: { write: () => true, end: () => {} } as never,
stdout: Readable.from([stdout]),
stderr: Readable.from(['']),
pid: 1,
exitCode,
wait: async () => exitCode,
kill: async () => {},
};
}
/** Scripted git output keyed by the git subcommand (`args[3]`). */
type GitScript = Record<string, { stdout: string; exitCode?: number }>;
function gitKaos(script: GitScript): Kaos {
return createFakeKaos({
exec: async (...args: string[]): Promise<KaosProcess> => {
const subcommand = args[3] ?? '';
const scripted = script[subcommand];
if (scripted === undefined) return fakeProcess('', 1);
return fakeProcess(scripted.stdout, scripted.exitCode ?? 0);
},
});
}
describe('collectGitContext', () => {
it('returns an empty string when the directory is not a git repository', async () => {
const kaos = gitKaos({ 'rev-parse': { stdout: '', exitCode: 1 } });
expect(await collectGitContext(kaos, '/project')).toBe('');
});
it('builds a git-context block with all sections', async () => {
const kaos = gitKaos({
'rev-parse': { stdout: 'true' },
remote: { stdout: 'https://github.com/acme/widgets.git' },
branch: { stdout: 'main' },
status: { stdout: ' M src/a.ts\n?? src/b.ts' },
log: { stdout: 'abc123 first commit\ndef456 second commit' },
});
const block = await collectGitContext(kaos, '/project');
expect(block.startsWith('<git-context>\n')).toBe(true);
expect(block.endsWith('\n</git-context>')).toBe(true);
expect(block).toContain('Working directory: /project');
expect(block).toContain('Remote: https://github.com/acme/widgets.git');
expect(block).toContain('Project: acme/widgets');
expect(block).toContain('Branch: main');
expect(block).toContain('Dirty files (2):');
expect(block).toContain(' ?? src/b.ts');
expect(block).toContain('Recent commits:');
expect(block).toContain(' abc123 first commit');
});
it('caps dirty files at 20 and reports the remainder', async () => {
const dirty = Array.from({ length: 25 }, (_, i) => ` M src/f${String(i)}.ts`).join('\n');
const kaos = gitKaos({
'rev-parse': { stdout: 'true' },
status: { stdout: dirty },
});
const block = await collectGitContext(kaos, '/project');
expect(block).toContain('Dirty files (25):');
expect(block).toContain(' ... and 5 more');
});
it('returns an empty string when only the working directory is known', async () => {
const kaos = gitKaos({ 'rev-parse': { stdout: 'true' } });
expect(await collectGitContext(kaos, '/project')).toBe('');
});
it('omits both Remote and Project for a disallowed remote host', async () => {
const kaos = gitKaos({
'rev-parse': { stdout: 'true' },
remote: { stdout: 'git@internal.corp:secret/repo.git' },
branch: { stdout: 'main' },
});
const block = await collectGitContext(kaos, '/project');
expect(block).not.toContain('Remote:');
expect(block).not.toContain('Project:');
expect(block).not.toContain('secret/repo');
expect(block).toContain('Branch: main');
});
it('treats a hanging git command as a failure (timeout)', async () => {
vi.useFakeTimers();
try {
const kaos = createFakeKaos({
exec: async (): Promise<KaosProcess> => {
let release: (code: number) => void = () => {};
const exited = new Promise<number>((resolve) => {
release = resolve;
});
return {
stdin: { write: () => true, end: () => {} } as never,
stdout: Readable.from(['']),
stderr: Readable.from(['']),
pid: 1,
exitCode: null,
wait: () => exited,
// A real process exits once it receives SIGKILL.
kill: async () => {
release(137);
},
};
},
});
const promise = collectGitContext(kaos, '/project');
await vi.advanceTimersByTimeAsync(6_000);
expect(await promise).toBe('');
} finally {
vi.useRealTimers();
}
});
});
describe('sanitizeRemoteUrl', () => {
it('strips credentials from an allowed HTTPS host', () => {
expect(sanitizeRemoteUrl('https://user:pass@github.com/acme/widgets.git')).toBe(
'https://github.com/acme/widgets.git',
);
});
it('passes through an allowed SSH host', () => {
expect(sanitizeRemoteUrl('git@github.com:acme/widgets.git')).toBe(
'git@github.com:acme/widgets.git',
);
});
it('rejects an unrecognized HTTPS host', () => {
expect(sanitizeRemoteUrl('https://internal.corp/acme/widgets.git')).toBeNull();
});
it('rejects an unrecognized SSH host', () => {
expect(sanitizeRemoteUrl('git@internal.corp:acme/widgets.git')).toBeNull();
});
it('passes through the SourceHut git host', () => {
expect(sanitizeRemoteUrl('git@git.sr.ht:~user/repo')).toBe('git@git.sr.ht:~user/repo');
});
});
describe('parseProjectName', () => {
it('extracts owner/repo from an SSH URL', () => {
expect(parseProjectName('git@github.com:acme/widgets.git')).toBe('acme/widgets');
});
it('extracts owner/repo from an HTTPS URL', () => {
expect(parseProjectName('https://github.com/acme/widgets.git')).toBe('acme/widgets');
});
it('extracts owner/repo from an HTTPS URL without a .git suffix', () => {
expect(parseProjectName('https://gitee.com/acme/widgets')).toBe('acme/widgets');
});
it('keeps the full namespace for nested GitLab groups (HTTPS)', () => {
expect(parseProjectName('https://gitlab.com/group/subgroup/repo.git')).toBe(
'group/subgroup/repo',
);
});
it('keeps the full namespace for nested GitLab groups (SSH)', () => {
expect(parseProjectName('git@gitlab.com:group/subgroup/repo.git')).toBe('group/subgroup/repo');
});
});