@osdlc/repo-cleaner 0.10.1 → 0.11.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,19 @@
1
1
  # @osdlc/repo-cleaner
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 14d4351: A worktree row whose directory is gone, or is no longer a checkout, now reads `prune` instead of `delete` in the report, and `summary` counts those rows in a `prune` column (and a `prune` count in each JSON bucket, a part of `safe-to-delete`). The row's `action` is `prune`.
8
+
9
+ New `--remote-url <url>` on every sweeping command: sweep a remote's branches with no checkout of your own. The URL is cloned once (`--no-checkout`) under the cache directory and reused; deletes are pushed from that clone, so a plan written with `--remote-url` can be applied later from anywhere.
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [14d4351]
14
+ - Updated dependencies [dd2265e]
15
+ - @osdlc/cli-tool@0.13.0
16
+
3
17
  ## 0.10.1
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -205,7 +205,7 @@ worktree, the primary or caller's worktree) has `deletable: false`.
205
205
 
206
206
  ```bash
207
207
  repo-cleaner [status] # the report: what is merged, what can go (the default)
208
- repo-cleaner summary # counts per repository: total, merged, delete, review, keep
208
+ repo-cleaner summary # counts per repository: total, merged, delete, prune, review, keep
209
209
  repo-cleaner plan # the deletable rows as a plan, to stdout or -o <file>
210
210
  repo-cleaner apply # re-check, show the steps, ask, delete
211
211
  ```
@@ -215,14 +215,21 @@ All four take the same paths and sweep options.
215
215
  ```console
216
216
  $ repo-cleaner summary
217
217
  /Users/me/work/api (acme/api, default main, origin)
218
- total merged delete review keep
219
- local branches 12 8 6 1 5
220
- worktrees 4 3 2 1 1
221
- remote branches 40 31 30 6 4
218
+ total merged delete prune review keep
219
+ local branches 12 8 6 0 1 5
220
+ worktrees 4 3 1 1 1 1
221
+ remote branches 40 31 30 0 6 4
222
222
 
223
- 38 to delete, 8 to review, across 1 repository.
223
+ 37 to delete, 1 to prune, 8 to review, across 1 repository.
224
224
  ```
225
225
 
226
+ `prune` counts worktree rows whose directory is gone, or is no longer a
227
+ checkout: deleting one runs `git worktree prune` to drop the stale link, and
228
+ a directory that is still there stays. The report labels those rows `prune`
229
+ rather than `delete`. In `--output json` they are `safe-to-delete` with
230
+ `action: "prune"`, and each summary bucket has a `prune` count, a part of its
231
+ `safe-to-delete` count.
232
+
226
233
  `merged` counts rows whose work is on the default branch: by ancestry, by a
227
234
  merged PR at the branch's head, or by a patch match. A branch checked out in a
228
235
  worktree counts as merged by ancestry or PR alone. The default branch and the
@@ -258,6 +265,34 @@ An entry that resolves to nothing is reported rather than dropped. A worklist
258
265
  that silently sweeps four of the five repositories it was given is worse than
259
266
  one that says which it could not find.
260
267
 
268
+ ### A remote with no checkout
269
+
270
+ ```bash
271
+ repo-cleaner --remote-url git@github.com:acme/api.git
272
+ repo-cleaner apply --remote-url git@github.com:acme/api.git
273
+ ```
274
+
275
+ `--remote-url <url>` sweeps a remote's branches when you have no checkout of
276
+ it. It clones the URL once, with `--no-checkout`, into
277
+ `repo-cleaner/remotes/` under the cache directory (`~/Library/Caches` on
278
+ macOS, `$XDG_CACHE_HOME` or `~/.cache` on Linux, `REPO_CLEANER_CACHE_DIR`
279
+ to move it), and sweeps that clone. Later runs reuse the clone and fetch. Only remote branches
280
+ are swept: the clone's own branches and worktree are not yours. Deletes are
281
+ pushed to the URL from the clone, compare-and-swap as always.
282
+
283
+ The clone is kept, not deleted after the run, so a plan written with
284
+ `--remote-url` can be applied later from anywhere: the plan names the clone's
285
+ directory. Remove the directory to drop it. The clone is a full one, not
286
+ `--filter=blob:none`: the patch check reads file contents, and a partial
287
+ clone fetches them one branch at a time.
288
+
289
+ The flag repeats for several remotes. It does not combine with paths,
290
+ `--branch` or `--plan`, `--only` may only name `remotes`, and an empty URL is
291
+ refused. A clone is reused only while its `origin` fetches from that URL and
292
+ pushes to it alone; `apply --plan` checks the same of a plan's clone before
293
+ deleting. The clone's directory name and every message carry the URL with
294
+ its credentials, query and fragment removed.
295
+
261
296
  ## Options
