@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/types.d.ts CHANGED
@@ -1,43 +1,219 @@
1
1
  /**
2
2
  * The inventory a sweep produces, and the vocabulary it is described in.
3
3
  *
4
- * One shape serves every category. A caller that wants only worktrees filters
5
- * by `category` rather than reading a different type, and a host that adds its
6
- * own knowledge (an issue tracker's linkage, say) rewrites rows in place.
4
+ * A sweep returns rows. Each row is one thing that could be deleted — a
5
+ * worktree, a local branch, a remote branch — with the sweep's verdict on it,
6
+ * the action that would delete it, the risks that action carries, and the
7
+ * evidence behind it all. A host that knows more than git does rewrites rows
8
+ * through the `annotate` hook rather than reaching into classification.
7
9
  */
10
+ import type { LandingCheck } from './landing.ts';
11
+ import type { Plan } from './plan.ts';
12
+ import type { ProcessHolder } from './processes.ts';
8
13
  import type { ProviderStatus } from './providers/types.ts';
9
- /** What the sweep concluded about an item. */
10
- export type Classification = 'safe-to-delete' | 'safe-to-keep' | 'needs-investigation';
11
- /** The kinds of thing a sweep inventories. */
12
- export type Category = 'local_branch' | 'worktree' | 'remote_branch';
14
+ /** The kinds of row, in the order a run acts on them. */
15
+ export declare const KINDS: readonly ["worktree", "branch", "remote"];
16
+ /** A worktree, a local branch, or a branch on a remote. */
17
+ export type Kind = (typeof KINDS)[number];
13
18
  /**
14
- * What a sweep is doing, for a status line. `repo` is the path being swept;
15
- * `index` and `of` place it among the run's repositories. `done` and `total`
16
- * count items within a step that walks many.
19
+ * A row's name with its kind in front: `worktree:<path>`, `branch:<name>`,
20
+ * `remote:<remote>/<name>`. Unique within a repository.
17
21
  */
18
- export interface SweepProgress {
19
- repo: string;
20
- index: number;
21
- of: number;
22
- step: 'discover' | 'fetch' | 'prs' | 'worktrees' | 'local-branches' | 'remote-branches';
23
- done?: number;
24
- total?: number;
25
- /** The CLI asked during the `prs` step: `gh`, `glab`. */
26
- tool?: string;
27
- }
28
- /** Which categories to gather. */
29
- export declare const ALL_SCOPES: readonly ["local-branches", "worktrees", "remote-branches"];
30
- export type Scope = (typeof ALL_SCOPES)[number];
22
+ export type RowId = `${Kind}:${string}`;
23
+ /** The row id of `name` in `kind`. */
24
+ export declare function rowId(kind: Kind, name: string): RowId;
25
+ /**
26
+ * `<kind>:<name>` split, or null when `text` has no kind in front. A
27
+ * one-letter prefix is a Windows drive (`C:\\…`), not a kind.
28
+ */
29
+ export declare function parseRowId(text: string): {
30
+ kind: Kind;
31
+ name: string;
32
+ } | null;
33
+ /**
34
+ * What the sweep concluded about a row.
35
+ *
36
+ * `delete` its work is on the default branch, or it is otherwise safe to lose
37
+ * `review` the signals are mixed, or it holds work; deleted only when picked
38
+ * `keep` structural (the default branch, the current checkout), or in use
39
+ * (an open pull request); a kept row with an action is deleted only
40
+ * when picked
41
+ * `blocked` something rules removal out; never deleted, whatever is granted
42
+ */
43
+ export declare const VERDICTS: readonly ["delete", "review", "keep", "blocked"];
44
+ export type Verdict = (typeof VERDICTS)[number];
45
+ /**
46
+ * What deleting a row does: `remove` a worktree, `prune` the stale record of
47
+ * a worktree whose directory is gone, `delete` a branch. Null on a row that
48
+ * can never be deleted.
49
+ */
50
+ export declare const ACTIONS: readonly ["remove", "prune", "delete"];
51
+ export type Action = (typeof ACTIONS)[number];
31
52
  /**
32
- * A risk a delete or remove carries, which the user approves by name:
33
- * `abandon-unmerged` drops commits not on the default branch or a merged PR
34
- * `discard-changes` drops a worktree's modified, staged or untracked files
35
- * `confirm-open-pr` the branch has an open PR; deleting the remote branch closes it
53
+ * A risk an action carries, which a person approves by name:
54
+ * `abandon-unmerged` drops commits not on the default branch, or a worktree's
55
+ * (or its submodules') commits and stashes no remote has
56
+ * `discard-changes` drops modified, staged or untracked files in a worktree
57
+ * or any of its submodules
58
+ * `confirm-open-pr` the branch has an open pull request; deleting the remote
59
+ * branch closes it
36
60
  */
