@young1lin/dsh-ui-gitworkbench 0.1.14 → 0.1.16

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CHANGELOG_EN.md +23 -0
  3. package/README.md +29 -3
  4. package/README_EN.md +1 -1
  5. package/lib/client.js +1512 -470
  6. package/lib/dir-listing.js +34 -0
  7. package/lib/fs-remove.js +5 -36
  8. package/lib/index.js +346 -117
  9. package/lib/path-lock.js +54 -0
  10. package/lib/repo-root.js +60 -0
  11. package/lib/worktree.js +83 -0
  12. package/lib/write-checked.js +1 -1
  13. package/package.json +5 -5
  14. package/src/client/ChromeGlyph.tsx +5 -0
  15. package/src/client/CodeEditor.tsx +19 -1
  16. package/src/client/DiffViews.tsx +116 -121
  17. package/src/client/FileBrowser.tsx +196 -23
  18. package/src/client/GitWorkbenchPanel.module.css +1 -0
  19. package/src/client/GitWorkbenchPanel.tsx +100 -12
  20. package/src/client/SideRails.tsx +106 -0
  21. package/src/client/diff-cells.tsx +147 -0
  22. package/src/client/diff-nav.ts +4 -1
  23. package/src/client/dir-tree.ts +31 -1
  24. package/src/client/file-rows.ts +40 -0
  25. package/src/client/h-rail.ts +70 -0
  26. package/src/client/ignored-cache.ts +193 -0
  27. package/src/client/index.ts +22 -3
  28. package/src/client/locales.ts +12 -2
  29. package/src/client/row-heights.ts +225 -0
  30. package/src/client/styles/changes.css +33 -2
  31. package/src/client/styles/controls.css +5 -0
  32. package/src/client/styles/files.css +5 -0
  33. package/src/client/styles/rails.css +72 -0
  34. package/src/client/use-row-window.ts +7 -3
  35. package/src/client/use-variable-row-window.ts +210 -0
  36. package/src/dir-listing.ts +47 -0
  37. package/src/fs-remove.ts +5 -36
  38. package/src/index.ts +372 -118
  39. package/src/path-lock.ts +56 -0
  40. package/src/repo-root.ts +66 -0
  41. package/src/types/dsh-shim.d.ts +12 -2
  42. package/src/worktree.ts +97 -0
  43. package/src/write-checked.ts +1 -1
package/lib/index.js CHANGED
@@ -76,7 +76,7 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
76
76
  * @module @young1lin/dsh-ui-gitworkbench
77
77
  */
78
78
  import { randomBytes } from 'node:crypto';
79
- import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
79
+ import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
80
80
  import { homedir, tmpdir } from 'node:os';
81
81
  import { join } from 'node:path';
82
82
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -86,16 +86,19 @@ import { saveJsonAtomic } from './atomic-json.js';
86
86
  import { runWriteChecked } from './write-checked.js';
87
87
  import { CommitPayloadCache, cacheKey } from './commit-cache.js';
88
88
  import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff, commitArgv, countBufferLines, decodesAsUtf8, fetchArgv, isBinaryPrefix, isNoMergeBaseError, isSafePathArg, parseNameStatus, parseNumstat, parseStatus, parseTracking, pullArgv, pushArgv, stageArgv, unstageArgv, } from './git-ops.js';
89
- import { planFromStatus, } from './discard-ops.js';
89
+ import { isSafeRelativePath, planFromStatus, } from './discard-ops.js';
90
+ import { shapeDirChildren } from './dir-listing.js';
90
91
  import { parseBlame } from './blame.js';
91
92
  import { removePathInside } from './fs-remove.js';
93
+ import { resolveInside } from './path-lock.js';
94
+ import { resolveRepoRoot, rootedDir } from './repo-root.js';
92
95
  import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js';
93
96
  import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js';
94
97
  import { LOG_FORMAT, parseLog } from './git-log.js';
95
98
  import { emptyLogFilter, logFilterArgs } from './log-filter.js';
96
99
  import { parseShortlog } from './shortlog.js';
97
100
  import { isBlankEntry, loadStyle, sanitizeEntry, stylePath, } from './style-store.js';