262
297
 
263
298
  Every command that sweeps takes these:
@@ -267,6 +302,7 @@ Every command that sweeps takes these:
267
302
  | `--only <list>` | `worktrees`, `branches`, `remotes` (default: all three) |
268
303
  | `--branch <name\|HEAD>` | One branch of this repository: its worktree, local and remote branch. Refuses the default branch |
269
304
  | `--remote <name>` | Remote to measure against (default `origin`) |
305
+ | `--remote-url <url>` | Sweep this remote's branches with no checkout of your own (see [A remote with no checkout](#a-remote-with-no-checkout)); repeatable |
270
306
  | `--no-gh` | Classify on git evidence alone |
271
307
  | `--no-fetch` | Skip the fetch; the header then says `not fetched` |
272
308
 
package/dist/cli.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Four verbs over one set of sweep filters:
5
5
  *
6
6
  * status (the default) inventory and classify; read-only but for the fetch
7
- * summary the same sweep as counts: totals, merged, delete, review, keep
7
+ * summary the same sweep as counts: totals, merged, delete, prune, review, keep
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
  *
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Four verbs over one set of sweep filters:
5
5
  *
6
6
  * status (the default) inventory and classify; read-only but for the fetch
7
- * summary the same sweep as counts: totals, merged, delete, review, keep
7
+ * summary the same sweep as counts: totals, merged, delete, prune, review, keep
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
  *
@@ -21,12 +21,14 @@
21
21
  * 10 nothing to sweep: no entry named a repository
22
22
  * 11 apply stopped at a failed step; the receipt says which
23
23
  */
24
- import { readSync } from 'node:fs';
25
- import { basename, dirname, join, resolve } from 'node:path';
24
+ import { existsSync, readSync, realpathSync } from 'node:fs';
25
+ import { basename, dirname, join, resolve, sep } from 'node:path';
26
26
  import { isatty } from 'node:tty';
27
- import { EXIT, askYesNo, canPrompt, defineCli } from '@osdlc/cli-tool';
27
+ import { EXIT, askYesNo, canPrompt, defineCli, systemDirs } from '@osdlc/cli-tool';
28
28
  import { Git } from '@osdlc/typed-git';
29
29
  import { resolveBranch } from "./branches.js";
30
+ import { VENDOR_DIRS } from "./devlink.js";
31
+ import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
30
32
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
31
33
  import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
32
34
  import { RECEIPT_VERSION } from "./receipt.js";
@@ -59,6 +61,13 @@ const SWEEP_FLAGS = {
59
61
  valueName: 'name',
60
62
  help: 'remote to measure against (default: origin)',
61
63
  },
64
+ remoteUrl: {
65
+ kind: 'list',
66
+ valueName: 'url',
67
+ default: [],
68
+ commaSeparated: false,
69
+ help: 'sweep the branches of this remote with no checkout of your own, through a clone kept in the cache directory; repeatable',
70
+ },
62
71
  gh: { kind: 'boolean', negatable: true, help: 'classify on git evidence alone' },