37
- export declare const AUTHORIZATIONS: readonly ["abandon-unmerged", "discard-changes", "confirm-open-pr"];
38
- export type Authorization = (typeof AUTHORIZATIONS)[number];
39
- /** The authorizations whose flag is set, in canonical order. */
40
- export declare function authorizationsFor(flags: Partial<Record<Authorization, boolean>>): Authorization[];
61
+ export declare const RISKS: readonly ["abandon-unmerged", "discard-changes", "confirm-open-pr"];
62
+ export type Risk = (typeof RISKS)[number];
63
+ /** The risks whose flag is set, in canonical order. */
64
+ export declare function risksFor(flags: Partial<Record<Risk, boolean>>): Risk[];
65
+ /** A condition that rules out removing a worktree, whatever is granted. */
66
+ export type Blocker = 'locked-worktree' | 'live-process' | 'process-probe-failed' | 'hidden-index-flags' | 'nested-repository' | 'branch-in-other-worktree' | 'submodule-unreadable';
67
+ /** Why a merged PR does not prove that a tip landed. */
68
+ export type PrEvidenceGap = 'other-base' | 'head-moved';
69
+ /** A pull request, reduced to what classification consults. */
70
+ export interface PRInfo {
71
+ number: number;
72
+ state: string;
73
+ mergedAt: string | null;
74
+ headRef: string;
75
+ /** The PR's head commit; null when the provider did not report one. */
76
+ headOid: string | null;
77
+ /** The branch the PR targets. */
78
+ baseRef: string;
79
+ title: string;
80
+ url: string;
81
+ }
82
+ /** A row's pull request, as evidence: the PR without its head branch, which the row names already. */
83
+ export type PrEvidence = Omit<PRInfo, 'headRef'> & {
84
+ /** For a merged PR: `exact-head` when it proves the tip landed, otherwise the gap. */
85
+ proof?: 'exact-head' | PrEvidenceGap;
86
+ };
87
+ /** Evidence every kind of row records about whether its work landed. */
88
+ export interface LandingEvidence {
89
+ pr?: PrEvidence;
90
+ /** The work is on the default branch: by ancestry, an exact-head merged PR, or a patch match. Absent on a row not judged. */
91
+ merged?: boolean;
92
+ /** Commits not on the default branch; null when they could not be counted. */
93
+ uniqueCommits?: number | null;
94
+ /** Set when the work landed under other commit ids. */
95
+ landing?: 'patch-equivalent';
96
+ /** Which patch check passed. */
97
+ landingCheck?: LandingCheck;
98
+ /** True when `git merge-tree` agreed the content is on the default branch. */
99
+ landingContentConfirmed?: boolean;
100
+ /** Commits of a partly landed branch that have no patch-equivalent commit. */
101
+ unmatchedCommits?: string[];
102
+ }
103
+ /** How a branch shows it was worked on, for a worktree merged by ancestry alone. */
104
+ export type BranchUse = 'commits' | 'upstream';
105
+ /** One submodule of a worktree, as evidence. */
106
+ export interface SubmoduleEvidence {
107
+ path: string;
108
+ populated: boolean;
109
+ head: string | null;
110
+ recorded: string | null;
111
+ dirtyFiles: number;
112
+ unpushedCommits: number;
113
+ stashes: number;
114
+ error?: string;
115
+ }
116
+ export interface WorktreeEvidence extends LandingEvidence {
117
+ path: string;
118
+ branch: string | null;
119
+ head: string | null;
120
+ bare: boolean;
121
+ detached: boolean;
122
+ primary: boolean;
123
+ /** The directory is there and is still a checkout. */
124
+ onDisk?: boolean;
125
+ /** The directory is there but is no longer a checkout (its `.git` is gone). */
126
+ directoryLeft?: boolean;
127
+ dirtyFiles?: number;
128
+ unpushedCommits?: number;
129
+ /** What `unpushedCommits` was counted against: the upstream, or the default branch. */
130
+ compareAgainst?: string;
131
+ branchExists?: boolean;
132
+ /** Set for a worktree merged by ancestry alone; null when its branch looks freshly made. */
133
+ branchUsed?: BranchUse | null;
134
+ lockReason?: string | null;
135
+ /** The lock's owner is dead, so the lock is cleared before the remove. */
136
+ staleLock?: boolean;
137
+ lockOwner?: {
138
+ pid: number;
139
+ startedAt: string | null;
140
+ };
141
+ liveProcesses?: ProcessHolder[];
142
+ idleShells?: ProcessHolder[];
143
+ processProbeError?: string;
144
+ /** Count of skip-worktree and assume-unchanged files. */
145
+ hiddenIndexFlags?: number;
146
+ /** The first ten of them. */
147
+ hiddenIndexPaths?: string[];
148
+ nestedRepositories?: string[];
149
+ otherWorktrees?: string[];
150
+ submodules?: SubmoduleEvidence[];
151
+ submoduleDirtyFiles?: number;
152
+ /** Its submodules make plain `git worktree remove` refuse, so the remove is forced. */
153
+ forcedForSubmodules?: boolean;
154
+ }
155
+ export interface BranchEvidence extends LandingEvidence {
156
+ branch: string;
157
+ upstream: string | null;
158
+ track: string | null;
159
+ lastCommit: string;
160
+ sha: string;
161
+ compareAgainst?: string;
162
+ /** Null when the remote default branch was not found. */
163
+ ancestorOfRemoteMain?: boolean | null;
164
+ /** The worktree whose removal frees this branch; it is deleted only when that worktree goes in the same run. */
165
+ worktree?: string;
166
+ }
167
+ export interface RemoteEvidence extends LandingEvidence {
168
+ branch: string;
169
+ remote: string;
170
+ sha: string;
171
+ /** A local branch has the same name. */
172
+ hasLocal: boolean;
173
+ ancestorOfRemoteMain?: boolean;
174
+ }
175
+ interface RowCommon {
176
+ id: RowId;
177
+ /** The row's name within its kind: a worktree's path, a branch name, `<remote>/<branch>`. */
178
+ name: string;
179
+ verdict: Verdict;
180
+ /** What deleting it does; null when it can never be deleted. */
181
+ action: Action | null;
182
+ /** One line, already phrased for a report. */
183
+ why: string;
184
+ /** What its action would lose, which a person must allow by name. */
185
+ risks: Risk[];
186
+ /** Non-empty exactly when `verdict` is `blocked`. */
187
+ blockers: Blocker[];
188
+ /** The literal commands that would carry out `action`. Never run by the sweep. */
189
+ commands: string[];
190
+ /**
191
+ * Sentences on what is unclear or what to do about a blocker. A list, so a
192
+ * host appends its own before anything is rendered.
193
+ */
194
+ notes: string[];
195
+ }
196
+ export interface WorktreeRow extends RowCommon {
197
+ kind: 'worktree';
198
+ evidence: WorktreeEvidence;
199
+ }
200
+ export interface BranchRow extends RowCommon {
201
+ kind: 'branch';
202
+ evidence: BranchEvidence;
203
+ }
204
+ export interface RemoteRow extends RowCommon {
205
+ kind: 'remote';
206
+ evidence: RemoteEvidence;
207
+ }
208
+ /** One thing that could be deleted, and the sweep's verdict on it. */
209
+ export type Row = WorktreeRow | BranchRow | RemoteRow;
210
+ /** A row whose deletion only prunes a stale worktree record. */
211
+ export declare function isPrune(row: Pick<Row, 'verdict' | 'action'>): boolean;
212
+ type Defaulted = 'commands' | 'notes' | 'risks' | 'blockers';
213
+ /** The fields a classifier supplies for a row of type `R`; kind, name and evidence it fixes once. */
214
+ export type RowFields<R extends Row> = Omit<R, Defaulted | 'id' | 'kind' | 'name' | 'evidence'> & Partial<Pick<R, Defaulted>>;
215
+ /** Build a row, defaulting its lists to empty. */
216
+ export declare function makeRow<R extends Row>(partial: Omit<R, Defaulted | 'id'> & Partial<Pick<R, Defaulted>>): R;
41
217
  /** What the sweep learned about a repository before inventorying it. */
