@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +691 -305
  3. package/dist/branches.d.ts +37 -36
  4. package/dist/branches.js +199 -196
  5. package/dist/clean.d.ts +179 -0
  6. package/dist/clean.js +824 -0
  7. package/dist/cli.d.ts +12 -16
  8. package/dist/cli.js +367 -478
  9. package/dist/hooks.d.ts +8 -12
  10. package/dist/hooks.js +2 -2
  11. package/dist/index.d.ts +18 -25
  12. package/dist/index.js +16 -23
  13. package/dist/landing.d.ts +3 -3
  14. package/dist/landing.js +7 -7
  15. package/dist/plan.d.ts +76 -70
  16. package/dist/plan.js +82 -77
  17. package/dist/providers/github.d.ts +2 -1
  18. package/dist/providers/github.js +2 -1
  19. package/dist/providers/gitlab.d.ts +3 -2
  20. package/dist/providers/gitlab.js +14 -8
  21. package/dist/providers/index.d.ts +5 -4
  22. package/dist/providers/index.js +8 -5
  23. package/dist/providers/types.d.ts +8 -2
  24. package/dist/prs.d.ts +35 -7
  25. package/dist/prs.js +45 -12
  26. package/dist/receipt.d.ts +46 -57
  27. package/dist/receipt.js +52 -28
  28. package/dist/reference.d.ts +12 -0
  29. package/dist/reference.js +152 -0
  30. package/dist/remote-mirror.d.ts +9 -1
  31. package/dist/remote-mirror.js +19 -1
  32. package/dist/render.d.ts +66 -61
  33. package/dist/render.js +206 -255
  34. package/dist/resolve.d.ts +3 -3
  35. package/dist/resolve.js +4 -12
  36. package/dist/status-line.d.ts +1 -1
  37. package/dist/status-line.js +5 -5
  38. package/dist/step-board.d.ts +1 -1
  39. package/dist/step-board.js +2 -5
  40. package/dist/submodules.d.ts +120 -0
  41. package/dist/submodules.js +292 -0
  42. package/dist/sweep.d.ts +107 -64
  43. package/dist/sweep.js +333 -119
  44. package/dist/types.d.ts +294 -105
  45. package/dist/types.js +106 -54
  46. package/dist/util.d.ts +13 -0
  47. package/dist/util.js +36 -0
  48. package/dist/version.generated.d.ts +1 -1
  49. package/dist/version.generated.js +1 -1
  50. package/dist/worktrees.d.ts +21 -37
  51. package/dist/worktrees.js +198 -165
  52. package/package.json +5 -5
  53. package/dist/apply.d.ts +0 -131
  54. package/dist/apply.js +0 -541
@@ -10,29 +10,21 @@
10
10
  * leaves the branch for a person to look at.
11
11
  */
12
12
  import { type Git } from '@osdlc/typed-git';
13
- import { type PRInfo, type PrEvidenceGap, type RepoInfo, type SweepItem } from './types.ts';
13
+ import { type BranchRow, type LandingEvidence, type PRInfo, type PrEvidenceGap, type RemoteRow, type RepoInfo } from './types.ts';
14
14
  /**
15
- * True when merged `pr` proves `tip` landed: it targeted the default branch
16
- * and its head commit is `tip`. Fork PRs never reach here; `fetchPrs` drops
17
- * them. A PR with no reported head counts as `head-moved`.
18
- */
19
- export declare function prCapturesTip(pr: PRInfo, tip: string, repo: RepoInfo): true | PrEvidenceGap;
20
- /**
21
- * `facts.pr`, plus `evidence` for a merged PR: `exact-head` when it proves
15
+ * `evidence.pr`, plus `proof` for a merged PR: `exact-head` when it proves
22
16
  * `tip` landed, otherwise the gap. Returns whether it counts as landing proof.
23
17
  */
