dsh-git-ui 0.0.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Turn-completion signal for the git pill's activity trigger.
3
+ *
4
+ * The client runtime's ConversationSnapshot carries `turnEnds` — completed
5
+ * turn numbers inside the snapshot window. A finished agent turn is the most
6
+ * likely moment the working tree changed, so the pill watches the highest
7
+ * completed turn and refreshes when it rises (best-effort; polling remains
8
+ * the fallback). Kept React-free so the logic is unit-testable without a
9
+ * browser runtime.
10
+ */
11
+
12
+ /**
13
+ * The slice of the conversation snapshot the pill observes for activity.
14
+ * `turnEnds` maps completed turn numbers to their closing event seq inside
15
+ * the snapshot window (source: the client runtime's ConversationSnapshot).
16
+ */
17
+ export interface TurnSignalSnapshot {
18
+ readonly turnEnds: ReadonlyMap<number, number>
19
+ }
20
+
21
+ /**
22
+ * Highest completed turn number in the snapshot window, 0 when none.
23
+ * Monotonic across ordinary activity (a finished turn only ever adds a
24
+ * larger key), so `next > prev` is a reliable "an agent turn just ended"
25
+ * edge — the best-effort trigger for an immediate refresh.
26
+ */
27
+ export function completedTurnCount(snapshot: TurnSignalSnapshot): number {
28
+ let max = 0
29
+ for (const turn of snapshot.turnEnds.keys()) {
30
+ if (turn > max) max = turn
31
+ }
32
+ return max
33
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Framework-free snapshot orchestration: session cwd resolution + git command
3
+ * sequence + frozen GitSnapshot assembly. Every dependency is injected
4
+ * structurally, so the whole flow is testable without a cordis runtime; the
5
+ * cordis shell (GitStatusService) only adapts host services into these faces.
6
+ */
7
+ import { parseBranchOutput, parseLogOutput, parseStatusOutput } from './parser.ts'
8
+ import type { GitRunner } from './git.ts'
9
+ import type { GitSnapshot, GitSnapshotResult } from './types.ts'
10
+
11
+ /** Resolved plugin config (already normalized; see normalizeConfig). */
12
+ export interface GitStatusConfig {
13
+ readonly timeoutMs: number
14
+ readonly maxStatusBytes: number
15
+ readonly maxChanges: number
16
+ readonly defaultRefreshIntervalMs: number
17
+ }
18
+
19
+ /** Session identity lookup: live first, persisted fallback. */
20
+ export interface SessionLookup {
21
+ /** Live session cwd; undefined when the session is cold or absent in memory. */
22
+ liveCwd(sessionId: string): string | undefined
23
+ /**
24
+ * Persisted session metadata; resolves to undefined when no persisted
25
+ * session exists, and to `{ cwd }` (cwd possibly undefined) otherwise.
26
+ */
27
+ persistedMeta(sessionId: string): Promise<{ readonly cwd?: string } | undefined>
28
+ }
29
+
30
+ /** Filesystem primitives (node:fs/promises slices). */
31
+ export interface FsLike {
32
+ realpath(path: string): Promise<string>
33
+ stat(path: string): Promise<{ isDirectory(): boolean }>
34
+ }
35
+
36
+ /** Everything the snapshot flow needs beyond the session lookup. */
37
+ export interface SnapshotDeps {
38
+ readonly run: GitRunner
39
+ readonly fs: FsLike
40
+ readonly sessions: SessionLookup
41
+ /** Injectable clock for deterministic tests. */
42
+ readonly now?: () => number
43
+ /** Caller-side cancellation (Remote `signal` slot): aborts in-flight git runs. */
44
+ readonly signal?: AbortSignal
45
+ }
46
+
47
+ /** Defaults applied by normalizeConfig when a value is absent or invalid. */
48
+ export const DEFAULT_CONFIG: GitStatusConfig = {
49
+ timeoutMs: 5000,
50
+ maxStatusBytes: 4 * 1024 * 1024,
51
+ maxChanges: 100,
52
+ defaultRefreshIntervalMs: 30_000,
53
+ }
54
+
55
+ /** Coerce a raw patch config value into a validated GitStatusConfig. */
56
+ export function normalizeConfig(raw: unknown): GitStatusConfig {
57
+ const value = (raw ?? {}) as Record<string, unknown>
58
+ const numberOr = (key: string, fallback: number): number => {
59
+ const candidate = value[key]
60
+ return typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0
61
+ ? candidate
62
+ : fallback
63
+ }
64
+ return {
65
+ timeoutMs: numberOr('timeoutMs', DEFAULT_CONFIG.timeoutMs) || DEFAULT_CONFIG.timeoutMs,
66
+ maxStatusBytes: numberOr('maxStatusBytes', DEFAULT_CONFIG.maxStatusBytes) || DEFAULT_CONFIG.maxStatusBytes,
67
+ maxChanges: Math.floor(numberOr('maxChanges', DEFAULT_CONFIG.maxChanges) || DEFAULT_CONFIG.maxChanges),
68
+ defaultRefreshIntervalMs: numberOr('defaultRefreshIntervalMs', DEFAULT_CONFIG.defaultRefreshIntervalMs),
69
+ }
70
+ }
71
+
72
+ /** Outcome of the cwd resolution step. */
73
+ type CwdResolution =
74
+ | { readonly ok: true; readonly cwd: string }
75
+ | { readonly ok: false; readonly error: Extract<GitSnapshotResult, { ok: false }>['error'] }
76
+
77
+ async function resolveCwd(sessions: SessionLookup, sessionId: string): Promise<CwdResolution> {
78
+ const live = sessions.liveCwd(sessionId)
79
+ if (live !== undefined) return { ok: true, cwd: live }
80
+ const persisted = await sessions.persistedMeta(sessionId)
81
+ if (persisted === undefined) return { ok: false, error: { code: 'session-not-found', sessionId } }
82
+ if (persisted.cwd === undefined) return { ok: false, error: { code: 'cwd-unavailable', sessionId } }
83
+ return { ok: true, cwd: persisted.cwd }
84
+ }
85
+
86
+ /** Classify a failed run outcome into a snapshot failure. */
87
+ function runFailure(result: { readonly timedOut: boolean }, detail: string): Extract<GitSnapshotResult, { ok: false }>['error'] {
88
+ return result.timedOut ? { code: 'timeout' } : { code: 'git-unavailable', detail }
89
+ }
90
+
91
+ /** Run one command, mapping a spawn-level failure to a snapshot failure. */
92
+ async function runCommand(
93
+ runner: GitRunner,
94
+ argv: readonly string[],
95
+ cwd: string,
96
+ label: string,
97
+ signal?: AbortSignal,
98
+ ): Promise<{ readonly run: Awaited<ReturnType<GitRunner['run']>> } | { readonly failure: Extract<GitSnapshotResult, { ok: false }>['error'] }> {
99
+ try {
100
+ return { run: await runner.run(argv, { cwd, ...(signal === undefined ? {} : { signal }) }) }
101
+ } catch (error) {
102
+ return { failure: { code: 'git-unavailable', detail: `${label}: ${error instanceof Error ? error.message : String(error)}` } }
103
+ }
104
+ }
105
+
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`
115
+ */
116
+ export async function snapshotForSession(
117
+ deps: SnapshotDeps,
118
+ config: GitStatusConfig,
119
+ sessionId: string,
120
+ ): Promise<GitSnapshotResult> {
121
+ const resolved = await resolveCwd(deps.sessions, sessionId)
122
+ if (!resolved.ok) return { ok: false, error: resolved.error }
123
+
124
+ let realCwd: string
125
+ try {
126
+ realCwd = await deps.fs.realpath(resolved.cwd)
127
+ const stat = await deps.fs.stat(realCwd)
128
+ if (!stat.isDirectory()) {
129
+ return { ok: false, error: { code: 'path-not-found', path: realCwd } }
130
+ }
131
+ } catch {
132
+ return { ok: false, error: { code: 'path-not-found', path: resolved.cwd } }
133
+ }
134
+
135
+ const toplevel = await runCommand(deps.run, ['git', 'rev-parse', '--show-toplevel'], realCwd, 'rev-parse', deps.signal)
136
+ if ('failure' in toplevel) return { ok: false, error: toplevel.failure }
137
+ if (toplevel.run.timedOut) return { ok: false, error: { code: 'timeout' } }
138
+ if (toplevel.run.exitCode !== 0) {
139
+ // exit 128 covers both "not a git repository" (plain directory) and
140
+ // other git failures (dubious ownership, unreadable work tree, …).
141
+ // Only the former is a stable non-repo state; everything else surfaces
142
+ // as git-unavailable with the actual reason instead of a misleading
143
+ // "no git repository" pill.
144
+ const stderr = toplevel.run.stderr
145
+ if (!stderr.includes('not a git repository')) {
146
+ return { ok: false, error: runFailure(toplevel.run, `git rev-parse failed: ${stderr.trim() || `exit ${String(toplevel.run.exitCode)}`}`) }
147
+ }
148
+ return { ok: false, error: { code: 'not-a-git-repo' } }
149
+ }
150
+ const root = toplevel.run.stdout.trim()
151
+ if (root === '') return { ok: false, error: { code: 'not-a-git-repo' } }
152
+
153
+ const branchRun = await runCommand(deps.run, ['git', 'branch', '--show-current'], root, 'branch', deps.signal)
154
+ if ('failure' in branchRun) return { ok: false, error: branchRun.failure }
155
+ if (branchRun.run.timedOut) return { ok: false, error: { code: 'timeout' } }
156
+ const branch = branchRun.run.exitCode === 0 ? parseBranchOutput(branchRun.run.stdout) : null
157
+
158
+ const headRun = await runCommand(deps.run, ['git', 'rev-parse', '--short', 'HEAD'], root, 'rev-parse HEAD', deps.signal)
159
+ if ('failure' in headRun) return { ok: false, error: headRun.failure }
160
+ if (headRun.run.timedOut) return { ok: false, error: { code: 'timeout' } }
161
+ // A failed HEAD read (non-timeout) only nulls the hash: the authoritative
162
+ // unborn flag comes from the status header below (`## No commits yet on
163
+ // main`), so a corrupt repo is never misreported as "no commits".
164
+ const head = headRun.run.exitCode === 0 ? (headRun.run.stdout.trim() || null) : null
165
+
166
+ const status = await runCommand(deps.run, ['git', 'status', '--porcelain=v1', '-z', '--branch'], root, 'status', deps.signal)
167
+ if ('failure' in status) return { ok: false, error: status.failure }
168
+ if (status.run.timedOut) return { ok: false, error: { code: 'timeout' } }
169
+ if (status.run.exitCode !== 0) {
170
+ return { ok: false, error: runFailure(status.run, `git status exited ${String(status.run.exitCode)}`) }
171
+ }
172
+ const parsed = parseStatusOutput(status.run.stdout, config.maxChanges)
173
+
174
+ const log = await runCommand(deps.run, ['git', 'log', '-n', '5', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI'], root, 'log', deps.signal)
175
+ if ('failure' in log) return { ok: false, error: log.failure }
176
+ if (log.run.timedOut) return { ok: false, error: { code: 'timeout' } }
177
+ const recentCommits = log.run.exitCode === 0 ? parseLogOutput(log.run.stdout) : []
178
+
179
+ const checkedAt = deps.now?.() ?? Date.now()
180
+ const snapshot: GitSnapshot = {
181
+ root,
182
+ branch,
183
+ head,
184
+ unborn: parsed.unborn,
185
+ dirty: parsed.staged + parsed.modified + parsed.untracked > 0,
186
+ staged: parsed.staged,
187
+ modified: parsed.modified,
188
+ untracked: parsed.untracked,
189
+ ahead: parsed.ahead,
190
+ behind: parsed.behind,
191
+ lastCommit: recentCommits[0] ?? null,
192
+ recentCommits,
193
+ changes: parsed.changes,
194
+ truncated: parsed.truncated || ('run' in status && status.run.stdoutLossy),
195
+ refreshIntervalMs: config.defaultRefreshIntervalMs,
196
+ checkedAt,
197
+ }
198
+ return { ok: true, value: snapshot }
199
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Git command execution adapter over the host subprocess service.
3
+ *
4
+ * The widget only needs a tiny slice of the subprocess contract; declaring it
5
+ * structurally here (instead of depending on the npm package, whose registry
6
+ * chain is incomplete) keeps the plugin buildable standalone while remaining
7
+ * wire-compatible with the host's `subprocess` service.
8
+ */
9
+ import { readFile } from 'node:fs/promises'
10
+
11
+ /** One collected stream disposition (matches the host SubprocessCollect). */
12
+ interface CollectDisposition {
13
+ readonly collect: {
14
+ readonly maxBytes: number
15
+ /**
16
+ * Spill disposition: when the stream overflows the in-memory tail, the
17
+ * host appends the COMPLETE stream to a private spill file (up to this
18
+ * cap) and `readFrom` reports its path. Without it, only the tail is
19
+ * ever retained and the head (and its change counts) is lost.
20
+ */
21
+ readonly spill?: { readonly maxBytes: number }
22
+ }
23
+ }
24
+
25
+ /** Structural slice of the host subprocess spawn spec. */
26
+ interface SpawnSpec {
27
+ readonly argv: readonly string[]
28
+ readonly cwd: string
29
+ readonly stdio: {
30
+ readonly stdout: CollectDisposition
31
+ readonly stderr: CollectDisposition
32
+ }
33
+ readonly graceMs: number
34
+ readonly signal?: AbortSignal
35
+ }
36
+
37
+ /** Structural slice of the host subprocess handle (collect-mode output). */
38
+ interface SpawnHandle {
39
+ readonly done: Promise<{ readonly exitCode: number | null; readonly signal: NodeJS.Signals | null }>
40
+ readonly collected: {
41
+ readonly stdout?: {
42
+ readFrom(fromByte: number): { readonly text: string; readonly lossy: boolean; readonly spillPath?: string }
43
+ }
44
+ readonly stderr?: {
45
+ readFrom(fromByte: number): { readonly text: string; readonly lossy: boolean; readonly spillPath?: string }
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Minimal subprocess-service face the adapter consumes. */
51
+ export interface SubprocessLike {
52
+ spawn(spec: SpawnSpec): SpawnHandle
53
+ }
54
+
55
+ /** One git command outcome. */
56
+ export interface GitRunResult {
57
+ /** Process exit code; null when terminated by a signal. */
58
+ readonly exitCode: number | null
59
+ readonly stdout: string
60
+ readonly stderr: string
61
+ /** True when the run was killed by our timeout (or the caller's signal). */
62
+ readonly timedOut: boolean
63
+ /**
64
+ * True when the final stdout text is still incomplete: the collected
65
+ * output overflowed its byte cap AND the spill file was unavailable (no
66
+ * spill configured on the host, or the spill cap also overflowed).
67
+ */
68
+ readonly stdoutLossy: boolean
69
+ }
70
+
71
+ /** The run primitive the snapshot orchestration uses. */
72
+ export interface GitRunner {
73
+ run(argv: readonly string[], opts: { readonly cwd: string; readonly signal?: AbortSignal }): Promise<GitRunResult>
74
+ }
75
+
76
+ /**
77
+ * Adapt the host subprocess service into a `GitRunner` with a per-command
78
+ * timeout. A timed-out run resolves (never rejects) with `timedOut: true`;
79
+ * only spawn-level failures (e.g. git not installed) reject.
80
+ *
81
+ * Overflow handling: stdout/stderr collect with a spill cap of
82
+ * `maxBytes * 16` (default 4 MiB memory tail → 64 MiB spill file). When the
83
+ * tail overflowed but the spill file holds the complete stream, the runner
84
+ * reads the file and reports `stdoutLossy: false` — the change COUNTS stay
85
+ * exact. `stdoutLossy: true` is reserved for the doubly-overflowed case
86
+ * (spill also exceeded), where the head is genuinely lost.
87
+ */
88
+ export function createGitRunner(subprocess: SubprocessLike, timeoutMs: number, maxBytes: number): GitRunner {
89
+ const spillMaxBytes = maxBytes * 16
90
+ return {
91
+ async run(argv, opts) {
92
+ const controller = new AbortController()
93
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
94
+ try {
95
+ const signal = opts.signal === undefined
96
+ ? controller.signal
97
+ : AbortSignal.any([controller.signal, opts.signal])
98
+ const handle = subprocess.spawn({
99
+ argv,
100
+ cwd: opts.cwd,
101
+ stdio: {
102
+ stdout: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } },
103
+ stderr: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } },
104
+ },
105
+ graceMs: 200,
106
+ signal,
107
+ })
108
+ let outcome: Awaited<SpawnHandle['done']>
109
+ try {
110
+ // `done` rejects for spawn-level failures; an abort-triggered
111
+ // rejection is the timeout path and resolves as timedOut.
112
+ outcome = await handle.done
113
+ } catch (error) {
114
+ if (controller.signal.aborted || opts.signal?.aborted === true) {
115
+ return { exitCode: null, stdout: '', stderr: '', timedOut: true, stdoutLossy: false }
116
+ }
117
+ throw error
118
+ }
119
+ const stdout = handle.collected.stdout?.readFrom(0)
120
+ const stderr = handle.collected.stderr?.readFrom(0)
121
+ const stdoutResolved = await resolveStdout(stdout)
122
+ return {
123
+ exitCode: outcome.exitCode,
124
+ stdout: stdoutResolved.text,
125
+ stderr: stderr?.text ?? '',
126
+ timedOut: controller.signal.aborted || opts.signal?.aborted === true,
127
+ stdoutLossy: stdoutResolved.lossy,
128
+ }
129
+ } finally {
130
+ clearTimeout(timer)
131
+ }
132
+ },
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Resolve the stdout text from a collect read: the in-memory tail, or — when
138
+ * the read is lossy and the host spilled the complete stream to a file — the
139
+ * spill file contents (so change COUNTS stay exact). A failed spill read
140
+ * falls back to the tail and keeps `lossy: true` (head genuinely lost).
141
+ */
142
+ async function resolveStdout(
143
+ read: { readonly text: string; readonly lossy: boolean; readonly spillPath?: string } | undefined,
144
+ ): Promise<{ readonly text: string; readonly lossy: boolean }> {
145
+ if (read === undefined) return { text: '', lossy: false }
146
+ if (!read.lossy || read.spillPath === undefined) return { text: read.text, lossy: read.lossy }
147
+ try {
148
+ return { text: await readFile(read.spillPath, 'utf8'), lossy: false }
149
+ } catch {
150
+ return { text: read.text, lossy: true }
151
+ }
152
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * dsh-git-ui host half: the `gitInfo` Remote service.
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
8
+ * discovery (`typertRemote` binding + `@Remote` marker).
9
+ */
10
+ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import { realpath, stat } from 'node:fs/promises'
13
+ import { createGitRunner, type SubprocessLike } from './git.ts'
14
+ import { normalizeConfig, snapshotForSession, type GitStatusConfig } from './core.ts'
15
+ import type { GitSnapshotRequest, GitSnapshotResult } from './types.ts'
16
+
17
+ export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange } from './types.ts'
18
+ export { normalizeConfig, DEFAULT_CONFIG } from './core.ts'
19
+ export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts'
20
+
21
+ /** Structural face of a live session header. */
22
+ interface SessionLike {
23
+ readonly header?: { readonly cwd?: string }
24
+ }
25
+
26
+ /** Structural face of the sessions service. */
27
+ interface SessionsLike {
28
+ get(id: string): SessionLike | undefined
29
+ }
30
+
31
+ /** Structural face of the session-persistence service. */
32
+ interface SessionPersistenceLike {
33
+ inspect(id: string): Promise<{ readonly meta: { readonly cwd?: string } }>
34
+ }
35
+
36
+ /** The `gitInfo` service: one `snapshot` Remote endpoint. */
37
+ export class GitStatusService extends TypertRemoteService {
38
+ static inject = ['subprocess', 'sessions', 'sessionPersistence']
39
+
40
+ private readonly config: GitStatusConfig
41
+
42
+ constructor(ctx: Context, config: unknown) {
43
+ super(ctx, 'gitInfo')
44
+ this.config = normalizeConfig(config)
45
+ }
46
+
47
+ @Remote('snapshot')
48
+ async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
49
+ const subprocess = this.ctx.get('subprocess') as SubprocessLike | undefined
50
+ if (subprocess === undefined) {
51
+ return { ok: false, error: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
52
+ }
53
+ const sessions = this.ctx.get('sessions') as SessionsLike | undefined
54
+ const persistence = this.ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
55
+ const runner = createGitRunner(subprocess, this.config.timeoutMs, this.config.maxStatusBytes)
56
+ return snapshotForSession(
57
+ {
58
+ run: runner,
59
+ fs: { realpath, stat },
60
+ sessions: {
61
+ liveCwd: (id) => sessions?.get(id)?.header?.cwd,
62
+ persistedMeta: async (id) => {
63
+ if (persistence === undefined) return undefined
64
+ try {
65
+ const inspection = await persistence.inspect(id)
66
+ return { cwd: inspection.meta.cwd }
67
+ } catch {
68
+ return undefined
69
+ }
70
+ },
71
+ },
72
+ signal,
73
+ },
74
+ this.config,
75
+ request.sessionId,
76
+ )
77
+ }
78
+ }
79
+
80
+ export default GitStatusService
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Pure parsers for the git porcelain/log output shapes used by the widget.
3
+ * No side effects and no I/O — fully unit-testable against literal fixtures
4
+ * (verified against real `git status --porcelain=v1 -z --branch` output).
5
+ */
6
+ import type { GitChange, GitChangeStatus, GitCommit } from './types.ts'
7
+
8
+ /** Parsed status counts plus the (possibly capped) change list. */
9
+ export interface ParsedStatus {
10
+ readonly branch: string | null
11
+ readonly unborn: boolean
12
+ readonly staged: number
13
+ readonly modified: number
14
+ readonly untracked: number
15
+ readonly ahead: number
16
+ readonly behind: number
17
+ readonly changes: readonly GitChange[]
18
+ readonly truncated: boolean
19
+ }
20
+
21
+ /** The NUL byte separating porcelain v1 -z entries. */
22
+ const NUL = '\u0000'
23
+ /** The unit separator used by the log --format payload. */
24
+ const LOG_SEP = '\u001f'
25
+
26
+ interface StatusHeader {
27
+ readonly branch: string | null
28
+ readonly unborn: boolean
29
+ readonly ahead: number
30
+ readonly behind: number
31
+ }
32
+
33
+ /**
34
+ * Parse the `## ` header line of `git status --porcelain=v1 -z --branch`.
35
+ * Recognized shapes (verified against git 2.x):
36
+ * `## main`
37
+ * `## main...origin/main`
38
+ * `## main...origin/main [ahead 1]`
39
+ * `## main...origin/main [behind 2]`
40
+ * `## main...origin/main [ahead 1, behind 2]`
41
+ * `## HEAD (no branch)` (detached)
42
+ * `## HEAD (detached at <hash>)` (detached, older git)
43
+ * `## No commits yet on main` (unborn)
44
+ * `## Initial commit on main` (unborn, older git)
45
+ */
46
+ export function parseStatusHeader(line: string): StatusHeader {
47
+ const body = line.startsWith('## ') ? line.slice(3) : line
48
+ if (body === '') return { branch: null, unborn: false, ahead: 0, behind: 0 }
49
+
50
+ const unbornMatch = /^(?:No commits yet on|Initial commit on)\s+(.+)$/.exec(body)
51
+ if (unbornMatch !== null) {
52
+ return { branch: unbornMatch[1] ?? null, unborn: true, ahead: 0, behind: 0 }
53
+ }
54
+
55
+ const detached = /^HEAD(?:\s+\([^)]*\))?$/.exec(body)
56
+ if (detached !== null) {
57
+ return { branch: null, unborn: false, ahead: 0, behind: 0 }
58
+ }
59
+
60
+ const bracketMatch = /^(.*?)\s*\[([^\]]+)\]$/.exec(body)
61
+ const core = bracketMatch?.[1] ?? body
62
+ let ahead = 0
63
+ let behind = 0
64
+ if (bracketMatch?.[2] !== undefined) {
65
+ for (const part of bracketMatch[2].split(',')) {
66
+ const trimmed = part.trim()
67
+ const aheadMatch = /^ahead (\d+)$/.exec(trimmed)
68
+ const behindMatch = /^behind (\d+)$/.exec(trimmed)
69
+ if (aheadMatch !== null) ahead = Number(aheadMatch[1])
70
+ if (behindMatch !== null) behind = Number(behindMatch[1])
71
+ }
72
+ }
73
+ // The core is `<branch>...<upstream>` — the branch never contains `...`.
74
+ const branch = core.split('...', 1)[0] ?? core
75
+ return { branch: branch === '' ? null : branch, unborn: false, ahead, behind }
76
+ }
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) {
83
+ case 'A': return 'added'
84
+ case 'M': return 'modified'
85
+ case 'D': return 'deleted'
86
+ case 'R': return 'renamed'
87
+ case 'T': return 'typechange'
88
+ case 'C': return 'added'
89
+ default: return 'modified'
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Parse the full `git status --porcelain=v1 -z --branch` output.
95
+ * -z format: every entry (header and each `XY path`) is NUL-terminated; a
96
+ * rename/copy entry emits `R <new>\0<old>\0` so the following item is the
97
+ * source path and must be consumed without becoming a change itself.
98
+ */
99
+ export function parseStatusOutput(output: string, maxChanges: number): ParsedStatus {
100
+ const raw = output.split(NUL)
101
+ // Trailing NUL produces a final empty segment; drop it.
102
+ const segments = raw[raw.length - 1] === '' ? raw.slice(0, -1) : raw
103
+ const header = parseStatusHeader(segments[0] ?? '')
104
+
105
+ let staged = 0
106
+ let modified = 0
107
+ let untracked = 0
108
+ const changes: GitChange[] = []
109
+ let truncated = false
110
+
111
+ for (let index = 1; index < segments.length; index += 1) {
112
+ const entry = segments[index] ?? ''
113
+ const x = entry[0] ?? ' '
114
+ const y = entry[1] ?? ' '
115
+ const path = entry.slice(3)
116
+ if (x === ' ' && y === ' ') continue
117
+ if (x === 'R' || x === 'C') {
118
+ // -z: the source path is the next segment — consume it.
119
+ index += 1
120
+ }
121
+ if (x === '?' && y === '?') {
122
+ untracked += 1
123
+ } else {
124
+ if (x !== ' ' && x !== '?') staged += 1
125
+ if (y !== ' ' && y !== '?') modified += 1
126
+ }
127
+ if (changes.length < maxChanges) {
128
+ changes.push({ path, status: changeStatus(x, y), staged: x !== ' ' && x !== '?' })
129
+ } else {
130
+ truncated = true
131
+ }
132
+ }
133
+
134
+ return {
135
+ branch: header.branch,
136
+ unborn: header.unborn,
137
+ staged,
138
+ modified,
139
+ untracked,
140
+ ahead: header.ahead,
141
+ behind: header.behind,
142
+ changes,
143
+ truncated,
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Parse `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI` output.
149
+ * One commit per line, fields separated by the unit separator; empty output
150
+ * (unborn repository) yields `[]`.
151
+ */
152
+ export function parseLogOutput(output: string): readonly GitCommit[] {
153
+ const commits: GitCommit[] = []
154
+ for (const line of output.split('\n')) {
155
+ if (line === '') continue
156
+ const [hash, shortHash, subject, author, dateIso] = line.split(LOG_SEP)
157
+ if (hash === undefined || hash === '') continue
158
+ commits.push({
159
+ hash,
160
+ shortHash: shortHash ?? '',
161
+ subject: subject ?? '',
162
+ author: author ?? '',
163
+ dateIso: dateIso ?? '',
164
+ })
165
+ }
166
+ return commits
167
+ }
168
+
169
+ /**
170
+ * Parse `git branch --show-current` output: the branch name, or null when
171
+ * empty (detached HEAD).
172
+ */
173
+ export function parseBranchOutput(output: string): string | null {
174
+ const trimmed = output.trim()
175
+ return trimmed === '' ? null : trimmed
176
+ }