42
218
  export interface RepoInfo {
43
219
  /** The main checkout's root, even when the sweep was pointed at a worktree. */
@@ -51,9 +227,8 @@ export interface RepoInfo {
51
227
  remote: string | null;
52
228
  currentBranch: string;
53
229
  /**
54
- * The provider asked for pull requests and what its probe found: whether
55
- * its CLI is installed and signed in. Null when none was asked
56
- * (`--provider none`, or a sweep without `--remotes`).
230
+ * The provider asked for pull requests and what its probe found. Null when
231
+ * none was asked (`provider: 'none'`, or a sweep without remote branches).
57
232
  */
58
233
  provider: ProviderStatus | null;
59
234
  /** True when the sweep fetched the remote before classifying. */
@@ -63,70 +238,48 @@ export interface RepoInfo {
63
238
  export declare function landedRef(repo: RepoInfo): string;
64
239
  /** True when the repository has a remote the sweep did not fetch, so its refs may be stale. */
65
240
  export declare function isUnfetched(repo: RepoInfo): boolean;
66
- /** Why a merged PR does not prove that a tip landed. */
67
- export type PrEvidenceGap = 'other-base' | 'head-moved';
68
- /** A pull request, reduced to what classification consults. */
69
- export interface PRInfo {
70
- number: number;
71
- state: string;
72
- mergedAt: string | null;
73
- headRef: string;
74
- /** The PR's head commit; null when `gh` did not report one. */
75
- headOid: string | null;
76
- /** The branch the PR targets. */
77
- baseRef: string;
78
- title: string;
79
- url: string;
80
- }
81
- export interface SweepItem {
82
- category: Category;
83
- id: string;
84
- classification: Classification;
85
- action: string;
86
- /** One line, already phrased for a report. */
87
- reason: string;
88
- /** The evidence behind the verdict. A host re-ranking a row reads these. */
89
- facts: Record<string, unknown>;
90
- /** Literal commands that would carry out `action`. Never run by this library. */
91
- commands: string[];
92
- /**
93
- * Sentences explaining what is unclear, for a `needs-investigation` row.
94
- *
95
- * Parts rather than one string: a host with knowledge this library does not
96
- * have appends its own sentence before anything is rendered. Empty for a row
97
- * that needs no investigation.
98
- */
99
- investigationParts: string[];
100
- /** What deleting or removing this row would need approved. */
101
- requires: Authorization[];
102
- /**
103
- * False for a row kept for structure, such as the default branch, and for a
104
- * blocked worktree. Never offered for deletion.
105
- */
106
- deletable: boolean;
241
+ /** Rows of one kind, counted by what the sweep concluded. */
242
+ export interface Bucket {
243
+ total: number;
244
+ /** Rows whose work is on the default branch. Rows kept for structure are not judged. */
245
+ merged: number;
246
+ /** `delete` rows that delete something. */
247
+ delete: number;
248
+ /** `delete` rows that only prune a stale worktree record. */
249
+ prune: number;
250
+ review: number;
251
+ blocked: number;
252
+ keep: number;
107
253
  }
108
- /** One repository's inventory, or the reason there is none. */
254
+ export type Counts = Record<Kind, Bucket>;
255
+ export declare function emptyBucket(): Bucket;
256
+ /** Per-kind counts over `rows`, every kind present. */
257
+ export declare function countRows(rows: readonly Row[]): Counts;
258
+ /** `counts` summed across kinds. */
259
+ export declare function totalOf(counts: Counts): Bucket;
260
+ /** One repository's rows, or the reason there are none. */
109
261
  export interface RepoSweep {
110
- /** Present when the repository could be read. */
111
- repo: RepoInfo | null;
112
262
  /** The path the sweep was pointed at, whether or not it could be read. */
113
263
  path: string;
114
- items: SweepItem[];
115
- /** Why this repository produced no inventory. Null on success. */
264
+ /** Present when the repository could be read. */
265
+ repo: RepoInfo | null;
266
+ rows: Row[];
267
+ counts: Counts;
268
+ /** Why this repository produced no rows. Null on success. */
116
269
  error: string | null;
117
270
  }
118
- /** An entry that named no repository. */
271
+ /** A target that named no repository. */
119
272
  export interface UnresolvedEntry {
120
273
  entry: string;
121
274
  reason: string;
122
275
  }
123
276
  /**
124
- * What resolving the entries cost. An entry that is itself a checkout costs
125
- * nothing to find; a container is walked, and a person who points one at the
126
- * wrong directory deserves to be told how much was read.
277
+ * What resolving the targets cost. A target that is itself a checkout costs
278
+ * nothing to find; a directory is walked, and a person who points one at the
279
+ * wrong place deserves to be told how much was read.
127
280
  */
128
281
  export interface ScanStats {
129
- /** Entries that had to be searched, rather than naming a checkout outright. */
282
+ /** Targets that had to be searched, rather than naming a checkout outright. */
130
283
  containers: number;
131
284
  /** Directories read across those searches. */
132
285
  directoriesScanned: number;
@@ -141,12 +294,18 @@ export interface DuplicateCheckout {
141
294
  /** Main-checkout paths, sorted. */
142
295
  roots: string[];
143
296
  }
144
- /** Every repository swept in one run, plus the entries that named none. */
145
- export interface SweepResult {
297
+ /** The options a sweep ran with, as a plan records them so a re-check runs the same sweep. */
298
+ export type SweptWith = Plan['options'];
299
+ /** Every repository swept in one run, plus the targets that named none. */
300
+ export interface Sweep {
146
301
  repos: RepoSweep[];
147
302
  unresolved: UnresolvedEntry[];
148
303
  scan: ScanStats;
149
304
  duplicates: DuplicateCheckout[];
305
+ /** Every repository's counts, summed per kind. */
306
+ counts: Counts;
307
+ /** What was swept, for a plan. */
308
+ options: SweptWith;
150
309
  }
151
310
  /**
152
311
  * Slugs shared by two or more of `repos`, roots sorted. A null entry or a
@@ -157,22 +316,34 @@ export declare function findDuplicateCheckouts(repos: readonly ({
157
316
  slug: string | null;
158
317
  } | null)[]): DuplicateCheckout[];
159
318
  export declare function emptyScan(): ScanStats;
160
- export interface SummaryBucket {
161
- total: number;
162
- /** Rows whose work is on the default branch (`facts.merged`). Rows kept for structure are not judged. */
163
- merged: number;
164
- 'safe-to-delete': number;
165
- /** The `safe-to-delete` rows that only prune a stale worktree link (`action: 'prune'`); the directory, if any, stays. */
166
- prune: number;
167
- 'safe-to-keep': number;
168
- 'needs-investigation': number;
169
- }
170
- export declare function emptyBucket(): SummaryBucket;
171
- /** Per-category counts over `items`. */
172
- export declare function summarize(items: readonly SweepItem[]): Record<string, SummaryBucket>;
173
- /** A row whose deletion only prunes a stale worktree link. */
174
- export declare function isPrune(item: Pick<SweepItem, 'classification' | 'action'>): boolean;
175
- type Defaulted = 'facts' | 'commands' | 'investigationParts' | 'requires' | 'deletable';
176
- /** Build an item, defaulting the collection fields and `deletable: true`. */
177
- export declare function makeItem(partial: Omit<SweepItem, Defaulted> & Partial<Pick<SweepItem, Defaulted>>): SweepItem;
319
+ /**
320
+ * What a sweep is doing, for a status line. `repo` is the path being swept;
321
+ * `index` and `of` place it among the run's repositories. `done` and `total`
322
+ * count rows within a step that walks many.
323
+ */
324
+ export interface SweepProgress {
325
+ repo: string;
326
+ index: number;
327
+ of: number;
328
+ step: 'discover' | 'fetch' | 'prs' | Kind;
329
+ done?: number;
330
+ total?: number;
331
+ /** The CLI asked during the `prs` step: `gh`, `glab`. */
332
+ tool?: string;
333
+ }
334
+ /**
335
+ * Why the library refused. Nothing has been touched when it throws.
336
+ *
337
+ * `conflict` options that contradict each other, or one that would have no effect
338
+ * `bad-input` a malformed row id or an unknown risk
339
+ * `not-found` a picked, spared or allowed row the plan does not have
340
+ * `ambiguous` a row id that matches rows in more than one repository
341
+ * `spared-changed` a spared remote branch is gone or moved since the plan
342
+ * `bad-plan` a plan that cannot be read, or one from another plan version
343
+ */
344
+ export type ErrorCode = 'conflict' | 'bad-input' | 'not-found' | 'ambiguous' | 'spared-changed' | 'bad-plan';
345
+ export declare class RepoCleanerError extends Error {
346
+ readonly code: ErrorCode;
347
+ constructor(code: ErrorCode, message: string);
348
+ }
178
349
  export {};
package/dist/types.js CHANGED
@@ -1,22 +1,76 @@
1
1
  /**
2
2
  * The inventory a sweep produces, and the vocabulary it is described in.
3
3
  *
4
- * One shape serves every category. A caller that wants only worktrees filters
5
- * by `category` rather than reading a different type, and a host that adds its
6
- * own knowledge (an issue tracker's linkage, say) rewrites rows in place.
4
+ * A sweep returns rows. Each row is one thing that could be deleted — a
5
+ * worktree, a local branch, a remote branch — with the sweep's verdict on it,
6
+ * the action that would delete it, the risks that action carries, and the
7
+ * evidence behind it all. A host that knows more than git does rewrites rows
8
+ * through the `annotate` hook rather than reaching into classification.
7
9
  */
8
- /** Which categories to gather. */
9
- export const ALL_SCOPES = ['local-branches', 'worktrees', 'remote-branches'];
10
+ /** The kinds of row, in the order a run acts on them. */
11
+ export const KINDS = ['worktree', 'branch', 'remote'];
12
+ /** The row id of `name` in `kind`. */
13
+ export function rowId(kind, name) {
14
+ return `${kind}:${name}`;
15
+ }
16
+ /**
17
+ * `<kind>:<name>` split, or null when `text` has no kind in front. A
18
+ * one-letter prefix is a Windows drive (`C:\\…`), not a kind.
19
+ */
20
+ export function parseRowId(text) {
21
+ const colon = text.indexOf(':');
22
+ if (colon <= 1)
23
+ return null;
24
+ const kind = text.slice(0, colon);
25
+ if (!KINDS.includes(kind))
26
+ return null;
27
+ return { kind: kind, name: text.slice(colon + 1) };
28
+ }
29
+ /**
30
+ * What the sweep concluded about a row.
31
+ *
32
+ * `delete` its work is on the default branch, or it is otherwise safe to lose
33
+ * `review` the signals are mixed, or it holds work; deleted only when picked
34
+ * `keep` structural (the default branch, the current checkout), or in use
35
+ * (an open pull request); a kept row with an action is deleted only
36
+ * when picked
37
+ * `blocked` something rules removal out; never deleted, whatever is granted
38
+ */
39
+ export const VERDICTS = ['delete', 'review', 'keep', 'blocked'];
10
40
  /**
11
- * A risk a delete or remove carries, which the user approves by name:
12
- * `abandon-unmerged` drops commits not on the default branch or a merged PR
13
- * `discard-changes` drops a worktree's modified, staged or untracked files
14
- * `confirm-open-pr` the branch has an open PR; deleting the remote branch closes it
41
+ * What deleting a row does: `remove` a worktree, `prune` the stale record of
42
+ * a worktree whose directory is gone, `delete` a branch. Null on a row that
43
+ * can never be deleted.
15
44
  */
16
- export const AUTHORIZATIONS = ['abandon-unmerged', 'discard-changes', 'confirm-open-pr'];
17
- /** The authorizations whose flag is set, in canonical order. */
18
- export function authorizationsFor(flags) {
19
- return AUTHORIZATIONS.filter((name) => flags[name] === true);
45
+ export const ACTIONS = ['remove', 'prune', 'delete'];
46
+ /**
47
+ * A risk an action carries, which a person approves by name:
48
+ * `abandon-unmerged` drops commits not on the default branch, or a worktree's
49
+ * (or its submodules') commits and stashes no remote has
50
+ * `discard-changes` drops modified, staged or untracked files in a worktree
51
+ * or any of its submodules
52
+ * `confirm-open-pr` the branch has an open pull request; deleting the remote
53
+ * branch closes it
54
+ */
55
+ export const RISKS = ['abandon-unmerged', 'discard-changes', 'confirm-open-pr'];
56
+ /** The risks whose flag is set, in canonical order. */
57
+ export function risksFor(flags) {
58
+ return RISKS.filter((name) => flags[name] === true);
59
+ }
60
+ /** A row whose deletion only prunes a stale worktree record. */
61
+ export function isPrune(row) {
62
+ return row.verdict === 'delete' && row.action === 'prune';
63
+ }
64
+ /** Build a row, defaulting its lists to empty. */
65
+ export function makeRow(partial) {
66
+ return {
67
+ ...partial,
68
+ id: rowId(partial.kind, partial.name),
69
+ commands: partial.commands ?? [],
70
+ notes: partial.notes ?? [],
71
+ risks: partial.risks ?? [],
72
+ blockers: partial.blockers ?? [],
73
+ };
20
74
  }
21
75
  /** The ref a branch must reach to count as landed: `<remote>/<default>`, or the local default without a remote. */
22
76
  export function landedRef(repo) {
@@ -26,6 +80,33 @@ export function landedRef(repo) {
26
80
  export function isUnfetched(repo) {
27
81
  return repo.remote !== null && !repo.fetched;
28
82
  }
83
+ export function emptyBucket() {
84
+ return { total: 0, merged: 0, delete: 0, prune: 0, review: 0, blocked: 0, keep: 0 };
85
+ }
86
+ /** Per-kind counts over `rows`, every kind present. */
87
+ export function countRows(rows) {
88
+ const out = Object.fromEntries(KINDS.map((k) => [k, emptyBucket()]));
89
+ for (const row of rows) {
90
+ const bucket = out[row.kind];
91
+ bucket.total += 1;
92
+ if (row.evidence.merged === true)
93
+ bucket.merged += 1;
94
+ if (isPrune(row))
95
+ bucket.prune += 1;
96
+ else
97
+ bucket[row.verdict] += 1;
98
+ }
99
+ return out;
100
+ }
101
+ /** `counts` summed across kinds. */
102
+ export function totalOf(counts) {
103
+ const out = emptyBucket();
104
+ for (const kind of KINDS) {
105
+ for (const key of Object.keys(out))
106
+ out[key] += counts[kind][key];
107
+ }
108
+ return out;
109
+ }
29
110
  /**
30
111
  * Slugs shared by two or more of `repos`, roots sorted. A null entry or a
31
112
  * repository without a slug is left out.
@@ -47,46 +128,11 @@ export function findDuplicateCheckouts(repos) {
47
128
  export function emptyScan() {
48
129
  return { containers: 0, directoriesScanned: 0, depthLimited: 0, checkoutsFound: 0 };
49
130
  }
50
- export function emptyBucket() {
51
- return {
52
- total: 0,
53
- merged: 0,
54
- 'safe-to-delete': 0,
55
- prune: 0,
56
- 'safe-to-keep': 0,
57
- 'needs-investigation': 0,
58
- };
59
- }
60
- /** Per-category counts over `items`. */
61
- export function summarize(items) {
62
- const out = {};
63
- for (const item of items) {
64
- const bucket = (out[item.category] ??= emptyBucket());
65
- bucket.total += 1;
66
- if (item.facts['merged'] === true)
67
- bucket.merged += 1;
68
- if (isPrune(item))
69
- bucket.prune += 1;
70
- bucket[item.classification] += 1;
131
+ export class RepoCleanerError extends Error {
132
+ code;
133
+ constructor(code, message) {
134
+ super(message);
135
+ this.name = 'RepoCleanerError';
136
+ this.code = code;
71
137
  }
72
- return out;
73
- }
74
- /** A row whose deletion only prunes a stale worktree link. */
75
- export function isPrune(item) {
76
- return item.classification === 'safe-to-delete' && item.action === 'prune';
77
- }
78
- /** Build an item, defaulting the collection fields and `deletable: true`. */
79
- export function makeItem(partial) {
80
- return {
81
- category: partial.category,
82
- id: partial.id,
83
- classification: partial.classification,
84
- action: partial.action,
85
- reason: partial.reason,
86
- facts: partial.facts ?? {},
87
- commands: partial.commands ?? [],
88
- investigationParts: partial.investigationParts ?? [],
89
- requires: partial.requires ?? [],
90
- deletable: partial.deletable ?? true,
91
- };
92
138
  }
package/dist/util.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** Small helpers every module of the package shares. */
2
+ /** True when `path` is a directory; false for anything else, or when it cannot be read. */
3
+ export declare function isDir(path: string): boolean;
4
+ /** An error as one line for a person: a failed git call's own output, or the message. */
5
+ export declare function errorText(err: unknown): string;
6
+ /** `1 commit`, `3 commits`. */
7
+ export declare const plural: (n: number, one: string, many: string) => string;
package/dist/util.js ADDED
@@ -0,0 +1,20 @@
1
+ /** Small helpers every module of the package shares. */
2
+ import { statSync } from 'node:fs';
3
+ import { CommandFailed } from '@osdlc/typed-git';
4
+ /** True when `path` is a directory; false for anything else, or when it cannot be read. */
5
+ export function isDir(path) {
6
+ try {
7
+ return statSync(path).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /** An error as one line for a person: a failed git call's own output, or the message. */
14
+ export function errorText(err) {
15
+ if (err instanceof CommandFailed)
16
+ return err.detail.trim() || err.message;
17
+ return err instanceof Error ? err.message : String(err);
18
+ }
19
+ /** `1 commit`, `3 commits`. */
20
+ export const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
@@ -1,2 +1,2 @@
1
1
  /** This package's version, from its package.json when it was built. */
2
- export declare const VERSION = "0.17.2";
2
+ export declare const VERSION = "0.18.0";
@@ -1,3 +1,3 @@
1
1
  // Generated by cli-tool-stamp-version from package.json. Do not edit.
2
2
  /** This package's version, from its package.json when it was built. */
3
- export const VERSION = '0.17.2';
3
+ export const VERSION = '0.18.0';