@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9

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 (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
package/src/worktree.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * worktree.ts — Git worktree isolation for agents.
3
3
  *
4
4
  * Creates a temporary git worktree so the agent works on an isolated copy of the repo.
5
- * On completion, if no changes were made, the worktree is cleaned up.
5
+ * On completion, if no changes were made and nothing in it would be lost, the worktree is cleaned up.
6
6
  * If changes exist, a branch is created and returned in the result.
7
+ * If git cannot inspect, commit or remove, the worktree is kept and the result carries `error` and `path`.
7
8
  *
8
9
  * Every git call goes through `pi.exec` (async) rather than `execFileSync`: a
9
10
  * worktree copy can take seconds, and a session that spawns several isolated
@@ -11,10 +12,12 @@
11
12
  */
12
13
 
13
14
  import { randomUUID } from "node:crypto";
14
- import { existsSync, realpathSync } from "node:fs";
15
+ import { type BigIntStats, existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
15
16
  import { tmpdir } from "node:os";
16
- import { join, relative } from "node:path";
17
+ import { join, relative, resolve } from "node:path";
17
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { errorMessage } from "./error-message.js";
20
+ import { QUOTE_PATH_ARGS, unquoteGitPath } from "./git-path.js";
18
21
 
19
22
  export interface WorktreeInfo {
20
23
  /** Absolute path to the worktree directory (the copied repo's root). */
@@ -61,6 +64,8 @@ export interface WorktreeCleanupResult {
61
64
  branch?: string;
62
65
  /** Worktree path if it was kept. */
63
66
  path?: string;
67
+ /** Cleanup failure; the worktree may contain uncommitted or unreachable work. */
68
+ error?: string;
64
69
  }
65
70
 
66
71
  /**
@@ -73,12 +78,17 @@ export interface WorktreeCleanupResult {
73
78
  * clean exit".
74
79
  */
75
80
  async function git(pi: ExtensionAPI, cwd: string, args: string[], timeout: number): Promise<string> {
81
+ return (await gitRaw(pi, cwd, args, timeout)).trim();
82
+ }
83
+
84
+ /** `git`, with stdout untrimmed: a path may start or end with whitespace. */
85
+ async function gitRaw(pi: ExtensionAPI, cwd: string, args: string[], timeout: number): Promise<string> {
76
86
  const result = await pi.exec("git", args, { cwd, timeout });
77
87
  if (result.killed) throw new GitTimeoutError(`\`git ${args.join(" ")}\` timed out after ${timeout}ms in ${cwd}`);
78
88
  if (result.code !== 0) {
79
89
  throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed (exit ${result.code})`);
80
90
  }
81
- return result.stdout.trim();
91
+ return result.stdout;
82
92
  }
83
93
 
84
94
  /** A git call killed by its timeout — reported as-is, never as a repo-state cause. */
@@ -96,7 +106,7 @@ async function step(run: Promise<string>, cause: (detail: string) => string): Pr
96
106
  try {
97
107
  return await run;
98
108
  } catch (err) {
99
- const detail = err instanceof Error ? err.message : String(err);
109
+ const detail = errorMessage(err);
100
110
  throw new WorktreeError(err instanceof GitTimeoutError ? detail : cause(detail));
101
111
  }
102
112
  }
@@ -157,13 +167,25 @@ export async function cleanupWorktree(
157
167
  return { hasChanges: false };
158
168
  }
159
169
 
170
+ let branch: string | undefined;
160
171
  try {
172
+ // Submodule state lives in the worktree's private git dir and is deleted with it.
173
+ await assertSubmodulesDisposable(pi, worktree.path, worktree.baseSha);
161
174
  // Check for uncommitted changes in the worktree
162
- const status = await git(pi, worktree.path, ["status", "--porcelain"], 10000);
175
+ const status = await dirtyState(pi, worktree.path);
163
176
 
164
177
  if (status) {
165
178
  // Changes exist — stage, commit, and create a branch
179
+ const modes = await indexOnlyModes(pi, worktree.path);
180
+ if (modes.length > 0) {
181
+ throw new Error(`mode staged only in the index; worktree kept: ${modes.join(", ")}`);
182
+ }
166
183
  await git(pi, worktree.path, ["add", "-A"], 10000);
184
+ // `add -A` skips a moved gitlink hidden by `submodule.<name>.ignore = all`; an empty
185
+ // commit would fail opaquely, so say why and keep the worktree.
186
+ if (!(await git(pi, worktree.path, ["diff", "--cached", "--name-only"], 10000))) {
187
+ throw new Error(`uncommitted changes could not be staged (a submodule \`ignore\` setting can hide a moved gitlink); worktree kept:\n${status}`);
188
+ }
167
189
  // Truncate description for commit message (no shell sanitization needed — pi.exec uses argv)
168
190
  const safeDesc = agentDescription.slice(0, 200);
169
191
  const commitMsg = `pi-agent: ${safeDesc}`;
@@ -173,7 +195,7 @@ export async function cleanupWorktree(
173
195
 
174
196
  if (currentSha === worktree.baseSha) {
175
197
  // No changes — remove worktree
176
- await removeWorktree(pi, cwd, worktree.path);
198
+ await removeWorktree(pi, cwd, worktree.path, worktree.baseSha);
177
199
  return { hasChanges: false };
178
200
  }
179
201
  }
@@ -190,33 +212,301 @@ export async function cleanupWorktree(
190
212
  }
191
213
  // Update branch name in worktree info for the caller
192
214
  worktree.branch = branchName;
215
+ branch = branchName;
193
216
 
194
217
  // Remove the worktree (branch persists in main repo)
195
- await removeWorktree(pi, cwd, worktree.path);
218
+ await removeWorktree(pi, cwd, worktree.path, worktree.baseSha);
196
219
 
197
220
  return {
198
221
  hasChanges: true,
199
222
  branch: worktree.branch,
223
+ };
224
+ } catch (error) {
225
+ // A failed status means the contents are unknown, not clean. Never remove
226
+ // the tree after a failed preservation step: even committed HEAD may have
227
+ // no reachable branch yet.
228
+ return {
229
+ hasChanges: true,
230
+ ...(branch ? { branch } : {}),
200
231
  path: worktree.path,
232
+ error: (error instanceof Error ? error.message : String(error)) || "Unknown git failure",
201
233
  };
202
- } catch {
203
- // Best effort cleanup on error
204
- try { await removeWorktree(pi, cwd, worktree.path); } catch { /* ignore */ }
205
- return { hasChanges: false };
206
234
  }
207
235
  }
208
236
 
209
237
  /**
210
- * Force-remove a worktree.
238
+ * Lines of a path listing, read without `-z` and with `core.quotePath=true`: every
239
+ * non-ASCII path byte is escaped, so `pi.exec` decoding stdout chunk by chunk cannot
240
+ * corrupt a path (see `unquoteGitPath`). Each line's paths are decoded by the caller.
211
241
  */
212
- async function removeWorktree(pi: ExtensionAPI, cwd: string, worktreePath: string): Promise<void> {
242
+ async function gitLines(pi: ExtensionAPI, repo: string, args: string[], timeout: number): Promise<string[]> {
243
+ return (await gitRaw(pi, repo, [...QUOTE_PATH_ARGS, ...args], timeout)).split("\n").filter(Boolean);
244
+ }
245
+
246
+ const INDEX_BATCH = 200;
247
+
248
+ /** `git update-index <flag> -- <paths>` in argv-sized batches. */
249
+ async function updateIndex(pi: ExtensionAPI, repo: string, flag: string, paths: string[]): Promise<void> {
250
+ for (let i = 0; i < paths.length; i += INDEX_BATCH) {
251
+ await git(pi, repo, ["update-index", flag, "--", ...paths.slice(i, i + INDEX_BATCH)], 30000);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Clear the assume-unchanged and skip-worktree index bits of `repo`: with either
257
+ * set, `status` and git's own removal check ignore edits to the file, so an edited
258
+ * file would be deleted with the worktree. A skip-worktree file absent from disk
259
+ * (sparse checkout) is left alone: clearing it would report a deletion nobody made.
260
+ * The tree is disposable, so mutating its index is fine.
261
+ */
262
+ async function clearHiddenIndexBits(pi: ExtensionAPI, repo: string): Promise<void> {
263
+ const assumed: string[] = [];
264
+ const skipped: string[] = [];
265
+ // `-v` tags: lowercase = assume-unchanged, `S` (or `s` with both) = skip-worktree.
266
+ for (const entry of await gitLines(pi, repo, ["ls-files", "-v"], 30000)) {
267
+ const tag = entry.charAt(0);
268
+ const hidden = tag !== tag.toUpperCase() || tag === "S";
269
+ if (!hidden) continue;
270
+ const path = unquoteGitPath(entry.slice(2));
271
+ if (tag !== tag.toUpperCase()) assumed.push(path);
272
+ if (tag.toLowerCase() === "s" && lstatOrUndefined(join(repo, path))) skipped.push(path);
273
+ }
274
+ await updateIndex(pi, repo, "--no-assume-unchanged", assumed);
275
+ await updateIndex(pi, repo, "--no-skip-worktree", skipped);
276
+ }
277
+
278
+ /** `lstat` of `path`, or undefined when it is missing or unreachable (ENOENT, ENOTDIR). */
279
+ function lstatOrUndefined(path: string): BigIntStats | undefined {
213
280
  try {
281
+ return lstatSync(path, { bigint: true });
282
+ } catch (error) {
283
+ // Anything else (EACCES, ...) is unknown, not absent: let cleanup fail and keep the tree.
284
+ const code = (error as NodeJS.ErrnoException).code;
285
+ if (code === "ENOENT" || code === "ENOTDIR") return undefined;
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Regular files whose staged executable bit (index vs `HEAD`, a new file counting as a change)
292
+ * differs from the file on disk, decoded. `add -A` restages from disk and would silently drop
293
+ * such a mode, and an index-only mode cannot be told from a later on-disk change, so cleanup
294
+ * keeps the worktree instead of guessing. A plain unstaged `chmod` is not listed: the index
295
+ * still matches `HEAD`, so `add -A` commits the disk mode. Skipped when `core.fileMode=false`:
296
+ * git ignores the bit then, so a mismatch is not a change.
297
+ */
298
+ async function indexOnlyModes(pi: ExtensionAPI, repo: string): Promise<string[]> {
299
+ if ((await git(pi, repo, ["config", "--type=bool", "--default", "true", "core.fileMode"], 5000)) === "false") return [];
300
+ const paths: string[] = [];
301
+ // `:<old mode> <new mode> <old sha> <new sha> <status>` TAB `<path>`
302
+ for (const line of await gitLines(pi, repo, ["diff", "--cached", "--raw", "--no-renames"], 30000)) {
303
+ const tab = line.indexOf("\t");
304
+ const [oldMode, newMode] = line.slice(1, tab).split(" ");
305
+ if (oldMode === newMode || (newMode !== "100644" && newMode !== "100755")) continue;
306
+ const path = unquoteGitPath(line.slice(tab + 1));
307
+ const disk = lstatOrUndefined(join(repo, path));
308
+ if (disk?.isFile() && ((disk.mode & 0o100n) !== 0n) !== (newMode === "100755")) paths.push(path);
309
+ }
310
+ return paths;
311
+ }
312
+
313
+ /**
314
+ * Uncommitted state of `repo`, untracked files and submodule changes included.
315
+ * `status.showUntrackedFiles=no` in user or repo config would otherwise hide new
316
+ * files from `status`, and `submodule.<name>.ignore` / `diff.ignoreSubmodules`
317
+ * would hide a moved gitlink; a clean-looking tree gets removed with them.
318
+ * Index bits that hide edits are cleared first, see `clearHiddenIndexBits`.
319
+ */
320
+ async function dirtyState(pi: ExtensionAPI, repo: string): Promise<string> {
321
+ await clearHiddenIndexBits(pi, repo);
322
+ return await git(pi, repo, ["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], 10000);
323
+ }
324
+
325
+ const GITLINK_MODE = "160000";
326
+
327
+ /**
328
+ * Gitlinks (mode 160000) of `repo` as path -> sha: the index when `rev` is
329
+ * omitted, else the tree of `rev`. Independent of `.gitmodules`: git refuses to
330
+ * remove a worktree on the gitlink, not on the config file.
331
+ */
332
+ async function gitlinks(pi: ExtensionAPI, repo: string, rev?: string): Promise<Map<string, string>> {
333
+ const links = new Map<string, string>();
334
+ for (const entry of await gitLines(pi, repo, rev ? ["ls-tree", "-r", rev] : ["ls-files", "-s"], 30000)) {
335
+ // `gitLines` drops blanks, so every entry is `<fields>` TAB `<path>`.
336
+ const tab = entry.indexOf("\t");
337
+ // ls-files: `<mode> <sha> <stage>`; ls-tree: `<mode> <type> <sha>`.
338
+ const [mode, second, third] = entry.slice(0, tab).split(" ");
339
+ if (mode === GITLINK_MODE) links.set(unquoteGitPath(entry.slice(tab + 1)), rev ? (third as string) : (second as string));
340
+ }
341
+ return links;
342
+ }
343
+
344
+ /** What removing a repository would destroy: stashes and commits (branch, tag or HEAD) no remote-tracking ref (or `published`) contains. */
345
+ async function unpublished(pi: ExtensionAPI, gitArgs: string[], cwd: string, published: Iterable<string>): Promise<string> {
346
+ const stash = await git(pi, cwd, [...gitArgs, "stash", "list"], 10000);
347
+ // `--not` with an object this repository lacks would fail the whole listing.
348
+ const known: string[] = [];
349
+ for (const sha of published) {
350
+ const probe = await pi.exec("git", [...gitArgs, "cat-file", "-e", `${sha}^{commit}`], { cwd, timeout: 5000 });
351
+ if (!probe.killed && probe.code === 0) known.push(sha);
352
+ }
353
+ // Tags count: a local tag can be the only ref holding a commit. Offline there is
354
+ // no telling a fetched tag from a local one, so an unpublished tagged commit fails safe.
355
+ const commits = await git(pi, cwd, [...gitArgs, "rev-list", "--branches", "--tags", "HEAD", "--not", "--remotes", ...known], 30000);
356
+ return [stash, commits].filter(Boolean).join("\n");
357
+ }
358
+
359
+ /**
360
+ * The git dir of the repository whose work tree is `dir`, read from `.git` (a directory, or
361
+ * a `gitdir: <path>` file for a submodule) rather than from `git rev-parse` output, which
362
+ * `pi.exec` can mis-decode when the path is non-ASCII.
363
+ */
364
+ function gitDirOf(dir: string): string {
365
+ const dotGit = join(dir, ".git");
366
+ if (statSync(dotGit).isDirectory()) return dotGit;
367
+ const target = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
368
+ if (!target) throw new Error(`${dotGit} is neither a directory nor a gitdir file`);
369
+ return resolve(dir, target.trim());
370
+ }
371
+
372
+ /**
373
+ * Inspect the nested repository at `dir`, then the ones behind its own gitlinks.
374
+ * `recorded` is the sha a trusted baseline pins it at (when any).
375
+ */
376
+ async function inspectRepo(
377
+ pi: ExtensionAPI,
378
+ dir: string,
379
+ label: string,
380
+ recorded: string | undefined,
381
+ trusted: Set<string>,
382
+ seen: Set<string>,
383
+ problems: string[],
384
+ ): Promise<void> {
385
+ seen.add(realpathSync(gitDirOf(dir)));
386
+ const dirty = await dirtyState(pi, dir);
387
+ const lost = await unpublished(pi, [], dir, recorded ? [recorded] : []);
388
+ const detail = [dirty, lost].filter(Boolean).join("\n");
389
+ if (detail) problems.push(`${label}:\n${detail}`);
390
+ const head = await git(pi, dir, ["rev-parse", "HEAD"], 5000);
391
+ await inspectGitlinks(pi, dir, head === recorded ? "HEAD" : undefined, trusted, seen, problems);
392
+ await inspectIgnoredRepos(pi, dir, dir, trusted, seen, problems);
393
+ }
394
+
395
+ /**
396
+ * Inspect nested repositories under gitignored paths of `repo` not already
397
+ * `seen`. Labels are relative to `root`, the worktree.
398
+ */
399
+ async function inspectIgnoredRepos(
400
+ pi: ExtensionAPI,
401
+ repo: string,
402
+ root: string,
403
+ trusted: Set<string>,
404
+ seen: Set<string>,
405
+ problems: string[],
406
+ ): Promise<void> {
407
+ for (const dir of await ignoredRepoDirs(pi, repo)) {
408
+ if (!seen.has(realpathSync(gitDirOf(dir)))) {
409
+ await inspectRepo(pi, dir, relative(root, dir), undefined, trusted, seen, problems);
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Inspect the nested repository behind every gitlink of `repo`, recursively.
416
+ * `baseRev` is a revision whose gitlinks are already trusted as published (the
417
+ * commit the worktree was created from): a nested HEAD at exactly that recorded
418
+ * sha, e.g. fetched by sha during `submodule update`, is not at risk. A gitlink
419
+ * the agent introduced has no baseline, so only remote-tracking refs vouch for it.
420
+ * Every baseline sha seen is added to `trusted`, for repositories no gitlink reaches.
421
+ */
422
+ async function inspectGitlinks(
423
+ pi: ExtensionAPI,
424
+ repo: string,
425
+ baseRev: string | undefined,
426
+ trusted: Set<string>,
427
+ seen: Set<string>,
428
+ problems: string[],
429
+ ): Promise<void> {
430
+ const base = baseRev ? await gitlinks(pi, repo, baseRev) : new Map<string, string>();
431
+ for (const sha of base.values()) trusted.add(sha);
432
+ for (const path of (await gitlinks(pi, repo)).keys()) {
433
+ const dir = join(repo, path);
434
+ if (existsSync(join(dir, ".git"))) await inspectRepo(pi, dir, path, base.get(path), trusted, seen, problems);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Nested repositories under gitignored paths: `add -A` skips them, so no gitlink
440
+ * records them, yet plain removal deletes their ignored content.
441
+ */
442
+ async function ignoredRepoDirs(pi: ExtensionAPI, worktreePath: string): Promise<string[]> {
443
+ const entries = await gitLines(pi, worktreePath, ["status", "--porcelain", "--ignored", "--untracked-files=all", "--ignore-submodules=none"], 30000);
444
+ // With `--untracked-files=all` git lists an ignored nested repository as its own `!! dir/` entry.
445
+ // Only directory entries (`dir/`, or `"dir/"` when quoted) can be a clone: decode just those.
446
+ const dirs = entries.filter(entry => entry.startsWith("!! ") && /\/"?$/.test(entry)).map(entry => unquoteGitPath(entry.slice(3)));
447
+ return dirs.map(dir => join(worktreePath, dir)).filter(dir => existsSync(join(dir, ".git")));
448
+ }
449
+
450
+ /** Git dirs under a `modules` directory, including those nested in other submodule git dirs. */
451
+ function moduleGitDirs(dir: string): string[] {
452
+ if (existsSync(join(dir, "HEAD")) && existsSync(join(dir, "objects"))) {
453
+ const nested = join(dir, "modules");
454
+ return [dir, ...(existsSync(nested) ? moduleGitDirs(nested) : [])];
455
+ }
456
+ return readdirSync(dir, { withFileTypes: true }).flatMap(entry => (entry.isDirectory() ? moduleGitDirs(join(dir, entry.name)) : []));
457
+ }
458
+
459
+ /** Absolute `modules` directory of the worktree's private git dir, when present. */
460
+ function modulesDir(worktreePath: string): string | undefined {
461
+ const dir = join(gitDirOf(worktreePath), "modules");
462
+ return existsSync(dir) ? dir : undefined;
463
+ }
464
+
465
+ /**
466
+ * Throw unless every nested repository the removal would delete holds nothing
467
+ * but already-published state. Git deletes submodule git dirs with the worktree,
468
+ * so unpushed commits or uncommitted files there would be lost even after the
469
+ * outer repo is branched. Covers every gitlink in the index, registered in
470
+ * `.gitmodules` or not, plus any leftover repository in the worktree's `modules`
471
+ * dir (e.g. after the agent deleted `.gitmodules`).
472
+ */
473
+ async function assertSubmodulesDisposable(pi: ExtensionAPI, worktreePath: string, baseSha: string): Promise<void> {
474
+ const seen = new Set<string>();
475
+ const trusted = new Set<string>();
476
+ const problems: string[] = [];
477
+ await inspectGitlinks(pi, worktreePath, baseSha, trusted, seen, problems);
478
+ await inspectIgnoredRepos(pi, worktreePath, worktreePath, trusted, seen, problems);
479
+ const modules = modulesDir(worktreePath);
480
+ for (const gitDir of modules ? moduleGitDirs(modules) : []) {
481
+ if (seen.has(realpathSync(gitDir))) continue;
482
+ const lost = await unpublished(pi, ["--git-dir", gitDir], worktreePath, trusted);
483
+ if (lost) problems.push(`${gitDir}:\n${lost}`);
484
+ }
485
+ if (problems.length) throw new Error(`submodule work would be lost on removal; worktree kept:\n${problems.join("\n")}`);
486
+ }
487
+
488
+ /** Whether git would refuse plain removal for submodule state: any gitlink in the index or a `modules` dir. */
489
+ async function hasSubmoduleState(pi: ExtensionAPI, worktreePath: string): Promise<boolean> {
490
+ return (await gitlinks(pi, worktreePath)).size > 0 || modulesDir(worktreePath) !== undefined;
491
+ }
492
+
493
+ /**
494
+ * Remove only a clean worktree; git refuses newly appeared uncommitted work.
495
+ *
496
+ * Git also refuses any worktree with submodule state, clean or not. Whether that
497
+ * is why removal failed is decided structurally (gitlinks, `modules` dir), never
498
+ * from git's locale-dependent message. Only then is `--force` used, and only after
499
+ * the outer tree and every nested repository re-verify as holding nothing unpublished.
500
+ */
501
+ async function removeWorktree(pi: ExtensionAPI, cwd: string, worktreePath: string, baseSha: string): Promise<void> {
502
+ try {
503
+ // Git's own clean check honors `status.showUntrackedFiles`; pin it so config cannot hide new files.
504
+ await git(pi, cwd, ["-c", "status.showUntrackedFiles=all", "worktree", "remove", worktreePath], 10000);
505
+ } catch (error) {
506
+ if (!(await hasSubmoduleState(pi, worktreePath))) throw error;
507
+ if (await dirtyState(pi, worktreePath)) throw error;
508
+ await assertSubmodulesDisposable(pi, worktreePath, baseSha);
214
509
  await git(pi, cwd, ["worktree", "remove", "--force", worktreePath], 10000);
215
- } catch {
216
- // If git worktree remove fails, try pruning
217
- try {
218
- await git(pi, cwd, ["worktree", "prune"], 5000);
219
- } catch { /* ignore */ }
220
510
  }
221
511
  }
222
512