From b2f9b9b83dbdd914b6507c75892651d1a379e640 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 29 Oct 2026 20:36:00 +0800 Subject: [PATCH 1/9] feat: support folder-scoped git status --- packages/agent-core-v2/src/app/git/git.ts | 8 ++- .../agent-core-v2/src/app/git/gitParsers.ts | 1 + .../agent-core-v2/src/app/git/gitService.ts | 18 +++--- .../src/workspace/workspaceFs/fsService.ts | 11 +++- .../workspace/workspaceGit/workspaceGit.ts | 4 +- .../workspaceGit/workspaceGitService.ts | 6 +- .../test/app/git/gitParsers.test.ts | 14 +++++ .../test/app/git/gitService.test.ts | 20 ++++++- .../workspace/workspaceFs/fsService.test.ts | 36 ++++++++++-- packages/kap-server/src/routes/fs.ts | 11 +++- packages/kap-server/test/fs.test.ts | 56 +++++++++++++++++++ 11 files changed, 163 insertions(+), 22 deletions(-) diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index c4291df5d73..f8b1c45f66a 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -26,10 +26,16 @@ export const fsPullRequestSchema = z.object({ export type FsPullRequest = z.infer; export const fsGitStatusRequestSchema = z.object({ + root: z.string().min(1).optional(), paths: z.array(z.string().min(1)).optional(), }); export type FsGitStatusRequest = z.infer; +export interface GitStatusOptions { + pathFilter?: ReadonlySet; + scopeToCwd?: boolean; +} + export const fsGitStatusResponseSchema = z.object({ branch: z.string(), ahead: z.number().int().nonnegative(), @@ -56,7 +62,7 @@ export type FsDiffResponse = z.infer; export interface IGitService { readonly _serviceBrand: undefined; - status(cwd: string, pathFilter?: ReadonlySet): Promise; + status(cwd: string, options?: GitStatusOptions): Promise; diff(cwd: string, relPath: string, absPath: string): Promise; findWorkTree(cwd: string): Promise; } diff --git a/packages/agent-core-v2/src/app/git/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index 3885f7fdb35..bfd373ca21b 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -130,6 +130,7 @@ export function parsePullRequest(stdout: string): FsPullRequest | null { if (typeof state !== 'string') return null; const normalized = state.toLowerCase(); if (normalized !== 'open' && normalized !== 'merged' && normalized !== 'closed') return null; + if (normalized === 'open' && record['isDraft'] === true) return { number, state: 'draft', url }; return { number, state: normalized, url }; } diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index 512b5c4d9f2..faad57f8535 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -1,4 +1,4 @@ -import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest } from './git'; +import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest, GitStatusOptions } from './git'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ErrorCodes, Error2 } from '#/errors'; @@ -28,18 +28,20 @@ export class GitService implements IGitService { @IHostFileSystem private readonly fs: IHostFileSystem, ) {} - async status(cwd: string, pathFilter?: ReadonlySet): Promise { + async status(cwd: string, options: GitStatusOptions = {}): Promise { const inside = await this.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 this.runCommand('git', ['status', '--porcelain=v1', '--branch', '-z'], cwd); + const statusArgs = ['status', '--porcelain=v1', '--branch', '-z']; + if (options.scopeToCwd === true) statusArgs.push('--', '.'); + const porc = await this.runCommand('git', statusArgs, cwd); if (porc.exitCode !== 0) { throw this.gitUnavailable(cwd, porc.stderr.trim() || `git status exit ${porc.exitCode}`); } - const result = parsePorcelain(porc.stdout, pathFilter); + const result = parsePorcelain(porc.stdout, options.pathFilter); const dirty = porc.stdout .split('\0') @@ -47,7 +49,9 @@ export class GitService implements IGitService { if (dirty) { const head = await this.runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd); if (head.exitCode === 0) { - const numstat = await this.runCommand('git', ['diff', '--no-color', '--numstat', 'HEAD', '--'], cwd); + const diffArgs = ['diff', '--no-color', '--numstat', 'HEAD', '--']; + if (options.scopeToCwd === true) diffArgs.push('.'); + const numstat = await this.runCommand('git', diffArgs, cwd); if (numstat.exitCode === 0) { const stats = parseNumstat(numstat.stdout); result.additions = stats.additions; @@ -126,7 +130,7 @@ export class GitService implements IGitService { const res = await this.runCommand( 'gh', - ['pr', 'view', '--json', 'number,url,state'], + ['pr', 'view', '--json', 'number,url,state,isDraft'], cwd, { env: { GH_NO_UPDATE_NOTIFIER: '1', GH_PROMPT_DISABLED: '1' }, @@ -197,7 +201,7 @@ export class GitService implements IGitService { } private resolveWorkspaceId(cwd: string): string { - const workspace = this.workspaces.findByRoot(cwd); + const workspace = this.workspaces.findContaining(cwd); if (workspace === undefined) { throw new Error(`workspace for root ${cwd} is not materialized`); } diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index 36141bd61f8..e69de670317 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -1031,7 +1031,16 @@ export class WorkspaceFsService implements IWorkspaceFsService { } } - return this.git.status(filter); + const cwd = req.root === undefined ? this.workDir : await this.resolveWithin(req.root); + if (req.root !== undefined) { + const stat = await this.hostFs.stat(cwd).catch(() => undefined); + if (stat?.isDirectory !== true) { + throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, `git root is not a directory: ${req.root}`, { + details: { cwd, path: req.root }, + }); + } + } + return this.git.status(cwd, { pathFilter: filter, scopeToCwd: req.root !== undefined }); } async diff(req: FsDiffRequest): Promise { diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts index 5aca9e3cf2e..b9b4121a483 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts @@ -1,10 +1,10 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { FsDiffResponse, FsGitStatusResponse } from '#/app/git/git'; +import type { FsDiffResponse, FsGitStatusResponse, GitStatusOptions } from '#/app/git/git'; export interface IWorkspaceGitService { readonly _serviceBrand: undefined; - status(pathFilter?: ReadonlySet): Promise; + status(cwd: string, options?: GitStatusOptions): Promise; diff(relPath: string, absPath: string): Promise; } diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts index 67a1f8d5f65..10159534e70 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts @@ -1,5 +1,5 @@ import { ref, type LiveRef } from '#/_base/di/instantiation'; -import { type FsDiffResponse, type FsGitStatusResponse, IGitService } from '#/app/git/git'; +import { type FsDiffResponse, type FsGitStatusResponse, type GitStatusOptions, IGitService } from '#/app/git/git'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceGitService } from './workspaceGit'; @@ -12,8 +12,8 @@ export class WorkspaceGitService implements IWorkspaceGitService { @ref(IGitService) private readonly git: LiveRef, ) {} - status(pathFilter?: ReadonlySet): Promise { - return this.git.current!.status(this.workspace.cwd, pathFilter); + status(cwd: string, options?: GitStatusOptions): Promise { + return this.git.current!.status(cwd, options); } diff(relPath: string, absPath: string): Promise { diff --git a/packages/agent-core-v2/test/app/git/gitParsers.test.ts b/packages/agent-core-v2/test/app/git/gitParsers.test.ts index 700e7c986bd..2f5fa334362 100644 --- a/packages/agent-core-v2/test/app/git/gitParsers.test.ts +++ b/packages/agent-core-v2/test/app/git/gitParsers.test.ts @@ -72,6 +72,20 @@ describe('parsePullRequest', () => { }); }); + it('normalizes an open draft PR', () => { + const out = '{"number":13,"url":"https://github.com/acme/repo/pull/13","state":"OPEN","isDraft":true}'; + expect(parsePullRequest(out)).toEqual({ + number: 13, + state: 'draft', + url: 'https://github.com/acme/repo/pull/13', + }); + }); + + it('keeps an open PR when isDraft is false', () => { + const out = '{"number":12,"url":"https://github.com/acme/repo/pull/12","state":"OPEN","isDraft":false}'; + expect(parsePullRequest(out)?.state).toBe('open'); + }); + it('returns null for malformed json', () => { expect(parsePullRequest('not json')).toBeNull(); }); diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index ffa48cea879..07cd25fd4d4 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -55,7 +55,7 @@ describe('GitService', () => { acquire: () => ({ runtime, track: (resource) => resource, dispose: () => {} }), }); reg.definePartialInstance(IWorkspaceInstanceManager, { - findByRoot: () => ({ id: 'workspace-1' } as never), + findContaining: () => ({ id: 'workspace-1' } as never), onDidChange: Event.None as Event, }); reg.define(IGitService, GitService); @@ -105,10 +105,26 @@ describe('GitService', () => { writeFileSync(join(repo, 'a.txt'), 'a2\n'); writeFileSync(join(repo, 'b.txt'), 'b2\n'); - const result = await service.status(repo, new Set(['a.txt'])); + const result = await service.status(repo, { pathFilter: new Set(['a.txt']) }); expect(result.entries).toEqual({ 'a.txt': 'modified' }); }); + it('scopes entries and numstat to the selected cwd', async () => { + mkdirSync(join(repo, 'a'), { recursive: true }); + mkdirSync(join(repo, 'b'), { recursive: true }); + writeFileSync(join(repo, 'a/file.txt'), 'one\n'); + writeFileSync(join(repo, 'b/file.txt'), 'one\n'); + commitAll('init'); + writeFileSync(join(repo, 'a/file.txt'), 'one\ntwo\n'); + writeFileSync(join(repo, 'b/file.txt'), 'one\ntwo\nthree\n'); + + const result = await service.status(join(repo, 'a'), { scopeToCwd: true }); + + expect(result.entries).toEqual({ 'a/file.txt': 'modified' }); + expect(result.additions).toBe(1); + expect(result.deletions).toBe(0); + }); + it('reports a non-ASCII path without quoting', async () => { const name = 'output/2026-08-31-bilibili-BV175t86pEre-26.8.31-总能等到回踩的.md'; mkdirSync(join(repo, 'output'), { recursive: true }); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index 390351c127a..14833da2452 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -54,7 +54,7 @@ function stubWorkspaceDirs(additionalDirs: readonly string[] = []): IWorkspaceDi function workspaceGitStub(git: IGitService): IWorkspaceGitService { return { _serviceBrand: undefined, - status: (filter) => git.status(WORK_DIR, filter), + status: (cwd, options) => git.status(cwd, options), diff: (rel, abs) => git.diff(WORK_DIR, rel, abs), }; } @@ -428,11 +428,11 @@ const emptyHandler: RunHandler = () => ({ stdout: '', exitCode: 0 }); describe('WorkspaceFsService.gitStatus', () => { it('delegates to IWorkspaceGitService with the handler root and a confined filter', async () => { - const calls: Array<{ cwd: string; filter: ReadonlySet | undefined }> = []; + const calls: Array<{ cwd: string; options: { pathFilter?: ReadonlySet; scopeToCwd?: boolean } | undefined }> = []; const git: IGitService = { _serviceBrand: undefined, - status: async (cwd, filter) => { - calls.push({ cwd, filter }); + status: async (cwd, options) => { + calls.push({ cwd, options }); return { branch: 'main', ahead: 0, @@ -450,12 +450,38 @@ describe('WorkspaceFsService.gitStatus', () => { 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(calls[0]?.options?.pathFilter).toEqual(new Set(['src/a.ts'])); + expect(calls[0]?.options?.scopeToCwd).toBe(false); expect(result.branch).toBe('main'); expect(result.entries).toEqual({ 'src/a.ts': 'modified' }); expect(result.additions).toBe(3); }); + it('delegates a selected additional root with cwd scoping', async () => { + const calls: Array<{ cwd: string; options: { scopeToCwd?: boolean } | undefined }> = []; + const git: IGitService = { + _serviceBrand: undefined, + status: async (cwd, options) => { + calls.push({ cwd, options }); + return { branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0, pullRequest: null }; + }, + diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, + }; + const fs = makeSession({ '/extra/placeholder': '' }, emptyHandler, [], git, [], undefined, {}, ['/extra']); + + await fs.gitStatus({ root: '/extra' }); + + expect(calls).toEqual([{ cwd: '/extra', options: { pathFilter: undefined, scopeToCwd: true } }]); + }); + + it('rejects roots outside the workspace and roots that are files', async () => { + const fs = makeSession({ 'src/a.ts': '' }, emptyHandler); + + await expect(fs.gitStatus({ root: '/outside' })).rejects.toMatchObject({ code: 'fs.path_escapes' }); + await expect(fs.gitStatus({ root: 'src/a.ts' })).rejects.toMatchObject({ code: 'fs.git_unavailable' }); + }); + it('propagates FS_GIT_UNAVAILABLE thrown by IGitService', async () => { const git: IGitService = { _serviceBrand: undefined, diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 51110be254b..6a6dfffb721 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -18,6 +18,7 @@ import { type Scope, type WorkspaceInstanceLease, } from '@moonshot-ai/agent-core-v2'; +import { canonicalWorkspaceRoot } from '@moonshot-ai/agent-core-v2/_base/utils/paths'; import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; import { RuntimeError } from '@moonshot-ai/agent-core-v2/runtime/runtimeRegistry'; import { @@ -261,7 +262,15 @@ function buildRuntimeFsScope( }, }; const instances = { - findByRoot: (root: string) => root === mapped.workDir ? { id: workspaceId } : undefined, + findContaining: (cwd: string) => { + const probe = canonicalWorkspaceRoot(cwd); + const contains = [mapped.workDir, ...(mapped.additionalDirs ?? [])].some((dir) => { + const root = canonicalWorkspaceRoot(dir); + const prefix = root.endsWith('/') ? root : `${root}/`; + return probe === root || probe.startsWith(prefix); + }); + return contains ? { id: workspaceId } : undefined; + }, } as unknown as IWorkspaceInstanceManager; const git = new WorkspaceGitService( workspace, diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index fe7590ca3fa..f2e4a9b34d3 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, sep } from 'node:path'; @@ -73,6 +74,28 @@ describe('server-v2 /api/v1 fs routes', () => { } }); + function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + } + + async function createWorkspaceSession(root: string, additionalDirs: string[]): Promise { + const workspaceRes = await fetch(`${base}/api/v1/workspaces`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ root, additional_dirs: additionalDirs }), + } as never); + const workspace = (await workspaceRes.json()) as Envelope<{ id: string }>; + expect(workspace.code).toBe(0); + const sessionRes = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ workspace_id: workspace.data.id }), + } as never); + const session = (await sessionRes.json()) as Envelope<{ id: string }>; + expect(session.code).toBe(0); + return session.data.id; + } + async function createSession(): Promise { const res = await fetch(`${base}/api/v1/sessions`, { method: 'POST', @@ -538,6 +561,39 @@ describe('server-v2 /api/v1 fs routes', () => { expect(body.code).toBe(ErrorCode.FS_GIT_UNAVAILABLE); }); + it('fs:git_status selects and scopes an additional root', async () => { + const extra = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-extra-')); + try { + for (const [cwd, name] of [[work!, 'main.txt'], [extra, 'extra.txt']] as const) { + git(cwd, 'init', '-b', 'main'); + git(cwd, 'config', 'user.email', 'test@example.com'); + git(cwd, 'config', 'user.name', 'Test'); + await writeFile(join(cwd, name), 'one\n'); + git(cwd, 'add', '-A'); + git(cwd, 'commit', '-m', 'init'); + await writeFile(join(cwd, name), 'one\ntwo\n'); + } + const id = await createWorkspaceSession(work!, [extra]); + + const primary = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: work }); + const additional = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: extra }); + const fileRoot = await postFs(id, 'git_status', { root: 'main.txt' }); + const outside = await postFs(id, 'git_status', { root: tmpdir() }); + + expect(primary.code).toBe(0); + expect(primary.data.entries).toEqual({ 'main.txt': 'modified' }); + expect(primary.data.additions).toBe(1); + expect(additional.code).toBe(0); + expect(additional.data.branch).toBe('main'); + expect(additional.data.entries).toEqual({ 'extra.txt': 'modified' }); + expect(additional.data.additions).toBe(1); + expect(fileRoot.code).toBe(ErrorCode.FS_GIT_UNAVAILABLE); + expect(outside.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + } finally { + await rm(extra, { recursive: true, force: true }); + } + }, 20000); + it('rejects an unknown action with VALIDATION_FAILED', async () => { const id = await createSession(); const body = await postFs(id, 'bogus', {}); From 1f08059e9ac01985702876411b44d8383146c94f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 29 Oct 2026 22:14:56 +0800 Subject: [PATCH 2/9] feat: support folder-scoped file diffs --- .changeset/folder-scoped-file-diffs.md | 5 +++ packages/agent-core-v2/src/app/git/git.ts | 1 + .../src/workspace/workspaceFs/fsService.ts | 34 +++++++++++++------ .../workspace/workspaceGit/workspaceGit.ts | 2 +- .../workspaceGit/workspaceGitService.ts | 4 +-- .../workspace/workspaceFs/fsService.test.ts | 21 +++++++++++- packages/kap-server/test/fs.test.ts | 4 +++ 7 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 .changeset/folder-scoped-file-diffs.md diff --git a/.changeset/folder-scoped-file-diffs.md b/.changeset/folder-scoped-file-diffs.md new file mode 100644 index 00000000000..47c7e03c3bc --- /dev/null +++ b/.changeset/folder-scoped-file-diffs.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add folder-scoped file diffs for multi-root workspace change panels. diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index f8b1c45f66a..5c651870b82 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -49,6 +49,7 @@ export type FsGitStatusResponse = z.infer; export const fsDiffRequestSchema = z.object({ path: z.string().min(1), + root: z.string().min(1).optional(), }); export type FsDiffRequest = z.infer; diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index e69de670317..142525210bf 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -1031,21 +1031,33 @@ export class WorkspaceFsService implements IWorkspaceFsService { } } - const cwd = req.root === undefined ? this.workDir : await this.resolveWithin(req.root); - if (req.root !== undefined) { - const stat = await this.hostFs.stat(cwd).catch(() => undefined); - if (stat?.isDirectory !== true) { - throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, `git root is not a directory: ${req.root}`, { - details: { cwd, path: req.root }, - }); - } - } + const cwd = await this.resolveGitRoot(req.root); return this.git.status(cwd, { pathFilter: filter, scopeToCwd: req.root !== undefined }); } async diff(req: FsDiffRequest): Promise { - const abs = await this.resolveWithin(req.path); - return this.git.diff(this.toRel(abs), abs); + const cwd = await this.resolveGitRoot(req.root); + const inputPath = req.root !== undefined && !this.path.isAbsolute(req.path) ? this.path.join(cwd, req.path) : req.path; + const abs = await this.resolveWithin(inputPath); + const rel = this.path.relative(cwd, abs).split(this.path.separator).join('/'); + if (rel === '..' || rel.startsWith('../') || this.path.isAbsolute(rel)) { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${req.path}" escapes git root`, { + details: { cwd, path: req.path }, + }); + } + return this.git.diff(cwd, rel, abs); + } + + private async resolveGitRoot(root?: string): Promise { + if (root === undefined) return this.workDir; + const cwd = await this.resolveWithin(root); + const stat = await this.hostFs.stat(cwd).catch(() => undefined); + if (stat?.isDirectory !== true) { + throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, `git root is not a directory: ${root}`, { + details: { cwd, path: root }, + }); + } + return cwd; } private async grepWithRg( diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts index b9b4121a483..c6d689d4700 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts @@ -5,7 +5,7 @@ export interface IWorkspaceGitService { readonly _serviceBrand: undefined; status(cwd: string, options?: GitStatusOptions): Promise; - diff(relPath: string, absPath: string): Promise; + diff(cwd: string, relPath: string, absPath: string): Promise; } export const IWorkspaceGitService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts index 10159534e70..1569e3e71bb 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts @@ -16,8 +16,8 @@ export class WorkspaceGitService implements IWorkspaceGitService { return this.git.current!.status(cwd, options); } - diff(relPath: string, absPath: string): Promise { - return this.git.current!.diff(this.workspace.cwd, relPath, absPath); + diff(cwd: string, relPath: string, absPath: string): Promise { + return this.git.current!.diff(cwd, relPath, absPath); } } diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index 14833da2452..72f2a46121a 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -55,7 +55,7 @@ function workspaceGitStub(git: IGitService): IWorkspaceGitService { return { _serviceBrand: undefined, status: (cwd, options) => git.status(cwd, options), - diff: (rel, abs) => git.diff(WORK_DIR, rel, abs), + diff: (cwd, rel, abs) => git.diff(cwd, rel, abs), }; } @@ -526,6 +526,25 @@ describe('WorkspaceFsService.diff', () => { expect(result.truncated).toBe(false); }); + it('delegates file diff using the selected additional root', 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 }; + }, + findWorkTree: async () => null, + }; + const fs = makeSession({ '/extra/src/a.ts': 'content' }, emptyHandler, [], git, [], undefined, {}, ['/extra']); + + const result = await fs.diff({ root: '/extra', path: 'src/a.ts' }); + + expect(calls).toEqual([{ cwd: '/extra', rel: 'src/a.ts', abs: '/extra/src/a.ts' }]); + expect(result.path).toBe('src/a.ts'); + }); + it('rejects paths that escape the workspace', async () => { const fs = makeSession({}, emptyHandler); await expect(fs.diff({ path: '../etc/passwd' })).rejects.toMatchObject({ diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index f2e4a9b34d3..a1edaecd778 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -577,6 +577,7 @@ describe('server-v2 /api/v1 fs routes', () => { const primary = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: work }); const additional = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: extra }); + const additionalDiff = await postFs<{ path: string; diff: string }>(id, 'diff', { root: extra, path: 'extra.txt' }); const fileRoot = await postFs(id, 'git_status', { root: 'main.txt' }); const outside = await postFs(id, 'git_status', { root: tmpdir() }); @@ -587,6 +588,9 @@ describe('server-v2 /api/v1 fs routes', () => { expect(additional.data.branch).toBe('main'); expect(additional.data.entries).toEqual({ 'extra.txt': 'modified' }); expect(additional.data.additions).toBe(1); + expect(additionalDiff.code).toBe(0); + expect(additionalDiff.data.path).toBe('extra.txt'); + expect(additionalDiff.data.diff).toContain('+two'); expect(fileRoot.code).toBe(ErrorCode.FS_GIT_UNAVAILABLE); expect(outside.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); } finally { From d550db375f92f9b974272a4d663ca9e669a14210 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 29 Oct 2026 22:37:41 +0800 Subject: [PATCH 3/9] feat: load pull requests independently --- .changeset/folder-scoped-file-diffs.md | 2 +- packages/agent-core-v2/src/app/git/git.ts | 8 ++++++++ packages/agent-core-v2/src/app/git/gitService.ts | 6 +++++- .../agent-core-v2/src/workspace/workspaceFs/fs.ts | 7 ++++++- .../src/workspace/workspaceFs/fsService.ts | 13 ++++++++++++- .../src/workspace/workspaceGit/workspaceGit.ts | 3 ++- .../workspace/workspaceGit/workspaceGitService.ts | 6 +++++- .../agent-core-v2/test/app/git/gitService.test.ts | 15 ++++++++++++++- .../test/workspace/workspaceFs/fsService.test.ts | 13 ++++++++++++- packages/kap-server/src/routes/fs.ts | 15 +++++++++++++++ packages/kap-server/test/fs.test.ts | 5 ++++- packages/kap-server/test/v2Sessions.test.ts | 1 + 12 files changed, 85 insertions(+), 9 deletions(-) diff --git a/.changeset/folder-scoped-file-diffs.md b/.changeset/folder-scoped-file-diffs.md index 47c7e03c3bc..4b82ac0c8b0 100644 --- a/.changeset/folder-scoped-file-diffs.md +++ b/.changeset/folder-scoped-file-diffs.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Add folder-scoped file diffs for multi-root workspace change panels. +Add folder-scoped file diffs and non-blocking pull request lookups for multi-root workspace panels. diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index 5c651870b82..91c913eeb4c 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -28,12 +28,19 @@ export type FsPullRequest = z.infer; export const fsGitStatusRequestSchema = z.object({ root: z.string().min(1).optional(), paths: z.array(z.string().min(1)).optional(), + include_pull_request: z.boolean().optional(), }); export type FsGitStatusRequest = z.infer; +export const fsPullRequestRequestSchema = z.object({ + root: z.string().min(1).optional(), +}); +export type FsPullRequestRequest = z.infer; + export interface GitStatusOptions { pathFilter?: ReadonlySet; scopeToCwd?: boolean; + includePullRequest?: boolean; } export const fsGitStatusResponseSchema = z.object({ @@ -64,6 +71,7 @@ export interface IGitService { readonly _serviceBrand: undefined; status(cwd: string, options?: GitStatusOptions): Promise; + pullRequest(cwd: string): Promise; diff(cwd: string, relPath: string, absPath: string): Promise; findWorkTree(cwd: string): Promise; } diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index faad57f8535..056643c3b31 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -60,10 +60,14 @@ export class GitService implements IGitService { } } - result.pullRequest = await this.readPullRequest(cwd); + result.pullRequest = options.includePullRequest === false ? null : await this.readPullRequest(cwd); return result; } + pullRequest(cwd: string): Promise { + return this.readPullRequest(cwd); + } + async diff(cwd: string, relPath: string, absPath: string): Promise { const inside = await this.runCommand('git', ['rev-parse', '--is-inside-work-tree'], cwd); if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') { diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts index 3ee6f6ac27e..bd227c5276d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts @@ -8,6 +8,8 @@ import { type FsDiffResponse, type FsGitStatusRequest, type FsGitStatusResponse, + type FsPullRequest, + type FsPullRequestRequest, } from '#/app/git/git'; export { @@ -15,8 +17,10 @@ export { fsDiffResponseSchema, fsGitStatusRequestSchema, fsGitStatusResponseSchema, + fsPullRequestRequestSchema, + fsPullRequestSchema, } from '#/app/git/git'; -export type { FsDiffRequest, FsDiffResponse, FsGitStatusRequest, FsGitStatusResponse }; +export type { FsDiffRequest, FsDiffResponse, FsGitStatusRequest, FsGitStatusResponse, FsPullRequest, FsPullRequestRequest }; export const fsKindSchema = z.enum(['file', 'directory', 'symlink']); export type FsKind = z.infer; @@ -261,6 +265,7 @@ export interface IWorkspaceFsService { suggest(req: FsSuggestRequest): Promise; grep(req: FsGrepRequest): Promise; gitStatus(req: FsGitStatusRequest): Promise; + pullRequest(req: FsPullRequestRequest): Promise; diff(req: FsDiffRequest): Promise; resolvePath(relPath: string): Promise; resolveDownload(relPath: string, allowExternalSymlinks?: boolean): Promise; diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index 142525210bf..b3914b497e3 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -4,6 +4,8 @@ import { type FsEntry, type FsGitStatusRequest, type FsGitStatusResponse, + type FsPullRequest, + type FsPullRequestRequest, type FsGrepFileHit, type FsGrepMatch, type FsGrepRequest, @@ -1032,7 +1034,16 @@ export class WorkspaceFsService implements IWorkspaceFsService { } const cwd = await this.resolveGitRoot(req.root); - return this.git.status(cwd, { pathFilter: filter, scopeToCwd: req.root !== undefined }); + return this.git.status(cwd, { + pathFilter: filter, + scopeToCwd: req.root !== undefined, + includePullRequest: req.include_pull_request !== false, + }); + } + + async pullRequest(req: FsPullRequestRequest): Promise { + const cwd = await this.resolveGitRoot(req.root); + return this.git.pullRequest(cwd); } async diff(req: FsDiffRequest): Promise { diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts index c6d689d4700..ba07f8e4902 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts @@ -1,10 +1,11 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { FsDiffResponse, FsGitStatusResponse, GitStatusOptions } from '#/app/git/git'; +import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest, GitStatusOptions } from '#/app/git/git'; export interface IWorkspaceGitService { readonly _serviceBrand: undefined; status(cwd: string, options?: GitStatusOptions): Promise; + pullRequest(cwd: string): Promise; diff(cwd: string, relPath: string, absPath: string): Promise; } diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts index 1569e3e71bb..1632f82355e 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts @@ -1,5 +1,5 @@ import { ref, type LiveRef } from '#/_base/di/instantiation'; -import { type FsDiffResponse, type FsGitStatusResponse, type GitStatusOptions, IGitService } from '#/app/git/git'; +import { type FsDiffResponse, type FsGitStatusResponse, type FsPullRequest, type GitStatusOptions, IGitService } from '#/app/git/git'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceGitService } from './workspaceGit'; @@ -16,6 +16,10 @@ export class WorkspaceGitService implements IWorkspaceGitService { return this.git.current!.status(cwd, options); } + pullRequest(cwd: string): Promise { + return this.git.current!.pullRequest(cwd); + } + diff(cwd: string, relPath: string, absPath: string): Promise { return this.git.current!.diff(cwd, relPath, absPath); } diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index 07cd25fd4d4..076de243265 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; @@ -65,6 +65,7 @@ describe('GitService', () => { }); afterEach(() => { + vi.restoreAllMocks(); disposables.dispose(); rmSync(repo, { recursive: true, force: true }); }); @@ -87,6 +88,18 @@ describe('GitService', () => { expect(result.pullRequest).toBeNull(); }, 15000); + it('skips the PR process when pull requests are excluded', async () => { + writeFileSync(join(repo, 'a.txt'), 'hello\n'); + commitAll('init'); + const spawn = vi.spyOn(HostProcessService.prototype, 'spawn'); + + const result = await service.status(repo, { includePullRequest: false }); + + expect(result.branch).not.toBe(''); + expect(result.pullRequest).toBeNull(); + expect(spawn.mock.calls.some(([command]) => command === 'gh')).toBe(false); + }); + it('reports a modified file with numstat', async () => { writeFileSync(join(repo, 'a.txt'), 'line1\n'); commitAll('init'); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index 72f2a46121a..c76996131b8 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -55,6 +55,7 @@ function workspaceGitStub(git: IGitService): IWorkspaceGitService { return { _serviceBrand: undefined, status: (cwd, options) => git.status(cwd, options), + pullRequest: (cwd) => git.pullRequest(cwd), diff: (cwd, rel, abs) => git.diff(cwd, rel, abs), }; } @@ -378,6 +379,7 @@ function defaultGitStub(): IGitService { pullRequest: null, }), diff: async () => ({ path: '', diff: '', truncated: false }), + pullRequest: async () => null, findWorkTree: async () => null, }; } @@ -444,6 +446,7 @@ describe('WorkspaceFsService.gitStatus', () => { }; }, diff: async () => ({ path: '', diff: '', truncated: false }), + pullRequest: async () => null, findWorkTree: async () => null, }; const fs = makeSession({}, emptyHandler, [], git); @@ -466,13 +469,18 @@ describe('WorkspaceFsService.gitStatus', () => { return { branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0, pullRequest: null }; }, diff: async () => ({ path: '', diff: '', truncated: false }), + pullRequest: async () => null, findWorkTree: async () => null, }; const fs = makeSession({ '/extra/placeholder': '' }, emptyHandler, [], git, [], undefined, {}, ['/extra']); await fs.gitStatus({ root: '/extra' }); + await fs.gitStatus({ root: '/extra', include_pull_request: false }); - expect(calls).toEqual([{ cwd: '/extra', options: { pathFilter: undefined, scopeToCwd: true } }]); + expect(calls).toEqual([ + { cwd: '/extra', options: { pathFilter: undefined, scopeToCwd: true, includePullRequest: true } }, + { cwd: '/extra', options: { pathFilter: undefined, scopeToCwd: true, includePullRequest: false } }, + ]); }); it('rejects roots outside the workspace and roots that are files', async () => { @@ -489,6 +497,7 @@ describe('WorkspaceFsService.gitStatus', () => { throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, 'git unavailable at /repo: not a repo'); }, diff: async () => ({ path: '', diff: '', truncated: false }), + pullRequest: async () => null, findWorkTree: async () => null, }; const fs = makeSession({}, emptyHandler, [], git); @@ -514,6 +523,7 @@ describe('WorkspaceFsService.diff', () => { calls.push({ cwd, rel, abs }); return { path: rel, diff: '-old\n+new\n', truncated: false }; }, + pullRequest: async () => null, findWorkTree: async () => null, }; const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler, [], git); @@ -535,6 +545,7 @@ describe('WorkspaceFsService.diff', () => { calls.push({ cwd, rel, abs }); return { path: rel, diff: '-old\n+new\n', truncated: false }; }, + pullRequest: async () => null, findWorkTree: async () => null, }; const fs = makeSession({ '/extra/src/a.ts': 'content' }, emptyHandler, [], git, [], undefined, {}, ['/extra']); diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 6a6dfffb721..5add81a4fc2 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -24,6 +24,7 @@ import { RuntimeError } from '@moonshot-ai/agent-core-v2/runtime/runtimeRegistry import { fsDiffRequestSchema, fsGitStatusRequestSchema, + fsPullRequestRequestSchema, fsGrepRequestSchema, fsListManyRequestSchema, fsListRequestSchema, @@ -133,6 +134,7 @@ const FS_ACTIONS = [ 'search', 'grep', 'git_status', + 'pull_request', 'diff', 'open', 'open-in', @@ -433,6 +435,9 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { case 'git_status': await handleGitStatus(runtimeFs.fs, req, reply); return; + case 'pull_request': + await handlePullRequest(runtimeFs.fs, req, reply); + return; case 'diff': await handleDiff(runtimeFs.fs, req, reply); return; @@ -936,6 +941,16 @@ async function handleGitStatus(fs: IWorkspaceFsService, req: Req, reply: Reply): reply.send(okEnvelope(data, req.id)); } +async function handlePullRequest(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsPullRequestRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.pullRequest(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + async function handleDiff(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { const parsed = fsDiffRequestSchema.safeParse(req.body ?? {}); if (!parsed.success) { diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index a1edaecd778..f88ae198676 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -576,7 +576,8 @@ describe('server-v2 /api/v1 fs routes', () => { const id = await createWorkspaceSession(work!, [extra]); const primary = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: work }); - const additional = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: extra }); + const additional = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: extra, include_pull_request: false }); + const pullRequest = await postFs(id, 'pull_request', { root: extra }); const additionalDiff = await postFs<{ path: string; diff: string }>(id, 'diff', { root: extra, path: 'extra.txt' }); const fileRoot = await postFs(id, 'git_status', { root: 'main.txt' }); const outside = await postFs(id, 'git_status', { root: tmpdir() }); @@ -588,6 +589,8 @@ describe('server-v2 /api/v1 fs routes', () => { expect(additional.data.branch).toBe('main'); expect(additional.data.entries).toEqual({ 'extra.txt': 'modified' }); expect(additional.data.additions).toBe(1); + expect(pullRequest.code).toBe(0); + expect(pullRequest.data).toBeNull(); expect(additionalDiff.code).toBe(0); expect(additionalDiff.data.path).toBe('extra.txt'); expect(additionalDiff.data.diff).toContain('+two'); diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index c32044eb9f2..782f4ed9a1e 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -150,6 +150,7 @@ const gitStub: IGitService = { pullRequest: preset.pullRequest, }; }, + pullRequest: async (cwd: string) => gitState.responses.get(cwd)?.pullRequest ?? null, diff: async () => { throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, 'not used in these tests'); }, From 548e1253c4c784e729a9708a75c162a8614ff359 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 29 Oct 2026 23:00:17 +0800 Subject: [PATCH 4/9] feat: add batched folder git queries --- .changeset/folder-scoped-file-diffs.md | 2 +- packages/agent-core-v2/src/app/git/git.ts | 29 ++++++++++ .../src/workspace/workspaceFs/fs.ts | 25 ++++++++- .../src/workspace/workspaceFs/fsService.ts | 42 +++++++++++++++ .../workspace/workspaceFs/fsService.test.ts | 53 +++++++++++++++++++ packages/kap-server/src/routes/fs.ts | 34 +++++++++++- packages/kap-server/test/fs.test.ts | 7 +++ 7 files changed, 188 insertions(+), 4 deletions(-) diff --git a/.changeset/folder-scoped-file-diffs.md b/.changeset/folder-scoped-file-diffs.md index 4b82ac0c8b0..77b4c084550 100644 --- a/.changeset/folder-scoped-file-diffs.md +++ b/.changeset/folder-scoped-file-diffs.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Add folder-scoped file diffs and non-blocking pull request lookups for multi-root workspace panels. +Add batched, folder-scoped Git and pull request queries for multi-root workspace panels. diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index 91c913eeb4c..3db440ffb50 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -54,6 +54,35 @@ export const fsGitStatusResponseSchema = z.object({ }); export type FsGitStatusResponse = z.infer; +export const fsBatchErrorSchema = z.object({ + code: z.number().int(), + msg: z.string(), +}); +export type FsBatchError = z.infer; + +export const fsGitStatusBatchRequestSchema = z.object({ + roots: z.array(z.string().min(1)).min(1).max(32), + include_pull_request: z.boolean().optional(), +}); +export type FsGitStatusBatchRequest = z.infer; + +export const fsGitStatusBatchResponseSchema = z.object({ + results: z.record(z.string(), fsGitStatusResponseSchema), + errors: z.record(z.string(), fsBatchErrorSchema), +}); +export type FsGitStatusBatchResponse = z.infer; + +export const fsPullRequestBatchRequestSchema = z.object({ + roots: z.array(z.string().min(1)).min(1).max(32), +}); +export type FsPullRequestBatchRequest = z.infer; + +export const fsPullRequestBatchResponseSchema = z.object({ + results: z.record(z.string(), fsPullRequestSchema.nullable()), + errors: z.record(z.string(), fsBatchErrorSchema), +}); +export type FsPullRequestBatchResponse = z.infer; + export const fsDiffRequestSchema = z.object({ path: z.string().min(1), root: z.string().min(1).optional(), diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts index bd227c5276d..a1f3c882551 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts @@ -8,8 +8,13 @@ import { type FsDiffResponse, type FsGitStatusRequest, type FsGitStatusResponse, + type FsGitStatusBatchRequest, + type FsGitStatusBatchResponse, + type FsBatchError, type FsPullRequest, type FsPullRequestRequest, + type FsPullRequestBatchRequest, + type FsPullRequestBatchResponse, } from '#/app/git/git'; export { @@ -17,10 +22,26 @@ export { fsDiffResponseSchema, fsGitStatusRequestSchema, fsGitStatusResponseSchema, + fsGitStatusBatchRequestSchema, + fsGitStatusBatchResponseSchema, fsPullRequestRequestSchema, fsPullRequestSchema, + fsPullRequestBatchRequestSchema, + fsPullRequestBatchResponseSchema, } from '#/app/git/git'; -export type { FsDiffRequest, FsDiffResponse, FsGitStatusRequest, FsGitStatusResponse, FsPullRequest, FsPullRequestRequest }; +export type { + FsDiffRequest, + FsDiffResponse, + FsGitStatusRequest, + FsGitStatusResponse, + FsGitStatusBatchRequest, + FsGitStatusBatchResponse, + FsBatchError, + FsPullRequest, + FsPullRequestRequest, + FsPullRequestBatchRequest, + FsPullRequestBatchResponse, +}; export const fsKindSchema = z.enum(['file', 'directory', 'symlink']); export type FsKind = z.infer; @@ -265,7 +286,9 @@ export interface IWorkspaceFsService { suggest(req: FsSuggestRequest): Promise; grep(req: FsGrepRequest): Promise; gitStatus(req: FsGitStatusRequest): Promise; + gitStatusBatch(req: FsGitStatusBatchRequest): Promise; pullRequest(req: FsPullRequestRequest): Promise; + pullRequestBatch(req: FsPullRequestBatchRequest): Promise; diff(req: FsDiffRequest): Promise; resolvePath(relPath: string): Promise; resolveDownload(relPath: string, allowExternalSymlinks?: boolean): Promise; diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index b3914b497e3..936652596a3 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -4,8 +4,13 @@ import { type FsEntry, type FsGitStatusRequest, type FsGitStatusResponse, + type FsGitStatusBatchRequest, + type FsGitStatusBatchResponse, + type FsBatchError, type FsPullRequest, type FsPullRequestRequest, + type FsPullRequestBatchRequest, + type FsPullRequestBatchResponse, type FsGrepFileHit, type FsGrepMatch, type FsGrepRequest, @@ -1041,11 +1046,41 @@ export class WorkspaceFsService implements IWorkspaceFsService { }); } + gitStatusBatch(req: FsGitStatusBatchRequest): Promise { + return this.batchGitValues(req.roots, (root) => this.gitStatus({ root, include_pull_request: req.include_pull_request })); + } + + pullRequestBatch(req: FsPullRequestBatchRequest): Promise { + return this.batchGitValues(req.roots, (root) => this.pullRequest({ root })); + } + async pullRequest(req: FsPullRequestRequest): Promise { const cwd = await this.resolveGitRoot(req.root); return this.git.pullRequest(cwd); } + private async batchGitValues( + inputRoots: readonly string[], + read: (root: string) => Promise, + ): Promise<{ results: Record; errors: Record }> { + const roots = [...new Set(inputRoots)]; + const results: Record = {}; + const errors: Record = {}; + let cursor = 0; + const worker = async (): Promise => { + while (cursor < roots.length) { + const root = roots[cursor++]!; + try { + results[root] = await read(root); + } catch (error) { + errors[root] = toGitBatchError(error); + } + } + }; + await Promise.all(Array.from({ length: Math.min(2, roots.length) }, () => worker())); + return { results, errors }; + } + async diff(req: FsDiffRequest): Promise { const cwd = await this.resolveGitRoot(req.root); const inputPath = req.root !== undefined && !this.path.isAbsolute(req.path) ? this.path.join(cwd, req.path) : req.path; @@ -1616,6 +1651,13 @@ function mapFsError(err: unknown, inputPath: string): Error { return err instanceof Error ? err : new Error(String(err)); } +function toGitBatchError(err: unknown): FsBatchError { + if (err instanceof Error2 && err.code === ErrorCodes.FS_GIT_UNAVAILABLE) { + return { code: 40908, msg: err.message }; + } + return toWireError(err); +} + function toWireError(err: unknown): { code: number; msg: string } { if (err instanceof Error2) { switch (err.code) { diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index c76996131b8..82a57604e7a 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -505,6 +505,59 @@ describe('WorkspaceFsService.gitStatus', () => { }); }); +describe('WorkspaceFsService git batches', () => { + it('returns independent results and errors with bounded concurrency', async () => { + let active = 0; + let maxActive = 0; + let statusCalls = 0; + const optionsSeen: Array<{ includePullRequest?: boolean } | undefined> = []; + const git: IGitService = { + _serviceBrand: undefined, + status: async (cwd, options) => { + statusCalls += 1; + optionsSeen.push(options); + active += 1; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + active -= 1; + if (cwd === '/b') throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, 'not a repo'); + return { branch: `branch-${cwd.slice(1)}`, ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0, pullRequest: null }; + }, + pullRequest: async (cwd) => { + if (cwd === '/b') throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, 'not a repo'); + return cwd === '/a' ? { number: 7, state: 'open', url: 'https://example.com/pull/7' } : null; + }, + diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, + }; + const fs = makeSession( + { '/a/placeholder': '', '/b/placeholder': '', '/c/placeholder': '' }, + emptyHandler, + [], + git, + [], + undefined, + {}, + ['/a', '/b', '/c'], + ); + + const status = await fs.gitStatusBatch({ roots: ['/a', '/b', '/c', '/a'], include_pull_request: false }); + + expect(statusCalls).toBe(3); + expect(maxActive).toBeLessThanOrEqual(2); + expect(optionsSeen.every(options => options?.includePullRequest === false)).toBe(true); + expect(Object.keys(status.results)).toEqual(['/a', '/c']); + expect(status.results['/a']?.branch).toBe('branch-a'); + expect(status.results['/c']?.branch).toBe('branch-c'); + expect(status.errors['/b']).toEqual({ code: 40908, msg: 'not a repo' }); + + const pullRequests = await fs.pullRequestBatch({ roots: ['/a', '/b', '/c'] }); + expect(pullRequests.results['/a']).toEqual({ number: 7, state: 'open', url: 'https://example.com/pull/7' }); + expect(pullRequests.results['/c']).toBeNull(); + expect(pullRequests.errors['/b']).toEqual({ code: 40908, msg: 'not a repo' }); + }); +}); + describe('WorkspaceFsService.diff', () => { it('delegates to IWorkspaceGitService with confined rel and abs paths', async () => { const calls: Array<{ cwd: string; rel: string; abs: string }> = []; diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 5add81a4fc2..5ca02e8b45d 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -24,7 +24,9 @@ import { RuntimeError } from '@moonshot-ai/agent-core-v2/runtime/runtimeRegistry import { fsDiffRequestSchema, fsGitStatusRequestSchema, + fsGitStatusBatchRequestSchema, fsPullRequestRequestSchema, + fsPullRequestBatchRequestSchema, fsGrepRequestSchema, fsListManyRequestSchema, fsListRequestSchema, @@ -134,7 +136,9 @@ const FS_ACTIONS = [ 'search', 'grep', 'git_status', + 'git_status:batch', 'pull_request', + 'pull_request:batch', 'diff', 'open', 'open-in', @@ -355,7 +359,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { [ErrorCode.FS_ALREADY_EXISTS]: {}, }, description: - 'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, mkdir, search, grep, git_status, diff, open, open-in, reveal.', + 'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, mkdir, search, grep, git_status, git_status:batch, pull_request, pull_request:batch, diff, open, open-in, reveal.', tags: ['fs'], operationId: 'fsAction', }, @@ -390,7 +394,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { const runtimeId = runtime_id ?? 'local'; req.body = request; const required: RuntimeCapability[] = ['fs']; - if (fsAction === 'search' || fsAction === 'grep' || fsAction === 'git_status' || fsAction === 'diff') { + if (fsAction === 'search' || fsAction === 'grep' || fsAction === 'git_status' || fsAction === 'git_status:batch' || fsAction === 'pull_request' || fsAction === 'pull_request:batch' || fsAction === 'diff') { required.push('process'); } runtimeFs = session === undefined && fsAction === 'search' @@ -435,9 +439,15 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { case 'git_status': await handleGitStatus(runtimeFs.fs, req, reply); return; + case 'git_status:batch': + await handleGitStatusBatch(runtimeFs.fs, req, reply); + return; case 'pull_request': await handlePullRequest(runtimeFs.fs, req, reply); return; + case 'pull_request:batch': + await handlePullRequestBatch(runtimeFs.fs, req, reply); + return; case 'diff': await handleDiff(runtimeFs.fs, req, reply); return; @@ -941,6 +951,26 @@ async function handleGitStatus(fs: IWorkspaceFsService, req: Req, reply: Reply): reply.send(okEnvelope(data, req.id)); } +async function handleGitStatusBatch(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsGitStatusBatchRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.gitStatusBatch(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handlePullRequestBatch(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsPullRequestBatchRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.pullRequestBatch(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + async function handlePullRequest(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { const parsed = fsPullRequestRequestSchema.safeParse(req.body ?? {}); if (!parsed.success) { diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index f88ae198676..8e2124bf017 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -578,6 +578,8 @@ describe('server-v2 /api/v1 fs routes', () => { const primary = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: work }); const additional = await postFs<{ branch: string; entries: Record; additions: number }>(id, 'git_status', { root: extra, include_pull_request: false }); const pullRequest = await postFs(id, 'pull_request', { root: extra }); + const batch = await postFs<{ results: Record }> }>(id, 'git_status:batch', { roots: [work, extra], include_pull_request: false }); + const prBatch = await postFs<{ results: Record }>(id, 'pull_request:batch', { roots: [work, extra] }); const additionalDiff = await postFs<{ path: string; diff: string }>(id, 'diff', { root: extra, path: 'extra.txt' }); const fileRoot = await postFs(id, 'git_status', { root: 'main.txt' }); const outside = await postFs(id, 'git_status', { root: tmpdir() }); @@ -591,6 +593,11 @@ describe('server-v2 /api/v1 fs routes', () => { expect(additional.data.additions).toBe(1); expect(pullRequest.code).toBe(0); expect(pullRequest.data).toBeNull(); + expect(batch.code).toBe(0); + expect(batch.data.results[work!]?.entries).toEqual({ 'main.txt': 'modified' }); + expect(batch.data.results[extra]?.entries).toEqual({ 'extra.txt': 'modified' }); + expect(prBatch.code).toBe(0); + expect(prBatch.data.results).toEqual({ [work!]: null, [extra]: null }); expect(additionalDiff.code).toBe(0); expect(additionalDiff.data.path).toBe('extra.txt'); expect(additionalDiff.data.diff).toContain('+two'); From 100397ab32a32d12ea4f8e3be5c8560949d7ac85 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 30 Oct 2026 13:30:17 +0800 Subject: [PATCH 5/9] chore: update folder query changeset --- .changeset/folder-scoped-file-diffs.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/folder-scoped-file-diffs.md b/.changeset/folder-scoped-file-diffs.md index 77b4c084550..8b7a0953c6a 100644 --- a/.changeset/folder-scoped-file-diffs.md +++ b/.changeset/folder-scoped-file-diffs.md @@ -1,5 +1,6 @@ --- -"@moonshot-ai/kimi-code": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kap-server": patch --- Add batched, folder-scoped Git and pull request queries for multi-root workspace panels. From a9c4e95a0e7e105c97d4c817800217157122a87b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 30 Oct 2026 14:21:50 +0800 Subject: [PATCH 6/9] test: pass local root to git status --- packages/agent-core-v2/test/program/program.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-core-v2/test/program/program.test.ts b/packages/agent-core-v2/test/program/program.test.ts index 0db0ad74b0e..0b23062f57b 100644 --- a/packages/agent-core-v2/test/program/program.test.ts +++ b/packages/agent-core-v2/test/program/program.test.ts @@ -614,7 +614,7 @@ describe('Program local generation and remote workdirs', () => { expect(fixture.profileRegistry.entries().find((entry) => entry.sourceId === 'workspace')?.contextKey) .toBe(environmentBindingId('local', fixture.localRoot)); - await local.git.status(); + await local.git.status(fixture.localRoot); expect(fixture.localGitCalls).toEqual([fixture.localRoot]); const listed = await local.fs.list({ path: '.', depth: 1, limit: 100, show_hidden: false, follow_gitignore: false, sort: 'name_asc', include_git_status: false }); From 62658a9134bca351c387a5f71f1a7d04a4aa590d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 30 Oct 2026 15:12:20 +0800 Subject: [PATCH 7/9] fix: correct folder-scoped git queries --- docs/en/reference/server-api.md | 59 +++++++++++++++++-- docs/zh/reference/server-api.md | 59 +++++++++++++++++-- .../agent-core-v2/src/app/git/gitParsers.ts | 6 +- .../agent-core-v2/src/app/git/gitService.ts | 10 +++- .../src/workspace/workspaceFs/fsService.ts | 40 ++++++++++--- .../workspace/workspaceGit/workspaceGit.ts | 8 ++- .../workspaceGit/workspaceGitService.ts | 14 ++++- .../test/app/git/gitService.test.ts | 8 ++- .../test/program/program.test.ts | 2 +- .../workspace/workspaceFs/fsService.test.ts | 52 +++++++++++++++- 10 files changed, 227 insertions(+), 31 deletions(-) diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index a6485706639..a4eb73eb75d 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -1709,7 +1709,7 @@ On success, `data` is `{ trusted }`, containing the effective trust state after ### File system -In-session file operations go through `POST /api/v1/sessions/{session_id}/fs:{action}` with JSON bodies; actions are `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `diff` / `open` / `open-in` / `reveal`. Actions run on the environment the session was created on; `open`, `open-in`, and `reveal` only work for sessions on the `local` environment. In addition: +In-session file operations go through `POST /api/v1/sessions/{session_id}/fs:{action}` with JSON bodies; actions are `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `git_status:batch` / `pull_request` / `pull_request:batch` / `diff` / `open` / `open-in` / `reveal`. Actions run on the environment the session was created on; `open`, `open-in`, and `reveal` only work for sessions on the `local` environment. In addition: | Method and path | Description | | --- | --- | @@ -1874,27 +1874,76 @@ On success, `data` is `{ files, files_scanned, truncated, elapsed_ms }` where ea #### `POST /api/v1/sessions/{session_id}/fs:git_status` -Reads the git status of the session workspace, optionally restricted to a set of paths. +Reads the git status of a session workspace directory, optionally restricted to a set of paths. Entry paths are relative to the selected directory. | Parameter | In | Type | Description | | --- | --- | --- | --- | | `session_id` | path | string | **Required.** Session id | -| `paths` | body | string[] | Restrict the status to these paths; omitted means the whole workspace | +| `root` | body | string | Workspace directory to inspect. Defaults to the session work directory; a relative value is resolved from it | +| `paths` | body | string[] | Restrict the status to these paths, relative to `root`; omitted means the whole selected directory | +| `include_pull_request` | body | boolean | Include the associated pull request. Default `true` | On success, `data` is `{ branch, ahead, behind, entries, additions, deletions, pullRequest }` where `entries` maps each changed path to its status (`clean` / `modified` / `added` / `deleted` / `renamed` / `untracked` / `ignored` / `conflicted`) and `pullRequest` is `{ number, state, url }` (`state` is `open` / `merged` / `closed` / `draft`) or `null`. - `40001`: body validation failure - `40401`: session not found - `40908`: git is unavailable (not a repository, or no git binary) +- `41304`: path escapes the session workspace + +#### `POST /api/v1/sessions/{session_id}/fs:git_status:batch` + +Reads git status for several workspace directories in one call. A failing directory folds into `errors` instead of failing the whole request. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `session_id` | path | string | **Required.** Session id | +| `roots` | body | string[] | **Required.** Workspace directories to inspect, 1–32 entries | +| `include_pull_request` | body | boolean | Include the associated pull request in each result. Default `true` | + +On success, `data` is `{ results, errors }`. `results` maps each successful root to the same status object as `fs:git_status`; `errors` maps each failed root to its `{ code, msg }` error, using the same error codes as the single-directory action. + +- `40001`: body validation failure +- `40401`: session not found + +#### `POST /api/v1/sessions/{session_id}/fs:pull_request` + +Reads the pull request associated with a session workspace directory. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `session_id` | path | string | **Required.** Session id | +| `root` | body | string | Workspace directory to inspect. Defaults to the session work directory; a relative value is resolved from it | + +On success, `data` is `{ number, state, url }` (`state` is `open` / `merged` / `closed` / `draft`) or `null` when no pull request is found. + +- `40001`: body validation failure +- `40401`: session not found +- `40908`: git is unavailable (not a repository, or no git binary) +- `41304`: path escapes the session workspace + +#### `POST /api/v1/sessions/{session_id}/fs:pull_request:batch` + +Reads pull requests for several workspace directories in one call. A failing directory folds into `errors` instead of failing the whole request. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `session_id` | path | string | **Required.** Session id | +| `roots` | body | string[] | **Required.** Workspace directories to inspect, 1–32 entries | + +On success, `data` is `{ results, errors }`. `results` maps each successful root to the same pull request object as `fs:pull_request`, or `null`; `errors` maps each failed root to its `{ code, msg }` error. + +- `40001`: body validation failure +- `40401`: session not found #### `POST /api/v1/sessions/{session_id}/fs:diff` -Returns the unified git diff of one file in the session workspace. +Returns the unified git diff of one file in a session workspace directory. | Parameter | In | Type | Description | | --- | --- | --- | --- | | `session_id` | path | string | **Required.** Session id | -| `path` | body | string | **Required.** File to diff, relative to the session work directory | +| `path` | body | string | **Required.** File to diff, relative to `root` | +| `root` | body | string | Workspace directory to inspect. Defaults to the session work directory; a relative value is resolved from it | On success, `data` is `{ path, diff, truncated }` where `diff` is the unified diff text and `truncated` reports an over-long diff cut short. diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index b58b13a5bb4..37ad50b94f2 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -1712,7 +1712,7 @@ schema 还接受共享消息格式中的 `tool_use`、`tool_result` 和 `thinkin ### 文件系统 -会话内文件操作走 `POST /api/v1/sessions/{session_id}/fs:{action}`,请求体为 JSON;动作包括 `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `diff` / `open` / `open-in` / `reveal`。动作在会话创建时所在的环境上执行;`open`、`open-in` 与 `reveal` 仅对 `local` 环境上的会话可用。另有: +会话内文件操作走 `POST /api/v1/sessions/{session_id}/fs:{action}`,请求体为 JSON;动作包括 `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `git_status:batch` / `pull_request` / `pull_request:batch` / `diff` / `open` / `open-in` / `reveal`。动作在会话创建时所在的环境上执行;`open`、`open-in` 与 `reveal` 仅对 `local` 环境上的会话可用。另有: | 方法与路径 | 说明 | | --- | --- | @@ -1877,27 +1877,76 @@ schema 还接受共享消息格式中的 `tool_use`、`tool_result` 和 `thinkin #### `POST /api/v1/sessions/{session_id}/fs:git_status` -读取会话工作区的 git 状态,可选限定在一组路径内。 +读取会话工作区中指定目录的 git 状态,可选限定在一组路径内。返回条目的路径相对于所选目录。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `session_id` | path | string | **必填。** 会话 id | -| `paths` | body | string[] | 将状态限定在这些路径;省略表示整个工作区 | +| `root` | body | string | 要查询的工作区目录,默认是会话工作目录;相对路径从会话工作目录解析 | +| `paths` | body | string[] | 将状态限定在这些相对于 `root` 的路径;省略表示整个所选目录 | +| `include_pull_request` | body | boolean | 是否包含关联的 Pull Request。默认 `true` | 成功时 `data` 为 `{ branch, ahead, behind, entries, additions, deletions, pullRequest }`,其中 `entries` 把每个变更路径映射到其状态(`clean` / `modified` / `added` / `deleted` / `renamed` / `untracked` / `ignored` / `conflicted`),`pullRequest` 为 `{ number, state, url }`(`state` 为 `open` / `merged` / `closed` / `draft`)或 `null`。 - `40001`:请求体校验失败 - `40401`:会话不存在 - `40908`:git 不可用(不是仓库,或没有 git 可执行文件) +- `41304`:路径越出会话工作区 + +#### `POST /api/v1/sessions/{session_id}/fs:git_status:batch` + +一次调用读取多个工作区目录的 git 状态;失败的目录会折进 `errors`,而不是让整个请求失败。 + +| 参数 | 位置 | 类型 | 说明 | +| --- | --- | --- | --- | +| `session_id` | path | string | **必填。** 会话 id | +| `roots` | body | string[] | **必填。** 要查询的工作区目录,1–32 条 | +| `include_pull_request` | body | boolean | 是否在每项结果中包含关联的 Pull Request。默认 `true` | + +成功时 `data` 为 `{ results, errors }`。`results` 把每个成功的 root 映射到与 `fs:git_status` 相同的状态对象;`errors` 把每个失败的 root 映射到其 `{ code, msg }` 错误,错误码与单目录动作一致。 + +- `40001`:请求体校验失败 +- `40401`:会话不存在 + +#### `POST /api/v1/sessions/{session_id}/fs:pull_request` + +读取会话工作区中指定目录关联的 Pull Request。 + +| 参数 | 位置 | 类型 | 说明 | +| --- | --- | --- | --- | +| `session_id` | path | string | **必填。** 会话 id | +| `root` | body | string | 要查询的工作区目录,默认是会话工作目录;相对路径从会话工作目录解析 | + +成功时 `data` 为 `{ number, state, url }`(`state` 为 `open` / `merged` / `closed` / `draft`);未找到 Pull Request 时为 `null`。 + +- `40001`:请求体校验失败 +- `40401`:会话不存在 +- `40908`:git 不可用(不是仓库,或没有 git 可执行文件) +- `41304`:路径越出会话工作区 + +#### `POST /api/v1/sessions/{session_id}/fs:pull_request:batch` + +一次调用读取多个工作区目录关联的 Pull Request;失败的目录会折进 `errors`,而不是让整个请求失败。 + +| 参数 | 位置 | 类型 | 说明 | +| --- | --- | --- | --- | +| `session_id` | path | string | **必填。** 会话 id | +| `roots` | body | string[] | **必填。** 要查询的工作区目录,1–32 条 | + +成功时 `data` 为 `{ results, errors }`。`results` 把每个成功的 root 映射到与 `fs:pull_request` 相同的对象或 `null`;`errors` 把每个失败的 root 映射到其 `{ code, msg }` 错误。 + +- `40001`:请求体校验失败 +- `40401`:会话不存在 #### `POST /api/v1/sessions/{session_id}/fs:diff` -返回会话工作区内单个文件的 unified git diff。 +返回会话工作区中指定目录内单个文件的 unified git diff。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `session_id` | path | string | **必填。** 会话 id | -| `path` | body | string | **必填。** 要 diff 的文件,相对于会话工作目录 | +| `path` | body | string | **必填。** 要 diff 的文件,相对于 `root` | +| `root` | body | string | 要查询的工作区目录,默认是会话工作目录;相对路径从会话工作目录解析 | 成功时 `data` 为 `{ path, diff, truncated }`,其中 `diff` 为 unified diff 文本,`truncated` 表示过长的 diff 被截断。 diff --git a/packages/agent-core-v2/src/app/git/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index bfd373ca21b..25922767ba6 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -3,6 +3,7 @@ import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from './git'; export function parsePorcelain( stdout: string, filter: ReadonlySet | undefined, + pathPrefix = '', ): FsGitStatusResponse { const records = stdout.split('\0'); let branch = ''; @@ -23,7 +24,10 @@ export function parsePorcelain( if (record.length < 4) continue; const xy = record.slice(0, 2); - const wirePath = record.slice(3); + const repoPath = record.slice(3); + const wirePath = pathPrefix !== '' && repoPath.startsWith(pathPrefix) + ? repoPath.slice(pathPrefix.length) + : repoPath; if (xy.startsWith('R') || xy.startsWith('C')) { i++; diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index 731582e9132..c3f3b55f518 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -35,6 +35,14 @@ export class GitService implements IGitService { throw this.gitUnavailable(cwd, inside.stderr.trim() || `git rev-parse exit ${inside.exitCode}`); } + let pathPrefix = ''; + if (options.scopeToCwd === true) { + const prefix = await this.runCommand('git', ['rev-parse', '--show-prefix'], cwd); + if (prefix.exitCode !== 0) { + throw this.gitUnavailable(cwd, prefix.stderr.trim() || `git rev-parse exit ${prefix.exitCode}`); + } + pathPrefix = prefix.stdout.trim(); + } const statusArgs = ['status', '--porcelain=v1', '--branch', '-z']; if (options.scopeToCwd === true) statusArgs.push('--', '.'); const porc = await this.runCommand('git', statusArgs, cwd); @@ -42,7 +50,7 @@ export class GitService implements IGitService { throw this.gitUnavailable(cwd, porc.stderr.trim() || `git status exit ${porc.exitCode}`); } - const result = parsePorcelain(porc.stdout, options.pathFilter); + const result = parsePorcelain(porc.stdout, options.pathFilter, pathPrefix); const dirty = porc.stdout .split('\0') diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index d2ab56edb90..ba243933d13 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -35,11 +35,16 @@ import { } from './fs'; const FsWireErrorCode = { + VALIDATION_FAILED: 40001, FS_PATH_NOT_FOUND: 40409, + ENVIRONMENT_NOT_FOUND: 40420, + SESSION_BUSY: 40901, FS_IS_DIRECTORY: 40906, FS_IS_BINARY: 40907, + ENVIRONMENT_UNAVAILABLE: 40926, FS_TOO_LARGE: 41302, FS_TOO_MANY_RESULTS: 41303, + FS_PATH_ESCAPES_SESSION: 41304, INTERNAL_ERROR: 50001, } as const; import ignore, { type Ignore } from 'ignore'; @@ -53,6 +58,8 @@ import { guessMime, } from '#/_base/utils/fileMeta'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; +import { EnvironmentError } from '#/environment/environmentRegistry'; +import { HandshakeError } from '#/remote/client/connection'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem'; import { isHostFsNotDirectory, isHostFsNotFound } from '#/os/interface/hostFsErrors'; @@ -1080,7 +1087,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { } const cwd = await this.resolveGitRoot(req.root); - return this.git.status(cwd, { + return this.git.statusAt(cwd, { pathFilter: filter, scopeToCwd: req.root !== undefined, includePullRequest: req.include_pull_request !== false, @@ -1097,7 +1104,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { async pullRequest(req: FsPullRequestRequest): Promise { const cwd = await this.resolveGitRoot(req.root); - return this.git.pullRequest(cwd); + return this.git.pullRequestAt(cwd); } private async batchGitValues( @@ -1105,8 +1112,8 @@ export class WorkspaceFsService implements IWorkspaceFsService { read: (root: string) => Promise, ): Promise<{ results: Record; errors: Record }> { const roots = [...new Set(inputRoots)]; - const results: Record = {}; - const errors: Record = {}; + const results = Object.create(null) as Record; + const errors = Object.create(null) as Record; let cursor = 0; const worker = async (): Promise => { while (cursor < roots.length) { @@ -1132,7 +1139,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { details: { cwd, path: req.path }, }); } - return this.git.diff(cwd, rel, abs); + return this.git.diffAt(cwd, rel, abs); } private async resolveGitRoot(root?: string): Promise { @@ -1713,8 +1720,27 @@ function mapFsError(err: unknown, inputPath: string): Error { } function toGitBatchError(err: unknown): FsBatchError { - if (err instanceof Error2 && err.code === ErrorCodes.FS_GIT_UNAVAILABLE) { - return { code: 40908, msg: err.message }; + if (err instanceof EnvironmentError) { + switch (err.code) { + case 'environment.not_found': + return { code: FsWireErrorCode.ENVIRONMENT_NOT_FOUND, msg: err.message }; + case 'environment.invalid_cwd': + return { code: FsWireErrorCode.VALIDATION_FAILED, msg: err.message }; + case 'environment.conflict': + return { code: FsWireErrorCode.SESSION_BUSY, msg: err.message }; + case 'environment.unavailable': + case 'environment.capability_unavailable': + return { code: FsWireErrorCode.ENVIRONMENT_UNAVAILABLE, msg: err.message }; + } + } + if (err instanceof HandshakeError) { + return { code: FsWireErrorCode.ENVIRONMENT_UNAVAILABLE, msg: err.message }; + } + if (err instanceof Error2) { + if (err.code === ErrorCodes.FS_GIT_UNAVAILABLE) return { code: 40908, msg: err.message }; + if (err.code === ErrorCodes.FS_PATH_ESCAPES) { + return { code: FsWireErrorCode.FS_PATH_ESCAPES_SESSION, msg: err.message }; + } } return toWireError(err); } diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts index ba07f8e4902..7da2d43ed5c 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts @@ -4,9 +4,11 @@ import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest, GitStatusOptio export interface IWorkspaceGitService { readonly _serviceBrand: undefined; - status(cwd: string, options?: GitStatusOptions): Promise; - pullRequest(cwd: string): Promise; - diff(cwd: string, relPath: string, absPath: string): Promise; + status(pathFilter?: ReadonlySet): Promise; + statusAt(cwd: string, options?: GitStatusOptions): Promise; + pullRequestAt(cwd: string): Promise; + diff(relPath: string, absPath: string): Promise; + diffAt(cwd: string, relPath: string, absPath: string): Promise; } export const IWorkspaceGitService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts index 4d609591a24..e01b38a8814 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts @@ -12,15 +12,23 @@ export class WorkspaceGitService implements IWorkspaceGitService { @ref(IGitService) private readonly git: LiveRef, ) {} - status(cwd: string, options?: GitStatusOptions): Promise { + status(pathFilter?: ReadonlySet): Promise { + return this.git.current!.status(this.workspace.cwd, { pathFilter }); + } + + statusAt(cwd: string, options?: GitStatusOptions): Promise { return this.git.current!.status(cwd, options); } - pullRequest(cwd: string): Promise { + pullRequestAt(cwd: string): Promise { return this.git.current!.pullRequest(cwd); } - diff(cwd: string, relPath: string, absPath: string): Promise { + diff(relPath: string, absPath: string): Promise { + return this.git.current!.diff(this.workspace.cwd, relPath, absPath); + } + + diffAt(cwd: string, relPath: string, absPath: string): Promise { return this.git.current!.diff(cwd, relPath, absPath); } } diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index 0c5084be5c7..73f256c428e 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -133,9 +133,13 @@ describe('GitService', () => { writeFileSync(join(repo, 'a/file.txt'), 'one\ntwo\n'); writeFileSync(join(repo, 'b/file.txt'), 'one\ntwo\nthree\n'); - const result = await service.status(join(repo, 'a'), { scopeToCwd: true }); + const result = await service.status(join(repo, 'a'), { + scopeToCwd: true, + pathFilter: new Set(['file.txt']), + includePullRequest: false, + }); - expect(result.entries).toEqual({ 'a/file.txt': 'modified' }); + expect(result.entries).toEqual({ 'file.txt': 'modified' }); expect(result.additions).toBe(1); expect(result.deletions).toBe(0); }); diff --git a/packages/agent-core-v2/test/program/program.test.ts b/packages/agent-core-v2/test/program/program.test.ts index 0b23062f57b..0db0ad74b0e 100644 --- a/packages/agent-core-v2/test/program/program.test.ts +++ b/packages/agent-core-v2/test/program/program.test.ts @@ -614,7 +614,7 @@ describe('Program local generation and remote workdirs', () => { expect(fixture.profileRegistry.entries().find((entry) => entry.sourceId === 'workspace')?.contextKey) .toBe(environmentBindingId('local', fixture.localRoot)); - await local.git.status(fixture.localRoot); + await local.git.status(); expect(fixture.localGitCalls).toEqual([fixture.localRoot]); const listed = await local.fs.list({ path: '.', depth: 1, limit: 100, show_hidden: false, follow_gitignore: false, sort: 'name_asc', include_git_status: false }); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index 5701469af44..1d65f54809a 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -11,6 +11,8 @@ import { import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { IGitService } from '#/app/git/git'; import { ErrorCodes, Error2 } from '#/errors'; +import { EnvironmentError } from '#/environment/environmentRegistry'; +import { HandshakeError } from '#/remote/client/connection'; import { type HostDirEntry, IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError } from '#/os/interface/hostFsErrors'; import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess'; @@ -57,9 +59,11 @@ function stubWorkspaceDirs(additionalDirs: readonly string[] = []): IWorkspaceDi function workspaceGitStub(git: IGitService): IWorkspaceGitService { return { _serviceBrand: undefined, - status: (cwd, options) => git.status(cwd, options), - pullRequest: (cwd) => git.pullRequest(cwd), - diff: (cwd, rel, abs) => git.diff(cwd, rel, abs), + status: (pathFilter) => git.status(WORK_DIR, { pathFilter }), + statusAt: (cwd, options) => git.status(cwd, options), + pullRequestAt: (cwd) => git.pullRequest(cwd), + diff: (rel, abs) => git.diff(WORK_DIR, rel, abs), + diffAt: (cwd, rel, abs) => git.diff(cwd, rel, abs), }; } @@ -589,6 +593,48 @@ describe('WorkspaceFsService git batches', () => { expect(pullRequests.results['/c']).toBeNull(); expect(pullRequests.errors['/b']).toEqual({ code: 40908, msg: 'not a repo' }); }); + + it('preserves special root names and established batch error codes', async () => { + const git: IGitService = { + _serviceBrand: undefined, + status: async (cwd) => { + if (cwd === '/missing-env') throw new EnvironmentError('environment.not_found', 'missing'); + if (cwd === '/invalid-cwd') throw new EnvironmentError('environment.invalid_cwd', 'invalid'); + if (cwd === '/conflict') throw new EnvironmentError('environment.conflict', 'conflict'); + if (cwd === '/unavailable') throw new EnvironmentError('environment.unavailable', 'unavailable'); + if (cwd === '/handshake') throw new HandshakeError('handshake'); + return { branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0, pullRequest: null }; + }, + pullRequest: async () => null, + diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, + }; + const roots = ['__proto__', '/missing-env', '/invalid-cwd', '/conflict', '/unavailable', '/handshake']; + const files = Object.fromEntries(roots.filter(root => root.startsWith('/')).map(root => [`${root}/placeholder`, ''])); + const fs = makeSession( + { '__proto__/placeholder': '', ...files }, + emptyHandler, + [], + git, + [], + undefined, + {}, + roots.filter(root => root.startsWith('/')), + ); + + const status = await fs.gitStatusBatch({ roots: [...roots, '/outside'], include_pull_request: false }); + + expect(Object.hasOwn(status.results, '__proto__')).toBe(true); + expect(JSON.stringify(status.results)).toContain('"__proto__"'); + expect(status.errors).toEqual({ + '/outside': { code: 41304, msg: 'path "/outside" escapes workspace' }, + '/missing-env': { code: 40420, msg: 'missing' }, + '/invalid-cwd': { code: 40001, msg: 'invalid' }, + '/conflict': { code: 40901, msg: 'conflict' }, + '/unavailable': { code: 40926, msg: 'unavailable' }, + '/handshake': { code: 40926, msg: 'handshake' }, + }); + }); }); describe('WorkspaceFsService.diff', () => { From 9ebedb0e7d6be94a27bd9539a6483ed62bbf1517 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 30 Oct 2026 15:46:52 +0800 Subject: [PATCH 8/9] fix: handle nested git roots correctly --- .../agent-core-v2/src/app/git/gitService.ts | 8 +++- .../src/workspace/workspaceFs/fsService.ts | 14 ++++-- .../test/app/git/gitService.test.ts | 28 +++++++++++ .../workspace/workspaceFs/fsService.test.ts | 48 +++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index c3f3b55f518..5948fd55829 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -41,7 +41,7 @@ export class GitService implements IGitService { if (prefix.exitCode !== 0) { throw this.gitUnavailable(cwd, prefix.stderr.trim() || `git rev-parse exit ${prefix.exitCode}`); } - pathPrefix = prefix.stdout.trim(); + pathPrefix = prefix.stdout.replace(/\r?\n$/, ''); } const statusArgs = ['status', '--porcelain=v1', '--branch', '-z']; if (options.scopeToCwd === true) statusArgs.push('--', '.'); @@ -73,7 +73,11 @@ export class GitService implements IGitService { return result; } - pullRequest(cwd: string): Promise { + async pullRequest(cwd: string): Promise { + const inside = await this.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}`); + } return this.readPullRequest(cwd); } diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index ba243933d13..ebb1fbf5522 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -1078,15 +1078,23 @@ export class WorkspaceFsService implements IWorkspaceFsService { } async gitStatus(req: FsGitStatusRequest): Promise { + const cwd = await this.resolveGitRoot(req.root); let filter: Set | undefined; if (req.paths !== undefined && req.paths.length > 0) { filter = new Set(); for (const p of req.paths) { - filter.add(this.toRel(await this.resolveWithin(p))); + const inputPath = req.root !== undefined && !this.path.isAbsolute(p) ? this.path.join(cwd, p) : p; + const abs = await this.resolveWithin(inputPath); + const rel = this.path.relative(cwd, abs).split(this.path.separator).join('/'); + if (req.root !== undefined && (rel === '..' || rel.startsWith('../') || this.path.isAbsolute(rel))) { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${p}" escapes git root`, { + details: { cwd, path: p }, + }); + } + filter.add(rel); } } - const cwd = await this.resolveGitRoot(req.root); return this.git.statusAt(cwd, { pathFilter: filter, scopeToCwd: req.root !== undefined, @@ -1134,7 +1142,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { const inputPath = req.root !== undefined && !this.path.isAbsolute(req.path) ? this.path.join(cwd, req.path) : req.path; const abs = await this.resolveWithin(inputPath); const rel = this.path.relative(cwd, abs).split(this.path.separator).join('/'); - if (rel === '..' || rel.startsWith('../') || this.path.isAbsolute(rel)) { + if (req.root !== undefined && (rel === '..' || rel.startsWith('../') || this.path.isAbsolute(rel))) { throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${req.path}" escapes git root`, { details: { cwd, path: req.path }, }); diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index 73f256c428e..abc06616b15 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -144,6 +144,21 @@ describe('GitService', () => { expect(result.deletions).toBe(0); }); + it('preserves whitespace in the selected cwd prefix', async () => { + mkdirSync(join(repo, ' lead'), { recursive: true }); + writeFileSync(join(repo, ' lead/file.txt'), 'one\n'); + commitAll('init'); + writeFileSync(join(repo, ' lead/file.txt'), 'one\ntwo\n'); + + const result = await service.status(join(repo, ' lead'), { + scopeToCwd: true, + pathFilter: new Set(['file.txt']), + includePullRequest: false, + }); + + expect(result.entries).toEqual({ 'file.txt': 'modified' }); + }); + it('reports a non-ASCII path without quoting', async () => { const name = 'output/2026-08-31-bilibili-BV175t86pEre-26.8.31-总能等到回踩的.md'; mkdirSync(join(repo, 'output'), { recursive: true }); @@ -176,6 +191,19 @@ describe('GitService', () => { }); }); + describe('pullRequest', () => { + it('throws FS_GIT_UNAVAILABLE when not a repo', async () => { + const notRepo = mkdtempSync(join(tmpdir(), 'not-repo-')); + try { + await expect(service.pullRequest(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'); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index 1d65f54809a..c3f9d0afc3b 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -520,6 +520,34 @@ describe('WorkspaceFsService.gitStatus', () => { ]); }); + it('resolves status filters from the selected root', async () => { + const calls: Array<{ options: { pathFilter?: ReadonlySet } | undefined }> = []; + const git: IGitService = { + _serviceBrand: undefined, + status: async (_cwd, options) => { + calls.push({ options }); + return { branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0, pullRequest: null }; + }, + diff: async () => ({ path: '', diff: '', truncated: false }), + pullRequest: async () => null, + findWorkTree: async () => null, + }; + const fs = makeSession( + { 'sub/link/file.ts': '', '/outside/file.ts': '' }, + emptyHandler, + [], + git, + ['/repo/link'], + undefined, + { '/repo/link': '/outside' }, + ); + + await fs.gitStatus({ root: 'sub', paths: ['link/file.ts'] }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.options?.pathFilter).toEqual(new Set(['link/file.ts'])); + }); + it('rejects roots outside the workspace and roots that are files', async () => { const fs = makeSession({ 'src/a.ts': '' }, emptyHandler); @@ -688,6 +716,26 @@ describe('WorkspaceFsService.diff', () => { expect(result.path).toBe('src/a.ts'); }); + it('preserves rootless diffs for allowed sibling directories', 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 }; + }, + pullRequest: async () => null, + findWorkTree: async () => null, + }; + const fs = makeSession({ '/extra/src/a.ts': 'content' }, emptyHandler, [], git, [], undefined, {}, ['/extra']); + + const result = await fs.diff({ path: '/extra/src/a.ts' }); + + expect(calls).toEqual([{ cwd: WORK_DIR, rel: '../extra/src/a.ts', abs: '/extra/src/a.ts' }]); + expect(result.diff).toContain('+new'); + }); + it('rejects paths that escape the workspace', async () => { const fs = makeSession({}, emptyHandler); await expect(fs.diff({ path: '../etc/passwd' })).rejects.toMatchObject({ From dbbf9b38c6f34c02a694dab59b7bd7d30e786861 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 30 Oct 2026 16:43:14 +0800 Subject: [PATCH 9/9] fix: align folder git status results --- docs/en/reference/server-api.md | 6 ++--- docs/zh/reference/server-api.md | 6 ++--- .../agent-core-v2/src/app/git/gitParsers.ts | 2 +- .../agent-core-v2/src/app/git/gitService.ts | 4 +++- .../src/workspace/workspaceFs/fsService.ts | 2 +- .../test/app/git/gitService.test.ts | 17 ++++++++++++++ .../workspace/workspaceFs/fsService.test.ts | 22 ++++++++++++++++++- 7 files changed, 49 insertions(+), 10 deletions(-) diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index a4eb73eb75d..c656d0f1d6b 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -1874,13 +1874,13 @@ On success, `data` is `{ files, files_scanned, truncated, elapsed_ms }` where ea #### `POST /api/v1/sessions/{session_id}/fs:git_status` -Reads the git status of a session workspace directory, optionally restricted to a set of paths. Entry paths are relative to the selected directory. +Reads git status, optionally restricted to a set of paths. When `root` is provided, entry paths are relative to that directory; when omitted, status covers the whole Git worktree and entry paths are relative to the repository root. | Parameter | In | Type | Description | | --- | --- | --- | --- | | `session_id` | path | string | **Required.** Session id | -| `root` | body | string | Workspace directory to inspect. Defaults to the session work directory; a relative value is resolved from it | -| `paths` | body | string[] | Restrict the status to these paths, relative to `root`; omitted means the whole selected directory | +| `root` | body | string | Workspace directory to scope to. When omitted, status covers the whole Git worktree; a relative value is resolved from the session work directory | +| `paths` | body | string[] | Restrict the status to these paths. Relative paths are resolved from `root` when provided, otherwise from the session work directory | | `include_pull_request` | body | boolean | Include the associated pull request. Default `true` | On success, `data` is `{ branch, ahead, behind, entries, additions, deletions, pullRequest }` where `entries` maps each changed path to its status (`clean` / `modified` / `added` / `deleted` / `renamed` / `untracked` / `ignored` / `conflicted`) and `pullRequest` is `{ number, state, url }` (`state` is `open` / `merged` / `closed` / `draft`) or `null`. diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 37ad50b94f2..b22c181f8d7 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -1877,13 +1877,13 @@ schema 还接受共享消息格式中的 `tool_use`、`tool_result` 和 `thinkin #### `POST /api/v1/sessions/{session_id}/fs:git_status` -读取会话工作区中指定目录的 git 状态,可选限定在一组路径内。返回条目的路径相对于所选目录。 +读取 git 状态,可选限定在一组路径内。提供 `root` 时,返回条目的路径相对于该目录;省略时,查询整个 Git worktree,条目路径相对于仓库根目录。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `session_id` | path | string | **必填。** 会话 id | -| `root` | body | string | 要查询的工作区目录,默认是会话工作目录;相对路径从会话工作目录解析 | -| `paths` | body | string[] | 将状态限定在这些相对于 `root` 的路径;省略表示整个所选目录 | +| `root` | body | string | 要限定查询范围的工作区目录;省略时查询整个 Git worktree,相对路径从会话工作目录解析 | +| `paths` | body | string[] | 将状态限定在这些路径;相对路径在提供 `root` 时从该目录解析,否则从会话工作目录解析 | | `include_pull_request` | body | boolean | 是否包含关联的 Pull Request。默认 `true` | 成功时 `data` 为 `{ branch, ahead, behind, entries, additions, deletions, pullRequest }`,其中 `entries` 把每个变更路径映射到其状态(`clean` / `modified` / `added` / `deleted` / `renamed` / `untracked` / `ignored` / `conflicted`),`pullRequest` 为 `{ number, state, url }`(`state` 为 `open` / `merged` / `closed` / `draft`)或 `null`。 diff --git a/packages/agent-core-v2/src/app/git/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index 25922767ba6..37695dbf71c 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -9,7 +9,7 @@ export function parsePorcelain( let branch = ''; let ahead = 0; let behind = 0; - const entries: Record = {}; + const entries = Object.create(null) as Record; for (let i = 0; i < records.length; i++) { const record = records[i]!; diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index 5948fd55829..4d372d4dd96 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -59,7 +59,9 @@ export class GitService implements IGitService { const head = await this.runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd); if (head.exitCode === 0) { const diffArgs = ['diff', '--no-color', '--numstat', 'HEAD', '--']; - if (options.scopeToCwd === true) diffArgs.push('.'); + const paths = options.pathFilter === undefined ? [] : [...options.pathFilter].toSorted(); + if (paths.length > 0) diffArgs.push(...paths); + else if (options.scopeToCwd === true) diffArgs.push('.'); const numstat = await this.runCommand('git', diffArgs, cwd); if (numstat.exitCode === 0) { const stats = parseNumstat(numstat.stdout); diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index ebb1fbf5522..ad7189ff5f2 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -1152,7 +1152,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { private async resolveGitRoot(root?: string): Promise { if (root === undefined) return this.workDir; - const cwd = await this.resolveWithin(root); + const cwd = root === '/' && this.isWithinWorkspace(root) ? this.path.resolve(root) : await this.resolveWithin(root); const stat = await this.hostFs.stat(cwd).catch(() => undefined); if (stat?.isDirectory !== true) { throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, `git root is not a directory: ${root}`, { diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index abc06616b15..bec82305651 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -128,9 +128,11 @@ describe('GitService', () => { mkdirSync(join(repo, 'a'), { recursive: true }); mkdirSync(join(repo, 'b'), { recursive: true }); writeFileSync(join(repo, 'a/file.txt'), 'one\n'); + writeFileSync(join(repo, 'a/other.txt'), 'one\n'); writeFileSync(join(repo, 'b/file.txt'), 'one\n'); commitAll('init'); writeFileSync(join(repo, 'a/file.txt'), 'one\ntwo\n'); + writeFileSync(join(repo, 'a/other.txt'), 'one\ntwo\nthree\n'); writeFileSync(join(repo, 'b/file.txt'), 'one\ntwo\nthree\n'); const result = await service.status(join(repo, 'a'), { @@ -144,6 +146,21 @@ describe('GitService', () => { expect(result.deletions).toBe(0); }); + it('preserves a file named __proto__ in a selected cwd', async () => { + mkdirSync(join(repo, 'sub'), { recursive: true }); + writeFileSync(join(repo, 'sub/__proto__'), 'one\n'); + commitAll('init'); + writeFileSync(join(repo, 'sub/__proto__'), 'one\ntwo\n'); + + const result = await service.status(join(repo, 'sub'), { + scopeToCwd: true, + includePullRequest: false, + }); + + expect(Object.hasOwn(result.entries, '__proto__')).toBe(true); + expect(JSON.stringify(result.entries)).toContain('"__proto__"'); + }); + it('preserves whitespace in the selected cwd prefix', async () => { mkdirSync(join(repo, ' lead'), { recursive: true }); writeFileSync(join(repo, ' lead/file.txt'), 'one\n'); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index c3f9d0afc3b..7b9f16c1a72 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -81,7 +81,7 @@ function fakeFs( } }; const addAbsAncestors = (abs: string): void => { - let dir = abs.slice(0, abs.lastIndexOf('/')); + let dir = abs.slice(0, abs.lastIndexOf('/')) || '/'; while (dir.length > 0 && !dirSet.has(dir)) { dirSet.add(dir); dir = dir.slice(0, dir.lastIndexOf('/')); @@ -555,6 +555,26 @@ describe('WorkspaceFsService.gitStatus', () => { await expect(fs.gitStatus({ root: 'src/a.ts' })).rejects.toMatchObject({ code: 'fs.git_unavailable' }); }); + it('allows a registered filesystem root as a Git root', async () => { + const calls: string[] = []; + const git: IGitService = { + _serviceBrand: undefined, + status: async (cwd) => { + calls.push(cwd); + return { branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0, pullRequest: null }; + }, + diff: async () => ({ path: '', diff: '', truncated: false }), + pullRequest: async () => null, + findWorkTree: async () => null, + }; + const fs = makeSession({ '/placeholder': '' }, emptyHandler, [], git, [], undefined, {}, ['/']); + + await fs.gitStatus({ root: '/' }); + await fs.gitStatusBatch({ roots: ['/'], include_pull_request: false }); + + expect(calls).toEqual(['/', '/']); + }); + it('propagates FS_GIT_UNAVAILABLE thrown by IGitService', async () => { const git: IGitService = { _serviceBrand: undefined,