@osdlc/repo-cleaner 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # @osdlc/repo-cleaner
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 14d4351: A worktree row whose directory is gone, or is no longer a checkout, now reads `prune` instead of `delete` in the report, and `summary` counts those rows in a `prune` column (and a `prune` count in each JSON bucket, a part of `safe-to-delete`). The row's `action` is `prune`.
8
+
9
+ New `--remote-url <url>` on every sweeping command: sweep a remote's branches with no checkout of your own. The URL is cloned once (`--no-checkout`) under the cache directory and reused; deletes are pushed from that clone, so a plan written with `--remote-url` can be applied later from anywhere.
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [14d4351]
14
+ - Updated dependencies [dd2265e]
15
+ - @osdlc/cli-tool@0.13.0
16
+
17
+ ## 0.10.1
18
+
19
+ ### Patch Changes
20
+
21
+ - cde11b5: `apply` fixes:
22
+
23
+ - 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.
24
+ - 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.
25
+ - 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.
26
+ - A run that deletes without a receipt file says so on stderr.
27
+ - 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.
28
+ - The prompt's `Delete N items?` counts rows, not steps: a stale-locked worktree is one item, not two.
29
+ - `--repo-root` naming no repository in the plan exits 1 instead of reporting nothing to delete.
30
+ - An empty plan's JSON receipt says `complete`, not `dry-run`, unless the run was `--dry-run`.
31
+
32
+ Library: `ApplyOptions.safeOnly` and `ApplyOptions.swept`, the `duplicate` drop reason, `RECEIPT_VERSION`, and `summarizeReceipt`, `renderApplySummary` and `ApplySummary`.
33
+
3
34
  ## 0.10.0
4
35
 
5
36
  ### Minor Changes
package/README.md CHANGED
@@ -205,7 +205,7 @@ worktree, the primary or caller's worktree) has `deletable: false`.
205
205
 
206
206
  ```bash
207
207
  repo-cleaner [status] # the report: what is merged, what can go (the default)
208
- repo-cleaner summary # counts per repository: total, merged, delete, review, keep
208
+ repo-cleaner summary # counts per repository: total, merged, delete, prune, review, keep
209
209
  repo-cleaner plan # the deletable rows as a plan, to stdout or -o <file>
210
210
  repo-cleaner apply # re-check, show the steps, ask, delete
211
211
  ```
@@ -215,14 +215,21 @@ All four take the same paths and sweep options.
215
215
  ```console
216
216
  $ repo-cleaner summary
217
217
  /Users/me/work/api (acme/api, default main, origin)
218
- total merged delete review keep
219
- local branches 12 8 6 1 5
220
- worktrees 4 3 2 1 1
221
- remote branches 40 31 30 6 4
218
+ total merged delete prune review keep
219
+ local branches 12 8 6 0 1 5
220
+ worktrees 4 3 1 1 1 1
221
+ remote branches 40 31 30 0 6 4
222
222
 
223
- 38 to delete, 8 to review, across 1 repository.
223
+ 37 to delete, 1 to prune, 8 to review, across 1 repository.
224
224
  ```
225
225
 
226
+ `prune` counts worktree rows whose directory is gone, or is no longer a
227
+ checkout: deleting one runs `git worktree prune` to drop the stale link, and
228
+ a directory that is still there stays. The report labels those rows `prune`
229
+ rather than `delete`. In `--output json` they are `safe-to-delete` with
230
+ `action: "prune"`, and each summary bucket has a `prune` count, a part of its
231
+ `safe-to-delete` count.
232
+
226
233
  `merged` counts rows whose work is on the default branch: by ancestry, by a
227
234
  merged PR at the branch's head, or by a patch match. A branch checked out in a
228
235
  worktree counts as merged by ancestry or PR alone. The default branch and the
@@ -258,6 +265,34 @@ An entry that resolves to nothing is reported rather than dropped. A worklist
258
265
  that silently sweeps four of the five repositories it was given is worse than
259
266
  one that says which it could not find.
260
267
 
