Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/folder-scoped-file-diffs.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +2 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a CLI changeset for the draft-state correction

Existing fs:git_status callers with a draft pull request now receive draft instead of open because this PR adds the isDraft field and changes its parsing, but the changeset only announces the new folder and batch queries and versions internal packages. The user-visible correction therefore will not appear in the CLI changelog; record it as a separate logical changeset for @moonshot-ai/kimi-code.

AGENTS.md reference: AGENTS.md:L108-L109

Useful? React with 👍 / 👎.

59 changes: 54 additions & 5 deletions docs/en/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down Expand Up @@ -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.

Expand Down
59 changes: 54 additions & 5 deletions docs/zh/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 环境上的会话可用。另有:

| 方法与路径 | 说明 |
| --- | --- |
Expand Down Expand Up @@ -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 被截断。

Expand Down
46 changes: 45 additions & 1 deletion packages/agent-core-v2/src/app/git/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,23 @@ export const fsPullRequestSchema = z.object({
export type FsPullRequest = z.infer<typeof fsPullRequestSchema>;

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<typeof fsGitStatusRequestSchema>;

export const fsPullRequestRequestSchema = z.object({
root: z.string().min(1).optional(),
});
export type FsPullRequestRequest = z.infer<typeof fsPullRequestRequestSchema>;

export interface GitStatusOptions {
pathFilter?: ReadonlySet<string>;
scopeToCwd?: boolean;
includePullRequest?: boolean;
}

export const fsGitStatusResponseSchema = z.object({
branch: z.string(),
ahead: z.number().int().nonnegative(),
Expand All @@ -41,8 +54,38 @@ export const fsGitStatusResponseSchema = z.object({
});
export type FsGitStatusResponse = z.infer<typeof fsGitStatusResponseSchema>;

export const fsBatchErrorSchema = z.object({
code: z.number().int(),
msg: z.string(),
});
export type FsBatchError = z.infer<typeof fsBatchErrorSchema>;

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<typeof fsGitStatusBatchRequestSchema>;

export const fsGitStatusBatchResponseSchema = z.object({
results: z.record(z.string(), fsGitStatusResponseSchema),
errors: z.record(z.string(), fsBatchErrorSchema),
});
export type FsGitStatusBatchResponse = z.infer<typeof fsGitStatusBatchResponseSchema>;

export const fsPullRequestBatchRequestSchema = z.object({
roots: z.array(z.string().min(1)).min(1).max(32),
});
export type FsPullRequestBatchRequest = z.infer<typeof fsPullRequestBatchRequestSchema>;

export const fsPullRequestBatchResponseSchema = z.object({
results: z.record(z.string(), fsPullRequestSchema.nullable()),
errors: z.record(z.string(), fsBatchErrorSchema),
});
export type FsPullRequestBatchResponse = z.infer<typeof fsPullRequestBatchResponseSchema>;

export const fsDiffRequestSchema = z.object({
path: z.string().min(1),
root: z.string().min(1).optional(),
});
export type FsDiffRequest = z.infer<typeof fsDiffRequestSchema>;

Expand All @@ -56,7 +99,8 @@ export type FsDiffResponse = z.infer<typeof fsDiffResponseSchema>;
export interface IGitService {
readonly _serviceBrand: undefined;

status(cwd: string, pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse>;
status(cwd: string, options?: GitStatusOptions): Promise<FsGitStatusResponse>;
pullRequest(cwd: string): Promise<FsPullRequest | null>;
diff(cwd: string, relPath: string, absPath: string): Promise<FsDiffResponse>;
findWorkTree(cwd: string): Promise<GitWorkTree | null>;
}
Expand Down
9 changes: 7 additions & 2 deletions packages/agent-core-v2/src/app/git/gitParsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from './git';
export function parsePorcelain(
stdout: string,
filter: ReadonlySet<string> | undefined,
pathPrefix = '',
): FsGitStatusResponse {
const records = stdout.split('\0');
let branch = '';
let ahead = 0;
let behind = 0;
const entries: Record<string, FsGitStatus> = {};
const entries = Object.create(null) as Record<string, FsGitStatus>;

for (let i = 0; i < records.length; i++) {
const record = records[i]!;
Expand All @@ -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;
Comment thread
liruifengv marked this conversation as resolved.

if (xy.startsWith('R') || xy.startsWith('C')) {
i++;
Expand Down Expand Up @@ -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 };
}

Expand Down
36 changes: 29 additions & 7 deletions packages/agent-core-v2/src/app/git/gitService.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,26 +29,40 @@ export class GitService implements IGitService {
private readonly environmentId: string = LOCAL_ENVIRONMENT_ID,
) {}

async status(cwd: string, pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse> {
async status(cwd: string, options: GitStatusOptions = {}): Promise<FsGitStatusResponse> {
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('--', '.');
Comment thread
liruifengv marked this conversation as resolved.
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')
.some((record) => record.length > 0 && !record.startsWith('## '));
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);
Comment thread
liruifengv marked this conversation as resolved.
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;
Expand All @@ -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<FsPullRequest | null> {
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<FsDiffResponse> {
const inside = await this.runCommand('git', ['rev-parse', '--is-inside-work-tree'], cwd);
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
Expand Down Expand Up @@ -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' },
Expand Down
Loading
Loading