@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.
package/dist/hooks.d.ts CHANGED
@@ -1,18 +1,14 @@
1
1
  /**
2
2
  * What a host may supply that this package cannot know for itself.
3
3
  *
4
- * A standalone run passes none; a host passes `annotate` so its verdicts
5
- * survive `applyPlan`'s re-sweep.
4
+ * A standalone run passes none; a host passes `annotate` so its verdicts are
5
+ * in the sweep, the plan, and the re-check `check` and `run` make.
6
6
  */
7
- import type { RepoInfo, SweepItem } from './types.ts';
7
+ import type { RepoInfo, Row } from './types.ts';
8
8
  /**
9
- * Re-rank a repository's swept rows with what the host knows. Runs after the
10
- * sweep, on its output; it never reaches into classification. A host that
11
- * holds back or demotes a worktree passes its own annotated rows through
12
- * `holdFreedBranches`, as `applyPlan` does, so that worktree's branch is kept
13
- * too.
9
+ * Re-rank a repository's swept rows with what the host knows: change a
10
+ * verdict, add a note, drop a row. Runs after classification, on its output;
11
+ * it never reaches into it. A worktree the host holds back keeps its branch
12
+ * too: the sweep applies that rule after the hook.
14
13
  */
15
- export type Annotate = (repo: RepoInfo, items: SweepItem[]) => SweepItem[];
16
- export interface HostHooks {
17
- annotate?: Annotate | undefined;
18
- }
14
+ export type Annotate = (repo: RepoInfo, rows: Row[]) => Row[];
package/dist/hooks.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * What a host may supply that this package cannot know for itself.
3
3
  *
4
- * A standalone run passes none; a host passes `annotate` so its verdicts
5
- * survive `applyPlan`'s re-sweep.
4
+ * A standalone run passes none; a host passes `annotate` so its verdicts are
5
+ * in the sweep, the plan, and the re-check `check` and `run` make.
6
6
  */
7
7
  export {};
package/dist/index.d.ts CHANGED
@@ -2,30 +2,23 @@
2
2
  * `@osdlc/repo-cleaner` — what has been merged into the default branch, and
3
3
  * what can go away.
4
4
  *
5
- * Point it at a repository and it inventories the local branches, worktrees
6
- * and remote branches, classifying each as safe-to-delete, safe-to-keep or
7
- * needs-investigation, with the evidence behind the verdict and the literal
8
- * command that would act on it. The sweep never deletes anything; `applyPlan`
9
- * deletes the rows of a plan a person approved.
5
+ * `sweep` inventories worktrees, local branches and remote branches across
6
+ * one repository or many, and judges each row: `delete`, `review`, `keep` or
7
+ * `blocked`, with the risks its deletion carries and the evidence behind it.
8
+ * The sweep deletes nothing. `plan` saves the rows and their evidence;
9
+ * `check` resolves a person's approval against a plan and shows the steps a
10
+ * run would take; `run` takes them, compare-and-swap, verifying each.
10
11
  *
11
- * Two signals settle most items — a merged pull request, and ancestry of the
12
- * default branch — and either alone is enough. A host that knows more than
13
- * git does (an issue tracker, say) re-ranks the returned rows
14
- * rather than reaching into classification, which is why every row carries
15
- * its facts and why `investigationParts` is a list rather than a sentence.
12
+ * A host that knows more than git does (an issue tracker, say) passes
13
+ * `annotate` to re-rank the rows, rather than reaching into classification,
14
+ * which is why every row carries its evidence and why `notes` is a list.
16
15
  */
