refactor(agent-core-v2): extract git integration to App-scope IGitService

- Move git status/diff and `gh pr` lookup out of `session/agentFs` into a new
  App-scope `IGitService` (`app/git`), which spawns git via `node:child_process`
  and owns the pull-request cache.
- `ISessionFsService.gitStatus/diff` now only confine paths and delegate to
  `IGitService`; the wire surface and the `FS_GIT_UNAVAILABLE` error code are
  unchanged.
- Move pure git-output parsers to `app/git/gitParsers`, add real-git
  `GitService` tests, and rewrite the `fsService` git tests as delegation tests.
This commit is contained in:
haozhe.yang 2026-07-02 12:09:57 +08:00
parent ab63a3c036
commit 0a74f7f009
9 changed files with 531 additions and 201 deletions

View file

@ -62,6 +62,10 @@ const DOMAIN_LAYER = new Map([
// it is a pure seed with no IO, so it sits in L1.
['sessionContext', 1],
['hostFs', 1],
// `git` is the App-scope `IGitService` that runs `git status` / `git diff`
// against a local repo via `node:child_process`; it depends only on `_base`
// and the `errors` facade, so it sits in L1 beside the other host bridges.
['git', 1],
['workspaceContext', 1],
['chatProvider', 1],
['hooks', 1],

View file

@ -0,0 +1,38 @@
/**
* `git` domain (L1) git integration for a repository on the local disk.
*
* Defines the `IGitService` that runs `git status` / `git diff` (plus `gh pr
* view`) against a repository identified by an absolute `cwd`. App-scoped and
* self-contained: it spawns `git` / `gh` via `node:child_process` directly, so
* it never depends on a Session's execution environment. Path confinement is
* the caller's responsibility the service receives already-resolved absolute
* `cwd` and repo-relative paths.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { FsDiffResponse, FsGitStatusResponse } from '@moonshot-ai/protocol';
export interface IGitService {
readonly _serviceBrand: undefined;
/**
* `git status` for the repo at `cwd`. `pathFilter`, when provided, restricts
* `entries` to the given repo-relative posix paths; `branch` / `ahead` /
* `behind` / `additions` / `deletions` / `pullRequest` always reflect the
* the whole tree. Throws `FS_GIT_UNAVAILABLE` when `cwd` is not a git work
* tree or git itself fails.
*/
status(cwd: string, pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse>;
/**
* `git diff HEAD -- <relPath>` for the repo at `cwd`. `relPath` is the
* repo-relative posix path passed to git; `absPath` is the confined absolute
* path used only to tell "clean file" apart from "path does not exist".
* Throws `FS_GIT_UNAVAILABLE` on git failure, `FS_PATH_NOT_FOUND` when the path
* is missing.
*/
diff(cwd: string, relPath: string, absPath: string): Promise<FsDiffResponse>;
}
export const IGitService: ServiceIdentifier<IGitService> =
createDecorator<IGitService>('gitService');

View file

@ -1,10 +1,11 @@
/**
* `agentFs` domain (L2) pure git-output parsers.
* `git` domain (L1) pure git-output parsers.
*
* Parses `git status --porcelain=v1 --branch`, `git diff --numstat`, and
* `gh pr view --json` output into the protocol `FsGitStatusResponse` shape.
* No IO, no DI plain functions so they can be unit-tested directly. Ported
* from v1 `services/fs/fsGit.ts`.
* No IO, no DI plain functions so they can be unit-tested directly. Moved
* from `session/agentFs/fsGit.ts` (originally ported from v1
* `services/fs/fsGit.ts`).
*/
import path from 'node:path';
@ -13,7 +14,7 @@ import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from '@moonshot-
export function parsePorcelain(
stdout: string,
filter: Set<string> | undefined,
filter: ReadonlySet<string> | undefined,
): FsGitStatusResponse {
const lines = stdout.split('\n');
let branch = '';

View file

@ -0,0 +1,263 @@
/**
* `git` domain (L1) `IGitService` implementation.
*
* Runs `git status` / `git diff` (and `gh pr view`) against a repository on the
* local disk by spawning `git` / `gh` through `node:child_process` directly.
* Bound at App scope it owns no Session dependency, so the caller supplies an
* absolute `cwd` and already-confined repo-relative paths.
*
* The process runner below mirrors v1 `services/fs/fsGitService.ts`: a small
* self-contained `runCommand` + `killChild` that collects stdout/stderr and the
* exit code, with optional timeout / abort support (used by `gh pr view`).
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { stat } from 'node:fs/promises';
import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError } from '#/errors';
import { IGitService } from './git';
import { parseNumstat, parsePorcelain, parsePullRequest } from './gitParsers';
/** Cap a single file's unified diff so a runaway generated file cannot blow up
* the envelope; the response carries `truncated` so the UI can say so. */
const DIFF_MAX_BYTES = 1_048_576;
const PR_SPAWN_TIMEOUT_MS = 5_000;
const PULL_REQUEST_TTL_MS = 60_000;
export class GitService implements IGitService {
declare readonly _serviceBrand: undefined;
private readonly pullRequestCache = new Map<
string,
{ value: FsPullRequest | null; fetchedAt: number }
>();
async status(cwd: string, pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse> {
const inside = await runCommand('git', ['rev-parse', '--is-inside-work-tree'], cwd);
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
throw this.gitUnavailable(cwd, inside.stderr.trim() || `git rev-parse exit ${inside.exitCode}`);
}
const porc = await runCommand('git', ['status', '--porcelain=v1', '--branch'], cwd);
if (porc.exitCode !== 0) {
throw this.gitUnavailable(cwd, porc.stderr.trim() || `git status exit ${porc.exitCode}`);
}
const result = parsePorcelain(porc.stdout, pathFilter);
// Aggregate line stats against HEAD. Only worth a second spawn when the
// tree is dirty AND there is a HEAD to diff against (a repo with no commits
// yet has neither side); otherwise the stats stay 0. Dirtiness is read from
// the UNFILTERED porcelain and the numstat is NOT scoped by `pathFilter` —
// the header counter reflects the whole working tree.
const dirty = porc.stdout
.split('\n')
.some((line) => line.length > 0 && !line.startsWith('## '));
if (dirty) {
const head = await runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
if (head.exitCode === 0) {
const numstat = await runCommand('git', ['diff', '--no-color', '--numstat', 'HEAD', '--'], cwd);
if (numstat.exitCode === 0) {
const stats = parseNumstat(numstat.stdout);
result.additions = stats.additions;
result.deletions = stats.deletions;
}
}
}
result.pullRequest = await this.readPullRequest(cwd);
return result;
}
async diff(cwd: string, relPath: string, absPath: string): Promise<FsDiffResponse> {
const inside = await runCommand('git', ['rev-parse', '--is-inside-work-tree'], cwd);
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
throw this.gitUnavailable(cwd, inside.stderr.trim() || `git rev-parse exit ${inside.exitCode}`);
}
const statusRes = await runCommand('git', ['status', '--porcelain=v1', '--', relPath], cwd);
if (statusRes.exitCode !== 0) {
throw this.gitUnavailable(cwd, statusRes.stderr.trim() || `git status exit ${statusRes.exitCode}`);
}
const untracked = statusRes.stdout.startsWith('??');
// A repo with no commits yet has no HEAD to diff against — every changed
// file is all-new there, same as the untracked case.
const headRes = await runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
const hasHead = headRes.exitCode === 0;
let diffStdout: string;
if (untracked || !hasHead) {
// An untracked file has no HEAD side; diff it against /dev/null so the UI
// gets an all-added hunk. `git diff --no-index` exits 1 when files differ.
const res = await runCommand(
'git',
['diff', '--no-color', '--no-index', '--', '/dev/null', relPath],
cwd,
);
if (res.exitCode !== 0 && res.exitCode !== 1) {
throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`);
}
diffStdout = res.stdout;
} else {
const res = await runCommand('git', ['diff', '--no-color', 'HEAD', '--', relPath], cwd);
if (res.exitCode !== 0) {
throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`);
}
if (res.stdout.length === 0 && statusRes.stdout.length === 0) {
// Not changed at all — distinguish "clean file" (empty diff is fine)
// from a path that does not exist anywhere.
const exists = await stat(absPath).then(
() => true,
() => false,
);
if (!exists) {
throw new KimiError(ErrorCodes.FS_PATH_NOT_FOUND, `path not found: ${relPath}`, {
details: { path: relPath },
});
}
}
diffStdout = res.stdout;
}
const truncated = diffStdout.length > DIFF_MAX_BYTES;
return {
path: relPath,
diff: truncated ? diffStdout.slice(0, DIFF_MAX_BYTES) : diffStdout,
truncated,
};
}
private async readPullRequest(cwd: string): Promise<FsPullRequest | null> {
const cached = this.pullRequestCache.get(cwd);
const now = Date.now();
if (cached !== undefined && now - cached.fetchedAt < PULL_REQUEST_TTL_MS) {
return cached.value;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PR_SPAWN_TIMEOUT_MS);
timer.unref?.();
try {
const res = await runCommand(
'gh',
['pr', 'view', '--json', 'number,url,state'],
cwd,
{
env: { GH_NO_UPDATE_NOTIFIER: '1', GH_PROMPT_DISABLED: '1' },
signal: controller.signal,
},
);
const value = res.exitCode === 0 ? parsePullRequest(res.stdout) : null;
this.pullRequestCache.set(cwd, { value, fetchedAt: now });
return value;
} finally {
clearTimeout(timer);
}
}
private gitUnavailable(cwd: string, detail: string): KimiError {
return new KimiError(ErrorCodes.FS_GIT_UNAVAILABLE, `git unavailable at ${cwd}: ${detail}`, {
details: { cwd, detail },
});
}
}
interface RunResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
interface RunOptions {
readonly timeoutMs?: number;
readonly env?: NodeJS.ProcessEnv;
readonly signal?: AbortSignal;
}
function runCommand(
cmd: string,
args: readonly string[],
cwd: string,
options: RunOptions = {},
): Promise<RunResult> {
return new Promise<RunResult>((resolve) => {
const child = spawn(cmd, [...args], {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
env: options.env ? { ...process.env, ...options.env } : process.env,
windowsHide: true,
});
let stdout = '';
let stderr = '';
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (result: RunResult): void => {
if (settled) return;
settled = true;
if (timer !== undefined) clearTimeout(timer);
resolve(result);
};
const kill = (): void => {
killChild(child);
};
const signal = options.signal;
if (signal !== undefined) {
if (signal.aborted) kill();
else signal.addEventListener('abort', kill, { once: true });
}
if (options.timeoutMs !== undefined) {
timer = setTimeout(() => {
kill();
finish({ exitCode: -1, stdout, stderr });
}, options.timeoutMs);
timer.unref?.();
}
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
stdout += chunk;
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.once('error', () => {
finish({ exitCode: -1, stdout, stderr });
});
child.once('close', (code) => {
finish({ exitCode: code ?? -1, stdout, stderr });
});
});
}
function killChild(child: ChildProcess): void {
// On Windows, `ChildProcess.kill()` only signals the direct child (e.g. the
// `cmd.exe` wrapper when a shell is involved, or the `git`/`gh` parent),
// leaving grandchildren alive and holding the cwd. Terminate the whole
// process tree so the working directory is released promptly.
if (process.platform === 'win32' && child.pid !== undefined) {
try {
const killer = spawn('taskkill', ['/T', '/F', '/PID', String(child.pid)], {
stdio: 'ignore',
windowsHide: true,
});
killer.once('error', () => {});
return;
} catch {
// fall through to the direct kill below
}
}
try {
child.kill();
} catch {
/* best effort */
}
}
registerScopedService(LifecycleScope.App, IGitService, GitService, InstantiationType.Delayed, 'git');

View file

@ -0,0 +1,10 @@
/**
* `git` domain barrel re-exports the git contract (`git`), its scoped
* service (`gitService`), the pure parsers (`gitParsers`), and the git error
* codes (`errors`). Importing this barrel registers the `IGitService` binding
* into the scope registry.
*/
export * from './git';
export * from './gitParsers';
export * from './gitService';

View file

@ -2,9 +2,12 @@
* `agentFs` domain (L2) `ISessionFsService` implementation.
*
* Backs the fs REST surface (search / grep / git status / git diff) by
* orchestrating `ISessionAgentFileSystem` (file IO) and `ISessionProcessRunner` (`rg` /
* `git` / `gh`). Bound at Session scope the workspace root and execution
* environment come from the scope, so no `sessionId` is threaded through.
* orchestrating `ISessionAgentFileSystem` (file IO), `ISessionProcessRunner` (`rg`),
* and `IGitService` (git status / diff). Bound at Session scope the workspace
* root and execution environment come from the scope, so no `sessionId` is
* threaded through. Git operations are delegated to the App-scoped
* `IGitService`; this service only confines paths and computes repo-relative
* paths before calling it.
*
* Path confinement is lexical (`ISessionWorkspaceContext.isWithin`); it does not
* follow symlinks, matching the rest of v2 (`_base/tools/policies/path-access.ts`).
@ -29,7 +32,6 @@ import {
type FsListResponse,
type FsMkdirRequest,
type FsMkdirResponse,
type FsPullRequest,
type FsReadRequest,
type FsReadResponse,
type FsSearchHit,
@ -45,13 +47,13 @@ import ignore, { type Ignore } from 'ignore';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError } from '#/errors';
import { ISessionProcessRunner } from '#/session/process';
import { IGitService } from '#/app/git';
import { ITelemetryService } from '#/app/telemetry';
import { ISessionProcessRunner } from '#/session/process';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { type AgentFileStat, ISessionAgentFileSystem } from './agentFs';
import { type FsDownloadResolved, type FsPathResolved, ISessionFsService } from './fs';
import { parseNumstat, parsePorcelain, parsePullRequest } from './fsGit';
import { runCommand } from './fsProcess';
import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator';
import {
@ -68,9 +70,6 @@ import {
const SEARCH_HARD_CAP = 500;
const GREP_TIMEOUT_MS = 30_000;
const WALK_MAX_DEPTH = 64;
const DIFF_MAX_BYTES = 1_048_576;
const PR_SPAWN_TIMEOUT_MS = 5_000;
const PULL_REQUEST_TTL_MS = 60_000;
/** Hard cap for `fs:read` payloads (10 MiB). */
const FS_READ_MAX_BYTES = 10 * 1024 * 1024;
@ -86,10 +85,6 @@ export class SessionFsService implements ISessionFsService {
declare readonly _serviceBrand: undefined;
private readonly gitignoreCache = new Map<string, Ignore>();
private readonly pullRequestCache = new Map<
string,
{ value: FsPullRequest | null; fetchedAt: number }
>();
/**
* Cached ripgrep resolution. `undefined` = not probed yet; `null` = probed
* and unavailable (use the node fallback). Mirrors the old `rgAvailable`
@ -102,6 +97,7 @@ export class SessionFsService implements ISessionFsService {
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@ISessionProcessRunner private readonly runner: ISessionProcessRunner,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IGitService private readonly git: IGitService,
) {}
async list(req: FsListRequest): Promise<FsListResponse> {
@ -446,159 +442,21 @@ export class SessionFsService implements ISessionFsService {
async gitStatus(req: FsGitStatusRequest): Promise<FsGitStatusResponse> {
const cwd = this.workspace.workDir;
let filterSet: Set<string> | undefined;
let filter: Set<string> | undefined;
if (req.paths !== undefined && req.paths.length > 0) {
filterSet = new Set();
filter = new Set();
for (const p of req.paths) {
filterSet.add(this.toRel(this.resolveWithin(p)));
filter.add(this.toRel(this.resolveWithin(p)));
}
}
const inside = await runCommand(
this.runner,
['git', 'rev-parse', '--is-inside-work-tree'],
{ cwd },
);
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
throw this.gitUnavailable(cwd, inside.stderr.trim() || `git rev-parse exit ${inside.exitCode}`);
}
const porc = await runCommand(
this.runner,
['git', 'status', '--porcelain=v1', '--branch'],
{ cwd },
);
if (porc.exitCode !== 0) {
throw this.gitUnavailable(cwd, porc.stderr.trim() || `git status exit ${porc.exitCode}`);
}
const result = parsePorcelain(porc.stdout, filterSet);
const dirty = porc.stdout
.split('\n')
.some((line) => line.length > 0 && !line.startsWith('## '));
if (dirty) {
const head = await runCommand(
this.runner,
['git', 'rev-parse', '--verify', '--quiet', 'HEAD'],
{ cwd },
);
if (head.exitCode === 0) {
const numstat = await runCommand(
this.runner,
['git', 'diff', '--no-color', '--numstat', 'HEAD', '--'],
{ cwd },
);
if (numstat.exitCode === 0) {
const stats = parseNumstat(numstat.stdout);
result.additions = stats.additions;
result.deletions = stats.deletions;
}
}
}
result.pullRequest = await this.readPullRequest(cwd);
return result;
return this.git.status(cwd, filter);
}
async diff(req: FsDiffRequest): Promise<FsDiffResponse> {
const cwd = this.workspace.workDir;
const rel = this.toRel(this.resolveWithin(req.path));
const inside = await runCommand(
this.runner,
['git', 'rev-parse', '--is-inside-work-tree'],
{ cwd },
);
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
throw this.gitUnavailable(cwd, inside.stderr.trim() || `git rev-parse exit ${inside.exitCode}`);
}
const statusRes = await runCommand(
this.runner,
['git', 'status', '--porcelain=v1', '--', rel],
{ cwd },
);
if (statusRes.exitCode !== 0) {
throw this.gitUnavailable(cwd, statusRes.stderr.trim() || `git status exit ${statusRes.exitCode}`);
}
const untracked = statusRes.stdout.startsWith('??');
const headRes = await runCommand(
this.runner,
['git', 'rev-parse', '--verify', '--quiet', 'HEAD'],
{ cwd },
);
const hasHead = headRes.exitCode === 0;
let diffStdout: string;
if (untracked || !hasHead) {
const res = await runCommand(
this.runner,
['git', 'diff', '--no-color', '--no-index', '--', '/dev/null', rel],
{ cwd },
);
if (res.exitCode !== 0 && res.exitCode !== 1) {
throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`);
}
diffStdout = res.stdout;
} else {
const res = await runCommand(
this.runner,
['git', 'diff', '--no-color', 'HEAD', '--', rel],
{ cwd },
);
if (res.exitCode !== 0) {
throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`);
}
if (res.stdout.length === 0 && statusRes.stdout.length === 0) {
const exists = await this.fs.stat(rel).then(
() => true,
() => false,
);
if (!exists) {
throw new KimiError(ErrorCodes.FS_PATH_NOT_FOUND, `path not found: ${req.path}`, {
details: { path: req.path },
});
}
}
diffStdout = res.stdout;
}
const truncated = diffStdout.length > DIFF_MAX_BYTES;
return {
path: rel,
diff: truncated ? diffStdout.slice(0, DIFF_MAX_BYTES) : diffStdout,
truncated,
};
}
private async readPullRequest(cwd: string): Promise<FsPullRequest | null> {
const cached = this.pullRequestCache.get(cwd);
const now = Date.now();
if (cached !== undefined && now - cached.fetchedAt < PULL_REQUEST_TTL_MS) {
return cached.value;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PR_SPAWN_TIMEOUT_MS);
timer.unref?.();
try {
const res = await runCommand(
this.runner,
['gh', 'pr', 'view', '--json', 'number,url,state'],
{
cwd,
env: { GH_NO_UPDATE_NOTIFIER: '1', GH_PROMPT_DISABLED: '1' },
signal: controller.signal,
},
);
const value = res.exitCode === 0 ? parsePullRequest(res.stdout) : null;
this.pullRequestCache.set(cwd, { value, fetchedAt: now });
return value;
} finally {
clearTimeout(timer);
}
const abs = this.resolveWithin(req.path);
return this.git.diff(cwd, this.toRel(abs), abs);
}
private async grepWithRg(
@ -813,12 +671,6 @@ export class SessionFsService implements ISessionFsService {
if (rel === '') return '.';
return rel.split(sep).join('/');
}
private gitUnavailable(cwd: string, detail: string): KimiError {
return new KimiError(ErrorCodes.FS_GIT_UNAVAILABLE, `git unavailable at ${cwd}: ${detail}`, {
details: { cwd, detail },
});
}
}
function parseRgJsonOutput(

View file

@ -10,6 +10,8 @@ import {
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IGitService } from '#/app/git';
import { ErrorCodes, KimiError } from '#/errors';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { ISessionFsService } from '#/session/agentFs/fs';
import { SessionFsService } from '#/session/agentFs/fsService';
@ -188,10 +190,27 @@ afterEach(() => {
host = undefined;
});
function defaultGitStub(): IGitService {
return {
_serviceBrand: undefined,
status: async () => ({
branch: '',
ahead: 0,
behind: 0,
entries: {},
additions: 0,
deletions: 0,
pullRequest: null,
}),
diff: async () => ({ path: '', diff: '', truncated: false }),
};
}
function makeSession(
files: Record<string, string>,
handler: RunHandler,
events: Array<{ event: string; properties: Record<string, unknown> }> = [],
git: IGitService = defaultGitStub(),
): ISessionFsService {
host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1', [
@ -199,6 +218,7 @@ function makeSession(
stubPair(ISessionAgentFileSystem, fakeFs(files)),
stubPair(ISessionProcessRunner, fakeRunner(handler)),
stubPair(ITelemetryService, telemetryStub(events)),
stubPair(IGitService, git),
]);
return session.accessor.get(ISessionFsService);
}
@ -206,50 +226,72 @@ function makeSession(
const emptyHandler: RunHandler = () => ({ stdout: '', exitCode: 0 });
describe('SessionFsService.gitStatus', () => {
it('returns branch, entries, numstat, and null pull request', async () => {
const fs = makeSession({}, (args) => {
const cmd = args.join(' ');
if (cmd.includes('--is-inside-work-tree')) return { stdout: 'true\n', exitCode: 0 };
if (cmd.includes('status --porcelain')) {
return { stdout: '## main...origin/main\n M src/a.ts\n', exitCode: 0 };
}
if (cmd.includes('--verify') && cmd.includes('HEAD')) return { stdout: '', exitCode: 0 };
if (cmd.includes('--numstat')) return { stdout: '3\t1\tsrc/a.ts\n', exitCode: 0 };
if (args[0] === 'gh') return { stdout: '', exitCode: 1 };
return { stdout: '', exitCode: 0 };
});
const result = await fs.gitStatus({});
it('delegates to IGitService with the session cwd and a confined filter', async () => {
const calls: Array<{ cwd: string; filter: ReadonlySet<string> | undefined }> = [];
const git: IGitService = {
_serviceBrand: undefined,
status: async (cwd, filter) => {
calls.push({ cwd, filter });
return {
branch: 'main',
ahead: 0,
behind: 0,
entries: { 'src/a.ts': 'modified' },
additions: 3,
deletions: 1,
pullRequest: null,
};
},
diff: async () => ({ path: '', diff: '', truncated: false }),
};
const fs = makeSession({}, emptyHandler, [], git);
const result = await fs.gitStatus({ paths: ['src/a.ts'] });
expect(calls).toHaveLength(1);
expect(calls[0]?.cwd).toBe(WORK_DIR);
expect(calls[0]?.filter).toEqual(new Set(['src/a.ts']));
expect(result.branch).toBe('main');
expect(result.entries).toEqual({ 'src/a.ts': 'modified' });
expect(result.additions).toBe(3);
expect(result.deletions).toBe(1);
expect(result.pullRequest).toBeNull();
});
it('throws FS_GIT_UNAVAILABLE when not a git repo', async () => {
const fs = makeSession({}, () => ({
stdout: '',
stderr: 'not a git repository',
exitCode: 128,
}));
it('propagates FS_GIT_UNAVAILABLE thrown by IGitService', async () => {
const git: IGitService = {
_serviceBrand: undefined,
status: async () => {
throw new KimiError(ErrorCodes.FS_GIT_UNAVAILABLE, 'git unavailable at /repo: not a repo');
},
diff: async () => ({ path: '', diff: '', truncated: false }),
};
const fs = makeSession({}, emptyHandler, [], git);
await expect(fs.gitStatus({})).rejects.toMatchObject({ code: 'fs.git_unavailable' });
});
});
describe('SessionFsService.diff', () => {
it('returns the unified diff for a tracked file', async () => {
const fs = makeSession({ 'src/a.ts': 'content' }, (args) => {
const cmd = args.join(' ');
if (cmd.includes('--is-inside-work-tree')) return { stdout: 'true\n', exitCode: 0 };
if (cmd.includes('status --porcelain')) return { stdout: ' M src/a.ts\n', exitCode: 0 };
if (cmd.includes('--verify') && cmd.includes('HEAD')) return { stdout: '', exitCode: 0 };
if (cmd.includes('diff') && cmd.includes('HEAD')) {
return { stdout: '-old\n+new\n', exitCode: 0 };
}
return { stdout: '', exitCode: 0 };
});
it('delegates to IGitService with confined rel and abs paths', async () => {
const calls: Array<{ cwd: string; rel: string; abs: string }> = [];
const git: IGitService = {
_serviceBrand: undefined,
status: async () => ({
branch: '',
ahead: 0,
behind: 0,
entries: {},
additions: 0,
deletions: 0,
pullRequest: null,
}),
diff: async (cwd, rel, abs) => {
calls.push({ cwd, rel, abs });
return { path: rel, diff: '-old\n+new\n', truncated: false };
},
};
const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler, [], git);
const result = await fs.diff({ path: 'src/a.ts' });
expect(result.path).toBe('src/a.ts');
expect(calls).toHaveLength(1);
expect(calls[0]?.cwd).toBe(WORK_DIR);
expect(calls[0]?.rel).toBe('src/a.ts');
expect(calls[0]?.abs).toBe(resolve(WORK_DIR, 'src/a.ts'));
expect(result.diff).toContain('+new');
expect(result.truncated).toBe(false);
});

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { parseNumstat, parsePorcelain, parsePullRequest } from '#/session/agentFs/fsGit';
import { parseNumstat, parsePorcelain, parsePullRequest } from '#/app/git/gitParsers';
describe('parsePorcelain', () => {
it('parses branch header and ahead/behind', () => {

View file

@ -0,0 +1,120 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { GitService } from '#/app/git/gitService';
import { ErrorCodes } from '#/errors';
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
.toString()
.trim();
}
describe('GitService', () => {
let repo: string;
let service: GitService;
beforeEach(() => {
repo = mkdtempSync(join(tmpdir(), 'git-service-'));
git(repo, 'init');
git(repo, 'config', 'user.email', 'test@example.com');
git(repo, 'config', 'user.name', 'Test');
service = new GitService();
});
afterEach(() => {
rmSync(repo, { recursive: true, force: true });
});
function commitAll(message: string): void {
git(repo, 'add', '-A');
git(repo, 'commit', '-m', message);
}
describe('status', () => {
it('reports a clean tree', async () => {
writeFileSync(join(repo, 'a.txt'), 'hello\n');
commitAll('init');
const result = await service.status(repo);
expect(typeof result.branch).toBe('string');
expect(result.entries).toEqual({});
expect(result.additions).toBe(0);
expect(result.deletions).toBe(0);
expect(result.pullRequest).toBeNull();
});
it('reports a modified file with numstat', async () => {
writeFileSync(join(repo, 'a.txt'), 'line1\n');
commitAll('init');
writeFileSync(join(repo, 'a.txt'), 'line1\nline2\nline3\n');
const result = await service.status(repo);
expect(result.entries).toEqual({ 'a.txt': 'modified' });
expect(result.additions).toBe(2);
expect(result.deletions).toBe(0);
});
it('restricts entries to the path filter', async () => {
writeFileSync(join(repo, 'a.txt'), 'a\n');
writeFileSync(join(repo, 'b.txt'), 'b\n');
commitAll('init');
writeFileSync(join(repo, 'a.txt'), 'a2\n');
writeFileSync(join(repo, 'b.txt'), 'b2\n');
const result = await service.status(repo, new Set(['a.txt']));
expect(result.entries).toEqual({ 'a.txt': 'modified' });
});
it('throws FS_GIT_UNAVAILABLE when not a repo', async () => {
const notRepo = mkdtempSync(join(tmpdir(), 'not-repo-'));
try {
await expect(service.status(notRepo)).rejects.toMatchObject({
code: ErrorCodes.FS_GIT_UNAVAILABLE,
});
} finally {
rmSync(notRepo, { recursive: true, force: true });
}
});
});
describe('diff', () => {
it('returns the unified diff for a tracked modified file', async () => {
writeFileSync(join(repo, 'a.txt'), 'old\n');
commitAll('init');
writeFileSync(join(repo, 'a.txt'), 'new\n');
const result = await service.diff(repo, 'a.txt', join(repo, 'a.txt'));
expect(result.path).toBe('a.txt');
expect(result.diff).toContain('+new');
expect(result.diff).toContain('-old');
expect(result.truncated).toBe(false);
});
it('returns an all-added diff for an untracked file', async () => {
writeFileSync(join(repo, 'a.txt'), 'hello\n');
commitAll('init');
writeFileSync(join(repo, 'b.txt'), 'brand new\n');
const result = await service.diff(repo, 'b.txt', join(repo, 'b.txt'));
expect(result.diff).toContain('+brand new');
});
it('throws FS_PATH_NOT_FOUND for a missing path', async () => {
writeFileSync(join(repo, 'a.txt'), 'hello\n');
commitAll('init');
await expect(
service.diff(repo, 'missing.txt', join(repo, 'missing.txt')),
).rejects.toMatchObject({ code: ErrorCodes.FS_PATH_NOT_FOUND });
});
});
});