@osdlc/repo-cleaner 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @osdlc/repo-cleaner
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cbb7007: A full sweep no longer holds back the branch of a worktree it can remove. When a worktree is `safe-to-delete`, its branch is judged on its own and can be deleted in the same run: `apply` removes the worktree, then deletes the branch. Before, the branch stayed `keep` ("checked out in a worktree") until the next run. The branch row records the worktree in `facts.worktree`, and `apply` skips the branch as `not-deletable` unless the same run removes that worktree. This also fixes `--branch`, which could plan a branch delete while skipping its worktree; that delete then failed with "refused: checked out".
8
+ - 41439a6: `repo-cleaner cache` lists the clones `--remote-url` keeps, with their URL and size, and `cache remove <url>` or `cache remove --all` removes them, a clone left by a stopped run included. A checkout directory named `cache` is now swept as `./cache`. `apply`'s question no longer takes its answer from keys typed while the sweep ran (through `@osdlc/cli-tool`'s `askYesNo`). The README's install section covers a global install that the shell cannot find, fish with a Node version manager included.
9
+
10
+ ### Patch Changes
11
+
12
+ - Updated dependencies [41439a6]
13
+ - @osdlc/cli-tool@0.16.0
14
+
15
+ ## 0.14.2
16
+
17
+ ### Patch Changes
18
+
19
+ - 5cba918: `apply` says `skipped`, not `dropped`, for rows it leaves alone, matching the `skip` label on each row: `skipped 12, not deleted: 8 needs-review (name with --row), 4 not-authorized (--allow abandon-unmerged)`. The counts name how to include each kind. The closing prune step reads `prune worktree records`, with the repository on the progress board, instead of a bare `prune`. The JSON receipt and summary keep their `dropped` field.
20
+
21
+ ## 0.14.1
22
+
23
+ ### Patch Changes
24
+
25
+ - Updated dependencies [a3ef86d]
26
+ - @osdlc/cli-tool@0.15.0
27
+
3
28
  ## 0.14.0
4
29
 
5
30
  ### Minor Changes
package/README.md CHANGED
@@ -34,7 +34,7 @@ own bar, empty while it waits, half full while it runs and full with a `✓`
34
34
  ██████████ ✓ delete local_branch feat/a
35
35
  █████░░░░░ › delete local_branch feat/b
36
36
  ░░░░░░░░░░ delete remote_branch origin/feat/b
37
- ░░░░░░░░░░ prune
37
+ ░░░░░░░░░░ prune worktree records in /Users/me/work/api
38
38
  ████████░░░░░░░░░░░░░░░░░░░░░░ 1/4
39
39
  ```
40
40
 
@@ -68,6 +68,28 @@ npm install @osdlc/repo-cleaner
68
68
 
69
69
  ESM only. Compiled JavaScript with type declarations, for Node 22 or newer.
70
70
 
71
+ For the command, install it globally:
72
+
73
+ ```sh
74
+ npm install -g @osdlc/repo-cleaner
75
+ ```
76
+
77
+ If the shell then says `Unknown command` or `command not found`, npm put the
78
+ command in a directory that is not on `PATH`. `npm prefix -g` prints where
79
+ it went; the command is in its `bin`. This is common with a Node version
80
+ manager (proto, nvm, fnm, volta): each Node version has its own global
81
+ directory, and a shell setup that names one version's `bin` directly misses
82
+ the others. Let the manager set `PATH` instead. In fish, with proto:
83
+
84
+ ```fish
85
+ # ~/.config/fish/config.fish, in place of a fish_add_path …/node/<version>/bin line
86
+ proto activate fish | source
87
+ ```
88
+
89
+ nvm users in fish can use a fish port such as `nvm.fish`; fnm has
90
+ `fnm env --use-on-cd --shell fish | source`. Then open a new shell and check
91
+ with `type repo-cleaner`.
92
+
71
93
  ## How it decides
72
94
 
73
95
  Three signals settle most items. The first two are enough alone; the third needs a content check too (see [Patch equivalence](#patch-equivalence)):
@@ -248,6 +270,12 @@ approves them by name:
248
270
  A row kept for structure (default branch, current HEAD, a branch held by a
249
271
  worktree, the primary or caller's worktree) has `deletable: false`.
250
272
 
273
+ A branch whose worktree is `safe-to-delete` is not held by it: the sweep judges
274
+ the branch on its own and records the worktree's path in `facts.worktree`, so
275
+ one run removes the worktree and then deletes the branch. `apply` deletes such
276
+ a branch only when the same run removes its worktree, and skips it otherwise.
277
+ With `--only branches` no worktree is swept, so the branch stays held.
278
+
251
279
  ## Commands
252
280
 
253
281
  ```bash
