dsh-git-ui 0.0.2 → 0.1.0

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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * IDEA 式时间格式化:不足 60 分钟「x 分钟前」、今天「今天 HH:mm」、
3
+ * 昨天「昨天 HH:mm」、其余「Y/M/D HH:mm」。纯函数,可单元测试。
4
+ */
5
+
6
+ /** 本地化标签由调用方注入(组件侧经字典提供)。 */
7
+ export interface TimeLabels {
8
+ readonly minutesAgo: (n: number) => string
9
+ readonly today: string
10
+ readonly yesterday: string
11
+ }
12
+
13
+ const pad = (n: number): string => String(n).padStart(2, '0')
14
+
15
+ /** 日历日键(本地时区),用于今天/昨天判定。 */
16
+ function dayKey(x: Date): string {
17
+ return `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`
18
+ }
19
+
20
+ export function formatWhen(iso: string, now: number, labels: TimeLabels): string {
21
+ const then = Date.parse(iso)
22
+ if (!Number.isFinite(then)) return iso
23
+ const d = new Date(then)
24
+ const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
25
+ const seconds = Math.floor((now - then) / 1000)
26
+ if (seconds >= 0 && seconds < 3600) {
27
+ return labels.minutesAgo(Math.max(1, Math.floor(seconds / 60)))
28
+ }
29
+ if (dayKey(d) === dayKey(new Date(now))) return `${labels.today} ${hm}`
30
+ if (dayKey(d) === dayKey(new Date(now - 86_400_000))) return `${labels.yesterday} ${hm}`
31
+ return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()} ${hm}`
32
+ }
@@ -15,21 +15,21 @@
15
15
  */
16
16
  import { resolve, sep } from 'node:path'
17
17
  import { resolveWorkspace, runCommand, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
18
- import type { GitAction, GitActionResult, GitActionRequest } from './types.ts'
18
+ import type { GitAction, GitActionResult, GitActionRequest, GitOperationErrorCode } from './types.ts'
19
19
 
20
20
  /** Build the command sequence for one action, validating every path against the root. */
21
21
  function buildArgv(action: GitAction, root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
22
22
  switch (action.kind) {
23
23
  case 'stage':
24
- return withPaths(['git', 'add', '--'], action.paths, root)
24
+ return withPaths([['git', 'add', '--']], action.paths, root)
25
25
  case 'stage-all':
26
26
  return { argv: [['git', 'add', '-A']] }
27
27
  case 'unstage':
28
- return withPaths(['git', 'restore', '--staged', '--'], action.paths, root)
28
+ return withPaths([['git', 'restore', '--staged', '--']], action.paths, root)
29
29
  case 'unstage-all':
30
30
  return { argv: [['git', 'restore', '--staged', '--', '.']] }
31
31
  case 'discard':
32
- return withPaths(['git', 'restore', '--'], action.paths, root)
32
+ return withPaths([['git', 'restore', '--']], action.paths, root)
33
33
  case 'discard-all':
34
34
  // Reset the index to HEAD first, then the work tree to the index — the
35
35
  // IDE-style "roll back everything tracked" semantics.
@@ -40,21 +40,49 @@ function buildArgv(action: GitAction, root: string): { readonly argv: readonly (
40
40
  if (action.paths === undefined || action.paths.length === 0) {
41
41
  return { argv: [['git', 'commit', '-m', message]] }
42
42
  }
43
- // git commit -- <paths> stages those paths from the work tree and
44
- // commits only them (index state of other paths is ignored) — the
45
- // IDE-style "commit selected files" semantics.
46
- return withPaths(['git', 'commit', '-m', message, '--'], action.paths, root)
43
+ // 两步序列(IDE 式「提交所选文件」语义,含未跟踪文件):
44
+ // 1. `git add -- <paths>` 先把所选路径纳入索引——裸的
45
+ // `git commit -- <未跟踪路径>` 会报 pathspec 错误,先行暂存使其可匹配;
46
+ // 2. `git commit -m <msg> -- <paths>` 按路径限定提交这些路径的工作区内容,
47
+ // 其余已暂存文件不受影响。对已跟踪路径与单命令完全等价(已实测验证)。
48
+ return withPaths([['git', 'add', '--'], ['git', 'commit', '-m', message, '--']], action.paths, root)
47
49
  }
50
+ case 'branch-create': {
51
+ // Name validity is validated by runAction (invalid-name), not here.
52
+ const from = action.from === undefined || action.from === '' ? [] : [action.from]
53
+ return { argv: [['git', 'branch', action.name, ...from]] }
54
+ }
55
+ case 'branch-checkout':
56
+ return { argv: [['git', 'checkout', action.name]] }
57
+ case 'branch-delete':
58
+ return { argv: [['git', 'branch', action.force === true ? '-D' : '-d', action.name]] }
59
+ case 'fetch':
60
+ // fetch --all --prune:拉取所有远程引用更新 + 清理已删除的远程跟踪分支。
61
+ return { argv: [['git', 'fetch', '--all', '--prune']] }
48
62
  }
49
63
  }
50
64
 
51
- /** Append validated repo-relative paths behind `--`. */
52
- function withPaths(prefix: readonly string[], paths: readonly string[], root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
65
+ /**
66
+ * A branch name is valid when it matches git's ref-name grammar at the level
67
+ * we care about: non-empty, ASCII ref chars only, no leading `-` (option
68
+ * injection guard, though argv never shells out), no `..` (path traversal of
69
+ * refs), no trailing `/`, and no double slashes.
70
+ */
71
+ export function isValidBranchName(name: string): boolean {
72
+ if (name === '' || name.startsWith('-') || name.includes('..') || name.endsWith('/') || name.includes('//')) return false
73
+ return /^[A-Za-z0-9._/-]+$/.test(name)
74
+ }
75
+
76
+ /**
77
+ * 校验仓库相对路径后追加到 `--` 之后;`prefixes` 可给出多条命令序列,
78
+ * 校验后的路径逐一附加到每条序列(commit 所选路径即两步序列)。
79
+ */
80
+ function withPaths(prefixes: readonly (readonly string[])[], paths: readonly string[], root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
53
81
  if (paths.length === 0) return { error: 'no paths given' }
54
82
  for (const path of paths) {
55
83
  if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` }
