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