@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/index.ts ADDED
@@ -0,0 +1,1172 @@
1
+ /**
2
+ * Host half of @young1lin/dsh-ui-gitworkbench.
3
+ *
4
+ * A TypertRemoteService exposed at endpoints `gitWorkbench/stats` and
5
+ * `gitWorkbench/fileDiff`. The Typert gateway discovers methods by source-marker
6
+ * reflection (the @Remote decorator) — no generated descriptor, no monorepo
7
+ * edit. The browser reaches them through the generic connection RPC channel.
8
+ *
9
+ * `stats` computes the working-tree change picture (vs HEAD) by spawning git
10
+ * through the subprocess capability with PIPED stdio. Pipe capture is used
11
+ * deliberately instead of `ctx.shell`: the shell executor can run commands
12
+ * through a PTY whose scrollback drops the head of large outputs, which loses
13
+ * the first files of a big `git diff`. Pipes deliver every byte.
14
+ *
15
+ * Untracked files are enumerated per-file (`--untracked-files=all`) and their
16
+ * diff segments are synthesized host-side from a direct file read —
17
+ * `git diff --no-index /dev/null <f>` is NOT used because on Windows git
18
+ * resolves `/dev/null` as a repo-relative path ("Could not access ...nul").
19
+ * Synthesis is also cheaper: one fs read per file, no spawn.
20
+ *
21
+ * `fileDiff` returns one file's diff on demand (tracked: `git diff HEAD --`;
22
+ * untracked: synthesized), so the payload cap on `stats` never hides content.
23
+ *
24
+ * `commitStats` and the commit branch of `fileDiff` read immutable objects, so
25
+ * both answer from a bounded per-process cache and both spawn their git reads
26
+ * concurrently — a git spawn costs about 100ms on Windows and dominates the
27
+ * work. `stats` reads the working tree and is never cached.
28
+ *
29
+ * Worktree emulation rides on the same service: `worktreeEnter` creates (or
30
+ * reuses) `<repoRoot>/.agents/worktrees/<name>` as a real git worktree on
31
+ * branch `wt/<name>` and binds the session to it in
32
+ * `~/.dsh/gitworkbench-worktree-bindings.json`; `worktreeExit` unbinds (optionally
33
+ * removing a clean worktree); `sessionWorktree`/`worktreeStatus` report the
34
+ * binding. The session cwd itself is immutable in dsh, so the enter result
35
+ * carries a hint telling the model how to address the worktree relatively.
36
+ *
37
+ * The same three operations are also registered as agent tools
38
+ * (`worktree_enter`/`worktree_exit`/`worktree_status`) via `ctx.tools`, so the
39
+ * model can drive them directly; each tool takes its sessionId/cwd from the
40
+ * calling agent's session rather than model-supplied arguments.
41
+ *
42
+ * @module @young1lin/dsh-ui-gitworkbench
43
+ */
44
+ import { randomBytes } from 'node:crypto'
45
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
46
+ import { homedir } from 'node:os'
47
+ import { join } from 'node:path'
48
+ import type { Readable } from 'node:stream'
49
+ import type { Context } from '@deepseek-ai/cordis'
50
+ import { defineTool, type ToolRunContext } from '@deepseek-ai/dsh-tools'
51
+ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
52
+ import { saveJsonAtomic } from './atomic-json.js'
53
+ import { CommitPayloadCache, cacheKey } from './commit-cache.js'
54
+ import {
55
+ NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff,
56
+ commitArgv, countBufferLines, fetchArgv, isBinaryPrefix, isNoMergeBaseError,
57
+ isSafePathArg, parseNameStatus, parseNumstat, parseStatus, parseTracking,
58
+ pullArgv, pushArgv, stageArgv, stageStateOf, unstageArgv,
59
+ type GitFile, type GitFileStatus, type MutableGitFile,
60
+ type OpFailure, type PullMode, type Tracking,
61
+ } from './git-ops.js'
62
+ import { LOG_FORMAT, parseLog, type GitCommit } from './git-log.js'
63
+ import {
64
+ isBlankEntry, loadStyle, sanitizeEntry, stylePath,
65
+ type StyleEntry, type StyleFile,
66
+ } from './style-store.js'
67
+ import {
68
+ bindingsPath, branchFor, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir,
69
+ type BindingsFile, type WorktreeBinding, type WorktreeEntry, type WorktreeOpResult,
70
+ } from './worktree.js'
71
+
72
+ export type { WorktreeBinding, WorktreeOpResult }
73
+ export type { GitFile, GitFileStatus }
74
+ export type { StyleEntry }
75
+
76
+ /** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
77
+ const DIFF_CHAR_CAP = 400_000
78
+ /** Untracked files larger than this are listed + counted but never diffed. */
79
+ const UNTRACKED_FILE_BYTE_CAP = 1_000_000
80
+ /** At most this many bytes of synthesized untracked diff ride along in `stats`. */
81
+ const UNTRACKED_TOTAL_CHAR_CAP = 160_000
82
+ /** Files with a NUL byte in the first 8k are treated as binary. */
83
+ const BINARY_SNIFF_BYTES = 8_000
84
+ /** Untracked files measured at once. Enough to keep the disk busy, few enough
85
+ * that a repository with thousands of them cannot exhaust the file table. */
86
+ const UNTRACKED_READ_CONCURRENCY = 16
87
+ /** How many recent commits ride along in `stats` — the history tab's first page. */
88
+ const HISTORY_COMMITS = 20
89
+ /** How many further commits one `commits` page loads. */
90
+ const HISTORY_PAGE = 30
91
+ /** Upper bound on a caller-supplied page size. */
92
+ const HISTORY_PAGE_MAX = 200
93
+ /**
94
+ * Most branch names sent to the browser. `worktreeStatus` is polled, so an
95
+ * unbounded list would repeat on the wire every few seconds; the picker reports
96
+ * the cut rather than quietly showing a short list.
97
+ */
98
+ const BRANCH_LIST_CAP = 500
99
+ /** Commit change sets held per host process before the least recently used is dropped. */
100
+ const COMMIT_CACHE_CAPACITY = 32
101
+ /** Per-file commit diffs held per host process — far more numerous, and far smaller, than a whole change set. */
102
+ const COMMIT_DIFF_CACHE_CAPACITY = 128
103
+ /** Abbreviated or full object name — rejects anything that could read as a git option. */
104
+ const COMMIT_HASH = /^[0-9a-fA-F]{4,40}$/
105
+
106
+ export type { GitCommit } from './git-log.js'
107
+
108
+ export interface WorkbenchStats {
109
+ readonly worktreePath: string
110
+ readonly branch: string
111
+ readonly ahead: number
112
+ readonly behind: number
113
+ readonly detached: boolean
114
+ readonly addedLines: number
115
+ readonly deletedLines: number
116
+ readonly addedFiles: number
117
+ readonly deletedFiles: number
118
+ readonly modifiedFiles: number
119
+ readonly files: readonly GitFile[]
120
+ /** Combined diff text: `git diff HEAD` plus synthesized untracked segments, capped. */
121
+ readonly diff: string
122
+ /**
123
+ * Commits this view is about: the single commit for `commitStats`, the range's
124
+ * commits for `compareRefs`. Empty for `stats` — the working tree is not a
125
+ * log, and the history list loads its own pages through `commits` so it can
126
+ * follow a ref of its own.
127
+ */
128
+ readonly commits: readonly GitCommit[]
129
+ readonly error?: string
130
+ }
131
+
132
+ interface GitResult {
133
+ readonly stdout: string
134
+ readonly exitCode: number
135
+ /** Last 300 chars of stderr, or the spawn exception message — for error reporting. */
136
+ readonly stderr: string
137
+ }
138
+
139
+ /** What every write operation reports back. */
140
+ export interface GitOpResult {
141
+ readonly ok: boolean
142
+ /** Present only on failure; `unknown` still carries `error` for the user to read. */
143
+ readonly failure?: OpFailure
144
+ /** git's own message on failure, trimmed to the tail. */
145
+ readonly error?: string
146
+ /** git's own message on success — `push` in particular says where it went. */
147
+ readonly output?: string
148
+ }
149
+
150
+ /**
151
+ * Narrow whatever the client sent to a list of path strings.
152
+ *
153
+ * This crosses the RPC boundary, so it is untyped on arrival; a non-array or a
154
+ * list with a number in it must become an empty list and be refused by the argv
155
+ * builder, not reach git as `[object Object]`.
156
+ */
157
+ function asPathList(paths: unknown): string[] {
158
+ if (!Array.isArray(paths)) return []
159
+ return paths.filter((path): path is string => typeof path === 'string')
160
+ }
161
+
162
+ /** Binding-file IO handle (injected dependencies, so tests can substitute readers/writers). */
163
+ interface BindingsFileIo {
164
+ readonly path: string
165
+ load(): Promise<BindingsFile>
166
+ save(file: BindingsFile): Promise<void>
167
+ }
168
+
169
+ /** Style-file IO handle, on the same terms as {@link BindingsFileIo}. */
170
+ interface StyleFileIo {
171
+ readonly path: string
172
+ load(): Promise<StyleFile>
173
+ save(file: StyleFile): Promise<void>
174
+ }
175
+
176
+ /** A TypertRemoteService registers itself under `ctx.gitWorkbench` and is found by the gateway. */
177
+ export class GitWorkbenchService extends TypertRemoteService {
178
+ static inject = ['subprocess', 'tools']
179
+
180
+ /** Whole commit change sets, keyed by worktree + hash. */
181
+ private readonly commitStatsCache = new CommitPayloadCache<WorkbenchStats>(COMMIT_CACHE_CAPACITY)
182
+ /** Single-file commit diffs, keyed by worktree + hash + path. */
183
+ private readonly commitDiffCache = new CommitPayloadCache<string>(COMMIT_DIFF_CACHE_CAPACITY)
184
+
185
+ /**
186
+ * Bindings mirrored in memory, keyed by session id. The prompt-context
187
+ * provider is synchronous and cannot read the bindings file, so every
188
+ * mutation updates this inside the same critical section that writes it.
189
+ */
190
+ private readonly bindingMirror = new Map<string, WorktreeBinding>()
191
+
192
+ constructor(ctx: Context) {
193
+ super(ctx, 'gitWorkbench')
194
+ this.registerWorktreeTools(ctx)
195
+ this.registerWorktreePrompt(ctx)
196
+ // Hydrate the mirror through the same queue as the mutations, so a binding
197
+ // written before hydration finishes is not overwritten by the stale read.
198
+ // A failed read leaves the mirror empty: sessions then get no standing
199
+ // notice, while the tools keep working straight off the file.
200
+ void this.withBindings(async (io) => {
201
+ const file = await io.load()
202
+ for (const [id, binding] of Object.entries(file.bindings)) this.bindingMirror.set(id, binding)
203
+ }).catch(() => {})
204
+ }
205
+
206
+ /**
207
+ * Contribute the session's worktree binding to every model request.
208
+ *
209
+ * The binding is a CONVENTION, not an enforced boundary: `session.header.cwd`
210
+ * is immutable, so the filesystem and shell tools keep resolving against the
211
+ * repository root whatever this session is bound to. A one-shot hint in the
212
+ * `worktree_enter` result decays — compaction can prune it, and the `cwd`
213
+ * prompt variable goes on naming the repo root every turn. A standing context
214
+ * is what keeps the convention in front of the model.
215
+ *
216
+ * Registered as dynamic CONTEXT rather than a stable section: the value is
217
+ * per-session and mutable, so it belongs in the per-request runtime snapshot
218
+ * instead of the cached prompt prefix. Mounted through `ctx.inject` so an
219
+ * assembly without a systemPrompt registry simply skips it.
220
+ */
221
+ private registerWorktreePrompt(ctx: Context): void {
222
+ ctx.inject(['systemPrompt'], (scope: Context) => {
223
+ scope.systemPrompt.context({
224
+ name: 'worktree:binding',
225
+ order: 115,
226
+ text: (context) => {
227
+ const sessionId = context.agent?.session.id
228
+ const binding = sessionId === undefined ? undefined : this.bindingMirror.get(sessionId)
229
+ if (binding === undefined) return ''
230
+ const rel = `.agents/worktrees/${binding.name}`
231
+ return `This session is bound to git worktree "${binding.name}" (branch ${branchFor(binding.name)}).\n`
232
+ + 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
233
+ + `- shell commands: pass workdir "${rel}"\n`
234
+ + `- file tools: prefix every path with ${rel}/\n`
235
+ + 'A path without that prefix acts on the MAIN worktree, not the bound one. Call worktree_exit to unbind.'
236
+ },
237
+ })
238
+ })
239
+ }
240
+
241
+ /**
242
+ * Expose the worktree RPCs to the model as three agent tools. All of them
243
+ * derive sessionId/cwd from the calling agent's session (`exec.agent.session`)
244
+ * — the tools are session-scoped, so a call without a session is refused.
245
+ *
246
+ * Output schemas follow dsh-tools' enforced JSON-Schema subset: single type
247
+ * strings only (a `['object', 'null']` array is rejected — hence the `oneOf`
248
+ * for `binding`), and every object node declares `additionalProperties`
249
+ * explicitly. The status schema keeps `ok`/`error` optional-but-declared so
250
+ * its no-session early return still validates.
251
+ */
252
+ private registerWorktreeTools(ctx: Context): void {
253
+ const output = (schema: Record<string, unknown>) => ({
254
+ schema,
255
+ render: (_args: unknown, value: unknown) => [{ type: 'text' as const, text: JSON.stringify(value) }],
256
+ })
257
+ const OP_SCHEMA = {
258
+ type: 'object', additionalProperties: false,
259
+ properties: {
260
+ ok: { type: 'boolean', required: true },
261
+ worktreePath: { type: 'string' },
262
+ branch: { type: 'string' },
263
+ hint: { type: 'string' },
264
+ error: { type: 'string' },
265
+ },
266
+ } as const
267
+ const STATUS_SCHEMA = {
268
+ type: 'object', additionalProperties: false,
269
+ properties: {
270
+ ok: { type: 'boolean' },
271
+ error: { type: 'string' },
272
+ binding: { oneOf: [{ type: 'null' }, { type: 'object', additionalProperties: true }] },
273
+ worktrees: { type: 'array', items: { type: 'object', additionalProperties: true } },
274
+ branches: { type: 'array', items: { type: 'string' } },
275
+ branchesTruncated: { type: 'boolean' },
276
+ },
277
+ } as const
278
+
279
+ ctx.tools.register(defineTool({
280
+ name: 'worktree_enter',
281
+ description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name> on branch wt/<name> '
282
+ + 'and bind this session to it. After entering, address the worktree relatively from the session cwd: '
283
+ + 'for shell commands pass workdir ".agents/worktrees/<name>" (per-call workdir is supported and resolved '
284
+ + 'against the session cwd); for file tools use paths prefixed with .agents/worktrees/<name>/. '
285
+ + 'Call with no name to auto-generate one. Use worktree_exit to leave.',
286
+ parameters: {
287
+ name: { type: 'string', description: 'Optional worktree name ([A-Za-z0-9._-], max 40). Auto-generated when omitted.' },
288
+ },
289
+ output: output(OP_SCHEMA),
290
+ execute: async (args: { name?: string }, exec: ToolRunContext) => {
291
+ const session = exec.agent?.session
292
+ if (session === undefined) return { ok: false, error: 'worktree tools require a calling session' }
293
+ return this.worktreeEnter(session.id, session.header.cwd, args?.name, exec.signal)
294
+ },
295
+ presentCall: () => ({ card: 'generic', title: 'Enter worktree', kind: 'other' }),
296
+ }))
297
+
298
+ ctx.tools.register(defineTool({
299
+ name: 'worktree_exit',
300
+ description: 'Leave the session\'s bound worktree. Keeps the worktree directory on disk by default; '
301
+ + 'pass remove: true to also delete it (refused while it has uncommitted changes).',
302
+ parameters: {
303
+ remove: { type: 'boolean', description: 'Also run git worktree remove (default false).' },
304
+ },
305
+ output: output(OP_SCHEMA),
306
+ execute: async (args: { remove?: boolean }, exec: ToolRunContext) => {
307
+ const session = exec.agent?.session
308
+ if (session === undefined) return { ok: false, error: 'worktree tools require a calling session' }
309
+ return this.worktreeExit(session.id, args?.remove, exec.signal)
310
+ },
311
+ presentCall: () => ({ card: 'generic', title: 'Exit worktree', kind: 'other' }),
312
+ }))
313
+
314
+ ctx.tools.register(defineTool({
315
+ name: 'worktree_status',
316
+ description: 'Show this session\'s bound worktree (if any) and the repository\'s existing worktrees with branches.',
317
+ parameters: {},
318
+ output: output(STATUS_SCHEMA),
319
+ execute: async (_args: Record<string, never>, exec: ToolRunContext) => {
320
+ const session = exec.agent?.session
321
+ if (session === undefined) return { ok: false, error: 'worktree tools require a calling session' }
322
+ return this.worktreeStatus(session.id, session.header.cwd, exec.signal)
323
+ },
324
+ presentCall: () => ({ card: 'generic', title: 'Worktree status', kind: 'read' }),
325
+ }))
326
+ }
327
+
328
+ /** Working-tree change stats for a worktree (plain-identifier params; signal last — SRC requirements). */
329
+ @Remote('stats')
330
+ async stats(worktreePath: string | undefined, signal: AbortSignal): Promise<WorkbenchStats> {
331
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
332
+
333
+ // Four independent reads of the same worktree. Running them together is
334
+ // safe: git takes .git/index.lock only to write back a refreshed index and
335
+ // skips that write when it cannot get the lock, so the reports stay correct.
336
+ const [statusInfo, numstat, diff, revInfo] = await Promise.all([
337
+ this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
338
+ this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
339
+ this.git(cwd, ['diff', 'HEAD'], signal),
340
+ this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
341
+ ])
342
+ if (statusInfo.exitCode !== 0) {
343
+ const detail = statusInfo.stderr.length > 0 ? `: ${statusInfo.stderr}` : ''
344
+ return { ...emptyStats(cwd), error: `git status failed (exit ${statusInfo.exitCode})${detail}` }
345
+ }
346
+
347
+ const counts = parseNumstat(numstat.stdout)
348
+ const files = parseStatus(statusInfo.stdout, counts)
349
+
350
+ // Untracked files in two passes, because the two halves cost wildly
351
+ // different amounts. EVERY untracked file needs a line count and a binary
352
+ // flag, which come off the raw buffer with no utf8 decode; only the handful
353
+ // that fit the payload budget pay for a decode and a synthesized segment.
354
+ const untracked = files.filter(file => file.status === 'untracked')
355
+ const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path))
356
+
357
+ let budget = UNTRACKED_TOTAL_CHAR_CAP
358
+ let untrackedDiff = ''
359
+ for (const [index, file] of untracked.entries()) {
360
+ const measure = measured[index]
361
+ file.addedLines = measure.lineCount
362
+ file.binary = measure.binary
363
+ if (budget <= 0 || !measure.diffable) continue
364
+ const segment = await untrackedSegment(cwd, file.path)
365
+ if (segment === null) continue
366
+ const text = clipDiff(segment, budget, '…[untracked diff truncated]')
367
+ untrackedDiff += `${text}\n`
368
+ budget -= text.length
369
+ }
370
+
371
+ let addedLines = 0
372
+ let deletedLines = 0
373
+ let addedFiles = 0
374
+ let deletedFiles = 0
375
+ let modifiedFiles = 0
376
+ for (const file of files) {
377
+ addedLines += file.addedLines
378
+ deletedLines += file.deletedLines
379
+ if (file.status === 'added' || file.status === 'untracked') addedFiles += 1
380
+ else if (file.status === 'deleted') deletedFiles += 1
381
+ else modifiedFiles += 1
382
+ }
383
+
384
+ let branch = revInfo.stdout.trim()
385
+ let detached = false
386
+ if (branch === 'HEAD' || branch.length === 0) {
387
+ // rev-parse names nothing before the first commit, but symbolic-ref
388
+ // still resolves an unborn branch: a fresh `git init` shows its branch
389
+ // name in the chip rather than a detached hash or a blank (TESTS.md I1).
390
+ const sym = (await this.git(cwd, ['symbolic-ref', '--short', 'HEAD'], signal)).stdout.trim()
391
+ if (sym.length > 0) {
392
+ branch = sym
393
+ } else {
394
+ detached = true
395
+ const short = (await this.git(cwd, ['rev-parse', '--short', 'HEAD'], signal)).stdout.trim()
396
+ branch = short.length > 0 ? short : ''
397
+ }
398
+ }
399
+ const { ahead, behind } = parseBranch(statusInfo.stdout)
400
+
401
+ let combined = diff.stdout
402
+ if (untrackedDiff.length > 0) combined += `\n${untrackedDiff}`
403
+ combined = clipDiff(combined, DIFF_CHAR_CAP, '…[diff truncated]')
404
+
405
+ return {
406
+ worktreePath: cwd, branch, ahead, behind, detached,
407
+ addedLines, deletedLines, addedFiles, deletedFiles, modifiedFiles,
408
+ files, diff: combined,
409
+ // No log here: this call is polled every 15s, and the history list follows
410
+ // a ref this one knows nothing about. `commits` serves it instead.
411
+ commits: [],
412
+ }
413
+ }
414
+
415
+ /**
416
+ * One file's diff on demand. With `commit` the diff is that commit's change to
417
+ * the file; without it, the working tree against HEAD (plain-identifier params;
418
+ * signal last).
419
+ */
420
+ @Remote('fileDiff')
421
+ async fileDiff(worktreePath: string, path: string, commit: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
422
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
423
+ if (typeof path !== 'string' || path.length === 0) return { diff: '' }
424
+ if (typeof commit === 'string' && commit.length > 0) {
425
+ if (!COMMIT_HASH.test(commit)) return { diff: '' }
426
+ const key = cacheKey(cwd, commit, path)
427
+ const cached = this.commitDiffCache.get(key)
428
+ if (cached !== undefined) return { diff: cached }
429
+ // `--first-parent` for the same reason as in `commitStats`: without it a
430
+ // merge commit has no diff to show and the pane opens empty.
431
+ const shown = await this.git(cwd, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal)
432
+ if (shown.exitCode !== 0) return { diff: '' }
433
+ this.commitDiffCache.set(key, shown.stdout)
434
+ return { diff: shown.stdout }
435
+ }
436
+ const tracked = await this.git(cwd, ['diff', 'HEAD', '--', path], signal)
437
+ if (tracked.stdout.trim().length > 0) return { diff: tracked.stdout }
438
+ return { diff: await untrackedSegment(cwd, path) ?? '' }
439
+ }
440
+
441
+ /**
442
+ * One commit's change set, in the SAME {@link WorkbenchStats} shape as the working-tree
443
+ * view so the drawer's tree and diff panes render it with no separate code path.
444
+ * `branch` carries the short hash (there is no branch to name) and `commits` the
445
+ * single commit's metadata.
446
+ *
447
+ * Rename detection is off: with `--no-renames` the paths from `--numstat` and
448
+ * `--name-status` agree exactly and match the patch text, at the cost of showing
449
+ * a rename as a delete plus an add.
450
+ */
451
+ @Remote('commitStats')
452
+ async commitStats(worktreePath: string, hash: string, signal: AbortSignal): Promise<WorkbenchStats> {
453
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
454
+ if (typeof hash !== 'string' || !COMMIT_HASH.test(hash)) {
455
+ return { ...emptyStats(cwd), error: 'not a commit hash' }
456
+ }
457
+ const key = cacheKey(cwd, hash)
458
+ const cached = this.commitStatsCache.get(key)
459
+ if (cached !== undefined) return cached
460
+
461
+ // Four independent reads of one immutable commit, so they run concurrently.
462
+ // A git spawn costs ~100ms on Windows and dominates the work itself, which
463
+ // made the sequential chain roughly four times slower than the data
464
+ // required. The cost is that a well-formed hash naming no object now spends
465
+ // four failed spawns where it used to stop after one.
466
+ // `--first-parent` is what makes a MERGE show anything at all. Plain
467
+ // `git show` prints no diff for a commit with two parents — there is no
468
+ // single "before" to compare against — so selecting a merge used to open an
469
+ // empty pane. Against the first parent the answer is well defined and is the
470
+ // useful one: what this merge brought into the branch it landed on. On a
471
+ // single-parent commit the flag is a no-op, byte for byte.
472
+ const [meta, numstat, nameStatus, patch] = await Promise.all([
473
+ this.git(cwd, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
474
+ this.git(cwd, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
475
+ this.git(cwd, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
476
+ this.git(cwd, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
477
+ ])
478
+ if (meta.exitCode !== 0) {
479
+ const detail = meta.stderr.length > 0 ? `: ${meta.stderr}` : ''
480
+ return { ...emptyStats(cwd), error: `git show failed (exit ${meta.exitCode})${detail}` }
481
+ }
482
+ const commits = parseLog(meta.stdout)
483
+ const files = parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout))
484
+
485
+ let addedLines = 0
486
+ let deletedLines = 0
487
+ let addedFiles = 0
488
+ let deletedFiles = 0
489
+ let modifiedFiles = 0
490
+ for (const file of files) {
491
+ addedLines += file.addedLines
492
+ deletedLines += file.deletedLines
493
+ if (file.status === 'added') addedFiles += 1
494
+ else if (file.status === 'deleted') deletedFiles += 1
495
+ else modifiedFiles += 1
496
+ }
497
+
498
+ let diff = patch.stdout
499
+ diff = clipDiff(diff, DIFF_CHAR_CAP, '…[diff truncated]')
500
+
501
+ const value: WorkbenchStats = {
502
+ worktreePath: cwd,
503
+ branch: commits[0]?.hash ?? hash.slice(0, 7),
504
+ ahead: 0, behind: 0, detached: false,
505
+ addedLines, deletedLines, addedFiles, deletedFiles, modifiedFiles,
506
+ files, diff, commits,
507
+ }
508
+ // Only a successful read is stored. Caching the error payloads above would
509
+ // pin a transient condition — an aborted signal, a repository mid-fetch —
510
+ // for the rest of the process.
511
+ this.commitStatsCache.set(key, value)
512
+ return value
513
+ }
514
+
515
+ /**
516
+ * A page of some ref's commit log.
517
+ *
518
+ * The ref is a parameter because a log needs no working tree: a branch with no
519
+ * worktree cannot be viewed as files, but its history reads exactly like any
520
+ * other. The worktree argument only says which object store resolves the ref.
521
+ * @param worktreePath - worktree whose object store resolves the ref; empty falls back to the host cwd.
522
+ * @param ref - ref to walk; empty means the worktree's own HEAD.
523
+ * @param skip - commits to skip, counting back from the ref.
524
+ * @param limit - page size; out-of-range values fall back to the default page.
525
+ * @param signal - abort signal.
526
+ * @returns the page, and whether the log continues past it.
527
+ */
528
+ @Remote('commits')
529
+ async commits(worktreePath: string, ref: string, skip: number, limit: number, signal: AbortSignal): Promise<{ commits: GitCommit[]; hasMore: boolean }> {
530
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
531
+ const target = typeof ref === 'string' && ref.length > 0 ? ref : 'HEAD'
532
+ if (!isRefName(target)) return { commits: [], hasMore: false }
533
+ const from = Number.isInteger(skip) && skip >= 0 ? skip : 0
534
+ const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE
535
+ // Reading one row beyond the page answers "is there more" without a second
536
+ // traversal of the log.
537
+ //
538
+ // `--topo-order` is what makes the commit graph legible, and it is why the
539
+ // list is not in date order. Default (chronological) ordering interleaves
540
+ // commits from concurrent branches, so a branch's lane opens, sits idle for
541
+ // a dozen unrelated rows, and closes far from where it started. Topological
542
+ // order keeps a branch's commits contiguous — it is what `git log --graph`
543
+ // turns on for itself, for the same reason.
544
+ const log = await this.git(
545
+ cwd,
546
+ ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`],
547
+ signal,
548
+ )
549
+ const page = parseLog(log.stdout)
550
+ return { commits: page.slice(0, size), hasMore: page.length > size }
551
+ }
552
+
553
+ /**
554
+ * Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
555
+ *
556
+ * The diff uses `base...head`: what `head` changed since the two diverged,
557
+ * which is what "the difference between these branches" normally means and
558
+ * what a forge's compare view shows. A plain two-dot diff would additionally
559
+ * report everything `base` gained in the meantime as if `head` had removed it.
560
+ * `commits` carries the commits unique to `head` (`base..head`).
561
+ *
562
+ * Deliberately NOT cached: a ref name is a moving pointer, unlike the commit
563
+ * hash {@link commitStats} is keyed by.
564
+ * @param worktreePath - worktree whose object store resolves the refs.
565
+ * @param base - ref the comparison starts from.
566
+ * @param head - ref whose changes are reported.
567
+ * @param signal - abort signal.
568
+ * @returns the change set between the refs, or an error payload.
569
+ */
570
+ @Remote('compareRefs')
571
+ async compareRefs(worktreePath: string, base: string, head: string, signal: AbortSignal): Promise<WorkbenchStats> {
572
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
573
+ if (!isRefName(base) || !isRefName(head)) return { ...emptyStats(cwd), error: 'not a ref name' }
574
+ const range = `${base}...${head}`
575
+ const diffNumstat = (args: readonly string[]) => this.git(cwd, ['diff', '--numstat', '--no-renames', ...args], signal)
576
+ const diffNameStatus = (args: readonly string[]) => this.git(cwd, ['diff', '--name-status', '--no-renames', ...args], signal)
577
+ const diffPatch = (args: readonly string[]) => this.git(cwd, ['diff', '--no-renames', ...args], signal)
578
+ let [numstat, nameStatus, patch, log] = await Promise.all([
579
+ diffNumstat([range]),
580
+ diffNameStatus([range]),
581
+ diffPatch([range]),
582
+ this.git(cwd, ['log', `-${HISTORY_COMMITS}`, `--pretty=format:${LOG_FORMAT}`, `${base}..${head}`], signal),
583
+ ])
584
+ if (numstat.exitCode !== 0 && isNoMergeBaseError(numstat.stderr)) {
585
+ // Unrelated histories have no merge base for `A...B` to diff from. A
586
+ // two-tip diff still answers "what differs between these branches" with
587
+ // the full tree — which is what the compare tab is for (TESTS.md C3).
588
+ ;[numstat, nameStatus, patch] = await Promise.all([
589
+ diffNumstat([base, head]),
590
+ diffNameStatus([base, head]),
591
+ diffPatch([base, head]),
592
+ ])
593
+ }
594
+ if (numstat.exitCode !== 0) {
595
+ const detail = numstat.stderr.length > 0 ? `: ${numstat.stderr}` : ''
596
+ return { ...emptyStats(cwd), error: `git diff failed (exit ${numstat.exitCode})${detail}` }
597
+ }
598
+ const files = parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout))
599
+
600
+ let addedLines = 0
601
+ let deletedLines = 0
602
+ let addedFiles = 0
603
+ let deletedFiles = 0
604
+ let modifiedFiles = 0
605
+ for (const file of files) {
606
+ addedLines += file.addedLines
607
+ deletedLines += file.deletedLines
608
+ if (file.status === 'added') addedFiles += 1
609
+ else if (file.status === 'deleted') deletedFiles += 1
610
+ else modifiedFiles += 1
611
+ }
612
+
613
+ let diff = patch.stdout
614
+ diff = clipDiff(diff, DIFF_CHAR_CAP, '…[diff truncated]')
615
+
616
+ return {
617
+ worktreePath: cwd, branch: range,
618
+ ahead: 0, behind: 0, detached: false,
619
+ addedLines, deletedLines, addedFiles, deletedFiles, modifiedFiles,
620
+ files, diff, commits: parseLog(log.stdout),
621
+ }
622
+ }
623
+
624
+ /** The session's worktree binding, or nulls when unbound (plain-identifier params; signal last). */
625
+ @Remote('sessionWorktree')
626
+ async sessionWorktree(sessionId: string, signal: AbortSignal): Promise<{ worktreePath: string | null; name: string | null }> {
627
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return { worktreePath: null, name: null }
628
+ const file = await this.bindingsIo().load()
629
+ const binding = file.bindings[sessionId]
630
+ return binding === undefined ? { worktreePath: null, name: null } : { worktreePath: binding.worktreePath, name: binding.name }
631
+ }
632
+
633
+ /** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
634
+ @Remote('worktreeEnter')
635
+ async worktreeEnter(sessionId: string, repoPath: string, name: string | undefined, signal: AbortSignal): Promise<WorktreeOpResult> {
636
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return { ok: false, error: 'sessionId is required' }
637
+ const cwd = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd()
638
+ const repoRoot = await this.repoRootOf(cwd, signal)
639
+ if (repoRoot === null) {
640
+ const probe = await this.git(cwd, ['rev-parse', '--show-toplevel'], signal)
641
+ return { ok: false, error: `not a git repository${probe.stderr.length > 0 ? `: ${probe.stderr}` : ''}` }
642
+ }
643
+ const wtName = sanitizeName(name, () => randomHex(6))
644
+ const dir = worktreeDir(repoRoot, wtName)
645
+ // A cancelled `add`, a crashed host, or a worktree directory deleted by hand all
646
+ // leave a registration git keeps reporting (flagged `prunable`, which the list
647
+ // parser does not read). Prune FIRST so the list below describes what is really
648
+ // on disk — otherwise a stale entry passes for a reusable worktree and the
649
+ // session binds to a directory that no longer exists.
650
+ await this.git(repoRoot, ['worktree', 'prune'], signal)
651
+ // Directory already a registered worktree -> reuse it, no second `add`.
652
+ const existing = parseWorktreeList((await this.git(repoRoot, ['worktree', 'list', '--porcelain'], signal)).stdout)
653
+ .find(entry => entry.path.replace(/\\/g, '/') === dir)
654
+ // Branch point, read BEFORE `add` so a fresh worktree records exactly where it
655
+ // started. Reuse paths recover it with merge-base instead (theirs is historical).
656
+ const headBefore = (await this.git(repoRoot, ['rev-parse', 'HEAD'], signal)).stdout.trim()
657
+ let reusedBranch = false
658
+ let baseCommit: string | undefined
659
+ if (existing === undefined) {
660
+ const add = await this.git(repoRoot, ['worktree', 'add', '-b', branchFor(wtName), dir], signal)
661
+ if (add.exitCode === 0) {
662
+ baseCommit = headBefore.length > 0 ? headBefore : undefined
663
+ } else {
664
+ // `git worktree remove` keeps branch wt/<name> (exit never deletes it — it may
665
+ // carry unmerged commits), so a re-enter after remove finds the branch present:
666
+ // verify the ref and check the existing branch out instead of failing on `-b`.
667
+ const branch = branchFor(wtName)
668
+ const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], signal)
669
+ if (verified.exitCode !== 0) {
670
+ return { ok: false, error: `git worktree add failed (exit ${add.exitCode})${add.stderr.length > 0 ? `: ${add.stderr}` : ''}` }
671
+ }
672
+ const retry = await this.git(repoRoot, ['worktree', 'add', dir, branch], signal)
673
+ if (retry.exitCode !== 0) {
674
+ return { ok: false, error: `git worktree add failed (exit ${retry.exitCode})${retry.stderr.length > 0 ? `: ${retry.stderr}` : ''}` }
675
+ }
676
+ reusedBranch = true
677
+ }
678
+ }
679
+ if (baseCommit === undefined) {
680
+ // Reused worktree or branch: its real branch point is historical, so take the
681
+ // merge base with the repo's current HEAD. A failure leaves the field absent.
682
+ const merged = await this.git(repoRoot, ['merge-base', branchFor(wtName), 'HEAD'], signal)
683
+ if (merged.exitCode === 0) baseCommit = merged.stdout.trim() || undefined
684
+ }
685
+ await this.withBindings(async io => {
686
+ const file = await io.load()
687
+ const binding: WorktreeBinding = {
688
+ repoRoot, worktreePath: dir, name: wtName, enteredAt: new Date().toISOString(),
689
+ ...baseCommit === undefined ? {} : { baseCommit },
690
+ }
691
+ file.bindings[sessionId] = binding
692
+ await io.save(file)
693
+ this.bindingMirror.set(sessionId, binding)
694
+ })
695
+ const rel = `.agents/worktrees/${wtName}`
696
+ return {
697
+ ok: true, worktreePath: dir, branch: branchFor(wtName),
698
+ hint: `Session bound to worktree "${wtName}" at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedBranch ? ` Note: reused existing branch ${branchFor(wtName)} (carries its prior commits).` : ''}`,
699
+ }
700
+ }
701
+
702
+ /** Unbind the session's worktree; with remove=true, delete a clean worktree from disk. */
703
+ @Remote('worktreeExit')
704
+ async worktreeExit(sessionId: string, remove: boolean | undefined, signal: AbortSignal): Promise<WorktreeOpResult> {
705
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return { ok: false, error: 'sessionId is required' }
706
+ // The whole load→save span runs under the binding mutex: a concurrent enter
707
+ // must not save a file that still contains this session's binding.
708
+ return this.withBindings(async io => {
709
+ const file = await io.load()
710
+ const binding = file.bindings[sessionId]
711
+ if (binding === undefined) return { ok: false, error: 'no worktree binding for this session' }
712
+ if (remove === true) {
713
+ const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal)
714
+ if (status.exitCode !== 0) {
715
+ return { ok: false, error: `cannot inspect worktree (exit ${status.exitCode})${status.stderr.length > 0 ? `: ${status.stderr}` : ''}` }
716
+ }
717
+ if (status.stdout.trim().length > 0) {
718
+ return { ok: false, error: 'worktree has uncommitted changes; commit or stash first, or call worktree_exit without remove to keep it' }
719
+ }
720
+ const rm = await this.git(binding.repoRoot, ['worktree', 'remove', binding.worktreePath], signal)
721
+ if (rm.exitCode !== 0) {
722
+ return { ok: false, error: `git worktree remove failed (exit ${rm.exitCode})${rm.stderr.length > 0 ? `: ${rm.stderr}` : ''}` }
723
+ }
724
+ }
725
+ delete file.bindings[sessionId]
726
+ await io.save(file)
727
+ this.bindingMirror.delete(sessionId)
728
+ return { ok: true, worktreePath: binding.worktreePath, hint: remove === true ? 'Worktree removed and binding cleared.' : 'Binding cleared; worktree kept on disk.' }
729
+ })
730
+ }
731
+
732
+ /**
733
+ * The session's binding, every worktree of the surrounding repo, and every
734
+ * local branch (all empty when unbound and outside a repo).
735
+ *
736
+ * Worktrees and branches are BOTH reported because they answer different
737
+ * questions: a worktree can be viewed as a working tree, while a branch with
738
+ * no worktree has no directory to read and can only be browsed or compared.
739
+ *
740
+ * Branches come back most-recently-committed first. With hundreds of them the
741
+ * order is what makes the list usable — the handful anyone is working on sit
742
+ * at the top, so the picker is useful before a single character is typed.
743
+ * @param sessionId - session whose binding is looked up.
744
+ * @param repoPath - caller's directory, used when the session is unbound.
745
+ * @param signal - abort signal.
746
+ * @returns the binding, the repository's worktrees, and its local branches.
747
+ */
748
+ @Remote('worktreeStatus')
749
+ async worktreeStatus(sessionId: string, repoPath: string, signal: AbortSignal): Promise<{ binding: WorktreeBinding | null; worktrees: WorktreeEntry[]; branches: string[]; branchesTruncated: boolean }> {
750
+ const file = await this.bindingsIo().load()
751
+ const binding = typeof sessionId === 'string' && sessionId.length > 0 ? file.bindings[sessionId] ?? null : null
752
+ // Unbound: list the CALLER's repo. Falling back to the host's launch directory
753
+ // would answer about whatever directory dsh was started in, not this session's.
754
+ const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd()
755
+ const cwd = binding?.repoRoot ?? caller
756
+ const root = await this.repoRootOf(cwd, signal)
757
+ if (root === null) return { binding, worktrees: [], branches: [], branchesTruncated: false }
758
+ const [listed, named] = await Promise.all([
759
+ this.git(root, ['worktree', 'list', '--porcelain'], signal),
760
+ this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
761
+ ])
762
+ const all = named.stdout.split('\n').map(line => line.trim()).filter(line => line.length > 0)
763
+ const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP)
764
+ return {
765
+ binding,
766
+ worktrees: parseWorktreeList(listed.stdout),
767
+ branches,
768
+ branchesTruncated,
769
+ }
770
+ }
771
+
772
+ /**
773
+ * The styling that applies to a directory: its project's, and the global one.
774
+ *
775
+ * Both are returned rather than one resolved entry, because the menu edits
776
+ * each scope separately and has to show what each currently holds. Resolution
777
+ * — project wins — belongs to the client that renders it.
778
+ * @param worktreePath - directory whose repository identifies the project.
779
+ * @param signal - abort signal.
780
+ * @returns the two scopes' entries (null when unset) and the resolved repo root.
781
+ */
782
+ @Remote('styleGet')
783
+ async styleGet(worktreePath: string, signal: AbortSignal): Promise<{ project: StyleEntry | null; global: StyleEntry | null; repoRoot: string | null }> {
784
+ const file = await this.styleIo().load()
785
+ const root = await this.repoRootFor(worktreePath, signal)
786
+ return {
787
+ project: root === null ? null : file.projects[root] ?? null,
788
+ global: isBlankEntry(file.global) ? null : file.global,
789
+ repoRoot: root,
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Replace one scope's styling.
795
+ *
796
+ * A blank entry deletes the scope's record instead of storing an empty one: a
797
+ * stored blank project entry is indistinguishable from "cleared" to a reader,
798
+ * but it would still shadow the global scope.
799
+ * @param worktreePath - directory whose repository identifies the project.
800
+ * @param scope - `project` or `global`.
801
+ * @param entry - the styling to store; anything invalid in it is dropped.
802
+ * @param signal - abort signal.
803
+ * @returns whether it was stored, with the reason when it was not.
804
+ */
805
+ @Remote('styleSet')
806
+ async styleSet(worktreePath: string, scope: string, entry: unknown, signal: AbortSignal): Promise<{ ok: boolean; error?: string }> {
807
+ if (scope !== 'project' && scope !== 'global') return { ok: false, error: `unknown scope "${String(scope)}"` }
808
+ const clean = sanitizeEntry(entry)
809
+ const root = scope === 'project' ? await this.repoRootFor(worktreePath, signal) : null
810
+ if (scope === 'project' && root === null) return { ok: false, error: 'not inside a git repository' }
811
+ return this.withStyle(async io => {
812
+ const file = await io.load()
813
+ const projects = { ...file.projects }
814
+ if (scope === 'project' && root !== null) {
815
+ if (isBlankEntry(clean)) delete projects[root]
816
+ else projects[root] = clean
817
+ }
818
+ await io.save({ v: 1, global: scope === 'global' ? clean : file.global, projects })
819
+ return { ok: true }
820
+ })
821
+ }
822
+
823
+ /* ------------------------------- write ops ------------------------------- */
824
+
825
+ /**
826
+ * Where the current branch stands against its upstream.
827
+ *
828
+ * Read from `git status` rather than `rev-list --count`, because the drawer
829
+ * needs the same call to tell "tracks nothing" apart from "tracks origin and
830
+ * is level with it" — the first is what makes push pass `--set-upstream`, and
831
+ * a count query answers zero for both.
832
+ * @param worktreePath - directory to read; empty falls back to the host cwd.
833
+ * @param signal - abort signal.
834
+ * @returns the branch, its upstream, and the divergence in commits.
835
+ */
836
+ @Remote('syncStatus')
837
+ async syncStatus(worktreePath: string, signal: AbortSignal): Promise<Tracking & { hasRemote: boolean }> {
838
+ const cwd = this.cwdOf(worktreePath)
839
+ const [status, remotes] = await Promise.all([
840
+ this.git(cwd, ['status', '--porcelain=v1', '--branch'], signal),
841
+ this.git(cwd, ['remote'], signal),
842
+ ])
843
+ return { ...parseTracking(status.stdout), hasRemote: remotes.stdout.trim().length > 0 }
844
+ }
845
+
846
+ /**
847
+ * Add paths to the index.
848
+ * @param worktreePath - directory to run in.
849
+ * @param paths - repository-relative paths; an empty list is refused rather
850
+ * than turned into a whole-tree `git add`.
851
+ * @param signal - abort signal.
852
+ */
853
+ @Remote('stage')
854
+ async stage(worktreePath: string, paths: readonly string[], signal: AbortSignal): Promise<GitOpResult> {
855
+ return this.writeOp(worktreePath, () => stageArgv(asPathList(paths)), signal)
856
+ }
857
+
858
+ /**
859
+ * Remove paths from the index, leaving the working tree untouched.
860
+ * @param worktreePath - directory to run in.
861
+ * @param paths - repository-relative paths.
862
+ * @param signal - abort signal.
863
+ */
864
+ @Remote('unstage')
865
+ async unstage(worktreePath: string, paths: readonly string[], signal: AbortSignal): Promise<GitOpResult> {
866
+ return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal)
867
+ }
868
+
869
+ /**
870
+ * Commit what is in the index.
871
+ * @param worktreePath - directory to run in.
872
+ * @param message - commit message, used verbatim; blank is refused.
873
+ * @param amend - replace the previous commit rather than adding one.
874
+ * @param signal - abort signal.
875
+ */
876
+ @Remote('commit')
877
+ async commit(worktreePath: string, message: string, amend: boolean | undefined, signal: AbortSignal): Promise<GitOpResult> {
878
+ return this.writeOp(worktreePath, () => commitArgv(String(message ?? ''), amend === true), signal)
879
+ }
880
+
881
+ /**
882
+ * Update remote-tracking refs without touching the working tree.
883
+ * @param worktreePath - directory to run in.
884
+ * @param signal - abort signal.
885
+ * @returns the operation result, plus the divergence the fetch revealed.
886
+ */
887
+ @Remote('fetch')
888
+ async fetch(worktreePath: string, signal: AbortSignal): Promise<GitOpResult & { tracking?: Tracking }> {
889
+ const result = await this.writeOp(worktreePath, () => fetchArgv(), signal, NETWORK_GRACE_MS)
890
+ if (!result.ok) return result
891
+ // The point of fetching is the count it produces, so report it in the same
892
+ // round trip rather than making the client ask again.
893
+ const status = await this.git(this.cwdOf(worktreePath), ['status', '--porcelain=v1', '--branch'], signal)
894
+ return { ...result, tracking: parseTracking(status.stdout) }
895
+ }
896
+
897
+ /**
898
+ * Integrate the upstream's commits.
899
+ * @param worktreePath - directory to run in.
900
+ * @param mode - `ff-only` (default), `rebase`, or `merge`. Always explicit, so
901
+ * the button's label is what actually runs.
902
+ * @param signal - abort signal.
903
+ */
904
+ @Remote('pull')
905
+ async pull(worktreePath: string, mode: string | undefined, signal: AbortSignal): Promise<GitOpResult> {
906
+ const chosen: PullMode = mode === 'rebase' || mode === 'merge' ? mode : 'ff-only'
907
+ return this.writeOp(worktreePath, () => pullArgv(chosen), signal, NETWORK_GRACE_MS)
908
+ }
909
+
910
+ /**
911
+ * Publish the current branch.
912
+ *
913
+ * Never forces. A rejected push means the remote holds commits this branch
914
+ * does not, and the answer to that is to pull, not to overwrite somebody's
915
+ * work — the failure is classified as `diverged` so the drawer can say so.
916
+ * @param worktreePath - directory to run in.
917
+ * @param signal - abort signal.
918
+ */
919
+ @Remote('push')
920
+ async push(worktreePath: string, signal: AbortSignal): Promise<GitOpResult> {
921
+ const cwd = this.cwdOf(worktreePath)
922
+ const status = await this.git(cwd, ['status', '--porcelain=v1', '--branch'], signal)
923
+ const tracking = parseTracking(status.stdout)
924
+ if (tracking.detached) return { ok: false, failure: 'unknown', error: 'HEAD is detached; nothing to push' }
925
+ if (tracking.branch.length === 0) return { ok: false, failure: 'unknown', error: 'no branch to push' }
926
+ return this.writeOp(worktreePath, () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS)
927
+ }
928
+
929
+ /** Shared shape for every write op: run it, classify what went wrong. */
930
+ private async writeOp(
931
+ worktreePath: string,
932
+ build: () => readonly string[],
933
+ signal: AbortSignal,
934
+ graceMs?: number,
935
+ ): Promise<GitOpResult> {
936
+ let argv: readonly string[]
937
+ try {
938
+ argv = build()
939
+ } catch (error) {
940
+ // A rejected argument never reaches git. This is the path an empty path
941
+ // list or a blank commit message takes.
942
+ return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
943
+ }
944
+ const result = await this.git(this.cwdOf(worktreePath), argv, signal, graceMs)
945
+ const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
946
+ if (failure === null) return { ok: true, output: result.stdout.trim().slice(-1000) }
947
+ // The classification is a hint; the real text rides along beside it, because
948
+ // "unknown" has to stay actionable.
949
+ return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) }
950
+ }
951
+
952
+ /** A directory argument, falling back to the host's own cwd. */
953
+ private cwdOf(worktreePath: string | undefined): string {
954
+ return typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
955
+ }
956
+
957
+ /**
958
+ * @param worktreePath - a directory, or an empty value for the host's cwd.
959
+ * @param signal - abort signal.
960
+ * @returns the enclosing repository root, or null outside a repository.
961
+ */
962
+ private repoRootFor(worktreePath: string, signal: AbortSignal): Promise<string | null> {
963
+ const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath.replace(/\\/g, '/') : process.cwd()
964
+ return this.repoRootOf(cwd, signal)
965
+ }
966
+
967
+ /** Style-file IO, mirroring {@link bindingsIo}. */
968
+ private styleIo(): StyleFileIo {
969
+ const path = stylePath(homedir())
970
+ return {
971
+ path,
972
+ load: (): Promise<StyleFile> => loadStyle(async p => readFile(p, 'utf8'), path),
973
+ save: (file: StyleFile): Promise<void> =>
974
+ saveJsonAtomic(async d => { await mkdir(d, { recursive: true }) }, async (p, s) => { await writeFile(p, s, 'utf8') }, async (from, to) => { await rename(from, to) }, path, file),
975
+ }
976
+ }
977
+
978
+ /** Tail of the promise chain that serializes style-file critical sections. */
979
+ private styleQueue: Promise<unknown> = Promise.resolve()
980
+
981
+ /** Run a style load→save section to completion before the next starts, so two
982
+ * scopes saved at once cannot overwrite each other. */
983
+ private withStyle<T>(section: (io: StyleFileIo) => Promise<T>): Promise<T> {
984
+ const run = this.styleQueue.then(() => section(this.styleIo()))
985
+ this.styleQueue = run.then(() => undefined, () => undefined)
986
+ return run
987
+ }
988
+
989
+ /** Binding-file IO as injected dependencies, so tests can substitute readers/writers later. */
990
+ private bindingsIo(): BindingsFileIo {
991
+ const path = bindingsPath(homedir())
992
+ return {
993
+ path,
994
+ load: (): Promise<BindingsFile> =>
995
+ loadBindings(async p => readFile(p, 'utf8'), path),
996
+ save: (file: BindingsFile): Promise<void> =>
997
+ saveBindings(async d => { await mkdir(d, { recursive: true }) }, async (p, s) => { await writeFile(p, s, 'utf8') }, async (from, to) => { await rename(from, to) }, path, file),
998
+ }
999
+ }
1000
+
1001
+ /** Tail of the promise chain that serializes binding-file critical sections. */
1002
+ private bindingsQueue: Promise<unknown> = Promise.resolve()
1003
+
1004
+ /** Run a load→save critical section to completion before the next one starts (single host
1005
+ * process); a failing section rejects to its caller without breaking the chain. */
1006
+ private withBindings<T>(section: (io: BindingsFileIo) => Promise<T>): Promise<T> {
1007
+ const run = this.bindingsQueue.then(() => section(this.bindingsIo()))
1008
+ this.bindingsQueue = run.then(() => undefined, () => undefined)
1009
+ return run
1010
+ }
1011
+
1012
+ /** Resolve the repo root for a directory (null when not a git repo). Always forward slashes. */
1013
+ private async repoRootOf(cwd: string, signal: AbortSignal): Promise<string | null> {
1014
+ const out = await this.git(cwd, ['rev-parse', '--show-toplevel'], signal)
1015
+ if (out.exitCode !== 0) return null
1016
+ return out.stdout.trim().replace(/\\/g, '/') || null
1017
+ }
1018
+
1019
+ /**
1020
+ * Spawn `git <args>` in cwd with piped stdio; drains both streams and returns
1021
+ * full stdout. Never throws.
1022
+ *
1023
+ * Every call runs with credential prompting disabled, reads included. stdin is
1024
+ * ignored, which does not turn a prompt into an error — it turns it into a
1025
+ * wait nobody can end, inside the host process. A read never needs a prompt,
1026
+ * so switching them off costs nothing and closes the hang for fetch and push.
1027
+ * @param graceMs - override for network operations, which wait on a remote
1028
+ * rather than on the disk.
1029
+ */
1030
+ private async git(cwd: string, argv: readonly string[], signal: AbortSignal, graceMs = 30_000): Promise<GitResult> {
1031
+ try {
1032
+ const handle = this.ctx.subprocess.spawn({
1033
+ // core.quotepath=false on EVERY call: with the default on, git octal-
1034
+ // escapes non-ASCII paths, which JSON unescaping cannot decode (JSON
1035
+ // has no octal escapes) — and status and numstat would then key the
1036
+ // same file under different strings, so counts silently vanish and
1037
+ // CJK paths render escaped (TESTS.md A12).
1038
+ argv: ['git', '-c', 'core.quotepath=false', ...argv],
1039
+ cwd,
1040
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
1041
+ graceMs,
1042
+ signal,
1043
+ env: { ...NON_INTERACTIVE_ENV },
1044
+ })
1045
+ const [stdout, stderr, outcome] = await Promise.all([
1046
+ readAll(handle.stdout),
1047
+ readAll(handle.stderr), // drain so a chatty stderr cannot deadlock the pipe
1048
+ handle.done,
1049
+ ])
1050
+ return { stdout, exitCode: outcome.exitCode ?? 0, stderr: stderr.slice(-300).trim() }
1051
+ } catch (error) {
1052
+ return { stdout: '', exitCode: 1, stderr: error instanceof Error ? error.message : String(error) }
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ export default GitWorkbenchService
1058
+
1059
+ /**
1060
+ * Run `task` over every item with at most `limit` of them in flight.
1061
+ *
1062
+ * @param items - inputs; results come back index-aligned with these.
1063
+ * @param limit - how many tasks may run at once.
1064
+ * @param task - work to run per item.
1065
+ * @returns each item's result, in the input's order.
1066
+ */
1067
+ async function mapPooled<T, R>(items: readonly T[], limit: number, task: (item: T) => Promise<R>): Promise<R[]> {
1068
+ const results = new Array<R>(items.length)
1069
+ let next = 0
1070
+ const worker = async (): Promise<void> => {
1071
+ for (let index = next++; index < items.length; index = next++) {
1072
+ results[index] = await task(items[index])
1073
+ }
1074
+ }
1075
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
1076
+ return results
1077
+ }
1078
+
1079
+ /** What every untracked file must report, whether or not its diff ships. */
1080
+ interface UntrackedMeasure {
1081
+ readonly lineCount: number
1082
+ readonly binary: boolean
1083
+ /** False when the file is missing, binary, or past the per-file byte cap. */
1084
+ readonly diffable: boolean
1085
+ }
1086
+
1087
+ /**
1088
+ * Count an untracked file's lines and decide whether it is binary.
1089
+ *
1090
+ * Both answers come off the raw buffer: decoding a megabyte to utf8 only to
1091
+ * count newlines is the expensive half of this pass, and most untracked files
1092
+ * never reach the bundled diff. Never throws; an unreadable file reports zero
1093
+ * lines and nothing to diff.
1094
+ * @param cwd - worktree the path is relative to.
1095
+ * @param path - repository-relative file path.
1096
+ * @returns the file's line count, binary flag, and whether a diff may be built.
1097
+ */
1098
+ async function measureUntracked(cwd: string, path: string): Promise<UntrackedMeasure> {
1099
+ let bytes: Buffer
1100
+ try {
1101
+ bytes = await readFile(join(cwd, path))
1102
+ } catch {
1103
+ return { lineCount: 0, binary: false, diffable: false }
1104
+ }
1105
+ if (isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) return { lineCount: 0, binary: true, diffable: false }
1106
+ return {
1107
+ lineCount: countBufferLines(bytes),
1108
+ binary: false,
1109
+ diffable: bytes.length <= UNTRACKED_FILE_BYTE_CAP,
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Synthesize the unified-diff "new file" segment for an untracked file.
1115
+ *
1116
+ * `git diff --no-index /dev/null <f>` is NOT used: on Windows git resolves
1117
+ * `/dev/null` as a repo-relative path. Never throws.
1118
+ * @param cwd - worktree the path is relative to.
1119
+ * @param path - repository-relative file path.
1120
+ * @returns the segment, or null when the file is missing, binary, or oversized.
1121
+ */
1122
+ async function untrackedSegment(cwd: string, path: string): Promise<string | null> {
1123
+ let bytes: Buffer
1124
+ try {
1125
+ bytes = await readFile(join(cwd, path))
1126
+ } catch {
1127
+ return null
1128
+ }
1129
+ if (isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) return null
1130
+ if (bytes.length > UNTRACKED_FILE_BYTE_CAP) return null
1131
+ const lines = countBufferLines(bytes)
1132
+ const text = bytes.toString('utf8')
1133
+ const body = text.endsWith('\n') ? text.slice(0, -1) : text
1134
+ return [
1135
+ `diff --git a/${path} b/${path}`,
1136
+ 'new file mode 100644',
1137
+ '--- /dev/null',
1138
+ `+++ b/${path}`,
1139
+ `@@ -0,0 +1,${lines} @@`,
1140
+ ...body.split('\n').map(line => `+${line}`),
1141
+ ].join('\n')
1142
+ }
1143
+
1144
+ async function readAll(stream: Readable | undefined): Promise<string> {
1145
+ if (stream === undefined) return ''
1146
+ const chunks: Buffer[] = []
1147
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array))
1148
+ return Buffer.concat(chunks).toString('utf8')
1149
+ }
1150
+
1151
+ /** Random hex string for generated worktree names (`wt-<hex>`). */
1152
+ function randomHex(digits: number): string {
1153
+ const bytes = randomBytes(Math.ceil(digits / 2))
1154
+ return bytes.toString('hex').slice(0, digits)
1155
+ }
1156
+
1157
+ function emptyStats(worktreePath: string): WorkbenchStats {
1158
+ return {
1159
+ worktreePath, branch: '', ahead: 0, behind: 0, detached: false,
1160
+ addedLines: 0, deletedLines: 0, addedFiles: 0, deletedFiles: 0, modifiedFiles: 0,
1161
+ files: [], diff: '', commits: [],
1162
+ }
1163
+ }
1164
+
1165
+ function parseBranch(stdout: string): { ahead: number; behind: number } {
1166
+ const header = stdout.split('\n').find(line => line.startsWith('##'))
1167
+ if (header === undefined) return { ahead: 0, behind: 0 }
1168
+ const ahead = /ahead (\d+)/.exec(header)
1169
+ const behind = /behind (\d+)/.exec(header)
1170
+ return { ahead: ahead ? Number.parseInt(ahead[1], 10) : 0, behind: behind ? Number.parseInt(behind[1], 10) : 0 }
1171
+ }
1172
+