24
- export declare function recordPr(facts: Record<string, unknown>, pr: PRInfo | undefined, tip: string, repo: RepoInfo): {
18
+ export declare function recordPr(evidence: LandingEvidence, pr: PRInfo | undefined, tip: string, repo: RepoInfo): {
25
19
  landed: boolean;
26
20
  gap: PrEvidenceGap | null;
27
21
  };
28
- /** Why a branch has no PR, naming where the sweep looked. */
29
- export declare function noPrSentence(repo: RepoInfo): string;
30
- /** One sentence on why a merged PR did not count, for `investigationParts`. */
22
+ /** One sentence on why a merged PR did not count, for a row's notes. */
31
23
  export declare function gapSentence(pr: PRInfo, gap: PrEvidenceGap, repo: RepoInfo): string;
32
24
  /**
33
- * `facts.merged`: the work is on the default branch, by an exact-head merged
34
- * PR, by ancestry or by a patch match. Every category records it this way, so
35
- * `summary` counts one definition.
25
+ * `evidence.merged`: the work is on the default branch, by an exact-head
26
+ * merged PR, by ancestry or by a patch match. Every kind records it this way,
27
+ * so the counts use one definition.
36
28
  */
37
29
  export declare function mergedFact(e: {
38
30
  landed: boolean;
@@ -52,24 +44,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): SweepItem[];
56
- export declare function classifyLocalBranch(opts: {
57
- name: string;
58
- upstream: string;
59
- track: string;
60
- lastCommit: string;
61
- sha: string;
62
- repo: RepoInfo;
63
- prs: Map<string, PRInfo>;
64
- worktreeBranches: ReadonlySet<string>;
65
- git: Git;
66
- }): SweepItem;
67
- export declare function gatherRemoteBranches(repo: RepoInfo, prs: Map<string, PRInfo>, localBranchNames: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): SweepItem[];
68
- export declare function classifyRemoteBranch(opts: {
69
- name: string;
70
- sha: string;
71
- repo: RepoInfo;
72
- prs: Map<string, PRInfo>;
73
- localBranchNames: ReadonlySet<string>;
74
- git: Git;
75
- }): SweepItem;
47
+ export declare function gatherLocalBranches(repo: RepoInfo, prs: Map<string, PRInfo>, worktreeBranches: ReadonlySet<string>, git: Git, only?: string, tick?: (done: number, total: number) => void): BranchRow[];
48
+ /** 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 { authorizationsFor, landedRef, makeItem, } from "./types.js";
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; `fetchPrs` drops
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
- export function prCapturesTip(pr, tip, repo) {
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
- * `facts.pr`, plus `evidence` for a merged PR: `exact-head` when it proves
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(facts, pr, tip, repo) {
39
+ export function recordPr(evidence, pr, tip, repo) {
38
40
  if (pr === undefined)
39
41
  return { landed: false, gap: null };
40
- const out = {
41
- number: pr.number,
42
- state: pr.state,
43
- merged_at: pr.mergedAt,
44
- url: pr.url,
45
- title: pr.title,
46
- head_oid: pr.headOid,
47
- base_ref: pr.baseRef,
48
- };
49
- facts['pr'] = out;
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['evidence'] = verdict === true ? 'exact-head' : verdict;
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
- export function noPrSentence(repo) {
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 `investigationParts`. */
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
- * `facts.merged`: the work is on the default branch, by an exact-head merged
71
- * PR, by ancestry or by a patch match. Every category records it this way, so
72
- * `summary` counts one definition.
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
- /** What deleting a branch needs: its unlanded commits, and its open PR. */
78
- function branchRequires(unmerged, pr) {
79
- return authorizationsFor({
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
- export function classifyLocalBranch(opts) {
146
+ function classifyLocalBranch(opts) {
141
147
  const { name, upstream, track, lastCommit, sha, repo, prs, worktreeBranches, git } = opts;
142
- const facts = {
148
+ const evidence = {
143
149
  branch: name,
144
150
  upstream: upstream || null,
145
151
  track: track || null,
146
- last_commit: lastCommit,
152
+ lastCommit,
147
153
  sha,
148
154
  };
149
- const keep = (reason, extra = {}) => makeItem({
150
- category: 'local_branch',
151
- id: name,
152
- classification: 'safe-to-keep',
153
- action: 'keep',
154
- reason,
155
- facts,
156
- ...extra,
157
- });
158
- const pinned = (reason) => keep(reason, { deletable: false });
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(facts, pr, sha, repo);
172
- facts['merged'] = mergedFact({
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
- facts['compare_against'] = compareRef;
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
- facts['ancestor_of_remote_main'] = ancestor;
183
+ evidence.ancestorOfRemoteMain = ancestor;
186
184
  const isAncestor = ancestor === true;
187
185
  const pr = prs.get(name);
188
- const { landed, gap } = recordPr(facts, pr, sha, repo);
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(facts, patch);
191
+ recordLanding(evidence, patch);
194
192
  const patchLanded = patch !== null && patch.check !== null;
195
193
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
196
- facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
194
+ evidence.merged = mergedFact({ landed, ancestor: isAncestor, patchLanded });
197
195
  if (!landed && !isAncestor) {
198
- facts['unique_commits'] = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
199
- }
200
- const requires = branchRequires(unmerged, pr);
201
- const deleteCmd = [localDeleteCommand(name, sha)];
202
- if (pr && landed) {
203
- return makeItem({
204
- category: 'local_branch',
205
- id: name,
206
- classification: 'safe-to-delete',
207
- action: 'delete',
208
- reason: `PR #${pr.number} merged`,
209
- facts,
210
- commands: deleteCmd,
211
- requires,
212
- });
213
- }
214
- if (isAncestor) {
215
- return makeItem({
216
- category: 'local_branch',
217
- id: name,
218
- classification: 'safe-to-delete',
219
- action: 'delete',
220
- reason: `fully merged into ${compareRef}`,
221
- facts,
222
- commands: deleteCmd,
223
- requires,
224
- });
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 makeItem({
228
- category: 'local_branch',
229
- id: name,
230
- classification: 'safe-to-delete',
231
- action: 'delete',
232
- reason: `patch-equivalent to commits on ${compareRef} (${patch.check}); ${confirmationNote(patch)}`,
233
- facts,
234
- commands: deleteCmd,
235
- requires,
236
- });
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 parts = [lead];
244
- parts.push(upstream ? `Tracks \`${upstream}\` (${track || 'in sync'}).` : 'Has no upstream.');
245
- parts.push(pr ? `Associated PR #${pr.number} is ${pr.state} (${pr.url}).` : noPrSentence(repo));
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
- parts.push(gapSentence(pr, gap, repo));
218
+ notes.push(gapSentence(pr, gap, repo));
248
219
  const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
249
220
  if (unmatched !== null)
250
- parts.push(unmatched);
251
- parts.push(`Last commit: ${lastCommit} (${sha}).`);
252
- return makeItem({
253
- category: 'local_branch',
254
- id: name,
255
- classification: 'needs-investigation',
256
- action: 'review',
257
- reason: 'mixed or absent signals',
258
- facts,
259
- investigationParts: parts,
260
- requires,
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
- // ── remote branches ────────────────────────────────────────────────────────
264
- export function gatherRemoteBranches(repo, prs, localBranchNames, git, only, tick) {
265
- if (repo.remote === null)
266
- return [];
267
- // Default door: an unreachable remote throws rather than rendering as "the
268
- // remote has no branches". `[]` here means the remote really has none.
269
- const pattern = only === undefined ? undefined : `refs/heads/${only}`;
270
- const rows = git.at(repo.root).lsRemote(repo.remote, pattern, { heads: true });
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, { sha, ref }] of rows.entries()) {
273
- tick?.(i, rows.length);
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
- items.push(classifyRemoteBranch({ name, sha, repo, prs, localBranchNames, git }));
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
- export function classifyRemoteBranch(opts) {
284
- const { name, sha, repo, prs, localBranchNames, git } = opts;
285
- const id = `${repo.remote}/${name}`;
286
- const facts = {
287
- branch: name,
288
- remote: repo.remote,
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
- has_local: localBranchNames.has(name),
316
+ hasLocal: localBranchNames.has(branch),
291
317
  };
292
- const keep = (reason, extra = {}) => makeItem({
293
- category: 'remote_branch',
294
- id,
295
- classification: 'safe-to-keep',
296
- action: 'keep',
297
- reason,
298
- facts,
299
- ...extra,
300
- });
301
- if (name === repo.mainBranch)
302
- return keep('default branch', { deletable: false });
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` comes from `ls-remote`, so the clone may never have fetched
305
- // it, and `remoteMain` may not exist either. Both exit 128; this row renders
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
- facts['ancestor_of_remote_main'] = isAncestor;
309
- const pr = prs.get(name);
310
- const { landed, gap } = recordPr(facts, pr, sha, repo);
311
- // `sha` came from `ls-remote` and may not be in this clone; the check then
312
- // finds nothing and the branch stays unlanded.
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(facts, patch);
333
+ recordLanding(evidence, patch);
316
334
  const patchLanded = patch !== null && patch.check !== null;
317
335
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
318
- facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
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
- facts['unique_commits'] = counted.ok ? counted.value.ahead : null;
323
- }
324
- const requires = branchRequires(unmerged, pr);
325
- const deleteCmd = repo.remote === null ? [] : [remoteDeleteCommand(repo.remote, name, sha)];
326
- if (pr && landed) {
327
- return makeItem({
328
- category: 'remote_branch',
329
- id,
330
- classification: 'safe-to-delete',
331
- action: 'delete',
332
- reason: `PR #${pr.number} merged (auto-delete didn't fire)`,
333
- facts,
334
- commands: deleteCmd,
335
- requires,
336
- });
337
- }
338
- if (isAncestor) {
339
- return makeItem({
340
- category: 'remote_branch',
341
- id,
342
- classification: 'safe-to-delete',
343
- action: 'delete',
344
- reason: `fully merged into ${remoteMain}`,
345
- facts,
346
- commands: deleteCmd,
347
- requires,
348
- });
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 makeItem({
352
- category: 'remote_branch',
353
- id,
354
- classification: 'safe-to-delete',
355
- action: 'delete',
356
- reason: `patch-equivalent to commits on ${remoteMain} (${patch.check}); ${confirmationNote(patch)}`,
357
- facts,
358
- commands: deleteCmd,
359
- requires,
360
- });
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
- if (pr && pr.state === 'OPEN')
363
- return keep(`open PR #${pr.number}`, { requires });
364
- const parts = [`Remote branch \`${id}\` is not an ancestor of \`${remoteMain}\`.`];
365
- parts.push(pr ? `Associated PR #${pr.number} is ${pr.state}.` : 'No PR found.');
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
- parts.push(gapSentence(pr, gap, repo));
372
+ notes.push(gapSentence(pr, gap, repo));
368
373
  const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
369
374
  if (unmatched !== null)
370
- parts.push(unmatched);
371
- parts.push('Decide whether to push more work, delete, or leave it as long-lived.');
372
- return makeItem({
373
- category: 'remote_branch',
374
- id,
375
- classification: 'needs-investigation',
376
- action: 'review',
377
- reason: 'mixed or absent signals',
378
- facts,
379
- investigationParts: parts,
380
- requires,
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
  }