@osdlc/repo-cleaner 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +691 -305
  3. package/dist/branches.d.ts +37 -36
  4. package/dist/branches.js +199 -196
  5. package/dist/clean.d.ts +179 -0
  6. package/dist/clean.js +824 -0
  7. package/dist/cli.d.ts +12 -16
  8. package/dist/cli.js +367 -478
  9. package/dist/hooks.d.ts +8 -12
  10. package/dist/hooks.js +2 -2
  11. package/dist/index.d.ts +18 -25
  12. package/dist/index.js +16 -23
  13. package/dist/landing.d.ts +3 -3
  14. package/dist/landing.js +7 -7
  15. package/dist/plan.d.ts +76 -70
  16. package/dist/plan.js +82 -77
  17. package/dist/providers/github.d.ts +2 -1
  18. package/dist/providers/github.js +2 -1
  19. package/dist/providers/gitlab.d.ts +3 -2
  20. package/dist/providers/gitlab.js +14 -8
  21. package/dist/providers/index.d.ts +5 -4
  22. package/dist/providers/index.js +8 -5
  23. package/dist/providers/types.d.ts +8 -2
  24. package/dist/prs.d.ts +35 -7
  25. package/dist/prs.js +45 -12
  26. package/dist/receipt.d.ts +46 -57
  27. package/dist/receipt.js +52 -28
  28. package/dist/reference.d.ts +12 -0
  29. package/dist/reference.js +152 -0
  30. package/dist/remote-mirror.d.ts +9 -1
  31. package/dist/remote-mirror.js +19 -1
  32. package/dist/render.d.ts +66 -61
  33. package/dist/render.js +206 -255
  34. package/dist/resolve.d.ts +3 -3
  35. package/dist/resolve.js +4 -12
  36. package/dist/status-line.d.ts +1 -1
  37. package/dist/status-line.js +5 -5
  38. package/dist/step-board.d.ts +1 -1
  39. package/dist/step-board.js +2 -5
  40. package/dist/submodules.d.ts +120 -0
  41. package/dist/submodules.js +292 -0
  42. package/dist/sweep.d.ts +107 -64
  43. package/dist/sweep.js +333 -119
  44. package/dist/types.d.ts +294 -105
  45. package/dist/types.js +106 -54
  46. package/dist/util.d.ts +13 -0
  47. package/dist/util.js +36 -0
  48. package/dist/version.generated.d.ts +1 -1
  49. package/dist/version.generated.js +1 -1
  50. package/dist/worktrees.d.ts +21 -37
  51. package/dist/worktrees.js +198 -165
  52. package/package.json +5 -5
  53. package/dist/apply.d.ts +0 -131
  54. package/dist/apply.js +0 -541