17
- export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherRemoteBranches, localDeleteCommand, remoteDeleteCommand, resolveBranch, } from './branches.ts';
18
- export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, type WorktreeBlocker, } from './worktrees.ts';
19
- export { DiscoveryFailed, discoverRepo } from './discover.ts';
20
- export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from './landing.ts';
21
- export { ApplyArgumentError, ApplyRefused, applyPlan, type RowSelection, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
22
- export type { Annotate, HostHooks } from './hooks.ts';
23
- export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
24
- export { RECEIPT_VERSION, ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step, } from './receipt.ts';
25
- export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, type DefaultProbeOptions, type LockOwner, type ProcessHolder, type ProcessProbe, } from './processes.ts';
26
- export { fetchPrs } from './prs.ts';
27
- export { PROVIDER_CHOICES, PROVIDER_NAMES, chooseProvider, forgeTarget, githubProvider, gitlabProvider, probeProvider, providers, type ForgeTarget, type PrProvider, type ProviderChoice, type ProviderName, type ProviderStatus, } from './providers/index.ts';
28
- export { expandTilde, isCheckoutRoot, resolveEntries, type Resolution, type ResolveOptions, } from './resolve.ts';
29
- export { holdFreedBranches, sweepRepo, sweepRepos, type SweepOptions, type SweepReposOptions, } from './sweep.ts';
30
- export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, type ApplySummary, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
31
- export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, type Authorization, type Category, type Classification, type DuplicateCheckout, type PRInfo, type RepoInfo, type RepoSweep, type ScanStats, type Scope, type SummaryBucket, type SweepItem, type SweepProgress, type SweepResult, type UnresolvedEntry, } from './types.ts';
16
+ export { sweep, inspectWorktree, type Runners, type SweepOptions } from './sweep.ts';
17
+ export { PLAN_VERSION, plan, readPlan, writePlan, type Plan, type PlanRepo, type PlanRow, } from './plan.ts';
18
+ export { check, run, type Allow, type Approval, type CheckOptions, type Preview, type ResolvedApproval, type Resolved, type RunOptions, } from './clean.ts';
19
+ export { RECEIPT_VERSION, ReceiptSchema, type DropReason, type Dropped, type Receipt, type Step, } from './receipt.ts';
20
+ export { render, type RunSummary, type Style } from './render.ts';
21
+ export { defaultProcessProbe, type ProcessHolder, type ProcessProbe } from './processes.ts';
22
+ export { providers, type PrProvider, type ProviderChoice } from './providers/index.ts';
23
+ export type { Annotate } from './hooks.ts';
24
+ export { KINDS, RISKS, RepoCleanerError, type Action, type Blocker, type BranchEvidence, type BranchRow, type Bucket, type Counts, type ErrorCode, type Kind, type LandingEvidence, type PrEvidence, type RemoteEvidence, type RemoteRow, type RepoInfo, type RepoSweep, type Risk, type Row, type RowId, type SubmoduleEvidence, type Sweep, type SweepProgress, type SweptWith, type Verdict, type WorktreeEvidence, type WorktreeRow, } from './types.ts';
package/dist/index.js CHANGED
@@ -2,29 +2,22 @@
2
2
  * `@osdlc/repo-cleaner` — what has been merged into the default branch, and
3
3
  * what can go away.
4
4
  *
5
- * Point it at a repository and it inventories the local branches, worktrees
6
- * and remote branches, classifying each as safe-to-delete, safe-to-keep or
7
- * needs-investigation, with the evidence behind the verdict and the literal
8
- * command that would act on it. The sweep never deletes anything; `applyPlan`
9
- * deletes the rows of a plan a person approved.
5
+ * `sweep` inventories worktrees, local branches and remote branches across
6
+ * one repository or many, and judges each row: `delete`, `review`, `keep` or
7
+ * `blocked`, with the risks its deletion carries and the evidence behind it.
8
+ * The sweep deletes nothing. `plan` saves the rows and their evidence;
9
+ * `check` resolves a person's approval against a plan and shows the steps a
10
+ * run would take; `run` takes them, compare-and-swap, verifying each.
10
11
  *
11
- * Two signals settle most items — a merged pull request, and ancestry of the
12
- * default branch — and either alone is enough. A host that knows more than
13
- * git does (an issue tracker, say) re-ranks the returned rows
14
- * rather than reaching into classification, which is why every row carries
15
- * its facts and why `investigationParts` is a list rather than a sentence.
12
+ * A host that knows more than git does (an issue tracker, say) passes
13
+ * `annotate` to re-rank the rows, rather than reaching into classification,
14
+ * which is why every row carries its evidence and why `notes` is a list.
16
15
  */
