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.
- package/README.md +55 -20
- package/README.zh.md +55 -21
- package/cordis.patch.yml +1 -1
- package/lib/client.js +36 -35
- package/lib/client.js.map +4 -4
- package/lib/contracts/host-endpoints.d.ts +27 -0
- package/lib/host/actions.d.ts +22 -2
- package/lib/host/core.d.ts +7 -1
- package/lib/host/index.d.ts +25 -15
- package/lib/host/index.js +419 -53
- package/lib/host/index.js.map +4 -4
- package/lib/host/parser.d.ts +37 -1
- package/lib/host/queries.d.ts +17 -0
- package/lib/host/types.d.ts +131 -1
- package/package.json +1 -1
- package/src/adapters/dsh/client-adapter.ts +120 -0
- package/src/adapters/dsh/types/cordis.d.ts +48 -0
- package/src/adapters/dsh/types/typert-protocol.d.ts +81 -0
- package/src/adapters/dsh/types/ui-primitives.d.ts +45 -0
- package/src/adapters/dsh/ui-primitives.ts +16 -0
- package/src/client/GitCenter.tsx +1414 -149
- package/src/client/GitPill.tsx +282 -67
- package/src/client/changes-diff.ts +63 -0
- package/src/client/controller.ts +34 -31
- package/src/client/error-text.ts +21 -0
- package/src/client/file-tree.ts +101 -0
- package/src/client/git-graph.ts +188 -0
- package/src/client/icons.tsx +292 -0
- package/src/client/index.ts +38 -134
- package/src/client/locales.ts +124 -0
- package/src/client/popup-close.ts +19 -0
- package/src/client/remote.ts +85 -3
- package/src/client/select-menu.tsx +113 -0
- package/src/client/side-by-side.ts +150 -0
- package/src/client/styles.ts +1375 -86
- package/src/client/time-format.ts +32 -0
- package/src/contracts/client-platform.ts +147 -0
- package/src/contracts/host-endpoints.ts +58 -0
- package/src/contracts/plugin-activation.ts +129 -0
- package/src/contracts/ui-context.tsx +28 -0
- package/src/contracts/ui-primitives.ts +48 -0
- package/src/host/actions.ts +66 -15
- package/src/host/core.ts +9 -2
- package/src/host/index.ts +60 -54
- package/src/host/parser.ts +155 -12
- package/src/host/queries.ts +289 -0
- package/src/host/types.ts +104 -0
package/src/host/index.ts
CHANGED
|
@@ -1,96 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-git-ui host
|
|
2
|
+
* dsh-git-ui host 适配层:Cordis/typert 壳。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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,
|
|
16
|
-
import {
|
|
17
|
-
import type { GitActionResult,
|
|
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
|
-
|
|
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
|
-
/**
|
|
28
|
+
/** Cordis sessions 服务的结构化切片。 */
|
|
30
29
|
interface SessionsLike {
|
|
31
|
-
get(id: string):
|
|
30
|
+
get(id: string): { readonly header?: { readonly cwd?: string } } | undefined
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
/**
|
|
33
|
+
/** Cordis sessionPersistence 服务的结构化切片。 */
|
|
35
34
|
interface SessionPersistenceLike {
|
|
36
35
|
inspect(id: string): Promise<{ readonly meta: { readonly cwd?: string } }>
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
/**
|
|
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
|
|
47
|
+
private readonly endpoints: HostEndpoints
|
|
44
48
|
|
|
45
49
|
constructor(ctx: Context, config: unknown) {
|
|
46
50
|
super(ctx, 'gitInfo')
|
|
47
|
-
|
|
51
|
+
const normalizedConfig = normalizeConfig(config)
|
|
52
|
+
const deps = this.buildDeps(ctx, normalizedConfig)
|
|
53
|
+
this.endpoints = createHostEndpoints(deps, normalizedConfig)
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
/**
|
|
51
|
-
private
|
|
52
|
-
const subprocess =
|
|
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
|
-
|
|
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 =
|
|
57
|
-
const persistence =
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
package/src/host/parser.ts
CHANGED
|
@@ -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
|
-
/**
|
|
79
|
-
function
|
|
80
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
if (y !== ' ' && y !== '?') modified += 1
|
|
146
|
+
pushChange(path, 'untracked', false)
|
|
147
|
+
continue
|
|
126
148
|
}
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
+
}
|