mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-18 21:25:40 +00:00
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:
parent
ab63a3c036
commit
0a74f7f009
9 changed files with 531 additions and 201 deletions
|
|
@ -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],
|
||||
|
|
|
|||
38
packages/agent-core-v2/src/app/git/git.ts
Normal file
38
packages/agent-core-v2/src/app/git/git.ts
Normal 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');
|
||||
|
|
@ -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 = '';
|
||||
263
packages/agent-core-v2/src/app/git/gitService.ts
Normal file
263
packages/agent-core-v2/src/app/git/gitService.ts
Normal 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');
|
||||
10
packages/agent-core-v2/src/app/git/index.ts
Normal file
10
packages/agent-core-v2/src/app/git/index.ts
Normal 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';
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
120
packages/agent-core-v2/test/git/gitService.test.ts
Normal file
120
packages/agent-core-v2/test/git/gitService.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue