@osdlc/repo-cleaner 0.9.2 → 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 CHANGED
@@ -1,5 +1,43 @@
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
+
3
41
  ## 0.9.2
4
42
 
5
43
  ### 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
- The sweep never deletes anything. It emits commands, and can write the
11
- deletable rows as a plan. `repo-cleaner apply` deletes the plan rows you
12
- select, after checking each one again.
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
- | `--all` | Sweep the repositories named in the config file |
239
- | `--config <path>` | Config file to read |
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
- A checkout directory named `apply` is swept as `./apply`, and one named `dev` as
253
- `./dev`: `dev` runs the tool from a checkout (see Running from a checkout).
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 --branch HEAD --plan-out plan.json
259
- repo-cleaner apply --plan plan.json --row worktree:/path/to/wt --row local_branch:feat/x
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 again and drops a selected row that is gone, no longer
264
- deletable, whose facts moved, or whose `requires` was not granted with
265
- `--authorize <name>` (every row) or `--authorize <name>=<category>:<id>` (one
266
- row). It then removes worktrees, deletes local branches, deletes remote
267
- branches and prunes, in that order. Branch deletes are compare-and-swap, every
268
- step is verified, and the first failure stops the run.
269
-
270
- A receipt records every step and is rewritten after each one. Plans and receipts go under
271
- `repo-cleaner/plans/` and `repo-cleaner/receipts/` in
272
- [`@osdlc/tiered-dirs`](https://www.npmjs.com/package/@osdlc/tiered-dirs)' standalone durable home
273
- (`repo-cleaner` directory, `REPO_CLEANER_*` override variables), unless `--plan-out` or `--receipt-dir` says
274
- otherwise. `--dry-run` checks again and prints the steps without writing anything.
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 every selected row was applied or dropped with a reason |
281
- | `1` | Failed against the state it found: a `{name}` token nothing defines, an unreadable plan, a row not in the plan |
282
- | `2` | Cannot run as typed: an unknown flag, a malformed `--row`, `--branch` with `--all` or a path |
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 goes. Unused under `dryRun`. */
42
- receiptPath: string;
43
- /** Shown in the receipt. */
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
- * Apply the `requested` rows of `plan` under `grants`. A row named twice runs once.
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 RowRef[], grants: readonly Grant[], opts: ApplyOptions): Receipt;
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.rows.some((row) => sameRow(row, ref)));
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}`, { forceWithLease: { ref, expect: step.expected_sha } });
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`. A row named twice runs once.
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
- const rows = requested.filter((row, i) => requested.findIndex((other) => sameRow(other, row)) === i);
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 selected');
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(`--authorize names unselected row ${label(grant.row)}`);
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 save = () => writeJsonAtomic(opts.receiptPath, receipt);
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';
@@ -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
- if (name === repo.currentBranch)
144
- return pinned('current HEAD');
145
- if (worktreeBranches.has(name))
146
- return pinned('checked out in a worktree (handle via that worktree)');
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
- * Two commands. A bare invocation sweeps: it inventories, fetching each
5
- * remote first unless `--no-fetch`, and writes nothing but an optional plan.
6
- * `repo-cleaner apply` deletes the rows of a plan that were selected and
7
- * authorized. Scope is opt-in: the sweep covers the repository you are
8
- * standing in unless `--all` or a path says otherwise.
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 every selected row was applied or dropped with a reason
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;