dsh-git-ui 0.0.2 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +55 -20
  2. package/README.zh.md +55 -21
  3. package/cordis.patch.yml +1 -1
  4. package/lib/client.js +36 -35
  5. package/lib/client.js.map +4 -4
  6. package/lib/contracts/host-endpoints.d.ts +27 -0
  7. package/lib/host/actions.d.ts +22 -2
  8. package/lib/host/core.d.ts +7 -1
  9. package/lib/host/index.d.ts +25 -15
  10. package/lib/host/index.js +419 -53
  11. package/lib/host/index.js.map +4 -4
  12. package/lib/host/parser.d.ts +37 -1
  13. package/lib/host/queries.d.ts +17 -0
  14. package/lib/host/types.d.ts +131 -1
  15. package/package.json +1 -1
  16. package/src/adapters/dsh/client-adapter.ts +120 -0
  17. package/src/adapters/dsh/types/cordis.d.ts +48 -0
  18. package/src/adapters/dsh/types/typert-protocol.d.ts +81 -0
  19. package/src/adapters/dsh/types/ui-primitives.d.ts +45 -0
  20. package/src/adapters/dsh/ui-primitives.ts +16 -0
  21. package/src/client/GitCenter.tsx +1414 -149
  22. package/src/client/GitPill.tsx +282 -67
  23. package/src/client/changes-diff.ts +63 -0
  24. package/src/client/controller.ts +34 -31
  25. package/src/client/error-text.ts +21 -0
  26. package/src/client/file-tree.ts +101 -0
  27. package/src/client/git-graph.ts +188 -0
  28. package/src/client/icons.tsx +292 -0
  29. package/src/client/index.ts +38 -134
  30. package/src/client/locales.ts +124 -0
  31. package/src/client/popup-close.ts +19 -0
  32. package/src/client/remote.ts +85 -3
  33. package/src/client/select-menu.tsx +113 -0
  34. package/src/client/side-by-side.ts +150 -0
  35. package/src/client/styles.ts +1375 -86
  36. package/src/client/time-format.ts +32 -0
  37. package/src/contracts/client-platform.ts +147 -0
  38. package/src/contracts/host-endpoints.ts +58 -0
  39. package/src/contracts/plugin-activation.ts +129 -0
  40. package/src/contracts/ui-context.tsx +28 -0
  41. package/src/contracts/ui-primitives.ts +48 -0
  42. package/src/host/actions.ts +66 -15
  43. package/src/host/core.ts +9 -2
  44. package/src/host/index.ts +60 -54
  45. package/src/host/parser.ts +155 -12
  46. package/src/host/queries.ts +289 -0
  47. package/src/host/types.ts +104 -0
package/src/host/index.ts CHANGED
@@ -1,96 +1,102 @@
1
1
  /**
2
- * dsh-git-ui host half: the `gitInfo` Remote service.
2
+ * dsh-git-ui host 适配层:Cordis/typert 壳。
3
3
  *
4
- * Cordis shell only every behavior lives in `core.ts`/`actions.ts` behind
5
- * injected structural faces, so tests never need a cordis runtime. The class
6
- * is a plugin in its own right (class form), mounted by the bundle patch row
7
- * with the package name; the gateway exposes `gitInfo/snapshot` and
8
- * `gitInfo/run` through SRC discovery (`typertRemote` binding + `@Remote`
9
- * marker).
4
+ * 本文件是 host 端**唯一** import `@deepseek-ai/*` 的地方。职责:
5
+ * 1. Cordis 服务(subprocess / sessions / sessionPersistence)适配为
6
+ * 结构化 `SnapshotDeps` 接口;
7
+ * 2. 调用 `createHostEndpoints(deps, config)` 获得纯业务端点;
8
+ * 3. `@Remote` 装饰器将端点暴露给 typert Gateway。
9
+ *
10
+ * 业务逻辑全部在 `contracts/host-endpoints.ts` → `host/core.ts` /
11
+ * `host/actions.ts` / `host/queries.ts`,与框架无关。
10
12
  */
11
13
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
12
14
  import type { Context } from '@deepseek-ai/cordis'
13
15
  import { realpath, stat } from 'node:fs/promises'
14
16
  import { createGitRunner, type SubprocessLike } from './git.ts'
15
- import { normalizeConfig, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
16
- import { runAction } from './actions.ts'
17
- import type { GitActionResult, GitActionRequest, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
17
+ import { normalizeConfig, type GitStatusConfig, type SnapshotDeps } from './core.ts'
18
+ import { createHostEndpoints, type HostEndpoints } from '../contracts/host-endpoints.ts'
19
+ import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
18
20
 
19
- export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest } from './types.ts'
21
+ export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest, GitQuery, GitQueryResult, GitQueryRequest, GitQueryResponse, GitBranch, GitFileStat, GitRef } from './types.ts'
20
22
  export { normalizeConfig, DEFAULT_CONFIG } from './core.ts'