@@ -255,9 +283,15 @@ repo-cleaner [status] # the report: what is merged, what can go (the default)
255
283
  repo-cleaner summary # counts per repository: total, merged, delete, prune, review, keep
256
284
  repo-cleaner plan # the deletable rows as a plan, to stdout or -o <file>
257
285
  repo-cleaner apply # re-check, show the steps, ask, delete
286
+ repo-cleaner cache # list or remove the clones --remote-url keeps
258
287
  ```
259
288
 
260
- All four take the same paths and sweep options.
289
+ The first four take the same paths and sweep options. A checkout directory
290
+ named like a command is swept as `./<name>`.
291
+
292
+ `apply` asks before deleting, and throws away anything typed while the sweep
293
+ ran, so a key pressed early cannot answer the question. It says so when it
294
+ did.
261
295
 
262
296
  ```console
263
297
  $ repo-cleaner summary --remotes
@@ -329,7 +363,10 @@ pushed to the URL from the clone, compare-and-swap as always.
329
363
 
330
364
  The clone is kept, not deleted after the run, so a plan written with
331
365
  `--remote-url` can be applied later from anywhere: the plan names the clone's
332
- directory. Remove the directory to drop it. The clone is a full one, not
366
+ directory. `repo-cleaner cache` lists the clones with their URL and size;
367
+ `repo-cleaner cache remove <url>` drops one, and `cache remove --all` drops
368
+ every one, a clone left by a stopped run included. A plan whose clone was
369
+ removed cannot be applied; sweep again. The clone is a full one, not
333
370
  `--filter=blob:none`: the patch check reads file contents, and a partial
334
371
  clone fetches them one branch at a time.
335
372
 
@@ -395,13 +432,13 @@ asks `Delete N items? [y/N]`:
395
432
  /Users/me/work/api
396
433
  delete local_branch fix/login-redirect
397
434
  git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d…
398
- prune
435
+ prune worktree records
399
436
  git -C /Users/me/work/api worktree prune
400
437
  skip worktree /Users/me/work/api/wt/spike
401
438
  needs-review: clean but no clear disposability signal; name it with --row to apply it
402
439
 
403
440
  would delete: 1 local branch
404
- dropped 1: 1 needs-review
441
+ skipped 1, not deleted: 1 needs-review (name with --row)
405
442
  Delete 2 items? [y/N]
406
443
  ```
407
444
 
@@ -434,7 +471,7 @@ Every run ends with what it did:
434
471
 
435
472
  ```text
436
473
  deleted: 1 worktree, 3 local branches, 2 remote branches, 1 stale worktree pruned
437
- dropped 2: 1 needs-review, 1 duplicate
474
+ skipped 2, not deleted: 1 needs-review (name with --row), 1 duplicate
438
475
  status: complete
