@osdlc/repo-cleaner 0.17.2 → 0.18.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.
@@ -10,29 +10,21 @@
10
10
  * leaves the branch for a person to look at.
11
11
  */
12
12
  import { type Git } from '@osdlc/typed-git';
13
- import { type PRInfo, type PrEvidenceGap, type RepoInfo, type SweepItem } from './types.ts';
13
+ import { type BranchRow, type LandingEvidence, type PRInfo, type PrEvidenceGap, type RemoteRow, type RepoInfo } from './types.ts';
14
14
  /**
15
- * True when merged `pr` proves `tip` landed: it targeted the default branch
16
- * and its head commit is `tip`. Fork PRs never reach here; `fetchPrs` drops
17
- * them. A PR with no reported head counts as `head-moved`.
18
- */
19
- export declare function prCapturesTip(pr: PRInfo, tip: string, repo: RepoInfo): true | PrEvidenceGap;
20
- /**
21
- * `facts.pr`, plus `evidence` for a merged PR: `exact-head` when it proves
15
+ * `evidence.pr`, plus `proof` for a merged PR: `exact-head` when it proves
22
16
  * `tip` landed, otherwise the gap. Returns whether it counts as landing proof.
23
17
  */
24
- export declare function recordPr(facts: Record<string, unknown>, pr: PRInfo | undefined, tip: string, repo: RepoInfo): {
18
+ export declare function recordPr(evidence: LandingEvidence, pr: PRInfo | undefined, tip: string, repo: RepoInfo): {
25
19
  landed: boolean;
26
20
  gap: PrEvidenceGap | null;
27
21
  };
28
- /** Why a branch has no PR, naming where the sweep looked. */
29
- export declare function noPrSentence(repo: RepoInfo): string;
30
- /** One sentence on why a merged PR did not count, for `investigationParts`. */
22
+ /** One sentence on why a merged PR did not count, for a row's notes. */
31
23
  export declare function gapSentence(pr: PRInfo, gap: PrEvidenceGap, repo: RepoInfo): string;
32
24
  /**
33
- * `facts.merged`: the work is on the default branch, by an exact-head merged
34
- * PR, by ancestry or by a patch match. Every category records it this way, so
35
- * `summary` counts one definition.
25
+ * `evidence.merged`: the work is on the default branch, by an exact-head
26
+ * merged PR, by ancestry or by a patch match. Every kind records it this way,
27
+ * so the counts use one definition.
36
28
  */
37
29
  export declare function mergedFact(e: {
38
30
  landed: boolean;
@@ -52,24 +44,5 @@ export declare const HELD_BY_WORKTREE = "checked out in a worktree (handle via t
52
44
  export declare function localDeleteCommand(name: string, sha: string): string;
53
45
  /** Delete `remote`'s branch `name` only while it still points at `sha`. */
54
46
  export declare function remoteDeleteCommand(remote: string, name: string, sha: string): string;
55
- export declare function gatherLocalBranches(repo: RepoInfo, prs: Map<string, PRInfo>, worktreeBranches: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): SweepItem[];
56
- export declare function classifyLocalBranch(opts: {
57
- name: string;
58
- upstream: string;
59
- track: string;
60
- lastCommit: string;
61
- sha: string;
62
- repo: RepoInfo;
63
- prs: Map<string, PRInfo>;
64
- worktreeBranches: ReadonlySet<string>;
65
- git: Git;
66
- }): SweepItem;
67
- export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): SweepItem[];
68
- export declare function classifyRemoteBranch(opts: {
69
- name: string;
70
- sha: string;
71
- repo: RepoInfo;
72
- prs: Map<string, PRInfo>;
73
- localBranchNames: ReadonlySet<string>;
74
- git: Git;
75
- }): SweepItem;
47
+ export declare function gatherLocalBranches(repo: RepoInfo, prs: Map<string, PRInfo>, worktreeBranches: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): BranchRow[];
48
+ export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): RemoteRow[];
package/dist/branches.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { shellJoin } from '@osdlc/command-seam';
13
13
  import { forceWithLeaseArg } from '@osdlc/typed-git';
14
14
  import { confirmationNote, judgeLanding, landingConfirmed, recordLanding, unmatchedSentence, } from "./landing.js";
15
- import { authorizationsFor, landedRef, makeItem, } from "./types.js";
15
+ import { landedRef, makeRow, risksFor, } from "./types.js";
16
16
  /**
17
17
  * Five fields in one pass, so a repository costs one `for-each-ref`. The full
18
18
  * sha, so it compares with a PR's `headRefOid`.
@@ -23,7 +23,7 @@ const LOCAL_FORMAT = '%(refname:short)%09%(upstream:short)%09%(upstream:track)%0
23
23
  * and its head commit is `tip`. Fork PRs never reach here; `fetchPrs` drops
24
24
  * them. A PR with no reported head counts as `head-moved`.
25
25
  */
26
- export function prCapturesTip(pr, tip, repo) {
26
+ function prCapturesTip(pr, tip, repo) {
27
27
  if (pr.baseRef !== repo.mainBranch)
28
28
  return 'other-base';
29
29
  if (pr.headOid === null || pr.headOid !== tip)
@@ -31,52 +31,45 @@ export function prCapturesTip(pr, tip, repo) {
31
31
  return true;
32
32
  }
33
33
  /**
34
- * `facts.pr`, plus `evidence` for a merged PR: `exact-head` when it proves
34
+ * `evidence.pr`, plus `proof` for a merged PR: `exact-head` when it proves
35
35
  * `tip` landed, otherwise the gap. Returns whether it counts as landing proof.
36
36
  */
37
- export function recordPr(facts, pr, tip, repo) {
37
+ export function recordPr(evidence, pr, tip, repo) {
38
38
  if (pr === undefined)
39
39
  return { landed: false, gap: null };
40
- const out = {
41
- number: pr.number,
42
- state: pr.state,
43
- merged_at: pr.mergedAt,
44
- url: pr.url,
45
- title: pr.title,
46
- head_oid: pr.headOid,
47
- base_ref: pr.baseRef,
48
- };
49
- facts['pr'] = out;
40
+ const { headRef: _, ...rest } = pr;
41
+ const out = rest;
42
+ evidence.pr = out;
50
43
  if (pr.state !== 'MERGED')
51
44
  return { landed: false, gap: null };
52
45
  const verdict = prCapturesTip(pr, tip, repo);
53
- out['evidence'] = verdict === true ? 'exact-head' : verdict;
46
+ out.proof = verdict === true ? 'exact-head' : verdict;
54
47
  return verdict === true ? { landed: true, gap: null } : { landed: false, gap: verdict };
55
48
  }
56
49
  /** Why a branch has no PR, naming where the sweep looked. */
57
- export function noPrSentence(repo) {
50
+ function noPrSentence(repo) {
58
51
  const provider = repo.provider;
59
52
  if (provider === null)
60
53
  return 'No PR lookup (no provider).';
61
54
  return `No ${provider.noun} found via \`${provider.listing}\`.`;
62
55
  }
63
- /** One sentence on why a merged PR did not count, for `investigationParts`. */
56
+ /** One sentence on why a merged PR did not count, for a row's notes. */
64
57
  export function gapSentence(pr, gap, repo) {
65
58
  return gap === 'other-base'
66
59
  ? `PR #${pr.number} merged into \`${pr.baseRef}\`, not \`${repo.mainBranch}\`, so it is not landing proof (other-base).`
67
60
  : `PR #${pr.number} merged at head ${pr.headOid ?? '(unknown)'}, which is not this tip, so it is not landing proof (head-moved).`;
68
61
  }
69
62
  /**
70
- * `facts.merged`: the work is on the default branch, by an exact-head merged
71
- * PR, by ancestry or by a patch match. Every category records it this way, so
72
- * `summary` counts one definition.
63
+ * `evidence.merged`: the work is on the default branch, by an exact-head
64
+ * merged PR, by ancestry or by a patch match. Every kind records it this way,
65
+ * so the counts use one definition.
73
66
  */
74
67
  export function mergedFact(e) {
75
68
  return e.landed || e.ancestor || e.patchLanded;
76
69
  }
77
70
  /** What deleting a branch needs: its unlanded commits, and its open PR. */
78
- function branchRequires(unmerged, pr) {
79
- return authorizationsFor({
71
+ function branchRisks(unmerged, pr) {
72
+ return risksFor({
80
73
  'abandon-unmerged': unmerged,
81
74
  'confirm-open-pr': pr?.state === 'OPEN',
82
75
  });
@@ -137,25 +130,17 @@ export function gatherLocalBranches(repo, prs, worktreeBranches, git, only, tick
137
130
  }
138
131
  return items;
139
132
  }
140
- export function classifyLocalBranch(opts) {
133
+ function classifyLocalBranch(opts) {
141
134
  const { name, upstream, track, lastCommit, sha, repo, prs, worktreeBranches, git } = opts;
142
- const facts = {
135
+ const evidence = {
143
136
  branch: name,
144
137
  upstream: upstream || null,
145
138
  track: track || null,
146
- last_commit: lastCommit,
139
+ lastCommit,
147
140
  sha,
148
141
  };
149
- const keep = (reason, extra = {}) => makeItem({
150
- category: 'local_branch',
151
- id: name,
152
- classification: 'safe-to-keep',
153
- action: 'keep',
154
- reason,
155
- facts,
156
- ...extra,
157
- });
158
- const pinned = (reason) => keep(reason, { deletable: false });
142
+ const row = (r) => makeRow({ kind: 'branch', name, evidence, ...r });
143
+ const pinned = (why) => row({ verdict: 'keep', action: null, why });
159
144
  if (name === repo.mainBranch)
160
145
  return pinned('default branch');
161
146
  const held = name === repo.currentBranch
@@ -168,8 +153,8 @@ export function classifyLocalBranch(opts) {
168
153
  // an exact-head merged PR, without the patch checks a deletable row gets.
169
154
  // `soft`: a missing default ref reads as not merged.
170
155
  const pr = prs.get(name);
171
- const { landed } = recordPr(facts, pr, sha, repo);
172
- facts['merged'] = mergedFact({
156
+ const { landed } = recordPr(evidence, pr, sha, repo);
157
+ evidence.merged = mergedFact({
173
158
  landed,
174
159
  ancestor: git.at(repo.root).soft.isAncestor(sha, landedRef(repo)),
175
160
  patchLanded: false,
@@ -179,85 +164,56 @@ export function classifyLocalBranch(opts) {
179
164
  // Work merged only into an unpushed local default has not landed. A missing
180
165
  // remote-tracking ref exits 128: ancestry unknown (null).
181
166
  const compareRef = landedRef(repo);
182
- facts['compare_against'] = compareRef;
167
+ evidence.compareAgainst = compareRef;
183
168
  const ancestry = git.at(repo.root).try.isAncestor(name, compareRef);
184
169
  const ancestor = ancestry.ok ? ancestry.value : null;
185
- facts['ancestor_of_remote_main'] = ancestor;
170
+ evidence.ancestorOfRemoteMain = ancestor;
186
171
  const isAncestor = ancestor === true;
187
172
  const pr = prs.get(name);
188
- const { landed, gap } = recordPr(facts, pr, sha, repo);
173
+ const { landed, gap } = recordPr(evidence, pr, sha, repo);
189
174
  // Rebase merges, cherry-picks and squash merges leave the tip off the default
190
175
  // branch; the patches say whether the work is there anyway.
191
176
  const patch = landed || isAncestor ? null : judgeLanding(repo, sha, pr, gap, git);
192
177
  if (patch !== null)
193
- recordLanding(facts, patch);
178
+ recordLanding(evidence, patch);
194
179
  const patchLanded = patch !== null && patch.check !== null;
195
180
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
196
- facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
181
+ evidence.merged = mergedFact({ landed, ancestor: isAncestor, patchLanded });
197
182
  if (!landed && !isAncestor) {
198
- facts['unique_commits'] = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
199
- }
200
- const requires = branchRequires(unmerged, pr);
201
- const deleteCmd = [localDeleteCommand(name, sha)];
202
- if (pr && landed) {
203
- return makeItem({
204
- category: 'local_branch',
205
- id: name,
206
- classification: 'safe-to-delete',
207
- action: 'delete',
208
- reason: `PR #${pr.number} merged`,
209
- facts,
210
- commands: deleteCmd,
211
- requires,
212
- });
213
- }
214
- if (isAncestor) {
215
- return makeItem({
216
- category: 'local_branch',
217
- id: name,
218
- classification: 'safe-to-delete',
219
- action: 'delete',
220
- reason: `fully merged into ${compareRef}`,
221
- facts,
222
- commands: deleteCmd,
223
- requires,
224
- });
183
+ evidence.uniqueCommits = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
225
184
  }
185
+ const risks = branchRisks(unmerged, pr);
186
+ const commands = [localDeleteCommand(name, sha)];
187
+ const deletes = (why) => row({ verdict: 'delete', action: 'delete', why, commands, risks });
188
+ if (pr && landed)
189
+ return deletes(`PR #${pr.number} merged`);
190
+ if (isAncestor)
191
+ return deletes(`fully merged into ${compareRef}`);
226
192
  if (patchLanded) {
227
- return makeItem({
228
- category: 'local_branch',
229
- id: name,
230
- classification: 'safe-to-delete',
231
- action: 'delete',
232
- reason: `patch-equivalent to commits on ${compareRef} (${patch.check}); ${confirmationNote(patch)}`,
233
- facts,
234
- commands: deleteCmd,
235
- requires,
236
- });
193
+ return deletes(`patch-equivalent to commits on ${compareRef} (${patch.check}); ${confirmationNote(patch)}`);
194
+ }
195
+ if (pr && pr.state === 'OPEN') {
196
+ return row({ verdict: 'keep', action: 'delete', why: `open PR #${pr.number}`, commands, risks });
237
197
  }
238
- if (pr && pr.state === 'OPEN')
239
- return keep(`open PR #${pr.number}`, { requires });
240
198
  const lead = ancestor === null
241
199
  ? `Remote default branch \`${compareRef}\` was not found, so ancestry is unknown.`
242
200
  : `Branch \`${name}\` is not an ancestor of \`${compareRef}\`.`;
243
- const parts = [lead];
244
- parts.push(upstream ? `Tracks \`${upstream}\` (${track || 'in sync'}).` : 'Has no upstream.');
245
- parts.push(pr ? `Associated PR #${pr.number} is ${pr.state} (${pr.url}).` : noPrSentence(repo));
201
+ const notes = [lead];
202
+ notes.push(upstream ? `Tracks \`${upstream}\` (${track || 'in sync'}).` : 'Has no upstream.');
203
+ notes.push(pr ? `Associated PR #${pr.number} is ${pr.state} (${pr.url}).` : noPrSentence(repo));
246
204
  if (pr && gap)
247
- parts.push(gapSentence(pr, gap, repo));
205
+ notes.push(gapSentence(pr, gap, repo));
248
206
  const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
249
207
  if (unmatched !== null)
250
- parts.push(unmatched);
251
- parts.push(`Last commit: ${lastCommit} (${sha}).`);
252
- return makeItem({
253
- category: 'local_branch',
254
- id: name,
255
- classification: 'needs-investigation',
256
- action: 'review',
257
- reason: 'mixed or absent signals',
258
- facts,
259
- investigationParts: parts,
260
- requires,
208
+ notes.push(unmatched);
209
+ notes.push(`Last commit: ${lastCommit} (${sha}).`);
210
+ return row({
211
+ verdict: 'review',
212
+ action: 'delete',
213
+ why: 'mixed or absent signals',
214
+ commands,
215
+ notes,
216
+ risks,
261
217
  });
262
218
  }
263
219
  // ── remote branches ────────────────────────────────────────────────────────
@@ -280,103 +236,67 @@ export function gatherRemoteBranches(repo, prs, localBranchNames, git, only, tic
280
236
  }
281
237
  return items;
282
238
  }
283
- export function classifyRemoteBranch(opts) {
284
- const { name, sha, repo, prs, localBranchNames, git } = opts;
285
- const id = `${repo.remote}/${name}`;
286
- const facts = {
287
- branch: name,
288
- remote: repo.remote,
239
+ function classifyRemoteBranch(opts) {
240
+ const { name: branch, sha, repo, prs, localBranchNames, git } = opts;
241
+ const remote = repo.remote ?? '';
242
+ const name = `${remote}/${branch}`;
243
+ const evidence = {
244
+ branch,
245
+ remote,
289
246
  sha,
290
- has_local: localBranchNames.has(name),
247
+ hasLocal: localBranchNames.has(branch),
291
248
  };
292
- const keep = (reason, extra = {}) => makeItem({
293
- category: 'remote_branch',
294
- id,
295
- classification: 'safe-to-keep',
296
- action: 'keep',
297
- reason,
298
- facts,
299
- ...extra,
300
- });
301
- if (name === repo.mainBranch)
302
- return keep('default branch', { deletable: false });
249
+ const row = (r) => makeRow({ kind: 'remote', name, evidence, ...r });
250
+ if (branch === repo.mainBranch)
251
+ return row({ verdict: 'keep', action: null, why: 'default branch' });
303
252
  const remoteMain = landedRef(repo);
304
253
  // `soft`: `sha` comes from `ls-remote`, so the clone may never have fetched
305
254
  // it, and `remoteMain` may not exist either. Both exit 128; this row renders
306
255
  // "cannot tell" as false.
307
256
  const isAncestor = git.at(repo.root).soft.isAncestor(sha, remoteMain);
308
- facts['ancestor_of_remote_main'] = isAncestor;
309
- const pr = prs.get(name);
310
- const { landed, gap } = recordPr(facts, pr, sha, repo);
257
+ evidence.ancestorOfRemoteMain = isAncestor;
258
+ const pr = prs.get(branch);
259
+ const { landed, gap } = recordPr(evidence, pr, sha, repo);
311
260
  // `sha` came from `ls-remote` and may not be in this clone; the check then
312
261
  // finds nothing and the branch stays unlanded.
313
262
  const patch = landed || isAncestor ? null : judgeLanding(repo, sha, pr, gap, git);
314
263
  if (patch !== null)
315
- recordLanding(facts, patch);
264
+ recordLanding(evidence, patch);
316
265
  const patchLanded = patch !== null && patch.check !== null;
317
266
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
318
- facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
267
+ evidence.merged = mergedFact({ landed, ancestor: isAncestor, patchLanded });
319
268
  if (!landed && !isAncestor) {
320
269
  // Unknown (null) when the clone lacks `sha` or `remoteMain`, as above.
321
270
  const counted = git.at(repo.root).try.aheadBehind(remoteMain, sha);
322
- facts['unique_commits'] = counted.ok ? counted.value.ahead : null;
323
- }
324
- const requires = branchRequires(unmerged, pr);
325
- const deleteCmd = repo.remote === null ? [] : [remoteDeleteCommand(repo.remote, name, sha)];
326
- if (pr && landed) {
327
- return makeItem({
328
- category: 'remote_branch',
329
- id,
330
- classification: 'safe-to-delete',
331
- action: 'delete',
332
- reason: `PR #${pr.number} merged (auto-delete didn't fire)`,
333
- facts,
334
- commands: deleteCmd,
335
- requires,
336
- });
337
- }
338
- if (isAncestor) {
339
- return makeItem({
340
- category: 'remote_branch',
341
- id,
342
- classification: 'safe-to-delete',
343
- action: 'delete',
344
- reason: `fully merged into ${remoteMain}`,
345
- facts,
346
- commands: deleteCmd,
347
- requires,
348
- });
271
+ evidence.uniqueCommits = counted.ok ? counted.value.ahead : null;
349
272
  }
273
+ const risks = branchRisks(unmerged, pr);
274
+ const commands = repo.remote === null ? [] : [remoteDeleteCommand(repo.remote, branch, sha)];
275
+ const deletes = (why) => row({ verdict: 'delete', action: 'delete', why, commands, risks });
276
+ if (pr && landed)
277
+ return deletes(`PR #${pr.number} merged (auto-delete didn't fire)`);
278
+ if (isAncestor)
279
+ return deletes(`fully merged into ${remoteMain}`);
350
280
  if (patchLanded) {
351
- return makeItem({
352
- category: 'remote_branch',
353
- id,
354
- classification: 'safe-to-delete',
355
- action: 'delete',
356
- reason: `patch-equivalent to commits on ${remoteMain} (${patch.check}); ${confirmationNote(patch)}`,
357
- facts,
358
- commands: deleteCmd,
359
- requires,
360
- });
281
+ return deletes(`patch-equivalent to commits on ${remoteMain} (${patch.check}); ${confirmationNote(patch)}`);
282
+ }
283
+ if (pr && pr.state === 'OPEN') {
284
+ return row({ verdict: 'keep', action: 'delete', why: `open PR #${pr.number}`, commands, risks });
361
285
  }
362
- if (pr && pr.state === 'OPEN')
363
- return keep(`open PR #${pr.number}`, { requires });
364
- const parts = [`Remote branch \`${id}\` is not an ancestor of \`${remoteMain}\`.`];
365
- parts.push(pr ? `Associated PR #${pr.number} is ${pr.state}.` : 'No PR found.');
286
+ const notes = [`Remote branch \`${name}\` is not an ancestor of \`${remoteMain}\`.`];
287
+ notes.push(pr ? `Associated PR #${pr.number} is ${pr.state}.` : 'No PR found.');
366
288
  if (pr && gap)
367
- parts.push(gapSentence(pr, gap, repo));
289
+ notes.push(gapSentence(pr, gap, repo));
368
290
  const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
369
291
  if (unmatched !== null)
370
- parts.push(unmatched);
371
- parts.push('Decide whether to push more work, delete, or leave it as long-lived.');
372
- return makeItem({
373
- category: 'remote_branch',
374
- id,
375
- classification: 'needs-investigation',
376
- action: 'review',
377
- reason: 'mixed or absent signals',
378
- facts,
379
- investigationParts: parts,
380
- requires,
292
+ notes.push(unmatched);
293
+ notes.push('Decide whether to push more work, delete, or leave it as long-lived.');
294
+ return row({
295
+ verdict: 'review',
296
+ action: 'delete',
297
+ why: 'mixed or absent signals',
298
+ commands,
299
+ notes,
300
+ risks,
381
301
  });
382
302
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Cleaning up: `check` a plan against an approval, then `run` what it shows.
3
+ *
4
+ * `check` resolves the approval (the rows picked, the rows spared, the risks
5
+ * allowed), refuses one that contradicts itself, sweeps every plan repository
6
+ * again and drops each selected row that is gone, cannot be deleted, moved
7
+ * since the plan, or carries a risk not allowed. It returns the steps a run
8
+ * would take. `run` sweeps once more and takes those steps in a fixed order,
9
+ * stopping at the first failure:
10
+ *
11
+ * 1. spared remote branches, each tip checked with `ls-remote`;
12
+ * 2. worktrees, each unlocked first when its lock's owner is dead, and each
13
+ * checked again for live processes just before it is unlocked or removed;
14
+ * one with submodules is removed with `--force` after its files and every
15
+ * submodule are checked again;
16
+ * 3. local branches;
17
+ * 4. remote branches.
18
+ *
19
+ * A worktree whose directory is gone goes by its own record. One left without
20
+ * its `.git` takes the repository-wide prune, last, and only while that prune
21
+ * would drop nothing the run does not select.
22
+ *
23
+ * Branch deletes are compare-and-swap on the planned sha. Every step is
24
+ * verified, and the receipt is rewritten after each.
25
+ */
26
+ import type { Annotate } from './hooks.ts';
27
+ import { type Plan } from './plan.ts';
28
+ import { type Dropped, type Receipt, type Step } from './receipt.ts';
29
+ import { type Runners } from './sweep.ts';
30
+ import { type Kind, type Risk, type RowId, type Sweep, type SweepProgress, type SweptWith } from './types.ts';
31
+ /** A risk allowed for every row (`row` absent), or for one row. */
32
+ export interface Allow {
33
+ risk: Risk;
34
+ /** A row id or bare name, resolved as a pick is. */
35
+ row?: string | undefined;
36
+ }
37
+ /**
38
+ * What a person approved. Rows are named by id (`branch:feat/x`), or by a
39
+ * bare name or path when only one plan row has it.
40
+ *
41
+ * `pick` work on exactly these rows; a `review` row, or a `keep` row with
42
+ * an action, runs only when picked. Default: every `delete` row.
43
+ * `spare` leave these rows alone; a spared remote branch is still checked,
44
+ * and the run stops if it is gone or moved since the plan.
45
+ * `allow` the risks a row's action may carry.
46
+ */
47
+ export interface Approval {
48
+ pick?: readonly string[] | undefined;
49
+ spare?: readonly string[] | undefined;
50
+ allow?: readonly (Risk | Allow)[] | undefined;
51
+ }
52
+ /** A row reference resolved to one plan row. */
53
+ export interface Resolved {
54
+ repo: string;
55
+ id: RowId;
56
+ }
57
+ /** An approval with every row resolved to its plan row. */
58
+ export interface ResolvedApproval {
59
+ pick: Resolved[];
60
+ spare: Resolved[];
61
+ allow: {
62
+ risk: Risk;
63
+ row: Resolved | null;
64
+ }[];
65
+ }
66
+ interface Shared extends Runners {
67
+ /** Where the caller stands: the base for relative worktree paths, and a worktree never removed. Defaults to the process's cwd. */
68
+ cwd?: string | undefined;
69
+ /** A host's re-ranking, applied to every re-sweep as the sweep applied it. */
70
+ annotate?: Annotate | undefined;
71
+ /** Told what the re-sweep is doing, for a status line. */
72
+ onProgress?: ((progress: SweepProgress) => void) | undefined;
73
+ }
74
+ export interface CheckOptions extends Shared {
75
+ /**
76
+ * A sweep made just now, used as the re-check instead of sweeping again: for
77
+ * a preview straight after a sweep. `run` always sweeps again.
78
+ */
79
+ sweep?: Sweep | undefined;
80
+ /** Where remote URL clones are kept; a plan repository there must still push only where it fetches from. */
81
+ cacheDir?: string | undefined;
82
+ }
83
+ export interface RunOptions extends Shared {
84
+ /** Where the receipt is rewritten after each step; none when absent. */
85
+ receiptPath?: string | undefined;
86
+ /** Where the plan came from, recorded in the receipt. Default `(memory)`. */
87
+ source?: string | undefined;
88
+ now?: (() => Date) | undefined;
89
+ /** Told once the steps are known, before the first runs; each step's `result` is updated in place. */
90
+ onStart?: ((steps: readonly Step[]) => void) | undefined;
91
+ /** Told before each step: its index, the step count and the step. */
92
+ onStep?: ((index: number, total: number, step: Step) => void) | undefined;
93
+ /** Told after each step, with its `result` set, before the receipt is saved. */
94
+ onStepDone?: ((index: number, total: number, step: Step) => void) | undefined;
95
+ }
96
+ /** What a run would do, for a person to approve: the steps, and the rows dropped and why. */
97
+ export interface Preview {
98
+ plan: Plan;
99
+ approval: ResolvedApproval;
100
+ /** Each with `result: 'would-run'`. */
101
+ steps: Step[];
102
+ dropped: Dropped[];
103
+ /** Allowed risks no row that runs carries. Not an error: what a row carries can change between runs. */
104
+ unusedAllows: {
105
+ risk: Risk;
106
+ row: RowId | null;
107
+ }[];
108
+ }
109
+ /** A row reference as typed: a kind and a name, or a bare name (`kind: null`). */
110
+ export interface RowRef {
111
+ kind: Kind | null;
112
+ name: string;
113
+ }
114
+ /**
115
+ * `kind:name`, or a bare name. A word before a colon that is not a kind is an
116
+ * error (a typo such as `wt:x`), but a path is a bare name whatever colons it
117
+ * holds: a prefix with a path separator in it is never a kind.
118
+ */
119
+ export declare function parseRowRef(text: string): RowRef;
120
+ /** `<risk>` for every row, or `<risk>=<row>` for one. */
121
+ export declare function parseAllow(text: string): Allow;
122
+ /**
123
+ * What can be refused of `approval` before anything is swept: a malformed
124
+ * row or risk, a row a sweep with `options` could never hold, and a row
125
+ * picked and spared as written. A row named two ways, and an `allow` for a
126
+ * spared or unpicked row, are found when the approval is resolved against
127
+ * the plan.
128
+ */
129
+ export declare function checkApproval(options: Pick<SweptWith, 'only' | 'branch'>, approval: Approval): void;
130
+ /**
131
+ * `approval` resolved against `plan`, or a {@link RepoCleanerError} for one
132
+ * that contradicts itself or names rows the plan does not have.
133
+ */
134
+ export declare function resolveApproval(plan: Plan, approval: Approval, cwd: string): ResolvedApproval;
135
+ /**
136
+ * A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
137
+ * `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
138
+ * nested groups stay apart. Null for a path on this machine, including
139
+ * `file://` and a Windows drive path.
140
+ */
141
+ export declare function networkRemoteKey(url: string): string | null;
142
+ /**
143
+ * What running `plan` under `approval` would do now: sweeps every repository
144
+ * the selection is in again (or reads `opts.sweep`), and returns the steps
145
+ * and the rows dropped, with why. Deletes nothing.
146
+ *
147
+ * @throws RepoCleanerError for an approval that contradicts itself, names a
148
+ * row the plan does not have or names one ambiguously, or spares a remote
149
+ * branch that moved. Nothing has been touched.
150
+ */
151
+ export declare function check(plan: Plan, approval: Approval, opts?: CheckOptions): Preview;
152
+ /**
153
+ * Take the steps `preview` showed: sweeps again, drops a row that moved since
154
+ * the preview, and runs the rest in order, compare-and-swap, verifying each
155
+ * step and stopping at the first failure. Only what the preview showed runs; a
156
+ * row it dropped stays dropped.
157
+ *
158
+ * @throws RepoCleanerError (`spared-changed`) when a spared remote branch moved
159
+ * since the preview. Nothing has been touched.
160
+ */
161
+ export declare function run(preview: Preview, opts?: RunOptions): Receipt;
162
+ export {};