@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 +25 -0
- package/README.md +43 -6
- package/dist/apply.js +16 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +142 -4
- package/dist/remote-mirror.d.ts +24 -0
- package/dist/remote-mirror.js +43 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +31 -4
- package/dist/step-board.js +2 -1
- package/dist/sweep.js +20 -5
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +5 -5
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/remote-mirror.d.ts
CHANGED
|
@@ -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;
|
package/dist/remote-mirror.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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('
|
|
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}`;
|
package/dist/step-board.js
CHANGED
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
2
|
+
export declare const VERSION = "0.15.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/repo-cleaner",
|
|
3
|
-
"version": "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/
|
|
67
|
-
"@osdlc/
|
|
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",
|