package/dist/util.js ADDED
@@ -0,0 +1,36 @@
1
+ /** Small helpers every module of the package shares. */
2
+ import { statSync } from 'node:fs';
3
+ import { failureOf } from '@osdlc/cli-tool';
4
+ import { CommandFailed } from '@osdlc/typed-git';
5
+ /** True when `path` is a directory; false for anything else, or when it cannot be read. */
6
+ export function isDir(path) {
7
+ try {
8
+ return statSync(path).isDirectory();
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ /**
15
+ * An error as one line for a person: a failed git call's own output, or the
16
+ * message. Takes a thrown error or a `try` door's failure, which is a
17
+ * `Failure` rather than an `Error`; a failure's classified message reads
18
+ * better than the raw output it was classified from. The text goes into
19
+ * notes and report lines, so line breaks are folded into spaces.
20
+ */
21
+ export function errorText(err) {
22
+ return oneLine(rawErrorText(err));
23
+ }
24
+ function rawErrorText(err) {
25
+ if (err instanceof CommandFailed)
26
+ return err.detail.trim() || err.message;
27
+ if (err instanceof Error)
28
+ return err.message;
29
+ const failure = failureOf(err);
30
+ if (failure !== null)
31
+ return failure.message || failure.detail?.trim() || '';
32
+ return String(err);
33
+ }
34
+ const oneLine = (text) => text.replace(/\s*\n\s*/g, ' ').trim();
35
+ /** `1 commit`, `3 commits`. */
36
+ export const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
@@ -1,2 +1,2 @@
1
1
  /** This package's version, from its package.json when it was built. */
2
- export declare const VERSION = "0.17.2";
2
+ export declare const VERSION = "0.19.0";
@@ -1,3 +1,3 @@
1
1
  // Generated by cli-tool-stamp-version from package.json. Do not edit.
2
2
  /** This package's version, from its package.json when it was built. */
3
- export const VERSION = '0.17.2';
3
+ export const VERSION = '0.19.0';
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { Git } from '@osdlc/typed-git';
9
9
  import { type ProcessProbe } from './processes.ts';
10
- import { type PRInfo, type RepoInfo, type SweepItem } from './types.ts';
10
+ import { type PRInfo, type RepoInfo, type WorktreeEvidence, type WorktreeRow } from './types.ts';
11
11
  /** The command line, not run, that removes the worktree at `path`. */
12
12
  export declare function worktreeRemoveCommand(root: string, path: string, opts?: {
13
13
  force?: boolean;
@@ -16,45 +16,29 @@ export declare function worktreeRemoveCommand(root: string, path: string, opts?:
16
16
  export declare function worktreeUnlockCommand(root: string, path: string): string;
17
17
  /** The command line, not run, that drops registrations of worktrees whose directories are gone. */
18
18
  export declare function worktreePruneCommand(root: string): string;
19
- /** A condition that rules out removing a worktree, whatever its branch says. */
20
- export type WorktreeBlocker = 'locked-worktree' | 'live-process' | 'process-probe-failed' | 'hidden-index-flags' | 'nested-repository' | 'branch-in-other-worktree';
21
- /** True for a worktree row held back by `facts.blockers`. */
22
- export declare function hasBlockers(item: Pick<SweepItem, 'facts'>): boolean;
19
+ /**
20
+ * Whether removing this worktree takes `git worktree remove --force`: its
21
+ * submodules make plain remove refuse, it has modified or untracked files
22
+ * (which only `discard-changes` lets go), or its branch is gone. The force
23
+ * skips git's own cleanliness check, so a run checks again itself first.
24
+ */
25
+ export declare function removeIsForced(e: WorktreeEvidence): boolean;
26
+ /**
27
+ * The commands that carry out `action` on the worktree whose evidence is `e`:
28
+ * an unlock first for a stale lock. A prune of a worktree whose directory is
29
+ * gone removes that worktree's record alone (`git worktree remove` takes a
30
+ * missing directory); one whose directory is left without its `.git` needs
31
+ * the repository-wide `git worktree prune`, which `git worktree remove`
32
+ * refuses to stand in for.
33
+ */
34
+ export declare function worktreeCommands(root: string, e: WorktreeEvidence, action: 'remove' | 'prune'): string[];
23
35
  /**
24
36
  * Every worktree of `repo`, and the set of branches they hold.
25
37
  *
26
38
  * The branch set is returned because a local branch checked out in a worktree
27
39
  * must not be judged on its own: the worktree is what a person would act on.
28
40
  */
29
- export declare function gatherWorktrees(repo: RepoInfo, prs: Map<string, PRInfo>, cwd: string, git: Git, only: string | undefined, probe: ProcessProbe, tick?: (done: number, total: number) => void): [SweepItem[], Set<string>];
30
- export declare function classifyWorktree(opts: {
31
- path: string;
32
- branch: string;
33
- /** The worktree's HEAD commit; null when git reported none. */
34
- head: string | null;
35
- isBare: boolean;
36
- isDetached: boolean;
37
- isPrimary: boolean;
38
- locked: boolean;
39
- lockReason: string | null;
40
- /** Other worktrees with the same branch checked out. */
41
- otherWorktrees: string[];
42
- repo: RepoInfo;
43
- prs: Map<string, PRInfo>;
44
- cwd: string;
45
- git: Git;
46
- /** Answers lock-owner and working-directory questions. One per sweep: it caches the process table. */
47
- probe: ProcessProbe;
48
- }): SweepItem;
49
- /** How a branch shows it was worked on, for a worktree merged by ancestry alone. */
50
- export type BranchUse = 'commits' | 'upstream';
51
- /**
52
- * The sign that `branch` was worked on, or null for a branch that looks
53
- * freshly made. `commits`: its reflog records work (see {@link isWorkEntry}). `upstream`: it tracks a branch on a remote, and not
54
- * the default branch (`mainBranch`) of any remote, so someone pushed it as
55
- * its own branch. A branch with neither, such as one `git worktree add -b`
56
- * just made from the default branch, with or without `--track`, reads as
57
- * null: removing its worktree could lose ignored files from work about to
58
- * start.
59
- */
60
- export declare function branchUse(git: Git, branch: string, mainBranch: string): BranchUse | null;
41
+ export declare function gatherWorktrees(repo: RepoInfo, prs: Map<string, PRInfo>, cwd: string, git: Git, only: {
42
+ branch?: string | undefined;
43
+ path?: string | undefined;
44
+ }, probe: ProcessProbe, tick?: (done: number, total: number) => void): [WorktreeRow[], Set<string>];
package/dist/worktrees.js CHANGED
@@ -5,14 +5,16 @@
5
5
  * than a branch. Anything uncommitted or unpushed stops the verdict cold —
6
6
  * losing an hour of someone's work costs more than any amount of clutter.
7
7
  */
8
- import { existsSync, statSync } from 'node:fs';
9
- import { isAbsolute, join, sep } from 'node:path';
8
+ import { existsSync, realpathSync } from 'node:fs';
9
+ import { isAbsolute, join, resolve, sep } from 'node:path';
10
10
  import { shellJoin } from '@osdlc/command-seam';
11
11
  import { nodeGuard } from '@osdlc/path-guard/node';
12
12
  import { gapSentence, mergedFact, recordPr } from "./branches.js";
13
13
  import { confirmationNote, judgeLanding, landingConfirmed, recordLanding, unmatchedSentence, } from "./landing.js";
14
14
  import { busyHolders, isIdleHolder, parseLockOwner } from "./processes.js";
15
- import { authorizationsFor, landedRef, makeItem, } from "./types.js";
15
+ import { inspectSubmodules, readIndexHazards, submoduleFacts, unpushedPhrase, unreadablePhrase, worktreeGitDirs, } from "./submodules.js";
16
+ import { landedRef, makeRow, risksFor, } from "./types.js";
17
+ import { isDir } from "./util.js";
16
18
  /** The command line, not run, that removes the worktree at `path`. */
17
19
  export function worktreeRemoveCommand(root, path, opts = {}) {
18
20
  return shellJoin([
@@ -33,36 +35,57 @@ export function worktreeUnlockCommand(root, path) {
33
35
  export function worktreePruneCommand(root) {
34
36
  return shellJoin(['git', '-C', root, 'worktree', 'prune']);
35
37
  }
36
- function isDir(path) {
37
- try {
38
- return statSync(path).isDirectory();
39
- }
40
- catch {
41
- return false;
42
- }
38
+ /**
39
+ * Whether removing this worktree takes `git worktree remove --force`: its
40
+ * submodules make plain remove refuse, it has modified or untracked files
41
+ * (which only `discard-changes` lets go), or its branch is gone. The force
42
+ * skips git's own cleanliness check, so a run checks again itself first.
43
+ */
44
+ export function removeIsForced(e) {
45
+ return (e.forcedForSubmodules === true ||
46
+ (e.dirtyFiles ?? 0) > 0 ||
47
+ (e.submoduleDirtyFiles ?? 0) > 0 ||
48
+ (e.branch !== null && e.branchExists === false));
43
49
  }
44
- /** True for a worktree row held back by `facts.blockers`. */
45
- export function hasBlockers(item) {
46
- const blockers = item.facts['blockers'];
47
- return Array.isArray(blockers) && blockers.length > 0;
50
+ /**
51
+ * The commands that carry out `action` on the worktree whose evidence is `e`:
52
+ * an unlock first for a stale lock. A prune of a worktree whose directory is
53
+ * gone removes that worktree's record alone (`git worktree remove` takes a
54
+ * missing directory); one whose directory is left without its `.git` needs
55
+ * the repository-wide `git worktree prune`, which `git worktree remove`
56
+ * refuses to stand in for.
57
+ */
58
+ export function worktreeCommands(root, e, action) {
59
+ // `git worktree remove` and `prune` both skip a locked worktree, so a stale
60
+ // lock is cleared first.
61
+ const unlock = e.staleLock === true ? [worktreeUnlockCommand(root, e.path)] : [];
62
+ return [
63
+ ...unlock,
64
+ action === 'remove'
65
+ ? worktreeRemoveCommand(root, e.path, { force: removeIsForced(e) })
66
+ : e.directoryLeft === true
67
+ ? worktreePruneCommand(root)
68
+ : worktreeRemoveCommand(root, e.path),
69
+ ];
48
70
  }
49
- /** Cap on `facts.hidden_index_paths`; `facts.hidden_index_flags` is the full count. */
71
+ /** Cap on `evidence.hiddenIndexPaths`; `evidence.hiddenIndexFlags` is the full count. */
50
72
  const HIDDEN_PATHS_SHOWN = 10;
51
73
  /**
52
74
  * The blockers on one worktree, each with the sentence telling a person what
53
- * to do about it. Writes the evidence for each into `facts`.
75
+ * to do about it. Writes the evidence for each into `evidence`.
54
76
  */
55
77
  function readBlockers(opts) {
56
- const { path, branch, onDisk, locked, lockReason, otherWorktrees, facts, git, probe } = opts;
78
+ const { path, branch, onDisk, locked, lockReason, otherWorktrees, evidence, git, probe } = opts;
79
+ const { submodules } = opts;
57
80
  const out = [];
58
81
  if (locked) {
59
- facts['lock_reason'] = lockReason;
82
+ evidence.lockReason = lockReason;
60
83
  // A lock whose owner is dead holds nothing. A lock that names no owner, or
61
84
  // one that cannot be ruled out, stays a blocker.
62
85
  const owner = parseLockOwner(lockReason);
63
86
  if (owner !== null && !probe.alive(owner.pid, owner.startedAt)) {
64
- facts['stale_lock'] = true;
65
- facts['lock_owner'] = { pid: owner.pid, started_at: owner.startedAt ?? null };
87
+ evidence.staleLock = true;
88
+ evidence.lockOwner = { pid: owner.pid, startedAt: owner.startedAt ?? null };
66
89
  }
67
90
  else {
68
91
  out.push([
@@ -80,9 +103,9 @@ function readBlockers(opts) {
80
103
  const shells = live.filter(isIdleHolder);
81
104
  const busy = busyHolders(live);
82
105
  if (shells.length > 0)
83
- facts['idle_shells'] = shells;
106
+ evidence.idleShells = shells;
84
107
  if (busy.length > 0) {
85
- facts['live_processes'] = busy;
108
+ evidence.liveProcesses = busy;
86
109
  out.push([
87
110
  'live-process',
88
111
  `Running in the worktree: ${busy.map((h) => `${h.command} (pid ${h.pid})`).join(', ')}; stop them or wait for them to finish.`,
@@ -93,41 +116,43 @@ function readBlockers(opts) {
93
116
  // No process table to read (a host without `lsof`, a timeout): nobody
94
117
  // can say the directory is idle, so the worktree is held back.
95
118
  const message = err instanceof Error ? err.message : String(err);
96
- facts['process_probe_error'] = message;
119
+ evidence.processProbeError = message;
97
120
  out.push([
98
121
  'process-probe-failed',
99
122
  `Could not tell whether a process is running in the worktree (${message}); check by hand, or fix the probe and sweep again.`,
100
123
  ]);
101
124
  }
102
- const at = git.at(path);
103
- // Status hides edits to these files, so "clean" cannot be trusted.
104
- const hidden = at
105
- .lsFilesTagged()
106
- .filter((row) => row.tag === 'S' || row.tag !== row.tag.toUpperCase())
107
- .map((row) => row.path);
125
+ // Status hides edits to flagged files, so "clean" cannot be trusted, and a
126
+ // nested repository has its own history. A submodule's count too: `--force`
127
+ // removes its files with the rest.
128
+ const own = readIndexHazards(git.at(path), path);
129
+ const inSubmodules = (pick) => submodules.submodules.flatMap((s) => pick(s).map((p) => `${s.path}/${p}`));
130
+ const hidden = [...own.hiddenIndexPaths, ...inSubmodules((s) => s.hiddenIndexPaths)];
108
131
  if (hidden.length > 0) {
109
- facts['hidden_index_flags'] = hidden.length;
110
- facts['hidden_index_paths'] = hidden.slice(0, HIDDEN_PATHS_SHOWN);
132
+ evidence.hiddenIndexFlags = hidden.length;
133
+ evidence.hiddenIndexPaths = hidden.slice(0, HIDDEN_PATHS_SHOWN);
111
134
  out.push([
112
135
  'hidden-index-flags',
113
136
  `${hidden.length} file(s) are marked skip-worktree or assume-unchanged, so status may hide edits; clear the flags with \`git update-index --no-skip-worktree --no-assume-unchanged <path>\` and check again.`,
114
137
  ]);
115
138
  }
116
- // No `excludeStandard`: an ignored directory can hold a repository too.
117
- // Only the top of each listed directory is checked.
118
- const nested = at
119
- .lsFiles({ others: true, directory: true })
120
- .filter((entry) => entry.endsWith('/') && existsSync(join(path, entry, '.git')));
139
+ const nested = [...own.nestedRepositories, ...inSubmodules((s) => s.nestedRepositories)];
121
140
  if (nested.length > 0) {
122
- facts['nested_repositories'] = nested;
141
+ evidence.nestedRepositories = nested;
123
142
  out.push([
124
143
  'nested-repository',
125
144
  `Nested repositories (${nested.join(', ')}) have their own history; move them out before removing the worktree.`,
126
145
  ]);
127
146
  }
128
147
  }
148
+ if (submodules.unreadable.length > 0) {
149
+ out.push([
150
+ 'submodule-unreadable',
151
+ `Could not read ${unreadablePhrase(submodules.unreadable)}; check them by hand, then sweep again.`,
152
+ ]);
153
+ }
129
154
  if (otherWorktrees.length > 0) {
130
- facts['other_worktrees'] = otherWorktrees;
155
+ evidence.otherWorktrees = otherWorktrees;
131
156
  out.push([
132
157
  'branch-in-other-worktree',
133
158
  `Branch \`${branch}\` is also checked out at ${otherWorktrees.join(', ')}; deleting the branch along with this worktree would pull it from under the other.`,
@@ -149,7 +174,22 @@ export function gatherWorktrees(repo, prs, cwd, git, only, probe, tick) {
149
174
  pathsByBranch.set(entry.branch, [...(pathsByBranch.get(entry.branch) ?? []), entry.path]);
150
175
  }
151
176
  }
152
- const entries = only === undefined ? all : all.filter((entry) => entry.branch === only);
177
+ // A path matches however git spelled it: as listed, resolved, or through symlinks.
178
+ const samePath = (listed, wanted) => listed === wanted ||
179
+ resolve(listed) === wanted ||
180
+ (existsSync(listed) && realpathSync(listed) === wanted);
181
+ const entries = all.filter((entry) => (only.branch === undefined || entry.branch === only.branch) &&
182
+ (only.path === undefined || samePath(entry.path, only.path)));
183
+ // Read once, and only when a worktree is gone: its git dir may still hold
184
+ // submodule history that the prune would delete.
185
+ let gitDirs;
186
+ const gitDirOf = (path) => {
187
+ if (gitDirs === undefined) {
188
+ const common = git.at(repo.root).soft.gitCommonDir();
189
+ gitDirs = common === null ? new Map() : worktreeGitDirs(common);
190
+ }
191
+ return gitDirs.get(resolve(path)) ?? null;
192
+ };
153
193
  const items = [];
154
194
  for (const [i, entry] of entries.entries()) {
155
195
  tick?.(i, entries.length);
@@ -169,13 +209,14 @@ export function gatherWorktrees(repo, prs, cwd, git, only, probe, tick) {
169
209
  cwd,
170
210
  git,
171
211
  probe,
212
+ gitDirOf,
172
213
  }));
173
214
  }
174
215
  return [items, new Set(pathsByBranch.keys())];
175
216
  }
176
- export function classifyWorktree(opts) {
217
+ function classifyWorktree(opts) {
177
218
  const { path, branch, head, isBare, isDetached, isPrimary, repo, prs, cwd, git } = opts;
178
- const facts = {
219
+ const evidence = {
179
220
  path,
180
221
  branch: branch || null,
181
222
  head,
@@ -183,16 +224,8 @@ export function classifyWorktree(opts) {
183
224
  detached: isDetached,
184
225
  primary: isPrimary,
185
226
  };
186
- const keep = (reason, extra = {}) => makeItem({
187
- category: 'worktree',
188
- id: path,
189
- classification: 'safe-to-keep',
190
- action: 'keep',
191
- reason,
192
- facts,
193
- ...extra,
194
- });
195
- const pinned = (reason) => keep(reason, { deletable: false });
227
+ const row = (r) => makeRow({ kind: 'worktree', name: path, evidence, ...r });
228
+ const pinned = (why) => row({ verdict: 'keep', action: null, why });
196
229
  if (isPrimary)
197
230
  return pinned('primary worktree (repo root)');
198
231
  // The caller is standing in this worktree. Proposing its removal would
@@ -208,8 +241,23 @@ export function classifyWorktree(opts) {
208
241
  // that drops its registration leaves the directory alone.
209
242
  const dirPresent = isDir(path);
210
243
  const onDisk = dirPresent && existsSync(join(path, '.git'));
211
- facts['on_disk'] = onDisk;
212
- facts['directory_left'] = dirPresent && !onDisk;
244
+ evidence.onDisk = onDisk;
245
+ evidence.directoryLeft = dirPresent && !onDisk;
246
+ // The worktree's git dir from the map read once per repository; a gone
247
+ // worktree has no checkout to ask, and its git dir may still hold
248
+ // submodule history that removing it would delete.
249
+ const submodules = inspectSubmodules(git, path, { gitDir: opts.gitDirOf(path) });
250
+ const submoduleDirty = submodules.dirty.reduce((n, s) => n + s.dirtyFiles, 0);
251
+ if (submodules.submodules.length > 0) {
252
+ evidence.submodules = submoduleFacts(submodules);
253
+ evidence.submoduleDirtyFiles = submoduleDirty;
254
+ }
255
+ // Plain `worktree remove` refuses a worktree with submodules whatever their
256
+ // state, so the remove is forced. The force also skips git's own check for
257
+ // modified and untracked files, which the sweep makes below and `run`
258
+ // makes again just before it removes.
259
+ if (onDisk && submodules.forceRequired)
260
+ evidence.forcedForSubmodules = true;
213
261
  let dirtyFiles = [];
214
262
  let unpushed = [];
215
263
  if (onDisk) {
@@ -230,89 +278,105 @@ export function classifyWorktree(opts) {
230
278
  const range = git.at(path).try.logLines({ range: `${compareAgainst}..HEAD`, format: '%h %s' });
231
279
  if (range.ok)
232
280
  unpushed = range.value;
233
- facts['compare_against'] = compareAgainst;
281
+ evidence.compareAgainst = compareAgainst;
234
282
  }
235
- facts['dirty_files'] = dirtyFiles.length;
236
- facts['unpushed_commits'] = unpushed.length;
283
+ evidence.dirtyFiles = dirtyFiles.length;
284
+ evidence.unpushedCommits = unpushed.length;
237
285
  const branchExists = !!branch && git.at(repo.root).resolveRef(branch) !== null;
238
- facts['branch_exists'] = branchExists;
286
+ evidence.branchExists = branchExists;
239
287
  const pr = branch ? prs.get(branch) : undefined;
240
- const { landed, gap } = recordPr(facts, pr, head ?? '', repo);
288
+ const { landed, gap } = recordPr(evidence, pr, head ?? '', repo);
241
289
  if (unpushed.length > 0)
242
- facts['unique_commits'] = unpushed.length;
290
+ evidence.uniqueCommits = unpushed.length;
243
291
  // A merged PR at HEAD lands every commit, even ones the fallback
244
- // `compare_against` (a squash merge, a deleted upstream) counts as ahead.
292
+ // `compareAgainst` (a squash merge, a deleted upstream) counts as ahead.
245
293
  // A rebase merge, cherry-pick or squash merge leaves HEAD off the default
246
294
  // branch; the patches say whether the work is there anyway. Judged from HEAD,
247
295
  // so a dirty tree is still caught by the dirty-file check below.
248
296
  const patch = landed || !onDisk || head === null ? null : judgeLanding(repo, head, pr, gap, git);
249
297
  if (patch !== null)
250
- recordLanding(facts, patch);
298
+ recordLanding(evidence, patch);
251
299
  const patchLanded = patch !== null && patch.check !== null;
252
300
  const unlanded = unpushed.length > 0 && !landed && !landingConfirmed(patch);
253
301
  if (onDisk && head !== null) {
254
302
  // `soft`: a missing default ref reads as not merged.
255
- facts['merged'] = mergedFact({
303
+ evidence.merged = mergedFact({
256
304
  landed,
257
305
  patchLanded,
258
306
  ancestor: git.at(repo.root).soft.isAncestor(head, landedRef(repo)),
259
307
  });
260
308
  }
261
- const requires = authorizationsFor({
262
- 'abandon-unmerged': unlanded,
263
- 'discard-changes': dirtyFiles.length > 0,
309
+ const dirty = dirtyFiles.length > 0 || submoduleDirty > 0;
310
+ // A submodule's git dir lives in the worktree's own git dir, so removing
311
+ // the worktree, or pruning it once its directory is gone, deletes the
312
+ // submodule's commits and stashes with it. The same risk as the worktree's
313
+ // own unpushed commits covers them.
314
+ const submoduleWork = submodules.unpushed.length > 0;
315
+ const submoduleWorkText = submoduleWork ? unpushedPhrase(submodules.unpushed) : '';
316
+ const risks = risksFor({
317
+ 'abandon-unmerged': unlanded || submoduleWork,
318
+ 'discard-changes': dirty,
264
319
  'confirm-open-pr': pr?.state === 'OPEN',
265
320
  });
266
- const blockers = readBlockers({
267
- ...opts,
268
- onDisk,
269
- facts,
270
- });
271
- const kinds = blockers.map(([kind]) => kind);
272
- // `git worktree remove` and `prune` both skip a locked worktree, so a stale
273
- // lock is cleared first.
274
- const unlock = facts['stale_lock'] === true ? [worktreeUnlockCommand(repo.root, path)] : [];
275
- const safeRemove = [...unlock, worktreeRemoveCommand(repo.root, path)];
276
- const forceRemove = [...unlock, worktreeRemoveCommand(repo.root, path, { force: true })];
277
- const pruneOnly = [...unlock, worktreePruneCommand(repo.root)];
278
- facts['blockers'] = kinds;
279
- if (kinds.length > 0) {
280
- return makeItem({
281
- category: 'worktree',
282
- id: path,
283
- classification: 'needs-investigation',
284
- action: 'review',
285
- reason: `blocked: ${kinds.join(', ')}`,
286
- facts,
287
- investigationParts: blockers.map(([, advice]) => advice),
288
- deletable: false,
321
+ const blockers = readBlockers({ ...opts, onDisk, evidence, submodules });
322
+ const removes = worktreeCommands(repo.root, evidence, 'remove');
323
+ const pruneOnly = worktreeCommands(repo.root, evidence, 'prune');
324
+ // Said in the reason of a row whose remove is forced for its submodules.
325
+ const forced = evidence.forcedForSubmodules === true
326
+ ? '; its submodules are clean and pushed, so the remove is forced'
327
+ : '';
328
+ if (blockers.length > 0) {
329
+ const kinds = blockers.map(([kind]) => kind);
330
+ return row({
331
+ verdict: 'blocked',
332
+ action: null,
333
+ why: `blocked: ${kinds.join(', ')}`,
334
+ blockers: kinds,
335
+ notes: blockers.map(([, advice]) => advice),
336
+ });
337
+ }
338
+ if (!onDisk && submoduleWork) {
339
+ return row({
340
+ verdict: 'review',
341
+ action: 'prune',
342
+ why: `directory gone, but its git dir keeps work no remote has in ${submoduleWorkText}`,
343
+ commands: pruneOnly,
344
+ risks,
345
+ notes: [
346
+ `Worktree ${path} is gone, but its git dir keeps ${submoduleWorkText}; pruning it deletes them.`,
347
+ 'Push the work from that git dir, or allow abandon-unmerged to prune it anyway.',
348
+ ],
289
349
  });
290
350
  }
291
351
  if (!onDisk) {
292
- return makeItem({
293
- category: 'worktree',
294
- id: path,
295
- classification: 'safe-to-delete',
352
+ return row({
353
+ verdict: 'delete',
296
354
  action: 'prune',
297
- reason: dirPresent
298
- ? 'directory is no longer a checkout (no .git); prune the stale link, the directory stays'
299
- : 'worktree directory no longer on disk; prune the stale link',
300
- facts,
355
+ why: dirPresent
356
+ ? 'directory is no longer a checkout (no .git); prune the stale record, the directory stays'
357
+ : 'worktree directory no longer on disk; prune the stale record',
301
358
  commands: pruneOnly,
302
359
  });
303
360
  }
304
- if (dirtyFiles.length > 0 || unlanded) {
305
- return makeItem({
306
- category: 'worktree',
307
- id: path,
308
- classification: 'needs-investigation',
309
- action: 'review',
310
- reason: `${dirtyFiles.length} dirty file(s), ${unpushed.length} commit(s) ahead of ${facts['compare_against']}`,
311
- facts,
312
- requires,
313
- investigationParts: [
361
+ if (dirty || unlanded || submoduleWork) {
362
+ const inSubmodules = submoduleDirty > 0
363
+ ? `, ${submoduleDirty} in submodule(s) ${submodules.dirty.map((s) => s.path).join(', ')}`
364
+ : '';
365
+ const workInSubmodules = submoduleWork ? `; unpushed work in ${submoduleWorkText}` : '';
366
+ return row({
367
+ verdict: 'review',
368
+ action: 'remove',
369
+ why: `${dirtyFiles.length} dirty file(s)${inSubmodules}, ${unpushed.length} commit(s) ahead of ${evidence.compareAgainst}${workInSubmodules}`,
370
+ commands: removes,
371
+ risks,
372
+ notes: [
314
373
  `Worktree ${path} has uncommitted work or unpushed commits.`,
315
374
  `Branch: ${branch || '(detached)'}.`,
375
+ ...(submoduleWork
376
+ ? [
377
+ `Removing it also deletes the work in ${submoduleWorkText}, whose git dirs live in the worktree's own.`,
378
+ ]
379
+ : []),
316
380
  ...(patchLanded && unlanded
317
381
  ? [
318
382
  `Its commits are patch-equivalent to commits on ${landedRef(repo)} (${patch.check}), but ${confirmationNote(patch)}.`,
@@ -322,80 +386,49 @@ export function classifyWorktree(opts) {
322
386
  ],
323
387
  });
324
388
  }
325
- if (pr && landed) {
326
- return makeItem({
327
- category: 'worktree',
328
- id: path,
329
- classification: 'safe-to-delete',
330
- action: 'remove',
331
- reason: `clean; PR #${pr.number} merged`,
332
- facts,
333
- commands: safeRemove,
334
- requires,
335
- });
336
- }
389
+ const deletes = (why) => row({ verdict: 'delete', action: 'remove', why, commands: removes, risks });
390
+ if (pr && landed)
391
+ return deletes(`clean; PR #${pr.number} merged${forced}`);
337
392
  if (patchLanded) {
338
- return makeItem({
339
- category: 'worktree',
340
- id: path,
341
- classification: 'safe-to-delete',
342
- action: 'remove',
343
- reason: `clean; patch-equivalent to commits on ${landedRef(repo)} (${patch.check}); ${confirmationNote(patch)}`,
344
- facts,
345
- commands: safeRemove,
346
- requires,
347
- });
393
+ return deletes(`clean; patch-equivalent to commits on ${landedRef(repo)} (${patch.check}); ${confirmationNote(patch)}${forced}`);
348
394
  }
349
395
  if (branch && !branchExists) {
350
- return makeItem({
351
- category: 'worktree',
352
- id: path,
353
- classification: 'safe-to-delete',
396
+ return deletes(`branch \`${branch}\` no longer exists${forced}`);
397
+ }
398
+ if (pr && pr.state === 'OPEN') {
399
+ return row({
400
+ verdict: 'keep',
354
401
  action: 'remove',
355
- reason: `branch \`${branch}\` no longer exists`,
356
- facts,
357
- commands: forceRemove,
358
- requires,
402
+ why: `open PR #${pr.number}`,
403
+ commands: removes,
404
+ risks,
359
405
  });
360
406
  }
361
- if (pr && pr.state === 'OPEN')
362
- return keep(`open PR #${pr.number}`, { requires });
363
407
  // Merged by ancestry alone: a worktree just made from the default branch is
364
408
  // merged that way too, so ancestry needs a sign the branch was worked on.
365
- if (facts['merged'] === true && branch && head !== null) {
409
+ if (evidence.merged === true && branch && head !== null) {
366
410
  const used = branchUse(git.at(repo.root), branch, repo.mainBranch);
367
- facts['branch_used'] = used;
411
+ evidence.branchUsed = used;
368
412
  if (used !== null) {
369
- return makeItem({
370
- category: 'worktree',
371
- id: path,
372
- classification: 'safe-to-delete',
373
- action: 'remove',
374
- reason: `clean; merged into ${landedRef(repo)}, and ${USE_REASON[used]}`,
375
- facts,
376
- commands: safeRemove,
377
- requires,
378
- });
413
+ return deletes(`clean; merged into ${landedRef(repo)}, and ${USE_REASON[used]}${forced}`);
379
414
  }
380
415
  }
381
- const parts = [`Worktree \`${path}\` is clean.`];
416
+ const notes = [`Worktree \`${path}\` is clean.`];
382
417
  if (branch)
383
- parts.push(`Branch: \`${branch}\`.`);
384
- parts.push(pr ? `PR #${pr.number} is ${pr.state}.` : 'No associated PR.');
418
+ notes.push(`Branch: \`${branch}\`.`);
419
+ notes.push(pr ? `PR #${pr.number} is ${pr.state}.` : 'No associated PR.');
385
420
  if (pr && gap)
386
- parts.push(gapSentence(pr, gap, repo));
421
+ notes.push(gapSentence(pr, gap, repo));
387
422
  const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
388
423
  if (unmatched !== null)
389
- parts.push(unmatched);
390
- return makeItem({
391
- category: 'worktree',
392
- id: path,
393
- classification: 'needs-investigation',
394
- action: 'review',
395
- reason: 'clean but no clear disposability signal',
396
- facts,
397
- investigationParts: parts,
398
- requires,
424
+ notes.push(unmatched);
425
+ return row({
426
+ verdict: 'review',
427
+ action: 'remove',
428
+ why: 'clean but no clear disposability signal',
429
+ commands: removes,
430
+ notes,
431
+ risks,
399
432
  });
400
433
  }
401
434
  const USE_REASON = {
@@ -422,7 +455,7 @@ function isWorkEntry(entry) {
422
455
  * null: removing its worktree could lose ignored files from work about to
423
456
  * start.
424
457
  */
425
- export function branchUse(git, branch, mainBranch) {
458
+ function branchUse(git, branch, mainBranch) {
426
459
  // `raw`: a branch with no reflog (core.logAllRefUpdates off) exits non-zero.
427
460
  const reflog = git.raw(['reflog', 'show', '--format=%gs', `refs/heads/${branch}`, '--']);
428
461
  if (reflog.exitCode === 0 && reflog.stdout.split('\n').some(isWorkEntry)) {