@young1lin/dsh-ui-gitworkbench 0.1.15 → 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 (41) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CHANGELOG_EN.md +16 -0
  3. package/README.md +4 -3
  4. package/README_EN.md +1 -1
  5. package/lib/client.js +1519 -477
  6. package/lib/dir-listing.js +34 -0
  7. package/lib/fs-remove.js +5 -36
  8. package/lib/index.js +192 -41
  9. package/lib/path-lock.js +54 -0
  10. package/lib/worktree.js +83 -0
  11. package/lib/write-checked.js +1 -1
  12. package/package.json +1 -1
  13. package/src/client/ChromeGlyph.tsx +5 -0
  14. package/src/client/CodeEditor.tsx +19 -1
  15. package/src/client/DiffViews.tsx +116 -121
  16. package/src/client/FileBrowser.tsx +196 -23
  17. package/src/client/GitWorkbenchPanel.module.css +1 -0
  18. package/src/client/GitWorkbenchPanel.tsx +100 -12
  19. package/src/client/SideRails.tsx +106 -0
  20. package/src/client/diff-cells.tsx +147 -0
  21. package/src/client/diff-nav.ts +4 -1
  22. package/src/client/dir-tree.ts +31 -1
  23. package/src/client/file-rows.ts +40 -0
  24. package/src/client/h-rail.ts +70 -0
  25. package/src/client/ignored-cache.ts +193 -0
  26. package/src/client/index.ts +22 -3
  27. package/src/client/locales.ts +12 -2
  28. package/src/client/row-heights.ts +225 -0
  29. package/src/client/styles/changes.css +33 -2
  30. package/src/client/styles/controls.css +5 -0
  31. package/src/client/styles/files.css +5 -0
  32. package/src/client/styles/rails.css +72 -0
  33. package/src/client/use-row-window.ts +7 -3
  34. package/src/client/use-variable-row-window.ts +210 -0
  35. package/src/dir-listing.ts +47 -0
  36. package/src/fs-remove.ts +5 -36
  37. package/src/index.ts +217 -43
  38. package/src/path-lock.ts +56 -0
  39. package/src/types/dsh-shim.d.ts +12 -2
  40. package/src/worktree.ts +97 -0
  41. package/src/write-checked.ts +1 -1
@@ -0,0 +1,34 @@
1
+ /**
2
+ * One lazily listed directory, shaped for the wire.
3
+ *
4
+ * The Files tab browses ignored directories by reading them from the
5
+ * filesystem ONE level at a time — git cannot do this scoped: a pathspec
6
+ * under `--directory` still collapses the whole ignored directory to a
7
+ * single line, and dropping `--directory` would enumerate every file inside
8
+ * `node_modules` at once. A `readdir` is one level by construction, costs
9
+ * milliseconds, and says what a browser wants to know: what is HERE.
10
+ *
11
+ * Only the shaping is pure (ordering, capping); the read itself stays in
12
+ * `index.ts`, which vitest cannot load. Same split as `fs-remove.ts`.
13
+ *
14
+ * @module @young1lin/dsh-ui-gitworkbench/dir-listing
15
+ */
16
+ /** The most entries one expansion returns. A real directory level is a few
17
+ * hundred at most (`node_modules`'s own top level); a cap beyond that is a
18
+ * reported fuse against a pathological directory, not a working number. */
19
+ export const DIR_CHILD_CAP = 5_000;
20
+ /**
21
+ * Order and cap raw readdir results: directories before files (the shape
22
+ * every file tree has, matching `treeRows`), each run by name, and a cut
23
+ * REPORTED rather than silent.
24
+ *
25
+ * @param raw - the directory's entries with their best-known dir-ness
26
+ * (symlinks already resolved by the caller).
27
+ * @param cap - most entries to return.
28
+ */
29
+ export function shapeDirChildren(raw, cap = DIR_CHILD_CAP) {
30
+ const byName = (a, b) => a.name.localeCompare(b.name);
31
+ const ordered = [...raw.filter(entry => entry.dir).sort(byName), ...raw.filter(entry => !entry.dir).sort(byName)];
32
+ const truncated = ordered.length > cap;
33
+ return { entries: truncated ? ordered.slice(0, cap) : ordered, truncated };
34
+ }
package/lib/fs-remove.js CHANGED
@@ -6,8 +6,8 @@
6
6
  * `git clean` refuses paths it cannot index, which on Windows includes every
7
7
  * reserved device name (`nul`, `con`, `aux`, `com1`, and the same names with
8
8
  * any extension). So the removal goes through the filesystem, where git's own
