@osdlc/repo-cleaner 0.10.0 → 0.10.1
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 +17 -0
- package/README.md +14 -0
- package/dist/apply.d.ts +23 -2
- package/dist/apply.js +75 -2
- package/dist/branches.d.ts +10 -0
- package/dist/branches.js +15 -5
- package/dist/cli.js +152 -104
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/receipt.d.ts +4 -0
- package/dist/receipt.js +11 -2
- package/dist/render.d.ts +24 -1
- package/dist/render.js +71 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.js +7 -5
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @osdlc/repo-cleaner
|
|
2
2
|
|
|
3
|
+
## 0.10.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- cde11b5: `apply` fixes:
|
|
8
|
+
|
|
9
|
+
- The run a confirmation approves keeps the safe-to-delete guard: a row that turned into a review row between the preview and the yes is dropped as `needs-review`, not run.
|
|
10
|
+
- Two clones of one remote no longer delete its branches twice. The second row is dropped as the new reason `duplicate`, naming the clone that deletes it; before, the second push was rejected and the run stopped with exit 11. Remotes are compared by the push URLs git uses (`url.*.insteadOf` applied), a network URL in any form (`git@host:o/r`, `ssh://`, `https://`) as `host/path`, and a local path as written.
|
|
11
|
+
- A swept `apply` re-checks once before asking, from the sweep it just ran, instead of sweeping again; the run that deletes still sweeps afresh.
|
|
12
|
+
- A run that deletes without a receipt file says so on stderr.
|
|
13
|
+
- Every run ends with a summary: `deleted: 1 worktree, 3 local branches, 2 remote branches`, the dropped rows by reason, and how many rows a failed run did not reach. A preview says `would delete:`. `--output json` adds a `summary` object.
|
|
14
|
+
- The prompt's `Delete N items?` counts rows, not steps: a stale-locked worktree is one item, not two.
|
|
15
|
+
- `--repo-root` naming no repository in the plan exits 1 instead of reporting nothing to delete.
|
|
16
|
+
- An empty plan's JSON receipt says `complete`, not `dry-run`, unless the run was `--dry-run`.
|
|
17
|
+
|
|
18
|
+
Library: `ApplyOptions.safeOnly` and `ApplyOptions.swept`, the `duplicate` drop reason, `RECEIPT_VERSION`, and `summarizeReceipt`, `renderApplySummary` and `ApplySummary`.
|
|
19
|
+
|
|
3
20
|
## 0.10.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -312,6 +312,20 @@ On yes it runs only the steps it showed: worktrees first, then local branches,
|
|
|
312
312
|
remote branches and a prune, in that order. Branch deletes are
|
|
313
313
|
compare-and-swap, every step is verified, and the first failure stops the run.
|
|
314
314
|
|
|
315
|
+
Every run ends with what it did:
|
|
316
|
+
|
|
317
|
+
```text
|
|
318
|
+
deleted: 1 worktree, 3 local branches, 2 remote branches, 1 stale worktree pruned
|
|
319
|
+
dropped 2: 1 needs-review, 1 duplicate
|
|
320
|
+
status: complete
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The preview says `would delete:`, and a run that stops at a failed step says
|
|
324
|
+
how many rows it did not reach. `--output json` carries the same counts as
|
|
325
|
+
`summary`. Two clones of one remote list the same remote branches; `apply`
|
|
326
|
+
deletes each once and drops the other clone's row as `duplicate`. Remotes are
|
|
327
|
+
compared by the push URL git uses, with `url.*.insteadOf` applied.
|
|
328
|
+
|
|
315
329
|
A receipt records every step and is rewritten after each one. With
|
|
316
330
|
`--plan plan.json` it goes beside the plan, as
|
|
317
331
|
`plan.receipt-<timestamp>.json`. `--receipt <file>` puts it anywhere. A run
|
package/dist/apply.d.ts
CHANGED
|
@@ -18,8 +18,8 @@ import type { CommandRunner } from '@osdlc/command-seam';
|
|
|
18
18
|
import type { HostHooks } from './hooks.ts';
|
|
19
19
|
import { type ProcessProbe } from './processes.ts';
|
|
20
20
|
import { type Plan } from './plan.ts';
|
|
21
|
-
import type
|
|
22
|
-
import { type Authorization, type SweepProgress } from './types.ts';
|
|
21
|
+
import { type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
|
|
22
|
+
import { type Authorization, type SweepItem, type SweepProgress } from './types.ts';
|
|
23
23
|
/** An authorization the caller gave, for one row or (`row: null`) for all. */
|
|
24
24
|
export interface Grant {
|
|
25
25
|
name: Authorization;
|
|
@@ -45,6 +45,20 @@ export interface ApplyOptions extends HostHooks {
|
|
|
45
45
|
/** Limit the run to the plan repository rooted here. */
|
|
46
46
|
repoRoot?: string | undefined;
|
|
47
47
|
now?: () => Date;
|
|
48
|
+
/**
|
|
49
|
+
* Drop each selected row the re-check does not judge `safe-to-delete`.
|
|
50
|
+
* Defaults to true when no rows are requested, so a row to review runs only
|
|
51
|
+
* when it is named. Pass it to keep that guard on a run whose rows came
|
|
52
|
+
* from such a selection, such as the run a preview confirmed.
|
|
53
|
+
*/
|
|
54
|
+
safeOnly?: boolean | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Items to re-check against instead of sweeping again, keyed by repository
|
|
57
|
+
* root, already annotated: the hook is not applied to them again. For a
|
|
58
|
+
* preview straight after a sweep; the run that deletes should always sweep
|
|
59
|
+
* again.
|
|
60
|
+
*/
|
|
61
|
+
swept?: ReadonlyMap<string, readonly SweepItem[]> | undefined;
|
|
48
62
|
/** Rows dropped before this run, such as by a preview; recorded first in the receipt. */
|
|
49
63
|
dropped?: readonly Dropped[] | undefined;
|
|
50
64
|
/** Passed to the re-sweep's `onProgress`, for a status line. */
|
|
@@ -66,6 +80,13 @@ export declare function parseGrant(text: string): Grant;
|
|
|
66
80
|
export type RowSelection = RowRef & {
|
|
67
81
|
repo?: string;
|
|
68
82
|
};
|
|
83
|
+
/**
|
|
84
|
+
* A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
|
|
85
|
+
* `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
|
|
86
|
+
* nested groups stay apart. Null for a path on this machine, including
|
|
87
|
+
* `file://` and a Windows drive path.
|
|
88
|
+
*/
|
|
89
|
+
export declare function networkRemoteKey(url: string): string | null;
|
|
69
90
|
/**
|
|
70
91
|
* Apply the `requested` rows of `plan` under `grants`. An empty `requested`
|
|
71
92
|
* selects every row of the plan (of `repoRoot`'s repository, when given) and
|
package/dist/apply.js
CHANGED
|
@@ -20,6 +20,7 @@ import { CommandFailed, Git, PUSH_REJECTIONS } from '@osdlc/typed-git';
|
|
|
20
20
|
import { localDeleteCommand, remoteDeleteCommand } from "./branches.js";
|
|
21
21
|
import { busyHolders, defaultProcessProbe, parseLockOwner } from "./processes.js";
|
|
22
22
|
import { PLAN_CATEGORIES, fingerprint, isPlanCategory, sameFingerprint, } from "./plan.js";
|
|
23
|
+
import { RECEIPT_VERSION } from "./receipt.js";
|
|
23
24
|
import { sweepRepo } from "./sweep.js";
|
|
24
25
|
import { AUTHORIZATIONS } from "./types.js";
|
|
25
26
|
import { worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand } from "./worktrees.js";
|
|
@@ -78,6 +79,10 @@ function granted(grants, ref, name) {
|
|
|
78
79
|
}
|
|
79
80
|
/** The plan repository's rows as they stand now, with the host's overlay applied. */
|
|
80
81
|
function resweep(repo, opts) {
|
|
82
|
+
const given = opts.swept?.get(repo.root);
|
|
83
|
+
if (given !== undefined) {
|
|
84
|
+
return [...given];
|
|
85
|
+
}
|
|
81
86
|
const sweep = sweepRepo(repo.root, {
|
|
82
87
|
remote: repo.remote,
|
|
83
88
|
noGh: repo.no_gh,
|
|
@@ -257,6 +262,73 @@ function execute(step, row, git, probe) {
|
|
|
257
262
|
}
|
|
258
263
|
}
|
|
259
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
|
|
267
|
+
* `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
|
|
268
|
+
* nested groups stay apart. Null for a path on this machine, including
|
|
269
|
+
* `file://` and a Windows drive path.
|
|
270
|
+
*/
|
|
271
|
+
export function networkRemoteKey(url) {
|
|
272
|
+
const u = url.trim();
|
|
273
|
+
if (/^[a-z]:[\\/]/i.test(u) || /^file:/i.test(u))
|
|
274
|
+
return null;
|
|
275
|
+
const uri = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::(\d+))?(\/.*)?$/i.exec(u);
|
|
276
|
+
const scp = uri === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(u) : null;
|
|
277
|
+
const host = uri?.[1] ?? scp?.[1];
|
|
278
|
+
if (host === undefined)
|
|
279
|
+
return null;
|
|
280
|
+
const port = uri?.[2] === undefined ? '' : `:${uri[2]}`;
|
|
281
|
+
const path = (uri?.[3] ?? scp?.[2] ?? '').replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
|
|
282
|
+
return `${host.toLowerCase()}${port}/${path}`;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* One string per remote repository, so two clones that push to the same one
|
|
286
|
+
* agree. Built from the push URLs git would use (`remote get-url --push
|
|
287
|
+
* --all`, which applies `url.*.insteadOf` and `pushInsteadOf`): a network URL
|
|
288
|
+
* by {@link networkRemoteKey}, a local path resolved against the repository
|
|
289
|
+
* and kept as written, `.git` and all. Null when git reports no URL; such a
|
|
290
|
+
* row is never treated as a duplicate.
|
|
291
|
+
*/
|
|
292
|
+
function remoteKey(git, repo, remote) {
|
|
293
|
+
const urls = git.at(repo).try.remoteGetUrls(remote, { push: true });
|
|
294
|
+
if (!urls.ok || urls.value === null || urls.value.length === 0)
|
|
295
|
+
return null;
|
|
296
|
+
const keys = urls.value.map((url) => networkRemoteKey(url) ?? resolve(repo, url.trim().replace(/^file:\/\/(localhost)?/i, '')));
|
|
297
|
+
return [...new Set(keys)].sort().join('\n');
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Keep the first row for each remote branch. Two clones of one remote each
|
|
301
|
+
* list its branches; deleting one twice fails the second push and stops the
|
|
302
|
+
* run, so the later row is dropped as `duplicate`.
|
|
303
|
+
*/
|
|
304
|
+
function onePerRemoteBranch({ accepted, dropped }, git) {
|
|
305
|
+
const seen = new Map();
|
|
306
|
+
const kept = [];
|
|
307
|
+
const extra = [];
|
|
308
|
+
for (const row of accepted) {
|
|
309
|
+
if (row.item.category !== 'remote_branch') {
|
|
310
|
+
kept.push(row);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const remote = remoteKey(git, row.repo, String(row.item.facts['remote']));
|
|
314
|
+
const key = remote === null ? null : `${remote}\0${String(row.item.facts['branch'])}`;
|
|
315
|
+
const first = key === null ? undefined : seen.get(key);
|
|
316
|
+
if (first !== undefined) {
|
|
317
|
+
extra.push({
|
|
318
|
+
repo: row.repo,
|
|
319
|
+
category: row.item.category,
|
|
320
|
+
id: row.item.id,
|
|
321
|
+
reason: 'duplicate',
|
|
322
|
+
detail: `the same remote branch is deleted from ${first}`,
|
|
323
|
+
});
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (key !== null)
|
|
327
|
+
seen.set(key, row.repo);
|
|
328
|
+
kept.push(row);
|
|
329
|
+
}
|
|
330
|
+
return { accepted: kept, dropped: [...dropped, ...extra] };
|
|
331
|
+
}
|
|
260
332
|
/**
|
|
261
333
|
* Apply the `requested` rows of `plan` under `grants`. An empty `requested`
|
|
262
334
|
* selects every row of the plan (of `repoRoot`'s repository, when given) and
|
|
@@ -286,7 +358,7 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
286
358
|
}
|
|
287
359
|
}
|
|
288
360
|
const receipt = {
|
|
289
|
-
version:
|
|
361
|
+
version: RECEIPT_VERSION,
|
|
290
362
|
plan: opts.planPath,
|
|
291
363
|
status: opts.dryRun ? 'dry-run' : 'applying',
|
|
292
364
|
started_at: now().toISOString(),
|
|
@@ -294,7 +366,8 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
294
366
|
dropped: [],
|
|
295
367
|
steps: [],
|
|
296
368
|
};
|
|
297
|
-
const
|
|
369
|
+
const rechecked = recheck(selected, grants, opts.safeOnly ?? requested.length === 0, opts);
|
|
370
|
+
const { accepted, dropped } = onePerRemoteBranch(rechecked, new Git(opts.callerCwd, { runner: opts.git }));
|
|
298
371
|
receipt.dropped = [...(opts.dropped ?? []), ...dropped];
|
|
299
372
|
const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
|
|
300
373
|
const plannedSteps = ordered.flatMap((row) => stepsFor(row).map((step) => [step, row]));
|
package/dist/branches.d.ts
CHANGED
|
@@ -27,6 +27,16 @@ export declare function recordPr(facts: Record<string, unknown>, pr: PRInfo | un
|
|
|
27
27
|
};
|
|
28
28
|
/** One sentence on why a merged PR did not count, for `investigationParts`. */
|
|
29
29
|
export declare function gapSentence(pr: PRInfo, gap: PrEvidenceGap, repo: RepoInfo): string;
|
|
30
|
+
/**
|
|
31
|
+
* `facts.merged`: the work is on the default branch, by an exact-head merged
|
|
32
|
+
* PR, by ancestry or by a patch match. Every category records it this way, so
|
|
33
|
+
* `summary` counts one definition.
|
|
34
|
+
*/
|
|
35
|
+
export declare function mergedFact(e: {
|
|
36
|
+
landed: boolean;
|
|
37
|
+
ancestor: boolean;
|
|
38
|
+
patchLanded: boolean;
|
|
39
|
+
}): boolean;
|
|
30
40
|
/** `HEAD` as the branch checked out at `git`'s directory; any other name as given. */
|
|
31
41
|
export declare function resolveBranch(value: string, git: Git): string;
|
|
32
42
|
/**
|
package/dist/branches.js
CHANGED
|
@@ -59,6 +59,14 @@ export function gapSentence(pr, gap, repo) {
|
|
|
59
59
|
? `PR #${pr.number} merged into \`${pr.baseRef}\`, not \`${repo.mainBranch}\`, so it is not landing proof (other-base).`
|
|
60
60
|
: `PR #${pr.number} merged at head ${pr.headOid ?? '(unknown)'}, which is not this tip, so it is not landing proof (head-moved).`;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* `facts.merged`: the work is on the default branch, by an exact-head merged
|
|
64
|
+
* PR, by ancestry or by a patch match. Every category records it this way, so
|
|
65
|
+
* `summary` counts one definition.
|
|
66
|
+
*/
|
|
67
|
+
export function mergedFact(e) {
|
|
68
|
+
return e.landed || e.ancestor || e.patchLanded;
|
|
69
|
+
}
|
|
62
70
|
/** What deleting a branch needs: its unlanded commits, and its open PR. */
|
|
63
71
|
function branchRequires(unmerged, pr) {
|
|
64
72
|
return authorizationsFor({
|
|
@@ -152,7 +160,11 @@ export function classifyLocalBranch(opts) {
|
|
|
152
160
|
// `soft`: a missing default ref reads as not merged.
|
|
153
161
|
const pr = prs.get(name);
|
|
154
162
|
const { landed } = recordPr(facts, pr, sha, repo);
|
|
155
|
-
facts['merged'] =
|
|
163
|
+
facts['merged'] = mergedFact({
|
|
164
|
+
landed,
|
|
165
|
+
ancestor: git.at(repo.root).soft.isAncestor(sha, landedRef(repo)),
|
|
166
|
+
patchLanded: false,
|
|
167
|
+
});
|
|
156
168
|
return pinned(held);
|
|
157
169
|
}
|
|
158
170
|
// Work merged only into an unpushed local default has not landed. A missing
|
|
@@ -172,8 +184,7 @@ export function classifyLocalBranch(opts) {
|
|
|
172
184
|
recordLanding(facts, patch);
|
|
173
185
|
const patchLanded = patch !== null && patch.check !== null;
|
|
174
186
|
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
175
|
-
|
|
176
|
-
facts['merged'] = landed || isAncestor || patchLanded;
|
|
187
|
+
facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
|
|
177
188
|
if (!landed && !isAncestor) {
|
|
178
189
|
facts['unique_commits'] = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
|
|
179
190
|
}
|
|
@@ -297,8 +308,7 @@ export function classifyRemoteBranch(opts) {
|
|
|
297
308
|
recordLanding(facts, patch);
|
|
298
309
|
const patchLanded = patch !== null && patch.check !== null;
|
|
299
310
|
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
300
|
-
|
|
301
|
-
facts['merged'] = landed || isAncestor || patchLanded;
|
|
311
|
+
facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
|
|
302
312
|
if (!landed && !isAncestor) {
|
|
303
313
|
// Unknown (null) when the clone lacks `sha` or `remoteMain`, as above.
|
|
304
314
|
const counted = git.at(repo.root).try.aheadBehind(remoteMain, sha);
|
package/dist/cli.js
CHANGED
|
@@ -29,7 +29,8 @@ import { Git } from '@osdlc/typed-git';
|
|
|
29
29
|
import { resolveBranch } from "./branches.js";
|
|
30
30
|
import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
|
|
31
31
|
import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
|
|
32
|
-
import {
|
|
32
|
+
import { RECEIPT_VERSION } from "./receipt.js";
|
|
33
|
+
import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, rowsActedOn, summarizeReceipt, } from "./render.js";
|
|
33
34
|
import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
|
|
34
35
|
import { sweepRepos } from "./sweep.js";
|
|
35
36
|
const EXIT_NOTHING_TO_SWEEP = 10;
|
|
@@ -160,7 +161,7 @@ const applyCli = () => defineCli({
|
|
|
160
161
|
repoRoot: {
|
|
161
162
|
kind: 'string',
|
|
162
163
|
valueName: 'path',
|
|
163
|
-
help: 'apply only the
|
|
164
|
+
help: 'with a plan of several repositories: apply only the one rooted here',
|
|
164
165
|
},
|
|
165
166
|
receipt: {
|
|
166
167
|
kind: 'string',
|
|
@@ -209,27 +210,23 @@ function failure(ctx, err) {
|
|
|
209
210
|
return EXIT.error;
|
|
210
211
|
}
|
|
211
212
|
/** Validate the sweep flags and run the sweep, or answer with the exit code. */
|
|
212
|
-
function sweep(name, v, paths, ctx,
|
|
213
|
+
function sweep(name, v, paths, ctx, status) {
|
|
213
214
|
const only = v.only?.filter((s) => s !== '');
|
|
214
215
|
const unknown = (only ?? []).filter((s) => !(s in ONLY));
|
|
215
216
|
if (unknown.length > 0) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
code: usageError(ctx, name, `unknown --only value(s): ${unknown.join(', ')}`),
|
|
219
|
-
};
|
|
217
|
+
const message = `unknown --only value(s): ${unknown.join(', ')}`;
|
|
218
|
+
return { kind: 'exit', code: usageError(ctx, name, message) };
|
|
220
219
|
}
|
|
221
220
|
if (v.branch !== undefined && paths.length > 0) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
code: usageError(ctx, name, '--branch sweeps this repository only; drop the paths'),
|
|
225
|
-
};
|
|
221
|
+
const message = '--branch sweeps this repository only; drop the paths';
|
|
222
|
+
return { kind: 'exit', code: usageError(ctx, name, message) };
|
|
226
223
|
}
|
|
227
224
|
let branch;
|
|
228
225
|
try {
|
|
229
226
|
branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
|
|
230
227
|
}
|
|
231
228
|
catch (err) {
|
|
232
|
-
return {
|
|
229
|
+
return { kind: 'exit', code: failure(ctx, err) };
|
|
233
230
|
}
|
|
234
231
|
const options = {
|
|
235
232
|
baseDir: ctx.cwd,
|
|
@@ -250,16 +247,14 @@ function sweep(name, v, paths, ctx, render, status) {
|
|
|
250
247
|
status.clear();
|
|
251
248
|
if (branch !== undefined && result.repos.length > 1) {
|
|
252
249
|
const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
|
|
253
|
-
return {
|
|
250
|
+
return { kind: 'exit', code: failure(ctx, err) };
|
|
254
251
|
}
|
|
255
252
|
const refused = branch === undefined ? null : (result.repos[0]?.error ?? null);
|
|
256
253
|
if (refused !== null)
|
|
257
|
-
return {
|
|
258
|
-
if (result.repos.length === 0)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
return { ok: true, result, branch, values: v };
|
|
254
|
+
return { kind: 'exit', code: failure(ctx, new Error(refused)) };
|
|
255
|
+
if (result.repos.length === 0)
|
|
256
|
+
return { kind: 'nothing', result };
|
|
257
|
+
return { kind: 'ok', result, branch, values: v };
|
|
263
258
|
}
|
|
264
259
|
/**
|
|
265
260
|
* Name each repository the sweep could not read, on stderr. True when there
|
|
@@ -292,9 +287,13 @@ function runStatus(argv, ctx, status) {
|
|
|
292
287
|
return usageError(ctx, NAME, parsed.message);
|
|
293
288
|
const v = parsed.values;
|
|
294
289
|
const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep });
|
|
295
|
-
const swept = sweep(NAME, v, parsed.positionals, ctx,
|
|
296
|
-
if (
|
|
290
|
+
const swept = sweep(NAME, v, parsed.positionals, ctx, status);
|
|
291
|
+
if (swept.kind === 'exit')
|
|
297
292
|
return swept.code;
|
|
293
|
+
if (swept.kind === 'nothing') {
|
|
294
|
+
ctx.io.stdout(render(swept.result));
|
|
295
|
+
return EXIT_NOTHING_TO_SWEEP;
|
|
296
|
+
}
|
|
298
297
|
if (v.commands) {
|
|
299
298
|
const commands = proposedCommands(swept.result);
|
|
300
299
|
if (commands.length > 0)
|
|
@@ -314,11 +313,11 @@ function runSummary(argv, ctx, status) {
|
|
|
314
313
|
return usageError(ctx, name, parsed.message);
|
|
315
314
|
const v = parsed.values;
|
|
316
315
|
const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r);
|
|
317
|
-
const swept = sweep(name, v, parsed.positionals, ctx,
|
|
318
|
-
if (
|
|
316
|
+
const swept = sweep(name, v, parsed.positionals, ctx, status);
|
|
317
|
+
if (swept.kind === 'exit')
|
|
319
318
|
return swept.code;
|
|
320
319
|
ctx.io.stdout(render(swept.result));
|
|
321
|
-
return EXIT.ok;
|
|
320
|
+
return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
|
|
322
321
|
}
|
|
323
322
|
function runPlan(argv, ctx, status) {
|
|
324
323
|
const name = `${NAME} plan`;
|
|
@@ -328,12 +327,14 @@ function runPlan(argv, ctx, status) {
|
|
|
328
327
|
if (parsed.status === 'error')
|
|
329
328
|
return usageError(ctx, name, parsed.message);
|
|
330
329
|
const v = parsed.values;
|
|
331
|
-
const swept = sweep(name, v, parsed.positionals, ctx,
|
|
332
|
-
|
|
333
|
-
return '';
|
|
334
|
-
}, status);
|
|
335
|
-
if (!swept.ok)
|
|
330
|
+
const swept = sweep(name, v, parsed.positionals, ctx, status);
|
|
331
|
+
if (swept.kind === 'exit')
|
|
336
332
|
return swept.code;
|
|
333
|
+
// stdout carries only the plan, so the summary goes to stderr either way.
|
|
334
|
+
if (swept.kind === 'nothing') {
|
|
335
|
+
ctx.io.stderr(renderSummary(swept.result) + '\n');
|
|
336
|
+
return EXIT_NOTHING_TO_SWEEP;
|
|
337
|
+
}
|
|
337
338
|
const plan = planOf(swept, new Date());
|
|
338
339
|
const unreadable = reportUnreadable(ctx, name, swept.result);
|
|
339
340
|
ctx.io.stderr(renderSummary(swept.result) + '\n');
|
|
@@ -352,8 +353,68 @@ function receiptBeside(planPath, now) {
|
|
|
352
353
|
const stem = basename(planPath).replace(/\.json$/i, '');
|
|
353
354
|
return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
|
|
354
355
|
}
|
|
356
|
+
/** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
|
|
355
357
|
function wouldRun(receipt) {
|
|
356
|
-
return
|
|
358
|
+
return rowsActedOn(summarizeReceipt(receipt));
|
|
359
|
+
}
|
|
360
|
+
/** Sweep and plan in memory, or read `--plan`; or answer with the exit code. */
|
|
361
|
+
function loadPlan(name, v, paths, ctx, deps, status, now) {
|
|
362
|
+
const receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
|
|
363
|
+
if (v.plan === undefined) {
|
|
364
|
+
const swept = sweep(name, v, paths, ctx, status);
|
|
365
|
+
if (swept.kind === 'exit')
|
|
366
|
+
return swept.code;
|
|
367
|
+
if (swept.kind === 'nothing') {
|
|
368
|
+
ctx.io.stdout(v.output === 'json' ? renderJson(swept.result) : renderText(swept.result));
|
|
369
|
+
return EXIT_NOTHING_TO_SWEEP;
|
|
370
|
+
}
|
|
371
|
+
const items = new Map();
|
|
372
|
+
for (const r of swept.result.repos)
|
|
373
|
+
if (r.repo !== null)
|
|
374
|
+
items.set(r.repo.root, r.items);
|
|
375
|
+
return {
|
|
376
|
+
plan: planOf(swept, now),
|
|
377
|
+
source: '(swept)',
|
|
378
|
+
receiptPath,
|
|
379
|
+
unreadable: reportUnreadable(ctx, name, swept.result),
|
|
380
|
+
swept: items,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
if (v.plan === '-') {
|
|
385
|
+
const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
|
|
386
|
+
return { plan, source: '(stdin)', receiptPath, unreadable: false, swept: undefined };
|
|
387
|
+
}
|
|
388
|
+
const source = resolve(ctx.cwd, v.plan);
|
|
389
|
+
const plan = readPlan(source);
|
|
390
|
+
return {
|
|
391
|
+
plan,
|
|
392
|
+
source,
|
|
393
|
+
receiptPath: receiptPath ?? receiptBeside(source, now),
|
|
394
|
+
unreadable: false,
|
|
395
|
+
swept: undefined,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return failure(ctx, err);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** The run's flags that contradict each other, as a usage message, or null. */
|
|
403
|
+
function applyConflict(v, paths) {
|
|
404
|
+
if (v.plan !== undefined) {
|
|
405
|
+
const sweepFlags = [
|
|
406
|
+
paths.length > 0 ? 'paths' : null,
|
|
407
|
+
v.only !== undefined ? '--only' : null,
|
|
408
|
+
v.branch !== undefined ? '--branch' : null,
|
|
409
|
+
v.remote !== undefined ? '--remote' : null,
|
|
410
|
+
].filter((f) => f !== null);
|
|
411
|
+
if (sweepFlags.length > 0)
|
|
412
|
+
return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
|
|
413
|
+
}
|
|
414
|
+
if (v.plan === '-' && !v.yes && !v.dryRun) {
|
|
415
|
+
return '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run';
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
357
418
|
}
|
|
358
419
|
function runApply(argv, ctx, deps, status) {
|
|
359
420
|
const name = `${NAME} apply`;
|
|
@@ -375,57 +436,39 @@ function runApply(argv, ctx, deps, status) {
|
|
|
375
436
|
return usageError(ctx, name, err.message);
|
|
376
437
|
throw err;
|
|
377
438
|
}
|
|
378
|
-
const
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
paths.length > 0 ? 'paths' : null,
|
|
382
|
-
v.only !== undefined ? '--only' : null,
|
|
383
|
-
v.branch !== undefined ? '--branch' : null,
|
|
384
|
-
v.remote !== undefined ? '--remote' : null,
|
|
385
|
-
].filter((f) => f !== null);
|
|
386
|
-
if (sweepFlags.length > 0) {
|
|
387
|
-
return usageError(ctx, name, `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
if (fromStdin && !v.yes && !v.dryRun) {
|
|
391
|
-
return usageError(ctx, name, '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run');
|
|
392
|
-
}
|
|
439
|
+
const conflict = applyConflict(v, paths);
|
|
440
|
+
if (conflict !== null)
|
|
441
|
+
return usageError(ctx, name, conflict);
|
|
393
442
|
const now = new Date();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const ok = () => (unreadable ? EXIT.error : EXIT.ok);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
443
|
+
const loaded = loadPlan(name, v, paths, ctx, deps, status, now);
|
|
444
|
+
if (typeof loaded === 'number')
|
|
445
|
+
return loaded;
|
|
446
|
+
const { plan, source, receiptPath } = loaded;
|
|
447
|
+
const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
|
|
448
|
+
const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
|
|
449
|
+
const print = (receipt, shownPath) => {
|
|
450
|
+
ctx.io.stdout(v.output === 'json'
|
|
451
|
+
? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
|
|
452
|
+
: renderReceipt(receipt, shownPath));
|
|
453
|
+
};
|
|
454
|
+
const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
|
|
455
|
+
if (repoRoot !== undefined && planned.length === 0) {
|
|
456
|
+
return failure(ctx, new Error(`no plan repository at ${repoRoot}`));
|
|
407
457
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
else {
|
|
415
|
-
planSource = resolve(ctx.cwd, v.plan);
|
|
416
|
-
plan = readPlan(planSource);
|
|
417
|
-
receiptPath ??= receiptBeside(planSource, now);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
catch (err) {
|
|
421
|
-
return failure(ctx, err);
|
|
422
|
-
}
|
|
458
|
+
if (rows.length === 0 && planned.every((r) => r.rows.length === 0)) {
|
|
459
|
+
if (v.output === 'json')
|
|
460
|
+
print(emptyReceipt(source, now, v.dryRun), null);
|
|
461
|
+
else
|
|
462
|
+
ctx.io.stdout('nothing to delete\n');
|
|
463
|
+
return ok();
|
|
423
464
|
}
|
|
424
|
-
const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
|
|
425
465
|
const base = {
|
|
426
466
|
callerCwd: ctx.cwd,
|
|
427
|
-
planPath:
|
|
467
|
+
planPath: source,
|
|
428
468
|
repoRoot,
|
|
469
|
+
// An implicit selection runs only safe-to-delete rows, in the preview and
|
|
470
|
+
// in the run it confirms, whose rows are passed explicitly.
|
|
471
|
+
safeOnly: rows.length === 0,
|
|
429
472
|
...(v.progress
|
|
430
473
|
? {
|
|
431
474
|
onSweepProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
|
|
@@ -433,25 +476,17 @@ function runApply(argv, ctx, deps, status) {
|
|
|
433
476
|
}
|
|
434
477
|
: {}),
|
|
435
478
|
};
|
|
436
|
-
const print = (receipt, shownPath) => {
|
|
437
|
-
ctx.io.stdout(v.output === 'json'
|
|
438
|
-
? `${JSON.stringify({ receiptPath: shownPath, receipt }, null, 2)}\n`
|
|
439
|
-
: renderReceipt(receipt, shownPath));
|
|
440
|
-
};
|
|
441
479
|
let preview;
|
|
442
480
|
try {
|
|
443
|
-
|
|
481
|
+
// A sweep that just ran is the preview's re-check; the run that deletes sweeps again.
|
|
482
|
+
preview = applyPlan(plan, rows, grants, {
|
|
483
|
+
...base,
|
|
484
|
+
dryRun: true,
|
|
485
|
+
receiptPath: null,
|
|
486
|
+
swept: loaded.swept,
|
|
487
|
+
});
|
|
444
488
|
}
|
|
445
489
|
catch (err) {
|
|
446
|
-
if (err instanceof ApplyArgumentError) {
|
|
447
|
-
if (rows.length === 0 && plan.repos.every((r) => r.rows.length === 0)) {
|
|
448
|
-
if (v.output === 'json')
|
|
449
|
-
print(emptyReceipt(planSource, now), null);
|
|
450
|
-
else
|
|
451
|
-
ctx.io.stdout('nothing to delete\n');
|
|
452
|
-
return ok();
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
490
|
return failure(ctx, err);
|
|
456
491
|
}
|
|
457
492
|
const count = wouldRun(preview);
|
|
@@ -460,17 +495,9 @@ function runApply(argv, ctx, deps, status) {
|
|
|
460
495
|
return ok();
|
|
461
496
|
}
|
|
462
497
|
if (!v.yes) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
|
|
467
|
-
if (answer === null) {
|
|
468
|
-
return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
|
|
469
|
-
}
|
|
470
|
-
if (!answer) {
|
|
471
|
-
ctx.io.stderr('nothing deleted\n');
|
|
472
|
-
return EXIT.ok;
|
|
473
|
-
}
|
|
498
|
+
const declined = confirm(ctx, name, preview, count, v.output === 'json', deps);
|
|
499
|
+
if (declined !== null)
|
|
500
|
+
return declined;
|
|
474
501
|
}
|
|
475
502
|
// Only what the preview showed: rows it dropped stay dropped.
|
|
476
503
|
// Pinned to their repositories: a branch name two repositories share stays two rows.
|
|
@@ -489,14 +516,35 @@ function runApply(argv, ctx, deps, status) {
|
|
|
489
516
|
return failure(ctx, err);
|
|
490
517
|
}
|
|
491
518
|
print(receipt, receiptPath);
|
|
519
|
+
if (receiptPath === null) {
|
|
520
|
+
ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
|
|
521
|
+
}
|
|
492
522
|
return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
|
|
493
523
|
}
|
|
494
|
-
|
|
524
|
+
/**
|
|
525
|
+
* Show the preview and ask. Null to go ahead; otherwise the exit code: 0 for
|
|
526
|
+
* a no, 2 when there is no one to ask.
|
|
527
|
+
*/
|
|
528
|
+
function confirm(ctx, name, preview, count, json, deps) {
|
|
529
|
+
// The preview is for the person answering, so it goes where the question does.
|
|
530
|
+
const show = json ? ctx.io.stderr : ctx.io.stdout;
|
|
531
|
+
show(renderReceipt(preview, null));
|
|
532
|
+
const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
|
|
533
|
+
if (answer === null) {
|
|
534
|
+
return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
|
|
535
|
+
}
|
|
536
|
+
if (!answer) {
|
|
537
|
+
ctx.io.stderr('nothing deleted\n');
|
|
538
|
+
return EXIT.ok;
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
function emptyReceipt(plan, now, dryRun) {
|
|
495
543
|
const at = now.toISOString();
|
|
496
544
|
return {
|
|
497
|
-
version:
|
|
545
|
+
version: RECEIPT_VERSION,
|
|
498
546
|
plan,
|
|
499
|
-
status: 'dry-run',
|
|
547
|
+
status: dryRun ? 'dry-run' : 'complete',
|
|
500
548
|
started_at: at,
|
|
501
549
|
finished_at: at,
|
|
502
550
|
dropped: [],
|
package/dist/index.d.ts
CHANGED
|
@@ -21,10 +21,10 @@ export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from
|
|
|
21
21
|
export { ApplyArgumentError, applyPlan, type RowSelection, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
|
|
22
22
|
export type { Annotate, HostHooks } from './hooks.ts';
|
|
23
23
|
export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
|
|
24
|
-
export { ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
|
|
24
|
+
export { RECEIPT_VERSION, ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step, } from './receipt.ts';
|
|
25
25
|
export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, type DefaultProbeOptions, type LockOwner, type ProcessHolder, type ProcessProbe, } from './processes.ts';
|
|
26
26
|
export { fetchPrs } from './prs.ts';
|
|
27
27
|
export { expandTilde, isCheckoutRoot, resolveEntries, type Resolution, type ResolveOptions, } from './resolve.ts';
|
|
28
28
|
export { sweepRepo, sweepRepos, type SweepOptions, type SweepReposOptions } from './sweep.ts';
|
|
29
|
-
export { proposedCommands, renderJson, renderReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
|
|
29
|
+
export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, type ApplySummary, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
|
|
30
30
|
export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, type Authorization, type Category, type Classification, type DuplicateCheckout, type PRInfo, type RepoInfo, type RepoSweep, type ScanStats, type Scope, type SummaryBucket, type SweepItem, type SweepProgress, type SweepResult, type UnresolvedEntry, } from './types.ts';
|
package/dist/index.js
CHANGED
|
@@ -20,10 +20,10 @@ export { DiscoveryFailed, discoverRepo } from "./discover.js";
|
|
|
20
20
|
export { assessLanding, patchEquivalent } from "./landing.js";
|
|
21
21
|
export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
|
|
22
22
|
export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
|
|
23
|
-
export { ReceiptSchema } from "./receipt.js";
|
|
23
|
+
export { RECEIPT_VERSION, ReceiptSchema, } from "./receipt.js";
|
|
24
24
|
export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, } from "./processes.js";
|
|
25
25
|
export { fetchPrs } from "./prs.js";
|
|
26
26
|
export { expandTilde, isCheckoutRoot, resolveEntries, } from "./resolve.js";
|
|
27
27
|
export { sweepRepo, sweepRepos } from "./sweep.js";
|
|
28
|
-
export { proposedCommands, renderJson, renderReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
|
|
28
|
+
export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
|
|
29
29
|
export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, } from "./types.js";
|
package/dist/receipt.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** What `applyPlan` did, step by step, rewritten after every step. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
/** The receipt format's version, written into every receipt. */
|
|
4
|
+
export declare const RECEIPT_VERSION = 1;
|
|
3
5
|
export declare const RowRefSchema: z.ZodObject<{
|
|
4
6
|
category: z.ZodEnum<{
|
|
5
7
|
local_branch: "local_branch";
|
|
@@ -23,6 +25,7 @@ export declare const DroppedSchema: z.ZodObject<{
|
|
|
23
25
|
"needs-review": "needs-review";
|
|
24
26
|
"facts-changed": "facts-changed";
|
|
25
27
|
"not-authorized": "not-authorized";
|
|
28
|
+
duplicate: "duplicate";
|
|
26
29
|
}>;
|
|
27
30
|
detail: z.ZodString;
|
|
28
31
|
}, z.core.$strip>;
|
|
@@ -81,6 +84,7 @@ export declare const ReceiptSchema: z.ZodObject<{
|
|
|
81
84
|
"needs-review": "needs-review";
|
|
82
85
|
"facts-changed": "facts-changed";
|
|
83
86
|
"not-authorized": "not-authorized";
|
|
87
|
+
duplicate: "duplicate";
|
|
84
88
|
}>;
|
|
85
89
|
detail: z.ZodString;
|
|
86
90
|
}, z.core.$strip>>;
|
package/dist/receipt.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/** What `applyPlan` did, step by step, rewritten after every step. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { PLAN_CATEGORIES } from "./plan.js";
|
|
4
|
+
/** The receipt format's version, written into every receipt. */
|
|
5
|
+
export const RECEIPT_VERSION = 1;
|
|
4
6
|
export const RowRefSchema = z.object({ category: z.enum(PLAN_CATEGORIES), id: z.string() });
|
|
5
7
|
export const DroppedSchema = RowRefSchema.extend({
|
|
6
8
|
repo: z.string(),
|
|
7
|
-
reason: z.enum([
|
|
9
|
+
reason: z.enum([
|
|
10
|
+
'gone',
|
|
11
|
+
'not-deletable',
|
|
12
|
+
'needs-review',
|
|
13
|
+
'facts-changed',
|
|
14
|
+
'not-authorized',
|
|
15
|
+
'duplicate',
|
|
16
|
+
]),
|
|
8
17
|
detail: z.string(),
|
|
9
18
|
});
|
|
10
19
|
export const StepSchema = z.object({
|
|
@@ -25,7 +34,7 @@ export const StepSchema = z.object({
|
|
|
25
34
|
verification: z.string().nullable(),
|
|
26
35
|
});
|
|
27
36
|
export const ReceiptSchema = z.object({
|
|
28
|
-
version: z.literal(
|
|
37
|
+
version: z.literal(RECEIPT_VERSION),
|
|
29
38
|
plan: z.string(),
|
|
30
39
|
status: z.enum(['dry-run', 'applying', 'complete', 'failed']),
|
|
31
40
|
started_at: z.string(),
|
package/dist/render.d.ts
CHANGED
|
@@ -48,5 +48,28 @@ export declare function renderText(result: SweepResult, opts?: {
|
|
|
48
48
|
export declare function renderJson(result: SweepResult, extra?: Record<string, unknown>): string;
|
|
49
49
|
/** Every command a `safe-to-delete` row that needs no authorization proposes, in report order. */
|
|
50
50
|
export declare function proposedCommands(result: SweepResult): string[];
|
|
51
|
-
/**
|
|
51
|
+
/** What an apply run did, or would do, counted by kind. */
|
|
52
|
+
export interface ApplySummary {
|
|
53
|
+
/** Rows deleted (or, in a dry run, that would be), by category. Pruned worktrees are not counted here. */
|
|
54
|
+
deleted: {
|
|
55
|
+
worktree: number;
|
|
56
|
+
local_branch: number;
|
|
57
|
+
remote_branch: number;
|
|
58
|
+
};
|
|
59
|
+
/** Worktrees whose stale registration was pruned; their directories were gone or left alone. */
|
|
60
|
+
pruned: number;
|
|
61
|
+
/** Dropped rows by reason. */
|
|
62
|
+
dropped: Record<string, number>;
|
|
63
|
+
/** Steps that failed: 0 or 1, since the first failure stops the run. */
|
|
64
|
+
failed: number;
|
|
65
|
+
/** Row steps never reached because a step failed. */
|
|
66
|
+
notRun: number;
|
|
67
|
+
}
|
|
68
|
+
/** Count a receipt's outcome. A dry run's `would-run` steps count as done. */
|
|
69
|
+
export declare function summarizeReceipt(receipt: Receipt): ApplySummary;
|
|
70
|
+
/** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
|
|
71
|
+
export declare function rowsActedOn(summary: ApplySummary): number;
|
|
72
|
+
/** The closing lines of an apply run: what it deleted, dropped and could not finish. */
|
|
73
|
+
export declare function renderApplySummary(receipt: Receipt): string[];
|
|
74
|
+
/** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
|
|
52
75
|
export declare function renderReceipt(receipt: Receipt, receiptPath: string | null): string;
|
package/dist/render.js
CHANGED
|
@@ -264,7 +264,74 @@ export function proposedCommands(result) {
|
|
|
264
264
|
}
|
|
265
265
|
return out;
|
|
266
266
|
}
|
|
267
|
-
/**
|
|
267
|
+
/** Count a receipt's outcome. A dry run's `would-run` steps count as done. */
|
|
268
|
+
export function summarizeReceipt(receipt) {
|
|
269
|
+
const out = {
|
|
270
|
+
deleted: { worktree: 0, local_branch: 0, remote_branch: 0 },
|
|
271
|
+
pruned: 0,
|
|
272
|
+
dropped: {},
|
|
273
|
+
failed: 0,
|
|
274
|
+
notRun: 0,
|
|
275
|
+
};
|
|
276
|
+
for (const d of receipt.dropped)
|
|
277
|
+
out.dropped[d.reason] = (out.dropped[d.reason] ?? 0) + 1;
|
|
278
|
+
for (const s of receipt.steps) {
|
|
279
|
+
if (s.result === 'failed')
|
|
280
|
+
out.failed += 1;
|
|
281
|
+
if (s.row === null)
|
|
282
|
+
continue;
|
|
283
|
+
if (s.result === 'pending') {
|
|
284
|
+
if (s.action !== 'unlock-worktree')
|
|
285
|
+
out.notRun += 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (s.result !== 'ok' && s.result !== 'would-run')
|
|
289
|
+
continue;
|
|
290
|
+
if (s.action === 'remove-worktree')
|
|
291
|
+
out.deleted.worktree += 1;
|
|
292
|
+
else if (s.action === 'prune-worktrees')
|
|
293
|
+
out.pruned += 1;
|
|
294
|
+
else if (s.action === 'delete-local')
|
|
295
|
+
out.deleted.local_branch += 1;
|
|
296
|
+
else if (s.action === 'delete-remote')
|
|
297
|
+
out.deleted.remote_branch += 1;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
/** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
|
|
302
|
+
export function rowsActedOn(summary) {
|
|
303
|
+
const d = summary.deleted;
|
|
304
|
+
return d.worktree + d.local_branch + d.remote_branch + summary.pruned;
|
|
305
|
+
}
|
|
306
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
307
|
+
/** The closing lines of an apply run: what it deleted, dropped and could not finish. */
|
|
308
|
+
export function renderApplySummary(receipt) {
|
|
309
|
+
const s = summarizeReceipt(receipt);
|
|
310
|
+
const dry = receipt.status === 'dry-run';
|
|
311
|
+
const parts = [
|
|
312
|
+
s.deleted.worktree > 0 ? plural(s.deleted.worktree, 'worktree', 'worktrees') : null,
|
|
313
|
+
s.deleted.local_branch > 0
|
|
314
|
+
? plural(s.deleted.local_branch, 'local branch', 'local branches')
|
|
315
|
+
: null,
|
|
316
|
+
s.deleted.remote_branch > 0
|
|
317
|
+
? plural(s.deleted.remote_branch, 'remote branch', 'remote branches')
|
|
318
|
+
: null,
|
|
319
|
+
s.pruned > 0 ? `${plural(s.pruned, 'stale worktree', 'stale worktrees')} pruned` : null,
|
|
320
|
+
].filter((p) => p !== null);
|
|
321
|
+
const lines = [
|
|
322
|
+
`${dry ? 'would delete' : 'deleted'}: ${parts.length > 0 ? parts.join(', ') : 'nothing'}`,
|
|
323
|
+
];
|
|
324
|
+
const dropped = Object.entries(s.dropped);
|
|
325
|
+
if (dropped.length > 0) {
|
|
326
|
+
const total = dropped.reduce((n, [, c]) => n + c, 0);
|
|
327
|
+
lines.push(`dropped ${total}: ${dropped.map(([reason, c]) => `${c} ${reason}`).join(', ')}`);
|
|
328
|
+
}
|
|
329
|
+
if (s.failed > 0) {
|
|
330
|
+
lines.push(`stopped at a failed step; ${plural(s.notRun, 'row', 'rows')} not run`);
|
|
331
|
+
}
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
334
|
+
/** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
|
|
268
335
|
export function renderReceipt(receipt, receiptPath) {
|
|
269
336
|
const lines = [];
|
|
270
337
|
for (const d of receipt.dropped) {
|
|
@@ -275,6 +342,9 @@ export function renderReceipt(receipt, receiptPath) {
|
|
|
275
342
|
const tail = s.error ?? s.verification;
|
|
276
343
|
lines.push(`${s.result.padEnd(8)} ${what}${tail ? ` (${tail})` : ''}`);
|
|
277
344
|
}
|
|
345
|
+
if (lines.length > 0)
|
|
346
|
+
lines.push('');
|
|
347
|
+
lines.push(...renderApplySummary(receipt));
|
|
278
348
|
lines.push(receiptPath === null
|
|
279
349
|
? `status: ${receipt.status}`
|
|
280
350
|
: `receipt: ${receiptPath} (${receipt.status})`);
|
|
@@ -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.
|
|
2
|
+
export declare const VERSION = "0.10.1";
|
package/dist/worktrees.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
9
9
|
import { isAbsolute, join, sep } from 'node:path';
|
|
10
10
|
import { shellJoin } from '@osdlc/command-seam';
|
|
11
11
|
import { nodeGuard } from '@osdlc/path-guard/node';
|
|
12
|
-
import { gapSentence, recordPr } from "./branches.js";
|
|
12
|
+
import { gapSentence, mergedFact, recordPr } from "./branches.js";
|
|
13
13
|
import { confirmationNote, judgeLanding, landingConfirmed, recordLanding, unmatchedSentence, } from "./landing.js";
|
|
14
14
|
import { busyHolders, isIdleHolder, parseLockOwner } from "./processes.js";
|
|
15
15
|
import { authorizationsFor, landedRef, makeItem, } from "./types.js";
|
|
@@ -251,10 +251,12 @@ export function classifyWorktree(opts) {
|
|
|
251
251
|
const patchLanded = patch !== null && patch.check !== null;
|
|
252
252
|
const unlanded = unpushed.length > 0 && !landed && !landingConfirmed(patch);
|
|
253
253
|
if (onDisk && head !== null) {
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
// `soft`: a missing default ref reads as not merged.
|
|
255
|
+
facts['merged'] = mergedFact({
|
|
256
|
+
landed,
|
|
257
|
+
patchLanded,
|
|
258
|
+
ancestor: git.at(repo.root).soft.isAncestor(head, landedRef(repo)),
|
|
259
|
+
});
|
|
258
260
|
}
|
|
259
261
|
const requires = authorizationsFor({
|
|
260
262
|
'abandon-unmerged': unlanded,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/repo-cleaner",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
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",
|
|
@@ -50,10 +50,10 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"zod": "4.4.3",
|
|
52
52
|
"@osdlc/cli-tool": "^0.12.0",
|
|
53
|
-
"@osdlc/command-seam": "^0.14.0",
|
|
54
53
|
"@osdlc/path-guard": "^0.3.1",
|
|
55
|
-
"@osdlc/
|
|
54
|
+
"@osdlc/command-seam": "^0.14.0",
|
|
56
55
|
"@osdlc/typed-git": "^0.12.0",
|
|
56
|
+
"@osdlc/typed-gh": "^0.14.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/tsconfig": "^0.4.1",
|
|
67
|
+
"@osdlc/prettier-config": "^0.3.1"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"build": "cli-tool-stamp-version && tsc -p tsconfig.build.json",
|