439
476
  ```
440
477
 
package/dist/apply.js CHANGED
@@ -174,7 +174,22 @@ function recheck(selected, kept, grants, implicit, opts) {
174
174
  }
175
175
  accepted.push({ repo: repo.root, item, force: granted(grants, ref, 'discard-changes') });
176
176
  }
177
- return { accepted, dropped, verified };
177
+ // A branch freed by its worktree's removal goes only with that worktree.
178
+ const leaving = new Set(accepted.filter((row) => row.item.category === 'worktree').map((row) => row.item.id));
179
+ const held = accepted.filter((row) => {
180
+ const worktree = row.item.facts['worktree'];
181
+ return typeof worktree === 'string' && !leaving.has(worktree);
182
+ });
183
+ for (const { repo, item } of held) {
184
+ dropped.push({
185
+ repo,
186
+ category: item.category,
187
+ id: item.id,
188
+ reason: 'not-deletable',
189
+ detail: `checked out in ${String(item.facts['worktree'])}, which this run does not remove`,
190
+ });
191
+ }
192
+ return { accepted: accepted.filter((row) => !held.includes(row)), dropped, verified };
178
193
  }
179
194
  function pending(repo, row, action, command, expectedSha = null) {
180
195
  return {
package/dist/cli.d.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * plan write the deletable rows as a plan, to stdout or a file
9
9
  * apply re-check, show the steps, ask, then delete
10
10
  *
11
+ * and `cache`, which lists and removes the clones `--remote-url` keeps.
12
+ *
11
13
  * `apply` works from a saved plan (`--plan`) or sweeps first. Either way it
12
14
  * re-checks every row, prints the steps it would run and asks before deleting;
13
15
  * `--yes` skips the question. Without a terminal and without `--yes` it
package/dist/cli.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * plan write the deletable rows as a plan, to stdout or a file
9
9
  * apply re-check, show the steps, ask, then delete
10
10
  *
11
+ * and `cache`, which lists and removes the clones `--remote-url` keeps.
12
+ *
11
13
  * `apply` works from a saved plan (`--plan`) or sweeps first. Either way it
12
14
  * re-checks every row, prints the steps it would run and asks before deleting;
13
15
  * `--yes` skips the question. Without a terminal and without `--yes` it
@@ -21,15 +23,15 @@
21
23
  * 10 nothing to sweep: no entry named a repository
22
24
  * 11 apply stopped at a failed step; the receipt says which
23
25
  */
24
- import { existsSync, readSync, realpathSync } from 'node:fs';
26
+ import { existsSync, readSync, realpathSync, rmSync } from 'node:fs';
25
27
  import { basename, dirname, join, resolve, sep } from 'node:path';
26
28
  import { isatty } from 'node:tty';
27
29
  import { EXIT, askYesNo, canPrompt, defineCli, systemDirs } from '@osdlc/cli-tool';
28
30
  import { Git } from '@osdlc/typed-git';
29
- import { detectColorLevel } from '@osdlc/ux';
31
+ import { detectColorLevel, stripControls } from '@osdlc/ux';
30
32
  import { resolveBranch } from "./branches.js";
31
33
  import { VENDOR_DIRS } from "./devlink.js";
32
- import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
34
+ import { cachedMirrorOf, ensureMirror, listMirrors, mirrorProblem, redactUrl, } from "./remote-mirror.js";
33
35
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
34
36
  import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
35
37
  import { PROVIDER_CHOICES } from "./providers/index.js";
@@ -128,6 +130,7 @@ const statusCli = () => defineCli({
128
130
  ${NAME} summary counts per repository: total, merged, delete, prune, review, keep
129
131
  ${NAME} plan write the deletable rows as a plan
130
132
  ${NAME} apply re-check, show the steps, ask, delete
133
+ ${NAME} cache list or remove the clones --remote-url keeps
131
134
  ${NAME} dev run ${NAME} from a development checkout
132
135
 
133
136
  Each command takes --help. A checkout directory named like a command is
@@ -207,7 +210,26 @@ const applyCli = () => defineCli({
207
210
  output: OUTPUT,
208
211
  },
209
212
  epilogue: `risks: abandon-unmerged, discard-changes, confirm-open-pr