56
84
  }
57
- return { argv: [[...prefix, ...paths]] }
85
+ return { argv: prefixes.map((prefix) => [...prefix, ...paths]) }
58
86
  }
59
87
 
60
88
  /**
@@ -71,13 +99,26 @@ export function isSafePath(path: string, root: string): boolean {
71
99
  }
72
100
 
73
101
  /** Map a snapshot-flow failure (which may carry git-unavailable) onto the operation error shape. */
74
- function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, { ok: false }>['error']): GitActionResult & { ok: false } {
102
+ export function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, { ok: false }>['error']): GitActionResult & { ok: false } {
75
103
  if (failure.code === 'git-unavailable') {
76
104
  return { ok: false, error: { code: 'git-error', message: failure.detail } }
77
105
  }
78
106
  return { ok: false, error: failure }
79
107
  }
80
108
 
109
+ /**
110
+ * 把 git 命令失败归类为可预期的业务错误(其余保持 git-error)。
111
+ * 切分支被工作区未提交变更阻止是最常见的可预期失败:git 输出
112
+ * "would be overwritten by checkout"(或中文本地化 "将被 checkout 覆盖"),
113
+ * 归一化为 local-changes-block,client 据此给友好提示 + 处理变更引导。
114
+ */
115
+ export function classifyOperationError(kind: GitAction['kind'], message: string): GitOperationErrorCode {
116
+ if (kind === 'branch-checkout' && /would be overwritten by checkout|将被 checkout 覆盖|有未跟踪工作区文件将会被 checkout 覆盖/i.test(message)) {
117
+ return 'local-changes-block'
118
+ }
119
+ return 'git-error'
120
+ }
121
+
81
122
  /**
82
123
  * Execute one management action against the session's repository and return
83
124
  * the refreshed snapshot on success (the caller re-renders from it, so the
@@ -96,11 +137,20 @@ export async function runAction(
96
137
  return { ok: false, error: { code: 'git-error', message: 'commit message is empty' } }
97
138
  }
98
139
 
140
+ const kind = request.action.kind
141
+ if (kind === 'branch-create' || kind === 'branch-checkout' || kind === 'branch-delete') {
142
+ const name = request.action.name
143
+ if (!isValidBranchName(name)) {
144
+ return { ok: false, error: { code: 'invalid-name', message: `invalid branch name: ${name}` } }
145
+ }
146
+ }
147
+
99
148
  const built = buildArgv(request.action, root)
100
149
  if ('error' in built) return { ok: false, error: { code: 'invalid-path', message: built.error } }
101
150
 
102
- // Run the command sequence; a failure stops the rest (the first commands
103
- // may already have taken effect — they are all idempotent restores).
151
+ // Run the command sequence; a failure stops the rest. 先行命令可能已生效:
152
+ // restore 类命令幂等可重入;两步提交若 add 成功后 commit 失败,所选路径
153
+ // 留在暂存区(IDE 行为相同,下次重试即可成功)。
104
154
  let lastStdout = ''
105
155
  for (const argv of built.argv) {
106
156
  const outcome = await runCommand(deps.run, argv, root, `action ${request.action.kind}`, deps.signal)
@@ -110,10 +160,11 @@ export async function runAction(
110
160
  // git writes user-facing failures to stderr OR stdout (e.g. a clean
111
161
  // repo's `git commit` reports "nothing to commit" on stdout).
112
162
  const message = outcome.run.stderr.trim() || outcome.run.stdout.trim()
163
+ const code = classifyOperationError(request.action.kind, message)
113
164
  return {
114
165
  ok: false,
115
166
  error: {
116
- code: 'git-error',
167
+ code,
117
168
  message: message !== '' ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`,
118
169
  },
119
170
  }
package/src/host/core.ts CHANGED
@@ -157,8 +157,14 @@ export async function resolveWorkspace(
157
157
  * 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
158
158
  * 2. `git branch --show-current` — null when detached
159
159
  * 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
160
- * 4. `git status --porcelain=v1 -z --branch`
160
+ * 4. `git status --porcelain=v1 -z --branch --untracked-files=all`
161
161
  * 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
162
+ *
163
+ * --untracked-files=all:git 默认 normal 模式会把整目录未跟踪折叠为单条
164
+ * `?? dir/`(尾斜杠)且不枚举其内部文件——隐藏目录(.agent/.tianqi 等)的
165
+ * 变更因此从不进入变更清单。`all` 强制逐文件枚举(与 IDEA / VSCode 一致),
166
+ * 内部文件得以展示;maxChanges 截断列表、maxStatusBytes spill 保计数精确,
167
+ * 超大未跟踪树(如未 gitignore 的构建产物)经此路径优雅降级。
162
168
  */
