diff --git a/.changeset/folder-scoped-file-diffs.md b/.changeset/folder-scoped-file-diffs.md new file mode 100644 index 00000000000..8b7a0953c6a --- /dev/null +++ b/.changeset/folder-scoped-file-diffs.md @@ -0,0 +1,6 @@ +--- +"@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. diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index a6485706639..c656d0f1d6b 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 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 | -| `paths` | body | string[] | Restrict the status to these paths; omitted means the whole workspace | +| `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`. - `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..b22c181f8d7 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 状态,可选限定在一组路径内。提供 `root` 时,返回条目的路径相对于该目录;省略时,查询整个 Git worktree,条目路径相对于仓库根目录。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `session_id` | path | string | **必填。** 会话 id | -| `paths` | body | string[] | 将状态限定在这些路径;省略表示整个工作区 | +| `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`。 - `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/git.ts b/packages/agent-core-v2/src/app/git/git.ts index c4291df5d73..3db440ffb50 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -26,10 +26,23 @@ 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(), + 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({ branch: z.string(), ahead: z.number().int().nonnegative(), @@ -41,8 +54,38 @@ 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(), }); export type FsDiffRequest = z.infer; @@ -56,7 +99,8 @@ export type FsDiffResponse = z.infer; export interface IGitService { readonly _serviceBrand: undefined; - status(cwd: string, pathFilter?: ReadonlySet): Promise; + 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/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index 3885f7fdb35..37695dbf71c 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -3,12 +3,13 @@ import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from './git'; export function parsePorcelain( stdout: string, filter: ReadonlySet | undefined, + pathPrefix = '', ): FsGitStatusResponse { const records = stdout.split('\0'); 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]!; @@ -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++; @@ -130,6 +134,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 0b9e2d0cbb6..4d372d4dd96 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'; @@ -29,18 +29,28 @@ export class GitService implements IGitService { private readonly environmentId: string = LOCAL_ENVIRONMENT_ID, ) {} - 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); + 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.replace(/\r?\n$/, ''); + } + 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, pathPrefix); const dirty = porc.stdout .split('\0') @@ -48,7 +58,11 @@ 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', '--']; + 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); result.additions = stats.additions; @@ -57,10 +71,18 @@ export class GitService implements IGitService { } } - result.pullRequest = await this.readPullRequest(cwd); + result.pullRequest = options.includePullRequest === false ? null : await this.readPullRequest(cwd); return result; } + 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); + } + 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') { @@ -127,7 +149,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' }, diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts index 3ee6f6ac27e..a1f3c882551 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts @@ -8,6 +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 { @@ -15,8 +22,26 @@ export { fsDiffResponseSchema, fsGitStatusRequestSchema, fsGitStatusResponseSchema, + fsGitStatusBatchRequestSchema, + fsGitStatusBatchResponseSchema, + fsPullRequestRequestSchema, + fsPullRequestSchema, + fsPullRequestBatchRequestSchema, + fsPullRequestBatchResponseSchema, } from '#/app/git/git'; -export type { FsDiffRequest, FsDiffResponse, FsGitStatusRequest, FsGitStatusResponse }; +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; @@ -261,6 +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 ef36d69da56..ad7189ff5f2 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -4,6 +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, @@ -28,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'; @@ -46,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'; @@ -1064,20 +1078,88 @@ 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); } } - return this.git.status(filter); + return this.git.statusAt(cwd, { + pathFilter: filter, + scopeToCwd: req.root !== undefined, + includePullRequest: req.include_pull_request !== false, + }); + } + + 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.pullRequestAt(cwd); + } + + private async batchGitValues( + inputRoots: readonly string[], + read: (root: string) => Promise, + ): Promise<{ results: Record; errors: Record }> { + const roots = [...new Set(inputRoots)]; + 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) { + 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 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 (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 }, + }); + } + return this.git.diffAt(cwd, rel, abs); + } + + private async resolveGitRoot(root?: string): Promise { + if (root === undefined) return this.workDir; + 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}`, { + details: { cwd, path: root }, + }); + } + return cwd; } private async grepWithRg( @@ -1645,6 +1727,32 @@ function mapFsError(err: unknown, inputPath: string): Error { return err instanceof Error ? err : new Error(String(err)); } +function toGitBatchError(err: unknown): FsBatchError { + 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); +} + function toWireError(err: unknown): { code: number; msg: string } { if (err instanceof Error2) { switch (err.code) { diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts index 5aca9e3cf2e..7da2d43ed5c 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts @@ -1,11 +1,14 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { FsDiffResponse, FsGitStatusResponse } from '#/app/git/git'; +import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest, GitStatusOptions } from '#/app/git/git'; export interface IWorkspaceGitService { readonly _serviceBrand: undefined; 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 bf9cf0ccc77..e01b38a8814 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 FsPullRequest, type GitStatusOptions, IGitService } from '#/app/git/git'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceGitService } from './workspaceGit'; @@ -13,11 +13,23 @@ export class WorkspaceGitService implements IWorkspaceGitService { ) {} status(pathFilter?: ReadonlySet): Promise { - return this.git.current!.status(this.workspace.cwd, pathFilter); + return this.git.current!.status(this.workspace.cwd, { pathFilter }); + } + + statusAt(cwd: string, options?: GitStatusOptions): Promise { + return this.git.current!.status(cwd, options); + } + + pullRequestAt(cwd: string): Promise { + return this.git.current!.pullRequest(cwd); } 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/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 b0fa3c0420f..bec82305651 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'; @@ -57,7 +57,7 @@ describe('GitService', () => { acquireWhenReady: async () => ({ environment, 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); @@ -67,6 +67,7 @@ describe('GitService', () => { }); afterEach(() => { + vi.restoreAllMocks(); disposables.dispose(); rmSync(repo, { recursive: true, force: true }); }); @@ -89,6 +90,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'); @@ -107,10 +120,62 @@ 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, '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'), { + scopeToCwd: true, + pathFilter: new Set(['file.txt']), + includePullRequest: false, + }); + + expect(result.entries).toEqual({ 'file.txt': 'modified' }); + expect(result.additions).toBe(1); + 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'); + 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 }); @@ -143,6 +208,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 f0555810a88..7b9f16c1a72 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,8 +59,11 @@ function stubWorkspaceDirs(additionalDirs: readonly string[] = []): IWorkspaceDi function workspaceGitStub(git: IGitService): IWorkspaceGitService { return { _serviceBrand: undefined, - status: (filter) => git.status(WORK_DIR, filter), + 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), }; } @@ -76,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('/')); @@ -381,6 +386,7 @@ function defaultGitStub(): IGitService { pullRequest: null, }), diff: async () => ({ path: '', diff: '', truncated: false }), + pullRequest: async () => null, findWorkTree: async () => null, }; } @@ -461,11 +467,11 @@ function makeRemoteSession( 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, @@ -477,18 +483,98 @@ describe('WorkspaceFsService.gitStatus', () => { }; }, diff: async () => ({ path: '', diff: '', truncated: false }), + pullRequest: async () => null, findWorkTree: async () => null, }; const fs = makeSession({}, emptyHandler, [], git); const result = await fs.gitStatus({ paths: ['src/a.ts'] }); expect(calls).toHaveLength(1); expect(calls[0]?.cwd).toBe(WORK_DIR); - expect(calls[0]?.filter).toEqual(new Set(['src/a.ts'])); + expect(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 }), + 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, includePullRequest: true } }, + { cwd: '/extra', options: { pathFilter: undefined, scopeToCwd: true, includePullRequest: false } }, + ]); + }); + + 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); + + 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('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, @@ -496,6 +582,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); @@ -503,6 +590,101 @@ 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' }); + }); + + 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', () => { it('delegates to IWorkspaceGitService with confined rel and abs paths', async () => { const calls: Array<{ cwd: string; rel: string; abs: string }> = []; @@ -521,6 +703,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); @@ -533,6 +716,46 @@ 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 }; + }, + pullRequest: async () => null, + 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('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({ diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 3a963b1997c..6199e4003e2 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -24,6 +24,9 @@ import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir import { fsDiffRequestSchema, fsGitStatusRequestSchema, + fsGitStatusBatchRequestSchema, + fsPullRequestRequestSchema, + fsPullRequestBatchRequestSchema, fsGrepRequestSchema, fsListManyRequestSchema, fsListRequestSchema, @@ -123,6 +126,9 @@ const FS_ACTIONS = [ 'search', 'grep', 'git_status', + 'git_status:batch', + 'pull_request', + 'pull_request:batch', 'diff', 'open', 'open-in', @@ -205,7 +211,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', }, @@ -232,7 +238,15 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { let environmentFs: FsScope | undefined; try { const required: EnvironmentCapability[] = ['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'); } environmentFs = session === undefined && fsAction === 'search' @@ -277,6 +291,15 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { case 'git_status': await handleGitStatus(environmentFs.fs, req, reply); return; + case 'git_status:batch': + await handleGitStatusBatch(environmentFs.fs, req, reply); + return; + case 'pull_request': + await handlePullRequest(environmentFs.fs, req, reply); + return; + case 'pull_request:batch': + await handlePullRequestBatch(environmentFs.fs, req, reply); + return; case 'diff': await handleDiff(environmentFs.fs, req, reply); return; @@ -757,6 +780,36 @@ 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) { + 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 27fa1a9fe29..cdaa9e84427 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 { IEnvironmentService } from '@moonshot-ai/agent-core-v2'; import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -76,6 +77,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', @@ -505,6 +528,53 @@ 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, 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() }); + + 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(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'); + 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', {}); 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'); },