210
- A row that carries a risk you did not --allow is dropped, never deleted.`,
213
+ A row that carries a risk you did not --allow is skipped, never deleted.`,
214
+ });
215
+ const cacheCli = () => defineCli({
216
+ name: `${NAME} cache`,
217
+ summary: 'List the clones --remote-url keeps in the cache directory, or remove them. ' +
218
+ 'A removed clone is cloned again by the next --remote-url run.',
219
+ positionals: [
220
+ { name: 'verb', help: 'list (the default) or remove' },
221
+ {
222
+ name: 'url',
223
+ variadic: true,
224
+ help: 'with remove: the URL whose clone to remove, as given to --remote-url',
225
+ },
226
+ ],
227
+ flags: {
228
+ all: { kind: 'boolean', help: 'with remove: remove every clone' },
229
+ output: OUTPUT,
230
+ },
231
+ epilogue: 'A plan written with --remote-url names its clone. Removing the clone means that plan ' +
232
+ 'can no longer be applied; sweep again instead.',
211
233
  });
212
234
  const terminalConfirm = (question) => (canPrompt() ? askYesNo(question) : null);
213
235
  const PAUSE = new Int32Array(new SharedArrayBuffer(4));
@@ -714,6 +736,120 @@ function confirm(ctx, name, preview, count, json, deps) {
714
736
  }
715
737
  return null;
716
738
  }
