mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-25 17:14:54 +00:00
178 lines
6.1 KiB
TypeScript
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');
|
|
});
|
|
});
|