9
- * refusal to leave the repository does not apply — hence the checks here
10
- * rather than a bare `rm`.
9
+ * refusal to leave the repository does not apply — hence the path lock in
10
+ * `path-lock.ts` rather than a bare `rm`.
11
11
  *
12
12
  * Lives outside `index.ts` so vitest can load it: the class there needs the
13
13
  * dsh runtime, and the property worth testing is "what does this delete, and
@@ -16,36 +16,7 @@
16
16
  * @module @young1lin/dsh-ui-gitworkbench/fs-remove
17
17
  */
18
18
  import { rm } from 'node:fs/promises';
19
- import { resolve, sep } from 'node:path';
20
- import { isSafeRelativePath } from './discard-ops.js';
21
- /**
22
- * Resolve a repo-relative path against the worktree root, refusing to leave it.
23
- *
24
- * The second lock rather than the only one: {@link isSafeRelativePath} already
25
- * rejected traversal spellings when the plan was made. This re-checks the
26
- * RESOLVED path, which is the form the filesystem acts on, so a path that
27
- * survives the first check by being spelled unusually still has to land inside
28
- * the root to be acted on.
29
- *
30
- * @param root - the worktree directory, absolute.
31
- * @param relative - repo-relative path from a plan step.
32
- * @returns the absolute path to act on.
33
- * @throws if the path is not a safe relative path, resolves outside the root,
34
- * or IS the root.
35
- */
36
- export function resolveInside(root, relative) {
37
- if (!isSafeRelativePath(relative)) {
38
- throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`);
39
- }
40
- const base = resolve(root);
41
- const target = resolve(base, relative);
42
- if (target === base)
43
- throw new Error('refusing to delete the worktree root');
44
- if (!target.startsWith(base + sep)) {
45
- throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`);
46
- }
47
- return target;
48
- }
19
+ import { resolveInside } from './path-lock.js';
49
20
  /**
50
21
  * Remove one entry from the worktree, having proven it is inside it.
51
22
  *
@@ -60,10 +31,8 @@ export function resolveInside(root, relative) {
60
31
  * `force` makes an absent entry a success: the reader asked for it to be gone,
61
32
  * and it is.
62
33
  *
63
- * A symlinked directory inside the worktree could still point outward; that is
64
- * a repository someone already has write access to, and resolving link targets
65
- * per segment on every delete would cost a stat per segment for a case git
66
- * itself does not defend against.
34
+ * A symlinked directory inside the worktree could still point outward the
35
+ * limit of a lexical resolve, stated where the lock is.
67
36
  *
68
37
  * @param root - the worktree directory, absolute.
69
38
  * @param relative - repo-relative path from a plan step.
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,9 +86,11 @@ 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';
92
94
  import { resolveRepoRoot, rootedDir } from './repo-root.js';
93
95
  import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js';
94
96
  import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js';
@@ -96,7 +98,7 @@ import { LOG_FORMAT, parseLog } from './git-log.js';
96
98
  import { emptyLogFilter, logFilterArgs } from './log-filter.js';
97
99
  import { parseShortlog } from './shortlog.js';
98
100
  import { isBlankEntry, loadStyle, sanitizeEntry, stylePath, } from './style-store.js';
99
- 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';
100
102
  /** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
101
103
  const DIFF_CHAR_CAP = 400_000;
102
104
  /** Untracked files larger than this are listed + counted but never diffed. */
@@ -121,6 +123,11 @@ const SHORTLOG_CAP = 500;
121
123
  /** Path list cap for the picker: a monorepo can outrun any popup; past this
122
124
  * the tree is cut and the truncation reported, never silent. */
123
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;
124
131
  /**
125
132
  * Most branch names sent to the browser. `worktreeStatus` is polled, so an
126
133
  * unbounded list would repeat on the wire every few seconds; the picker reports
@@ -164,6 +171,7 @@ let GitWorkbenchService = (() => {
164
171
  let _commits_decorators;
165
172
  let _authors_decorators;
166
173
  let _repoTree_decorators;
174
+ let _ignoredDir_decorators;
167
175
  let _compareRefs_decorators;
168
176
  let _sessionWorktree_decorators;
169
177
  let _worktreeEnter_decorators;
@@ -194,6 +202,7 @@ let GitWorkbenchService = (() => {
194
202
  _commits_decorators = [Remote('commits')];
195
203
  _authors_decorators = [Remote('authors')];
196
204
  _repoTree_decorators = [Remote('repoTree')];
205
+ _ignoredDir_decorators = [Remote('ignoredDir')];
197
206
  _compareRefs_decorators = [Remote('compareRefs')];
198
207
  _sessionWorktree_decorators = [Remote('sessionWorktree')];
199
208
  _worktreeEnter_decorators = [Remote('worktreeEnter')];
@@ -221,6 +230,7 @@ let GitWorkbenchService = (() => {
221
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);
222
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);
223
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);
224
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);
225
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);
226
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);
@@ -250,10 +260,32 @@ let GitWorkbenchService = (() => {
250
260
  * mutation updates this inside the same critical section that writes it.
251
261
  */