739
+ /** `bytes` as a short size: `512 B`, `3.4 MB`. */
740
+ function sizeText(bytes) {
741
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
742
+ let value = bytes;
743
+ let unit = 0;
744
+ while (value >= 1024 && unit < units.length - 1) {
745
+ value /= 1024;
746
+ unit++;
747
+ }
748
+ return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${units[unit]}`;
749
+ }
750
+ function cacheLine(m) {
751
+ const why = m.partial ? 'left by a stopped clone' : m.problem;
752
+ const note = why === null ? '' : ` (${why})`;
753
+ // A URL, directory name or git message is outside text: no terminal controls.
754
+ return (stripControls(`${sizeText(m.bytes).padStart(9)} ${m.url ?? '(no origin)'} ${m.dir}${note}`) +
755
+ '\n');
756
+ }
757
+ function runCache(argv, ctx) {
758
+ const name = `${NAME} cache`;
759
+ const parsed = cacheCli().parse(argv, ctx.io);
760
+ if (parsed.status === 'help')
761
+ return EXIT.ok;
762
+ if (parsed.status === 'error')
763
+ return usageError(ctx, name, parsed.message);
764
+ const v = parsed.values;
765
+ const [verb = 'list', ...urls] = parsed.positionals;
766
+ const dir = mirrorCache(ctx);
767
+ const json = v.output === 'json';
768
+ if (verb === 'list') {
769
+ if (urls.length > 0 || v.all)
770
+ return usageError(ctx, name, 'list takes no URLs and no --all');
771
+ return cacheList(ctx, dir, json);
772
+ }
773
+ if (verb === 'remove') {
774
+ const hasUrls = urls.length > 0;
775
+ if (hasUrls === v.all) {
776
+ return usageError(ctx, name, 'remove takes URLs or --all, not both and not neither');
777
+ }
778
+ return cacheRemove(ctx, name, dir, v.all ? null : urls, json);
779
+ }
780
+ return usageError(ctx, name, `unknown verb ${JSON.stringify(verb)}; use list or remove`);
781
+ }
782
+ function cacheList(ctx, dir, json) {
783
+ const mirrors = listMirrors(dir);
784
+ if (json) {
785
+ ctx.io.stdout(`${JSON.stringify({ cache: dir, mirrors }, null, 2)}\n`);
786
+ return EXIT.ok;
787
+ }
788
+ if (mirrors.length === 0) {
789
+ ctx.io.stdout(`no clones in ${dir}\n`);
790
+ return EXIT.ok;
791
+ }
792
+ for (const m of mirrors)
793
+ ctx.io.stdout(cacheLine(m));
794
+ const total = mirrors.reduce((sum, m) => sum + m.bytes, 0);
795
+ const count = `${mirrors.length} clone${mirrors.length === 1 ? '' : 's'}`;
796
+ ctx.io.stdout(`${count}, ${sizeText(total)}, in ${dir}\n`);
797
+ return EXIT.ok;
798
+ }
799
+ /**
800
+ * The clones to remove: those of `urls`, or every entry when `urls` is null,
801
+ * each with the URL to name it by (credentials removed), and the URLs that
802
+ * have none.
803
+ */
804
+ function cacheTargets(dir, urls) {
805
+ if (urls === null) {
806
+ const targets = listMirrors(dir).map((m) => ({ dir: m.dir, label: m.url ?? '(no origin)' }));
807
+ return { targets, missing: [] };
808
+ }
809
+ const targets = [];
810
+ const missing = [];
811
+ for (const url of urls) {
812
+ const shown = redactUrl(url);
813
+ const found = cachedMirrorOf(url, dir);
814
+ if (found === null)
815
+ missing.push(shown);
816
+ else
817
+ targets.push({ dir: found, label: shown });
818
+ }
819
+ return { targets, missing };
820
+ }
821
+ /**
822
+ * Remove the clones of `urls`, or every entry when `urls` is null. A URL with
823
+ * no clone, or a clone that cannot be removed, is reported and the rest still
824
+ * go; either makes the exit 1.
825
+ */
826
+ function cacheRemove(ctx, name, dir, urls, json) {
827
+ const { targets, missing } = cacheTargets(dir, urls);
828
+ const removed = [];
829
+ const failed = [];
830
+ for (const t of targets) {
831
+ try {
832
+ rmSync(t.dir, { recursive: true, force: true });
833
+ removed.push(t.dir);
834
+ if (!json)
835
+ ctx.io.stdout(stripControls(`removed ${t.label} ${t.dir}`) + '\n');
836
+ }
837
+ catch (err) {
838
+ const error = err instanceof Error ? err.message : String(err);
839
+ failed.push({ dir: t.dir, error });
840
+ ctx.io.stderr(stripControls(`${name}: cannot remove ${t.label} ${t.dir}: ${error}`) + '\n');
841
+ }
842
+ }
843
+ for (const url of missing)
844
+ ctx.io.stderr(stripControls(`${name}: no clone of ${url}`) + '\n');
845
+ if (json) {
846
+ ctx.io.stdout(`${JSON.stringify({ cache: dir, removed, missing, failed }, null, 2)}\n`);
847
+ }
848
+ else if (targets.length === 0 && missing.length === 0) {
849
+ ctx.io.stdout(`no clones in ${dir}\n`);
850
+ }
851
+ return missing.length > 0 || failed.length > 0 ? EXIT.error : EXIT.ok;
852
+ }
717
853
  function emptyReceipt(plan, now, dryRun) {
718
854
  const at = now.toISOString();
719
855
  return {
@@ -752,6 +888,8 @@ export function main(argv, ctx, deps = {}) {
752
888
  return runPlan(rest, c, status);
753
889
  if (sub === 'apply')
754
890
  return runApply(rest, c, deps, status);
891
+ if (sub === 'cache')
892
+ return runCache(rest, c);
755
893
  if (sub === 'status')
756
894
  return runStatus(rest, c, status);
757
895
  return runStatus(argv, c, status);
@@ -53,3 +53,27 @@ export declare function mirrorProblem(dir: string, url?: string, opts?: {
53
53
  export declare function ensureMirror(url: string, cacheDir: string, opts?: {
54
54
  git?: CommandRunner | undefined;
55
55
  }): string;
56
+ /** One entry of the mirror cache, as `cache list` reports it. */
57
+ export interface CachedMirror {
58
+ /** The entry's directory. */
59
+ dir: string;
60
+ /** The URL it was cloned from, credentials removed; null when there is none to read. */
61
+ url: string | null;
62
+ /** Bytes on disk, every file under the directory counted. */
63
+ bytes: number;
64
+ /** A clone left behind by a run that stopped before it finished. */
65
+ partial: boolean;
66
+ /** What {@link mirrorProblem} found; null for a usable mirror and for a partial clone. */
67
+ problem: string | null;
68
+ }
69
+ /** Every entry under `cacheDir`, sorted by directory; none when the cache does not exist. */
70
+ export declare function listMirrors(cacheDir: string, opts?: {
71
+ git?: CommandRunner | undefined;
72
+ }): CachedMirror[];
73
+ /**
74
+ * The mirror directory `url` would use under `cacheDir`, when there is one;
75
+ * null when there is none. The lookup is by name, the same one
76
+ * {@link ensureMirror} clones to, so the URL must be the one the sweep was
77
+ * given, credentials included.
78
+ */
79
+ export declare function cachedMirrorOf(url: string, cacheDir: string): string | null;
@@ -13,7 +13,7 @@
13
13
  * date.
14
14
  */
15
15
  import { createHash } from 'node:crypto';
16
- import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
16
+ import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { Git, parseRemoteRepo } from '@osdlc/typed-git';
19
19
  /** A remote URL that cannot be mirrored, or a mirror that cannot be made or reused. */
@@ -121,3 +121,45 @@ export function ensureMirror(url, cacheDir, opts = {}) {
121
121
  }
122
122
  return dir;
123
123
  }
124
+ const PARTIAL = /\.partial-\d+$/;
125
+ /** Bytes under `path`, symlinks counted as themselves, not followed. */
126
+ function sizeOf(path) {
127
+ const stat = lstatSync(path, { throwIfNoEntry: false });
128
+ if (stat === undefined)
129
+ return 0;
130
+ if (!stat.isDirectory())
131
+ return stat.size;
132
+ let total = 0;
133
+ for (const name of readdirSync(path))
134
+ total += sizeOf(join(path, name));
135
+ return total;
136
+ }
137
+ /** Every entry under `cacheDir`, sorted by directory; none when the cache does not exist. */
138
+ export function listMirrors(cacheDir, opts = {}) {
139
+ if (!existsSync(cacheDir))
140
+ return [];
141
+ return readdirSync(cacheDir, { withFileTypes: true })
142
+ .filter((entry) => entry.isDirectory())
143
+ .map((entry) => entry.name)
144
+ .sort()
145
+ .map((name) => {
146
+ const dir = join(cacheDir, name);
147
+ const bytes = sizeOf(dir);
148
+ if (PARTIAL.test(name))
149
+ return { dir, url: null, bytes, partial: true, problem: null };
150
+ const problem = mirrorProblem(dir, undefined, opts);
151
+ const urls = new Git(dir, { runner: opts.git }).try.remoteGetUrls('origin');
152
+ const url = urls.ok && urls.value.length > 0 ? redactUrl(urls.value[0]) : null;
153
+ return { dir, url, bytes, partial: false, problem };
154
+ });
155
+ }
156
+ /**
157
+ * The mirror directory `url` would use under `cacheDir`, when there is one;
158
+ * null when there is none. The lookup is by name, the same one
159
+ * {@link ensureMirror} clones to, so the URL must be the one the sweep was
160
+ * given, credentials included.
161
+ */
162
+ export function cachedMirrorOf(url, cacheDir) {
163
+ const dir = join(cacheDir, mirrorName(url));
164
+ return existsSync(dir) ? dir : null;
165
+ }
package/dist/render.d.ts CHANGED
@@ -94,7 +94,7 @@ export interface ApplySummary {
94
94
  export declare function summarizeReceipt(receipt: Receipt): ApplySummary;
95
95
  /** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
96
96
  export declare function rowsActedOn(summary: ApplySummary): number;
97
- /** The closing lines of an apply run: what it deleted, dropped and could not finish. */
97
+ /** The closing lines of an apply run: what it deleted, skipped and could not finish. */
98
98
  export declare function renderApplySummary(receipt: Receipt): string[];
99
99
  /** The word a preview shows for a step: what it does to the row. */
100
100
  export declare const STEP_VERB: Record<Step['action'], string>;
package/dist/render.js CHANGED
@@ -352,7 +352,27 @@ export function rowsActedOn(summary) {
352
352
  return d.worktree + d.local_branch + d.remote_branch + summary.pruned;
353
353
  }
354
354
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
355
- /** The closing lines of an apply run: what it deleted, dropped and could not finish. */
355
+ /**
356
+ * How to include a skipped row next time, for the reasons a person can act
357
+ * on. A `not-authorized` detail is `needs <risk>, <risk>` (see `recheck`).
358
+ */
359
+ function dropHints(receipt) {
360
+ const risks = new Set();
361
+ for (const d of receipt.dropped) {
362
+ if (d.reason !== 'not-authorized')
363
+ continue;
364
+ for (const risk of d.detail.replace(/^needs /, '').split(', '))
365
+ if (risk)
366
+ risks.add(risk);
367
+ }
368
+ return {
369
+ 'needs-review': 'name with --row',
370
+ ...(risks.size > 0
371
+ ? { 'not-authorized': [...risks].map((r) => `--allow ${r}`).join(', ') }
372
+ : {}),
373
+ };
374
+ }
375
+ /** The closing lines of an apply run: what it deleted, skipped and could not finish. */
356
376
  export function renderApplySummary(receipt) {
357
377
  const s = summarizeReceipt(receipt);
358
378
  const dry = receipt.status === 'dry-run';
@@ -372,7 +392,12 @@ export function renderApplySummary(receipt) {
372
392
  const dropped = Object.entries(s.dropped);
373
393
  if (dropped.length > 0) {
374
394
  const total = dropped.reduce((n, [, c]) => n + c, 0);
375
- lines.push(`dropped ${total}: ${dropped.map(([reason, c]) => `${c} ${reason}`).join(', ')}`);
395
+ const hints = dropHints(receipt);
396
+ const counts = dropped.map(([reason, c]) => {
397
+ const hint = hints[reason];
398
+ return hint === undefined ? `${c} ${reason}` : `${c} ${reason} (${hint})`;
399
+ });
400
+ lines.push(`skipped ${total}, not deleted: ${counts.join(', ')}`);
376
401
  }
377
402
  if (s.failed > 0) {
378
403
  lines.push(`stopped at a failed step; ${plural(s.notRun, 'row', 'rows')} not run`);
@@ -421,7 +446,9 @@ export function renderPreview(receipt, style = PLAIN) {
421
446
  for (const s of receipt.steps.filter((s) => s.repo === repo)) {
422
447
  const verb = STEP_VERB[s.action];
423
448
  const color = actionStyle(style, verb === 'unlock' ? 'review' : verb);
424
- lines.push(s.row === null ? ` ${color(verb)}` : ` ${color(pad(verb, 6))} ${row(s.row)}`);
449
+ lines.push(s.row === null
450
+ ? ` ${color(pad(verb, 6))} worktree records`
451
+ : ` ${color(pad(verb, 6))} ${row(s.row)}`);
425
452
  lines.push(` ${style.muted(stripControls(s.command))}`);
426
453
  }
427
454
  for (const d of receipt.dropped.filter((d) => d.repo === repo)) {
@@ -456,7 +483,7 @@ export function renderReceipt(receipt, receiptPath, style = PLAIN, opts = {}) {
456
483
  // A run shown live has printed its dropped rows and steps already.
457
484
  for (const d of opts.steps === false ? [] : receipt.dropped) {
458
485
  const shown = stripControls(`${d.category}:${d.id} — ${d.reason} (${d.detail})`);
459
- lines.push(`${style.review('dropped ')}${shown}`);
486
+ lines.push(`${style.review('skipped ')}${shown}`);
460
487
  }
461
488
  for (const s of opts.steps === false ? [] : receipt.steps) {
462
489
  const what = s.row === null ? s.command : `${s.row.category}:${s.row.id} — ${s.command}`;
@@ -13,8 +13,9 @@ const BAR = 10;
13
13
  const TOTAL_BAR = 30;
14
14
  function label(step) {
15
15
  const verb = STEP_VERB[step.action];
16
+ // The closing prune belongs to no row: it clears the repository's stale worktree records.
16
17
  if (step.row === null)
17
- return verb;
18
+ return stripControls(`${verb.padEnd(6)} worktree records in ${step.repo}`);
18
19
  return stripControls(`${verb.padEnd(6)} ${step.row.category} ${step.row.id}`);
19
20
  }
20
21
  /**
package/dist/sweep.js CHANGED
@@ -99,12 +99,27 @@ export function sweepRepo(path, opts = {}) {
99
99
  items.push(...worktreeItems);
100
100
  let localBranchNames = new Set();
101
101
  if (scope.has('local-branches') || scope.has('remote-branches')) {
102
- const pinned = only === undefined
103
- ? worktreeBranches
104
- : new Set(worktreeItems
105
- .filter((item) => !item.deletable)
106
- .map((item) => String(item.facts['branch'])));
102
+ // A branch whose worktree goes in the same plan is judged on its own:
103
+ // apply removes worktrees before it deletes branches. A full sweep frees
104
+ // the branch of a safe-to-delete worktree; a branch-scoped sweep frees
105
+ // the named branch when its worktree is deletable at all.
106
+ const freed = new Map();
107
+ if (scope.has('worktrees')) {
108
+ for (const item of worktreeItems) {
109
+ const branch = item.facts['branch'];
110
+ const goes = item.deletable && (only !== undefined || item.classification === 'safe-to-delete');
111
+ if (goes && typeof branch === 'string')
112
+ freed.set(branch, item.id);
113
+ }
114
+ }
115
+ const pinned = new Set([...worktreeBranches].filter((name) => !freed.has(name)));
107
116
  const localItems = gatherLocalBranches(repo, prs, pinned, git, only, ticker('local-branches'));
117
+ // Apply drops a freed branch unless the same run removes its worktree.
118
+ for (const item of localItems) {
119
+ const worktree = freed.get(item.id);
120
+ if (worktree !== undefined)
121
+ item.facts['worktree'] = worktree;
122
+ }
108
123
  localBranchNames = new Set(localItems.map((item) => item.id));
109
124
  if (scope.has('local-branches'))
110
125
  items.push(...localItems);
@@ -1,2 +1,2 @@
1
1
  /** This package's version, from its package.json when it was built. */
2
- export declare const VERSION = "0.14.0";
2
+ export declare const VERSION = "0.15.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.14.0';
3
+ export const VERSION = '0.15.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/repo-cleaner",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "What has been merged into the default branch, and what can go away. Inventories local branches, worktrees and remote branches across one repository or many, classifying each with the evidence behind the verdict and the command that would act on it. Plans and applies approved deletes, compare-and-swap.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "sksizer",
@@ -49,11 +49,11 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "zod": "^4.6.6",
52
+ "@osdlc/cli-tool": "^0.16.0",
52
53
  "@osdlc/command-seam": "^0.14.0",
53
- "@osdlc/path-guard": "^0.3.1",
54
54
  "@osdlc/typed-gh": "^0.14.1",
55
- "@osdlc/cli-tool": "^0.14.0",
56
55
  "@osdlc/typed-git": "^0.12.0",
56
+ "@osdlc/path-guard": "^0.3.1",
57
57
  "@osdlc/ux": "^0.1.1"
58
58
  },
59
59
  "devDependencies": {
@@ -63,8 +63,8 @@
63
63
  "prettier": "3.9.6",
64
64
  "typescript": "~6.0.3",
65
65
  "vitest": "^5.0.5",
66
- "@osdlc/tsconfig": "^0.4.1",
67
- "@osdlc/prettier-config": "^0.3.1"
66
+ "@osdlc/prettier-config": "^0.3.1",
67
+ "@osdlc/tsconfig": "^0.4.1"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "cli-tool-stamp-version && tsc -p tsconfig.build.json",