21
- export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts'
22
- export { isSafePath, runAction } from './actions.ts'
23
-
24
- /** Structural face of a live session header. */
25
- interface SessionLike {
26
- readonly header?: { readonly cwd?: string }
27
- }
23
+ export { parseStatusOutput, parseLogOutput, parseBranchOutput, parseNameStatusOutput } from './parser.ts'
24
+ export { isSafePath, isValidBranchName, runAction } from './actions.ts'
25
+ export { runQuery } from './queries.ts'
26
+ export { createHostEndpoints, type HostEndpoints } from '../contracts/host-endpoints.ts'
28
27
 
29
- /** Structural face of the sessions service. */
28
+ /** Cordis sessions 服务的结构化切片。 */
30
29
  interface SessionsLike {
31
- get(id: string): SessionLike | undefined
30
+ get(id: string): { readonly header?: { readonly cwd?: string } } | undefined
32
31
  }
33
32
 
34
- /** Structural face of the session-persistence service. */
33
+ /** Cordis sessionPersistence 服务的结构化切片。 */
35
34
  interface SessionPersistenceLike {
36
35
  inspect(id: string): Promise<{ readonly meta: { readonly cwd?: string } }>
37
36
  }
38
37
 
39
- /** The `gitInfo` service: `snapshot` (read) and `run` (management) endpoints. */
38
+ /**
39
+ * gitInfo Remote 服务:Cordis 壳。
40
+ *
41
+ * 构造时从 Cordis Context 取出宿主服务,适配为 `SnapshotDeps`,再调用
42
+ * `createHostEndpoints` 获得纯业务端点。三个 `@Remote` 方法仅做委托。
43
+ */
40
44
  export class GitStatusService extends TypertRemoteService {
41
45
  static inject = ['subprocess', 'sessions', 'sessionPersistence']
42
46
 
43
- private readonly config: GitStatusConfig
47
+ private readonly endpoints: HostEndpoints
44
48
 
45
49
  constructor(ctx: Context, config: unknown) {
46
50
  super(ctx, 'gitInfo')
47
- this.config = normalizeConfig(config)
51
+ const normalizedConfig = normalizeConfig(config)
52
+ const deps = this.buildDeps(ctx, normalizedConfig)
53
+ this.endpoints = createHostEndpoints(deps, normalizedConfig)
48
54
  }
49
55
 
50
- /** Adapter face shared by both endpoints (injected services + runner). */
51
- private deps(signal?: AbortSignal): { readonly deps: SnapshotDeps } | { readonly failure: { readonly code: 'git-unavailable'; readonly detail: string } } {
52
- const subprocess = this.ctx.get('subprocess') as SubprocessLike | undefined
56
+ /** Cordis 服务适配为结构化 SnapshotDeps。 */
57
+ private buildDeps(ctx: Context, config: GitStatusConfig): SnapshotDeps {
58
+ const subprocess = ctx.get('subprocess') as SubprocessLike | undefined
53
59
  if (subprocess === undefined) {
54
- return { failure: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
60
+ // 返回一个永远失败的 deps——端点调用会走到 git-unavailable 降级路径。
61
+ return {
62
+ run: { run: async () => { throw new Error('subprocess service unavailable') } },
63
+ fs: { realpath, stat },
64
+ sessions: { liveCwd: () => undefined, persistedMeta: async () => undefined },
65
+ }
55
66
  }
56
- const sessions = this.ctx.get('sessions') as SessionsLike | undefined
57
- const persistence = this.ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
58
- const runner = createGitRunner(subprocess, this.config.timeoutMs, this.config.maxStatusBytes)
67
+ const sessions = ctx.get('sessions') as SessionsLike | undefined
68
+ const persistence = ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
59
69
  return {
60
- deps: {
61
- run: runner,
62
- fs: { realpath, stat },
63
- sessions: {
64
- liveCwd: (id) => sessions?.get(id)?.header?.cwd,
65
- persistedMeta: async (id) => {
66
- if (persistence === undefined) return undefined
67
- try {
68
- const inspection = await persistence.inspect(id)
69
- return { cwd: inspection.meta.cwd }
70
- } catch {
71
- return undefined
72
- }
73
- },
70
+ run: createGitRunner(subprocess, config.timeoutMs, config.maxStatusBytes),
71
+ fs: { realpath, stat },
72
+ sessions: {
73
+ liveCwd: (id) => sessions?.get(id)?.header?.cwd,
74
+ persistedMeta: async (id) => {
75
+ if (persistence === undefined) return undefined
76
+ try {
77
+ const inspection = await persistence.inspect(id)
78
+ return { cwd: inspection.meta.cwd }
79
+ } catch {
80
+ return undefined
81
+ }
74
82
  },
75
- signal,
76
83
  },
77
84
  }
78
85
  }
79
86
 
80
87
  @Remote('snapshot')
81
88
  async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
82
- const adapted = this.deps(signal)
83
- if ('failure' in adapted) return { ok: false, error: adapted.failure }
84
- return snapshotForSession(adapted.deps, this.config, request.sessionId)
89
+ return this.endpoints.snapshot(request, signal)
85
90
  }
86
91
 
87
92
  @Remote('run')
88
93
  async run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult> {
89
- const adapted = this.deps(signal)
90
- if ('failure' in adapted) {
91
- return { ok: false, error: { code: 'git-error', message: adapted.failure.detail } }
92
- }
93
- return runAction(adapted.deps, this.config, request)
94
+ return this.endpoints.run(request, signal)
95
+ }
96
+
97
+ @Remote('query')
98
+ async query(request: GitQueryRequest, signal?: AbortSignal): Promise<GitQueryResponse> {
99
+ return this.endpoints.query(request, signal)
94
100
  }
95
101
  }
96
102
 
@@ -3,7 +3,7 @@
3
3
  * No side effects and no I/O — fully unit-testable against literal fixtures
4
4
  * (verified against real `git status --porcelain=v1 -z --branch` output).
5
5
  */
6
- import type { GitChange, GitChangeStatus, GitCommit } from './types.ts'
6
+ import type { GitChange, GitChangeStatus, GitCommit, GitRef, GraphCommit } from './types.ts'
7
7
 
8
8
  /** Parsed status counts plus the (possibly capped) change list. */
9
9
  export interface ParsedStatus {
@@ -75,11 +75,9 @@ export function parseStatusHeader(line: string): StatusHeader {
75
75
  return { branch: branch === '' ? null : branch, unborn: false, ahead, behind }
76
76
  }
77
77
 
78
- /** Map one porcelain XY pair to a change status. */
79
- function changeStatus(x: string, y: string): GitChangeStatus {
80
- if (x === '?' && y === '?') return 'untracked'
81
- if (x === 'U' || y === 'U' || (x !== ' ' && y !== ' ')) return 'conflicted'
82
- switch (x) {
78
+ /** 单列状态码 变更状态映射(真实冲突由 isConflicted 单独判定)。 */
79
+ function singleStatus(code: string): GitChangeStatus {
80
+ switch (code) {
83
81
  case 'A': return 'added'
84
82
  case 'M': return 'modified'
85
83
  case 'D': return 'deleted'
@@ -90,11 +88,25 @@ function changeStatus(x: string, y: string): GitChangeStatus {
90
88
  }
91
89
  }
92
90
 
91
+ /**
92
+ * 真实合并冲突:任一侧为 U(UU/AU/UD/UA/DU),或双方同添/同删(AA/DD)。
93
+ * 注意 MM/AM/MD 等「已暂存 + 工作区再改」是合法混合态而非冲突
94
+ * (旧规则「双列均非空即冲突」会把 MM 误报为冲突)。
95
+ */
96
+ function isConflicted(x: string, y: string): boolean {
97
+ return x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')
98
+ }
99
+
93
100
  /**
94
101
  * Parse the full `git status --porcelain=v1 -z --branch` output.
95
102
  * -z format: every entry (header and each `XY path`) is NUL-terminated; a
96
103
  * rename/copy entry emits `R <new>\0<old>\0` so the following item is the
97
104
  * source path and must be consumed without becoming a change itself.
105
+ *
106
+ * 混合状态拆分(IDEA 式):X、Y 均非空的合法对(MM/AM/MD/RM…)拆为
107
+ * 「已暂存侧 + 未暂存侧」两条 GitChange——UI 据此把同一文件分别列入
108
+ * 「已暂存更改」与「更改」两组,两侧可独立操作、差异基线唯一。
109
+ * 真实冲突(isConflicted)保持单条 conflicted 条目。
98
110
  */
99
111
  export function parseStatusOutput(output: string, maxChanges: number): ParsedStatus {
100
112
  const raw = output.split(NUL)
@@ -108,6 +120,17 @@ export function parseStatusOutput(output: string, maxChanges: number): ParsedSta
108
120
  const changes: GitChange[] = []
109
121
  let truncated = false
110
122
 
123
+ /** 收录一条变更条目;超出上限仅置截断标记(计数不受影响)。
124
+ * isDirectory 由 git 输出权威标记(未跟踪目录条目为 `dir/` 尾斜杠),
125
+ * 展示层依赖此字段,不再自行解析路径字符串。 */
126
+ const pushChange = (path: string, status: GitChangeStatus, isStaged: boolean): void => {
127
+ if (changes.length < maxChanges) {
128
+ changes.push({ path, status, staged: isStaged, isDirectory: path.endsWith('/') })
129
+ } else {
130
+ truncated = true
131
+ }
132
+ }
133
+
111
134
  for (let index = 1; index < segments.length; index += 1) {
112
135
  const entry = segments[index] ?? ''
113
136
  const x = entry[0] ?? ' '
@@ -120,14 +143,23 @@ export function parseStatusOutput(output: string, maxChanges: number): ParsedSta
120
143
  }
121
144
  if (x === '?' && y === '?') {
122
145
  untracked += 1
123
- } else {
124
- if (x !== ' ' && x !== '?') staged += 1
125
- if (y !== ' ' && y !== '?') modified += 1
146
+ pushChange(path, 'untracked', false)
147
+ continue
126
148
  }
127
- if (changes.length < maxChanges) {
128
- changes.push({ path, status: changeStatus(x, y), staged: x !== ' ' && x !== '?' })
149
+ // 计数仍按 X/Y 两列分别累计;拆双条目不改变总数。
150
+ if (x !== ' ' && x !== '?') staged += 1
151
+ if (y !== ' ' && y !== '?') modified += 1
152
+
153
+ if (isConflicted(x, y)) {
154
+ // 冲突文件按单条展示(IDEA 冲突条目形态),归入已暂存组。
155
+ pushChange(path, 'conflicted', true)
156
+ } else if (x !== ' ' && y !== ' ') {
157
+ // 混合态:已暂存侧状态取 X,未暂存侧状态取 Y。
158
+ pushChange(path, singleStatus(x), true)
159
+ pushChange(path, singleStatus(y), false)
129
160
  } else {
130
- truncated = true
161
+ const isStaged = x !== ' '
162
+ pushChange(path, singleStatus(isStaged ? x : y), isStaged)
131
163
  }
132
164
  }
133
165
 
@@ -166,6 +198,116 @@ export function parseLogOutput(output: string): readonly GitCommit[] {
166
198
  return commits
167
199
  }
168
200
 
201
+ /**
202
+ * 解析带图的 log 输出:
203
+ * `%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%P%x1f%D`
204
+ * 其中 `%P` 为空格分隔的父提交哈希(根提交为空),`%D` 为 ref 装饰。
205
+ * 返回适合分支图渲染器的 `GraphCommit[]`。
206
+ */
207
+ export function parseGraphLogOutput(output: string, remotes: readonly string[] = []): readonly GraphCommit[] {
208
+ const commits: GraphCommit[] = []
209
+ for (const line of output.split('\n')) {
210
+ if (line === '') continue
211
+ const [hash, shortHash, subject, author, dateIso, parentField, decoField] = line.split(LOG_SEP)
212
+ if (hash === undefined || hash === '') continue
213
+ const parents = (parentField ?? '')
214
+ .split(' ')
215
+ .filter((p) => p !== '')
216
+ commits.push({
217
+ hash,
218
+ shortHash: shortHash ?? '',
219
+ subject: subject ?? '',
220
+ author: author ?? '',
221
+ dateIso: dateIso ?? '',
222
+ parents,
223
+ refs: parseDecorations(decoField ?? '', remotes),
224
+ })
225
+ }
226
+ return commits
227
+ }
228
+
229
+ /**
230
+ * 解析 `%D` 装饰串,形如 `HEAD -> main, origin/main, tag: v1.0`;空串无 refs。
231
+ * 分类规则:`HEAD -> x` 为当前分支;`tag: t` 为标签;
232
+ * 带远程前缀(`<remote>/…`)为远程分支;其余为本地分支。
233
+ */
234
+ export function parseDecorations(decorations: string, remotes: readonly string[]): readonly GitRef[] {
235
+ const trimmed = decorations.trim()
236
+ if (trimmed === '') return []
237
+ const refs: GitRef[] = []
238
+ for (const token of trimmed.split(', ')) {
239
+ if (token.startsWith('HEAD -> ')) {
240
+ refs.push({ kind: 'branch', name: token.slice(8), head: true })
241
+ } else if (token.startsWith('tag: ')) {
242
+ refs.push({ kind: 'tag', name: token.slice(5), head: false })
243
+ } else if (remotes.some((remote) => token === remote || token.startsWith(`${remote}/`))) {
244
+ refs.push({ kind: 'remote', name: token, head: false })
245
+ } else {
246
+ refs.push({ kind: 'branch', name: token, head: false })
247
+ }
248
+ }
249
+ return refs
250
+ }
251
+
252
+ /**
253
+ * 解析 `git show -s --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%b` 输出:
254
+ * 前五个字段为机器可读元数据,第六字段起为 %b 正文
255
+ * (%b 已排除 subject 首段落,天然无重复展示问题)。
256
+ */
257
+ export function parseShowMeta(output: string): { readonly commit: GitCommit; readonly body: string } | null {
258
+ const trimmed = output.trimEnd()
259
+ if (trimmed === '') return null
260
+ const [hash, shortHash, subject, author, dateIso, ...bodyParts] = trimmed.split(LOG_SEP)
261
+ if (hash === undefined || hash === '') return null
262
+ return {
263
+ commit: {
264
+ hash,
265
+ shortHash: shortHash ?? '',
266
+ subject: subject ?? '',
267
+ author: author ?? '',
268
+ dateIso: dateIso ?? '',
269
+ },
270
+ body: bodyParts.join(LOG_SEP).trimEnd(),
271
+ }
272
+ }
273
+
274
+ /** `--name-status` 状态码 → 变更状态映射。 */
275
+ function nameStatusCode(code: string): GitChangeStatus {
276
+ switch (code) {
277
+ case 'A': return 'added'
278
+ case 'D': return 'deleted'
279
+ case 'R': return 'renamed'
280
+ case 'T': return 'typechange'
281
+ case 'U': return 'conflicted'
282
+ default: return 'modified'
283
+ }
284
+ }
285
+
286
+ /**
287
+ * 解析 `git show --format= --name-status -z` 输出:NUL 分隔,
288
+ * `X\0path\0`,rename/copy 为 `R100\0old\0new\0`(取新路径)。
289
+ * -z 原始输出不引号化,非 ASCII 路径天然免疫乱码(旧 --stat 八进制转义问题的根因消除)。
290
+ */
291
+ export function parseNameStatusOutput(output: string): readonly { readonly path: string; readonly status: GitChangeStatus }[] {
292
+ const raw = output.split(NUL)
293
+ const segments = raw[raw.length - 1] === '' ? raw.slice(0, -1) : raw
294
+ const rows: { path: string; status: GitChangeStatus }[] = []
295
+ for (let i = 0; i < segments.length; i += 1) {
296
+ const entry = segments[i] ?? ''
297
+ if (entry === '') continue
298
+ const code = entry[0] ?? ' '
299
+ if (code === 'R' || code === 'C') {
300
+ // rename/copy:old 在 i+1、new 在 i+2,展示取新路径。
301
+ rows.push({ path: segments[i + 2] ?? '', status: nameStatusCode(code) })
302
+ i += 2
303
+ } else {
304
+ rows.push({ path: segments[i + 1] ?? '', status: nameStatusCode(code) })
305
+ i += 1
306
+ }
307
+ }
308
+ return rows
309
+ }
310
+
169
311
  /**
170
312
  * Parse `git branch --show-current` output: the branch name, or null when
171
313
  * empty (detached HEAD).
@@ -174,3 +316,4 @@ export function parseBranchOutput(output: string): string | null {
174
316
  const trimmed = output.trim()
175
317
  return trimmed === '' ? null : trimmed
176
318
  }
319
+
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Framework-free read-only query runner (history / diff / show / branches).
3
+ *
4
+ * Same layering as `core.ts`/`actions.ts`: structural injection, testable
5
+ * against real temporary repositories without a cordis runtime. Every query
6
+ * resolves the workspace once, then runs one or two read-only git commands
7
+ * against the repository root.
8
+ */
9
+ import { resolveWorkspace, runCommand, type GitStatusConfig, type SnapshotDeps } from './core.ts'
10
+ import { parseBranchOutput, parseGraphLogOutput, parseNameStatusOutput, parseShowMeta } from './parser.ts'
11
+ import { isSafePath, operationError } from './actions.ts'
12
+ import type { GitBranch, GitQueryRequest, GitQueryResponse } from './types.ts'
13
+
14
+ /** Machine-readable log format for show queries (no parents). */
15
+ const LOG_FORMAT = '%H%x1f%h%x1f%s%x1f%an%x1f%aI'
16
+ /** 带图的 log 格式(%P = 父提交,%D = ref 装饰)。 */
17
+ const GRAPH_FORMAT = '%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%P%x1f%D'
18
+
19
+ /** History page size cap (and default). 千条级 + 无限滚动。 */
20
+ const MAX_HISTORY_LIMIT = 1000
21
+
22
+ /** A ref is acceptable when non-empty and free of whitespace. */
23
+ function isValidRef(ref: string): boolean {
24
+ return ref !== '' && !/\s/.test(ref)
25
+ }
26
+
27
+ /**
28
+ * 执行一条只读查询。结果均为 JSON 纯数据且有界
29
+ * (history 分页;diff 文本受 runner 的 spill/截断约束)。
30
+ * `config` 当前未用:保留以与 runAction 共享 runner 签名契约
31
+ * (deps, config, request),后续查询限流等调优可直接启用。
32
+ */
33
+ export async function runQuery(
34
+ deps: SnapshotDeps,
35
+ config: GitStatusConfig,
36
+ request: GitQueryRequest,
37
+ ): Promise<GitQueryResponse> {
38
+ void config
39
+ const workspace = await resolveWorkspace(deps, request.sessionId)
40
+ if (!workspace.ok) return { ok: false, error: operationError(workspace.error).error }
41
+ const root = workspace.root
42
+ const query = request.query
43
+
44
+ switch (query.kind) {
45
+ case 'history':
46
+ return historyQuery(deps, root, query)
47
+ case 'diff':
48
+ return diffQuery(deps, root, query.path, query.base)
49
+ case 'show':
50
+ return showQuery(deps, root, query.ref)
51
+ case 'branches':
52
+ return branchesQuery(deps, root)
53
+ case 'tags':
54
+ return tagsQuery(deps, root)
55
+ case 'authors':
56
+ return authorsQuery(deps, root)
57
+ }
58
+ }
59
+
60
+ async function historyQuery(
61
+ deps: SnapshotDeps,
62
+ root: string,
63
+ query: Extract<GitQueryRequest['query'], { kind: 'history' }>,
64
+ ): Promise<GitQueryResponse> {
65
+ const safeLimit = Math.min(Math.max(Math.floor(query.limit), 0), MAX_HISTORY_LIMIT)
66
+ const safeSkip = Math.max(Math.floor(query.skip), 0)
67
+ if (query.ref !== undefined && !isValidRef(query.ref)) {
68
+ return { ok: false, error: { code: 'invalid-name', message: `invalid ref: ${query.ref}` } }
69
+ }
70
+ const search = query.search?.trim() ?? ''
71
+ const hexLike = /^[0-9a-f]{7,40}$/i.test(search)
72
+ // 哈希精准检索:仅定位目标提交自身(--no-walk 不遍历祖先)→ 单条目,
73
+ // 不再列出该提交的全部祖先;文本搜索走 --grep(-i -E,跨引用匹配)。
74
+ const scope = hexLike ? [] : query.ref === undefined ? ['--all'] : [query.ref]
75
+ const noWalk = hexLike ? ['--no-walk', search] : []
76
+ const filters: string[] = []
77
+ if (search !== '' && !hexLike) filters.push('--regexp-ignore-case', '--extended-regexp', `--grep=${search}`)
78
+ const author = query.author?.trim() ?? ''
79
+ if (author !== '') filters.push(`--author=${author}`)
80
+ const since = query.since?.trim() ?? ''
81
+ if (since !== '') filters.push(`--since=${since}`)
82
+
83
+ const log = await runCommand(
84
+ deps.run,
85
+ // -n/--skip 前置:git 的 `-n N` 出现在 `--no-walk` 之后会重置 no-walk
86
+ // (hexLike 会错误列出全部祖先),前置则 `-n 1000 --no-walk x` 恒返回单条。
87
+ ['git', 'log', ...filters, `--skip=${String(safeSkip)}`, '-n', String(safeLimit), ...noWalk, ...scope, `--format=${GRAPH_FORMAT}`],
88
+ root,
89
+ 'log',
90
+ deps.signal,
91
+ )
92
+ if ('failure' in log) return { ok: false, error: operationError(log.failure).error }
93
+ if (log.run.timedOut) return { ok: false, error: { code: 'timeout' } }
94
+ if (log.run.exitCode !== 0) {
95
+ // 未出生仓库无提交:git log 以 128 此信息退出——稳定空历史,非错误。
96
+ if (log.run.stderr.includes('does not have any commits')) {
97
+ return { ok: true, value: { kind: 'history', commits: [], total: 0 } }
98
+ }
99
+ // 哈希无解析(未命中)或前缀不唯一(ambiguous):稳定空结果(让用户输入更长前缀)。
100
+ if (hexLike && /unknown revision|bad revision|ambiguous/i.test(log.run.stderr)) {
101
+ return { ok: true, value: { kind: 'history', commits: [], total: 0 } }
102
+ }
103
+ return gitError('log', log.run.stderr, log.run.stdout)
104
+ }
105
+
106
+ // 过滤范围内的提交总数(best-effort)。
107
+ let total = 0
108
+ const count = await runCommand(deps.run, ['git', 'rev-list', '--count', ...noWalk, ...scope, ...filters], root, 'rev-list', deps.signal)
109
+ if ('run' in count && count.run.exitCode === 0) {
110
+ const parsed = Number(count.run.stdout.trim())
111
+ if (Number.isFinite(parsed) && parsed >= 0) total = parsed
112
+ }
113
+
114
+ // 远程名用于 %D 装饰的远程分支分类;失败时降级为空列表(其余按本地分支处理)。
115
+ let remotes: readonly string[] = []
116
+ const remoteRun = await runCommand(deps.run, ['git', 'remote'], root, 'remote', deps.signal)
117
+ if ('run' in remoteRun && remoteRun.run.exitCode === 0) {
118
+ remotes = remoteRun.run.stdout.split('\n').map((s) => s.trim()).filter((s) => s !== '')
119
+ }
120
+
121
+ return { ok: true, value: { kind: 'history', commits: parseGraphLogOutput(log.run.stdout, remotes), total } }
122
+ }
123
+
124
+ /**
125
+ * 单文件差异(变更界面对照查看用)。
126
+ * staged = --cached;worktree = 工作区对索引;
127
+ * 未版本管理文件 worktree 差异为空 → 回退 --no-index 与 /dev/null 对比(退出码 1 视为有差异的成功)。
128
+ */
129
+ async function diffQuery(
130
+ deps: SnapshotDeps,
131
+ root: string,
132
+ path: string,
133
+ base: 'worktree' | 'staged',
134
+ ): Promise<GitQueryResponse> {
135
+ if (!isSafePath(path, root)) return { ok: false, error: { code: 'invalid-path', message: `unsafe path: ${path}` } }
136
+ // 使用 -U999999 显示完整文档上下文(而非仅变更 hunk),支持文档浏览体验。
137
+ const argv = base === 'staged'
138
+ ? ['git', 'diff', '--cached', '-U999999', '--', path]
139
+ : ['git', 'diff', '-U999999', '--', path]
140
+ const run = await runCommand(deps.run, argv, root, 'diff', deps.signal)
141
+ if ('failure' in run) return { ok: false, error: operationError(run.failure).error }
142
+ if (run.run.timedOut) return { ok: false, error: { code: 'timeout' } }
143
+ if (run.run.exitCode !== 0) return gitError('diff', run.run.stderr, run.run.stdout)
144
+ if (run.run.stdout !== '' || base === 'staged') {
145
+ return { ok: true, value: { kind: 'diff', path, text: run.run.stdout } }
146
+ }
147
+ // 空差异:可能是未版本管理文件——与 /dev/null 对比生成全增差异。
148
+ const ni = await runCommand(deps.run, ['git', 'diff', '--no-index', '-U999999', '--', '/dev/null', path], root, 'diff --no-index', deps.signal)
149
+ if ('failure' in ni) return { ok: false, error: operationError(ni.failure).error }
150
+ if (ni.run.timedOut) return { ok: false, error: { code: 'timeout' } }
151
+ if (ni.run.exitCode !== 0 && ni.run.exitCode !== 1) return gitError('diff', ni.run.stderr, ni.run.stdout)
152
+ return { ok: true, value: { kind: 'diff', path, text: ni.run.stdout } }
153
+ }
154
+
155
+ async function showQuery(deps: SnapshotDeps, root: string, ref: string): Promise<GitQueryResponse> {
156
+ if (!isValidRef(ref)) return { ok: false, error: { code: 'invalid-name', message: `invalid ref: ${ref}` } }
157
+ // -s 仅输出格式块:%b 为排除首段落后的正文,独立调用避免解析歧义。
158
+ const meta = await runCommand(
159
+ deps.run,
160
+ ['git', 'show', '-s', `--format=${LOG_FORMAT}%x1f%b`, ref],
161
+ root,
162
+ 'show',
163
+ deps.signal,
164
+ )
165
+ if ('failure' in meta) return { ok: false, error: operationError(meta.failure).error }
166
+ if (meta.run.timedOut) return { ok: false, error: { code: 'timeout' } }
167
+ if (meta.run.exitCode !== 0) return gitError('show', meta.run.stderr, meta.run.stdout)
168
+
169
+ const stat = await runCommand(
170
+ deps.run,
171
+ ['git', '-c', 'core.quotePath=false', 'show', '--format=', '--name-status', '-z', ref],
172
+ root,
173
+ 'show --name-status',
174
+ deps.signal,
175
+ )
176
+ if ('failure' in stat) return { ok: false, error: operationError(stat.failure).error }
177
+ if (stat.run.timedOut) return { ok: false, error: { code: 'timeout' } }
178
+ if (stat.run.exitCode !== 0) return gitError('show', stat.run.stderr, stat.run.stdout)
179
+
180
+ const parsed = parseShowMeta(meta.run.stdout)
181
+ return {
182
+ ok: true,
183
+ value: {
184
+ kind: 'show',
185
+ ref,
186
+ commit: parsed?.commit ?? null,
187
+ body: parsed?.body ?? '',
188
+ stats: parseNameStatusOutput(stat.run.stdout),
189
+ },
190
+ }
191
+ }
192
+
193
+ /** 作者列表(工具栏用户选择用),去重排序截断 100。 */
194
+ async function authorsQuery(deps: SnapshotDeps, root: string): Promise<GitQueryResponse> {
195
+ const run = await runCommand(deps.run, ['git', 'log', '--all', '-n', '1000', '--format=%an'], root, 'log authors', deps.signal)
196
+ if ('failure' in run) return { ok: false, error: operationError(run.failure).error }
197
+ if (run.run.timedOut) return { ok: false, error: { code: 'timeout' } }
198
+ if (run.run.exitCode !== 0) return { ok: true, value: { kind: 'authors', authors: [] } }
199
+ const authors = [...new Set(run.run.stdout.split('\n').map((s) => s.trim()).filter((s) => s !== ''))].sort().slice(0, 100)
200
+ return { ok: true, value: { kind: 'authors', authors } }
201
+ }
202
+
203
+ /** 标签列表(左栏过滤树用),复用 tab 分隔解析。 */
204
+ async function tagsQuery(deps: SnapshotDeps, root: string): Promise<GitQueryResponse> {
205
+ const FORMAT = '--format=%(refname:short)%09%(objectname:short)'
206
+ const run = await runCommand(deps.run, ['git', 'tag', FORMAT], root, 'tag', deps.signal)
207
+ if ('failure' in run) return { ok: false, error: operationError(run.failure).error }
208
+ if (run.run.timedOut) return { ok: false, error: { code: 'timeout' } }
209
+ if (run.run.exitCode !== 0) return gitError('tag', run.run.stderr, run.run.stdout)
210
+ return { ok: true, value: { kind: 'tags', tags: parseBranchList(run.run.stdout) } }
211
+ }
212
+
213
+ async function branchesQuery(deps: SnapshotDeps, root: string): Promise<GitQueryResponse> {
214
+ // 本地分支格式:name\thash\tupstream\ttrack(track 如 [ahead 2, behind 1])。
215
+ // 远程分支无上游 → upstream/track 为空。
216
+ const LOCAL_FORMAT = '--format=%(refname:short)%09%(objectname:short)%09%(upstream:short)%09%(upstream:track)'
217
+ const REMOTE_FORMAT = '--format=%(refname:short)%09%(objectname:short)'
218
+ const local = await runCommand(deps.run, ['git', 'branch', LOCAL_FORMAT], root, 'branch', deps.signal)
219
+ if ('failure' in local) return { ok: false, error: operationError(local.failure).error }
220
+ if (local.run.timedOut) return { ok: false, error: { code: 'timeout' } }
221
+ if (local.run.exitCode !== 0) return gitError('branch', local.run.stderr, local.run.stdout)
222
+
223
+ const remote = await runCommand(deps.run, ['git', 'branch', '-r', REMOTE_FORMAT], root, 'branch -r', deps.signal)
224
+ if ('failure' in remote) return { ok: false, error: operationError(remote.failure).error }
225
+ if (remote.run.timedOut) return { ok: false, error: { code: 'timeout' } }
226
+ if (remote.run.exitCode !== 0) return gitError('branch -r', remote.run.stderr, remote.run.stdout)
227
+
228
+ const current = await runCommand(deps.run, ['git', 'branch', '--show-current'], root, 'branch', deps.signal)
229
+ const currentName = 'run' in current && current.run.exitCode === 0 ? parseBranchOutput(current.run.stdout) : null
230
+
231
+ // 默认分支:origin/HEAD 符号引用(如 origin/main);失败降级 null。
232
+ let defaultBranch: string | null = null
233
+ const def = await runCommand(deps.run, ['git', 'symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], root, 'symbolic-ref', deps.signal)
234
+ if ('run' in def && def.run.exitCode === 0) {
235
+ const value = def.run.stdout.trim()
236
+ const slash = value.indexOf('/')
237
+ defaultBranch = value === '' ? null : slash === -1 ? value : value.slice(slash + 1)
238
+ }
239
+
240
+ return {
241
+ ok: true,
242
+ value: {
243
+ kind: 'branches',
244
+ current: currentName,
245
+ defaultBranch,
246
+ local: parseBranchList(local.run.stdout),
247
+ remote: parseBranchList(remote.run.stdout).filter((branch) => !branch.name.endsWith('/HEAD')),
248
+ },
249
+ }
250
+ }
251
+
252
+ /**
253
+ * 解析 `%(refname:short)%09%(objectname:short)[%09%(upstream:short)%09%(upstream:track)]` 行。
254
+ * 本地分支含 4 字段(upstream + track),远程分支仅 2 字段(无上游)。
255
+ * track 格式:`[ahead N]`、`[behind N]`、`[ahead N, behind N]` 或空(无上游/已同步)。
256
+ */
257
+ function parseBranchList(output: string): readonly GitBranch[] {
258
+ const branches: GitBranch[] = []
259
+ for (const line of output.split('\n')) {
260
+ if (line === '') continue
261
+ const parts = line.split('\t')
262
+ const name = parts[0]
263
+ const hash = parts[1]
264
+ if (name === undefined || name === '') continue
265
+ const track = parts[3] ?? ''
266
+ const aheadMatch = /ahead (\d+)/.exec(track)
267
+ const behindMatch = /behind (\d+)/.exec(track)
268
+ const ahead = aheadMatch ? Number(aheadMatch[1]) : 0
269
+ const behind = behindMatch ? Number(behindMatch[1]) : 0
270
+ branches.push({
271
+ name,
272
+ shortHash: hash === undefined || hash === '' ? null : hash,
273
+ ...(ahead > 0 ? { ahead } : {}),
274
+ ...(behind > 0 ? { behind } : {}),
275
+ })
276
+ }
277
+ return branches
278
+ }
279
+
280
+ function gitError(label: string, stderr: string, stdout: string): GitQueryResponse {
281
+ const message = stderr.trim() || stdout.trim()
282
+ return {
283
+ ok: false,
284
+ error: {
285
+ code: 'git-error',
286
+ message: message !== '' ? message : `git ${label} failed`,
287
+ },
288
+ }
289
+ }