@young1lin/dsh-ui-gitworkbench 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.
package/src/git-log.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Commit-log records from `git log` / `git show`.
3
+ *
4
+ * Subject (`%s`) is the first line; body (`%b`) is everything after the blank
5
+ * line. Records are delimited by ASCII RS (`%x1e`) so a body may contain
6
+ * newlines without breaking the parse. Fields inside a record are US (`%x1f`).
7
+ *
8
+ * `body` is always a string (empty when the commit has none). RPC payloads
9
+ * cannot carry `undefined`.
10
+ */
11
+
12
+ export interface GitCommit {
13
+ readonly hash: string
14
+ readonly subject: string
15
+ readonly when: string
16
+ readonly body: string
17
+ /**
18
+ * Abbreviated parent hashes, in git's order — first parent first. This is the
19
+ * DAG: the commit graph is drawn from nothing else. Empty for a root commit.
20
+ */
21
+ readonly parents: readonly string[]
22
+ /**
23
+ * Branch and tag names pointing at this commit, already stripped of git's
24
+ * `HEAD -> ` and `tag: ` prefixes. Empty for the overwhelming majority.
25
+ */
26
+ readonly refs: readonly string[]
27
+ }
28
+
29
+ /**
30
+ * Pretty format: RS, hash, when, subject, parents, refs, body.
31
+ *
32
+ * `body` stays last because it is the only field that may contain newlines;
33
+ * anything after it would have to survive them. Parents (`%p`) and refs (`%D`)
34
+ * are single-line by construction.
35
+ */
36
+ export const LOG_FORMAT = '%x1e%h%x1f%cr%x1f%s%x1f%p%x1f%D%x1f%b'
37
+
38
+ /**
39
+ * Split `%D` into plain ref names.
40
+ *
41
+ * git writes decorations as a comma-joined list where HEAD is an arrow pair
42
+ * (`HEAD -> main`) and tags carry a `tag: ` prefix. Both are rendered as the
43
+ * bare name; which kind of ref it is does not change what the row shows.
44
+ * @param decoration - the `%D` field, possibly empty.
45
+ */
46
+ function parseRefs(decoration: string): string[] {
47
+ const out: string[] = []
48
+ for (const raw of decoration.split(',')) {
49
+ let name = raw.trim()
50
+ if (name.length === 0) continue
51
+ // `HEAD -> main` names the branch HEAD is on; keep the branch.
52
+ const arrow = name.indexOf('->')
53
+ if (arrow !== -1) name = name.slice(arrow + 2).trim()
54
+ if (name.startsWith('tag:')) name = name.slice(4).trim()
55
+ // A remote's HEAD is a symbolic ref: it always points where that remote's
56
+ // default branch already points, so it is a second label for a commit that
57
+ // is guaranteed to carry the first. In a log row it is pure noise.
58
+ if (name === 'origin/HEAD' || name.endsWith('/HEAD')) continue
59
+ if (name.length > 0) out.push(name)
60
+ }
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * Parse a `LOG_FORMAT` stream into commits.
66
+ * @param stdout - git's stdout.
67
+ */
68
+ export function parseLog(stdout: string): GitCommit[] {
69
+ const out: GitCommit[] = []
70
+ for (const record of stdout.split('\x1e')) {
71
+ if (record.length === 0) continue
72
+ const parts = record.split('\x1f')
73
+ if (parts.length < 3) continue
74
+ const hash = parts[0]!.trim()
75
+ const when = parts[1] ?? ''
76
+ const subject = (parts[2] ?? '').replace(/\n+$/g, '')
77
+ const parents = (parts[3] ?? '').trim().split(/\s+/).filter(part => part.length > 0)
78
+ const refs = parseRefs(parts[4] ?? '')
79
+ const body = (parts[5] ?? '').replace(/^\n+/, '').replace(/\n+$/g, '')
80
+ if (hash.length > 0) out.push({ hash, subject, when, body, parents, refs })
81
+ }
82
+ return out
83
+ }
84
+
85
+ /**
86
+ * The text a "copy message" action puts on the clipboard: subject, then a
87
+ * blank line, then the body when there is one.
88
+ * @param commit - parsed commit.
89
+ */
90
+ export function commitMessageText(commit: GitCommit): string {
91
+ return commit.body.length > 0 ? `${commit.subject}\n\n${commit.body}` : commit.subject
92
+ }
package/src/git-ops.ts ADDED
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Argument vectors and output readers for the drawer's WRITE operations —
3
+ * stage, unstage, commit, fetch, pull, push.
4
+ *
5
+ * Everything here is a pure function over strings, kept apart from the RPC
6
+ * methods in `index.ts` so the interesting half can be tested without spawning
7
+ * git. What matters about `git push` is the argv it is handed and what the
8
+ * plugin concludes from the exit code; the spawn between them has no branches.
9
+ *
10
+ * Two rules hold throughout, because both failures are silent:
11
+ *
12
+ * - Every pathspec goes after `--`, and is checked for a leading dash on top
13
+ * of that. A file may legitimately be named `-f`, and passed positionally
14
+ * it becomes an option instead of a path.
15
+ * - Nothing here builds a destructive command. There is no `--force`, no
16
+ * `reset --hard`, no `clean`. Losing committed work needs a confirmation
17
+ * design of its own, not a button that happens to be adjacent to Push.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ /** How `pull` should integrate the upstream's commits. */
23
+ export type PullMode = 'ff-only' | 'rebase' | 'merge'
24
+
25
+ /**
26
+ * Environment that makes git FAIL on a credential prompt instead of waiting for
27
+ * one.
28
+ *
29
+ * The subprocess capability is spawned with `stdin: 'ignore'`, which does not
30
+ * make an interactive prompt an error — it makes it a prompt nobody can answer,
31
+ * and git waits. That wait is inside the host process, so a single push to a
32
+ * repository whose token expired would hang the plugin for every session until
33
+ * the 30s grace elapsed. Each variable below closes one prompt route: git's own
34
+ * terminal prompt, Git Credential Manager's GUI, and the two askpass helpers.
35
+ */
36
+ export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
37
+ GIT_TERMINAL_PROMPT: '0',
38
+ GCM_INTERACTIVE: 'never',
39
+ GIT_ASKPASS: '',
40
+ SSH_ASKPASS: '',
41
+ }
42
+
43
+ /** Network operations wait on a remote, not on the disk. */
44
+ export const NETWORK_GRACE_MS = 120_000
45
+
46
+ /**
47
+ * Whether a string is safe to hand git as a pathspec.
48
+ * @param path - repository-relative path from the client.
49
+ * @returns false for an empty string or anything git would read as an option.
50
+ */
51
+ export function isSafePathArg(path: string): boolean {
52
+ return typeof path === 'string' && path.length > 0 && !path.startsWith('-')
53
+ }
54
+
55
+ function checkedPaths(paths: readonly string[]): string[] {
56
+ if (paths.length === 0) throw new Error('no paths given')
57
+ for (const path of paths) {
58
+ if (!isSafePathArg(path)) throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
59
+ }
60
+ return [...paths]
61
+ }
62
+
63
+ /**
64
+ * @param paths - repository-relative paths to stage.
65
+ * @returns argv for `git`, paths separated by `--`.
66
+ */
67
+ export function stageArgv(paths: readonly string[]): string[] {
68
+ return ['add', '--', ...checkedPaths(paths)]
69
+ }
70
+
71
+ /**
72
+ * @param paths - repository-relative paths to remove from the index.
73
+ * @returns argv for `git`. `restore --staged` leaves the working tree alone;
74
+ * `reset` would too, but `restore` cannot be confused with the
75
+ * destructive spellings of the same verb.
76
+ */
77
+ export function unstageArgv(paths: readonly string[]): string[] {
78
+ return ['restore', '--staged', '--', ...checkedPaths(paths)]
79
+ }
80
+
81
+ /**
82
+ * @param message - the commit message, used verbatim.
83
+ * @param amend - replace the previous commit instead of adding one.
84
+ * @returns argv for `git`. Never `-a`: the drawer has a staging area, and
85
+ * sweeping the whole worktree in would make that split a lie.
86
+ */
87
+ export function commitArgv(message: string, amend: boolean): string[] {
88
+ if (typeof message !== 'string' || message.trim().length === 0) {
89
+ throw new Error('a commit message is required')
90
+ }
91
+ // One argv element. No shell runs here, so quoting is not the hazard —
92
+ // splitting on whitespace would be, and a multi-line body is normal.
93
+ return amend ? ['commit', '--amend', '-m', message] : ['commit', '-m', message]
94
+ }
95
+
96
+ /**
97
+ * @returns argv for `git`. `--prune` so a branch deleted on the remote stops
98
+ * being counted as something to pull.
99
+ */
100
+ export function fetchArgv(): string[] {
101
+ return ['fetch', '--prune']
102
+ }
103
+
104
+ /**
105
+ * @param mode - how to integrate the upstream's commits.
106
+ * @returns argv for `git`. The mode is always explicit, never the user's
107
+ * `pull.rebase` config: the button says what it will do.
108
+ */
109
+ export function pullArgv(mode: PullMode): string[] {
110
+ if (mode === 'rebase') return ['pull', '--rebase']
111
+ if (mode === 'merge') return ['pull', '--no-rebase']
112
+ return ['pull', '--ff-only']
113
+ }
114
+
115
+ /**
116
+ * @param branch - the current branch, needed only on its first push.
117
+ * @param hasUpstream - whether the branch already tracks a remote branch.
118
+ * @returns argv for `git`. With an upstream, bare `push` respects the user's
119
+ * own remote and refspec configuration; without one, the first push
120
+ * establishes `origin/<branch>`.
121
+ */
122
+ export function pushArgv(branch: string, hasUpstream: boolean): string[] {
123
+ if (hasUpstream) return ['push']
124
+ if (!isSafePathArg(branch)) throw new Error(`unsafe branch name: ${JSON.stringify(branch)}`)
125
+ return ['push', '--set-upstream', 'origin', branch]
126
+ }
127
+
128
+ /** What `git status --branch --porcelain=v1` says about where this branch sits. */
129
+ export interface Tracking {
130
+ readonly branch: string
131
+ /** `origin/main`, or null when the branch tracks nothing (or HEAD is detached). */
132
+ readonly upstream: string | null
133
+ readonly ahead: number
134
+ readonly behind: number
135
+ readonly detached: boolean
136
+ }
137
+
138
+ const NO_TRACKING: Tracking = { branch: '', upstream: null, ahead: 0, behind: 0, detached: false }
139
+
140
+ /**
141
+ * Read the `##` header of porcelain status output.
142
+ *
143
+ * The distinction that matters is "no upstream" versus "an upstream we are level
144
+ * with": the first means push must pass `--set-upstream`, and both otherwise
145
+ * look like zero ahead and zero behind.
146
+ * @param stdout - full `git status --porcelain=v1 --branch` output.
147
+ */
148
+ export function parseTracking(stdout: string): Tracking {
149
+ const header = stdout.split('\n').find(line => line.startsWith('## '))
150
+ if (header === undefined) return NO_TRACKING
151
+
152
+ const body = header.slice(3)
153
+ if (body.startsWith('HEAD (no branch)')) return { ...NO_TRACKING, detached: true }
154
+
155
+ // An unborn branch (fresh `git init`) reports "No commits yet on main" —
156
+ // with the same optional upstream and bracket suffixes as a born header.
157
+ // The sync bar wants the branch's NAME, not the English sentence around it.
158
+ const UNBORN_PREFIX = 'No commits yet on '
159
+ const born = body.startsWith(UNBORN_PREFIX) ? body.slice(UNBORN_PREFIX.length) : body
160
+
161
+ // Divergence rides in a trailing bracket; strip it before splitting the refs.
162
+ const bracket = born.indexOf(' [')
163
+ const refs = bracket === -1 ? born : born.slice(0, bracket)
164
+ const counts = bracket === -1 ? '' : born.slice(bracket)
165
+
166
+ // `...` is the separator. Split on the LAST occurrence, not the first: a
167
+ // branch may contain dots, and only the separator is three of them.
168
+ const at = refs.lastIndexOf('...')
169
+ const branch = at === -1 ? refs : refs.slice(0, at)
170
+ const upstream = at === -1 ? null : refs.slice(at + 3)
171
+
172
+ const ahead = /ahead (\d+)/.exec(counts)
173
+ const behind = /behind (\d+)/.exec(counts)
174
+ return {
175
+ branch,
176
+ upstream: upstream !== null && upstream.length > 0 ? upstream : null,
177
+ ahead: ahead ? Number.parseInt(ahead[1]!, 10) : 0,
178
+ behind: behind ? Number.parseInt(behind[1]!, 10) : 0,
179
+ detached: false,
180
+ }
181
+ }
182
+
183
+ /** Which side of the index a file's changes are on. */
184
+ export interface StageState {
185
+ readonly staged: boolean
186
+ readonly unstaged: boolean
187
+ }
188
+
189
+ /** Porcelain XY pairs that mean "unmerged", per git-status(1). */
190
+ const CONFLICT_XY = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'])
191
+
192
+ /**
193
+ * Split a porcelain status pair into index and worktree state.
194
+ *
195
+ * A conflicted file reports content in the index (`UU`), so reading the X
196
+ * column alone calls it staged — and the drawer would then offer to commit a
197
+ * file with conflict markers still in it. Conflicts are reported as unstaged
198
+ * work, which is what they are until somebody resolves them.
199
+ * @param xy - the two status columns, e.g. ` M`, `MM`, `??`.
200
+ */
201
+ export function stageStateOf(xy: string): StageState {
202
+ if (xy === '??' || xy === '!!') return { staged: false, unstaged: true }
203
+ if (CONFLICT_XY.has(xy)) return { staged: false, unstaged: true }
204
+ const index = xy[0] ?? ' '
205
+ const worktree = xy[1] ?? ' '
206
+ return { staged: index !== ' ', unstaged: worktree !== ' ' }
207
+ }
208
+
209
+ /** Why an operation failed, in terms the drawer can explain to a person. */
210
+ export type OpFailure =
211
+ | 'auth'
212
+ | 'network'
213
+ | 'no-upstream'
214
+ | 'diverged'
215
+ | 'conflict'
216
+ | 'nothing-to-commit'
217
+ | 'dirty'
218
+ | 'unknown'
219
+
220
+ /**
221
+ * Turn git's exit into a reason the UI can act on.
222
+ *
223
+ * Matching on message text is fragile in general, but the alternative is
224
+ * showing raw stderr and letting the user work out that "Updates were rejected"
225
+ * means "fetch first". Anything unrecognised becomes `unknown`, and the caller
226
+ * still carries the real stderr alongside — the classification adds a hint, it
227
+ * never replaces the evidence.
228
+ * @param exitCode - git's exit status.
229
+ * @param stderr - captured stderr.
230
+ * @param stdout - captured stdout; `nothing to commit` arrives here, not stderr.
231
+ * @returns null when the command succeeded.
232
+ */
233
+ export function classifyFailure(exitCode: number, stderr: string, stdout: string): OpFailure | null {
234
+ if (exitCode === 0) return null
235
+ const text = `${stderr}\n${stdout}`.toLowerCase()
236
+
237
+ if (text.includes('nothing to commit')
238
+ || text.includes('no changes added to commit')
239
+ || text.includes('nothing added to commit')) return 'nothing-to-commit'
240
+
241
+ if (text.includes('authentication failed')
242
+ || text.includes('could not read username')
243
+ || text.includes('could not read password')
244
+ || text.includes('permission denied (publickey)')
245
+ || text.includes('terminal prompts disabled')) return 'auth'
246
+
247
+ // A network failure is worth its own class: "offline" and "bad remote URL"
248
+ // are fixable in different places, and neither is git's fault (TESTS.md D5).
249
+ if (text.includes('could not resolve host')
250
+ || text.includes('network is unreachable')
251
+ || text.includes('failed to connect')
252
+ || text.includes('connection timed out')) return 'network'
253
+
254
+ if (text.includes('no upstream configured')
255
+ || text.includes('has no upstream branch')) return 'no-upstream'
256
+
257
+ if (text.includes('conflict (')
258
+ || text.includes('merge conflict')
259
+ || text.includes('fix conflicts')) return 'conflict'
260
+
261
+ if (text.includes('[rejected]')
262
+ || text.includes('updates were rejected')
263
+ || text.includes('not possible to fast-forward')
264
+ || text.includes('need to specify how to reconcile divergent branches')) return 'diverged'
265
+
266
+ if (text.includes('would be overwritten')
267
+ || text.includes('local changes')) return 'dirty'
268
+
269
+ return 'unknown'
270
+ }
271
+
272
+ // ---- Porcelain readers for the READ side (stats / commits / compare) ----
273
+ //
274
+ // These parsers lived in index.ts until the fixture catalog demanded unit
275
+ // coverage. index.ts imports its dsh peers as VALUES (it extends
276
+ // TypertRemoteService), so vitest cannot load that module without a web
277
+ // profile — pure parsing belongs here, the same split this file already
278
+ // makes for the write side.
279
+
280
+ /** The five change kinds the drawer distinguishes. */
281
+ export type GitFileStatus = 'added' | 'deleted' | 'modified' | 'renamed' | 'untracked'
282
+
283
+ /** One file of a change set, as `stats` / `commitStats` / `compareRefs` report it. */
284
+ export interface GitFile {
285
+ readonly path: string
286
+ readonly status: GitFileStatus
287
+ readonly addedLines: number
288
+ readonly deletedLines: number
289
+ readonly binary: boolean
290
+ /** Present only for renames/copies: the path this file moved from. Omitted otherwise. */
291
+ readonly previousPath?: string
292
+ /**
293
+ * Whether the file has content in the index, and whether it has content in
294
+ * the working tree that the index does not have. Both can be true at once —
295
+ * a file staged and then edited again. Only meaningful for the working-tree
296
+ * view; a commit's or a range's files are neither.
297
+ *
298
+ * A conflicted file reports `unstaged` even though its index entry is
299
+ * populated, so the drawer cannot offer to commit unresolved markers.
300
+ */
301
+ readonly staged?: boolean
302
+ readonly unstaged?: boolean
303
+ }
304
+
305
+ /** The mutable half of {@link GitFile}: untracked entries get their counts
306
+ * and binary flag filled in by the synthesis pass in `stats`. */
307
+ export interface MutableGitFile {
308
+ path: string
309
+ status: GitFileStatus
310
+ addedLines: number
311
+ deletedLines: number
312
+ binary: boolean
313
+ previousPath?: string
314
+ staged?: boolean
315
+ unstaged?: boolean
316
+ }
317
+
318
+ interface NumstatEntry { added: number; deleted: number; binary: boolean }
319
+
320
+ /**
321
+ * Read `git diff --numstat` output into a path-keyed map.
322
+ *
323
+ * A `-` in either count column means git could not (or would not — a `-diff`
324
+ * gitattributes marker does it too) count the file: binary. Rename entries
325
+ * print `old => new` in the path column; the NEW path is what the porcelain
326
+ * status list also keys on, so that is the key kept here.
327
+ * @param stdout - full `--numstat` output.
328
+ */
329
+ export function parseNumstat(stdout: string): Map<string, NumstatEntry> {
330
+ const out = new Map<string, NumstatEntry>()
331
+ for (const line of stdout.split('\n')) {
332
+ if (line.length === 0) continue
333
+ const parts = line.split('\t')
334
+ if (parts.length < 3) continue
335
+ const binary = parts[0] === '-' || parts[1] === '-'
336
+ const added = parts[0] === '-' ? 0 : Number.parseInt(parts[0]!, 10)
337
+ const deleted = parts[1] === '-' ? 0 : Number.parseInt(parts[1]!, 10)
338
+ const path = stripRenameTarget(parts.slice(2).join('\t'))
339
+ if (path.length > 0) out.set(path, { added: Number.isFinite(added) ? added : 0, deleted: Number.isFinite(deleted) ? deleted : 0, binary })
340
+ }
341
+ return out
342
+ }
343
+
344
+ /** Parse porcelain lines into a MUTABLE file list — untracked entries get their
345
+ * counts filled in by the synthesis pass afterwards. */
346
+ export function parseStatus(stdout: string, numstat: Map<string, NumstatEntry>): MutableGitFile[] {
347
+ const files: MutableGitFile[] = []
348
+ for (const line of stdout.split('\n')) {
349
+ if (line.length === 0 || line.startsWith('##')) continue
350
+ if (line.length < 3) continue
351
+ const xy = line.slice(0, 2)
352
+ const { path, previousPath, renamed } = parsePath(line.slice(3))
353
+ if (path.length === 0) continue
354
+ const counts = numstat.get(path) ?? { added: 0, deleted: 0, binary: false }
355
+ const { staged, unstaged } = stageStateOf(xy)
356
+ const base: MutableGitFile = {
357
+ path, status: statusFromXY(xy, renamed),
358
+ addedLines: counts.added, deletedLines: counts.deleted, binary: counts.binary,
359
+ staged, unstaged,
360
+ }
361
+ files.push(renamed && previousPath.length > 0 ? { ...base, previousPath } : base)
362
+ }
363
+ return files
364
+ }
365
+
366
+ /**
367
+ * Parse `git show --name-status --no-renames` into the mutable file list, taking
368
+ * line counts from the matching `--numstat` entry.
369
+ * @param stdout - name-status output (`<status>\t<path>` per line).
370
+ * @param numstat - per-path counts from {@link parseNumstat}.
371
+ * @returns one entry per file the commit touched.
372
+ */
373
+ export function parseNameStatus(stdout: string, numstat: Map<string, NumstatEntry>): MutableGitFile[] {
374
+ const files: MutableGitFile[] = []
375
+ for (const line of stdout.split('\n')) {
376
+ if (line.length === 0) continue
377
+ const tab = line.indexOf('\t')
378
+ if (tab < 0) continue
379
+ const code = line.slice(0, tab)
380
+ const path = line.slice(tab + 1).trim()
381
+ if (path.length === 0) continue
382
+ const counts = numstat.get(path) ?? { added: 0, deleted: 0, binary: false }
383
+ files.push({
384
+ path,
385
+ status: code.startsWith('A') ? 'added' : code.startsWith('D') ? 'deleted' : 'modified',
386
+ addedLines: counts.added,
387
+ deletedLines: counts.deleted,
388
+ binary: counts.binary,
389
+ })
390
+ }
391
+ return files
392
+ }
393
+
394
+ /**
395
+ * Split a porcelain path field into the file's path and, for a rename, the
396
+ * path it moved from.
397
+ *
398
+ * With `core.quotepath=false` (this plugin sets it on every git call) CJK and
399
+ * accented paths arrive as raw UTF-8, unquoted. Git still quotes a path that
400
+ * contains control characters or a quote, using C escapes — which is exactly
401
+ * JSON's escape alphabet, so `JSON.parse` un-escapes it. Octal escapes from
402
+ * the default quotepath mode are NOT JSON and stay raw; that is why the
403
+ * config, not smarter unescaping, is the fix.
404
+ * @param rest - the porcelain line past the two status columns.
405
+ */
406
+ function parsePath(rest: string): { path: string; previousPath: string; renamed: boolean } {
407
+ let value = rest
408
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
409
+ value = value.slice(1, -1)
410
+ try { value = JSON.parse(`"${value}"`) } catch { /* keep raw */ }
411
+ }
412
+ const arrow = value.indexOf(' -> ')
413
+ if (arrow >= 0) return { path: value.slice(arrow + 4), previousPath: value.slice(0, arrow), renamed: true }
414
+ return { path: value, previousPath: '', renamed: false }
415
+ }
416
+
417
+ function stripRenameTarget(path: string): string {
418
+ const arrow = path.indexOf(' -> ')
419
+ return arrow >= 0 ? path.slice(arrow + 4) : path
420
+ }
421
+
422
+ function statusFromXY(xy: string, renamed: boolean): GitFileStatus {
423
+ if (xy === '??') return 'untracked'
424
+ if (renamed || xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C') return 'renamed'
425
+ if (xy[0] === 'A' || xy[1] === 'A') return 'added'
426
+ if (xy[0] === 'D' || xy[1] === 'D') return 'deleted'
427
+ return 'modified'
428
+ }
429
+
430
+ /** ASCII line feed. Safe to count in raw UTF-8 bytes: no multi-byte sequence
431
+ * can contain it, so a byte scan and a decoded scan agree exactly. */
432
+ const NEWLINE = 0x0a
433
+
434
+ /**
435
+ * Count lines in a UTF-8 buffer without decoding it.
436
+ * @param bytes - file contents.
437
+ * @returns the line count, counting a final unterminated line.
438
+ */
439
+ export function countBufferLines(bytes: Buffer): number {
440
+ if (bytes.length === 0) return 0
441
+ let lines = 0
442
+ for (let at = bytes.indexOf(NEWLINE); at !== -1; at = bytes.indexOf(NEWLINE, at + 1)) lines += 1
443
+ return bytes[bytes.length - 1] === NEWLINE ? lines : lines + 1
444
+ }
445
+
446
+ /**
447
+ * Whether a raw file buffer smells binary: a NUL byte inside the sniff window.
448
+ *
449
+ * UTF-16 text is the trap this exists for — it is text, but every other byte
450
+ * is NUL, so an 8 KB prefix catches it without reading a 200 MB blob. A NUL
451
+ * PAST the window does not decide anything: a text file may legitimately
452
+ * contain one deep in its body (TESTS.md A9).
453
+ * @param bytes - the file's contents, however much of them is cheap to read.
454
+ * @param windowBytes - how many leading bytes may decide.
455
+ */
456
+ export function isBinaryPrefix(bytes: Buffer, windowBytes: number): boolean {
457
+ return bytes.subarray(0, windowBytes).includes(0)
458
+ }
459
+
460
+ /**
461
+ * Clip a diff to a character cap, and SAY so when the clip happened — a
462
+ * silently shortened diff reads as a complete one (TESTS.md H1).
463
+ * @param text - the diff.
464
+ * @param cap - most characters to keep.
465
+ * @param marker - the truncation note appended when clipping.
466
+ */
467
+ export function clipDiff(text: string, cap: number, marker: string): string {
468
+ return text.length > cap ? `${text.slice(0, cap)}\n${marker}` : text
469
+ }
470
+
471
+ /**
472
+ * Cap the branch list the picker shows, and REPORT the cut: `branchesTruncated`
473
+ * is what lets the picker say "showing the first 500" instead of quietly
474
+ * looking like the repository only has 500 branches (TESTS.md F5).
475
+ * @param names - branch names, newest-commit-first.
476
+ * @param cap - how many to send.
477
+ */
478
+ export function capBranches(names: readonly string[], cap: number): { branches: string[]; branchesTruncated: boolean } {
479
+ return { branches: names.slice(0, cap), branchesTruncated: names.length > cap }
480
+ }
481
+
482
+ /**
483
+ * Whether stderr is the "no merge base" refusal `git diff A...B` gives for
484
+ * histories with no common ancestor — the cue to retry the comparison as a
485
+ * plain two-tip diff instead of failing outright (TESTS.md C3).
486
+ * @param stderr - stderr of the failed three-dot diff.
487
+ */
488
+ export function isNoMergeBaseError(stderr: string): boolean {
489
+ return stderr.includes('no merge base')
490
+ }