@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 +14 -0
- package/README.md +42 -6
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +68 -7
- package/dist/remote-mirror.d.ts +55 -0
- package/dist/remote-mirror.js +123 -0
- package/dist/render.d.ts +1 -1
- package/dist/render.js +21 -12
- package/dist/types.d.ts +4 -0
- package/dist/types.js +14 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.js +1 -1
- package/package.json +6 -6
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
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
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)} ${
|
|
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((
|
|
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 {
|
|
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.
|
|
2
|
+
export declare 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: '
|
|
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.
|
|
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.
|
|
53
|
-
"@osdlc/path-guard": "^0.3.1",
|
|
52
|
+
"@osdlc/cli-tool": "^0.13.0",
|
|
54
53
|
"@osdlc/command-seam": "^0.14.0",
|
|
55
|
-
"@osdlc/
|
|
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/
|
|
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",
|