163
169
  export async function snapshotForSession(
164
170
  deps: SnapshotDeps,
@@ -182,7 +188,8 @@ export async function snapshotForSession(
182
188
  // main`), so a corrupt repo is never misreported as "no commits".
183
189
  const head = headRun.run.exitCode === 0 ? (headRun.run.stdout.trim() || null) : null
184
190
 
185
- const status = await runCommand(deps.run, ['git', 'status', '--porcelain=v1', '-z', '--branch'], root, 'status', deps.signal)
191
+ // --untracked-files=all:强制枚举未跟踪目录内部文件(根因修复——见模块注释)。
192
+ const status = await runCommand(deps.run, ['git', 'status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], root, 'status', deps.signal)
186
193
  if ('failure' in status) return { ok: false, error: status.failure }
187
194
  if (status.run.timedOut) return { ok: false, error: { code: 'timeout' } }
188
195
  if (status.run.exitCode !== 0) {
package/src/host/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * dsh-git-ui host half: the `gitInfo` Remote service.
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
+ * Cordis shell only — every behavior lives in `core.ts`/`actions.ts`/
5
+ * `queries.ts` behind injected structural faces, so tests never need a
6
+ * cordis runtime. The class is a plugin in its own right (class form),
7
+ * mounted by the bundle patch row with the package name; the gateway exposes
8
+ * `gitInfo/snapshot`, `gitInfo/run` and `gitInfo/query` through SRC
9
+ * discovery (`typertRemote` binding + `@Remote` marker).
10
10
  */
11
11
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
12
12
  import type { Context } from '@deepseek-ai/cordis'
@@ -14,12 +14,14 @@ import { realpath, stat } from 'node:fs/promises'
14
14
  import { createGitRunner, type SubprocessLike } from './git.ts'
15
15
  import { normalizeConfig, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
16
16
  import { runAction } from './actions.ts'
17
- import type { GitActionResult, GitActionRequest, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
17
+ import { runQuery } from './queries.ts'
18
+ import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
18
19
 
19
- export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest } from './types.ts'
20
+ export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest, GitQuery, GitQueryResult, GitQueryRequest, GitQueryResponse, GitBranch, GitFileStat, GitRef } from './types.ts'
20
21
  export { normalizeConfig, DEFAULT_CONFIG } from './core.ts'
21
- export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts'
22
- export { isSafePath, runAction } from './actions.ts'
22
+ export { parseStatusOutput, parseLogOutput, parseBranchOutput, parseNameStatusOutput } from './parser.ts'
23
+ export { isSafePath, isValidBranchName, runAction } from './actions.ts'
24
+ export { runQuery } from './queries.ts'
23
25
 
24
26
  /** Structural face of a live session header. */
25
27
  interface SessionLike {
@@ -92,6 +94,15 @@ export class GitStatusService extends TypertRemoteService {
92
94
  }
93
95
  return runAction(adapted.deps, this.config, request)
94
96
  }
97
+
98
+ @Remote('query')
99
+ async query(request: GitQueryRequest, signal?: AbortSignal): Promise<GitQueryResponse> {
100
+ const adapted = this.deps(signal)
101
+ if ('failure' in adapted) {
102
+ return { ok: false, error: { code: 'git-error', message: adapted.failure.detail } }
103
+ }
104
+ return runQuery(adapted.deps, this.config, request)
105
+ }
95
106
  }
96
107
 
97
108
  export default GitStatusService
@@ -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
+