268
+ ### A remote with no checkout
269
+
270
+ ```bash
271
+ repo-cleaner --remote-url git@github.com:acme/api.git
272
+ repo-cleaner apply --remote-url git@github.com:acme/api.git
273
+ ```
274
+
275
+ `--remote-url <url>` sweeps a remote's branches when you have no checkout of
276
+ it. It clones the URL once, with `--no-checkout`, into
277
+ `repo-cleaner/remotes/` under the cache directory (`~/Library/Caches` on
278
+ macOS, `$XDG_CACHE_HOME` or `~/.cache` on Linux, `REPO_CLEANER_CACHE_DIR`
279
+ to move it), and sweeps that clone. Later runs reuse the clone and fetch. Only remote branches
280
+ are swept: the clone's own branches and worktree are not yours. Deletes are
281
+ pushed to the URL from the clone, compare-and-swap as always.
282
+
283
+ The clone is kept, not deleted after the run, so a plan written with
284
+ `--remote-url` can be applied later from anywhere: the plan names the clone's
285
+ directory. Remove the directory to drop it. The clone is a full one, not
286
+ `--filter=blob:none`: the patch check reads file contents, and a partial
287
+ clone fetches them one branch at a time.
288
+
289
+ The flag repeats for several remotes. It does not combine with paths,
290
+ `--branch` or `--plan`, `--only` may only name `remotes`, and an empty URL is
291
+ refused. A clone is reused only while its `origin` fetches from that URL and
292
+ pushes to it alone; `apply --plan` checks the same of a plan's clone before
293
+ deleting. The clone's directory name and every message carry the URL with
294
+ its credentials, query and fragment removed.
295
+
261
296
  ## Options
262
297
 
263
298
  Every command that sweeps takes these:
@@ -267,6 +302,7 @@ Every command that sweeps takes these:
267
302
  | `--only <list>` | `worktrees`, `branches`, `remotes` (default: all three) |
268
303
  | `--branch <name\|HEAD>` | One branch of this repository: its worktree, local and remote branch. Refuses the default branch |
269
304
  | `--remote <name>` | Remote to measure against (default `origin`) |
305
+ | `--remote-url <url>` | Sweep this remote's branches with no checkout of your own (see [A remote with no checkout](#a-remote-with-no-checkout)); repeatable |
270
306
  | `--no-gh` | Classify on git evidence alone |
271
307
  | `--no-fetch` | Skip the fetch; the header then says `not fetched` |
272
308
 
@@ -312,6 +348,20 @@ On yes it runs only the steps it showed: worktrees first, then local branches,
312
348
  remote branches and a prune, in that order. Branch deletes are
313
349
  compare-and-swap, every step is verified, and the first failure stops the run.
314
350
 
351
+ Every run ends with what it did:
352
+
353
+ ```text
354
+ deleted: 1 worktree, 3 local branches, 2 remote branches, 1 stale worktree pruned
355
+ dropped 2: 1 needs-review, 1 duplicate
356
+ status: complete
357
+ ```
358
+
359
+ The preview says `would delete:`, and a run that stops at a failed step says
360
+ how many rows it did not reach. `--output json` carries the same counts as
361
+ `summary`. Two clones of one remote list the same remote branches; `apply`
362
+ deletes each once and drops the other clone's row as `duplicate`. Remotes are
363
+ compared by the push URL git uses, with `url.*.insteadOf` applied.
364
+
315
365
  A receipt records every step and is rewritten after each one. With
316
366
  `--plan plan.json` it goes beside the plan, as
317
367
  `plan.receipt-<timestamp>.json`. `--receipt <file>` puts it anywhere. A run
package/dist/apply.d.ts CHANGED
@@ -18,8 +18,8 @@ import type { CommandRunner } from '@osdlc/command-seam';
18
18
  import type { HostHooks } from './hooks.ts';
19
19
  import { type ProcessProbe } from './processes.ts';
20
20
  import { type Plan } from './plan.ts';
