@osdlc/repo-cleaner 0.9.1 → 0.10.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 +45 -0
- package/README.md +75 -62
- package/dist/apply.d.ts +24 -7
- package/dist/apply.js +44 -13
- package/dist/branches.d.ts +2 -2
- package/dist/branches.js +24 -8
- package/dist/cli.d.ts +26 -7
- package/dist/cli.js +423 -183
- package/dist/devlink.d.ts +5 -0
- package/dist/devlink.js +3 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/plan.d.ts +7 -3
- package/dist/plan.js +24 -11
- package/dist/receipt.d.ts +2 -0
- package/dist/receipt.js +1 -1
- package/dist/render.d.ts +9 -0
- package/dist/render.js +63 -0
- package/dist/status-line.d.ts +24 -0
- package/dist/status-line.js +54 -0
- package/dist/sweep.d.ts +8 -1
- package/dist/sweep.js +17 -4
- package/dist/types.d.ts +15 -0
- package/dist/types.js +3 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +1 -1
- package/dist/worktrees.js +18 -4
- package/package.json +4 -4
- package/dist/config.d.ts +0 -53
- package/dist/config.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# @osdlc/repo-cleaner
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6e4284b: **Breaking:** repo-cleaner no longer reads a config file. It sweeps the repository you are in, or the paths you pass, which can be directories holding checkouts. `--all` and `--config` are removed: pass the directories instead (`repo-cleaner ~/Developer ~/work`), or keep them in a shell alias. Removed from the library: `CONFIG_ENV`, `DEFAULT_CONFIG`, `RepoCleanerConfigSchema`, `TOOL`, `clearConfigCache`, `configPath`, `configuredRepos`, `loadConfig` and `RepoCleanerConfig`. `REPO_CLEANER_MACHINE_CONFIG` is no longer read. The package no longer depends on `@osdlc/config-layers`.
|
|
8
|
+
- 6e4284b: A status line on stderr while a run works: which repository (`[2/5]` in a run of several), and which step — reading the repository, fetching, listing pull requests (gh), checking worktrees, local or remote branches with a count (`37/427`) — and, in `apply`, the re-check and each delete step. It is drawn only when stderr is a terminal, erased before any other output, and turned off with `--no-progress`.
|
|
9
|
+
|
|
10
|
+
Library: `SweepOptions.onProgress` receives a `SweepProgress` before each step and each item; `ApplyOptions.onSweepProgress` and `onStep` do the same for `applyPlan`.
|
|
11
|
+
- 6e4284b: New `repo-cleaner summary`: a table per repository counting local branches, worktrees and remote branches, with how many are merged into the default branch and how many the sweep would delete, review or keep. `--output json` gives the counts without the rows.
|
|
12
|
+
|
|
13
|
+
Each row now records `facts.merged`: true when its work is on the default branch by ancestry, an exact-head merged PR or a patch match. A branch checked out in a worktree, or at HEAD, is judged by ancestry or PR alone. The default branch and the primary or current worktree carry none. `SummaryBucket` gains `merged`, so the JSON report's `summary` counts it too.
|
|
14
|
+
- 6e4284b: **Breaking:** the CLI is three commands over one set of sweep options, and `apply` asks before it deletes.
|
|
15
|
+
|
|
16
|
+
- `repo-cleaner [status]` is the report, as before.
|
|
17
|
+
- `repo-cleaner plan` writes the plan to stdout, or to a file with `-o <file>`. It replaces `--write-plan` and `--plan-out`. Plans no longer go to a hidden directory by default.
|
|
18
|
+
- `repo-cleaner apply` sweeps and plans in memory, or reads `--plan <file>` (`-` reads stdin). With no `--row` it applies every row the re-check judges `safe-to-delete`, and drops a row to review with the new reason `needs-review`; name a review row with `--row` to apply it. It re-checks each row, prints the steps and asks `Delete N items? [y/N]`. `--yes` (`-y`) skips the question. With no terminal and no `--yes` it refuses with exit 2. `--plan -` needs `--yes` or `--dry-run`. On yes it runs only the steps it showed.
|
|
19
|
+
- `--only worktrees,branches,remotes` replaces `--scope local-branches,worktrees,remote-branches`, and works on every command.
|
|
20
|
+
- `--allow <risk>[=<category>:<id>]` replaces `--authorize`.
|
|
21
|
+
- Receipts go beside a `--plan` file as `<plan>.receipt-<timestamp>.json`, or to `--receipt <file>`. `--receipt-dir` and the default receipts directory are removed. A run that swept, or read stdin, writes a receipt only with `--receipt`.
|
|
22
|
+
|
|
23
|
+
Library: `applyPlan` with an empty row list selects every row of the plan and drops each one not judged `safe-to-delete` (`needs-review`), and `receiptPath` may be `null` to write no receipt. New `parsePlan(text, source)`. `stampedPath` is removed; use `fileStamp`.
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- 6e4284b: A registered worktree whose directory still exists but whose `.git` is gone (git lists it as prunable) no longer makes the whole repository unreadable with `git status: This is not a git repository`. It is a `safe-to-delete` row whose command is `git worktree prune`, which drops the registration and leaves the directory; `facts.directory_left` is true.
|
|
28
|
+
- 6e4284b: `apply` fixes: a branch name two repositories share applies in both instead of failing as ambiguous; a repository the sweep could not read (such as a failed fetch) is named on stderr and exits 1 instead of reading as "nothing to delete", and `plan` does the same; the receipt file records the rows the preview dropped; a worktree's fingerprint includes `on_disk`, so a prune row whose directory came back is dropped rather than removed. The status line strips terminal controls from paths and fits wide characters to the terminal's width. A worktree's `facts.merged` counts a patch match the same way a branch's does.
|
|
29
|
+
|
|
30
|
+
Library: `applyPlan` takes `RowSelection` rows (a row with an optional `repo`) and `ApplyOptions.dropped`.
|
|
31
|
+
|
|
32
|
+
## 0.9.3
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- e0aaba5: A remote branch delete the remote refuses now fails its step with git's reason and what it means, such as `[remote rejected] (pre-receive hook declined): A hook on the remote refused the push.`, read from typed-git's per-ref push report instead of stderr.
|
|
37
|
+
- Updated dependencies [e0aaba5]
|
|
38
|
+
- @osdlc/typed-git@0.12.0
|
|
39
|
+
- @osdlc/typed-gh@0.14.1
|
|
40
|
+
|
|
41
|
+
## 0.9.2
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [c58f58b]
|
|
46
|
+
- @osdlc/typed-gh@0.14.0
|
|
47
|
+
|
|
3
48
|
## 0.9.1
|
|
4
49
|
|
|
5
50
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@ remote branches, classifying each as `safe-to-delete`, `safe-to-keep` or
|
|
|
7
7
|
`needs-investigation` — with the evidence behind the verdict and the literal
|
|
8
8
|
command that would act on it.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
`repo-cleaner` (or `repo-cleaner status`) never deletes anything. It prints
|
|
11
|
+
the commands that would. `repo-cleaner apply` deletes: it checks each row
|
|
12
|
+
again, shows you the steps and asks before it runs them.
|
|
13
13
|
|
|
14
14
|
```console
|
|
15
15
|
$ repo-cleaner
|
|
@@ -201,13 +201,40 @@ approves them by name:
|
|
|
201
201
|
A row kept for structure (default branch, current HEAD, a branch held by a
|
|
202
202
|
worktree, the primary or caller's worktree) has `deletable: false`.
|
|
203
203
|
|
|
204
|
+
## Commands
|
|
205
|
+
|
|
206
|
+
```bash
|
|
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
|
|
209
|
+
repo-cleaner plan # the deletable rows as a plan, to stdout or -o <file>
|
|
210
|
+
repo-cleaner apply # re-check, show the steps, ask, delete
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
All four take the same paths and sweep options.
|
|
214
|
+
|
|
215
|
+
```console
|
|
216
|
+
$ repo-cleaner summary
|
|
217
|
+
/Users/me/work/api (acme/api, default main, origin)
|
|
218
|
+
total merged delete review keep
|
|
219
|
+
local branches 12 8 6 1 5
|
|
220
|
+
worktrees 4 3 2 1 1
|
|
221
|
+
remote branches 40 31 30 6 4
|
|
222
|
+
|
|
223
|
+
38 to delete, 8 to review, across 1 repository.
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`merged` counts rows whose work is on the default branch: by ancestry, by a
|
|
227
|
+
merged PR at the branch's head, or by a patch match. A branch checked out in a
|
|
228
|
+
worktree counts as merged by ancestry or PR alone. The default branch and the
|
|
229
|
+
primary worktree are counted in `total` but not judged. Each row's verdict is
|
|
230
|
+
in its `facts.merged`.
|
|
231
|
+
|
|
204
232
|
## Scope is opt-in
|
|
205
233
|
|
|
206
234
|
```bash
|
|
207
235
|
repo-cleaner # the repository you are standing in
|
|
208
236
|
repo-cleaner ~/work/api ~/work/web # named repositories
|
|
209
237
|
repo-cleaner ~/Developer # every checkout under it
|
|
210
|
-
repo-cleaner --all # every repository in the config file
|
|
211
238
|
```
|
|
212
239
|
|
|
213
240
|
A bare invocation never fans out. What an argument names is read off the disk
|
|
@@ -233,88 +260,74 @@ one that says which it could not find.
|
|
|
233
260
|
|
|
234
261
|
## Options
|
|
235
262
|
|
|
263
|
+
Every command that sweeps takes these:
|
|
264
|
+
|
|
236
265
|
| Flag | Effect |
|
|
237
266
|
| --- | --- |
|
|
238
|
-
| `--
|
|
239
|
-
| `--
|
|
240
|
-
| `--scope <list>` | `local-branches`, `worktrees`, `remote-branches` |
|
|
267
|
+
| `--only <list>` | `worktrees`, `branches`, `remotes` (default: all three) |
|
|
268
|
+
| `--branch <name\|HEAD>` | One branch of this repository: its worktree, local and remote branch. Refuses the default branch |
|
|
241
269
|
| `--remote <name>` | Remote to measure against (default `origin`) |
|
|
242
270
|
| `--no-gh` | Classify on git evidence alone |
|
|
243
271
|
| `--no-fetch` | Skip the fetch; the header then says `not fetched` |
|
|
272
|
+
|
|
273
|
+
`status` also takes:
|
|
274
|
+
|
|
275
|
+
| Flag | Effect |
|
|
276
|
+
| --- | --- |
|
|
244
277
|
| `--keep` | Include `safe-to-keep` rows in the report |
|
|
245
278
|
| `--commands` | Print the proposed commands of rows that need no authorization, and nothing else |
|
|
246
279
|
| `--output json` | The full inventory, facts included |
|
|
247
|
-
| `--branch <name\|HEAD>` | Sweep one branch of this repository: its worktree, local and remote branch. Refuses the default branch |
|
|
248
|
-
| `--write-plan` | Write the deletable rows as a plan for `apply` |
|
|
249
|
-
| `--plan-out <path>` | Write the plan here; implies `--write-plan` |
|
|
250
|
-
| `--version` | Print `repo-cleaner <version>` and exit; it must be the first argument |
|
|
251
280
|
|
|
252
|
-
|
|
253
|
-
|
|
281
|
+
`--version` prints `repo-cleaner <version>` and exits; it must be the first
|
|
282
|
+
argument. A checkout directory named like a command (`status`, `summary`,
|
|
283
|
+
`plan`, `apply`, `dev`) is swept as `./status` and so on: `dev` runs the tool from a
|
|
284
|
+
checkout (see Running from a checkout).
|
|
254
285
|
|
|
255
286
|
## Plan and apply
|
|
256
287
|
|
|
257
288
|
```bash
|
|
258
|
-
repo-cleaner
|
|
259
|
-
repo-cleaner apply --
|
|
289
|
+
repo-cleaner apply # sweep, show the steps, ask, delete
|
|
290
|
+
repo-cleaner apply --only worktrees --yes # no question
|
|
291
|
+
repo-cleaner plan -o plan.json # save the plan to review or edit
|
|
292
|
+
repo-cleaner apply --plan plan.json --row local_branch:feat/x
|
|
293
|
+
repo-cleaner plan | repo-cleaner apply --plan - --yes
|
|
260
294
|
```
|
|
261
295
|
|
|
262
296
|
A plan holds each deletable row with a fingerprint of the facts behind its
|
|
263
|
-
verdict. `apply` sweeps
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
row
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
297
|
+
verdict. `apply` either sweeps and plans in memory, or reads a plan with
|
|
298
|
+
`--plan <file>` (`-` reads stdin). With no `--row` it applies every row judged
|
|
299
|
+
`safe-to-delete` and drops each row to review as `needs-review`. Name a row
|
|
300
|
+
with `--row <category>:<id>` to apply it, review rows included.
|
|
301
|
+
|
|
302
|
+
Before it deletes anything, `apply` sweeps again and drops a row that is gone,
|
|
303
|
+
no longer deletable, whose facts moved, or that carries a risk you did not
|
|
304
|
+
accept with `--allow <risk>` (every row) or `--allow <risk>=<category>:<id>`
|
|
305
|
+
(one row). It prints the steps it would run and asks `Delete N items? [y/N]`.
|
|
306
|
+
`--yes` skips the question. With no terminal to ask on and no `--yes`, it
|
|
307
|
+
refuses with exit 2 rather than deleting. `--plan -` needs `--yes` or
|
|
308
|
+
`--dry-run`, because stdin carries the plan. `--dry-run` prints the steps and
|
|
309
|
+
stops.
|
|
310
|
+
|
|
311
|
+
On yes it runs only the steps it showed: worktrees first, then local branches,
|
|
312
|
+
remote branches and a prune, in that order. Branch deletes are
|
|
313
|
+
compare-and-swap, every step is verified, and the first failure stops the run.
|
|
314
|
+
|
|
315
|
+
A receipt records every step and is rewritten after each one. With
|
|
316
|
+
`--plan plan.json` it goes beside the plan, as
|
|
317
|
+
`plan.receipt-<timestamp>.json`. `--receipt <file>` puts it anywhere. A run
|
|
318
|
+
that swept, or read its plan from stdin, writes a receipt only when
|
|
319
|
+
`--receipt` names one; the printed steps are its record.
|
|
275
320
|
|
|
276
321
|
## Exit codes
|
|
277
322
|
|
|
278
323
|
| Code | Meaning |
|
|
279
324
|
| --- | --- |
|
|
280
|
-
| `0` | Swept; or
|
|
281
|
-
| `1` | Failed against the state it found:
|
|
282
|
-
| `2` | Cannot run as typed: an unknown flag, a malformed `--row`, `--branch` with
|
|
325
|
+
| `0` | Swept; or `apply` ran, was declined, or had nothing to run |
|
|
326
|
+
| `1` | Failed against the state it found: an unreadable plan, a row not in the plan |
|
|
327
|
+
| `2` | Cannot run as typed: an unknown flag, a malformed `--row`, `--branch` with a path, `apply` with nobody to ask and no `--yes` |
|
|
283
328
|
| `10` | Nothing to sweep: no entry named a repository |
|
|
284
329
|
| `11` | `apply` stopped at a failed step; the receipt says which |
|
|
285
330
|
|
|
286
|
-
## Configuration
|
|
287
|
-
|
|
288
|
-
Only the CLI reads a config file. The library takes repositories as an
|
|
289
|
-
argument, so a host that already knows where its repositories are passes them
|
|
290
|
-
in.
|
|
291
|
-
|
|
292
|
-
```yaml
|
|
293
|
-
paths:
|
|
294
|
-
dev: ~/Developer
|
|
295
|
-
repos:
|
|
296
|
-
- '{dev}'
|
|
297
|
-
- ~/work/api
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
`paths:` names a directory once; any `repos:` entry then refers to it as
|
|
301
|
-
`{name}`. A token nothing defines is an error, not an empty path.
|
|
302
|
-
|
|
303
|
-
The file is [`@osdlc/config-layers`](https://www.npmjs.com/package/@osdlc/config-layers)'s machine config — `config.yaml` in the host
|
|
304
|
-
OS's config home, resolved through
|
|
305
|
-
[`@osdlc/tiered-dirs`](https://www.npmjs.com/package/@osdlc/tiered-dirs)' standalone tier (`repo-cleaner`
|
|
306
|
-
directory, `REPO_CLEANER_*` override variables):
|
|
307
|
-
|
|
308
|
-
| OS | Path |
|
|
309
|
-
| --- | --- |
|
|
310
|
-
| macOS | `~/Library/Application Support/repo-cleaner/config.yaml` |
|
|
311
|
-
| Linux and other | `$XDG_CONFIG_HOME/repo-cleaner/config.yaml`, else `~/.config/repo-cleaner/config.yaml` |
|
|
312
|
-
| Windows | `%APPDATA%\repo-cleaner\config.yaml` |
|
|
313
|
-
|
|
314
|
-
`REPO_CLEANER_MACHINE_CONFIG`, or `--config <path>`, relocates it. A missing,
|
|
315
|
-
unreadable or malformed file degrades to no repositories rather than failing
|
|
316
|
-
the run.
|
|
317
|
-
|
|
318
331
|
## As a library
|
|
319
332
|
|
|
320
333
|
```ts
|
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 { Receipt, RowRef } from './receipt.ts';
|
|
22
|
-
import { type Authorization } from './types.ts';
|
|
21
|
+
import type { Dropped, Receipt, RowRef, Step } from './receipt.ts';
|
|
22
|
+
import { type Authorization, 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;
|
|
@@ -38,13 +38,19 @@ export interface ApplyOptions extends HostHooks {
|
|
|
38
38
|
/** The caller's directory; the sweep never offers the worktree holding it. */
|
|
39
39
|
callerCwd: string;
|
|
40
40
|
dryRun: boolean;
|
|
41
|
-
/** Where the receipt
|
|
42
|
-
receiptPath: string;
|
|
43
|
-
/**
|
|
41
|
+
/** Where the receipt is written after each step; null writes none. Unused under `dryRun`. */
|
|
42
|
+
receiptPath: string | null;
|
|
43
|
+
/** Where the plan came from, shown in the receipt. */
|
|
44
44
|
planPath: string;
|
|
45
45
|
/** Limit the run to the plan repository rooted here. */
|
|
46
46
|
repoRoot?: string | undefined;
|
|
47
47
|
now?: () => Date;
|
|
48
|
+
/** Rows dropped before this run, such as by a preview; recorded first in the receipt. */
|
|
49
|
+
dropped?: readonly Dropped[] | undefined;
|
|
50
|
+
/** Passed to the re-sweep's `onProgress`, for a status line. */
|
|
51
|
+
onSweepProgress?: ((progress: SweepProgress) => void) | undefined;
|
|
52
|
+
/** Told before each step runs: its 0-based index, the step count and the step. Not called under `dryRun`. */
|
|
53
|
+
onStep?: ((index: number, total: number, step: Step) => void) | undefined;
|
|
48
54
|
}
|
|
49
55
|
/** A selection or grant the plan cannot satisfy. Nothing has been touched. */
|
|
50
56
|
export declare class ApplyArgumentError extends Error {
|
|
@@ -54,9 +60,20 @@ export declare function parseRowRef(text: string): RowRef;
|
|
|
54
60
|
/** `<name>` for every row, or `<name>=<category>:<id>` for one. */
|
|
55
61
|
export declare function parseGrant(text: string): Grant;
|
|
56
62
|
/**
|
|
57
|
-
*
|
|
63
|
+
* A row to apply, optionally pinned to the plan repository rooted at `repo`.
|
|
64
|
+
* Without `repo`, a row id that two plan repositories share is ambiguous.
|
|
65
|
+
*/
|
|
66
|
+
export type RowSelection = RowRef & {
|
|
67
|
+
repo?: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Apply the `requested` rows of `plan` under `grants`. An empty `requested`
|
|
71
|
+
* selects every row of the plan (of `repoRoot`'s repository, when given) and
|
|
72
|
+
* then drops each one the re-sweep does not judge `safe-to-delete`: a row to
|
|
73
|
+
* review runs only when it is named. A row named twice runs once. A row whose
|
|
74
|
+
* `requires` was not granted is dropped, never run.
|
|
58
75
|
*
|
|
59
76
|
* @throws ApplyArgumentError before touching anything, when a row or grant does
|
|
60
77
|
* not fit the plan.
|
|
61
78
|
*/
|
|
62
|
-
export declare function applyPlan(plan: Plan, requested: readonly
|
|
79
|
+
export declare function applyPlan(plan: Plan, requested: readonly RowSelection[], grants: readonly Grant[], opts: ApplyOptions): Receipt;
|
package/dist/apply.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { resolve } from 'node:path';
|
|
19
|
-
import { CommandFailed, Git } from '@osdlc/typed-git';
|
|
19
|
+
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";
|
|
@@ -53,6 +53,8 @@ function errorText(err) {
|
|
|
53
53
|
return err.detail.trim() || err.message;
|
|
54
54
|
return err instanceof Error ? err.message : String(err);
|
|
55
55
|
}
|
|
56
|
+
const sameSelection = (a, b) => sameRow(a, b) &&
|
|
57
|
+
(a.repo === undefined ? '' : resolve(a.repo)) === (b.repo === undefined ? '' : resolve(b.repo));
|
|
56
58
|
/** Each selected row's plan repository. Unknown and ambiguous rows are errors. */
|
|
57
59
|
function locate(plan, rows, repoRoot) {
|
|
58
60
|
const repos = repoRoot === undefined
|
|
@@ -60,8 +62,9 @@ function locate(plan, rows, repoRoot) {
|
|
|
60
62
|
: plan.repos.filter((r) => resolve(r.root) === resolve(repoRoot));
|
|
61
63
|
if (repos.length === 0)
|
|
62
64
|
throw new ApplyArgumentError(`no plan repository at ${repoRoot}`);
|
|
63
|
-
return rows.map((ref) => {
|
|
64
|
-
const hits = repos.filter((r) => r.
|
|
65
|
+
return rows.map(({ repo, ...ref }) => {
|
|
66
|
+
const hits = repos.filter((r) => (repo === undefined || resolve(r.root) === resolve(repo)) &&
|
|
67
|
+
r.rows.some((row) => sameRow(row, ref)));
|
|
65
68
|
if (hits.length === 0)
|
|
66
69
|
throw new ApplyArgumentError(`row ${label(ref)} is not in the plan`);
|
|
67
70
|
if (hits.length > 1) {
|
|
@@ -83,6 +86,7 @@ function resweep(repo, opts) {
|
|
|
83
86
|
git: opts.git,
|
|
84
87
|
gh: opts.gh,
|
|
85
88
|
probe: opts.probe,
|
|
89
|
+
onProgress: opts.onSweepProgress,
|
|
86
90
|
});
|
|
87
91
|
if (sweep.repo === null || sweep.error !== null) {
|
|
88
92
|
throw new Error(sweep.error ?? `could not read repository: ${repo.root}`);
|
|
@@ -90,7 +94,7 @@ function resweep(repo, opts) {
|
|
|
90
94
|
return opts.annotate === undefined ? sweep.items : opts.annotate(sweep.repo, sweep.items);
|
|
91
95
|
}
|
|
92
96
|
/** Re-sweep, then split the selection into rows to run and rows dropped. */
|
|
93
|
-
function recheck(selected, grants, opts) {
|
|
97
|
+
function recheck(selected, grants, implicit, opts) {
|
|
94
98
|
const swept = new Map();
|
|
95
99
|
for (const { repo } of selected) {
|
|
96
100
|
if (!swept.has(repo))
|
|
@@ -112,6 +116,10 @@ function recheck(selected, grants, opts) {
|
|
|
112
116
|
drop('not-deletable', item.reason);
|
|
113
117
|
continue;
|
|
114
118
|
}
|
|
119
|
+
if (implicit && item.classification !== 'safe-to-delete') {
|
|
120
|
+
drop('needs-review', `${item.reason}; name it with --row to apply it`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
115
123
|
const now = fingerprint(item);
|
|
116
124
|
if (!sameFingerprint(planned.fingerprint, now)) {
|
|
117
125
|
drop('facts-changed', `planned ${JSON.stringify(planned.fingerprint)}, now ${JSON.stringify(now)}`);
|
|
@@ -234,7 +242,14 @@ function execute(step, row, git, probe) {
|
|
|
234
242
|
case 'delete-remote': {
|
|
235
243
|
const remote = String(row.item.facts['remote']);
|
|
236
244
|
const ref = `refs/heads/${String(row.item.facts['branch'])}`;
|
|
237
|
-
at.push(remote, `:${ref}`, {
|
|
245
|
+
const report = at.push(remote, `:${ref}`, {
|
|
246
|
+
forceWithLease: { ref, expect: step.expected_sha },
|
|
247
|
+
});
|
|
248
|
+
const refused = report.refs.find((r) => r.flag === '!');
|
|
249
|
+
if (refused?.flag === '!') {
|
|
250
|
+
const why = refused.rawReason === null ? '' : ` (${refused.rawReason})`;
|
|
251
|
+
throw new Error(`${refused.summary}${why}: ${PUSH_REJECTIONS[refused.rejection].message}`);
|
|
252
|
+
}
|
|
238
253
|
if (at.lsRemote(remote, ref).some((r) => r.ref === ref)) {
|
|
239
254
|
throw new Error(`ls-remote still lists ${ref}`);
|
|
240
255
|
}
|
|
@@ -243,20 +258,31 @@ function execute(step, row, git, probe) {
|
|
|
243
258
|
}
|
|
244
259
|
}
|
|
245
260
|
/**
|
|
246
|
-
* Apply the `requested` rows of `plan` under `grants`.
|
|
261
|
+
* Apply the `requested` rows of `plan` under `grants`. An empty `requested`
|
|
262
|
+
* selects every row of the plan (of `repoRoot`'s repository, when given) and
|
|
263
|
+
* then drops each one the re-sweep does not judge `safe-to-delete`: a row to
|
|
264
|
+
* review runs only when it is named. A row named twice runs once. A row whose
|
|
265
|
+
* `requires` was not granted is dropped, never run.
|
|
247
266
|
*
|
|
248
267
|
* @throws ApplyArgumentError before touching anything, when a row or grant does
|
|
249
268
|
* not fit the plan.
|
|
250
269
|
*/
|
|
251
270
|
export function applyPlan(plan, requested, grants, opts) {
|
|
252
271
|
const now = opts.now ?? (() => new Date());
|
|
253
|
-
|
|
272
|
+
// Every row of the plan keeps its repository, so a branch name two
|
|
273
|
+
// repositories share is two rows, not an ambiguity.
|
|
274
|
+
const wanted = requested.length > 0
|
|
275
|
+
? requested
|
|
276
|
+
: plan.repos
|
|
277
|
+
.filter((r) => opts.repoRoot === undefined || resolve(r.root) === resolve(opts.repoRoot))
|
|
278
|
+
.flatMap((r) => r.rows.map((row) => ({ category: row.category, id: row.id, repo: r.root })));
|
|
279
|
+
const rows = wanted.filter((row, i) => wanted.findIndex((other) => sameSelection(other, row)) === i);
|
|
254
280
|
if (rows.length === 0)
|
|
255
|
-
throw new ApplyArgumentError('no rows
|
|
281
|
+
throw new ApplyArgumentError('the plan has no rows to apply');
|
|
256
282
|
const selected = locate(plan, rows, opts.repoRoot);
|
|
257
283
|
for (const grant of grants) {
|
|
258
284
|
if (grant.row !== null && !rows.some((r) => sameRow(r, grant.row))) {
|
|
259
|
-
throw new ApplyArgumentError(`--
|
|
285
|
+
throw new ApplyArgumentError(`--allow names unselected row ${label(grant.row)}`);
|
|
260
286
|
}
|
|
261
287
|
}
|
|
262
288
|
const receipt = {
|
|
@@ -268,8 +294,8 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
268
294
|
dropped: [],
|
|
269
295
|
steps: [],
|
|
270
296
|
};
|
|
271
|
-
const { accepted, dropped } = recheck(selected, grants, opts);
|
|
272
|
-
receipt.dropped = dropped;
|
|
297
|
+
const { accepted, dropped } = recheck(selected, grants, requested.length === 0, opts);
|
|
298
|
+
receipt.dropped = [...(opts.dropped ?? []), ...dropped];
|
|
273
299
|
const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
|
|
274
300
|
const plannedSteps = ordered.flatMap((row) => stepsFor(row).map((step) => [step, row]));
|
|
275
301
|
for (const repo of new Set(ordered.map((row) => row.repo))) {
|
|
@@ -282,14 +308,19 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
282
308
|
receipt.finished_at = now().toISOString();
|
|
283
309
|
return receipt;
|
|
284
310
|
}
|
|
285
|
-
const
|
|
311
|
+
const receiptPath = opts.receiptPath;
|
|
312
|
+
const save = () => {
|
|
313
|
+
if (receiptPath !== null)
|
|
314
|
+
writeJsonAtomic(receiptPath, receipt);
|
|
315
|
+
};
|
|
286
316
|
save();
|
|
287
317
|
const git = new Git(opts.callerCwd, { runner: opts.git });
|
|
288
318
|
const probe = opts.probe ?? defaultProcessProbe();
|
|
289
319
|
// Worktrees whose lock this run cleared. typed-git has no `worktree lock`
|
|
290
320
|
// verb, so a failed remove cannot put the lock back; the receipt says so.
|
|
291
321
|
const unlocked = new Set();
|
|
292
|
-
for (const [step, row] of plannedSteps) {
|
|
322
|
+
for (const [i, [step, row]] of plannedSteps.entries()) {
|
|
323
|
+
opts.onStep?.(i, plannedSteps.length, step);
|
|
293
324
|
try {
|
|
294
325
|
step.verification = execute(step, row, git, probe);
|
|
295
326
|
step.result = 'ok';
|
package/dist/branches.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare function resolveBranch(value: string, git: Git): string;
|
|
|
38
38
|
export declare function localDeleteCommand(name: string, sha: string): string;
|
|
39
39
|
/** Delete `remote`'s branch `name` only while it still points at `sha`. */
|
|
40
40
|
export declare function remoteDeleteCommand(remote: string, name: string, sha: string): string;
|
|
41
|
-
export declare function gatherLocalBranches(repo: RepoInfo, prs: Map<string, PRInfo>, worktreeBranches: ReadonlySet<string>, git: Git, only?: string): SweepItem[];
|
|
41
|
+
export declare function gatherLocalBranches(repo: RepoInfo, prs: Map<string, PRInfo>, worktreeBranches: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): SweepItem[];
|
|
42
42
|
export declare function classifyLocalBranch(opts: {
|
|
43
43
|
name: string;
|
|
44
44
|
upstream: string;
|
|
@@ -50,7 +50,7 @@ export declare function classifyLocalBranch(opts: {
|
|
|
50
50
|
worktreeBranches: ReadonlySet<string>;
|
|
51
51
|
git: Git;
|
|
52
52
|
}): SweepItem;
|
|
53
|
-
export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, only?: string): SweepItem[];
|
|
53
|
+
export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): SweepItem[];
|
|
54
54
|
export declare function classifyRemoteBranch(opts: {
|
|
55
55
|
name: string;
|
|
56
56
|
sha: string;
|
package/dist/branches.js
CHANGED
|
@@ -95,10 +95,11 @@ export function remoteDeleteCommand(remote, name, sha) {
|
|
|
95
95
|
]);
|
|
96
96
|
}
|
|
97
97
|
// ── local branches ─────────────────────────────────────────────────────────
|
|
98
|
-
export function gatherLocalBranches(repo, prs, worktreeBranches, git, only) {
|
|
98
|
+
export function gatherLocalBranches(repo, prs, worktreeBranches, git, only, tick) {
|
|
99
99
|
const rows = git.at(repo.root).forEachRef('refs/heads/', { format: LOCAL_FORMAT });
|
|
100
100
|
const items = [];
|
|
101
|
-
for (const line of rows) {
|
|
101
|
+
for (const [i, line] of rows.entries()) {
|
|
102
|
+
tick?.(i, rows.length);
|
|
102
103
|
const parts = line.split('\t');
|
|
103
104
|
if (parts.length !== 5)
|
|
104
105
|
continue;
|
|
@@ -140,10 +141,20 @@ export function classifyLocalBranch(opts) {
|
|
|
140
141
|
const pinned = (reason) => keep(reason, { deletable: false });
|
|
141
142
|
if (name === repo.mainBranch)
|
|
142
143
|
return pinned('default branch');
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
const held = name === repo.currentBranch
|
|
145
|
+
? 'current HEAD'
|
|
146
|
+
: worktreeBranches.has(name)
|
|
147
|
+
? 'checked out in a worktree (handle via that worktree)'
|
|
148
|
+
: null;
|
|
149
|
+
if (held !== null) {
|
|
150
|
+
// Never deletable here, but still counted as merged or not: by ancestry or
|
|
151
|
+
// an exact-head merged PR, without the patch checks a deletable row gets.
|
|
152
|
+
// `soft`: a missing default ref reads as not merged.
|
|
153
|
+
const pr = prs.get(name);
|
|
154
|
+
const { landed } = recordPr(facts, pr, sha, repo);
|
|
155
|
+
facts['merged'] = landed || git.at(repo.root).soft.isAncestor(sha, landedRef(repo));
|
|
156
|
+
return pinned(held);
|
|
157
|
+
}
|
|
147
158
|
// Work merged only into an unpushed local default has not landed. A missing
|
|
148
159
|
// remote-tracking ref exits 128: ancestry unknown (null).
|
|
149
160
|
const compareRef = landedRef(repo);
|
|
@@ -161,6 +172,8 @@ export function classifyLocalBranch(opts) {
|
|
|
161
172
|
recordLanding(facts, patch);
|
|
162
173
|
const patchLanded = patch !== null && patch.check !== null;
|
|
163
174
|
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
175
|
+
// Merged: the work is on the default branch, by ancestry, an exact-head merged PR or a patch match.
|
|
176
|
+
facts['merged'] = landed || isAncestor || patchLanded;
|
|
164
177
|
if (!landed && !isAncestor) {
|
|
165
178
|
facts['unique_commits'] = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
|
|
166
179
|
}
|
|
@@ -230,7 +243,7 @@ export function classifyLocalBranch(opts) {
|
|
|
230
243
|
});
|
|
231
244
|
}
|
|
232
245
|
// ── remote branches ────────────────────────────────────────────────────────
|
|
233
|
-
export function gatherRemoteBranches(repo, prs, localBranchNames, git, only) {
|
|
246
|
+
export function gatherRemoteBranches(repo, prs, localBranchNames, git, only, tick) {
|
|
234
247
|
if (repo.remote === null)
|
|
235
248
|
return [];
|
|
236
249
|
// Default door: an unreachable remote throws rather than rendering as "the
|
|
@@ -238,7 +251,8 @@ export function gatherRemoteBranches(repo, prs, localBranchNames, git, only) {
|
|
|
238
251
|
const pattern = only === undefined ? undefined : `refs/heads/${only}`;
|
|
239
252
|
const rows = git.at(repo.root).lsRemote(repo.remote, pattern, { heads: true });
|
|
240
253
|
const items = [];
|
|
241
|
-
for (const { sha, ref } of rows) {
|
|
254
|
+
for (const [i, { sha, ref }] of rows.entries()) {
|
|
255
|
+
tick?.(i, rows.length);
|
|
242
256
|
if (!ref.startsWith('refs/heads/'))
|
|
243
257
|
continue;
|
|
244
258
|
const name = ref.slice('refs/heads/'.length);
|
|
@@ -283,6 +297,8 @@ export function classifyRemoteBranch(opts) {
|
|
|
283
297
|
recordLanding(facts, patch);
|
|
284
298
|
const patchLanded = patch !== null && patch.check !== null;
|
|
285
299
|
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
300
|
+
// Merged: the work is on the default branch, by ancestry, an exact-head merged PR or a patch match.
|
|
301
|
+
facts['merged'] = landed || isAncestor || patchLanded;
|
|
286
302
|
if (!landed && !isAncestor) {
|
|
287
303
|
// Unknown (null) when the clone lacks `sha` or `remoteMain`, as above.
|
|
288
304
|
const counted = git.at(repo.root).try.aheadBehind(remoteMain, sha);
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `repo-cleaner` — what has been merged, and what can go away.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Four verbs over one set of sweep filters:
|
|
5
|
+
*
|
|
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
|
|
8
|
+
* plan write the deletable rows as a plan, to stdout or a file
|
|
9
|
+
* apply re-check, show the steps, ask, then delete
|
|
10
|
+
*
|
|
11
|
+
* `apply` works from a saved plan (`--plan`) or sweeps first. Either way it
|
|
12
|
+
* re-checks every row, prints the steps it would run and asks before deleting;
|
|
13
|
+
* `--yes` skips the question. Without a terminal and without `--yes` it
|
|
14
|
+
* refuses rather than deleting. Scope is opt-in: the sweep covers the
|
|
15
|
+
* repository you are standing in unless a path says otherwise.
|
|
9
16
|
*
|
|
10
17
|
* Exit codes:
|
|
11
|
-
* 0 swept; or
|
|
18
|
+
* 0 swept; or apply ran, was declined, or had nothing to run
|
|
12
19
|
* 1 failed against the state it found
|
|
13
20
|
* 2 cannot run as typed
|
|
14
21
|
* 10 nothing to sweep: no entry named a repository
|
|
15
22
|
* 11 apply stopped at a failed step; the receipt says which
|
|
16
23
|
*/
|
|
17
24
|
import { type CliContext } from '@osdlc/cli-tool';
|
|
25
|
+
/**
|
|
26
|
+
* Asks a yes/no question, or returns null when there is no one to ask. `main`
|
|
27
|
+
* takes it as a parameter so a test can answer.
|
|
28
|
+
*/
|
|
29
|
+
export type Confirm = (question: string) => boolean | null;
|
|
30
|
+
export interface MainDeps {
|
|
31
|
+
confirm?: Confirm;
|
|
32
|
+
/** Reads `--plan -`. Defaults to stdin. */
|
|
33
|
+
readStdin?: () => string;
|
|
34
|
+
/** Whether stderr is a terminal, so a status line may be drawn. Defaults to `isatty(2)`. */
|
|
35
|
+
stderrIsTerminal?: boolean;
|
|
36
|
+
}
|
|
18
37
|
/** `main(argv, ctx)`: returns an exit code, never exits. */
|
|
19
|
-
export declare function main(argv: readonly string[], ctx: CliContext): number;
|
|
38
|
+
export declare function main(argv: readonly string[], ctx: CliContext, deps?: MainDeps): number;
|