@osdlc/repo-cleaner 0.14.2 → 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,17 @@
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
+
3
15
  ## 0.14.2
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -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
 
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
@@ -209,6 +212,25 @@ const applyCli = () => defineCli({
209
212
  epilogue: `risks: abandon-unmerged, discard-changes, confirm-open-pr
210
213
  A row that carries a risk you did not --allow is skipped, never deleted.`,
211
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.',
233
+ });
212
234
  const terminalConfirm = (question) => (canPrompt() ? askYesNo(question) : null);
213
235
  const PAUSE = new Int32Array(new SharedArrayBuffer(4));
214
236
  /**
@@ -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/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.2";
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.2';
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.2",
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.15.0",
53
- "@osdlc/path-guard": "^0.3.1",
52
+ "@osdlc/cli-tool": "^0.16.0",
54
53
  "@osdlc/command-seam": "^0.14.0",
55
54
  "@osdlc/typed-gh": "^0.14.1",
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": {