dsh-git-ui 0.0.1 → 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
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Framework-free git management operation runner.
3
+ *
4
+ * Same layering as `core.ts`: every dependency is injected structurally, the
5
+ * whole flow is testable against real temporary git repositories without a
6
+ * cordis runtime, and `GitStatusService` only adapts host services into the
7
+ * `SnapshotDeps` face.
8
+ *
9
+ * Security model: the browser only ever sends a `sessionId` plus
10
+ * repository-relative paths (as listed in a snapshot's `changes`). Paths are
11
+ * validated against the work-tree root (absolute paths and `..` escapes are
12
+ * rejected) and every git invocation uses `--` so a path can never be
13
+ * interpreted as an option. Commands run through the same subprocess adapter
14
+ * as the read-only snapshot flow — no shell is involved.
15
+ */
16
+ import { resolve, sep } from 'node:path'
17
+ import { resolveWorkspace, runCommand, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
18
+ import type { GitAction, GitActionResult, GitActionRequest, GitOperationErrorCode } from './types.ts'
19
+
20
+ /** Build the command sequence for one action, validating every path against the root. */
21
+ function buildArgv(action: GitAction, root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
22
+ switch (action.kind) {
23
+ case 'stage':
24
+ return withPaths([['git', 'add', '--']], action.paths, root)
25
+ case 'stage-all':
26
+ return { argv: [['git', 'add', '-A']] }
27
+ case 'unstage':
28
+ return withPaths([['git', 'restore', '--staged', '--']], action.paths, root)
29
+ case 'unstage-all':
30
+ return { argv: [['git', 'restore', '--staged', '--', '.']] }
31
+ case 'discard':
32
+ return withPaths([['git', 'restore', '--']], action.paths, root)
33
+ case 'discard-all':
34
+ // Reset the index to HEAD first, then the work tree to the index — the
35
+ // IDE-style "roll back everything tracked" semantics.
36
+ return { argv: [['git', 'restore', '--staged', '--', '.'], ['git', 'restore', '--', '.']] }
37
+ case 'commit': {
38
+ // Message emptiness is validated by runAction (git-error), not here.
39
+ const message = action.message.trim()
40
+ if (action.paths === undefined || action.paths.length === 0) {
41
+ return { argv: [['git', 'commit', '-m', message]] }
42
+ }
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)
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']] }
62
+ }
63
+ }
64
+
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 } {
81
+ if (paths.length === 0) return { error: 'no paths given' }
82
+ for (const path of paths) {
83
+ if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` }
84
+ }
85
+ return { argv: prefixes.map((prefix) => [...prefix, ...paths]) }
86
+ }
87
+
88
+ /**
89
+ * A path is safe when it is repo-relative and stays inside the work tree:
90
+ * reject absolute paths, drive letters / backslashes, and `..` escapes
91
+ * (checked via path resolution against the realpath'd root).
92
+ */
93
+ export function isSafePath(path: string, root: string): boolean {
94
+ if (path === '') return false
95
+ if (path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:/.test(path)) return false
96
+ const resolved = resolve(root, path)
97
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`
98
+ return resolved === root || resolved.startsWith(prefix)
99
+ }
100
+
101
+ /** Map a snapshot-flow failure (which may carry git-unavailable) onto the operation error shape. */
102
+ export function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, { ok: false }>['error']): GitActionResult & { ok: false } {
103
+ if (failure.code === 'git-unavailable') {
104
+ return { ok: false, error: { code: 'git-error', message: failure.detail } }
105
+ }
106
+ return { ok: false, error: failure }
107
+ }
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
+
122
+ /**
123
+ * Execute one management action against the session's repository and return
124
+ * the refreshed snapshot on success (the caller re-renders from it, so the
125
+ * UI never waits for the next poll).
126
+ */
127
+ export async function runAction(
128
+ deps: SnapshotDeps,
129
+ config: GitStatusConfig,
130
+ request: GitActionRequest,
131
+ ): Promise<GitActionResult> {
132
+ const workspace = await resolveWorkspace(deps, request.sessionId)
133
+ if (!workspace.ok) return operationError(workspace.error)
134
+ const root = workspace.root
135
+
136
+ if (request.action.kind === 'commit' && request.action.message.trim() === '') {
137
+ return { ok: false, error: { code: 'git-error', message: 'commit message is empty' } }
138
+ }
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
+
148
+ const built = buildArgv(request.action, root)
149
+ if ('error' in built) return { ok: false, error: { code: 'invalid-path', message: built.error } }
150
+
151
+ // Run the command sequence; a failure stops the rest. 先行命令可能已生效:
152
+ // restore 类命令幂等可重入;两步提交若 add 成功后 commit 失败,所选路径
153
+ // 留在暂存区(IDE 行为相同,下次重试即可成功)。
154
+ let lastStdout = ''
155
+ for (const argv of built.argv) {
156
+ const outcome = await runCommand(deps.run, argv, root, `action ${request.action.kind}`, deps.signal)
157
+ if ('failure' in outcome) return operationError(outcome.failure)
158
+ if (outcome.run.timedOut) return { ok: false, error: { code: 'timeout' } }
159
+ if (outcome.run.exitCode !== 0) {
160
+ // git writes user-facing failures to stderr OR stdout (e.g. a clean
161
+ // repo's `git commit` reports "nothing to commit" on stdout).
162
+ const message = outcome.run.stderr.trim() || outcome.run.stdout.trim()
163
+ const code = classifyOperationError(request.action.kind, message)
164
+ return {
165
+ ok: false,
166
+ error: {
167
+ code,
168
+ message: message !== '' ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`,
169
+ },
170
+ }
171
+ }
172
+ lastStdout = outcome.run.stdout.trim()
173
+ }
174
+
175
+ const snapshot = await snapshotForSession(deps, config, request.sessionId)
176
+ if (!snapshot.ok) return operationError(snapshot.error)
177
+ return { ok: true, snapshot: snapshot.value, ...(lastStdout === '' ? {} : { output: lastStdout }) }
178
+ }
package/src/host/core.ts CHANGED
@@ -89,7 +89,7 @@ function runFailure(result: { readonly timedOut: boolean }, detail: string): Ext
89
89
  }
90
90
 
91
91
  /** Run one command, mapping a spawn-level failure to a snapshot failure. */
92
- async function runCommand(
92
+ export async function runCommand(
93
93
  runner: GitRunner,
94
94
  argv: readonly string[],
95
95
  cwd: string,
@@ -104,20 +104,18 @@ async function runCommand(
104
104
  }
105
105
 
106
106
  /**
107
- * Build one frozen GitSnapshot for a session working directory.
108
- * Command sequence (all read-only; every command after the first runs with
109
- * the repository root as cwd):
110
- * 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
111
- * 2. `git branch --show-current` — null when detached
112
- * 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
113
- * 4. `git status --porcelain=v1 -z --branch`
114
- * 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
107
+ * Resolve a session's repository workspace: cwd (live or persisted), the
108
+ * realpath'd directory, and the git work-tree root via `rev-parse
109
+ * --show-toplevel`. Shared by the snapshot flow and the operation runner.
115
110
  */
116
- export async function snapshotForSession(
111
+ export type WorkspaceResolution =
112
+ | { readonly ok: true; readonly cwd: string; readonly root: string }
113
+ | { readonly ok: false; readonly error: Extract<GitSnapshotResult, { ok: false }>['error'] }
114
+
115
+ export async function resolveWorkspace(
117
116
  deps: SnapshotDeps,
118
- config: GitStatusConfig,
119
117
  sessionId: string,
120
- ): Promise<GitSnapshotResult> {
118
+ ): Promise<WorkspaceResolution> {
121
119
  const resolved = await resolveCwd(deps.sessions, sessionId)
122
120
  if (!resolved.ok) return { ok: false, error: resolved.error }
123
121
 
@@ -149,6 +147,33 @@ export async function snapshotForSession(
149
147
  }
150
148
  const root = toplevel.run.stdout.trim()
151
149
  if (root === '') return { ok: false, error: { code: 'not-a-git-repo' } }
150
+ return { ok: true, cwd: realCwd, root }
151
+ }
152
+
153
+ /**
154
+ * Build one frozen GitSnapshot for a session working directory.
155
+ * Command sequence (all read-only; every command after the first runs with
156
+ * the repository root as cwd):
157
+ * 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
158
+ * 2. `git branch --show-current` — null when detached
159
+ * 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
160
+ * 4. `git status --porcelain=v1 -z --branch --untracked-files=all`
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 的构建产物)经此路径优雅降级。
168
+ */
169
+ export async function snapshotForSession(
170
+ deps: SnapshotDeps,
171
+ config: GitStatusConfig,
172
+ sessionId: string,
173
+ ): Promise<GitSnapshotResult> {
174
+ const workspace = await resolveWorkspace(deps, sessionId)
175
+ if (!workspace.ok) return { ok: false, error: workspace.error }
176
+ const root = workspace.root
152
177
 
153
178
  const branchRun = await runCommand(deps.run, ['git', 'branch', '--show-current'], root, 'branch', deps.signal)
154
179
  if ('failure' in branchRun) return { ok: false, error: branchRun.failure }
@@ -163,7 +188,8 @@ export async function snapshotForSession(
163
188
  // main`), so a corrupt repo is never misreported as "no commits".
164
189
  const head = headRun.run.exitCode === 0 ? (headRun.run.stdout.trim() || null) : null
165
190
 
166
- 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)
167
193
  if ('failure' in status) return { ok: false, error: status.failure }
168
194
  if (status.run.timedOut) return { ok: false, error: { code: 'timeout' } }
169
195
  if (status.run.exitCode !== 0) {
package/src/host/index.ts CHANGED
@@ -1,22 +1,27 @@
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` behind injected
5
- * structural faces, so tests never need a cordis runtime. The class is a
6
- * plugin in its own right (class form), mounted by the bundle patch row with
7
- * the package name; the gateway exposes `gitInfo/snapshot` through SRC
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
8
9
  * discovery (`typertRemote` binding + `@Remote` marker).
9
10
  */
10
11
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
11
12
  import type { Context } from '@deepseek-ai/cordis'
12
13
  import { realpath, stat } from 'node:fs/promises'
13
14
  import { createGitRunner, type SubprocessLike } from './git.ts'
14
- import { normalizeConfig, snapshotForSession, type GitStatusConfig } from './core.ts'
15
- import type { GitSnapshotRequest, GitSnapshotResult } from './types.ts'
15
+ import { normalizeConfig, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
16
+ import { runAction } from './actions.ts'
17
+ import { runQuery } from './queries.ts'
18
+ import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
16
19
 
17
- export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange } 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'
18
21
  export { normalizeConfig, DEFAULT_CONFIG } from './core.ts'
19
- export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts'
22
+ export { parseStatusOutput, parseLogOutput, parseBranchOutput, parseNameStatusOutput } from './parser.ts'
23
+ export { isSafePath, isValidBranchName, runAction } from './actions.ts'
24
+ export { runQuery } from './queries.ts'
20
25
 
21
26
  /** Structural face of a live session header. */
22
27
  interface SessionLike {
@@ -33,7 +38,7 @@ interface SessionPersistenceLike {
33
38
  inspect(id: string): Promise<{ readonly meta: { readonly cwd?: string } }>
34
39
  }
35
40
 
36
- /** The `gitInfo` service: one `snapshot` Remote endpoint. */
41
+ /** The `gitInfo` service: `snapshot` (read) and `run` (management) endpoints. */
37
42
  export class GitStatusService extends TypertRemoteService {
38
43
  static inject = ['subprocess', 'sessions', 'sessionPersistence']
39
44
 
@@ -44,17 +49,17 @@ export class GitStatusService extends TypertRemoteService {
44
49
  this.config = normalizeConfig(config)
45
50
  }
46
51
 
47
- @Remote('snapshot')
48
- async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
52
+ /** Adapter face shared by both endpoints (injected services + runner). */
53
+ private deps(signal?: AbortSignal): { readonly deps: SnapshotDeps } | { readonly failure: { readonly code: 'git-unavailable'; readonly detail: string } } {
49
54
  const subprocess = this.ctx.get('subprocess') as SubprocessLike | undefined
50
55
  if (subprocess === undefined) {
51
- return { ok: false, error: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
56
+ return { failure: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
52
57
  }
53
58
  const sessions = this.ctx.get('sessions') as SessionsLike | undefined
54
59
  const persistence = this.ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
55
60
  const runner = createGitRunner(subprocess, this.config.timeoutMs, this.config.maxStatusBytes)
56
- return snapshotForSession(
57
- {
61
+ return {
62
+ deps: {
58
63
  run: runner,
59
64
  fs: { realpath, stat },
60
65
  sessions: {
@@ -71,9 +76,32 @@ export class GitStatusService extends TypertRemoteService {
71
76
  },
72
77
  signal,
73
78
  },
74
- this.config,
75
- request.sessionId,
76
- )
79
+ }
80
+ }
81
+
82
+ @Remote('snapshot')
83
+ async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
84
+ const adapted = this.deps(signal)
85
+ if ('failure' in adapted) return { ok: false, error: adapted.failure }
86
+ return snapshotForSession(adapted.deps, this.config, request.sessionId)
87
+ }
88
+
89
+ @Remote('run')
90
+ async run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult> {
91
+ const adapted = this.deps(signal)
92
+ if ('failure' in adapted) {
93
+ return { ok: false, error: { code: 'git-error', message: adapted.failure.detail } }
94
+ }
95
+ return runAction(adapted.deps, this.config, request)
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)
77
105
  }
78
106
  }
79
107
 
@@ -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
+