17
- export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherRemoteBranches, localDeleteCommand, remoteDeleteCommand, resolveBranch, } from "./branches.js";
18
- export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, } from "./worktrees.js";
19
- export { DiscoveryFailed, discoverRepo } from "./discover.js";
20
- export { assessLanding, patchEquivalent } from "./landing.js";
21
- export { ApplyArgumentError, ApplyRefused, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
22
- export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
16
+ export { sweep, inspectWorktree } from "./sweep.js";
17
+ export { PLAN_VERSION, plan, readPlan, writePlan, } from "./plan.js";
18
+ export { check, run, } from "./clean.js";
23
19
  export { RECEIPT_VERSION, ReceiptSchema, } from "./receipt.js";
24
- export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, } from "./processes.js";
25
- export { fetchPrs } from "./prs.js";
26
- export { PROVIDER_CHOICES, PROVIDER_NAMES, chooseProvider, forgeTarget, githubProvider, gitlabProvider, probeProvider, providers, } from "./providers/index.js";
27
- export { expandTilde, isCheckoutRoot, resolveEntries, } from "./resolve.js";
28
- export { holdFreedBranches, sweepRepo, sweepRepos, } from "./sweep.js";
29
- export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
30
- export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, } from "./types.js";
20
+ export { render } from "./render.js";
21
+ export { defaultProcessProbe } from "./processes.js";
22
+ export { providers } from "./providers/index.js";
23
+ export { KINDS, RISKS, RepoCleanerError, } from "./types.js";
package/dist/landing.d.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  * and a patch that is empty all end the same way: no evidence.
25
25
  */
26
26
  import type { Git } from '@osdlc/typed-git';
27
- import { type PRInfo, type PrEvidenceGap, type RepoInfo } from './types.ts';
27
+ import { type LandingEvidence, type PRInfo, type PrEvidenceGap, type RepoInfo } from './types.ts';
28
28
  /** Which check showed that the work is on the landed ref. */
29
29
  export type LandingCheck =
30
30
  /** Every commit on the branch has a patch-equivalent commit on the landed ref (rebase merge, cherry-pick). */
