@osdlc/repo-cleaner 0.17.2 → 0.19.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 +31 -0
- package/README.md +691 -305
- package/dist/branches.d.ts +37 -36
- package/dist/branches.js +199 -196
- package/dist/clean.d.ts +179 -0
- package/dist/clean.js +824 -0
- package/dist/cli.d.ts +12 -16
- package/dist/cli.js +367 -478
- 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/providers/github.d.ts +2 -1
- package/dist/providers/github.js +2 -1
- package/dist/providers/gitlab.d.ts +3 -2
- package/dist/providers/gitlab.js +14 -8
- package/dist/providers/index.d.ts +5 -4
- package/dist/providers/index.js +8 -5
- package/dist/providers/types.d.ts +8 -2
- package/dist/prs.d.ts +35 -7
- package/dist/prs.js +45 -12
- package/dist/receipt.d.ts +46 -57
- package/dist/receipt.js +52 -28
- package/dist/reference.d.ts +12 -0
- package/dist/reference.js +152 -0
- package/dist/remote-mirror.d.ts +9 -1
- package/dist/remote-mirror.js +19 -1
- package/dist/render.d.ts +66 -61
- package/dist/render.js +206 -255
- package/dist/resolve.d.ts +3 -3
- package/dist/resolve.js +4 -12
- 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 +107 -64
- package/dist/sweep.js +333 -119
- package/dist/types.d.ts +294 -105
- package/dist/types.js +106 -54
- package/dist/util.d.ts +13 -0
- package/dist/util.js +36 -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 +5 -5
- 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,33 @@ 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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
/** Remote branch rows, and the repository note when their tips had to come from disk. */
|
|
49
|
+
export interface RemoteBranches {
|
|
50
|
+
rows: RemoteRow[];
|
|
51
|
+
/**
|
|
52
|
+
* Why the tips are not the remote's own: a live listing failed and they
|
|
53
|
+
* came from disk, or offline there were none on disk. Null otherwise.
|
|
54
|
+
*/
|
|
55
|
+
note: string | null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Remote branches, judged. With `live` the tips are the remote's own, read
|
|
59
|
+
* with `ls-remote`, which also sees branches a narrowed fetch refspec leaves
|
|
60
|
+
* out: after a fetch, and in `clean`'s re-check, which must see the remote as
|
|
61
|
+
* it is before deleting on it. Without it, as `--offline` asks of the report
|
|
62
|
+
* and `plan`, they are the remote-tracking refs on disk
|
|
63
|
+
* (`refs/remotes/<remote>/`), and the remote is not contacted.
|
|
64
|
+
*
|
|
65
|
+
* A live listing that fails is not "the remote has no branches" and not a
|
|
66
|
+
* reason to lose the repository's other rows: the tips come from disk
|
|
67
|
+
* instead, and each row and the returned note say so. With `strict` (`clean`'s
|
|
68
|
+
* re-check when nothing was fetched) the refs on disk are what the re-check
|
|
69
|
+
* is there to replace, so a failed listing throws instead.
|
|
70
|
+
*/
|
|
71
|
+
export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, opts: {
|
|
72
|
+
live: boolean;
|
|
73
|
+
strict?: boolean | undefined;
|
|
74
|
+
only?: string | undefined;
|
|
75
|
+
tick?: ((done: number, total: number) => void) | undefined;
|
|
76
|
+
}): RemoteBranches;
|
package/dist/branches.js
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
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
|
+
import { prsUnchecked } from "./prs.js";
|
|
17
|
+
import { errorText } from "./util.js";
|
|
16
18
|
/**
|
|
17
19
|
* Five fields in one pass, so a repository costs one `for-each-ref`. The full
|
|
18
20
|
* sha, so it compares with a PR's `headRefOid`.
|
|
@@ -20,10 +22,10 @@ import { authorizationsFor, landedRef, makeItem, } from "./types.js";
|
|
|
20
22
|
const LOCAL_FORMAT = '%(refname:short)%09%(upstream:short)%09%(upstream:track)%09%(committerdate:iso8601-strict)%09%(objectname)';
|
|
21
23
|
/**
|
|
22
24
|
* True when merged `pr` proves `tip` landed: it targeted the default branch
|
|
23
|
-
* and its head commit is `tip`. Fork PRs never reach here;
|
|
25
|
+
* and its head commit is `tip`. Fork PRs never reach here; the provider drops
|
|
24
26
|
* them. A PR with no reported head counts as `head-moved`.
|
|
25
27
|
*/
|
|
26
|
-
|
|
28
|
+
function prCapturesTip(pr, tip, repo) {
|
|
27
29
|
if (pr.baseRef !== repo.mainBranch)
|
|
28
30
|
return 'other-base';
|
|
29
31
|
if (pr.headOid === null || pr.headOid !== tip)
|
|
@@ -31,54 +33,58 @@ export function prCapturesTip(pr, tip, repo) {
|
|
|
31
33
|
return true;
|
|
32
34
|
}
|
|
33
35
|
/**
|
|
34
|
-
* `
|
|
36
|
+
* `evidence.pr`, plus `proof` for a merged PR: `exact-head` when it proves
|
|
35
37
|
* `tip` landed, otherwise the gap. Returns whether it counts as landing proof.
|
|
36
38
|
*/
|
|
37
|
-
export function recordPr(
|
|
39
|
+
export function recordPr(evidence, pr, tip, repo) {
|
|
38
40
|
if (pr === undefined)
|
|
39
41
|
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;
|
|
42
|
+
const { headRef: _, ...rest } = pr;
|
|
43
|
+
const out = rest;
|
|
44
|
+
evidence.pr = out;
|
|
50
45
|
if (pr.state !== 'MERGED')
|
|
51
46
|
return { landed: false, gap: null };
|
|
52
47
|
const verdict = prCapturesTip(pr, tip, repo);
|
|
53
|
-
out
|
|
48
|
+
out.proof = verdict === true ? 'exact-head' : verdict;
|
|
54
49
|
return verdict === true ? { landed: true, gap: null } : { landed: false, gap: verdict };
|
|
55
50
|
}
|
|
56
|
-
/** Why a branch has no PR, naming where the sweep looked. */
|
|
57
|
-
|
|
51
|
+
/** Why a branch has no PR, naming where the sweep looked, or why it could not. */
|
|
52
|
+
function noPrSentence(repo) {
|
|
58
53
|
const provider = repo.provider;
|
|
59
54
|
if (provider === null)
|
|
60
55
|
return 'No PR lookup (no provider).';
|
|
56
|
+
// The provider's own error is in the repository's note, not on every row.
|
|
57
|
+
const unchecked = prsUnchecked(provider);
|
|
58
|
+
if (unchecked !== null)
|
|
59
|
+
return `No ${provider.noun} lookup (${unchecked.why}).`;
|
|
61
60
|
return `No ${provider.noun} found via \`${provider.listing}\`.`;
|
|
62
61
|
}
|
|
63
|
-
/** One sentence on why a merged PR did not count, for
|
|
62
|
+
/** One sentence on why a merged PR did not count, for a row's notes. */
|
|
64
63
|
export function gapSentence(pr, gap, repo) {
|
|
65
64
|
return gap === 'other-base'
|
|
66
65
|
? `PR #${pr.number} merged into \`${pr.baseRef}\`, not \`${repo.mainBranch}\`, so it is not landing proof (other-base).`
|
|
67
66
|
: `PR #${pr.number} merged at head ${pr.headOid ?? '(unknown)'}, which is not this tip, so it is not landing proof (head-moved).`;
|
|
68
67
|
}
|
|
69
68
|
/**
|
|
70
|
-
* `
|
|
71
|
-
* PR, by ancestry or by a patch match. Every
|
|
72
|
-
*
|
|
69
|
+
* `evidence.merged`: the work is on the default branch, by an exact-head
|
|
70
|
+
* merged PR, by ancestry or by a patch match. Every kind records it this way,
|
|
71
|
+
* so the counts use one definition.
|
|
73
72
|
*/
|
|
74
73
|
export function mergedFact(e) {
|
|
75
74
|
return e.landed || e.ancestor || e.patchLanded;
|
|
76
75
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
/**
|
|
77
|
+
* What deleting a branch needs: its unlanded commits, and its open PR.
|
|
78
|
+
*
|
|
79
|
+
* @param prUnknown No PR is known for the branch, and open PRs could not be
|
|
80
|
+
* checked: the provider's CLI is missing or signed out, or its listing
|
|
81
|
+
* failed. A remote branch is then treated as having an open PR: deleting a
|
|
82
|
+
* PR's head branch closes the PR, and nothing here can say there is none.
|
|
83
|
+
*/
|
|
84
|
+
function branchRisks(unmerged, pr, prUnknown = false) {
|
|
85
|
+
return risksFor({
|
|
80
86
|
'abandon-unmerged': unmerged,
|
|
81
|
-
'confirm-open-pr': pr?.state === 'OPEN',
|
|
87
|
+
'confirm-open-pr': pr?.state === 'OPEN' || prUnknown,
|
|
82
88
|
});
|
|
83
89
|
}
|
|
84
90
|
/** `HEAD` as the branch checked out at `git`'s directory; any other name as given. */
|
|
@@ -137,25 +143,17 @@ export function gatherLocalBranches(repo, prs, worktreeBranches, git, only, tick
|
|
|
137
143
|
}
|
|
138
144
|
return items;
|
|
139
145
|
}
|
|
140
|
-
|
|
146
|
+
function classifyLocalBranch(opts) {
|
|
141
147
|
const { name, upstream, track, lastCommit, sha, repo, prs, worktreeBranches, git } = opts;
|
|
142
|
-
const
|
|
148
|
+
const evidence = {
|
|
143
149
|
branch: name,
|
|
144
150
|
upstream: upstream || null,
|
|
145
151
|
track: track || null,
|
|
146
|
-
|
|
152
|
+
lastCommit,
|
|
147
153
|
sha,
|
|
148
154
|
};
|
|
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 });
|
|
155
|
+
const row = (r) => makeRow({ kind: 'branch', name, evidence, ...r });
|
|
156
|
+
const pinned = (why) => row({ verdict: 'keep', action: null, why });
|
|
159
157
|
if (name === repo.mainBranch)
|
|
160
158
|
return pinned('default branch');
|
|
161
159
|
const held = name === repo.currentBranch
|
|
@@ -168,8 +166,8 @@ export function classifyLocalBranch(opts) {
|
|
|
168
166
|
// an exact-head merged PR, without the patch checks a deletable row gets.
|
|
169
167
|
// `soft`: a missing default ref reads as not merged.
|
|
170
168
|
const pr = prs.get(name);
|
|
171
|
-
const { landed } = recordPr(
|
|
172
|
-
|
|
169
|
+
const { landed } = recordPr(evidence, pr, sha, repo);
|
|
170
|
+
evidence.merged = mergedFact({
|
|
173
171
|
landed,
|
|
174
172
|
ancestor: git.at(repo.root).soft.isAncestor(sha, landedRef(repo)),
|
|
175
173
|
patchLanded: false,
|
|
@@ -179,204 +177,209 @@ export function classifyLocalBranch(opts) {
|
|
|
179
177
|
// Work merged only into an unpushed local default has not landed. A missing
|
|
180
178
|
// remote-tracking ref exits 128: ancestry unknown (null).
|
|
181
179
|
const compareRef = landedRef(repo);
|
|
182
|
-
|
|
180
|
+
evidence.compareAgainst = compareRef;
|
|
183
181
|
const ancestry = git.at(repo.root).try.isAncestor(name, compareRef);
|
|
184
182
|
const ancestor = ancestry.ok ? ancestry.value : null;
|
|
185
|
-
|
|
183
|
+
evidence.ancestorOfRemoteMain = ancestor;
|
|
186
184
|
const isAncestor = ancestor === true;
|
|
187
185
|
const pr = prs.get(name);
|
|
188
|
-
const { landed, gap } = recordPr(
|
|
186
|
+
const { landed, gap } = recordPr(evidence, pr, sha, repo);
|
|
189
187
|
// Rebase merges, cherry-picks and squash merges leave the tip off the default
|
|
190
188
|
// branch; the patches say whether the work is there anyway.
|
|
191
189
|
const patch = landed || isAncestor ? null : judgeLanding(repo, sha, pr, gap, git);
|
|
192
190
|
if (patch !== null)
|
|
193
|
-
recordLanding(
|
|
191
|
+
recordLanding(evidence, patch);
|
|
194
192
|
const patchLanded = patch !== null && patch.check !== null;
|
|
195
193
|
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
196
|
-
|
|
194
|
+
evidence.merged = mergedFact({ landed, ancestor: isAncestor, patchLanded });
|
|
197
195
|
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
|
-
});
|
|
196
|
+
evidence.uniqueCommits = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
|
|
225
197
|
}
|
|
198
|
+
const risks = branchRisks(unmerged, pr);
|
|
199
|
+
const commands = [localDeleteCommand(name, sha)];
|
|
200
|
+
const deletes = (why) => row({ verdict: 'delete', action: 'delete', why, commands, risks });
|
|
201
|
+
if (pr && landed)
|
|
202
|
+
return deletes(`PR #${pr.number} merged`);
|
|
203
|
+
if (isAncestor)
|
|
204
|
+
return deletes(`fully merged into ${compareRef}`);
|
|
226
205
|
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
|
-
});
|
|
206
|
+
return deletes(`patch-equivalent to commits on ${compareRef} (${patch.check}); ${confirmationNote(patch)}`);
|
|
207
|
+
}
|
|
208
|
+
if (pr && pr.state === 'OPEN') {
|
|
209
|
+
return row({ verdict: 'keep', action: 'delete', why: `open PR #${pr.number}`, commands, risks });
|
|
237
210
|
}
|
|
238
|
-
if (pr && pr.state === 'OPEN')
|
|
239
|
-
return keep(`open PR #${pr.number}`, { requires });
|
|
240
211
|
const lead = ancestor === null
|
|
241
212
|
? `Remote default branch \`${compareRef}\` was not found, so ancestry is unknown.`
|
|
242
213
|
: `Branch \`${name}\` is not an ancestor of \`${compareRef}\`.`;
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
214
|
+
const notes = [lead];
|
|
215
|
+
notes.push(upstream ? `Tracks \`${upstream}\` (${track || 'in sync'}).` : 'Has no upstream.');
|
|
216
|
+
notes.push(pr ? `Associated PR #${pr.number} is ${pr.state} (${pr.url}).` : noPrSentence(repo));
|
|
246
217
|
if (pr && gap)
|
|
247
|
-
|
|
218
|
+
notes.push(gapSentence(pr, gap, repo));
|
|
248
219
|
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
|
249
220
|
if (unmatched !== null)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
investigationParts: parts,
|
|
260
|
-
requires,
|
|
221
|
+
notes.push(unmatched);
|
|
222
|
+
notes.push(`Last commit: ${lastCommit} (${sha}).`);
|
|
223
|
+
return row({
|
|
224
|
+
verdict: 'review',
|
|
225
|
+
action: 'delete',
|
|
226
|
+
why: 'mixed or absent signals',
|
|
227
|
+
commands,
|
|
228
|
+
notes,
|
|
229
|
+
risks,
|
|
261
230
|
});
|
|
262
231
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Remote branches, judged. With `live` the tips are the remote's own, read
|
|
234
|
+
* with `ls-remote`, which also sees branches a narrowed fetch refspec leaves
|
|
235
|
+
* out: after a fetch, and in `clean`'s re-check, which must see the remote as
|
|
236
|
+
* it is before deleting on it. Without it, as `--offline` asks of the report
|
|
237
|
+
* and `plan`, they are the remote-tracking refs on disk
|
|
238
|
+
* (`refs/remotes/<remote>/`), and the remote is not contacted.
|
|
239
|
+
*
|
|
240
|
+
* A live listing that fails is not "the remote has no branches" and not a
|
|
241
|
+
* reason to lose the repository's other rows: the tips come from disk
|
|
242
|
+
* instead, and each row and the returned note say so. With `strict` (`clean`'s
|
|
243
|
+
* re-check when nothing was fetched) the refs on disk are what the re-check
|
|
244
|
+
* is there to replace, so a failed listing throws instead.
|
|
245
|
+
*/
|
|
246
|
+
export function gatherRemoteBranches(repo, prs, localBranchNames, git, opts) {
|
|
247
|
+
const remote = repo.remote;
|
|
248
|
+
if (remote === null)
|
|
249
|
+
return { rows: [], note: null };
|
|
250
|
+
const { only, tick } = opts;
|
|
251
|
+
let tips = null;
|
|
252
|
+
let note = null;
|
|
253
|
+
if (opts.live) {
|
|
254
|
+
const pattern = only === undefined ? undefined : `refs/heads/${only}`;
|
|
255
|
+
const listed = git.at(repo.root).try.lsRemote(remote, pattern, { heads: true });
|
|
256
|
+
if (listed.ok) {
|
|
257
|
+
tips = listed.value.flatMap(({ sha, ref }) => ref.startsWith('refs/heads/') ? [{ name: ref.slice('refs/heads/'.length), sha }] : []);
|
|
258
|
+
}
|
|
259
|
+
else if (opts.strict === true) {
|
|
260
|
+
throw new Error(`\`git ls-remote ${remote}\` failed (${errorText(listed.error)}); clean deletes on a remote only after asking it, so nothing was run`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
note = `\`git ls-remote ${remote}\` failed (${errorText(listed.error)}); remote branches are read from the remote-tracking refs on disk.`;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (tips === null) {
|
|
267
|
+
tips = trackingTips(repo.root, remote, git, only);
|
|
268
|
+
// A fetched remote has at least its default branch on disk; none at all
|
|
269
|
+
// means it was never fetched, or its refs live somewhere else.
|
|
270
|
+
if (!opts.live && tips.length === 0 && only === undefined) {
|
|
271
|
+
note = `No remote-tracking refs for \`${remote}\` on disk, so no remote branches to judge; fetch it once, or drop \`--offline\`.`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
271
274
|
const items = [];
|
|
272
|
-
for (const [i, {
|
|
273
|
-
tick?.(i,
|
|
274
|
-
if (!ref.startsWith('refs/heads/'))
|
|
275
|
-
continue;
|
|
276
|
-
const name = ref.slice('refs/heads/'.length);
|
|
275
|
+
for (const [i, { name, sha }] of tips.entries()) {
|
|
276
|
+
tick?.(i, tips.length);
|
|
277
277
|
if (only !== undefined && name !== only)
|
|
278
278
|
continue;
|
|
279
|
-
|
|
279
|
+
const row = classifyRemoteBranch({ name, sha, repo, prs, localBranchNames, git });
|
|
280
|
+
if (note !== null) {
|
|
281
|
+
row.notes.push(`Tip read from \`refs/remotes/${remote}/${name}\` on disk: \`git ls-remote\` failed.`);
|
|
282
|
+
}
|
|
283
|
+
items.push(row);
|
|
280
284
|
}
|
|
281
|
-
return items;
|
|
285
|
+
return { rows: items, note };
|
|
282
286
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
/**
|
|
288
|
+
* `remote`'s branches as its remote-tracking refs on disk record them, under
|
|
289
|
+
* the default `refs/remotes/<remote>/` layout. The symbolic `HEAD` is not a
|
|
290
|
+
* branch.
|
|
291
|
+
*/
|
|
292
|
+
function trackingTips(root, remote, git, only) {
|
|
293
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
294
|
+
const at = git.at(root);
|
|
295
|
+
// Git allows `/` in a remote's name: `origin/mirror`'s refs sit under
|
|
296
|
+
// `refs/remotes/origin/` too, and are not `origin`'s branches.
|
|
297
|
+
const nested = at
|
|
298
|
+
.remotes()
|
|
299
|
+
.filter((r) => r.startsWith(`${remote}/`))
|
|
300
|
+
.map((r) => `refs/remotes/${r}/`);
|
|
301
|
+
return at.refs(only === undefined ? prefix : `${prefix}${only}`).flatMap(({ sha, ref }) => {
|
|
302
|
+
if (nested.some((p) => ref.startsWith(p)))
|
|
303
|
+
return [];
|
|
304
|
+
const name = ref.slice(prefix.length);
|
|
305
|
+
return name === 'HEAD' ? [] : [{ name, sha }];
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function classifyRemoteBranch(opts) {
|
|
309
|
+
const { name: branch, sha, repo, prs, localBranchNames, git } = opts;
|
|
310
|
+
const remote = repo.remote ?? '';
|
|
311
|
+
const name = `${remote}/${branch}`;
|
|
312
|
+
const evidence = {
|
|
313
|
+
branch,
|
|
314
|
+
remote,
|
|
289
315
|
sha,
|
|
290
|
-
|
|
316
|
+
hasLocal: localBranchNames.has(branch),
|
|
291
317
|
};
|
|
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 });
|
|
318
|
+
const row = (r) => makeRow({ kind: 'remote', name, evidence, ...r });
|
|
319
|
+
if (branch === repo.mainBranch)
|
|
320
|
+
return row({ verdict: 'keep', action: null, why: 'default branch' });
|
|
303
321
|
const remoteMain = landedRef(repo);
|
|
304
|
-
// `soft`: `sha`
|
|
305
|
-
// it, and `remoteMain` may not exist either. Both exit 128; this row
|
|
306
|
-
// "cannot tell" as false.
|
|
322
|
+
// `soft`: `sha` may come from `ls-remote`, so the clone may never have
|
|
323
|
+
// fetched it, and `remoteMain` may not exist either. Both exit 128; this row
|
|
324
|
+
// renders "cannot tell" as false.
|
|
307
325
|
const isAncestor = git.at(repo.root).soft.isAncestor(sha, remoteMain);
|
|
308
|
-
|
|
309
|
-
const pr = prs.get(
|
|
310
|
-
const { landed, gap } = recordPr(
|
|
311
|
-
// `sha`
|
|
312
|
-
//
|
|
326
|
+
evidence.ancestorOfRemoteMain = isAncestor;
|
|
327
|
+
const pr = prs.get(branch);
|
|
328
|
+
const { landed, gap } = recordPr(evidence, pr, sha, repo);
|
|
329
|
+
// A `sha` from `ls-remote` may not be in this clone; the check then finds
|
|
330
|
+
// nothing and the branch stays unlanded.
|
|
313
331
|
const patch = landed || isAncestor ? null : judgeLanding(repo, sha, pr, gap, git);
|
|
314
332
|
if (patch !== null)
|
|
315
|
-
recordLanding(
|
|
333
|
+
recordLanding(evidence, patch);
|
|
316
334
|
const patchLanded = patch !== null && patch.check !== null;
|
|
317
335
|
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
318
|
-
|
|
336
|
+
evidence.merged = mergedFact({ landed, ancestor: isAncestor, patchLanded });
|
|
319
337
|
if (!landed && !isAncestor) {
|
|
320
338
|
// Unknown (null) when the clone lacks `sha` or `remoteMain`, as above.
|
|
321
339
|
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
|
-
});
|
|
340
|
+
evidence.uniqueCommits = counted.ok ? counted.value.ahead : null;
|
|
349
341
|
}
|
|
342
|
+
const status = repo.provider;
|
|
343
|
+
// `--provider none` is a choice that this remote has no forge; anything else
|
|
344
|
+
// that left PRs unchecked means one could be open.
|
|
345
|
+
const unchecked = status === null || pr !== undefined ? null : prsUnchecked(status);
|
|
346
|
+
const unknownPr = status === null || unchecked === null
|
|
347
|
+
? null
|
|
348
|
+
: `Whether it has an open ${status.noun} is unknown (${unchecked.why}), so deleting it needs confirm-open-pr.`;
|
|
349
|
+
const risks = branchRisks(unmerged, pr, unknownPr !== null);
|
|
350
|
+
const commands = repo.remote === null ? [] : [remoteDeleteCommand(repo.remote, branch, sha)];
|
|
351
|
+
const deletes = (why) => row({
|
|
352
|
+
verdict: 'delete',
|
|
353
|
+
action: 'delete',
|
|
354
|
+
why,
|
|
355
|
+
commands,
|
|
356
|
+
risks,
|
|
357
|
+
notes: unknownPr === null ? [] : [unknownPr],
|
|
358
|
+
});
|
|
359
|
+
if (pr && landed)
|
|
360
|
+
return deletes(`PR #${pr.number} merged (auto-delete didn't fire)`);
|
|
361
|
+
if (isAncestor)
|
|
362
|
+
return deletes(`fully merged into ${remoteMain}`);
|
|
350
363
|
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
|
-
});
|
|
364
|
+
return deletes(`patch-equivalent to commits on ${remoteMain} (${patch.check}); ${confirmationNote(patch)}`);
|
|
365
|
+
}
|
|
366
|
+
if (pr && pr.state === 'OPEN') {
|
|
367
|
+
return row({ verdict: 'keep', action: 'delete', why: `open PR #${pr.number}`, commands, risks });
|
|
361
368
|
}
|
|
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.');
|
|
369
|
+
const notes = [`Remote branch \`${name}\` is not an ancestor of \`${remoteMain}\`.`];
|
|
370
|
+
notes.push(pr ? `Associated PR #${pr.number} is ${pr.state}.` : (unknownPr ?? noPrSentence(repo)));
|
|
366
371
|
if (pr && gap)
|
|
367
|
-
|
|
372
|
+
notes.push(gapSentence(pr, gap, repo));
|
|
368
373
|
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
|
369
374
|
if (unmatched !== null)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
investigationParts: parts,
|
|
380
|
-
requires,
|
|
375
|
+
notes.push(unmatched);
|
|
376
|
+
notes.push('Decide whether to push more work, delete, or leave it as long-lived.');
|
|
377
|
+
return row({
|
|
378
|
+
verdict: 'review',
|
|
379
|
+
action: 'delete',
|
|
380
|
+
why: 'mixed or absent signals',
|
|
381
|
+
commands,
|
|
382
|
+
notes,
|
|
383
|
+
risks,
|
|
381
384
|
});
|
|
382
385
|
}
|