@osdlc/repo-cleaner 0.18.0 → 0.19.1

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # @osdlc/repo-cleaner
2
2
 
3
+ ## 0.19.1
4
+
5
+ ### Patch Changes
6
+
7
+ - bfac1ae: The note that suggests `--provider none` now names what the provider lists ("if this remote has no PRs", or "no merge requests" for GitLab) instead of "if this remote has no forge", and the README drops "forge" the same way.
8
+
9
+ ## 0.19.0
10
+
11
+ ### Minor Changes
12
+
13
+ - b1c3209: A lookup that fails now costs its evidence, not the repository. See "When a lookup fails" in the README.
14
+
15
+ - A pull request listing that fails, such as `gh pr list` for a repository the account cannot see, no longer turns the repository into an error with no rows. Its rows are judged on git evidence, `RepoInfo.provider.listError` says why, and the repository carries a note.
16
+ - While open pull requests cannot be checked (the listing failed, or `gh`/`glab` is missing or signed out), deleting a remote branch with no known PR needs `confirm-open-pr`, since deleting an open PR's head branch closes it. `--provider none` asks nothing and gates nothing.
17
+ - `--offline` reads remote branches from the remote-tracking refs on disk, as documented, instead of calling `git ls-remote`. After a fetch, an `ls-remote` that fails falls back to those refs. A failed fetch still fails the repository.
18
+ - `clean`'s re-check, and its preview, read the remote's tips with `ls-remote`, so an offline plan's stale tracking refs never decide a delete: a branch the remote already deleted, or pushed past, is skipped, and a spared one refuses the run. If that `ls-remote` fails, the run is refused before anything runs. A branch deleted between the re-check and the push is reported gone, not a failure (`Step.gone`, `RunSummary.gone`).
19
+ - `--provider auto` asks no provider for a remote that names no host, such as a local path, which can have no pull requests.
20
+ - New in the library: `RepoSweep.notes`, `Preview.notes`, `RunOptions.onNotes` and the `RepoNote` type. A `PrProvider` reports a failure by returning null or by throwing.
21
+
3
22
  ## 0.18.0
4
23
 
5
24
  ### Minor Changes
package/README.md CHANGED
@@ -113,7 +113,10 @@ A target is what to sweep:
113
113
  | a directory | every checkout found under it, three levels deep at most |