63
72
  fetch: {
64
73
  kind: 'boolean',
@@ -98,7 +107,7 @@ const statusCli = () => defineCli({
98
107
  },
99
108
  epilogue: `commands:
100
109
  ${NAME} [status] this report (the default)
101
- ${NAME} summary counts per repository: total, merged, delete, review, keep
110
+ ${NAME} summary counts per repository: total, merged, delete, prune, review, keep
102
111
  ${NAME} plan write the deletable rows as a plan
103
112
  ${NAME} apply re-check, show the steps, ask, delete
104
113
  ${NAME} dev run ${NAME} from a development checkout
@@ -109,7 +118,7 @@ swept as ./<name>.`,
109
118
  const summaryCli = () => defineCli({
110
119
  name: `${NAME} summary`,
111
120
  summary: 'Count local branches, worktrees and remote branches per repository: how many there are, ' +
112
- 'how many are merged into the default branch, and how many would be deleted, reviewed or kept.',
121
+ 'how many are merged into the default branch, and how many would be deleted, pruned, reviewed or kept.',
113
122
  positionals: PATHS,
114
123
  flags: { ...SWEEP_FLAGS, output: OUTPUT },
115
124
  epilogue: 'merged: the work is on the default branch. Rows kept for structure (the default ' +
@@ -209,6 +218,10 @@ function failure(ctx, err) {
209
218
  ctx.io.stderr(`${NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
210
219
  return EXIT.error;
211
220
  }
221
+ /** Where `--remote-url` keeps its clones. */
222
+ function mirrorCache(ctx) {
223
+ return systemDirs(VENDOR_DIRS, { env: ctx.env }).standalone(NAME).cache().app('remotes').path;
224
+ }
212
225
  /** Validate the sweep flags and run the sweep, or answer with the exit code. */
213
226
  function sweep(name, v, paths, ctx, status) {
214
227
  const only = v.only?.filter((s) => s !== '');
@@ -221,6 +234,26 @@ function sweep(name, v, paths, ctx, status) {
221
234
  const message = '--branch sweeps this repository only; drop the paths';
222
235
  return { kind: 'exit', code: usageError(ctx, name, message) };
223
236
  }
237
+ // An empty URL is refused, not dropped: `--remote-url "$UNSET"` must not
238
+ // fall back to sweeping the checkout you are standing in.
239
+ if (v.remoteUrl.some((u) => u.trim() === '')) {
240
+ return {
241
+ kind: 'exit',
242
+ code: usageError(ctx, name, '--remote-url needs a URL; got an empty one'),
243
+ };
244
+ }
245
+ const urls = v.remoteUrl;
246
+ if (urls.length > 0) {
247
+ const conflict = paths.length > 0
248
+ ? '--remote-url sweeps a remote, not a checkout; drop the paths'
249
+ : v.branch !== undefined
250
+ ? '--branch reads the checkout you are in; it does not combine with --remote-url'
251
+ : (only ?? []).some((s) => s !== 'remotes')
252
+ ? '--remote-url has remote branches only; drop --only or pass --only remotes'
253
+ : null;
254
+ if (conflict !== null)
255
+ return { kind: 'exit', code: usageError(ctx, name, conflict) };
256
+ }
224
257
  let branch;
225
258
  try {
226
259
  branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
@@ -241,9 +274,25 @@ function sweep(name, v, paths, ctx, status) {
241
274
  ...(only === undefined ? {} : { scope: only.map((s) => ONLY[s]) }),
242
275
  ...(v.remote === undefined ? {} : { remote: v.remote }),
243
276
  };
277
+ let entries = paths.length > 0 ? [...paths] : [ctx.cwd];
278
+ if (urls.length > 0) {
279
+ const cache = mirrorCache(ctx);
280
+ try {
281
+ entries = urls.map((url, i) => {
282
+ if (v.progress)
283
+ status.update(`${NAME}: cloning remote ${i + 1}/${urls.length}`);
284
+ return ensureMirror(url, cache);
285
+ });
286
+ }
287
+ catch (err) {
288
+ status.clear();
289
+ return { kind: 'exit', code: failure(ctx, err) };
290
+ }
291
+ options.scope = ['remote-branches'];
292
+ }
244
293
  if (v.progress)
245
294
  status.update(`${NAME}: finding repositories`);
246
- const result = sweepRepos(paths.length > 0 ? paths : [ctx.cwd], options);
295
+ const result = sweepRepos(entries, options);
247
296
  status.clear();
248
297
  if (branch !== undefined && result.repos.length > 1) {
249
298
  const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
@@ -407,6 +456,7 @@ function applyConflict(v, paths) {
407
456
  v.only !== undefined ? '--only' : null,
408
457
  v.branch !== undefined ? '--branch' : null,
409
458
  v.remote !== undefined ? '--remote' : null,
459
+ v.remoteUrl.length > 0 ? '--remote-url' : null,
410
460
  ].filter((f) => f !== null);
411
461
  if (sweepFlags.length > 0)
412
462
  return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
@@ -444,6 +494,17 @@ function runApply(argv, ctx, deps, status) {
444
494
  if (typeof loaded === 'number')
445
495
  return loaded;
446
496
  const { plan, source, receiptPath } = loaded;
497
+ // A plan from `--remote-url` names a clone in the cache. Its deletes are
498
+ // pushed from there, so the clone must still push only where it fetches from.
499
+ const cacheDir = mirrorCache(ctx);
500
+ const cache = (existsSync(cacheDir) ? realpathSync(cacheDir) : cacheDir) + sep;
501
+ for (const repo of plan.repos) {
502
+ if (!repo.root.startsWith(cache))
503
+ continue;
504
+ const problem = mirrorProblem(repo.root);
505
+ if (problem !== null)
506
+ return failure(ctx, new Error(`${problem}; refusing to apply`));
507
+ }
447
508
  const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
448
509
  const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
449
510
  const print = (receipt, shownPath) => {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Sweep a remote with no checkout of your own: a clone of it, kept in
3
+ * repo-cleaner's cache directory, stands in for the checkout.
4
+ *
5
+ * The clone is `--no-checkout`, so no files are written outside `.git`. It is
6
+ * not a partial (`--filter=blob:none`) clone: the patch comparison that
7
+ * finds squash-merged branches reads file contents, and a partial clone
8
+ * fetches them over the network one comparison at a time, which made a sweep
9
+ * of a few hundred branches take minutes instead of seconds. It is kept
10
+ * rather than thrown away so a plan written from it can be applied later: the
11
+ * plan names the clone's directory, and `apply` re-sweeps and pushes deletes
12
+ * from there. A later run reuses it, and the sweep's fetch brings it up to
13
+ * date.
14
+ */
15
+ import type { CommandRunner } from '@osdlc/command-seam';
16
+ /** A remote URL that cannot be mirrored, or a mirror that cannot be made or reused. */
17
+ export declare class RemoteMirrorError extends Error {
18
+ constructor(message: string);
19
+ }
20
+ /**
21
+ * `text` with every URL's credentials removed: the user-info before `@` in a
22
+ * `scheme://` URL, and any query string or fragment. For a message or a name
23
+ * that must not carry a token.
24
+ */
25
+ export declare function redactUrl(text: string): string;
26
+ /**
27
+ * The directory name for `url`'s mirror: readable `host-owner-repo`, then a
28
+ * hash of the whole URL so two URLs never share a mirror. The readable part
29
+ * is read from the URL with its credentials, query and fragment removed, so a
30
+ * token never reaches the name.
31
+ */
32
+ export declare function mirrorName(url: string): string;
33
+ /**
34
+ * Why `dir` is not a mirror of `url` — or of whatever it was cloned from, when
35
+ * `url` is omitted — or null when it is. A mirror's `origin` has one fetch URL
36
+ * and pushes to that URL alone, so a delete cannot be sent elsewhere.
37
+ */
38
+ export declare function mirrorProblem(dir: string, url?: string, opts?: {
39
+ git?: CommandRunner | undefined;
40
+ }): string | null;
41
+ /**
42
+ * The directory of `url`'s mirror under `cacheDir`, cloned on first use. An
43
+ * existing mirror is reused as it is, once {@link mirrorProblem} finds
44
+ * nothing wrong with it; the sweep's own fetch brings it up to date. A first
45
+ * clone goes to a sibling directory and is renamed into place, so a clone
46
+ * that fails leaves nothing behind, and a run that loses a race to clone the
47
+ * same URL uses the winner's clone. Messages carry the URL with its
48
+ * credentials removed.
49
+ *
50
+ * @throws RemoteMirrorError when `url` is empty or looks like an option, when
51
+ * the clone fails, or when the directory is not a mirror of `url`.
52
+ */
53
+ export declare function ensureMirror(url: string, cacheDir: string, opts?: {
54
+ git?: CommandRunner | undefined;
55
+ }): string;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Sweep a remote with no checkout of your own: a clone of it, kept in
3
+ * repo-cleaner's cache directory, stands in for the checkout.
4
+ *
5
+ * The clone is `--no-checkout`, so no files are written outside `.git`. It is
6
+ * not a partial (`--filter=blob:none`) clone: the patch comparison that
7
+ * finds squash-merged branches reads file contents, and a partial clone
8
+ * fetches them over the network one comparison at a time, which made a sweep
9
+ * of a few hundred branches take minutes instead of seconds. It is kept
10
+ * rather than thrown away so a plan written from it can be applied later: the
11
+ * plan names the clone's directory, and `apply` re-sweeps and pushes deletes
12
+ * from there. A later run reuses it, and the sweep's fetch brings it up to
13
+ * date.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { Git, parseRemoteRepo } from '@osdlc/typed-git';
19
+ /** A remote URL that cannot be mirrored, or a mirror that cannot be made or reused. */
20
+ export class RemoteMirrorError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'RemoteMirrorError';
24
+ }
25
+ }
26
+ /** Characters kept in a directory name; anything else becomes `-`. */
27
+ const UNSAFE = /[^A-Za-z0-9._-]+/g;
28
+ /**
29
+ * `text` with every URL's credentials removed: the user-info before `@` in a
30
+ * `scheme://` URL, and any query string or fragment. For a message or a name
31
+ * that must not carry a token.
32
+ */
33
+ export function redactUrl(text) {
34
+ return text
35
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')
36
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^\s?#]*)[?#]\S*/gi, '$1');
37
+ }
38
+ /**
39
+ * The directory name for `url`'s mirror: readable `host-owner-repo`, then a
40
+ * hash of the whole URL so two URLs never share a mirror. The readable part
41
+ * is read from the URL with its credentials, query and fragment removed, so a
42
+ * token never reaches the name.
43
+ */
44
+ export function mirrorName(url) {
45
+ const hash = createHash('sha256').update(url).digest('hex').slice(0, 12);
46
+ const parsed = parseRemoteRepo(redactUrl(url));
47
+ const readable = parsed === null
48
+ ? 'remote'
49
+ : `${parsed.host}-${parsed.owner}-${parsed.repo}`.replace(UNSAFE, '-');
50
+ return `${readable.replace(/^[.-]+/, '')}-${hash}`;
51
+ }
52
+ /**
53
+ * Why `dir` is not a mirror of `url` — or of whatever it was cloned from, when
54
+ * `url` is omitted — or null when it is. A mirror's `origin` has one fetch URL
55
+ * and pushes to that URL alone, so a delete cannot be sent elsewhere.
56
+ */
57
+ export function mirrorProblem(dir, url, opts = {}) {
58
+ const git = new Git(dir, { runner: opts.git });
59
+ const fetch = git.try.remoteGetUrls('origin');
60
+ const push = git.try.remoteGetUrls('origin', { push: true });
61
+ if (!fetch.ok || !push.ok)
62
+ return `${dir} has no readable origin remote`;
63
+ const [from] = fetch.value;
64
+ if (fetch.value.length !== 1 || from === undefined) {
65
+ return `${dir} has ${fetch.value.length} origin URLs`;
66
+ }
67
+ if (url !== undefined && from !== url)
68
+ return `${dir} is a mirror of another URL`;
69
+ if (push.value.length !== 1 || push.value[0] !== from) {
70
+ return `${dir} pushes somewhere other than the URL it was cloned from`;
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * The directory of `url`'s mirror under `cacheDir`, cloned on first use. An
76
+ * existing mirror is reused as it is, once {@link mirrorProblem} finds
77
+ * nothing wrong with it; the sweep's own fetch brings it up to date. A first
78
+ * clone goes to a sibling directory and is renamed into place, so a clone
79
+ * that fails leaves nothing behind, and a run that loses a race to clone the
80
+ * same URL uses the winner's clone. Messages carry the URL with its
81
+ * credentials removed.
82
+ *
83
+ * @throws RemoteMirrorError when `url` is empty or looks like an option, when
84
+ * the clone fails, or when the directory is not a mirror of `url`.
85
+ */
86
+ export function ensureMirror(url, cacheDir, opts = {}) {
87
+ if (url === '' || url.startsWith('-')) {
88
+ throw new RemoteMirrorError(`not a remote URL: ${JSON.stringify(redactUrl(url))}`);
89
+ }
90
+ const shown = redactUrl(url);
91
+ const dir = join(cacheDir, mirrorName(url));
92
+ if (existsSync(dir)) {
93
+ const problem = mirrorProblem(dir, url, opts);
94
+ if (problem !== null) {
95
+ throw new RemoteMirrorError(`${problem}, not a mirror of ${shown}; remove the directory and run again`);
96
+ }
97
+ return dir;
98
+ }
99
+ mkdirSync(dirname(dir), { recursive: true });
100
+ const staging = `${dir}.partial-${process.pid}`;
101
+ rmSync(staging, { recursive: true, force: true });
102
+ try {
103
+ new Git(dirname(dir), { runner: opts.git }).rawChecked([
104
+ 'clone',
105
+ '--quiet',
106
+ '--no-checkout',
107
+ '--',
108
+ url,
109
+ staging,
110
+ ]);
111
+ renameSync(staging, dir);
112
+ }
113
+ catch (err) {
114
+ rmSync(staging, { recursive: true, force: true });
115
+ // Another run cloned the same URL first and won the rename: use its clone.
116
+ if (existsSync(dir) && mirrorProblem(dir, url, opts) === null)
117
+ return dir;
118
+ const detail = err.failure?.detail ??
119
+ (err instanceof Error ? err.message : String(err));
120
+ throw new RemoteMirrorError(`cannot clone ${shown}: ${redactUrl(detail.split(url).join(shown)).trim()}`);
121
+ }
122
+ return dir;
123
+ }
package/dist/render.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare function renderScan(result: SweepResult): string | null;
30
30
  export declare function renderSummary(result: SweepResult): string;
31
31
  /**
32
32
  * One repository as counts: a row per category with its total, how many are
33
- * merged, and how many the sweep would delete, review or keep.
33
+ * merged, and how many the sweep would delete, prune, review or keep.
34
34
  */
35
35
  export declare function renderCounts(sweep: RepoSweep): string;
36
36
  /** The whole run as counts: a table per repository, then the one-line summary. */
package/dist/render.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * find. JSON carries everything the text report elides, including the facts
7
7
  * behind every verdict.
8
8
  */
9
- import { emptyBucket, isUnfetched, summarize, } from "./types.js";
9
+ import { emptyBucket, isPrune, isUnfetched, summarize, } from "./types.js";
10
10
  import { hasBlockers } from "./worktrees.js";
11
11
  /** Order categories consistently, whatever order they were gathered in. */
12
12
  const CATEGORY_ORDER = ['local_branch', 'worktree', 'remote_branch'];
@@ -69,7 +69,9 @@ export function renderRepo(sweep, opts = {}) {
69
69
  const idWidth = Math.min(38, Math.max(...shown.map((item) => ellipsize(item.id, 38).length)));
70
70
  const categoryWidth = Math.max(...shown.map((item) => item.category.length));
71
71
  for (const item of shown) {
72
- const action = ACTION_LABEL[item.classification] ?? item.classification;
72
+ const action = isPrune(item)
73
+ ? 'prune'
74
+ : (ACTION_LABEL[item.classification] ?? item.classification);
73
75
  const note = requiresNote(item);
74
76
  lines.push(` ${pad(item.category, categoryWidth)} ${pad(ellipsize(item.id, 38), idWidth)} ${pad(action, 6)} ${item.reason}${note ? `; ${note}` : ''}`);
75
77
  // A blocked row has no commands; what to do about each blocker goes there instead.
@@ -86,6 +88,7 @@ function totals(result) {
86
88
  out.total += bucket.total;
87
89
  out.merged += bucket.merged;
88
90
  out['safe-to-delete'] += bucket['safe-to-delete'];
91
+ out.prune += bucket.prune;
89
92
  out['safe-to-keep'] += bucket['safe-to-keep'];
90
93
  out['needs-investigation'] += bucket['needs-investigation'];
91
94
  }
@@ -116,6 +119,10 @@ export function renderScan(result) {
116
119
  return line;
117
120
  return `${line} ${count(scan.depthLimited)} left unopened at the depth limit.`;
118
121
  }
122
+ /** The `safe-to-delete` rows that delete something, not the ones that only prune a stale link. */
123
+ function deleteOnly(bucket) {
124
+ return bucket['safe-to-delete'] - bucket.prune;
125
+ }
119
126
  /** The one-line verdict a person reads first, and often only. */
120
127
  export function renderSummary(result) {
121
128
  const counts = totals(result);
@@ -128,24 +135,26 @@ export function renderSummary(result) {
128
135
  tail.push(`${result.unresolved.length} ${result.unresolved.length === 1 ? 'entry' : 'entries'} unresolved`);
129
136
  }
130
137
  const suffix = tail.length > 0 ? ` (${tail.join(', ')})` : '';
131
- return `${counts['safe-to-delete']} to delete, ${counts['needs-investigation']} to review, across ${repos}${suffix}.`;
138
+ const prune = counts.prune > 0 ? `${counts.prune} to prune, ` : '';
139
+ return `${deleteOnly(counts)} to delete, ${prune}${counts['needs-investigation']} to review, across ${repos}${suffix}.`;
132
140
  }
133
141
  const COUNT_LABEL = {
134
142
  local_branch: 'local branches',
135
143
  worktree: 'worktrees',
136
144
  remote_branch: 'remote branches',
137
145
  };
146
+ /** A count table's columns. `delete` leaves out the rows `prune` counts, so each row is in one of delete, prune, review and keep. */
138
147
  const COUNT_COLUMNS = [
139
- 'total',
140
- 'merged',
141
- 'safe-to-delete',
142
- 'needs-investigation',
143
- 'safe-to-keep',
148
+ { heading: 'total', of: (b) => b.total },
149
+ { heading: 'merged', of: (b) => b.merged },
150
+ { heading: 'delete', of: deleteOnly },
151
+ { heading: 'prune', of: (b) => b.prune },
152
+ { heading: 'review', of: (b) => b['needs-investigation'] },
153
+ { heading: 'keep', of: (b) => b['safe-to-keep'] },
144
154
  ];
145
- const COUNT_HEADINGS = ['total', 'merged', 'delete', 'review', 'keep'];
146
155
  /**
147
156
  * One repository as counts: a row per category with its total, how many are
148
- * merged, and how many the sweep would delete, review or keep.
157
+ * merged, and how many the sweep would delete, prune, review or keep.
149
158
  */
150
159
  export function renderCounts(sweep) {
151
160
  const lines = [repoHeader(sweep)];
@@ -156,10 +165,10 @@ export function renderCounts(sweep) {
156
165
  const buckets = summarize(sweep.items);
157
166
  const labelWidth = Math.max(...Object.values(COUNT_LABEL).map((l) => l.length));
158
167
  const cell = (text, heading) => text.padStart(Math.max(heading.length, 5));
159
- lines.push(` ${' '.repeat(labelWidth)} ${COUNT_HEADINGS.map((h) => cell(h, h)).join(' ')}`);
168
+ lines.push(` ${' '.repeat(labelWidth)} ${COUNT_COLUMNS.map(({ heading }) => cell(heading, heading)).join(' ')}`);
160
169
  for (const category of CATEGORY_ORDER) {
161
170
  const bucket = buckets[category] ?? emptyBucket();
162
- const cells = COUNT_COLUMNS.map((col, i) => cell(count(bucket[col]), COUNT_HEADINGS[i]));
171
+ const cells = COUNT_COLUMNS.map(({ heading, of }) => cell(count(of(bucket)), heading));
163
172
  lines.push(` ${pad(COUNT_LABEL[category], labelWidth)} ${cells.join(' ')}`);
164
173
  }
165
174
  return lines.join('\n');
package/dist/types.d.ts CHANGED
@@ -155,12 +155,16 @@ export interface SummaryBucket {
155
155
  /** Rows whose work is on the default branch (`facts.merged`). Rows kept for structure are not judged. */
156
156
  merged: number;
157
157
  'safe-to-delete': number;
158
+ /** The `safe-to-delete` rows that only prune a stale worktree link (`action: 'prune'`); the directory, if any, stays. */
159
+ prune: number;
158
160
  'safe-to-keep': number;
159
161
  'needs-investigation': number;
160
162
  }
161
163
  export declare function emptyBucket(): SummaryBucket;
162
164
  /** Per-category counts over `items`. */
163
165
  export declare function summarize(items: readonly SweepItem[]): Record<string, SummaryBucket>;
166
+ /** A row whose deletion only prunes a stale worktree link. */
167
+ export declare function isPrune(item: Pick<SweepItem, 'classification' | 'action'>): boolean;
164
168
  type Defaulted = 'facts' | 'commands' | 'investigationParts' | 'requires' | 'deletable';
165
169
  /** Build an item, defaulting the collection fields and `deletable: true`. */
166
170
  export declare function makeItem(partial: Omit<SweepItem, Defaulted> & Partial<Pick<SweepItem, Defaulted>>): SweepItem;
package/dist/types.js CHANGED
@@ -48,7 +48,14 @@ export function emptyScan() {
48
48
  return { containers: 0, directoriesScanned: 0, depthLimited: 0, checkoutsFound: 0 };
49
49
  }
50
50
  export function emptyBucket() {
51
- return { total: 0, merged: 0, 'safe-to-delete': 0, 'safe-to-keep': 0, 'needs-investigation': 0 };
51
+ return {
52
+ total: 0,
53
+ merged: 0,
54
+ 'safe-to-delete': 0,
55
+ prune: 0,
56
+ 'safe-to-keep': 0,
57
+ 'needs-investigation': 0,
58
+ };
52
59
  }
53
60
  /** Per-category counts over `items`. */
54
61
  export function summarize(items) {
@@ -58,10 +65,16 @@ export function summarize(items) {
58
65
  bucket.total += 1;
59
66
  if (item.facts['merged'] === true)
60
67
  bucket.merged += 1;
68
+ if (isPrune(item))
69
+ bucket.prune += 1;
61
70
  bucket[item.classification] += 1;
62
71
  }
63
72
  return out;
64
73
  }
74
+ /** A row whose deletion only prunes a stale worktree link. */
75
+ export function isPrune(item) {
76
+ return item.classification === 'safe-to-delete' && item.action === 'prune';
77
+ }
65
78
  /** Build an item, defaulting the collection fields and `deletable: true`. */
66
79
  export function makeItem(partial) {
67
80
  return {
@@ -1,2 +1,2 @@
1
1
  /** This package's version, from its package.json when it was built. */
2
- export declare const VERSION = "0.10.1";
2
+ export declare const VERSION = "0.11.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.10.1';
3
+ export const VERSION = '0.11.0';
package/dist/worktrees.js CHANGED
@@ -293,7 +293,7 @@ export function classifyWorktree(opts) {
293
293
  category: 'worktree',
294
294
  id: path,
295
295
  classification: 'safe-to-delete',
296
- action: 'remove',
296
+ action: 'prune',
297
297
  reason: dirPresent
298
298
  ? 'directory is no longer a checkout (no .git); prune the stale link, the directory stays'
299
299
  : 'worktree directory no longer on disk; prune the stale link',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/repo-cleaner",
3
- "version": "0.10.1",
3
+ "version": "0.11.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.4.3",
52
- "@osdlc/cli-tool": "^0.12.0",
53
- "@osdlc/path-guard": "^0.3.1",
52
+ "@osdlc/cli-tool": "^0.13.0",
54
53
  "@osdlc/command-seam": "^0.14.0",
55
- "@osdlc/typed-git": "^0.12.0",
54
+ "@osdlc/path-guard": "^0.3.1",
56
55
  "@osdlc/typed-gh": "^0.14.1",
56
+ "@osdlc/typed-git": "^0.12.0",
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",