21
- import type { Dropped, Receipt, RowRef, Step } from './receipt.ts';
22
- import { type Authorization, type SweepProgress } from './types.ts';
21
+ import { type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
22
+ import { type Authorization, type SweepItem, type SweepProgress } from './types.ts';
23
23
  /** An authorization the caller gave, for one row or (`row: null`) for all. */
24
24
  export interface Grant {
25
25
  name: Authorization;
@@ -45,6 +45,20 @@ export interface ApplyOptions extends HostHooks {
45
45
  /** Limit the run to the plan repository rooted here. */
46
46
  repoRoot?: string | undefined;
47
47
  now?: () => Date;
48
+ /**
49
+ * Drop each selected row the re-check does not judge `safe-to-delete`.
50
+ * Defaults to true when no rows are requested, so a row to review runs only
51
+ * when it is named. Pass it to keep that guard on a run whose rows came
52
+ * from such a selection, such as the run a preview confirmed.
53
+ */
54
+ safeOnly?: boolean | undefined;
55
+ /**
56
+ * Items to re-check against instead of sweeping again, keyed by repository
57
+ * root, already annotated: the hook is not applied to them again. For a
58
+ * preview straight after a sweep; the run that deletes should always sweep
59
+ * again.
60
+ */
61
+ swept?: ReadonlyMap<string, readonly SweepItem[]> | undefined;
48
62
  /** Rows dropped before this run, such as by a preview; recorded first in the receipt. */
49
63
  dropped?: readonly Dropped[] | undefined;
50
64
  /** Passed to the re-sweep's `onProgress`, for a status line. */
@@ -66,6 +80,13 @@ export declare function parseGrant(text: string): Grant;
66
80
  export type RowSelection = RowRef & {
67
81
  repo?: string;
68
82
  };
83
+ /**
84
+ * A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
85
+ * `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
86
+ * nested groups stay apart. Null for a path on this machine, including
87
+ * `file://` and a Windows drive path.
88
+ */
89
+ export declare function networkRemoteKey(url: string): string | null;
69
90
  /**
70
91
  * Apply the `requested` rows of `plan` under `grants`. An empty `requested`
71
92
  * selects every row of the plan (of `repoRoot`'s repository, when given) and
package/dist/apply.js CHANGED
@@ -20,6 +20,7 @@ import { CommandFailed, Git, PUSH_REJECTIONS } from '@osdlc/typed-git';
20
20
  import { localDeleteCommand, remoteDeleteCommand } from "./branches.js";
21
21
  import { busyHolders, defaultProcessProbe, parseLockOwner } from "./processes.js";
22
22
  import { PLAN_CATEGORIES, fingerprint, isPlanCategory, sameFingerprint, } from "./plan.js";
23
+ import { RECEIPT_VERSION } from "./receipt.js";
23
24
  import { sweepRepo } from "./sweep.js";
24
25
  import { AUTHORIZATIONS } from "./types.js";
25
26
  import { worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand } from "./worktrees.js";
@@ -78,6 +79,10 @@ function granted(grants, ref, name) {
78
79
  }
79
80
  /** The plan repository's rows as they stand now, with the host's overlay applied. */
80
81
  function resweep(repo, opts) {
82
+ const given = opts.swept?.get(repo.root);
83
+ if (given !== undefined) {
84
+ return [...given];
85
+ }
81
86
  const sweep = sweepRepo(repo.root, {
82
87
  remote: repo.remote,
83
88
  noGh: repo.no_gh,
@@ -257,6 +262,73 @@ function execute(step, row, git, probe) {
257
262
  }
258
263
  }
259
264
  }
265
+ /**
266
+ * A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
267
+ * `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
268
+ * nested groups stay apart. Null for a path on this machine, including
269
+ * `file://` and a Windows drive path.
270
+ */
271
+ export function networkRemoteKey(url) {
272
+ const u = url.trim();
273
+ if (/^[a-z]:[\\/]/i.test(u) || /^file:/i.test(u))
274
+ return null;
275
+ const uri = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::(\d+))?(\/.*)?$/i.exec(u);
276
+ const scp = uri === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(u) : null;
277
+ const host = uri?.[1] ?? scp?.[1];
278
+ if (host === undefined)
279
+ return null;
280
+ const port = uri?.[2] === undefined ? '' : `:${uri[2]}`;
281
+ const path = (uri?.[3] ?? scp?.[2] ?? '').replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
282
+ return `${host.toLowerCase()}${port}/${path}`;
283
+ }
284
+ /**
285
+ * One string per remote repository, so two clones that push to the same one
286
+ * agree. Built from the push URLs git would use (`remote get-url --push
287
+ * --all`, which applies `url.*.insteadOf` and `pushInsteadOf`): a network URL
288
+ * by {@link networkRemoteKey}, a local path resolved against the repository
289
+ * and kept as written, `.git` and all. Null when git reports no URL; such a
290
+ * row is never treated as a duplicate.
291
+ */
292
+ function remoteKey(git, repo, remote) {
293
+ const urls = git.at(repo).try.remoteGetUrls(remote, { push: true });
294
+ if (!urls.ok || urls.value === null || urls.value.length === 0)
295
+ return null;
296
+ const keys = urls.value.map((url) => networkRemoteKey(url) ?? resolve(repo, url.trim().replace(/^file:\/\/(localhost)?/i, '')));
297
+ return [...new Set(keys)].sort().join('\n');
298
+ }
299
+ /**
300
+ * Keep the first row for each remote branch. Two clones of one remote each
301
+ * list its branches; deleting one twice fails the second push and stops the
302
+ * run, so the later row is dropped as `duplicate`.
303
+ */
304
+ function onePerRemoteBranch({ accepted, dropped }, git) {
305
+ const seen = new Map();
306
+ const kept = [];
307
+ const extra = [];
308
+ for (const row of accepted) {
309
+ if (row.item.category !== 'remote_branch') {
310
+ kept.push(row);
311
+ continue;
312
+ }
313
+ const remote = remoteKey(git, row.repo, String(row.item.facts['remote']));
314
+ const key = remote === null ? null : `${remote}\0${String(row.item.facts['branch'])}`;
315
+ const first = key === null ? undefined : seen.get(key);
316
+ if (first !== undefined) {
317
+ extra.push({
318
+ repo: row.repo,
319
+ category: row.item.category,
320
+ id: row.item.id,
321
+ reason: 'duplicate',
322
+ detail: `the same remote branch is deleted from ${first}`,
323
+ });
324
+ continue;
325
+ }
326
+ if (key !== null)
327
+ seen.set(key, row.repo);
328
+ kept.push(row);
329
+ }
330
+ return { accepted: kept, dropped: [...dropped, ...extra] };
331
+ }
260
332
  /**
261
333
  * Apply the `requested` rows of `plan` under `grants`. An empty `requested`
262
334
  * selects every row of the plan (of `repoRoot`'s repository, when given) and
@@ -286,7 +358,7 @@ export function applyPlan(plan, requested, grants, opts) {
286
358
  }
287
359
  }
288
360
  const receipt = {
289
- version: 1,
361
+ version: RECEIPT_VERSION,
290
362
  plan: opts.planPath,
291
363
  status: opts.dryRun ? 'dry-run' : 'applying',
292
364
  started_at: now().toISOString(),
@@ -294,7 +366,8 @@ export function applyPlan(plan, requested, grants, opts) {
294
366
  dropped: [],
295
367
  steps: [],
296
368
  };
297
- const { accepted, dropped } = recheck(selected, grants, requested.length === 0, opts);
369
+ const rechecked = recheck(selected, grants, opts.safeOnly ?? requested.length === 0, opts);
370
+ const { accepted, dropped } = onePerRemoteBranch(rechecked, new Git(opts.callerCwd, { runner: opts.git }));
298
371
  receipt.dropped = [...(opts.dropped ?? []), ...dropped];
299
372
  const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
300
373
  const plannedSteps = ordered.flatMap((row) => stepsFor(row).map((step) => [step, row]));
@@ -27,6 +27,16 @@ export declare function recordPr(facts: Record<string, unknown>, pr: PRInfo | un
27
27
  };
28
28
  /** One sentence on why a merged PR did not count, for `investigationParts`. */
29
29
  export declare function gapSentence(pr: PRInfo, gap: PrEvidenceGap, repo: RepoInfo): string;
30
+ /**
31
+ * `facts.merged`: the work is on the default branch, by an exact-head merged
32
+ * PR, by ancestry or by a patch match. Every category records it this way, so
33
+ * `summary` counts one definition.
34
+ */
35
+ export declare function mergedFact(e: {
36
+ landed: boolean;
37
+ ancestor: boolean;
38
+ patchLanded: boolean;
39
+ }): boolean;
30
40
  /** `HEAD` as the branch checked out at `git`'s directory; any other name as given. */
31
41
  export declare function resolveBranch(value: string, git: Git): string;
32
42
  /**
package/dist/branches.js CHANGED
@@ -59,6 +59,14 @@ export function gapSentence(pr, gap, repo) {
59
59
  ? `PR #${pr.number} merged into \`${pr.baseRef}\`, not \`${repo.mainBranch}\`, so it is not landing proof (other-base).`
60
60
  : `PR #${pr.number} merged at head ${pr.headOid ?? '(unknown)'}, which is not this tip, so it is not landing proof (head-moved).`;
61
61
  }
62
+ /**
63
+ * `facts.merged`: the work is on the default branch, by an exact-head merged
64
+ * PR, by ancestry or by a patch match. Every category records it this way, so
65
+ * `summary` counts one definition.
66
+ */
67
+ export function mergedFact(e) {
68
+ return e.landed || e.ancestor || e.patchLanded;
69
+ }
62
70
  /** What deleting a branch needs: its unlanded commits, and its open PR. */
63
71
  function branchRequires(unmerged, pr) {
64
72
  return authorizationsFor({
@@ -152,7 +160,11 @@ export function classifyLocalBranch(opts) {
152
160
  // `soft`: a missing default ref reads as not merged.
153
161
  const pr = prs.get(name);
154
162
  const { landed } = recordPr(facts, pr, sha, repo);
155
- facts['merged'] = landed || git.at(repo.root).soft.isAncestor(sha, landedRef(repo));
163
+ facts['merged'] = mergedFact({
164
+ landed,
165
+ ancestor: git.at(repo.root).soft.isAncestor(sha, landedRef(repo)),
166
+ patchLanded: false,
167
+ });
156
168
  return pinned(held);
157
169
  }
158
170
  // Work merged only into an unpushed local default has not landed. A missing
@@ -172,8 +184,7 @@ export function classifyLocalBranch(opts) {
172
184
  recordLanding(facts, patch);
173
185
  const patchLanded = patch !== null && patch.check !== null;
174
186
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
175
- // 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;
187
+ facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
177
188
  if (!landed && !isAncestor) {
178
189
  facts['unique_commits'] = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
179
190
  }
@@ -297,8 +308,7 @@ export function classifyRemoteBranch(opts) {
297
308
  recordLanding(facts, patch);
298
309
  const patchLanded = patch !== null && patch.check !== null;
299
310
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
300
- // 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;
311
+ facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
302
312
  if (!landed && !isAncestor) {
303
313
  // Unknown (null) when the clone lacks `sha` or `remoteMain`, as above.
304
314
  const counted = git.at(repo.root).try.aheadBehind(remoteMain, sha);
package/dist/cli.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Four verbs over one set of sweep filters:
5
5
  *
6
6
  * status (the default) inventory and classify; read-only but for the fetch
7
- * summary the same sweep as counts: totals, merged, delete, review, keep
7
+ * summary the same sweep as counts: totals, merged, delete, prune, review, keep
8
8
  * plan write the deletable rows as a plan, to stdout or a file
9
9
  * apply re-check, show the steps, ask, then delete
10
10
  *