114
114
  | a remote URL (`https://`, `ssh://`, `file://`, `user@host:path`) | its remote branches only, through a clone kept in the cache directory (see [A remote with no checkout](#a-remote-with-no-checkout)) |
115
115
 
116
- No target sweeps the checkout you are in. A bare invocation never fans out.
116
+ No target is the same as naming the current directory: at a checkout's root
117
+ it sweeps that repository, and in a directory of checkouts it sweeps each
118
+ one. In a subdirectory of a checkout it searches below that subdirectory,
119
+ like any other directory, so name the checkout instead.
117
120
  What a target names is read off the disk rather than from how it is written:
118
121
  a path that is itself a checkout names that repository, and a path that is
119
122
  not is searched for the checkouts under it, descending through directories
@@ -134,7 +137,7 @@ The report, `plan` and `clean` take these:
134
137
  | Option | Meaning |
135
138
  | --- | --- |
136
139
  | `--remote` | Also judge remote branches. Turns on the network: fetches each remote first and asks GitHub or GitLab about pull requests. Slower. |
137
- | `--offline` | With `--remote`: do not fetch; judge against the remote-tracking refs already on disk. |
140
+ | `--offline` | With `--remote`: do not fetch. Judge against the remote-tracking refs already on disk, remote branches included, so judging needs no access to the remote. Pull requests are still looked up; add `--provider none` for no network at all. The refs can be stale: see [`--offline` reads what is already on disk](#--offline-reads-what-is-already-on-disk). |
138
141
  | `--only <kinds>` | Only these kinds of row: `worktree`, `branch`, `remote`, comma-separated. Default: `worktree,branch`, plus `remote` with `--remote`. Naming `remote` implies `--remote`. |
139
142
  | `--branch <name\|HEAD>` | Only the rows of one branch: its worktree, its local branch, and its remote branch with `--remote`. `HEAD` is the branch checked out at the target. Never the default branch. |
140
143
  | `--against <remote>` | The remote whose default branch counts as "merged into". Default: `origin`, then the first remote. |
@@ -240,7 +243,9 @@ the branch on the remote as a backup.
240
243
  since the plan, the run stops before deleting anything: you spared it for
241
244
  what it held, so if that changed, the rest of the plan rests on a wrong
242
245
  assumption. The run's first step checks the tip again with `git ls-remote`,
243
- so one that moves after the check stops the run too.
246
+ so one that moves after the check stops the run too. This holds under
247
+ `--offline` too: `clean`'s re-check asks the remote, so a spared branch the
248
+ remote deleted refuses the run, whatever the tracking refs on disk say.
244
249
  - Sparing a worktree also spares its branch, which was only free to delete
245
250
  because the worktree was going.
246
251
 
@@ -274,7 +279,7 @@ you approve them by name with `--allow`:
274
279
  | --- | --- |
275
280
  | `abandon-unmerged` | Commits not on the default branch and not captured by an exact-head merged PR, or a worktree's (or its submodules') commits and stashes that no remote has. `evidence.uniqueCommits` counts the commits. |
276
281
  | `discard-changes` | Uncommitted or untracked files in the worktree or any of its submodules. |
277
- | `confirm-open-pr` | The branch has an open pull request; deleting the remote branch closes it. |
282
+ | `confirm-open-pr` | The branch has an open pull request, or open pull requests could not be checked (the CLI is missing or signed out, or the listing failed) so one cannot be ruled out; deleting the remote branch closes it. |
278
283
 
279
284
  A worktree's submodules are part of it: one `--allow` on the worktree row
280
285
  covers the same risk in every submodule. A stash in the worktree itself is
@@ -320,7 +325,8 @@ Refused after the sweep, before deleting, with exit 1:
320
325
  - a row name that matches rows in more than one repository: sweep or plan
321
326
  only the repository it is in (a bare name can also be given as its full id,
322
327
  when the matches are of different kinds);
323
- - a spared remote branch that is gone or moved since the plan.
328
+ - a spared remote branch that moved since the plan, or that the re-check no
329
+ longer finds.
324
330
 
325
331
  Allowed, because nothing contradicts:
326
332
 
@@ -568,14 +574,216 @@ table cannot be read the worktree is held back as `process-probe-failed`. A
568
574
  host or test supplies its own probe.
569
575
  Nothing is ever killed.
570
576
 
571
- Without the provider's CLI (`gh`, or `glab` for GitLab), or without
572
- authentication, classification proceeds on git evidence alone and the report
573
- says so. See [Providers (prototype)](#providers-prototype).
577
+ When the provider's CLI is missing or signed out, or a lookup fails, the
578
+ sweep keeps going on what git alone can tell it: see
579
+ [When a lookup fails](#when-a-lookup-fails).
574
580
 
575
581
  Every branch delete names the full sha the sweep judged. `update-ref -d` and
576
582
  the pinned `--force-with-lease` push both refuse when the branch has moved
577
583
  since, so a commit pushed while you read the plan survives it.
578
584
 
585
+ ## When a lookup fails
586
+
587
+ A sweep asks three things of the outside world: `git fetch` for the remote's
588
+ current state, `git ls-remote` for its branches, and `gh` or `glab` for pull
589
+ requests. Any of them can fail: the network is down, the repository was
590
+ renamed or deleted, the signed-in account cannot see it, a rate limit.
591
+
592
+ The rule is that a failed lookup costs only what it would have told you. The
593
+ rows that did not need it are judged as usual, the rows that did are judged
594
+ without it, and the repository carries a `note` saying what was missing. Only
595
+ a failed fetch costs the whole repository.
596
+
597
+ | What failed | What you get |
598
+ | --- | --- |
599
+ | `gh pr list` / `glab api` (the PR listing) | Every row is judged on git evidence. Remote branches with no known PR need `--allow confirm-open-pr` to delete. One note on the repository. |
600
+ | `gh` / `glab` not installed or not signed in | The provider is never asked, so this is treated like a failed listing: every row is judged on git evidence, remote branches with no known PR need `--allow confirm-open-pr` to delete, and a note on the repository says why. Pass `--provider none` if the remote has no pull requests, so there is nothing to check. |
601
+ | `git ls-remote`, after a fetch that worked | Remote branches are read from the refs that fetch just wrote. A note on the repository and on each remote row. |
602
+ | `git ls-remote`, in `clean`'s re-check of an `--offline` plan | The run is refused before anything runs. The refs on disk are what the re-check is there to replace, and `clean` does not delete on a remote it cannot ask. |
603
+ | `git fetch` | The repository is an error with no rows. Every row would otherwise be judged against an older default branch than the one you asked for. |
604
+
605
+ ### Example: a repository `gh` cannot see
606
+
607
+ The remote names a repository that was deleted, or that your account cannot
608
+ read. In 0.18 and earlier the repository came back as an error, and none of
609
+ it was judged:
610
+
611
+ ```console
612
+ $ repo-cleaner --remote --offline
613
+ ~/work/app (acme/app, default main, origin, not fetched)
614
+ error: GitHub has no such pull request, issue or repository, or you cannot see it.
615
+
616
+ 0 to delete, 0 to review, across 1 repository (1 unreadable).
617
+ ```
618
+
619
+ Now the merged branch is still found, and the note says what was missing:
620
+
621
+ ```console
622
+ $ repo-cleaner --remote --offline
623
+ ~/work/app (acme/app, default main, origin, not fetched)
624
+ note: Listing PRs with `gh pr list` failed (GitHub has no such pull request, issue or repository, or you cannot see it.); judged on git evidence alone.
625
+ branch feat/login delete fully merged into origin/main
626
+ git update-ref -d refs/heads/feat/login 44d2db93…
627
+ remote origin/feat/login delete fully merged into origin/main; needs --allow confirm-open-pr
628
+ git push --force-with-lease=refs/heads/feat/login:44d2db93… origin :refs/heads/feat/login
629
+
630
+ 2 to delete, 0 to review, across 1 repository.
631
+ ```
632
+
633
+ When the remote may have no pull requests at all, the note also suggests
634
+ `--provider none`: for a host `auto` could only guess was GitHub, or a local
635
+ path swept with `--provider github`. Under `auto`, a local path gets no
636
+ provider in the first place.
637
+
638
+ ### Why the remote branch needs `confirm-open-pr`
639
+
640
+ Deleting the head branch of an open pull request closes that pull request.
641
+ When the listing works, `repo-cleaner` knows which branches have one. When it
642
+ failed, or could not run because `gh` is missing or signed out, it cannot
643
+ tell, so it does not delete a remote branch on its own say:
644
+
645
+ ```console
646
+ $ repo-cleaner clean --remote --offline --yes
647
+ repo-cleaner clean: ~/work/app: Listing PRs with `gh pr list` failed (GitHub has no such pull request, …); judged on git evidence alone.
648
+ skipped remote:origin/feat/login — not-allowed (needs confirm-open-pr)
649
+ ok branch:feat/login — git update-ref -d refs/heads/feat/login 44d2db93… (ref does not resolve)
650
+
651
+ deleted: 1 local branch
652
+ skipped 1, not deleted: 1 not-allowed (--allow confirm-open-pr)
653
+ status: complete
654
+ ```
655
+
656
+ The local branch went; the remote branch stayed. To go on, pick one:
657
+
658
+ - Fix the lookup (install `gh`, sign in, check the remote URL) and run again.
659
+ - Pass `--provider none` if this remote has no pull requests at all, so there
660
+ is none to lose.
661
+ - Check for open pull requests yourself, then pass `--allow confirm-open-pr`.
662
+
663
+ The same happens when `gh` is not installed or not signed in. Nothing was
664
+ asked, so nothing can rule out an open pull request:
665
+
666
+ ```console
667
+ $ repo-cleaner --remote --offline
668
+ ~/work/app (acme/app, default main, origin, not fetched)
669
+ note: Could not check for open PRs: `gh` is not installed, so deleting a remote branch needs confirm-open-pr. Install `gh`, or pass `--allow confirm-open-pr` once you have checked.
670
+ branch feat/login delete fully merged into origin/main
671
+ git update-ref -d refs/heads/feat/login 255acd56…
672
+ remote origin/feat/login delete fully merged into origin/main; needs --allow confirm-open-pr
673
+ git push --force-with-lease=refs/heads/feat/login:255acd56… origin :refs/heads/feat/login
674
+
675
+ 2 to delete, 0 to review, across 1 repository.
676
+ ```
677
+
678
+ `--provider none` is how you say the remote has no pull requests: then there
679
+ is no lookup, no note and no gate.
680
+
681
+ ### `--offline` reads what is already on disk
682
+
683
+ `--offline` does not fetch, and reads remote branches from the
684
+ remote-tracking refs (`refs/remotes/<remote>/`) instead of asking the remote
685
+ with `git ls-remote`. So judging works with no network and with a remote that
686
+ no longer exists. In 0.18 and earlier it still called `git ls-remote`, and a
687
+ remote that could not be read failed the whole repository:
688
+
689
+ ```console
690
+ $ repo-cleaner --remote --offline --provider none # 0.18 and earlier
691
+ ~/work/api (default main, origin, not fetched)
692
+ error: git ls-remote: The remote '/srv/git/api.git' does not exist or is not a git repository. …
693
+ ```
694
+
695
+ ```console
696
+ $ repo-cleaner --remote --offline --provider none # now
697
+ ~/work/api (default main, origin, not fetched)
698
+ branch feat/login delete fully merged into origin/main
699
+ git update-ref -d refs/heads/feat/login 255acd56…
700
+ remote origin/feat/login delete fully merged into origin/main
701
+ git push --force-with-lease=refs/heads/feat/login:255acd56… origin :refs/heads/feat/login
702
+
703
+ 2 to delete, 0 to review, across 1 repository.
704
+ ```
705
+
706
+ `--offline` still asks `gh` or `glab` about pull requests. Add
707
+ `--provider none` to judge with no network at all.
708
+
709
+ The refs on disk can be stale. A remote-tracking ref stays until the next
710
+ `git fetch --prune`, so a branch the host deleted after its pull request
711
+ merged still shows in the report, and a branch someone pushed to since shows
712
+ its old tip. That is fine for a report, but `clean` deletes on the remote, so
713
+ before it does, its re-check asks the remote itself with `git ls-remote`. A
714
+ branch the remote no longer has is skipped as gone, and one whose tip moved
715
+ is skipped too:
716
+
717
+ ```console
718
+ $ repo-cleaner clean --remote --offline --provider none --only remote --yes
719
+ skipped remote:origin/feat/search — gone (no longer in the sweep)
720
+ ok remote:origin/feat/cart — git push … origin :refs/heads/feat/cart (ls-remote lists nothing)
721
+
722
+ deleted: 1 remote branch
723
+ skipped 1, not deleted: 1 gone
724
+ status: complete
725
+ ```
726
+
727
+ The preview does the same, so `--dry-run` shows what the run will do. If the
728
+ re-check's `git ls-remote` fails, nothing runs, `--dry-run` included.
729
+ A remote branch you `--spare` (see [Pick and spare](#pick-and-spare)) that is
730
+ gone from the remote refuses the run instead, because you spared it for what
731
+ it held. `git fetch --prune` drops the stale tracking refs, so the report
732
+ stops showing them.
733
+
734
+ ### In the library
735
+
736
+ The same information is on the result, for a host to show or act on:
737
+
738
+ - `RepoSweep.notes`: one sentence for each lookup the repository was judged
739
+ without. Empty when everything answered.
740
+ - `RepoInfo.provider.listError`: the provider's own reason, when its listing
741
+ failed.
742
+ - `Preview.notes` (`check`) and `RunOptions.onNotes` (`run`): the same notes
743
+ from their re-checks. A re-check that sweeps again can meet a failure the
744
+ first sweep did not. `check` given the sweep (`CheckOptions.sweep`) reports
745
+ that sweep's notes, plus those of each repository it sweeps again because
746
+ the sweep read its remote branches from disk.
747
+ - `Step.gone` on a receipt step, and `RunSummary.gone`: a remote branch
748
+ someone deleted between the re-check and the run's push, counted apart from
749
+ what the run deleted.
750
+
751
+ ```ts
752
+ import { sweep } from '@osdlc/repo-cleaner'
753
+
754
+ const result = sweep(['.'], { remote: true, offline: true, provider: 'github' })
755
+ for (const repo of result.repos) {
756
+ if (repo.error !== null) {
757
+ console.log(`${repo.path}: could not be swept: ${repo.error}`)
758
+ continue
759
+ }
760
+ for (const note of repo.notes) console.log(`note: ${note}`)
761
+ console.log(`listError: ${repo.repo?.provider?.listError ?? '(none)'}`)
762
+ for (const row of repo.rows) console.log(row.id, row.verdict, row.risks.join(', ') || '-')
763
+ }
764
+ ```
765
+
766
+ ```text
767
+ note: Listing PRs with `gh pr list` failed (GitHub has no such pull request, …); judged on git evidence alone.
768
+ listError: GitHub has no such pull request, issue or repository, or you cannot see it.
769
+ worktree:/Users/me/work/app keep -
770
+ branch:feat/login delete -
771
+ branch:main keep -
772
+ remote:origin/feat/login delete confirm-open-pr
773
+ remote:origin/main keep -
774
+ ```
775
+
776
+ The local branch needs no grant: deleting it closes no pull request. The
777
+ remote branch carries `confirm-open-pr`, so `check` drops it unless that risk
778
+ is allowed.
779
+
780
+ A `PrProvider` you write reports a failure by returning `null` or by
781
+ throwing; either becomes `listError` and a note. Return an empty list only
782
+ when the provider answered and there are no pull requests.
783
+
784
+ The decision behind this is
785
+ [D-UVUH](docs/planning/decisions/D-UVUH-failed-lookups.md).
786
+
579
787
  ## Plan and clean
580
788
 
581
789
  ```bash
@@ -656,8 +864,8 @@ const receipt = run(preview)
656
864
  | --- | --- |
657
865
  | `sweep(targets, opts?)` | Resolve the targets and judge every row. Options as on the command line: `only`, `remote`, `offline`, `against`, `provider`, `branch`, plus `cwd`, `cacheDir`, `annotate`, `onProgress`, `onWarn`, and the injectable `git`, `gh`, `glab` runners and `probe` |
658
866
  | `plan(sweep)` | Every row, fingerprinted, with the sweep's options |
659
- | `check(plan, approval, opts?)` | Resolve `{ pick, spare, allow }` against the plan, sweep again, and return a `Preview`: the steps, the rows dropped and why, and the allows no row needed. Deletes nothing |
660
- | `run(preview, opts?)` | Sweep once more and take the preview's steps, writing a `Receipt` (to `receiptPath` after every step, when given) |
867
+ | `check(plan, approval, opts?)` | Resolve `{ pick, spare, allow }` against the plan, sweep again, and return a `Preview`: the steps, the rows dropped and why, the allows no row needed, and `notes`, what the re-check judged without. Deletes nothing |
868
+ | `run(preview, opts?)` | Sweep once more and take the preview's steps, writing a `Receipt` (to `receiptPath` after every step, when given). `onNotes` hears what its own re-check judged without |
661
869
  | `inspectWorktree(path, opts?)` | One worktree judged as a sweep judges it, submodules included |
662
870
  | `readPlan`, `writePlan` | Plans on disk |
663
871
  | `render` | The command line's formats: `text`, `json`, `summary`, `commands`, `preview`, `receipt` |
@@ -688,7 +896,10 @@ a branch's `sha`, `upstream`, `track`; every kind's `merged`, `pr`,
688
896
  `uniqueCommits` and landing evidence.
689
897
 
690
898
  `sweep` never throws for a repository it cannot read: that one comes back
691
- with `error` set, because a fan-out must not end on its first bad entry. It
899
+ with `error` set, because a fan-out must not end on its first bad entry. A
900
+ lookup that only adds evidence (a pull request listing, or `ls-remote` after
901
+ the fetch) costs that evidence and not the repository: its rows are judged
902
+ without it, and the repository's `notes` say what was missing. It
692
903
  throws a `RepoCleanerError` for options that contradict each other, as the
693
904
  command line refuses them, before reading anything. `check` throws for an
694
905
  approval that contradicts itself, names a row the plan does not have, or
@@ -720,9 +931,10 @@ branch.
720
931
  | `github` | `gh` | `gh pr list --state all`, up to 300 |
721
932
  | `gitlab` | `glab` | `glab api projects/<path>/merge_requests?state=all`, 100 a page, up to 300 |
722
933
 
723
- With `--remote`, `--provider auto` (the default) reads the remote's host: a host with `gitlab`
724
- in its name is GitLab, any other is GitHub, which is what the tool did before
725
- providers existed. Name one with `--provider github|gitlab` for a host the
934
+ With `--remote`, `--provider auto` (the default) reads the remote's host: a
935
+ remote that names no host (a local path or `file://`) gets no provider, since
936
+ it can have no pull requests; a host with `gitlab` in its name is GitLab; any
937
+ other is GitHub, which is what the tool did before providers existed. Name one with `--provider github|gitlab` for a host the
726
938
  name does not give away, or `--provider none` to skip pull requests. A plan
727
939
  records the choice, so `clean` re-checks with the same provider.
728
940
 
@@ -738,20 +950,26 @@ Not done yet:
738
950
  - Gitea, Forgejo and Bitbucket.
739
951
  - Mapping a self-hosted host to a provider in config; today it is the host
740
952
  name or `--provider`.
741
- - Auth edge cases: tokens without API scope, `glab` signed in to a different
742
- host than the remote, rate limits. Each reads as "no PR found".
953
+ - Auth edge cases. A token without API scope, or a rate limit, is a failed
954
+ listing: the rows are judged on git evidence with a note (see
955
+ [When a lookup fails](#when-a-lookup-fails)). `glab` signed in only to a
956
+ different host than the remote's reads as not signed in for that host,
957
+ which gates remote deletes the same way.
743
958
  - Squash metadata. GitLab reports `squash_commit_sha`; it is not used, so a
744
959
  squashed MR proves landing only by its head commit, as on GitHub.
745
960
  - GitLab under a sub-path (`https://host/gitlab/group/proj`). The URL does not
746
961
  say where the install ends and the project begins, so the whole path is read
747
962
  as the project, `glab` is asked for a project that does not exist, and the
748
- listing fails. That reads as "no PR found": rows stay at `review`, nothing is
749
- deleted on its account.
963
+ listing fails. The rows are judged on git evidence with a note, and a remote
964
+ branch needs `--allow confirm-open-pr` to delete.
750
965
 
751
966
  ## Runtime dependencies
752
967
 
753
- `git` on `PATH`, and `gh` (or `glab` for GitLab) when PR state should count.
754
- Authentication is the CLI's own — this package reads no token.
968
+ `git` on `PATH`, and `gh` (or `glab` for GitLab) for pull request evidence.
969
+ Without it, deleting a remote branch needs `--allow confirm-open-pr`, or
970
+ `--provider none` for a remote with no pull requests (see
971
+ [When a lookup fails](#when-a-lookup-fails)). Authentication is the CLI's
972
+ own — this package reads no token.
755
973
 
756
974
  ## Running from a checkout
757
975
 
@@ -45,4 +45,32 @@ export declare function localDeleteCommand(name: string, sha: string): string;
45
45
  /** Delete `remote`'s branch `name` only while it still points at `sha`. */
46
46
  export declare function remoteDeleteCommand(remote: string, name: string, sha: string): string;
47
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[];
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
@@ -13,6 +13,8 @@ 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
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,7 +22,7 @@ import { landedRef, makeRow, risksFor, } 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
28
  function prCapturesTip(pr, tip, repo) {
@@ -46,11 +48,15 @@ export function recordPr(evidence, pr, tip, repo) {
46
48
  out.proof = verdict === true ? 'exact-head' : verdict;
47
49
  return verdict === true ? { landed: true, gap: null } : { landed: false, gap: verdict };
48
50
  }
49
- /** Why a branch has no PR, naming where the sweep looked. */
51
+ /** Why a branch has no PR, naming where the sweep looked, or why it could not. */
50
52
  function noPrSentence(repo) {
51
53
  const provider = repo.provider;
52
54
  if (provider === null)
53
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}).`;
54
60
  return `No ${provider.noun} found via \`${provider.listing}\`.`;
55
61
  }
56
62
  /** One sentence on why a merged PR did not count, for a row's notes. */
@@ -67,11 +73,18 @@ export function gapSentence(pr, gap, repo) {
67
73
  export function mergedFact(e) {
68
74
  return e.landed || e.ancestor || e.patchLanded;
69
75
  }
70
- /** What deleting a branch needs: its unlanded commits, and its open PR. */
71
- function branchRisks(unmerged, pr) {
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) {
72
85
  return risksFor({
73
86
  'abandon-unmerged': unmerged,
74
- 'confirm-open-pr': pr?.state === 'OPEN',
87
+ 'confirm-open-pr': pr?.state === 'OPEN' || prUnknown,
75
88
  });
76
89
  }
77
90
  /** `HEAD` as the branch checked out at `git`'s directory; any other name as given. */
@@ -216,25 +229,81 @@ function classifyLocalBranch(opts) {
216
229
  risks,
217
230
  });
218
231
  }
219
- // ── remote branches ────────────────────────────────────────────────────────
220
- export function gatherRemoteBranches(repo, prs, localBranchNames, git, only, tick) {
221
- if (repo.remote === null)
222
- return [];
223
- // Default door: an unreachable remote throws rather than rendering as "the
224
- // remote has no branches". `[]` here means the remote really has none.
225
- const pattern = only === undefined ? undefined : `refs/heads/${only}`;
226
- 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
+ }
227
274
  const items = [];
228
- for (const [i, { sha, ref }] of rows.entries()) {
229
- tick?.(i, rows.length);
230
- if (!ref.startsWith('refs/heads/'))
231
- continue;
232
- const name = ref.slice('refs/heads/'.length);
275
+ for (const [i, { name, sha }] of tips.entries()) {
276
+ tick?.(i, tips.length);
233
277
  if (only !== undefined && name !== only)
234
278
  continue;
235
- 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);
236
284
  }
237
- return items;
285
+ return { rows: items, note };
286
+ }
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
+ });
238
307
  }
239
308
  function classifyRemoteBranch(opts) {
240
309
  const { name: branch, sha, repo, prs, localBranchNames, git } = opts;
@@ -250,15 +319,15 @@ function classifyRemoteBranch(opts) {
250
319
  if (branch === repo.mainBranch)
251
320
  return row({ verdict: 'keep', action: null, why: 'default branch' });
252
321
  const remoteMain = landedRef(repo);
253
- // `soft`: `sha` comes from `ls-remote`, so the clone may never have fetched
254
- // it, and `remoteMain` may not exist either. Both exit 128; this row renders
255
- // "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.
256
325
  const isAncestor = git.at(repo.root).soft.isAncestor(sha, remoteMain);
257
326
  evidence.ancestorOfRemoteMain = isAncestor;
258
327
  const pr = prs.get(branch);
259
328
  const { landed, gap } = recordPr(evidence, pr, sha, repo);
260
- // `sha` came from `ls-remote` and may not be in this clone; the check then
261
- // finds nothing and the branch stays unlanded.
329
+ // A `sha` from `ls-remote` may not be in this clone; the check then finds
330
+ // nothing and the branch stays unlanded.
262
331
  const patch = landed || isAncestor ? null : judgeLanding(repo, sha, pr, gap, git);
263
332
  if (patch !== null)
264
333
  recordLanding(evidence, patch);
@@ -270,9 +339,23 @@ function classifyRemoteBranch(opts) {
270
339
  const counted = git.at(repo.root).try.aheadBehind(remoteMain, sha);
271
340
  evidence.uniqueCommits = counted.ok ? counted.value.ahead : null;
272
341
  }
273
- const risks = branchRisks(unmerged, pr);
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);
274
350
  const commands = repo.remote === null ? [] : [remoteDeleteCommand(repo.remote, branch, sha)];
275
- const deletes = (why) => row({ verdict: 'delete', action: 'delete', why, commands, risks });
351
+ const deletes = (why) => row({
352
+ verdict: 'delete',
353
+ action: 'delete',
354
+ why,
355
+ commands,
356
+ risks,
357
+ notes: unknownPr === null ? [] : [unknownPr],
358
+ });
276
359
  if (pr && landed)
277
360
  return deletes(`PR #${pr.number} merged (auto-delete didn't fire)`);
278
361
  if (isAncestor)
@@ -284,7 +367,7 @@ function classifyRemoteBranch(opts) {
284
367
  return row({ verdict: 'keep', action: 'delete', why: `open PR #${pr.number}`, commands, risks });
285
368
  }
286
369
  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.');
370
+ notes.push(pr ? `Associated PR #${pr.number} is ${pr.state}.` : (unknownPr ?? noPrSentence(repo)));
288
371
  if (pr && gap)
289
372
  notes.push(gapSentence(pr, gap, repo));
290
373
  const unmatched = patch === null ? null : unmatchedSentence(patch, repo);