mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 14:16:22 +00:00
Move src/{domain}/* to src/{scope}/{domain}/* (app/session/agent) so the
directory layout mirrors the DI LifecycleScope tree. Split the three
multi-scope domains so no domain spans two scopes:
- log -> app/log + session/sessionLog
- question -> session/question + agent/questionTools
- skill -> app/globalSkillCatalog + session/sessionSkillCatalog + agent/skill
Rewrite every #/ and relative import, module augmentation, vi.mock, and
source-path read; update the package barrel, registerScopedService domain
labels, the domain-layer checker, the dep-graph script, file headers, and
the di-scope-domains map.
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { Readable, type Writable } from 'node:stream';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
collectGitContext,
|
|
parseProjectName,
|
|
sanitizeRemoteUrl,
|
|
} from '#/session/agentFs/gitContext';
|
|
import type { ILogger } from '#/app/log';
|
|
import type { IProcess, ISessionProcessRunner } from '#/session/process';
|
|
|
|
function processWith(stdout: string, exitCode: number, stderr = ''): IProcess {
|
|
const stdoutStream = Readable.from([Buffer.from(stdout)]);
|
|
const stderrStream = Readable.from([Buffer.from(stderr)]);
|
|
return {
|
|
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
|
|
stdout: stdoutStream,
|
|
stderr: stderrStream,
|
|
pid: 1,
|
|
exitCode,
|
|
wait: vi.fn().mockResolvedValue(exitCode),
|
|
kill: vi.fn(async () => {}),
|
|
dispose: vi.fn(async () => {
|
|
stdoutStream.destroy();
|
|
stderrStream.destroy();
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** Scripted git output keyed by the full git invocation (`args.slice(3)`). */
|
|
type GitScript = Record<string, { stdout?: string; exitCode?: number; stderr?: string }>;
|
|
|
|
/**
|
|
* Build a runner whose `git` invocations are driven by `script`. Commands
|
|
* not present in the script fail (exit code 1), matching a probe that did
|
|
* not produce useful output.
|
|
*/
|
|
function gitRunner(script: GitScript): { runner: ISessionProcessRunner; exec: ReturnType<typeof vi.fn> } {
|
|
const exec = vi.fn(async (args: readonly string[]) => {
|
|
const key = args.slice(3).join(' ');
|
|
const out = script[key];
|
|
if (out === undefined) return processWith('', 1);
|
|
return processWith(out.stdout ?? '', out.exitCode ?? 0, out.stderr ?? '');
|
|
});
|
|
return { runner: { exec } as unknown as ISessionProcessRunner, exec };
|
|
}
|
|
|
|
function spyLogger(): {
|
|
logger: ILogger;
|
|
debug: ReturnType<typeof vi.fn>;
|
|
warn: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const debug = vi.fn();
|
|
const warn = vi.fn();
|
|
const logger: ILogger = {
|
|
error: vi.fn(),
|
|
warn,
|
|
info: vi.fn(),
|
|
debug,
|
|
child: vi.fn(),
|
|
};
|
|
return { logger, debug, warn };
|
|
}
|
|
|
|
describe('collectGitContext', () => {
|
|
it('builds a git-context block with all sections', async () => {
|
|
const { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': { stdout: 'true\n' },
|
|
'remote get-url origin': { stdout: 'git@github.com:owner/repo.git\n' },
|
|
'symbolic-ref --short HEAD': { stdout: 'main\n' },
|
|
'status --porcelain': { stdout: ' M src/a.ts\n?? src/b.ts' },
|
|
'log -3 --format=%h %s': { stdout: 'abc123 Initial commit\ndef456 second commit' },
|
|
});
|
|
|
|
const block = await collectGitContext(runner, '/repo');
|
|
|
|
expect(block.startsWith('<git-context>\n')).toBe(true);
|
|
expect(block.endsWith('\n</git-context>')).toBe(true);
|
|
expect(block).toContain('Working directory: /repo');
|
|
expect(block).toContain('Remote: git@github.com:owner/repo.git');
|
|
expect(block).toContain('Project: owner/repo');
|
|
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 Initial commit');
|
|
});
|
|
|
|
it('returns an unavailable block when the directory is not a git repository', async () => {
|
|
const { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': {
|
|
exitCode: 128,
|
|
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
|
|
},
|
|
});
|
|
const { logger, debug, warn } = spyLogger();
|
|
|
|
await expect(collectGitContext(runner, '/not-a-repo', logger)).resolves.toBe(
|
|
'<git-context status="unavailable" reason="not-a-repo"/>',
|
|
);
|
|
// A definitive not-a-repo is a user-facing signal, not a failure to log.
|
|
expect(debug).not.toHaveBeenCalled();
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns an empty string when rev-parse fails for a reason other than not-a-repo', async () => {
|
|
const { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': { exitCode: 1, stderr: 'fatal: some other git error' },
|
|
});
|
|
const { logger, debug } = spyLogger();
|
|
|
|
await expect(collectGitContext(runner, '/repo', logger)).resolves.toBe('');
|
|
expect(debug).toHaveBeenCalledWith(
|
|
'git context command failed',
|
|
expect.objectContaining({
|
|
command: 'git rev-parse --is-inside-work-tree',
|
|
exitCode: 1,
|
|
stderr: 'fatal: some other git error',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('returns an empty string when git fails to spawn', async () => {
|
|
const runner = {
|
|
exec: vi.fn(async (): Promise<IProcess> => {
|
|
throw new Error('spawn failed');
|
|
}),
|
|
} as unknown as ISessionProcessRunner;
|
|
const { logger, warn } = spyLogger();
|
|
|
|
await expect(collectGitContext(runner, '/repo', logger)).resolves.toBe('');
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'git context command failed to spawn',
|
|
expect.objectContaining({ command: 'git rev-parse --is-inside-work-tree' }),
|
|
);
|
|
});
|
|
|
|
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 { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': { stdout: 'true' },
|
|
'remote get-url origin': { stdout: '' },
|
|
'symbolic-ref --short HEAD': { stdout: '' },
|
|
'status --porcelain': { stdout: dirty },
|
|
'log -3 --format=%h %s': { stdout: '' },
|
|
});
|
|
|
|
const block = await collectGitContext(runner, '/repo');
|
|
|
|
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 { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': { stdout: 'true' },
|
|
});
|
|
|
|
await expect(collectGitContext(runner, '/repo')).resolves.toBe('');
|
|
});
|
|
|
|
it('omits both Remote and Project for a disallowed remote host', async () => {
|
|
const { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': { stdout: 'true' },
|
|
'remote get-url origin': { stdout: 'git@internal.corp:secret/repo.git' },
|
|
'symbolic-ref --short HEAD': { stdout: 'main' },
|
|
'status --porcelain': { stdout: '' },
|
|
'log -3 --format=%h %s': { stdout: '' },
|
|
});
|
|
|
|
const block = await collectGitContext(runner, '/repo');
|
|
|
|
expect(block).not.toContain('Remote:');
|
|
expect(block).not.toContain('Project:');
|
|
expect(block).not.toContain('secret/repo');
|
|
expect(block).toContain('Branch: main');
|
|
});
|
|
|
|
it('keeps branch and status when the origin remote is absent', async () => {
|
|
const { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': { stdout: 'true' },
|
|
'remote get-url origin': { exitCode: 2, stderr: "error: No such remote 'origin'" },
|
|
'symbolic-ref --short HEAD': { stdout: 'main' },
|
|
'status --porcelain': { stdout: ' M src/a.ts' },
|
|
'log -3 --format=%h %s': { stdout: 'abc123 first commit' },
|
|
});
|
|
const { logger, debug } = spyLogger();
|
|
|
|
const block = await collectGitContext(runner, '/repo', logger);
|
|
|
|
expect(block).toContain('Branch: main');
|
|
expect(block).toContain('Dirty files (1):');
|
|
expect(block).toContain('Recent commits:');
|
|
expect(block).not.toContain('Remote:');
|
|
expect(block).not.toContain('Project:');
|
|
expect(debug).toHaveBeenCalledWith(
|
|
'git context command failed',
|
|
expect.objectContaining({ command: 'git remote get-url origin' }),
|
|
);
|
|
});
|
|
|
|
it('keeps branch and status when the repository has no commits yet', async () => {
|
|
const { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': { stdout: 'true' },
|
|
'remote get-url origin': { stdout: 'https://github.com/acme/widgets.git' },
|
|
'symbolic-ref --short HEAD': { stdout: 'main' },
|
|
'status --porcelain': { stdout: '' },
|
|
'log -3 --format=%h %s': {
|
|
exitCode: 128,
|
|
stderr: "fatal: your current branch 'main' does not have any commits yet",
|
|
},
|
|
});
|
|
|
|
const block = await collectGitContext(runner, '/repo');
|
|
|
|
expect(block).toContain('Branch: main');
|
|
expect(block).toContain('Remote: https://github.com/acme/widgets.git');
|
|
expect(block).toContain('Project: acme/widgets');
|
|
expect(block).not.toContain('Recent commits:');
|
|
});
|
|
|
|
it('omits the Branch section in detached HEAD state', async () => {
|
|
const { runner } = gitRunner({
|
|
'rev-parse --is-inside-work-tree': { stdout: 'true' },
|
|
'symbolic-ref --short HEAD': {
|
|
exitCode: 128,
|
|
stderr: 'fatal: ref HEAD is not a symbolic ref',
|
|
},
|
|
'remote get-url origin': { stdout: 'https://github.com/acme/widgets.git' },
|
|
'status --porcelain': { stdout: '' },
|
|
'log -3 --format=%h %s': { stdout: 'abc123 first commit' },
|
|
});
|
|
|
|
const block = await collectGitContext(runner, '/repo');
|
|
|
|
expect(block).not.toContain('Branch:');
|
|
expect(block).toContain('Remote: https://github.com/acme/widgets.git');
|
|
expect(block).toContain('Recent commits:');
|
|
});
|
|
|
|
it('treats a hanging git command as a failure (timeout)', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runner = {
|
|
exec: vi.fn(async (): Promise<IProcess> => {
|
|
let release: (code: number) => void = () => {};
|
|
const exited = new Promise<number>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
return {
|
|
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
|
|
stdout: Readable.from(['']),
|
|
stderr: Readable.from(['']),
|
|
pid: 1,
|
|
exitCode: null,
|
|
wait: vi.fn(() => exited),
|
|
// A real process exits once it receives SIGKILL.
|
|
kill: vi.fn(async () => {
|
|
release(137);
|
|
}),
|
|
dispose: vi.fn(async () => {}),
|
|
};
|
|
}),
|
|
} as unknown as ISessionProcessRunner;
|
|
const { logger, debug } = spyLogger();
|
|
|
|
const promise = collectGitContext(runner, '/repo', logger);
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
await expect(promise).resolves.toBe('');
|
|
expect(debug).toHaveBeenCalledWith(
|
|
'git context command timed out',
|
|
expect.objectContaining({ command: 'git rev-parse --is-inside-work-tree' }),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('remote url helpers', () => {
|
|
it('sanitizes allowed https remotes and drops credentials', () => {
|
|
expect(sanitizeRemoteUrl('https://user:token@github.com/owner/repo.git')).toBe(
|
|
'https://github.com/owner/repo.git',
|
|
);
|
|
});
|
|
|
|
it('rejects private hosts', () => {
|
|
expect(sanitizeRemoteUrl('https://git.example.internal/owner/repo.git')).toBeNull();
|
|
});
|
|
|
|
it('parses project names from ssh and https urls', () => {
|
|
expect(parseProjectName('git@github.com:owner/repo.git')).toBe('owner/repo');
|
|
expect(parseProjectName('https://github.com/owner/repo.git')).toBe('owner/repo');
|
|
});
|
|
});
|