dsh-gitbar-lite 0.0.0-stage → 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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Diff text assembly.
3
+ *
4
+ * Everything here is pure: strings and Buffers in, strings out. That matters
5
+ * because the archived copy in the trash is the *only* record of a discarded
6
+ * change — `git restore` has already thrown the change away by the time anyone
7
+ * reads it — so the assembly has to be verifiable without a repository, and
8
+ * `test/diff.test.mjs` pins it line by line.
9
+ *
10
+ * Two jobs:
11
+ *
12
+ * 1. `synthesizeNewFileDiff` fills the one hole `git diff HEAD` leaves.
13
+ * Untracked paths are invisible to a diff against HEAD (they are not in
14
+ * HEAD and not in the index), yet discarding one deletes it, so without a
15
+ * synthesized diff the trash entry would say "one file, no content".
16
+ *
17
+ * 2. `limitDiff` caps what gets archived. A discarding user is having a bad
18
+ * day already; a 200 MB generated file must not turn into a 200 MB JSON
19
+ * write that then fails. The cap cuts on a *line* boundary, never
20
+ * mid-character, and records how many lines it dropped.
21
+ *
22
+ * @module dsh-gitbar-lite/host/diff
23
+ */
24
+
25
+ /** Bytes scanned when deciding whether a file is binary. */
26
+ const SNIFF_BYTES = 8000
27
+
28
+ /**
29
+ * Whether a file should be treated as binary, using git's own heuristic: a NUL
30
+ * byte within the first 8000 bytes.
31
+ *
32
+ * @param {Buffer | Uint8Array} bytes
33
+ */
34
+ export function looksBinary(bytes) {
35
+ const end = Math.min(bytes.length, SNIFF_BYTES)
36
+ for (let index = 0; index < end; index += 1) {
37
+ if (bytes[index] === 0) return true
38
+ }
39
+ return false
40
+ }
41
+
42
+ /** The `100644`/`100755` git mode text for a `fs.Stats`-like object. */
43
+ export function modeTextFor(stats) {
44
+ const mode = typeof stats?.mode === 'number' ? stats.mode : 0
45
+ return (mode & 0o111) !== 0 ? '100755' : '100644'
46
+ }
47
+
48
+ /**
49
+ * A unified diff for a file git does not track, shaped like the one git would
50
+ * have produced.
51
+ *
52
+ * Trailing `\r` is stripped from each line, matching what git shows for a
53
+ * repository with `text=auto` (this machine's global config has
54
+ * `core.autocrlf=true`, so a CRLF worktree file would otherwise diff
55
+ * differently from every tracked file next to it).
56
+ *
57
+ * @param {object} input
58
+ * @param {string} input.path repository-relative path
59
+ * @param {string} input.content decoded file content (ignored when binary)
60
+ * @param {boolean} [input.binary]
61
+ * @param {string} [input.mode] git mode text, from `modeTextFor`
62
+ * @returns {string}
63
+ */
64
+ export function synthesizeNewFileDiff({ path, content, binary = false, mode = '100644' }) {
65
+ const header = [`diff --git a/${path} b/${path}`, `new file mode ${mode}`]
66
+ if (binary) return `${[...header, `Binary files /dev/null and b/${path} differ`].join('\n')}\n`
67
+
68
+ const body = ['--- /dev/null', `+++ b/${path}`]
69
+ if (content === '') return `${[...header, ...body].join('\n')}\n`
70
+
71
+ const endsWithNewline = content.endsWith('\n')
72
+ const lines = content.split('\n')
73
+ if (endsWithNewline) lines.pop()
74
+ const textLines = lines.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
75
+
76
+ const hunks = [`@@ -0,0 +1,${textLines.length} @@`, ...textLines.map((line) => `+${line}`)]
77
+ if (!endsWithNewline) hunks.push('\')
78
+ return `${[...header, ...body, ...hunks].join('\n')}\n`
79
+ }
80
+
81
+ /**
82
+ * Cap a diff on a line boundary.
83
+ *
84
+ * @param {string} text
85
+ * @param {number} maxBytes ceiling for the returned text, in UTF-8 bytes
86
+ * @returns {{ text: string, truncated: boolean, droppedLines: number }}
87
+ */
88
+ export function limitDiff(text, maxBytes) {
89
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0) return { text, truncated: false, droppedLines: 0 }
90
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) return { text, truncated: false, droppedLines: 0 }
91
+
92
+ const lines = text.split('\n')
93
+ const kept = []
94
+ let used = 0
95
+ for (const line of lines) {
96
+ const cost = Buffer.byteLength(line, 'utf8') + 1
97
+ if (used + cost > maxBytes) break
98
+ kept.push(line)
99
+ used += cost
100
+ }
101
+
102
+ const dropped = lines.length - kept.length
103
+ const marker = `[dsh-gitbar-lite] diff truncated: ${dropped} more lines, ${Buffer.byteLength(text, 'utf8') - used} more bytes\n`
104
+ return { text: `${kept.join('\n')}\n${marker}`, truncated: true, droppedLines: dropped }
105
+ }
106
+
107
+ /**
108
+ * The path each block of a multi-file diff belongs to, for the reader's table
109
+ * of contents.
110
+ *
111
+ * `diff --git a/x b/y` is genuinely ambiguous when a path contains a space, so
112
+ * the more specific lines are preferred, in the order git emits them:
113
+ * `rename to` first, then the `+++`/`---` pair, then the header as a fallback.
114
+ *
115
+ * @param {string} text a full (possibly multi-file) diff
116
+ * @returns {{ path: string, text: string }[]}
117
+ */
118
+ export function splitDiffByFile(text) {
119
+ if (text === '') return []
120
+
121
+ const lines = text.split('\n')
122
+ const starts = []
123
+ for (let index = 0; index < lines.length; index += 1) {
124
+ if (lines[index].startsWith('diff --git ')) starts.push(index)
125
+ }
126
+ if (starts.length === 0) return []
127
+
128
+ const blocks = []
129
+ for (let index = 0; index < starts.length; index += 1) {
130
+ const from = starts[index]
131
+ const to = index + 1 < starts.length ? starts[index + 1] : lines.length
132
+ const block = lines.slice(from, to)
133
+ blocks.push({ path: pathOfBlock(block), text: block.join('\n') })
134
+ }
135
+ return blocks
136
+ }
137
+
138
+ /** Recover the new path from one `diff --git` block. */
139
+ function pathOfBlock(block) {
140
+ for (const line of block) {
141
+ if (line.startsWith('rename to ')) return line.slice('rename to '.length).trim()
142
+ }
143
+ for (const line of block) {
144
+ if (line.startsWith('+++ ')) {
145
+ const value = line.slice(4)
146
+ if (value === '/dev/null') continue
147
+ const path = value.startsWith('b/') ? value.slice(2) : value
148
+ const tab = path.indexOf('\t')
149
+ return (tab < 0 ? path : path.slice(0, tab)).trim()
150
+ }
151
+ }
152
+ for (const line of block) {
153
+ if (line.startsWith('--- ')) {
154
+ const value = line.slice(4)
155
+ if (value === '/dev/null') continue
156
+ const path = value.startsWith('a/') ? value.slice(2) : value
157
+ const tab = path.indexOf('\t')
158
+ return (tab < 0 ? path : path.slice(0, tab)).trim()
159
+ }
160
+ }
161
+ const header = block.find((line) => line.startsWith('diff --git '))
162
+ if (header === undefined) return ''
163
+ // Last resort: `diff --git a/x b/y` with no quoting (we always run with
164
+ // quotepath=false, so a path with spaces simply makes this ambiguous).
165
+ const body = header.slice('diff --git '.length)
166
+ const halves = body.split(' ')
167
+ const right = halves.length >= 2 ? halves[halves.length - 1] : body
168
+ return right.startsWith('b/') ? right.slice(2) : right
169
+ }
170
+
171
+ /**
172
+ * Join per-path diffs into the single blob a "discard all changes" entry
173
+ * archives, and report the per-file stats.
174
+ *
175
+ * @param {{ path: string, text: string }[]} parts
176
+ * @returns {string}
177
+ */
178
+ export function joinDiffParts(parts) {
179
+ return parts.map((part) => (part.text.endsWith('\n') ? part.text : `${part.text}\n`)).join('')
180
+ }
181
+
182
+ /**
183
+ * Count the `+`/`-` lines one diff block contributes, mirroring what
184
+ * `--numstat` reports (so the UI's numbers match whether they came from git or
185
+ * from a synthesized untracked diff).
186
+ *
187
+ * @param {string} text
188
+ * @returns {{ added: number, deleted: number }}
189
+ */
190
+ export function diffTotals(text) {
191
+ let added = 0
192
+ let deleted = 0
193
+ let inHunk = false
194
+ for (const line of text.split('\n')) {
195
+ if (line.startsWith('@@')) {
196
+ inHunk = true
197
+ continue
198
+ }
199
+ if (!inHunk) continue
200
+ if (line.startsWith('diff --git ')) {
201
+ inHunk = false
202
+ continue
203
+ }
204
+ if (line.startsWith('+')) added += 1
205
+ else if (line.startsWith('-')) deleted += 1
206
+ }
207
+ return { added, deleted }
208
+ }
209
+
210
+ /** Number of lines a brand-new file contributes (its `+` count). */
211
+ export function countContentLines(content) {
212
+ if (content === '') return 0
213
+ const endsWithNewline = content.endsWith('\n')
214
+ const count = content.split('\n').length - (endsWithNewline ? 1 : 0)
215
+ return count
216
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Git process layer.
3
+ *
4
+ * Everything this plugin knows about a repository comes through here, and every
5
+ * command goes through `ctx.subprocess` rather than `node:child_process`:
6
+ *
7
+ * - the harness resolves the executable for us (`resolveExecutable` checks an
8
+ * absolute path exists, walks the scrubbed PATH for a bare name, and
9
+ * rejects a relative path with separators);
10
+ * - the child is contained (a Windows Job object here), gets a bounded output
11
+ * buffer with spill, and is killed through `graceMs` rather than leaking;
12
+ * - the environment is scrubbed of `*TOKEN*`/`*SECRET*`-shaped variables
13
+ * before we even add ours.
14
+ *
15
+ * The environment we add on top is the one `@deepseek-ai/dsh-workspace-changes`
16
+ * uses for the same job: no terminal prompt (a credential prompt would hang a
17
+ * headless child forever), no optional index locks, no user config in the way,
18
+ * and a stable locale so the output we parse is the output we expect.
19
+ *
20
+ * Note the documented boundary: the harness file sandbox wraps the *shell*
21
+ * executors (pwsh/bash), not this service. A git child therefore runs with the
22
+ * OS user's full rights. That is why every path this plugin passes to git is
23
+ * derived from a repository root the host itself resolved, never from a raw
24
+ * client string.
25
+ *
26
+ * @module dsh-gitbar-lite/host/git
27
+ */
28
+
29
+ import { ERROR_CODES, GIT_ENV } from '../shared.js'
30
+
31
+ /** Milliseconds a git child gets to exit after termination starts. */
32
+ const TERMINATE_GRACE_MS = 2000
33
+
34
+ /** Retained stderr tail for diagnostics. */
35
+ const STDERR_TAIL_BYTES = 16 * 1024
36
+
37
+ /** Default stdout ceiling for one command; diffs go through a larger cap. */
38
+ export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
39
+
40
+ /** `git rev-parse` exits 128 when the directory is not inside a repository. */
41
+ export const NOT_A_REPOSITORY_EXIT = 128
42
+
43
+ /** The one stderr signature that means "not a repository" rather than "broken". */
44
+ const NOT_A_REPOSITORY = /not a git repository/i
45
+
46
+ /**
47
+ * A git command that ran and failed, carrying the command and its output.
48
+ *
49
+ * Kept distinct from a spawn/timeout failure so callers can classify the
50
+ * message (a conflict, a rejected push, a missing credential) without
51
+ * re-parsing a generic error string.
52
+ */
53
+ export class GitFailure extends Error {
54
+ /**
55
+ * @param {string} command the human-readable command, e.g. `git commit`
56
+ * @param {{ exitCode: number | null, stdout: string, stderr: string }} result
57
+ */
58
+ constructor(command, result) {
59
+ const detail = String(result.stderr ?? '').trim() || String(result.stdout ?? '').trim()
60
+ super(detail === '' ? `${command} failed (exit ${result.exitCode})` : `${command} failed: ${detail}`)
61
+ this.name = 'GitFailure'
62
+ this.command = command
63
+ this.exitCode = result.exitCode
64
+ this.stdout = result.stdout
65
+ this.stderr = result.stderr
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Build the git runner for one plugin instance.
71
+ *
72
+ * @param {object} ctx host context (must have `subprocess` injected)
73
+ * @param {object} config resolved plugin config
74
+ */
75
+ export function createGit(ctx, config) {
76
+ /** Resolved once per plugin instance: the executable cannot change under us. */
77
+ let resolved
78
+
79
+ /**
80
+ * Resolve the git executable, honouring an explicit `gitPath` override.
81
+ *
82
+ * A failure here is tagged with `E_NO_GIT` so the route layer reports "git is
83
+ * not installed (or the configured path is wrong)" rather than a generic
84
+ * internal error the user cannot act on.
85
+ *
86
+ * @returns {Promise<string>} absolute path to the executable
87
+ */
88
+ async function executable(signal) {
89
+ if (resolved !== undefined) return resolved
90
+ const configured = typeof config.gitPath === 'string' ? config.gitPath.trim() : ''
91
+ const name = configured === '' ? 'git' : configured
92
+ try {
93
+ resolved = await ctx.subprocess.resolveExecutable(name, undefined, signal)
94
+ } catch (error) {
95
+ const wrapped = new Error(
96
+ configured === ''
97
+ ? `找不到 git 可执行文件:${String(error?.message ?? error)}`
98
+ : `配置的 git 路径不可用(${configured}):${String(error?.message ?? error)}`,
99
+ )
100
+ wrapped.code = ERROR_CODES.NO_GIT
101
+ throw wrapped
102
+ }
103
+ return resolved
104
+ }
105
+
106
+ /**
107
+ * Run one git command.
108
+ *
109
+ * Never throws for a non-zero exit code — that is data, and callers use `ok()`
110
+ * when they want it to be an error. Throws only when the child could not be
111
+ * started, or when the timeout/abort fired.
112
+ *
113
+ * @param {readonly string[]} args arguments after the executable
114
+ * @param {object} [options]
115
+ * @param {string} [options.cwd] working directory (must be an absolute path)
116
+ * @param {AbortSignal} [options.signal] caller's cancellation
117
+ * @param {string} [options.stdin] text written to the child's stdin
118
+ * @param {number} [options.maxBytes] stdout ceiling for this command
119
+ * @returns {Promise<{ exitCode: number | null, stdout: string, stderr: string, truncated: boolean }>}
120
+ */
121
+ async function run(args, options = {}) {
122
+ const executablePath = await executable(options.signal)
123
+ const timeoutMs = Number.isFinite(config.timeoutMs) ? config.timeoutMs : 30000
124
+ // A zero/negative timeout means "no deadline of our own": still pass the
125
+ // caller's signal so an aborted request does not leave a child running.
126
+ const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined
127
+ const signal =
128
+ timeout === undefined
129
+ ? options.signal
130
+ : options.signal === undefined
131
+ ? timeout
132
+ : AbortSignal.any([options.signal, timeout])
133
+
134
+ const handle = ctx.subprocess.spawn({
135
+ argv: [executablePath, ...args],
136
+ cwd: options.cwd,
137
+ stdio: {
138
+ stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin },
139
+ stdout: { maxBytes: options.maxBytes ?? DEFAULT_MAX_OUTPUT_BYTES },
140
+ stderr: { maxBytes: STDERR_TAIL_BYTES },
141
+ },
142
+ graceMs: TERMINATE_GRACE_MS,
143
+ ...(signal === undefined ? {} : { signal }),
144
+ env: { ...GIT_ENV, ...(options.env ?? {}) },
145
+ })
146
+
147
+ const outcome = await handle.done
148
+
149
+ if (signal !== undefined && signal.aborted) {
150
+ const why = timeout !== undefined && timeout.aborted ? `timed out after ${timeoutMs}ms` : 'was aborted'
151
+ throw new GitFailure(`git ${args[0] ?? ''}`.trim(), { exitCode: null, stdout: '', stderr: why })
152
+ }
153
+
154
+ const out = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false }
155
+ const err = handle.collected.stderr?.readFrom(0) ?? { text: '', lossy: false }
156
+ return { exitCode: outcome.exitCode, stdout: out.text, stderr: err.text, truncated: out.lossy === true }
157
+ }
158
+
159
+ /**
160
+ * Run a git command and treat a non-zero exit as a failure.
161
+ *
162
+ * @param {readonly string[]} args
163
+ * @param {object} [options] same as `run`
164
+ * @returns {Promise<{ exitCode: number, stdout: string, stderr: string, truncated: boolean }>}
165
+ */
166
+ async function must(args, options = {}) {
167
+ const result = await run(args, options)
168
+ if (result.exitCode !== 0) throw new GitFailure(`git ${args.join(' ')}`, result)
169
+ return result
170
+ }
171
+
172
+ /**
173
+ * The repository root containing `cwd`, or `undefined` when there is none.
174
+ *
175
+ * The "not a repository" case is identified the way the shipped
176
+ * `workspace-changes` plugin identifies it — exit 128 plus that exact stderr
177
+ * — so a genuinely broken repository still surfaces as an error instead of
178
+ * being reported as "no repository here".
179
+ *
180
+ * @param {string} cwd absolute directory to probe
181
+ * @param {AbortSignal} [signal]
182
+ * @returns {Promise<string | undefined>}
183
+ */
184
+ async function repoRootOf(cwd, signal) {
185
+ const result = await run(['rev-parse', '--show-toplevel'], { cwd, signal })
186
+ if (result.exitCode === NOT_A_REPOSITORY_EXIT && NOT_A_REPOSITORY.test(result.stderr)) return undefined
187
+ if (result.exitCode !== 0) throw new GitFailure('git rev-parse --show-toplevel', result)
188
+ const first = result.stdout.split('\n')[0]
189
+ const root = typeof first === 'string' ? first.trim() : ''
190
+ return root === '' ? undefined : root
191
+ }
192
+
193
+ /**
194
+ * `true` when `candidate` is the root of a real working tree.
195
+ *
196
+ * Used to validate a user-chosen directory before it is persisted, so a bad
197
+ * pick fails here with a message instead of poisoning the binding file.
198
+ *
199
+ * @param {string} candidate absolute path
200
+ * @param {AbortSignal} [signal]
201
+ */
202
+ async function isRepoRoot(candidate, signal) {
203
+ const result = await run(['rev-parse', '--show-toplevel', '--is-inside-work-tree'], { cwd: candidate, signal })
204
+ if (result.exitCode !== 0) return undefined
205
+ const [toplevel, inside] = result.stdout.split('\n').map((line) => line.trim())
206
+ if (inside !== 'true' || typeof toplevel !== 'string' || toplevel === '') return undefined
207
+ return toplevel
208
+ }
209
+
210
+ return { executable, run, must, repoRootOf, isRepoRoot }
211
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Parsers for the two git outputs this plugin reads.
3
+ *
4
+ * Both are pure: no `ctx`, no filesystem, no clock. That is deliberate — the
5
+ * status parser is the single place where a wrong assumption turns into a
6
+ * plausible-looking but wrong file list, and `test/status.test.mjs` pins it
7
+ * against bytes captured from real repositories (including a rename, a staged
8
+ * delete, an intent-to-add, a non-ASCII path, and a conflicted index).
9
+ *
10
+ * Format reference (all read from `git status --porcelain=v2 --branch -z` with
11
+ * `core.quotepath=false`):
12
+ *
13
+ * # branch.oid <oid>|(initial)
14
+ * # branch.head <name>|(detached)
15
+ * # branch.upstream <name> (only when an upstream is configured)
16
+ * # branch.ab +<ahead> -<behind> (only when an upstream is configured)
17
+ * 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
18
+ * 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\0<origPath>
19
+ * u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
20
+ * ? <path>
21
+ * ! <path>
22
+ *
23
+ * With `-z` every record is NUL-terminated and paths are raw bytes, never
24
+ * quoted — which is why the parsers split on NUL and then count *fields* from
25
+ * the left instead of splitting on spaces: a path may contain spaces, and with
26
+ * `-z` git gives us no quoting to lean on.
27
+ *
28
+ * @module dsh-gitbar-lite/host/parse
29
+ */
30
+
31
+ import { FILE_STATUS } from '../shared.js'
32
+
33
+ /** Split a `-z` stream into records, dropping the trailing empty field. */
34
+ function splitNul(raw) {
35
+ const text = typeof raw === 'string' ? raw : ''
36
+ if (text === '') return []
37
+ const fields = text.split('\0')
38
+ if (fields.length > 0 && fields[fields.length - 1] === '') fields.pop()
39
+ return fields
40
+ }
41
+
42
+ /**
43
+ * Peel `count` space-separated tokens off the front of `text`.
44
+ *
45
+ * @returns {{ tokens: string[], rest: string }} `rest` is everything after the
46
+ * last token, verbatim (so it may be empty, and may itself contain spaces).
47
+ */
48
+ function takeTokens(text, count) {
49
+ const tokens = []
50
+ let rest = text
51
+ for (let index = 0; index < count; index += 1) {
52
+ const space = rest.indexOf(' ')
53
+ if (space < 0) return { tokens, rest: '' }
54
+ tokens.push(rest.slice(0, space))
55
+ rest = rest.slice(space + 1)
56
+ }
57
+ return { tokens, rest }
58
+ }
59
+
60
+ /**
61
+ * Bucket one `XY` pair into the status the UI shows.
62
+ *
63
+ * `U` anywhere, and the `AA`/`DD` both-added/both-deleted pairs, are conflicts:
64
+ * git reports those on `u` records, but the same letters can appear on an
65
+ * ordinary record once a merge has left the index dirty, so both are checked.
66
+ *
67
+ * @param {string} type record tag: `1`, `2`, `u`, `?` or `!`
68
+ * @param {string} index the `X` (index) status character
69
+ * @param {string} worktree the `Y` (worktree) status character
70
+ */
71
+ export function classify(type, index, worktree) {
72
+ if (type === '?') return FILE_STATUS.UNTRACKED
73
+ const conflicted =
74
+ type === 'u' ||
75
+ index === 'U' ||
76
+ worktree === 'U' ||
77
+ (index === 'A' && worktree === 'A') ||
78
+ (index === 'D' && worktree === 'D')
79
+ if (conflicted) return FILE_STATUS.CONFLICTED
80
+ if (index === 'R' || index === 'C') return FILE_STATUS.RENAMED
81
+ if (index === 'A') return FILE_STATUS.ADDED
82
+ if (index === 'D' || worktree === 'D') return FILE_STATUS.DELETED
83
+ return FILE_STATUS.MODIFIED
84
+ }
85
+
86
+ /** Build the normalized file entry both halves speak about. */
87
+ function makeEntry({ type, xy, path, origPath }) {
88
+ const index = xy.slice(0, 1)
89
+ const worktree = xy.slice(1, 2)
90
+ const kind = classify(type, index, worktree)
91
+ const untracked = type === '?'
92
+ const ignored = type === '!'
93
+ return {
94
+ /** Repository-relative path, git's own spelling (forward slashes). */
95
+ path,
96
+ /** Present only for a rename/copy: the path it came from. */
97
+ ...(origPath === undefined ? {} : { origPath }),
98
+ index,
99
+ worktree,
100
+ kind,
101
+ untracked,
102
+ ignored,
103
+ conflicted: kind === FILE_STATUS.CONFLICTED,
104
+ /**
105
+ * Whether *all* of this file's change is in the index.
106
+ *
107
+ * This is what the client's tick box mirrors: a ticked file is fully
108
+ * staged, so `git commit` (plain, no pathspec) commits exactly the ticked
109
+ * set. An untracked file is by definition not staged.
110
+ */
111
+ staged: !untracked && !ignored && index !== '.',
112
+ /** Both sides dirty — the tick box renders as indeterminate. */
113
+ partial: !untracked && !ignored && index !== '.' && worktree !== '.',
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Parse `git status --porcelain=v2 --branch -z`.
119
+ *
120
+ * @param {string} raw the command's stdout
121
+ * @returns {{
122
+ * branch: {
123
+ * oid: string | null, head: string | null, upstream: string | null,
124
+ * ahead: number, behind: number, detached: boolean, unborn: boolean,
125
+ * },
126
+ * files: ReturnType<typeof makeEntry>[],
127
+ * }}
128
+ */
129
+ export function parseStatus(raw) {
130
+ const branch = {
131
+ oid: null,
132
+ head: null,
133
+ upstream: null,
134
+ ahead: 0,
135
+ behind: 0,
136
+ detached: false,
137
+ unborn: false,
138
+ }
139
+ const files = []
140
+ const fields = splitNul(raw)
141
+
142
+ for (let cursor = 0; cursor < fields.length; cursor += 1) {
143
+ const field = fields[cursor]
144
+ if (field === '') continue
145
+ const tag = field.slice(0, 1)
146
+
147
+ if (tag === '#') {
148
+ const body = field.slice(2)
149
+ const space = body.indexOf(' ')
150
+ const key = space < 0 ? body : body.slice(0, space)
151
+ const value = space < 0 ? '' : body.slice(space + 1)
152
+ if (key === 'branch.oid') {
153
+ branch.oid = value
154
+ branch.unborn = value === '(initial)'
155
+ } else if (key === 'branch.head') {
156
+ branch.head = value
157
+ branch.detached = value === '(detached)'
158
+ } else if (key === 'branch.upstream') {
159
+ branch.upstream = value
160
+ } else if (key === 'branch.ab') {
161
+ const match = /^\+(\d+)\s+-(\d+)$/.exec(value)
162
+ if (match !== null) {
163
+ branch.ahead = Number(match[1])
164
+ branch.behind = Number(match[2])
165
+ }
166
+ }
167
+ continue
168
+ }
169
+
170
+ if (tag === '1') {
171
+ const { tokens, rest } = takeTokens(field.slice(2), 7)
172
+ if (tokens.length < 7) continue
173
+ files.push(makeEntry({ type: '1', xy: tokens[0], path: rest }))
174
+ continue
175
+ }
176
+
177
+ if (tag === '2') {
178
+ const { tokens, rest } = takeTokens(field.slice(2), 8)
179
+ if (tokens.length < 8) continue
180
+ // With -z the original path is its own NUL-terminated field, so consume it.
181
+ const origPath = fields[cursor + 1]
182
+ cursor += 1
183
+ files.push(makeEntry({ type: '2', xy: tokens[0], path: rest, origPath }))
184
+ continue
185
+ }
186
+
187
+ if (tag === 'u') {
188
+ // `u XY sub m1 m2 m3 mW h1 h2 h3 <path>` — nine fixed fields after the
189
+ // tag, then the path. Counting ten here consumes the first word of the
190
+ // path and yields an entry whose path is empty, which is a *silent* wrong
191
+ // answer: the conflict simply vanishes from the list.
192
+ const { tokens, rest } = takeTokens(field.slice(2), 9)
193
+ if (tokens.length < 9) continue
194
+ files.push(makeEntry({ type: 'u', xy: tokens[0], path: rest }))
195
+ continue
196
+ }
197
+
198
+ if (tag === '?' || tag === '!') {
199
+ files.push(makeEntry({ type: tag, xy: tag === '?' ? '??' : '!!', path: field.slice(2) }))
200
+ continue
201
+ }
202
+ }
203
+
204
+ return { branch, files }
205
+ }
206
+
207
+ /**
208
+ * Parse `git diff --numstat -z --find-renames`.
209
+ *
210
+ * Records are `<added>\t<deleted>\t<path>`, except a rename, where the path is
211
+ * empty and the next two NUL fields carry the old and new path. A binary file
212
+ * reports `-` for both counts.
213
+ *
214
+ * @param {string} raw the command's stdout
215
+ * @returns {Map<string, { path: string, origPath?: string, added: number, deleted: number, binary: boolean }>}
216
+ * keyed by the new path
217
+ */
218
+ export function parseNumstat(raw) {
219
+ const result = new Map()
220
+ const fields = splitNul(raw)
221
+ let cursor = 0
222
+
223
+ while (cursor < fields.length) {
224
+ const record = fields[cursor]
225
+ cursor += 1
226
+ if (record === '') continue
227
+
228
+ const firstTab = record.indexOf('\t')
229
+ if (firstTab < 0) continue
230
+ const secondTab = record.indexOf('\t', firstTab + 1)
231
+ if (secondTab < 0) continue
232
+
233
+ const addedText = record.slice(0, firstTab)
234
+ const deletedText = record.slice(firstTab + 1, secondTab)
235
+ let path = record.slice(secondTab + 1)
236
+ let origPath
237
+
238
+ if (path === '') {
239
+ origPath = fields[cursor]
240
+ path = fields[cursor + 1]
241
+ cursor += 2
242
+ if (path === undefined) break
243
+ }
244
+
245
+ const binary = addedText === '-'
246
+ result.set(path, {
247
+ path,
248
+ ...(origPath === undefined ? {} : { origPath }),
249
+ added: binary ? 0 : Number(addedText) || 0,
250
+ deleted: binary ? 0 : Number(deletedText) || 0,
251
+ binary,
252
+ })
253
+ }
254
+
255
+ return result
256
+ }
257
+
258
+ /**
259
+ * Parse `git diff --name-only -z`, used to learn the path list a bare
260
+ * `git diff HEAD` would cover without materializing the diff itself.
261
+ *
262
+ * @param {string} raw the command's stdout
263
+ * @returns {string[]}
264
+ */
265
+ export function parseNameOnly(raw) {
266
+ return splitNul(raw)
267
+ }