diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 2a78e69ca..4637a9c85 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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], diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts new file mode 100644 index 000000000..4f2f91d6c --- /dev/null +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -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): Promise; + + /** + * `git diff HEAD -- ` 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; +} + +export const IGitService: ServiceIdentifier = + createDecorator('gitService'); diff --git a/packages/agent-core-v2/src/session/agentFs/fsGit.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts similarity index 96% rename from packages/agent-core-v2/src/session/agentFs/fsGit.ts rename to packages/agent-core-v2/src/app/git/gitParsers.ts index df03e2270..3f98ae933 100644 --- a/packages/agent-core-v2/src/session/agentFs/fsGit.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -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 | undefined, + filter: ReadonlySet | undefined, ): FsGitStatusResponse { const lines = stdout.split('\n'); let branch = ''; diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts new file mode 100644 index 000000000..1351601de --- /dev/null +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -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): Promise { + 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 { + 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 { + 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 { + return new Promise((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 | 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'); diff --git a/packages/agent-core-v2/src/app/git/index.ts b/packages/agent-core-v2/src/app/git/index.ts new file mode 100644 index 000000000..c90fb3d07 --- /dev/null +++ b/packages/agent-core-v2/src/app/git/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/agentFs/fsService.ts b/packages/agent-core-v2/src/session/agentFs/fsService.ts index be48593b4..f88eb2449 100644 --- a/packages/agent-core-v2/src/session/agentFs/fsService.ts +++ b/packages/agent-core-v2/src/session/agentFs/fsService.ts @@ -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(); - 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 { @@ -446,159 +442,21 @@ export class SessionFsService implements ISessionFsService { async gitStatus(req: FsGitStatusRequest): Promise { const cwd = this.workspace.workDir; - let filterSet: Set | undefined; + let filter: Set | 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 { 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 { - 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( diff --git a/packages/agent-core-v2/test/agentFs/fsService.test.ts b/packages/agent-core-v2/test/agentFs/fsService.test.ts index 6ff7f7b7f..c7835987f 100644 --- a/packages/agent-core-v2/test/agentFs/fsService.test.ts +++ b/packages/agent-core-v2/test/agentFs/fsService.test.ts @@ -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, handler: RunHandler, events: Array<{ event: string; properties: Record }> = [], + 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 | 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); }); diff --git a/packages/agent-core-v2/test/agentFs/fsGit.test.ts b/packages/agent-core-v2/test/git/gitParsers.test.ts similarity index 99% rename from packages/agent-core-v2/test/agentFs/fsGit.test.ts rename to packages/agent-core-v2/test/git/gitParsers.test.ts index eccca89de..b7162cfeb 100644 --- a/packages/agent-core-v2/test/agentFs/fsGit.test.ts +++ b/packages/agent-core-v2/test/git/gitParsers.test.ts @@ -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', () => { diff --git a/packages/agent-core-v2/test/git/gitService.test.ts b/packages/agent-core-v2/test/git/gitService.test.ts new file mode 100644 index 000000000..c5fd9c056 --- /dev/null +++ b/packages/agent-core-v2/test/git/gitService.test.ts @@ -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 }); + }); + }); +});