@@ -77,8 +77,8 @@ export declare function judgeLanding(repo: RepoInfo, tip: string, pr: PRInfo | u
77
77
  export declare function landingConfirmed(landing: Landing | null): boolean;
78
78
  /** {@link assessLanding}, reduced to the check that passed, or null. */
79
79
  export declare function patchEquivalent(repo: RepoInfo, tip: string, git: Git, prHead?: string | null): LandingCheck | null;
80
- /** Write the landing evidence into a row's `facts`. */
81
- export declare function recordLanding(facts: Record<string, unknown>, landing: Landing): void;
80
+ /** Write the landing evidence into a row's evidence. */
81
+ export declare function recordLanding(evidence: LandingEvidence, landing: Landing): void;
82
82
  /**
83
83
  * The part of a row's reason that says whether the content check agreed, for a
84
84
  * patch-equivalent verdict.
package/dist/landing.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * shallow clone, a missing merge base, a commit that is not on the landed ref
24
24
  * and a patch that is empty all end the same way: no evidence.
25
25
  */
26
- import { landedRef } from "./types.js";
26
+ import { landedRef, } from "./types.js";
27
27
  /** Cap on {@link Landing} `unmatched`. */
28
28
  const UNMATCHED_SHOWN = 10;
29
29
  /**
@@ -237,15 +237,15 @@ export function landingConfirmed(landing) {
237
237
  export function patchEquivalent(repo, tip, git, prHead = null) {
238
238
  return assessLanding(repo, tip, git, prHead).check;
239
239
  }
240
- /** Write the landing evidence into a row's `facts`. */
241
- export function recordLanding(facts, landing) {
240
+ /** Write the landing evidence into a row's evidence. */
241
+ export function recordLanding(evidence, landing) {
242
242
  if (landing.check !== null) {
243
- facts['landing'] = 'patch-equivalent';
244
- facts['landing_check'] = landing.check;
245
- facts['landing_content_confirmed'] = landing.confirmed;
243
+ evidence.landing = 'patch-equivalent';
244
+ evidence.landingCheck = landing.check;
245
+ evidence.landingContentConfirmed = landing.confirmed;
246
246
  }
247
247
  else if (landing.unmatched.length > 0) {
248
- facts['unmatched_commits'] = landing.unmatched;
248
+ evidence.unmatchedCommits = landing.unmatched;
249
249
  }
250
250
  }
251
251
  /**
package/dist/plan.d.ts CHANGED
@@ -1,23 +1,27 @@
1
1
  /**
2
- * The cleanup plan: the deletable rows of a sweep, each with a fingerprint of
3
- * the facts its verdict rests on. `applyPlan` re-sweeps and drops any row
4
- * whose fingerprint no longer matches.
2
+ * The plan: a sweep's deletable rows, each with a fingerprint of the evidence
3
+ * its verdict rests on, and the options the sweep ran with. `check` sweeps
4
+ * again with the same options and drops any row whose fingerprint moved.
5
5
  */
6
6
  import { z } from 'zod';
7
- import { type ProviderChoice } from './providers/types.ts';
8
- import { type SweepItem } from './types.ts';
9
- export declare const PLAN_VERSION = 1;
10
- /** The categories a plan can hold, in the order apply runs them. */
11
- export declare const PLAN_CATEGORIES: readonly ["worktree", "local_branch", "remote_branch"];
12
- export type PlanCategory = (typeof PLAN_CATEGORIES)[number];
7
+ import { type Row, type Sweep } from './types.ts';
8
+ export declare const PLAN_VERSION = 2;
9
+ /** A row id, `<kind>:<name>` with a name; shared by the plan and the receipt. */
10
+ export declare const RowIdSchema: z.ZodCustom<`worktree:${string}` | `branch:${string}` | `remote:${string}`, `worktree:${string}` | `branch:${string}` | `remote:${string}`>;
13
11
  export declare const PlanRowSchema: z.ZodObject<{
14
- category: z.ZodEnum<{
15
- local_branch: "local_branch";
16
- worktree: "worktree";
17
- remote_branch: "remote_branch";
12
+ id: z.ZodCustom<`worktree:${string}` | `branch:${string}` | `remote:${string}`, `worktree:${string}` | `branch:${string}` | `remote:${string}`>;
13
+ verdict: z.ZodEnum<{
14
+ delete: "delete";
15
+ review: "review";
16
+ keep: "keep";
17
+ blocked: "blocked";
18
18
  }>;
19
- id: z.ZodString;
20
- requires: z.ZodArray<z.ZodEnum<{
19
+ action: z.ZodNullable<z.ZodEnum<{
20
+ delete: "delete";
21
+ remove: "remove";
22
+ prune: "prune";
23
+ }>>;
24
+ risks: z.ZodArray<z.ZodEnum<{
21
25
  "abandon-unmerged": "abandon-unmerged";
22
26
  "discard-changes": "discard-changes";
23
27
  "confirm-open-pr": "confirm-open-pr";
@@ -27,24 +31,21 @@ export declare const PlanRowSchema: z.ZodObject<{
27
31
  export type PlanRow = z.infer<typeof PlanRowSchema>;
28
32
  export declare const PlanRepoSchema: z.ZodObject<{
29
33
  root: z.ZodString;
30
- remote: z.ZodNullable<z.ZodString>;
31
- no_gh: z.ZodBoolean;
32
- provider: z.ZodOptional<z.ZodEnum<{
33
- github: "github";
34
- gitlab: "gitlab";
35
- auto: "auto";
36
- none: "none";
37
- }>>;
38
- branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
39
- fetch: z.ZodDefault<z.ZodBoolean>;
34
+ fetched: z.ZodBoolean;
40
35
  rows: z.ZodArray<z.ZodObject<{
41
- category: z.ZodEnum<{
42
- local_branch: "local_branch";
43
- worktree: "worktree";
44
- remote_branch: "remote_branch";
36
+ id: z.ZodCustom<`worktree:${string}` | `branch:${string}` | `remote:${string}`, `worktree:${string}` | `branch:${string}` | `remote:${string}`>;
37
+ verdict: z.ZodEnum<{
38
+ delete: "delete";
39
+ review: "review";
40
+ keep: "keep";
41
+ blocked: "blocked";
45
42
  }>;
46
- id: z.ZodString;
47
- requires: z.ZodArray<z.ZodEnum<{
43
+ action: z.ZodNullable<z.ZodEnum<{
44
+ delete: "delete";
45
+ remove: "remove";
46
+ prune: "prune";
47
+ }>>;
48
+ risks: z.ZodArray<z.ZodEnum<{
48
49
  "abandon-unmerged": "abandon-unmerged";
49
50
  "discard-changes": "discard-changes";
50
51
  "confirm-open-pr": "confirm-open-pr";
@@ -54,28 +55,42 @@ export declare const PlanRepoSchema: z.ZodObject<{
54
55
  }, z.core.$strip>;
55
56
  export type PlanRepo = z.infer<typeof PlanRepoSchema>;
56
57
  export declare const PlanSchema: z.ZodObject<{
57
- version: z.ZodLiteral<1>;
58
- created_at: z.ZodString;
59
- repos: z.ZodArray<z.ZodObject<{
60
- root: z.ZodString;
61
- remote: z.ZodNullable<z.ZodString>;
62
- no_gh: z.ZodBoolean;
63
- provider: z.ZodOptional<z.ZodEnum<{
58
+ version: z.ZodLiteral<2>;
59
+ createdAt: z.ZodString;
60
+ options: z.ZodObject<{
61
+ only: z.ZodArray<z.ZodEnum<{
62
+ worktree: "worktree";
63
+ branch: "branch";
64
+ remote: "remote";
65
+ }>>;
66
+ remote: z.ZodBoolean;
67
+ offline: z.ZodBoolean;
68
+ against: z.ZodNullable<z.ZodString>;
69
+ provider: z.ZodEnum<{
64
70
  github: "github";
65
71
  gitlab: "gitlab";
66
72
  auto: "auto";
67
73
  none: "none";
68
- }>>;
69
- branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
70
- fetch: z.ZodDefault<z.ZodBoolean>;
74
+ }>;
75
+ branch: z.ZodNullable<z.ZodString>;
76
+ }, z.core.$strip>;
77
+ repos: z.ZodArray<z.ZodObject<{
78
+ root: z.ZodString;
79
+ fetched: z.ZodBoolean;
71
80
  rows: z.ZodArray<z.ZodObject<{
72
- category: z.ZodEnum<{
73
- local_branch: "local_branch";
74
- worktree: "worktree";
75
- remote_branch: "remote_branch";
81
+ id: z.ZodCustom<`worktree:${string}` | `branch:${string}` | `remote:${string}`, `worktree:${string}` | `branch:${string}` | `remote:${string}`>;
82
+ verdict: z.ZodEnum<{
83
+ delete: "delete";
84
+ review: "review";
85
+ keep: "keep";
86
+ blocked: "blocked";
76
87
  }>;
77
- id: z.ZodString;
78
- requires: z.ZodArray<z.ZodEnum<{
88
+ action: z.ZodNullable<z.ZodEnum<{
89
+ delete: "delete";
90
+ remove: "remove";
91
+ prune: "prune";
92
+ }>>;
93
+ risks: z.ZodArray<z.ZodEnum<{
79
94
  "abandon-unmerged": "abandon-unmerged";
80
95
  "discard-changes": "discard-changes";
81
96
  "confirm-open-pr": "confirm-open-pr";
@@ -85,38 +100,29 @@ export declare const PlanSchema: z.ZodObject<{
85
100
  }, z.core.$strip>>;
86
101
  }, z.core.$strip>;
87
102
  export type Plan = z.infer<typeof PlanSchema>;
88
- /** What planning reads off a row. A host's own row type plans the same way. */
89
- export type PlannableRow = Pick<SweepItem, 'id' | 'facts' | 'requires' | 'deletable'> & {
90
- category: string;
91
- };
92
- export declare function isPlanCategory(category: string): category is PlanCategory;
93
103
  /**
94
- * The facts a row's verdict rests on. A branch: its sha and its PR's number
95
- * and state. A worktree: HEAD, branch, how much unlanded work it holds and
96
- * whether its lock was stale and whether it is on disk, so a lock taken or
97
- * revived after planning, or a directory that came back, drops the row.
104
+ * The evidence a row's verdict rests on. A branch: its sha and its PR's number
105
+ * and state. A worktree: HEAD, branch, how much unlanded work it holds, whether
106
+ * its lock was stale, whether it is on disk, and each submodule's state, so a
107
+ * lock taken or revived after planning, a directory that came back, or a
108
+ * submodule that changed, drops the row.
98
109
  */
99
- export declare function fingerprint(item: PlannableRow): Record<string, unknown>;
110
+ export declare function fingerprint(row: Row): Record<string, unknown>;
100
111
  /** Equal fingerprints, treating a missing key as null. */
101
112
  export declare function sameFingerprint(a: Record<string, unknown>, b: Record<string, unknown>): boolean;
102
- /** One repository's plan entry: its deletable rows. */
103
- export declare function planRepo(root: string, opts: {
104
- remote: string | null;
105
- noProvider: boolean;
106
- branch?: string | null;
107
- fetch?: boolean;
108
- provider?: ProviderChoice | undefined;
109
- }, items: readonly PlannableRow[]): PlanRepo;
113
+ /** True when `row` could run without being picked by name: it has an action and is not blocked. */
114
+ export declare function runnable(row: Pick<PlanRow, 'action' | 'verdict'>): boolean;
115
+ /** A plan of `sweep`: every row, in every repository it read. */
116
+ export declare function plan(sweep: Sweep, now?: Date): Plan;
110
117
  /** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
111
118
  export declare function fileStamp(d: Date): string;
112
119
  export declare function writePlan(path: string, plan: Plan): void;
113
- /** A plan file that cannot be read or does not match {@link PlanSchema}. */
114
- export declare class PlanReadError extends Error {
115
- }
116
120
  /**
117
121
  * Parse a plan's JSON text. `source` names where it came from, for the error.
118
- * @throws PlanReadError
122
+ * A plan of another version is refused, not translated: sweep again.
123
+ *
124
+ * @throws RepoCleanerError (`bad-plan`)
119
125
  */
120
126
  export declare function parsePlan(text: string, source: string): Plan;
121
- /** @throws PlanReadError */
127
+ /** @throws RepoCleanerError (`bad-plan`) */
122
128
  export declare function readPlan(path: string): Plan;
package/dist/plan.js CHANGED
@@ -1,74 +1,69 @@
1
1
  /**
2
- * The cleanup plan: the deletable rows of a sweep, each with a fingerprint of
3
- * the facts its verdict rests on. `applyPlan` re-sweeps and drops any row
4
- * whose fingerprint no longer matches.
2
+ * The plan: a sweep's deletable rows, each with a fingerprint of the evidence
3
+ * its verdict rests on, and the options the sweep ran with. `check` sweeps
4
+ * again with the same options and drops any row whose fingerprint moved.
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { z } from 'zod';
8
8
  import { PROVIDER_CHOICES } from "./providers/types.js";
9
- import { AUTHORIZATIONS } from "./types.js";
9
+ import { ACTIONS, KINDS, RISKS, RepoCleanerError, VERDICTS, parseRowId, } from "./types.js";
10
10
  import { writeJsonAtomic } from "./write-json.js";
11
- export const PLAN_VERSION = 1;
12
- /** The categories a plan can hold, in the order apply runs them. */
13
- export const PLAN_CATEGORIES = ['worktree', 'local_branch', 'remote_branch'];
11
+ export const PLAN_VERSION = 2;
12
+ /** A row id, `<kind>:<name>` with a name; shared by the plan and the receipt. */
13
+ export const RowIdSchema = z.custom((v) => typeof v === 'string' && (parseRowId(v)?.name ?? '') !== '', { message: 'expected <kind>:<name>' });
14
14
  export const PlanRowSchema = z.object({
15
- category: z.enum(PLAN_CATEGORIES),
16
- id: z.string(),
17
- requires: z.array(z.enum(AUTHORIZATIONS)),
15
+ id: RowIdSchema,
16
+ /** The sweep's verdict when the plan was made. Without a pick, only `delete` rows run. */
17
+ verdict: z.enum(VERDICTS),
18
+ /** What deleting it does; null for a row that can never be deleted, kept so a pick of it is skipped with its reason. */
19
+ action: z.enum(ACTIONS).nullable(),
20
+ risks: z.array(z.enum(RISKS)),
18
21
  fingerprint: z.record(z.string(), z.unknown()),
19
22
  });
20
23
  export const PlanRepoSchema = z.object({
21
24
  root: z.string(),
22
- /** The `remote` the sweep ran with; the re-sweep uses the same. */
23
- remote: z.string().nullable(),
24
- /**
25
- * True when the sweep asked no provider for pull requests. The name is the
26
- * plan format's, from when GitHub was the only provider; renaming it would
27
- * break every plan already written.
28
- */
29
- no_gh: z.boolean(),
30
- /** The provider choice the sweep ran with; absent is `auto`. `no_gh` wins over it. */
31
- provider: z.enum(PROVIDER_CHOICES).optional(),
32
- /** The `branch` the sweep was narrowed to; the re-sweep uses the same. */
33
- branch: z.string().nullable().default(null),
34
- /**
35
- * Whether the sweep fetched before classifying. The re-sweep fetches too, so
36
- * it judges against the remote's current default branch, as the sweep did.
37
- * A plan written before this field reads as not fetched.
38
- */
39
- fetch: z.boolean().default(false),
25
+ /** Whether this repository's sweep fetched; the re-check fetches too, so it judges against the same default branch. */
26
+ fetched: z.boolean(),
40
27
  rows: z.array(PlanRowSchema),
41
28
  });
42
29
  export const PlanSchema = z.object({
43
30
  version: z.literal(PLAN_VERSION),
44
- created_at: z.string(),
31
+ createdAt: z.string(),
32
+ /** What was swept; the re-check sweeps the same. `SweptWith` is this object's type. */
33
+ options: z.object({
34
+ only: z.array(z.enum(KINDS)),
35
+ remote: z.boolean(),
36
+ offline: z.boolean(),
37
+ against: z.string().nullable(),
38
+ provider: z.enum(PROVIDER_CHOICES),
39
+ branch: z.string().nullable(),
40
+ }),
45
41
  repos: z.array(PlanRepoSchema),
46
42
  });
47
- export function isPlanCategory(category) {
48
- return PLAN_CATEGORIES.includes(category);
49
- }
50
43
  /**
51
- * The facts a row's verdict rests on. A branch: its sha and its PR's number
52
- * and state. A worktree: HEAD, branch, how much unlanded work it holds and
53
- * whether its lock was stale and whether it is on disk, so a lock taken or
54
- * revived after planning, or a directory that came back, drops the row.
44
+ * The evidence a row's verdict rests on. A branch: its sha and its PR's number
45
+ * and state. A worktree: HEAD, branch, how much unlanded work it holds, whether
46
+ * its lock was stale, whether it is on disk, and each submodule's state, so a
47
+ * lock taken or revived after planning, a directory that came back, or a
48
+ * submodule that changed, drops the row.
55
49
  */
56
- export function fingerprint(item) {
57
- const f = item.facts;
58
- if (item.category === 'worktree') {
50
+ export function fingerprint(row) {
51
+ if (row.kind === 'worktree') {
52
+ const e = row.evidence;
59
53
  return {
60
- head: f['head'] ?? null,
61
- branch: f['branch'] ?? null,
62
- dirty_files: f['dirty_files'] ?? null,
63
- unpushed_commits: f['unpushed_commits'] ?? null,
64
- stale_lock: f['stale_lock'] ?? null,
65
- // Prune or remove: a directory that came back between plan and apply
54
+ head: e.head,
55
+ branch: e.branch,
56
+ dirtyFiles: e.dirtyFiles ?? null,
57
+ unpushedCommits: e.unpushedCommits ?? null,
58
+ staleLock: e.staleLock ?? null,
59
+ // Prune or remove: a directory that came back between plan and run
66
60
  // must not turn a prune into a remove.
67
- on_disk: f['on_disk'] ?? null,
61
+ onDisk: e.onDisk ?? null,
62
+ submodules: e.submodules ?? null,
68
63
  };
69
64
  }
70
- const pr = f['pr'];
71
- return { sha: f['sha'] ?? null, pr_number: pr?.number ?? null, pr_state: pr?.state ?? null };
65
+ const pr = row.evidence.pr;
66
+ return { sha: row.evidence.sha, prNumber: pr?.number ?? null, prState: pr?.state ?? null };
72
67
  }
73
68
  /** Equal fingerprints, treating a missing key as null. */
74
69
  export function sameFingerprint(a, b) {
@@ -79,27 +74,33 @@ export function sameFingerprint(a, b) {
79
74
  }
80
75
  return true;
81
76
  }
82
- /** One repository's plan entry: its deletable rows. */
83
- export function planRepo(root, opts, items) {
84
- const rows = [];
85
- for (const item of items) {
86
- if (!item.deletable || !isPlanCategory(item.category))
87
- continue;
88
- rows.push({
89
- category: item.category,
90
- id: item.id,
91
- requires: item.requires,
92
- fingerprint: fingerprint(item),
93
- });
94
- }
77
+ /**
78
+ * The rows of `rows` as a plan holds them: every one. Without a pick only those
79
+ * with an action run; the rest are there so a pick of one is skipped with its
80
+ * reason rather than refused as unknown.
81
+ */
82
+ function planRows(rows) {
83
+ return rows.map((row) => ({
84
+ id: row.id,
85
+ verdict: row.verdict,
86
+ action: row.action,
87
+ risks: row.risks,
88
+ fingerprint: fingerprint(row),
89
+ }));
90
+ }
91
+ /** True when `row` could run without being picked by name: it has an action and is not blocked. */
92
+ export function runnable(row) {
93
+ return row.action !== null && row.verdict !== 'blocked';
94
+ }
95
+ /** A plan of `sweep`: every row, in every repository it read. */
96
+ export function plan(sweep, now = new Date()) {
95
97
  return {
96
- root,
97
- remote: opts.remote,
98
- no_gh: opts.noProvider,
99
- ...(opts.provider === undefined || opts.provider === 'auto' ? {} : { provider: opts.provider }),
100
- branch: opts.branch ?? null,
101
- fetch: opts.fetch ?? false,
102
- rows,
98
+ version: PLAN_VERSION,
99
+ createdAt: now.toISOString(),
100
+ options: { ...sweep.options },
101
+ repos: sweep.repos.flatMap((s) => s.repo === null
102
+ ? []
103
+ : [{ root: s.repo.root, fetched: s.repo.fetched, rows: planRows(s.rows) }]),
103
104
  };
104
105
  }
105
106
  /** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
@@ -109,12 +110,11 @@ export function fileStamp(d) {
109
110
  export function writePlan(path, plan) {
110
111
  writeJsonAtomic(path, plan);
111
112
  }
112
- /** A plan file that cannot be read or does not match {@link PlanSchema}. */
113
- export class PlanReadError extends Error {
114
- }
115
113
  /**
116
114
  * Parse a plan's JSON text. `source` names where it came from, for the error.
117
- * @throws PlanReadError
115
+ * A plan of another version is refused, not translated: sweep again.
116
+ *
117
+ * @throws RepoCleanerError (`bad-plan`)
118
118
  */
119
119
  export function parsePlan(text, source) {
120
120
  let raw;
@@ -122,21 +122,26 @@ export function parsePlan(text, source) {
122
122
  raw = JSON.parse(text);
123
123
  }
124
124
  catch (err) {
125
- throw new PlanReadError(`cannot read plan ${source}: ${err instanceof Error ? err.message : String(err)}`);
125
+ throw new RepoCleanerError('bad-plan', `cannot read plan ${source}: ${err instanceof Error ? err.message : String(err)}`);
126
+ }
127
+ const version = raw?.version;
128
+ if (version !== PLAN_VERSION) {
129
+ throw new RepoCleanerError('bad-plan', `plan ${source} is version ${JSON.stringify(version ?? null)}; this repo-cleaner reads version ${PLAN_VERSION}. Sweep again to make a new plan`);
126
130
  }
127
131
  const parsed = PlanSchema.safeParse(raw);
128
- if (!parsed.success)
129
- throw new PlanReadError(`bad plan ${source}: ${parsed.error.message}`);
132
+ if (!parsed.success) {
133
+ throw new RepoCleanerError('bad-plan', `bad plan ${source}: ${parsed.error.message}`);
134
+ }
130
135
  return parsed.data;
131
136
  }
132
- /** @throws PlanReadError */
137
+ /** @throws RepoCleanerError (`bad-plan`) */
133
138
  export function readPlan(path) {
134
139
  let text;
135
140
  try {
136
141
  text = readFileSync(path, 'utf-8');
137
142
  }
138
143
  catch (err) {
139
- throw new PlanReadError(`cannot read plan ${path}: ${err instanceof Error ? err.message : String(err)}`);
144
+ throw new RepoCleanerError('bad-plan', `cannot read plan ${path}: ${err instanceof Error ? err.message : String(err)}`);
140
145
  }
141
146
  return parsePlan(text, path);
142
147
  }