252
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();
253
272
  constructor(ctx) {
254
273
  super(ctx, 'gitWorkbench');
255
274
  this.registerWorktreeTools(ctx);
256
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
+ });
257
289
  // Hydrate the mirror through the same queue as the mutations, so a binding
258
290
  // written before hydration finishes is not overwritten by the stale read.
259
291
  // A failed read leaves the mirror empty: sessions then get no standing
@@ -285,17 +317,20 @@ let GitWorkbenchService = (() => {
285
317
  name: 'worktree:binding',
286
318
  order: 115,
287
319
  text: (context) => {
288
- const sessionId = context.agent?.session.id;
289
- const binding = sessionId === undefined ? undefined : this.bindingMirror.get(sessionId);
290
- if (binding === undefined)
320
+ const session = context.agent?.session;
321
+ if (session === undefined)
322
+ return '';
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)
291
332
  return '';
292
- const rel = `.agents/worktrees/${binding.name}`;
293
- const branchNote = binding.branch === undefined ? '' : ` (branch ${binding.branch})`;
294
- return `This session is bound to git worktree "${binding.name}"${branchNote}.\n`
295
- + 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
296
- + `- shell commands: pass workdir "${rel}"\n`
297
- + `- file tools: prefix every path with ${rel}/\n`
298
- + 'A path without that prefix acts on the MAIN worktree, not the bound one. Call worktree_exit to unbind.';
333
+ return bindingNotice(effective.binding.name, effective.binding.branch, effective.inherited);
299
334
  },
300
335
  });
301
336
  });
