@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/CHANGELOG.md +18 -0
- package/README.md +497 -329
- package/dist/branches.d.ts +9 -36
- package/dist/branches.js +92 -172
- package/dist/clean.d.ts +162 -0
- package/dist/clean.js +755 -0
- package/dist/cli.d.ts +12 -16
- package/dist/cli.js +340 -479
- package/dist/hooks.d.ts +8 -12
- package/dist/hooks.js +2 -2
- package/dist/index.d.ts +18 -25
- package/dist/index.js +16 -23
- package/dist/landing.d.ts +3 -3
- package/dist/landing.js +7 -7
- package/dist/plan.d.ts +76 -70
- package/dist/plan.js +82 -77
- package/dist/receipt.d.ts +44 -57
- package/dist/receipt.js +46 -28
- package/dist/reference.d.ts +12 -0
- package/dist/reference.js +148 -0
- package/dist/remote-mirror.d.ts +8 -0
- package/dist/remote-mirror.js +18 -0
- package/dist/render.d.ts +61 -61
- package/dist/render.js +195 -254
- package/dist/resolve.js +1 -9
- package/dist/status-line.d.ts +1 -1
- package/dist/status-line.js +5 -5
- package/dist/step-board.d.ts +1 -1
- package/dist/step-board.js +2 -5
- package/dist/submodules.d.ts +120 -0
- package/dist/submodules.js +292 -0
- package/dist/sweep.d.ts +97 -67
- package/dist/sweep.js +272 -118
- package/dist/types.d.ts +276 -105
- package/dist/types.js +100 -54
- package/dist/util.d.ts +7 -0
- package/dist/util.js +20 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +21 -37
- package/dist/worktrees.js +198 -165
- package/package.json +4 -4
- package/dist/apply.d.ts +0 -131
- package/dist/apply.js +0 -541
package/dist/branches.d.ts
CHANGED
|
@@ -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
|
|
13
|
+
import { type BranchRow, type LandingEvidence, type PRInfo, type PrEvidenceGap, type RemoteRow, type RepoInfo } from './types.ts';
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
* `
|
|
34
|
-
* PR, by ancestry or by a patch match. Every
|
|
35
|
-
*
|
|
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):
|
|
56
|
-
export declare function
|
|
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 {
|
|
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
|
-
|
|
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
|
-
* `
|
|
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(
|
|
37
|
+
export function recordPr(evidence, pr, tip, repo) {
|
|
38
38
|
if (pr === undefined)
|
|
39
39
|
return { landed: false, gap: null };
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
* `
|
|
71
|
-
* PR, by ancestry or by a patch match. Every
|
|
72
|
-
*
|
|
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
|
|
79
|
-
return
|
|
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
|
-
|
|
133
|
+
function classifyLocalBranch(opts) {
|
|
141
134
|
const { name, upstream, track, lastCommit, sha, repo, prs, worktreeBranches, git } = opts;
|
|
142
|
-
const
|
|
135
|
+
const evidence = {
|
|
143
136
|
branch: name,
|
|
144
137
|
upstream: upstream || null,
|
|
145
138
|
track: track || null,
|
|
146
|
-
|
|
139
|
+
lastCommit,
|
|
147
140
|
sha,
|
|
148
141
|
};
|
|
149
|
-
const
|
|
150
|
-
|
|
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(
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
+
evidence.ancestorOfRemoteMain = ancestor;
|
|
186
171
|
const isAncestor = ancestor === true;
|
|
187
172
|
const pr = prs.get(name);
|
|
188
|
-
const { landed, gap } = recordPr(
|
|
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(
|
|
178
|
+
recordLanding(evidence, patch);
|
|
194
179
|
const patchLanded = patch !== null && patch.check !== null;
|
|
195
180
|
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
196
|
-
|
|
181
|
+
evidence.merged = mergedFact({ landed, ancestor: isAncestor, patchLanded });
|
|
197
182
|
if (!landed && !isAncestor) {
|
|
198
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
205
|
+
notes.push(gapSentence(pr, gap, repo));
|
|
248
206
|
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
|
249
207
|
if (unmatched !== null)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
284
|
-
const { name, sha, repo, prs, localBranchNames, git } = opts;
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
247
|
+
hasLocal: localBranchNames.has(branch),
|
|
291
248
|
};
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
309
|
-
const pr = prs.get(
|
|
310
|
-
const { landed, gap } = recordPr(
|
|
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(
|
|
264
|
+
recordLanding(evidence, patch);
|
|
316
265
|
const patchLanded = patch !== null && patch.check !== null;
|
|
317
266
|
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
289
|
+
notes.push(gapSentence(pr, gap, repo));
|
|
368
290
|
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
|
369
291
|
if (unmatched !== null)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
}
|
package/dist/clean.d.ts
ADDED
|
@@ -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 {};
|