@osdlc/repo-cleaner 0.18.0 → 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 +13 -0
- package/README.md +238 -20
- package/dist/branches.d.ts +29 -1
- package/dist/branches.js +111 -28
- package/dist/clean.d.ts +22 -5
- package/dist/clean.js +88 -19
- package/dist/cli.js +32 -4
- package/dist/index.d.ts +1 -1
- 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 +2 -0
- package/dist/receipt.js +6 -0
- package/dist/reference.js +12 -8
- package/dist/remote-mirror.d.ts +1 -1
- package/dist/remote-mirror.js +1 -1
- package/dist/render.d.ts +6 -1
- package/dist/render.js +12 -2
- package/dist/resolve.d.ts +3 -3
- package/dist/resolve.js +3 -3
- package/dist/sweep.d.ts +14 -1
- package/dist/sweep.js +67 -7
- package/dist/types.d.ts +20 -2
- package/dist/types.js +8 -2
- package/dist/util.d.ts +7 -1
- package/dist/util.js +18 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @osdlc/repo-cleaner
|
|
2
2
|
|
|
3
|
+
## 0.19.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b1c3209: A lookup that fails now costs its evidence, not the repository. See "When a lookup fails" in the README.
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- `--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.
|
|
12
|
+
- `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`).
|
|
13
|
+
- `--provider auto` asks no provider for a remote that names no host, such as a local path, which can have no pull requests.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
3
16
|
## 0.18.0
|
|
4
17
|
|
|
5
18
|
### 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
|
|
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
|
|
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
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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 forge, 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 not be on a forge 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 forge at all, so there is no
|
|
660
|
+
pull request 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 forge: then there is no
|
|
679
|
+
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 forge 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 forge 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,
|
|
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.
|
|
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
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
742
|
-
|
|
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.
|
|
749
|
-
|
|
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)
|
|
754
|
-
|
|
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 forge (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
|
|
package/dist/branches.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
-
/**
|
|
71
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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, {
|
|
229
|
-
tick?.(i,
|
|
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
|
-
|
|
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`
|
|
254
|
-
// it, and `remoteMain` may not exist either. Both exit 128; this row
|
|
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`
|
|
261
|
-
//
|
|
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
|
|
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({
|
|
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}.` :
|
|
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);
|