@@ -332,6 +367,7 @@ let GitWorkbenchService = (() => {
332
367
  ok: { type: 'boolean' },
333
368
  error: { type: 'string' },
334
369
  binding: { oneOf: [{ type: 'null' }, { type: 'object', additionalProperties: true }] },
370
+ bindingInherited: { type: 'boolean' },
335
371
  worktrees: { type: 'array', items: { type: 'object', additionalProperties: true } },
336
372
  branches: { type: 'array', items: { type: 'string' } },
337
373
  branchesTruncated: { type: 'boolean' },
@@ -375,7 +411,8 @@ let GitWorkbenchService = (() => {
375
411
  }));
376
412
  ctx.tools.register(defineTool({
377
413
  name: 'worktree_status',
378
- 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.',
379
416
  parameters: {},
380
417
  output: output(STATUS_SCHEMA),
381
418
  execute: async (_args, exec) => {
@@ -560,24 +597,28 @@ let GitWorkbenchService = (() => {
560
597
  if (layer !== 'unstaged' && layer !== 'staged') {
561
598
  throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`);
562
599
  }
563
- if (typeof path !== 'string' || !isSafePathArg(path)) {
600
+ if (typeof path !== 'string' || !isSafeRelativePath(path)) {
564
601
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
565
602
  }
566
603
  // Repository root, not the session's directory: every path below is
567
604
  // repository-relative (pathspecs resolve against the cwd, and so does
568
605
  // the file read for the editor's target). See repo-root.ts.
569
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.
570
610
  return layer === 'unstaged'
571
- ? await this.unstagedSides(root, path, signal)
611
+ ? await this.unstagedSides(root, path, resolveInside(root, path), signal)
572
612
  : await this.stagedSides(root, path, signal);
573
613
  }
574
- /** The unstaged layer: diff index→worktree, target = the working-tree file. */
575
- async unstagedSides(root, 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) {
576
617
  // Size guard first, off the stat rather than a read: declining a file past
577
618
  // the cap must not mean loading a pathological one whole first. Bytes are
578
619
  // all a stat knows; the line half of the guard needs the read below.
579
620
  try {
580
- const info = await stat(join(root, path));
621
+ const info = await stat(full);
581
622
  if (info.isFile() && targetTooLarge(info.size, 0))
582
623
  return { ...emptySides(), tooLarge: true };
583
624
  }
@@ -586,7 +627,7 @@ let GitWorkbenchService = (() => {
586
627
  }
587
628
  let bytes = null;
588
629
  try {
589
- bytes = await readFile(join(root, path));
630
+ bytes = await readFile(full);
590
631
  }
591
632
  catch {
592
633
  bytes = null;
@@ -832,12 +873,14 @@ let GitWorkbenchService = (() => {
832
873
  * @param signal - abort signal.
833
874
  */
834
875
  async fileImage(worktreePath, path, signal) {
835
- if (typeof path !== 'string' || !isSafePathArg(path)) {
876
+ if (typeof path !== 'string' || !isSafeRelativePath(path)) {
836
877
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
837
878
  }
838
879
  // The repository root — the image is read from disk at the same base
839
- // every other path in this plugin is relative to (repo-root.ts).
840
- const full = join(await this.rootedDirOf(worktreePath, signal), path);
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);
841
884
  let size = 0;
842
885
  try {
843
886
  const info = await stat(full);
@@ -1041,11 +1084,21 @@ let GitWorkbenchService = (() => {
1041
1084
  }
1042
1085
  /**
1043
1086
  * Every file path on HEAD — the filter popup's path picker, aggregated into
1044
- * a directory tree client-side.
1087
+ * a directory tree client-side — plus the ignored-but-present entries the
1088
+ * Files tab browses.
1045
1089
  *
1046
- * `-z` is load-bearing: NUL-separated output is UNQUOTED, while the default
1047
- * would render non-ASCII names as quoted octal escapes under
1048
- * `core.quotepath` and hand the picker garbage.
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.
1098
+ *
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.
1049
1102
  * @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
1050
1103
  * @param signal - abort signal.
1051
1104
  */
@@ -1055,10 +1108,88 @@ let GitWorkbenchService = (() => {
1055
1108
  // `server/` prefix and the picker would feed the log filter pathspecs
1056
1109
  // that match nothing (repo-root.ts).
1057
1110
  const root = await this.rootedDirOf(worktreePath, signal);
1058
- const res = await this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], 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
+ ]);
1059
1115
  const all = res.stdout.split('\0').filter(path => path.length > 0);
1060
1116
  const truncated = all.length > TREE_PATH_CAP;
1061
- 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]);
1062
1193
  }
1063
1194
  /**
1064
1195
  * Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
@@ -1131,13 +1262,20 @@ let GitWorkbenchService = (() => {
1131
1262
  files, diff, commits: parseLog(log.stdout),
1132
1263
  };
1133
1264
  }
1134
- /** 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
+ */
1135
1271
  async sessionWorktree(sessionId, signal) {
1136
1272
  if (typeof sessionId !== 'string' || sessionId.length === 0)
1137
- return { worktreePath: null, name: null };
1273
+ return { worktreePath: null, name: null, inherited: false };
1138
1274
  const file = await this.bindingsIo().load();
1139
- const binding = file.bindings[sessionId];
1140
- 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 };
1141
1279
  }
1142
1280
  /** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
1143
1281
  async worktreeEnter(sessionId, repoPath, name, signal) {
@@ -1229,8 +1367,15 @@ let GitWorkbenchService = (() => {
1229
1367
  return this.withBindings(async (io) => {
1230
1368
  const file = await io.load();
1231
1369
  const binding = file.bindings[sessionId];
1232
- if (binding === undefined)
1233
- 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
+ }
1234
1379
  if (remove === true) {
1235
1380
  const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal);
1236
1381
  if (status.exitCode !== 0) {
@@ -1261,21 +1406,26 @@ let GitWorkbenchService = (() => {
1261
1406
  * Branches come back most-recently-committed first. With hundreds of them the
1262
1407
  * order is what makes the list usable — the handful anyone is working on sit
1263
1408
  * at the top, so the picker is useful before a single character is typed.
1264
- * @param sessionId - session whose binding is looked up.
1409
+ * @param sessionId - session whose effective binding is looked up.
1265
1410
  * @param repoPath - caller's directory, used when the session is unbound.
1266
1411
  * @param signal - abort signal.
1267
- * @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.
1268
1414
  */
1269
1415
  async worktreeStatus(sessionId, repoPath, signal) {
1270
1416
  const file = await this.bindingsIo().load();
1271
- 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;
1272
1422
  // Unbound: list the CALLER's repo. Falling back to the host's launch directory
1273
1423
  // would answer about whatever directory dsh was started in, not this session's.
1274
1424
  const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd();
1275
1425
  const cwd = binding?.repoRoot ?? caller;
1276
1426
  const root = await this.repoRootOf(cwd, signal);
1277
1427
  if (root === null)
1278
- return { binding, worktrees: [], branches: [], branchesTruncated: false };
1428
+ return { binding, bindingInherited, worktrees: [], branches: [], branchesTruncated: false };
1279
1429
  const [listed, named] = await Promise.all([
1280
1430
  this.git(root, ['worktree', 'list', '--porcelain'], signal),
1281
1431
  this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
@@ -1284,6 +1434,7 @@ let GitWorkbenchService = (() => {
1284
1434
  const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP);
1285
1435
  return {
1286
1436
  binding,
1437
+ bindingInherited,
1287
1438
  worktrees: parseWorktreeList(listed.stdout),
1288
1439
  branches,
1289
1440
  branchesTruncated,
@@ -1725,7 +1876,7 @@ async function mapPooled(items, limit, task) {
1725
1876
  async function measureUntracked(root, path) {
1726
1877
  let bytes;
1727
1878
  try {
1728
- bytes = await readFile(join(root, path));
1879
+ bytes = await readFile(resolveInside(root, path));
1729
1880
  }
1730
1881
  catch {
1731
1882
  return { lineCount: 0, binary: false, diffable: false };
@@ -1752,7 +1903,7 @@ async function measureUntracked(root, path) {
1752
1903
  async function untrackedSegment(root, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
1753
1904
  let bytes;
1754
1905
  try {
1755
- bytes = await readFile(join(root, path));
1906
+ bytes = await readFile(resolveInside(root, path));
1756
1907
  }
1757
1908
  catch {
1758
1909
  return null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The one place a client-supplied path becomes an absolute path on disk.
3
+ *
4
+ * git needs no such lock: whatever pathspec it is handed, it will not read or
5
+ * write outside the repository, which is why `isSafePathArg` only has to keep
6
+ * a path from being mistaken for an option. The moment a path leaves git and
7
+ * reaches `readFile`, `stat` or `readdir`, that backstop is gone — `join(root,
8
+ * '../../../etc/passwd')` is just a path, and the browser is the least trusted
9
+ * source of paths this plugin has.
10
+ *
11
+ * So every filesystem call in the host resolves through here, and the RPCs
12
+ * that do it are pinned by `host-rooted-paths.test.ts` so a new one cannot
13
+ * quietly join a client string onto the root instead.
14
+ *
15
+ * What this does NOT defend against, deliberately and for the same reason
16
+ * `fs-remove.ts` says so: `resolve` is lexical, so a SYMLINK inside the
17
+ * worktree that points outward still resolves inside and is followed. Closing
18
+ * that means a `realpath` per segment on every read of every file, for a case
19
+ * git itself does not defend against and that presupposes write access to the
20
+ * repository the reader already opened.
21
+ *
22
+ * @module @young1lin/dsh-ui-gitworkbench/path-lock
23
+ */
24
+ import { resolve, sep } from 'node:path';
25
+ import { isSafeRelativePath } from './discard-ops.js';
26
+ /**
27
+ * Resolve a repo-relative path against the worktree root, refusing to leave it.
28
+ *
29
+ * Two locks, not one. {@link isSafeRelativePath} rejects the traversal
30
+ * SPELLINGS — absolute paths, drive letters, UNC prefixes, NUL bytes, any `..`
31
+ * segment including one buried mid-path. The `startsWith` below re-checks the
32
+ * RESOLVED path, which is the form the filesystem acts on, so a path that
33
+ * survives the first check by being spelled unusually still has to land inside
34
+ * the root to be acted on.
35
+ *
36
+ * @param root - the worktree directory, absolute.
37
+ * @param relative - repo-relative path from the client or from git's output.
38
+ * @returns the absolute path to act on.
39
+ * @throws if the path is not a safe relative path, resolves outside the root,
40
+ * or IS the root.
41
+ */
42
+ export function resolveInside(root, relative) {
43
+ if (!isSafeRelativePath(relative)) {
44
+ throw new Error(`unsafe path argument: ${JSON.stringify(relative)}`);
45
+ }
46
+ const base = resolve(root);
47
+ const target = resolve(base, relative);
48
+ if (target === base)
49
+ throw new Error('refusing to act on the worktree root itself');
50
+ if (!target.startsWith(base + sep)) {
51
+ throw new Error(`path escapes the worktree: ${JSON.stringify(relative)}`);
52
+ }
53
+ return target;
54
+ }