98
- import { bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
101
+ import { bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
99
102
  /** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
100
103
  const DIFF_CHAR_CAP = 400_000;
101
104
  /** Untracked files larger than this are listed + counted but never diffed. */
@@ -120,6 +123,11 @@ const SHORTLOG_CAP = 500;
120
123
  /** Path list cap for the picker: a monorepo can outrun any popup; past this
121
124
  * the tree is cut and the truncation reported, never silent. */
122
125
  const TREE_PATH_CAP = 50_000;
126
+ /** Cap on the ignored entry listing `repoTree` rides along. The listing is
127
+ * proportional to the .gitignore's coverage, not the repository (git
128
+ * collapses every fully-ignored directory to one line), so real repos sit in
129
+ * the tens; this is a reported fuse for a pathological ignore setup. */
130
+ const IGNORED_PATH_CAP = 5_000;
123
131
  /**
124
132
  * Most branch names sent to the browser. `worktreeStatus` is polled, so an
125
133
  * unbounded list would repeat on the wire every few seconds; the picker reports
@@ -163,6 +171,7 @@ let GitWorkbenchService = (() => {
163
171
  let _commits_decorators;
164
172
  let _authors_decorators;
165
173
  let _repoTree_decorators;
174
+ let _ignoredDir_decorators;
166
175
  let _compareRefs_decorators;
167
176
  let _sessionWorktree_decorators;
168
177
  let _worktreeEnter_decorators;
@@ -193,6 +202,7 @@ let GitWorkbenchService = (() => {
193
202
  _commits_decorators = [Remote('commits')];
194
203
  _authors_decorators = [Remote('authors')];
195
204
  _repoTree_decorators = [Remote('repoTree')];
205
+ _ignoredDir_decorators = [Remote('ignoredDir')];
196
206
  _compareRefs_decorators = [Remote('compareRefs')];
197
207
  _sessionWorktree_decorators = [Remote('sessionWorktree')];
198
208
  _worktreeEnter_decorators = [Remote('worktreeEnter')];
@@ -220,6 +230,7 @@ let GitWorkbenchService = (() => {
220
230
  __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);
221
231
  __esDecorate(this, null, _authors_decorators, { kind: "method", name: "authors", static: false, private: false, access: { has: obj => "authors" in obj, get: obj => obj.authors }, metadata: _metadata }, null, _instanceExtraInitializers);
222
232
  __esDecorate(this, null, _repoTree_decorators, { kind: "method", name: "repoTree", static: false, private: false, access: { has: obj => "repoTree" in obj, get: obj => obj.repoTree }, metadata: _metadata }, null, _instanceExtraInitializers);
233
+ __esDecorate(this, null, _ignoredDir_decorators, { kind: "method", name: "ignoredDir", static: false, private: false, access: { has: obj => "ignoredDir" in obj, get: obj => obj.ignoredDir }, metadata: _metadata }, null, _instanceExtraInitializers);
223
234
  __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);
224
235
  __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);
225
236
  __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);
@@ -249,10 +260,32 @@ let GitWorkbenchService = (() => {
249
260
  * mutation updates this inside the same critical section that writes it.
250
261
  */
251
262
  bindingMirror = new Map();
263
+ /**
264
+ * Session id → parent session id, as `agent/session-start` delivered it. A
265
+ * subagent header names its parent, and that edge is all the lineage walk
266
+ * needs: a child session without a binding of its own works under its
267
+ * nearest bound ancestor (see {@link resolveEffectiveBinding}). The map is
268
+ * never pruned — it holds one short string per session this process has
269
+ * seen, and a stale edge can only make a lookup walk further, never lie.
270
+ */
271
+ parentOf = new Map();
252
272
  constructor(ctx) {
253
273
  super(ctx, 'gitWorkbench');
254
274
  this.registerWorktreeTools(ctx);
255
275
  this.registerWorktreePrompt(ctx);
276
+ // Fires for every session the process publishes — fresh subagents and
277
+ // sessions whose loop resumes from disk. An IDLE session's edge is absent
278
+ // until its loop (re)starts, so a lookup can simply find no ancestor right
279
+ // after a host restart — the pre-feature behavior, fail-soft. `events.on`
280
+ // (not the typed `ctx.on` overload) because the event is declared by
281
+ // @deepseek-ai/dsh-agent, which this plugin does not depend on; the
282
+ // listener lives on this ctx's fiber.
283
+ ctx.events.on('agent/session-start', (payload) => {
284
+ const session = payload.agent?.session;
285
+ const parent = lineageEdgeOf(session?.header);
286
+ if (session !== undefined && parent !== undefined)
287
+ this.parentOf.set(session.id, parent);
288
+ });
256
289
  // Hydrate the mirror through the same queue as the mutations, so a binding
257
290
  // written before hydration finishes is not overwritten by the stale read.
258
291
  // A failed read leaves the mirror empty: sessions then get no standing
@@ -284,17 +317,20 @@ let GitWorkbenchService = (() => {
284
317
  name: 'worktree:binding',
285
318
  order: 115,
286
319
  text: (context) => {
287
- const sessionId = context.agent?.session.id;
288
- const binding = sessionId === undefined ? undefined : this.bindingMirror.get(sessionId);
289
- if (binding === undefined)
320
+ const session = context.agent?.session;
321
+ if (session === undefined)
290
322
  return '';
291
- const rel = `.agents/worktrees/${binding.name}`;
292
- const branchNote = binding.branch === undefined ? '' : ` (branch ${binding.branch})`;
293
- return `This session is bound to git worktree "${binding.name}"${branchNote}.\n`
294
- + 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
295
- + `- shell commands: pass workdir "${rel}"\n`
296
- + `- file tools: prefix every path with ${rel}/\n`
297
- + 'A path without that prefix acts on the MAIN worktree, not the bound one. Call worktree_exit to unbind.';
323
+ // First hop straight off the live header: the prompt must not depend
324
+ // on the session-start event having been seen (a plugin reload
325
+ // mid-session repopulates the map only through later events).
326
+ const parent = lineageEdgeOf(session.header);
327
+ if (parent !== undefined && !this.parentOf.has(session.id)) {
328
+ this.parentOf.set(session.id, parent);
329
+ }
330
+ const effective = resolveEffectiveBinding(session.id, this.parentOf, id => this.bindingMirror.get(id));
331
+ if (effective === undefined)
332
+ return '';
333
+ return bindingNotice(effective.binding.name, effective.binding.branch, effective.inherited);
298
334
  },
299
335
  });
300
336
  });
@@ -331,6 +367,7 @@ let GitWorkbenchService = (() => {
331
367
  ok: { type: 'boolean' },
332
368
  error: { type: 'string' },
333
369
  binding: { oneOf: [{ type: 'null' }, { type: 'object', additionalProperties: true }] },
370
+ bindingInherited: { type: 'boolean' },
334
371
  worktrees: { type: 'array', items: { type: 'object', additionalProperties: true } },
335
372
  branches: { type: 'array', items: { type: 'string' } },
336
373
  branchesTruncated: { type: 'boolean' },
@@ -374,7 +411,8 @@ let GitWorkbenchService = (() => {
374
411
  }));
375
412
  ctx.tools.register(defineTool({
376
413
  name: 'worktree_status',
377
- description: 'Show this session\'s bound worktree (if any) and the repository\'s existing worktrees with branches.',
414
+ description: 'Show this session\'s worktree (its own, or the one its parent session entered — bindingInherited says which) '
415
+ + 'and the repository\'s existing worktrees with branches.',
378
416
  parameters: {},
379
417
  output: output(STATUS_SCHEMA),
380
418
  execute: async (_args, exec) => {
@@ -401,10 +439,20 @@ let GitWorkbenchService = (() => {
401
439
  // the browser. The tree and the counters need only `status` and
402
440
  // `--numstat`, both of which stay around 110-140ms at that size, and the
403
441
  // pane already fetches the file it is actually showing through `fileDiff`.
404
- const [statusInfo, numstat, revInfo] = await Promise.all([
442
+ // The fourth read resolves where the rest of this method must run: the
443
+ // repository ROOT. The paths this method handles are repository-relative
444
+ // (that is what porcelain status prints wherever it runs), so the
445
+ // untracked files read below must be joined against the root — a session
446
+ // opened at `repo/server` would otherwise look for `repo/server/server/f`.
447
+ // The three git reads themselves are cwd-INSENSITIVE (their output names
448
+ // paths from the repository root whatever directory they run in), so they
449
+ // stay on the session's own directory and the root resolve rides along in
450
+ // the same batch: the polled call pays for it in wall time not at all.
451
+ const [statusInfo, numstat, revInfo, root] = await Promise.all([
405
452
  this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
406
453
  this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
407
454
  this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
455
+ this.rootedDirOf(worktreePath, signal),
408
456
  ]);
409
457
  if (statusInfo.exitCode !== 0) {
410
458
  const detail = statusInfo.stderr.length > 0 ? `: ${statusInfo.stderr}` : '';
@@ -418,7 +466,7 @@ let GitWorkbenchService = (() => {
418
466
  // segments into the payload — is gone with the payload itself; `fileDiff`
419
467
  // synthesizes the one segment the reader has actually opened.
420
468
  const untracked = files.filter(file => file.status === 'untracked');
421
- const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path));
469
+ const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(root, file.path));
422
470
  for (const [index, file] of untracked.entries()) {
423
471
  const measure = measured[index];
424
472
  file.addedLines = measure.lineCount;
@@ -485,41 +533,46 @@ let GitWorkbenchService = (() => {
485
533
  * Plain-identifier params, signal last.
486
534
  */
487
535
  async fileDiff(worktreePath, path, commit, base, head, signal) {
488
- const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
489
536
  if (typeof path !== 'string' || path.length === 0)
490
537
  return { diff: '' };
538
+ // The repository root, not the session's directory: the pathspec below
539
+ // is repository-relative, and git resolves pathspecs against the cwd —
540
+ // from a subdirectory `diff HEAD -- server/f` looks for
541
+ // `server/server/f`, matches nothing, and reports an EMPTY diff with
542
+ // exit 0. (repo-root.ts holds the whole story.)
543
+ const root = await this.rootedDirOf(worktreePath, signal);
491
544
  if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
492
545
  if (!isRefName(base) || !isRefName(head))
493
546
  return { diff: '' };
494
- const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal);
547
+ const ranged = await this.git(root, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal);
495
548
  if (ranged.exitCode === 0)
496
549
  return { diff: ranged.stdout };
497
550
  // Unrelated histories have no merge base for `A...B` to diff from; the
498
551
  // two-tip diff still answers what differs, exactly as `compareRefs` does.
499
552
  if (!isNoMergeBaseError(ranged.stderr))
500
553
  return { diff: '' };
501
- const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal);
554
+ const tips = await this.git(root, ['diff', '--no-renames', base, head, '--', path], signal);
502
555
  return { diff: tips.exitCode === 0 ? tips.stdout : '' };
503
556
  }
504
557
  if (typeof commit === 'string' && commit.length > 0) {
505
558
  if (!COMMIT_HASH.test(commit))
506
559
  return { diff: '' };
507
- const key = cacheKey(cwd, commit, path);
560
+ const key = cacheKey(root, commit, path);
508
561
  const cached = this.commitDiffCache.get(key);
509
562
  if (cached !== undefined)
510
563
  return { diff: cached };
511
564
  // `--first-parent` for the same reason as in `commitStats`: without it a
512
565
  // merge commit has no diff to show and the pane opens empty.
513
- const shown = await this.git(cwd, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal);
566
+ const shown = await this.git(root, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal);
514
567
  if (shown.exitCode !== 0)
515
568
  return { diff: '' };
516
569
  this.commitDiffCache.set(key, shown.stdout);
517
570
  return { diff: shown.stdout };
518
571
  }
519
- const tracked = await this.git(cwd, ['diff', 'HEAD', '--', path], signal);
572
+ const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal);
520
573
  if (tracked.stdout.trim().length > 0)
521
574
  return { diff: tracked.stdout };
522
- return { diff: await untrackedSegment(cwd, path) ?? '' };
575
+ return { diff: await untrackedSegment(root, path) ?? '' };
523
576
  }
524
577
  /**
525
578
  * One layer of one file for the side-by-side diff pane: the layer's
@@ -534,7 +587,8 @@ let GitWorkbenchService = (() => {
534
587
  * nothing for them and the unstaged layer falls back to the synthesized
535
588
  * new-file segment `fileDiff` already uses.
536
589
  *
537
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
590
+ * @param worktreePath - directory the session opened; git runs at its
591
+ * repository root. Empty falls back to the host cwd.
538
592
  * @param path - repository-relative path, as the drawer lists it.
539
593
  * @param layer - `unstaged` (index→worktree) or `staged` (HEAD→index).
540
594
  * @param signal - abort signal.
@@ -543,21 +597,28 @@ let GitWorkbenchService = (() => {
543
597
  if (layer !== 'unstaged' && layer !== 'staged') {
544
598
  throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`);
545
599
  }
546
- if (typeof path !== 'string' || !isSafePathArg(path)) {
600
+ if (typeof path !== 'string' || !isSafeRelativePath(path)) {
547
601
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
548
602
  }
549
- const cwd = this.cwdOf(worktreePath);
603
+ // Repository root, not the session's directory: every path below is
604
+ // repository-relative (pathspecs resolve against the cwd, and so does
605
+ // the file read for the editor's target). See repo-root.ts.
606
+ const root = await this.rootedDirOf(worktreePath, signal);
607
+ // The unstaged layer READS THE FILE, so its absolute path is built by the
608
+ // lock rather than by a join — see path-lock.ts. Done here, once, so the
609
+ // one place that resolves a client path is visible in this method.
550
610
  return layer === 'unstaged'
551
- ? await this.unstagedSides(cwd, path, signal)
552
- : await this.stagedSides(cwd, path, signal);
611
+ ? await this.unstagedSides(root, path, resolveInside(root, path), signal)
612
+ : await this.stagedSides(root, path, signal);
553
613
  }
554
- /** The unstaged layer: diff index→worktree, target = the working-tree file. */
555
- async unstagedSides(cwd, path, signal) {
614
+ /** The unstaged layer: diff index→worktree, target = the working-tree file.
615
+ * `full` is that file's absolute path, already through the lock. */
616
+ async unstagedSides(root, path, full, signal) {
556
617
  // Size guard first, off the stat rather than a read: declining a file past
557
618
  // the cap must not mean loading a pathological one whole first. Bytes are
558
619
  // all a stat knows; the line half of the guard needs the read below.
559
620
  try {
560
- const info = await stat(join(cwd, path));
621
+ const info = await stat(full);
561
622
  if (info.isFile() && targetTooLarge(info.size, 0))
562
623
  return { ...emptySides(), tooLarge: true };
563
624
  }
@@ -566,20 +627,20 @@ let GitWorkbenchService = (() => {
566
627
  }
567
628
  let bytes = null;
568
629
  try {
569
- bytes = await readFile(join(cwd, path));
630
+ bytes = await readFile(full);
570
631
  }
571
632
  catch {
572
633
  bytes = null;
573
634
  }
574
635
  if (bytes !== null && isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) {
575
- return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) };
636
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) };
576
637
  }
577
638
  if (bytes !== null && targetTooLarge(bytes.length, countBufferLines(bytes))) {
578
639
  return { ...emptySides(), tooLarge: true };
579
640
  }
580
- const diff = await this.layerDiffText(cwd, path, 'unstaged', signal);
641
+ const diff = await this.layerDiffText(root, path, 'unstaged', signal);
581
642
  if (binaryDiffOutput(diff)) {
582
- return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) };
643
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) };
583
644
  }
584
645
  // The diff half of the guard, AFTER the artifact exists: the target-side
585
646
  // checks above cannot see a worktree-deleted large file (no target to
@@ -591,7 +652,7 @@ let GitWorkbenchService = (() => {
591
652
  diff,
592
653
  diffSha: sha1Hex(diff),
593
654
  targetText: bytes === null ? '' : bytes.toString('utf8'),
594
- targetSha: bytes === null ? '' : await this.worktreeBlobSha(cwd, path, signal),
655
+ targetSha: bytes === null ? '' : await this.worktreeBlobSha(root, path, signal),
595
656
  binary: false,
596
657
  tooLarge: false,
597
658
  // Only the unstaged layer can report this, and only it needs to: the
@@ -600,11 +661,11 @@ let GitWorkbenchService = (() => {
600
661
  };
601
662
  }
602
663
  /** The staged layer: diff HEAD→index, target = the index blob. */
603
- async stagedSides(cwd, path, signal) {
664
+ async stagedSides(root, path, signal) {
604
665
  // `:path` resolves the stage-0 index entry: the target text when it exists,
605
666
  // and a failed resolution (no entry) is the empty target, not an error.
606
- const shown = await this.git(cwd, ['show', `:${path}`], signal);
607
- const sha = (await this.git(cwd, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim();
667
+ const shown = await this.git(root, ['show', `:${path}`], signal);
668
+ const sha = (await this.git(root, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim();
608
669
  const targetText = shown.exitCode === 0 ? shown.stdout : '';
609
670
  const targetBytes = Buffer.from(targetText, 'utf8');
610
671
  if (targetText.length > 0 && isBinaryPrefix(targetBytes, BINARY_SNIFF_BYTES)) {
@@ -613,7 +674,7 @@ let GitWorkbenchService = (() => {
613
674
  if (targetTooLarge(targetBytes.length, countBufferLines(targetBytes))) {
614
675
  return { ...emptySides(), tooLarge: true, targetSha: sha };
615
676
  }
616
- const diff = await this.layerDiffText(cwd, path, 'staged', signal);
677
+ const diff = await this.layerDiffText(root, path, 'staged', signal);
617
678
  if (binaryDiffOutput(diff)) {
618
679
  return { ...emptySides(), binary: true, targetSha: sha };
619
680
  }
@@ -634,8 +695,8 @@ let GitWorkbenchService = (() => {
634
695
  };
635
696
  }
636
697
  /** Blob sha of the working-tree file, '' when git cannot hash it. */
637
- async worktreeBlobSha(cwd, path, signal) {
638
- const hashed = await this.git(cwd, ['hash-object', '--', path], signal);
698
+ async worktreeBlobSha(root, path, signal) {
699
+ const hashed = await this.git(root, ['hash-object', '--', path], signal);
639
700
  return hashed.exitCode === 0 ? hashed.stdout.trim() : '';
640
701
  }
641
702
  /**
@@ -647,19 +708,19 @@ let GitWorkbenchService = (() => {
647
708
  * its own fresh fetch, so the two ends of the stale comparison are over the
648
709
  * SAME text by construction, not by two fetch sites staying in step.
649
710
  */
650
- async layerDiffText(cwd, path, layer, signal) {
711
+ async layerDiffText(root, path, layer, signal) {
651
712
  if (layer === 'staged') {
652
- return (await this.git(cwd, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
713
+ return (await this.git(root, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
653
714
  }
654
- const diff = (await this.git(cwd, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
715
+ const diff = (await this.git(root, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
655
716
  // Untracked files have no index entry, so `git diff` reports nothing for
656
717
  // them; the synthesized new-file segment is their unstaged diff. The
657
718
  // trailing newline is added here because every diff git prints carries
658
719
  // one: this text is what `applyBlocks` re-emits as a patch file, and a
659
720
  // patch whose last line has no LF is "corrupt patch" to `git apply` —
660
721
  // which is exactly what a real-git drive of the untracked path caught.
661
- if (diff.length === 0 && await this.isUntracked(cwd, path, signal)) {
662
- const segment = await untrackedSegment(cwd, path, SIDE_BYTE_CAP);
722
+ if (diff.length === 0 && await this.isUntracked(root, path, signal)) {
723
+ const segment = await untrackedSegment(root, path, SIDE_BYTE_CAP);
663
724
  return segment === null ? '' : `${segment}\n`;
664
725
  }
665
726
  return diff;
@@ -677,7 +738,7 @@ let GitWorkbenchService = (() => {
677
738
  * shared with `fileSides`, and the tmpfile pair. Every failure comes back as
678
739
  * a result — the method never throws across the RPC boundary.
679
740
  *
680
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
741
+ * @param worktreePath - directory the session opened; git runs at its repository root.
681
742
  * @param path - repository-relative path, as the drawer lists it.
682
743
  * @param layer - the layer the block was selected on; the mode decides which
683
744
  * one that may be.
@@ -688,14 +749,16 @@ let GitWorkbenchService = (() => {
688
749
  * @param signal - abort signal.
689
750
  */
690
751
  async applyBlocks(worktreePath, path, layer, diffSha, lines, mode, signal) {
691
- const cwd = this.cwdOf(worktreePath);
752
+ // The repository root — the patch's pathspecs are repository-relative
753
+ // and `git apply` resolves them against the cwd (repo-root.ts).
754
+ const root = await this.rootedDirOf(worktreePath, signal);
692
755
  const io = {
693
756
  git: (dir, argv) => this.git(dir, argv, signal),
694
- layerDiff: (file, which) => this.layerDiffText(cwd, file, which === 'staged' ? 'staged' : 'unstaged', signal),
757
+ layerDiff: (file, which) => this.layerDiffText(root, file, which === 'staged' ? 'staged' : 'unstaged', signal),
695
758
  writePatch: writeTmpPatch,
696
759
  dropPatch: dropTmpPatch,
697
760
  };
698
- return runApplyBlocks(io, cwd, path, layer, String(diffSha ?? ''), lines, mode);
761
+ return runApplyBlocks(io, root, path, layer, String(diffSha ?? ''), lines, mode);
699
762
  }
700
763
  /**
701
764
  * Save the side-by-side editor's buffer over the working-tree file it was
@@ -716,7 +779,7 @@ let GitWorkbenchService = (() => {
716
779
  * check and this method are one thing, and no unchecked write RPC exists or
717
780
  * may be added in this plugin.
718
781
  *
719
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
782
+ * @param worktreePath - directory the session opened; git runs at its repository root.
720
783
  * @param path - repository-relative path, as the drawer lists it.
721
784
  * @param text - the editor buffer, verbatim; written as bytes (LF as given).
722
785
  * @param expectedSha - the `targetSha` the buffer was opened with ('' when
@@ -725,7 +788,10 @@ let GitWorkbenchService = (() => {
725
788
  * @param signal - abort signal.
726
789
  */
727
790
  async writeChecked(worktreePath, path, text, expectedSha, signal) {
728
- const cwd = this.cwdOf(worktreePath);
791
+ // The repository root — the save target joins the same base every
792
+ // other path here is relative to, and that base is the root, not the
793
+ // directory the session opened (repo-root.ts).
794
+ const root = await this.rootedDirOf(worktreePath, signal);
729
795
  const io = {
730
796
  git: (dir, argv) => this.git(dir, argv, signal),
731
797
  exists: async (p) => {
@@ -743,7 +809,7 @@ let GitWorkbenchService = (() => {
743
809
  remove: async (p) => { await rm(p, { force: true }); },
744
810
  delay: ms => new Promise(resolve => { setTimeout(resolve, ms); }),
745
811
  };
746
- return runWriteChecked(io, cwd, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '');
812
+ return runWriteChecked(io, root, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '');
747
813
  }
748
814
  /**
749
815
  * One file's provenance, line by line — the side pane's blame gutter
@@ -754,7 +820,7 @@ let GitWorkbenchService = (() => {
754
820
  * rather than missing. Read-only, no index or worktree is touched, so this
755
821
  * needs none of the confirmation machinery the write paths carry.
756
822
  *
757
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
823
+ * @param worktreePath - directory the session opened; git runs at its repository root.
758
824
  * @param path - repository-relative path, as the drawer lists it.
759
825
  * @param signal - abort signal.
760
826
  */
@@ -762,10 +828,13 @@ let GitWorkbenchService = (() => {
762
828
  if (typeof path !== 'string' || !isSafePathArg(path)) {
763
829
  return { lines: [], truncated: false, error: `unsafe path argument: ${JSON.stringify(path)}` };
764
830
  }
765
- const cwd = this.cwdOf(worktreePath);
831
+ // The repository root: blame's pathspec resolves against the cwd, and
832
+ // from a subdirectory the repository-relative path doubles up
833
+ // (`server/server/f`) into a fatal "no such path" (repo-root.ts).
834
+ const root = await this.rootedDirOf(worktreePath, signal);
766
835
  // `--` keeps a path that looks like a revision from being read as one, as
767
836
  // every other pathspec in this plugin does.
768
- const run = await this.git(cwd, ['blame', '--line-porcelain', '--', path], signal);
837
+ const run = await this.git(root, ['blame', '--line-porcelain', '--', path], signal);
769
838
  if (run.exitCode !== 0) {
770
839
  // An untracked file has no blame, and git says so; that message is the
771
840
  // honest thing to show rather than an empty gutter.
@@ -799,15 +868,19 @@ let GitWorkbenchService = (() => {
799
868
  *
800
869
  * Read-only — nothing is spawned, nothing is written.
801
870
  *
802
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
871
+ * @param worktreePath - directory the session opened; git runs at its repository root.
803
872
  * @param path - repository-relative path, as the drawer lists it.
804
873
  * @param signal - abort signal.
805
874
  */
806
875
  async fileImage(worktreePath, path, signal) {
807
- if (typeof path !== 'string' || !isSafePathArg(path)) {
876
+ if (typeof path !== 'string' || !isSafeRelativePath(path)) {
808
877
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
809
878
  }
810
- const full = join(this.cwdOf(worktreePath), path);
879
+ // The repository root — the image is read from disk at the same base
880
+ // every other path in this plugin is relative to (repo-root.ts) — and
881
+ // through the lock, because this is a raw read with no git in the way
882
+ // to refuse a path that leaves the repository (path-lock.ts).
883
+ const full = resolveInside(await this.rootedDirOf(worktreePath, signal), path);
811
884
  let size = 0;
812
885
  try {
813
886
  const info = await stat(full);
@@ -848,11 +921,11 @@ let GitWorkbenchService = (() => {
848
921
  * Those are the files whose diff has to be synthesized rather than asked of
849
922
  * `git diff`, which reports nothing for them.
850
923
  */
851
- async isUntracked(cwd, path, signal) {
852
- const listed = await this.git(cwd, ['ls-files', '--', path], signal);
924
+ async isUntracked(root, path, signal) {
925
+ const listed = await this.git(root, ['ls-files', '--', path], signal);
853
926
  if (listed.exitCode !== 0 || listed.stdout.trim().length > 0)
854
927
  return false;
855
- const head = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal);
928
+ const head = await this.git(root, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal);
856
929
  return head.exitCode !== 0;
857
930
  }
858
931
  /**
@@ -885,11 +958,17 @@ let GitWorkbenchService = (() => {
885
958
  // empty pane. Against the first parent the answer is well defined and is the
886
959
  // useful one: what this merge brought into the branch it landed on. On a
887
960
  // single-parent commit the flag is a no-op, byte for byte.
961
+ // The repository root: no pathspec here today, but the cache key below
962
+ // must describe the same repository to a later caller, and running from
963
+ // the root is the one rule every read in this plugin follows
964
+ // (repo-root.ts). Resolved after the cache probe so a hit spawns
965
+ // nothing.
966
+ const root = await this.rootedDirOf(worktreePath, signal);
888
967
  const [meta, numstat, nameStatus, patch] = await Promise.all([
889
- this.git(cwd, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
890
- this.git(cwd, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
891
- this.git(cwd, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
892
- this.git(cwd, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
968
+ this.git(root, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
969
+ this.git(root, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
970
+ this.git(root, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
971
+ this.git(root, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
893
972
  ]);
894
973
  if (meta.exitCode !== 0) {
895
974
  const detail = meta.stderr.length > 0 ? `: ${meta.stderr}` : '';
@@ -954,6 +1033,10 @@ let GitWorkbenchService = (() => {
954
1033
  const from = Number.isInteger(skip) && skip >= 0 ? skip : 0;
955
1034
  const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE;
956
1035
  const effective = filter ?? emptyLogFilter();
1036
+ // The repository root: a filter's pathspec is repository-relative, and
1037
+ // git resolves pathspecs against the cwd — from a subdirectory a
1038
+ // filtered history silently comes back EMPTY with exit 0 (repo-root.ts).
1039
+ const root = await this.rootedDirOf(worktreePath, signal);
957
1040
  // Reading one row beyond the page answers "is there more" without a second
958
1041
  // traversal of the log.
959
1042
  //
@@ -966,7 +1049,7 @@ let GitWorkbenchService = (() => {
966
1049
  //
967
1050
  // Filter args go LAST: their segment ends with `--` + pathspecs, and
968
1051
  // nothing after that separator may be parsed as a flag.
969
- const log = await this.git(cwd, ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)], signal);
1052
+ const log = await this.git(root, ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)], signal);
970
1053
  // A bad filter (unparsable regex, invalid date) dies here, and an empty
971
1054
  // page is indistinguishable from "no match" unless the failure speaks —
972
1055
  // §6.13: the exit code + stderr tail is the only honest answer.
@@ -1001,20 +1084,112 @@ let GitWorkbenchService = (() => {
1001
1084
  }
1002
1085
  /**
1003
1086
  * Every file path on HEAD — the filter popup's path picker, aggregated into
1004
- * a directory tree client-side.
1087
+ * a directory tree client-side — plus the ignored-but-present entries the
1088
+ * Files tab browses.
1089
+ *
1090
+ * The ignored listing is `ls-files --others --ignored --exclude-standard
1091
+ * --directory`: every ignored FILE is listed verbatim (`application-local.yml`
1092
+ * is exactly the file a browser must find and `ls-tree HEAD` cannot), while
1093
+ * every directory a rule ignores as a whole collapses to ONE line with a
1094
+ * trailing slash — `node_modules/` costs one entry, not the ~40k files
1095
+ * inside it. The list therefore scales with the .gitignore's coverage, not
1096
+ * the repository: measured 54 entries / 48ms on this checkout with
1097
+ * node_modules present.
1005
1098
  *
1006
- * `-z` is load-bearing: NUL-separated output is UNQUOTED, while the default
1007
- * would render non-ASCII names as quoted octal escapes under
1008
- * `core.quotepath` and hand the picker garbage.
1099
+ * `-z` is load-bearing on both spawns: NUL-separated output is UNQUOTED,
1100
+ * while the default would render non-ASCII names as quoted octal escapes
1101
+ * under `core.quotepath` and hand the picker garbage.
1009
1102
  * @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
1010
1103
  * @param signal - abort signal.
1011
1104
  */
1012
1105
  async repoTree(worktreePath, signal) {
1013
- const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
1014
- const res = await this.git(cwd, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal);
1106
+ // The repository root: unlike status and numstat, `ls-tree` prints
1107
+ // cwd-RELATIVE paths from a subdirectory every entry would lose the
1108
+ // `server/` prefix and the picker would feed the log filter pathspecs
1109
+ // that match nothing (repo-root.ts).
1110
+ const root = await this.rootedDirOf(worktreePath, signal);
1111
+ const [res, ignoredRes] = await Promise.all([
1112
+ this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal),
1113
+ this.git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], signal),
1114
+ ]);
1015
1115
  const all = res.stdout.split('\0').filter(path => path.length > 0);
1016
1116
  const truncated = all.length > TREE_PATH_CAP;
1017
- return { paths: truncated ? all.slice(0, TREE_PATH_CAP) : all, truncated };
1117
+ const ignoredAll = ignoredRes.stdout.split('\0').filter(path => path.length > 0);
1118
+ const ignoredTruncated = ignoredAll.length > IGNORED_PATH_CAP;
1119
+ // `ls-tree`'s exit code is deliberately NOT checked: a repository with no
1120
+ // HEAD yet fails it, and "this repository has no files" is the honest
1121
+ // answer there. The ignored listing has no such legitimate failure, so a
1122
+ // non-zero exit is reported rather than served as an empty list — the one
1123
+ // shape that is indistinguishable from a repository ignoring nothing.
1124
+ const ignoredError = ignoredRes.exitCode === 0
1125
+ ? undefined
1126
+ : (ignoredRes.stderr.trim() || `git ls-files failed (exit ${ignoredRes.exitCode})`).slice(-500);
1127
+ return {
1128
+ paths: truncated ? all.slice(0, TREE_PATH_CAP) : all,
1129
+ truncated,
1130
+ ignored: ignoredTruncated ? ignoredAll.slice(0, IGNORED_PATH_CAP) : ignoredAll,
1131
+ ignoredTruncated,
1132
+ ...(ignoredError !== undefined ? { ignoredError } : {}),
1133
+ };
1134
+ }
1135
+ /**
1136
+ * One level of one ignored directory, read from the filesystem — the step
1137
+ * behind clicking `node_modules/` open in the Files tab.
1138
+ *
1139
+ * Deliberately NOT a git listing: with `--directory`, a pathspec under an
1140
+ * ignored directory still collapses to that one directory line (probed:
1141
+ * `ls-files --others --ignored --exclude-standard --directory -- 'node_modules/*'`
1142
+ * answers `node_modules/` and nothing else), and dropping `--directory`
1143
+ * would enumerate every file inside at once — the very flood the collapsed
1144
+ * listing exists to avoid. `readdir` is one level by construction and costs
1145
+ * milliseconds. What the entries ARE is inherited anyway: everything below
1146
+ * an ignored directory is ignored by descent, so the browser owes the
1147
+ * reader the directory's contents, not git's opinion of them.
1148
+ *
1149
+ * A directory that vanished between the listing and the click answers
1150
+ * empty: nothing to browse is the honest answer, and the next refresh
1151
+ * drops the row.
1152
+ *
1153
+ * The name says what it is FOR, not what it permits: nothing here checks
1154
+ * that `dir` is ignored, so this lists any directory inside the worktree.
1155
+ * That is the same reach the reader already has through `fileSides` and
1156
+ * `fileImage`, and adding an ignore check would only make the browser ask
1157
+ * git a second question to learn what it already knows from the listing it
1158
+ * was handed. What it does NOT permit is leaving the worktree, which is
1159
+ * what the path lock below is for.
1160
+ * @param worktreePath - worktree the directory lives in; empty falls back to the host cwd.
1161
+ * @param dir - repo-relative directory path, as the collapsed listing named it.
1162
+ * @param signal - abort signal.
1163
+ */
1164
+ async ignoredDir(worktreePath, dir, signal) {
1165
+ // The same lock every filesystem read in this plugin passes: the browser
1166
+ // is a less trusted source of paths than git's own output, and `readdir`
1167
+ // obeys no repository boundary (path-lock.ts).
1168
+ const target = resolveInside(await this.rootedDirOf(worktreePath, signal), dir);
1169
+ let dirents;
1170
+ try {
1171
+ dirents = await readdir(target, { withFileTypes: true });
1172
+ }
1173
+ catch {
1174
+ return { entries: [], truncated: false };
1175
+ }
1176
+ const plain = dirents
1177
+ .filter(entry => !entry.isSymbolicLink())
1178
+ .map(entry => ({ name: entry.name, dir: entry.isDirectory() }));
1179
+ // A pnpm-style layout makes every package row a symlink into the store;
1180
+ // `withFileTypes` reports the LINK, so a follow-up stat decides whether
1181
+ // the row expands. A broken link reads as a file and simply fails to
1182
+ // open, like any other dangling name.
1183
+ const links = dirents.filter(entry => entry.isSymbolicLink());
1184
+ const resolved = await Promise.all(links.map(async (entry) => {
1185
+ try {
1186
+ return { name: entry.name, dir: (await stat(join(target, entry.name))).isDirectory() };
1187
+ }
1188
+ catch {
1189
+ return { name: entry.name, dir: false };
1190
+ }
1191
+ }));
1192
+ return shapeDirChildren([...plain, ...resolved]);
1018
1193
  }
1019
1194
  /**
1020
1195
  * Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
@@ -1087,13 +1262,20 @@ let GitWorkbenchService = (() => {
1087
1262
  files, diff, commits: parseLog(log.stdout),
1088
1263
  };
1089
1264
  }
1090
- /** The session's worktree binding, or nulls when unbound (plain-identifier params; signal last). */
1265
+ /**
1266
+ * The session's EFFECTIVE worktree binding — its own, else the nearest bound
1267
+ * ancestor's — or nulls when neither exists. This is what the chip follows,
1268
+ * so a subagent session shows the worktree its conversation works in without
1269
+ * ever holding a binding of its own (plain-identifier params; signal last).
1270
+ */
1091
1271
  async sessionWorktree(sessionId, signal) {
1092
1272
  if (typeof sessionId !== 'string' || sessionId.length === 0)
1093
- return { worktreePath: null, name: null };
1273
+ return { worktreePath: null, name: null, inherited: false };
1094
1274
  const file = await this.bindingsIo().load();
1095
- const binding = file.bindings[sessionId];
1096
- return binding === undefined ? { worktreePath: null, name: null } : { worktreePath: binding.worktreePath, name: binding.name };
1275
+ const effective = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id]);
1276
+ return effective === undefined
1277
+ ? { worktreePath: null, name: null, inherited: false }
1278
+ : { worktreePath: effective.binding.worktreePath, name: effective.binding.name, inherited: effective.inherited };
1097
1279
  }
1098
1280
  /** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
1099
1281
  async worktreeEnter(sessionId, repoPath, name, signal) {
@@ -1185,8 +1367,15 @@ let GitWorkbenchService = (() => {
1185
1367
  return this.withBindings(async (io) => {
1186
1368
  const file = await io.load();
1187
1369
  const binding = file.bindings[sessionId];
1188
- if (binding === undefined)
1189
- return { ok: false, error: 'no worktree binding for this session' };
1370
+ if (binding === undefined) {
1371
+ // A subagent CAN see a worktree in its status while holding no binding
1372
+ // of its own (it works under its parent's). Naming that here keeps the
1373
+ // model from retrying an exit that cannot succeed.
1374
+ const inherited = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id]);
1375
+ return inherited === undefined
1376
+ ? { ok: false, error: 'no worktree binding for this session' }
1377
+ : { ok: false, error: 'this session has no binding of its own; its worktree is entered by a parent session — ask the parent session to call worktree_exit' };
1378
+ }
1190
1379
  if (remove === true) {
1191
1380
  const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal);
1192
1381
  if (status.exitCode !== 0) {
@@ -1217,21 +1406,26 @@ let GitWorkbenchService = (() => {
1217
1406
  * Branches come back most-recently-committed first. With hundreds of them the
1218
1407
  * order is what makes the list usable — the handful anyone is working on sit
1219
1408
  * at the top, so the picker is useful before a single character is typed.
1220
- * @param sessionId - session whose binding is looked up.
1409
+ * @param sessionId - session whose effective binding is looked up.
1221
1410
  * @param repoPath - caller's directory, used when the session is unbound.
1222
1411
  * @param signal - abort signal.
1223
- * @returns the binding, the repository's worktrees, and its local branches.
1412
+ * @returns the effective binding (with whether an ancestor lent it), the
1413
+ * repository's worktrees, and its local branches.
1224
1414
  */
1225
1415
  async worktreeStatus(sessionId, repoPath, signal) {
1226
1416
  const file = await this.bindingsIo().load();
1227
- const binding = typeof sessionId === 'string' && sessionId.length > 0 ? file.bindings[sessionId] ?? null : null;
1417
+ const effective = typeof sessionId === 'string' && sessionId.length > 0
1418
+ ? resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
1419
+ : undefined;
1420
+ const binding = effective?.binding ?? null;
1421
+ const bindingInherited = effective?.inherited ?? false;
1228
1422
  // Unbound: list the CALLER's repo. Falling back to the host's launch directory
1229
1423
  // would answer about whatever directory dsh was started in, not this session's.
1230
1424
  const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd();
1231
1425
  const cwd = binding?.repoRoot ?? caller;
1232
1426
  const root = await this.repoRootOf(cwd, signal);
1233
1427
  if (root === null)
1234
- return { binding, worktrees: [], branches: [], branchesTruncated: false };
1428
+ return { binding, bindingInherited, worktrees: [], branches: [], branchesTruncated: false };
1235
1429
  const [listed, named] = await Promise.all([
1236
1430
  this.git(root, ['worktree', 'list', '--porcelain'], signal),
1237
1431
  this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
@@ -1240,6 +1434,7 @@ let GitWorkbenchService = (() => {
1240
1434
  const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP);
1241
1435
  return {
1242
1436
  binding,
1437
+ bindingInherited,
1243
1438
  worktrees: parseWorktreeList(listed.stdout),
1244
1439
  branches,
1245
1440
  branchesTruncated,
@@ -1318,22 +1513,28 @@ let GitWorkbenchService = (() => {
1318
1513
  }
1319
1514
  /**
1320
1515
  * Add paths to the index.
1321
- * @param worktreePath - directory to run in.
1516
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1322
1517
  * @param paths - repository-relative paths; an empty list is refused rather
1323
1518
  * than turned into a whole-tree `git add`.
1324
1519
  * @param signal - abort signal.
1325
1520
  */
1326
1521
  async stage(worktreePath, paths, signal) {
1327
- return this.writeOp(worktreePath, () => stageArgv(asPathList(paths)), signal);
1522
+ // The repository root: `git add` resolves its pathspecs against the cwd,
1523
+ // and from a subdirectory a repository-relative path dies with
1524
+ // "pathspec did not match any files" (repo-root.ts).
1525
+ const root = await this.rootedDirOf(worktreePath, signal);
1526
+ return this.writeOp(root, () => stageArgv(asPathList(paths)), signal);
1328
1527
  }
1329
1528
  /**
1330
1529
  * Remove paths from the index, leaving the working tree untouched.
1331
- * @param worktreePath - directory to run in.
1530
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1332
1531
  * @param paths - repository-relative paths.
1333
1532
  * @param signal - abort signal.
1334
1533
  */
1335
1534
  async unstage(worktreePath, paths, signal) {
1336
- return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal);
1535
+ // Same rule as `stage` — the pathspecs are repository-relative.
1536
+ const root = await this.rootedDirOf(worktreePath, signal);
1537
+ return this.writeOp(root, () => unstageArgv(asPathList(paths)), signal);
1337
1538
  }
1338
1539
  /**
1339
1540
  * What discarding this file WOULD do, without doing it.
@@ -1344,16 +1545,19 @@ let GitWorkbenchService = (() => {
1344
1545
  * cannot come back" is exactly the difference the reader is being asked
1345
1546
  * about. So the dialog is built from this, read fresh, rather than from the
1346
1547
  * row that was clicked.
1347
- * @param worktreePath - directory to run in.
1548
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1348
1549
  * @param path - repository-relative path, as the drawer lists it.
1349
1550
  * @param signal - abort signal.
1350
1551
  * @returns the effect and whether it is irreversible; `effect` is absent when
1351
1552
  * git reports nothing to discard for that path.
1352
1553
  */
1353
1554
  async discardPlan(worktreePath, path, signal) {
1555
+ // The repository root — the plan's argv and delete paths are
1556
+ // repository-relative (repo-root.ts).
1557
+ const root = await this.rootedDirOf(worktreePath, signal);
1354
1558
  let plan;
1355
1559
  try {
1356
- plan = await this.planDiscard(worktreePath, path, signal);
1560
+ plan = await this.planDiscard(root, path, signal);
1357
1561
  }
1358
1562
  catch (error) {
1359
1563
  return { error: error instanceof Error ? error.message : String(error) };
@@ -1378,7 +1582,7 @@ let GitWorkbenchService = (() => {
1378
1582
  * it. `expectedEffect` is what the reader was shown and agreed to: if the
1379
1583
  * file changed underneath the dialog — staged, edited, reverted by someone
1380
1584
  * else — the freshly derived effect no longer matches and nothing is done.
1381
- * @param worktreePath - directory to run in.
1585
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1382
1586
  * @param path - repository-relative path, as the drawer lists it.
1383
1587
  * @param expectedEffect - the effect the confirmation stated; blank skips
1384
1588
  * the agreement check, which only the reversible
@@ -1387,9 +1591,13 @@ let GitWorkbenchService = (() => {
1387
1591
  * @returns the operation result, with the effect actually carried out.
1388
1592
  */
1389
1593
  async discardFile(worktreePath, path, expectedEffect, signal) {
1594
+ // The repository root, resolved ONCE for the plan and the steps alike:
1595
+ // every step's argv and delete path came out of a whole-tree porcelain
1596
+ // status, so they are repository-relative (repo-root.ts).
1597
+ const root = await this.rootedDirOf(worktreePath, signal);
1390
1598
  let plan;
1391
1599
  try {
1392
- plan = await this.planDiscard(worktreePath, path, signal);
1600
+ plan = await this.planDiscard(root, path, signal);
1393
1601
  }
1394
1602
  catch (error) {
1395
1603
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
@@ -1405,10 +1613,9 @@ let GitWorkbenchService = (() => {
1405
1613
  error: `this file changed since you were asked (now: ${plan.effect}); nothing was done`,
1406
1614
  };
1407
1615
  }
1408
- const cwd = this.cwdOf(worktreePath);
1409
1616
  for (const step of plan.steps) {
1410
1617
  if (step.kind === 'git') {
1411
- const result = await this.git(cwd, step.argv, signal);
1618
+ const result = await this.git(root, step.argv, signal);
1412
1619
  const failure = classifyFailure(result.exitCode, result.stderr, result.stdout);
1413
1620
  if (failure !== null) {
1414
1621
  return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) };
@@ -1416,7 +1623,7 @@ let GitWorkbenchService = (() => {
1416
1623
  continue;
1417
1624
  }
1418
1625
  try {
1419
- await removePathInside(cwd, step.path);
1626
+ await removePathInside(root, step.path);
1420
1627
  }
1421
1628
  catch (error) {
1422
1629
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
@@ -1432,12 +1639,14 @@ let GitWorkbenchService = (() => {
1432
1639
  * reports `D` plus `??` instead — which plans as "restore one, DELETE the
1433
1640
  * other" where the truth is "undo the rename".
1434
1641
  */
1435
- async planDiscard(worktreePath, path, signal) {
1642
+ async planDiscard(root, path, signal) {
1436
1643
  if (typeof path !== 'string' || !isSafePathArg(path)) {
1437
1644
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
1438
1645
  }
1439
- const cwd = this.cwdOf(worktreePath);
1440
- const status = await this.git(cwd, ['status', '--porcelain=v1', '--untracked-files=all'], signal);
1646
+ // Runs at the repository root the caller resolved: the plan's paths
1647
+ // must agree with the directory its steps execute in, and with the
1648
+ // client's `path`, which the drawer lists repository-relative.
1649
+ const status = await this.git(root, ['status', '--porcelain=v1', '--untracked-files=all'], signal);
1441
1650
  if (status.exitCode !== 0) {
1442
1651
  throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed');
1443
1652
  }
@@ -1451,7 +1660,8 @@ let GitWorkbenchService = (() => {
1451
1660
  * @param signal - abort signal.
1452
1661
  */
1453
1662
  async commit(worktreePath, message, amend, signal) {
1454
- return this.writeOp(worktreePath, () => commitArgv(String(message ?? ''), amend === true), signal);
1663
+ // No pathspec in this argv the session's own directory suffices.
1664
+ return this.writeOp(this.cwdOf(worktreePath), () => commitArgv(String(message ?? ''), amend === true), signal);
1455
1665
  }
1456
1666
  /**
1457
1667
  * Update remote-tracking refs without touching the working tree.
@@ -1460,7 +1670,7 @@ let GitWorkbenchService = (() => {
1460
1670
  * @returns the operation result, plus the divergence the fetch revealed.
1461
1671
  */
1462
1672
  async fetch(worktreePath, signal) {
1463
- const result = await this.writeOp(worktreePath, () => fetchArgv(), signal, NETWORK_GRACE_MS);
1673
+ const result = await this.writeOp(this.cwdOf(worktreePath), () => fetchArgv(), signal, NETWORK_GRACE_MS);
1464
1674
  if (!result.ok)
1465
1675
  return result;
1466
1676
  // The point of fetching is the count it produces, so report it in the same
@@ -1477,7 +1687,7 @@ let GitWorkbenchService = (() => {
1477
1687
  */
1478
1688
  async pull(worktreePath, mode, signal) {
1479
1689
  const chosen = mode === 'rebase' || mode === 'merge' ? mode : 'ff-only';
1480
- return this.writeOp(worktreePath, () => pullArgv(chosen), signal, NETWORK_GRACE_MS);
1690
+ return this.writeOp(this.cwdOf(worktreePath), () => pullArgv(chosen), signal, NETWORK_GRACE_MS);
1481
1691
  }
1482
1692
  /**
1483
1693
  * Publish the current branch.
@@ -1496,10 +1706,16 @@ let GitWorkbenchService = (() => {
1496
1706
  return { ok: false, failure: 'unknown', error: 'HEAD is detached; nothing to push' };
1497
1707
  if (tracking.branch.length === 0)
1498
1708
  return { ok: false, failure: 'unknown', error: 'no branch to push' };
1499
- return this.writeOp(worktreePath, () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS);
1709
+ return this.writeOp(this.cwdOf(worktreePath), () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS);
1500
1710
  }
1501
- /** Shared shape for every write op: run it, classify what went wrong. */
1502
- async writeOp(worktreePath, build, signal, graceMs) {
1711
+ /** Shared shape for every write op: run it, classify what went wrong.
1712
+ *
1713
+ * Takes the DIRECTORY to run in, already resolved: callers carrying
1714
+ * repository-relative pathspecs pass the rooted directory
1715
+ * ({@link GitWorkbenchService.rootedDirOf}); pathspec-free operations may
1716
+ * pass the session's own directory.
1717
+ */
1718
+ async writeOp(dir, build, signal, graceMs) {
1503
1719
  let argv;
1504
1720
  try {
1505
1721
  argv = build();
@@ -1509,7 +1725,7 @@ let GitWorkbenchService = (() => {
1509
1725
  // list or a blank commit message takes.
1510
1726
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
1511
1727
  }
1512
- const result = await this.git(this.cwdOf(worktreePath), argv, signal, graceMs);
1728
+ const result = await this.git(dir, argv, signal, graceMs);
1513
1729
  const failure = classifyFailure(result.exitCode, result.stderr, result.stdout);
1514
1730
  if (failure === null)
1515
1731
  return { ok: true, output: result.stdout.trim().slice(-1000) };
@@ -1567,11 +1783,24 @@ let GitWorkbenchService = (() => {
1567
1783
  return run;
1568
1784
  }
1569
1785
  /** Resolve the repo root for a directory (null when not a git repo). Always forward slashes. */
1570
- async repoRootOf(cwd, signal) {
1571
- const out = await this.git(cwd, ['rev-parse', '--show-toplevel'], signal);
1572
- if (out.exitCode !== 0)
1573
- return null;
1574
- return out.stdout.trim().replace(/\\/g, '/') || null;
1786
+ repoRootOf(cwd, signal) {
1787
+ return resolveRepoRoot((dir, argv) => this.git(dir, argv, signal), cwd);
1788
+ }
1789
+ /**
1790
+ * The directory to run git in and join paths against for a session's
1791
+ * `worktreePath`: the repository ROOT, falling back to the directory
1792
+ * itself outside a repository (the caller's own git run then fails the
1793
+ * way it always did, and that error is the honest one to show).
1794
+ *
1795
+ * The drawer's paths are repository-relative — porcelain status and
1796
+ * `--numstat` print them that way wherever they run — while pathspecs,
1797
+ * `:path` revisions, `hash-object` arguments and `join(dir, path)` all
1798
+ * resolve against the directory a command runs in. Those two halves only
1799
+ * agree at the root, so every method that carries a path runs there.
1800
+ * `repo-root.ts` tells the whole story, with the probes that caught it.
1801
+ */
1802
+ rootedDirOf(worktreePath, signal) {
1803
+ return rootedDir((dir, argv) => this.git(dir, argv, signal), this.cwdOf(worktreePath));
1575
1804
  }
1576
1805
  /**
1577
1806
  * Spawn `git <args>` in cwd with piped stdio; drains both streams and returns
@@ -1640,14 +1869,14 @@ async function mapPooled(items, limit, task) {
1640
1869
  * count newlines is the expensive half of this pass, and most untracked files
1641
1870
  * never reach the bundled diff. Never throws; an unreadable file reports zero
1642
1871
  * lines and nothing to diff.
1643
- * @param cwd - worktree the path is relative to.
1872
+ * @param root - repository root the path is relative to.
1644
1873
  * @param path - repository-relative file path.
1645
1874
  * @returns the file's line count, binary flag, and whether a diff may be built.
1646
1875
  */
1647
- async function measureUntracked(cwd, path) {
1876
+ async function measureUntracked(root, path) {
1648
1877
  let bytes;
1649
1878
  try {
1650
- bytes = await readFile(join(cwd, path));
1879
+ bytes = await readFile(resolveInside(root, path));
1651
1880
  }
1652
1881
  catch {
1653
1882
  return { lineCount: 0, binary: false, diffable: false };
@@ -1665,16 +1894,16 @@ async function measureUntracked(cwd, path) {
1665
1894
  *
1666
1895
  * `git diff --no-index /dev/null <f>` is NOT used: on Windows git resolves
1667
1896
  * `/dev/null` as a repo-relative path. Never throws.
1668
- * @param cwd - worktree the path is relative to.
1897
+ * @param root - repository root the path is relative to.
1669
1898
  * @param path - repository-relative file path.
1670
1899
  * @param byteCap - refuse files larger than this; defaults to the stats
1671
1900
  * payload's budget, which `fileSides` raises to its own.
1672
1901
  * @returns the segment, or null when the file is missing, binary, or oversized.
1673
1902
  */
1674
- async function untrackedSegment(cwd, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
1903
+ async function untrackedSegment(root, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
1675
1904
  let bytes;
1676
1905
  try {
1677
- bytes = await readFile(join(cwd, path));
1906
+ bytes = await readFile(resolveInside(root, path));
1678
1907
  }
1679
1908
  catch {
1680
1909
  return null;