@osdlc/repo-cleaner 0.9.2 → 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 +55 -0
- package/README.md +89 -62
- package/dist/apply.d.ts +45 -7
- package/dist/apply.js +118 -14
- package/dist/branches.d.ts +12 -2
- package/dist/branches.js +34 -8
- package/dist/cli.d.ts +26 -7
- package/dist/cli.js +472 -184
- package/dist/devlink.d.ts +5 -0
- package/dist/devlink.js +3 -1
- package/dist/index.d.ts +5 -6
- package/dist/index.js +3 -4
- package/dist/plan.d.ts +7 -3
- package/dist/plan.js +24 -11
- package/dist/receipt.d.ts +6 -0
- package/dist/receipt.js +11 -2
- package/dist/render.d.ts +33 -1
- package/dist/render.js +134 -1
- 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 +21 -5
- package/package.json +7 -7
- package/dist/config.d.ts +0 -53
- package/dist/config.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
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
|
+
|
|
20
|
+
## 0.10.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 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`.
|
|
25
|
+
- 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`.
|
|
26
|
+
|
|
27
|
+
Library: `SweepOptions.onProgress` receives a `SweepProgress` before each step and each item; `ApplyOptions.onSweepProgress` and `onStep` do the same for `applyPlan`.
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
- 6e4284b: **Breaking:** the CLI is three commands over one set of sweep options, and `apply` asks before it deletes.
|
|
32
|
+
|
|
33
|
+
- `repo-cleaner [status]` is the report, as before.
|
|
34
|
+
- `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.
|
|
35
|
+
- `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.
|
|
36
|
+
- `--only worktrees,branches,remotes` replaces `--scope local-branches,worktrees,remote-branches`, and works on every command.
|
|
37
|
+
- `--allow <risk>[=<category>:<id>]` replaces `--authorize`.
|
|
38
|
+
- 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`.
|
|
39
|
+
|
|
40
|
+
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`.
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- 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.
|
|
45
|
+
- 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.
|
|
46
|
+
|
|
47
|
+
Library: `applyPlan` takes `RowSelection` rows (a row with an optional `repo`) and `ApplyOptions.dropped`.
|
|
48
|
+
|
|
49
|
+
## 0.9.3
|
|
50
|
+
|
|
51
|
+
### Patch Changes
|
|
52
|
+
|
|
53
|
+
- 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.
|
|
54
|
+
- Updated dependencies [e0aaba5]
|
|
55
|
+
- @osdlc/typed-git@0.12.0
|
|
56
|
+
- @osdlc/typed-gh@0.14.1
|
|
57
|
+
|
|
3
58
|
## 0.9.2
|
|
4
59
|
|
|
5
60
|
### 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,88 @@ 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
|
+
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
|
+
|
|
329
|
+
A receipt records every step and is rewritten after each one. With
|
|
330
|
+
`--plan plan.json` it goes beside the plan, as
|
|
331
|
+
`plan.receipt-<timestamp>.json`. `--receipt <file>` puts it anywhere. A run
|
|
332
|
+
that swept, or read its plan from stdin, writes a receipt only when
|
|
333
|
+
`--receipt` names one; the printed steps are its record.
|
|
275
334
|
|
|
276
335
|
## Exit codes
|
|
277
336
|
|
|
278
337
|
| Code | Meaning |
|
|
279
338
|
| --- | --- |
|
|
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
|
|
339
|
+
| `0` | Swept; or `apply` ran, was declined, or had nothing to run |
|
|
340
|
+
| `1` | Failed against the state it found: an unreadable plan, a row not in the plan |
|
|
341
|
+
| `2` | Cannot run as typed: an unknown flag, a malformed `--row`, `--branch` with a path, `apply` with nobody to ask and no `--yes` |
|
|
283
342
|
| `10` | Nothing to sweep: no entry named a repository |
|
|
284
343
|
| `11` | `apply` stopped at a failed step; the receipt says which |
|
|
285
344
|
|
|
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
345
|
## As a library
|
|
319
346
|
|
|
320
347
|
```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
|
|
22
|
-
import { type Authorization } 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;
|
|
@@ -38,13 +38,33 @@ 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
|
+
/**
|
|
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;
|
|
62
|
+
/** Rows dropped before this run, such as by a preview; recorded first in the receipt. */
|
|
63
|
+
dropped?: readonly Dropped[] | undefined;
|
|
64
|
+
/** Passed to the re-sweep's `onProgress`, for a status line. */
|
|
65
|
+
onSweepProgress?: ((progress: SweepProgress) => void) | undefined;
|
|
66
|
+
/** Told before each step runs: its 0-based index, the step count and the step. Not called under `dryRun`. */
|
|
67
|
+
onStep?: ((index: number, total: number, step: Step) => void) | undefined;
|
|
48
68
|
}
|
|
49
69
|
/** A selection or grant the plan cannot satisfy. Nothing has been touched. */
|
|
50
70
|
export declare class ApplyArgumentError extends Error {
|
|
@@ -54,9 +74,27 @@ export declare function parseRowRef(text: string): RowRef;
|
|
|
54
74
|
/** `<name>` for every row, or `<name>=<category>:<id>` for one. */
|
|
55
75
|
export declare function parseGrant(text: string): Grant;
|
|
56
76
|
/**
|
|
57
|
-
*
|
|
77
|
+
* A row to apply, optionally pinned to the plan repository rooted at `repo`.
|
|
78
|
+
* Without `repo`, a row id that two plan repositories share is ambiguous.
|
|
79
|
+
*/
|
|
80
|
+
export type RowSelection = RowRef & {
|
|
81
|
+
repo?: string;
|
|
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;
|
|
90
|
+
/**
|
|
91
|
+
* Apply the `requested` rows of `plan` under `grants`. An empty `requested`
|
|
92
|
+
* selects every row of the plan (of `repoRoot`'s repository, when given) and
|
|
93
|
+
* then drops each one the re-sweep does not judge `safe-to-delete`: a row to
|
|
94
|
+
* review runs only when it is named. A row named twice runs once. A row whose
|
|
95
|
+
* `requires` was not granted is dropped, never run.
|
|
58
96
|
*
|
|
59
97
|
* @throws ApplyArgumentError before touching anything, when a row or grant does
|
|
60
98
|
* not fit the plan.
|
|
61
99
|
*/
|
|
62
|
-
export declare function applyPlan(plan: Plan, requested: readonly
|
|
100
|
+
export declare function applyPlan(plan: Plan, requested: readonly RowSelection[], grants: readonly Grant[], opts: ApplyOptions): Receipt;
|
package/dist/apply.js
CHANGED
|
@@ -16,10 +16,11 @@
|
|
|
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";
|
|
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";
|
|
@@ -53,6 +54,8 @@ function errorText(err) {
|
|
|
53
54
|
return err.detail.trim() || err.message;
|
|
54
55
|
return err instanceof Error ? err.message : String(err);
|
|
55
56
|
}
|
|
57
|
+
const sameSelection = (a, b) => sameRow(a, b) &&
|
|
58
|
+
(a.repo === undefined ? '' : resolve(a.repo)) === (b.repo === undefined ? '' : resolve(b.repo));
|
|
56
59
|
/** Each selected row's plan repository. Unknown and ambiguous rows are errors. */
|
|
57
60
|
function locate(plan, rows, repoRoot) {
|
|
58
61
|
const repos = repoRoot === undefined
|
|
@@ -60,8 +63,9 @@ function locate(plan, rows, repoRoot) {
|
|
|
60
63
|
: plan.repos.filter((r) => resolve(r.root) === resolve(repoRoot));
|
|
61
64
|
if (repos.length === 0)
|
|
62
65
|
throw new ApplyArgumentError(`no plan repository at ${repoRoot}`);
|
|
63
|
-
return rows.map((ref) => {
|
|
64
|
-
const hits = repos.filter((r) => r.
|
|
66
|
+
return rows.map(({ repo, ...ref }) => {
|
|
67
|
+
const hits = repos.filter((r) => (repo === undefined || resolve(r.root) === resolve(repo)) &&
|
|
68
|
+
r.rows.some((row) => sameRow(row, ref)));
|
|
65
69
|
if (hits.length === 0)
|
|
66
70
|
throw new ApplyArgumentError(`row ${label(ref)} is not in the plan`);
|
|
67
71
|
if (hits.length > 1) {
|
|
@@ -75,6 +79,10 @@ function granted(grants, ref, name) {
|
|
|
75
79
|
}
|
|
76
80
|
/** The plan repository's rows as they stand now, with the host's overlay applied. */
|
|
77
81
|
function resweep(repo, opts) {
|
|
82
|
+
const given = opts.swept?.get(repo.root);
|
|
83
|
+
if (given !== undefined) {
|
|
84
|
+
return [...given];
|
|
85
|
+
}
|
|
78
86
|
const sweep = sweepRepo(repo.root, {
|
|
79
87
|
remote: repo.remote,
|
|
80
88
|
noGh: repo.no_gh,
|
|
@@ -83,6 +91,7 @@ function resweep(repo, opts) {
|
|
|
83
91
|
git: opts.git,
|
|
84
92
|
gh: opts.gh,
|
|
85
93
|
probe: opts.probe,
|
|
94
|
+
onProgress: opts.onSweepProgress,
|
|
86
95
|
});
|
|
87
96
|
if (sweep.repo === null || sweep.error !== null) {
|
|
88
97
|
throw new Error(sweep.error ?? `could not read repository: ${repo.root}`);
|
|
@@ -90,7 +99,7 @@ function resweep(repo, opts) {
|
|
|
90
99
|
return opts.annotate === undefined ? sweep.items : opts.annotate(sweep.repo, sweep.items);
|
|
91
100
|
}
|
|
92
101
|
/** Re-sweep, then split the selection into rows to run and rows dropped. */
|
|
93
|
-
function recheck(selected, grants, opts) {
|
|
102
|
+
function recheck(selected, grants, implicit, opts) {
|
|
94
103
|
const swept = new Map();
|
|
95
104
|
for (const { repo } of selected) {
|
|
96
105
|
if (!swept.has(repo))
|
|
@@ -112,6 +121,10 @@ function recheck(selected, grants, opts) {
|
|
|
112
121
|
drop('not-deletable', item.reason);
|
|
113
122
|
continue;
|
|
114
123
|
}
|
|
124
|
+
if (implicit && item.classification !== 'safe-to-delete') {
|
|
125
|
+
drop('needs-review', `${item.reason}; name it with --row to apply it`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
115
128
|
const now = fingerprint(item);
|
|
116
129
|
if (!sameFingerprint(planned.fingerprint, now)) {
|
|
117
130
|
drop('facts-changed', `planned ${JSON.stringify(planned.fingerprint)}, now ${JSON.stringify(now)}`);
|
|
@@ -234,7 +247,14 @@ function execute(step, row, git, probe) {
|
|
|
234
247
|
case 'delete-remote': {
|
|
235
248
|
const remote = String(row.item.facts['remote']);
|
|
236
249
|
const ref = `refs/heads/${String(row.item.facts['branch'])}`;
|
|
237
|
-
at.push(remote, `:${ref}`, {
|
|
250
|
+
const report = at.push(remote, `:${ref}`, {
|
|
251
|
+
forceWithLease: { ref, expect: step.expected_sha },
|
|
252
|
+
});
|
|
253
|
+
const refused = report.refs.find((r) => r.flag === '!');
|
|
254
|
+
if (refused?.flag === '!') {
|
|
255
|
+
const why = refused.rawReason === null ? '' : ` (${refused.rawReason})`;
|
|
256
|
+
throw new Error(`${refused.summary}${why}: ${PUSH_REJECTIONS[refused.rejection].message}`);
|
|
257
|
+
}
|
|
238
258
|
if (at.lsRemote(remote, ref).some((r) => r.ref === ref)) {
|
|
239
259
|
throw new Error(`ls-remote still lists ${ref}`);
|
|
240
260
|
}
|
|
@@ -243,24 +263,102 @@ function execute(step, row, git, probe) {
|
|
|
243
263
|
}
|
|
244
264
|
}
|
|
245
265
|
/**
|
|
246
|
-
*
|
|
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
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Apply the `requested` rows of `plan` under `grants`. An empty `requested`
|
|
334
|
+
* selects every row of the plan (of `repoRoot`'s repository, when given) and
|
|
335
|
+
* then drops each one the re-sweep does not judge `safe-to-delete`: a row to
|
|
336
|
+
* review runs only when it is named. A row named twice runs once. A row whose
|
|
337
|
+
* `requires` was not granted is dropped, never run.
|
|
247
338
|
*
|
|
248
339
|
* @throws ApplyArgumentError before touching anything, when a row or grant does
|
|
249
340
|
* not fit the plan.
|
|
250
341
|
*/
|
|
251
342
|
export function applyPlan(plan, requested, grants, opts) {
|
|
252
343
|
const now = opts.now ?? (() => new Date());
|
|
253
|
-
|
|
344
|
+
// Every row of the plan keeps its repository, so a branch name two
|
|
345
|
+
// repositories share is two rows, not an ambiguity.
|
|
346
|
+
const wanted = requested.length > 0
|
|
347
|
+
? requested
|
|
348
|
+
: plan.repos
|
|
349
|
+
.filter((r) => opts.repoRoot === undefined || resolve(r.root) === resolve(opts.repoRoot))
|
|
350
|
+
.flatMap((r) => r.rows.map((row) => ({ category: row.category, id: row.id, repo: r.root })));
|
|
351
|
+
const rows = wanted.filter((row, i) => wanted.findIndex((other) => sameSelection(other, row)) === i);
|
|
254
352
|
if (rows.length === 0)
|
|
255
|
-
throw new ApplyArgumentError('no rows
|
|
353
|
+
throw new ApplyArgumentError('the plan has no rows to apply');
|
|
256
354
|
const selected = locate(plan, rows, opts.repoRoot);
|
|
257
355
|
for (const grant of grants) {
|
|
258
356
|
if (grant.row !== null && !rows.some((r) => sameRow(r, grant.row))) {
|
|
259
|
-
throw new ApplyArgumentError(`--
|
|
357
|
+
throw new ApplyArgumentError(`--allow names unselected row ${label(grant.row)}`);
|
|
260
358
|
}
|
|
261
359
|
}
|
|
262
360
|
const receipt = {
|
|
263
|
-
version:
|
|
361
|
+
version: RECEIPT_VERSION,
|
|
264
362
|
plan: opts.planPath,
|
|
265
363
|
status: opts.dryRun ? 'dry-run' : 'applying',
|
|
266
364
|
started_at: now().toISOString(),
|
|
@@ -268,8 +366,9 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
268
366
|
dropped: [],
|
|
269
367
|
steps: [],
|
|
270
368
|
};
|
|
271
|
-
const
|
|
272
|
-
|
|
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 }));
|
|
371
|
+
receipt.dropped = [...(opts.dropped ?? []), ...dropped];
|
|
273
372
|
const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
|
|
274
373
|
const plannedSteps = ordered.flatMap((row) => stepsFor(row).map((step) => [step, row]));
|
|
275
374
|
for (const repo of new Set(ordered.map((row) => row.repo))) {
|
|
@@ -282,14 +381,19 @@ export function applyPlan(plan, requested, grants, opts) {
|
|
|
282
381
|
receipt.finished_at = now().toISOString();
|
|
283
382
|
return receipt;
|
|
284
383
|
}
|
|
285
|
-
const
|
|
384
|
+
const receiptPath = opts.receiptPath;
|
|
385
|
+
const save = () => {
|
|
386
|
+
if (receiptPath !== null)
|
|
387
|
+
writeJsonAtomic(receiptPath, receipt);
|
|
388
|
+
};
|
|
286
389
|
save();
|
|
287
390
|
const git = new Git(opts.callerCwd, { runner: opts.git });
|
|
288
391
|
const probe = opts.probe ?? defaultProcessProbe();
|
|
289
392
|
// Worktrees whose lock this run cleared. typed-git has no `worktree lock`
|
|
290
393
|
// verb, so a failed remove cannot put the lock back; the receipt says so.
|
|
291
394
|
const unlocked = new Set();
|
|
292
|
-
for (const [step, row] of plannedSteps) {
|
|
395
|
+
for (const [i, [step, row]] of plannedSteps.entries()) {
|
|
396
|
+
opts.onStep?.(i, plannedSteps.length, step);
|
|
293
397
|
try {
|
|
294
398
|
step.verification = execute(step, row, git, probe);
|
|
295
399
|
step.result = 'ok';
|
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
|
/**
|
|
@@ -38,7 +48,7 @@ export declare function resolveBranch(value: string, git: Git): string;
|
|
|
38
48
|
export declare function localDeleteCommand(name: string, sha: string): string;
|
|
39
49
|
/** Delete `remote`'s branch `name` only while it still points at `sha`. */
|
|
40
50
|
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[];
|
|
51
|
+
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
52
|
export declare function classifyLocalBranch(opts: {
|
|
43
53
|
name: string;
|
|
44
54
|
upstream: string;
|
|
@@ -50,7 +60,7 @@ export declare function classifyLocalBranch(opts: {
|
|
|
50
60
|
worktreeBranches: ReadonlySet<string>;
|
|
51
61
|
git: Git;
|
|
52
62
|
}): SweepItem;
|
|
53
|
-
export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, only?: string): SweepItem[];
|
|
63
|
+
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
64
|
export declare function classifyRemoteBranch(opts: {
|
|
55
65
|
name: string;
|
|
56
66
|
sha: string;
|