@osdlc/repo-cleaner 0.6.1 → 0.9.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 +42 -0
- package/README.md +79 -2
- package/bin/repo-cleaner.js +3 -7
- package/dist/bin.d.ts +5 -0
- package/dist/bin.js +10 -0
- package/dist/branches.d.ts +6 -6
- package/dist/branches.js +53 -7
- package/dist/cli.js +2 -1
- package/dist/devlink.d.ts +10 -0
- package/dist/devlink.js +18 -0
- package/dist/discover.d.ts +2 -2
- package/dist/discover.js +15 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/landing.d.ts +90 -0
- package/dist/landing.js +265 -0
- package/dist/types.d.ts +6 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.js +29 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# @osdlc/repo-cleaner
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 43a4097: `pr-update dev` and `repo-cleaner dev` run the tool from a development checkout: `dev link <checkout>` makes the installed command run that checkout's source under Bun, `dev status` says which code runs, and `dev off` goes back. `PR_UPDATE_HOME` and `REPO_CLEANER_HOME` do the same for one run. `dev` is now a word for both tools: sweep a directory named `dev` with `repo-cleaner ./dev`, as with `apply`. The bin now loads `dist/bin.js`, built from the new `src/bin.ts`.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [43a4097]
|
|
12
|
+
- @osdlc/cli-tool@0.12.0
|
|
13
|
+
|
|
14
|
+
## 0.8.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- 7fe7f51: Output change: a remote on a host other than github.com is now named `host/owner/name` in the report, read with typed-git's `parseRemoteRepo`. Before, typed-gh read `owner/name` from any host, so one `owner/name` on two hosts was reported as a duplicate checkout. The `slug` field of `--output json` changes with it.
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- Updated dependencies [7fe7f51]
|
|
23
|
+
- @osdlc/typed-git@0.11.1
|
|
24
|
+
|
|
25
|
+
## 0.7.0
|
|
26
|
+
|
|
27
|
+
### Minor Changes
|
|
28
|
+
|
|
29
|
+
- 82d9841: A branch landed by rebase merge, cherry-pick or squash merge is now recognised without pull request data, and needs no grant when the content check agrees.
|
|
30
|
+
|
|
31
|
+
- A third landing signal, patch equivalence, runs when a PR at the exact tip and ancestry both fail. It compares by `git cherry` patch id: commit by commit (`per-commit`), the branch squashed into one change (`squashed`), and, for a branch that moved on after a merged PR's head, that head squashed plus each later commit (`head-moved`). It applies to local branches, remote branches and worktrees.
|
|
32
|
+
- A patch id is a heuristic, so a match alone still requires the `abandon-unmerged` authorization. A second check, `git merge-tree --write-tree` of the default branch and the tip (git 2.38 or later), must give the default branch's own tree to drop it; the outcome is in `facts.landing_content_confirmed` and the row's reason.
|
|
33
|
+
- The row records `facts.landing = 'patch-equivalent'` and `facts.landing_check`. A branch that is only partly on the default branch lists the commits that are not in `facts.unmatched_commits` and in the investigation text.
|
|
34
|
+
- It fails closed. A failing git call, unreadable output, a shallow clone, a missing merge base, an empty change, a merge commit in the range (for the commit-by-commit check), or any commit not on the default branch means the branch is not counted as landed.
|
|
35
|
+
- The squashed check writes one unreferenced commit object and no ref, needs no git identity, and ignores `git replace` refs.
|
|
36
|
+
- New exports: `assessLanding`, `patchEquivalent`, `Landing`, `LandingCheck`. Uses `cherry`, `commitTree` and `mergeTree` from `@osdlc/typed-git`.
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- Updated dependencies [82d9841]
|
|
41
|
+
- Updated dependencies [ebe0e0d]
|
|
42
|
+
- @osdlc/typed-git@0.11.0
|
|
43
|
+
- @osdlc/typed-gh@0.12.1
|
|
44
|
+
|
|
3
45
|
## 0.6.1
|
|
4
46
|
|
|
5
47
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -35,12 +35,13 @@ ESM only. Compiled JavaScript with type declarations, for Node 22 or newer.
|
|
|
35
35
|
|
|
36
36
|
## How it decides
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Three signals settle most items. The first two are enough alone; the third needs a content check too (see [Patch equivalence](#patch-equivalence)):
|
|
39
39
|
|
|
40
40
|
| Signal | Source |
|
|
41
41
|
| --- | --- |
|
|
42
42
|
| A `MERGED` pull request from this repository, into the default branch, whose head commit is the branch tip | `gh pr list`, one listing per repository |
|
|
43
43
|
| The branch tip is reachable from the default branch | `git merge-base --is-ancestor` |
|
|
44
|
+
| The branch's changes are on the default branch under other commit ids (rebase merge, cherry-pick, squash merge) | `git cherry` plus `git merge-tree`, see [Patch equivalence](#patch-equivalence) |
|
|
44
45
|
|
|
45
46
|
Ancestry is measured against `<remote>/<default>`, so work merged only into an
|
|
46
47
|
unpushed local default branch does not count. Without a remote, the local
|
|
@@ -67,6 +68,65 @@ Some worktrees are held back whatever their branch says. Each lands in
|
|
|
67
68
|
| `nested-repository` | An untracked or ignored directory holds a repository with its own history. |
|
|
68
69
|
| `branch-in-other-worktree` | Another worktree has the same branch checked out (`facts.other_worktrees`). |
|
|
69
70
|
|
|
71
|
+
### Patch equivalence
|
|
72
|
+
|
|
73
|
+
A rebase merge, a cherry-pick and a squash merge put the work on the default
|
|
74
|
+
branch under new commit ids, so the tip is not reachable from it and no PR
|
|
75
|
+
head matches. The sweep then compares by patch id (`git cherry`), which ignores
|
|
76
|
+
ids, messages and dates. It runs only when the first two signals do not
|
|
77
|
+
settle the item, for local branches, remote branches and worktrees alike.
|
|
78
|
+
|
|
79
|
+
A patch id is a heuristic, not proof, so a patch match alone does not make a
|
|
80
|
+
branch as safe as an ancestor. A second, independent check must agree:
|
|
81
|
+
`git merge-tree --write-tree <default> <tip>` must come out clean with exactly
|
|
82
|
+
the default branch's tree, which shows the branch's content is already there. It
|
|
83
|
+
needs git 2.38 or later; an older git, a conflict or any failure counts as not
|
|
84
|
+
agreed. The row is `safe-to-delete` either way, but only an agreed check drops
|
|
85
|
+
the `abandon-unmerged` authorization. Without it the row keeps requiring that
|
|
86
|
+
grant, and a worktree with unpushed commits stays `needs-investigation`. The row
|
|
87
|
+
carries three facts, and its reason says which case applied:
|
|
88
|
+
|
|
89
|
+
| Fact | Value |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `facts.landing` | `patch-equivalent` |
|
|
92
|
+
| `facts.landing_check` | which check passed, below |
|
|
93
|
+
| `facts.landing_content_confirmed` | `true` when the merge-tree check agreed, `false` otherwise |
|
|
94
|
+
|
|
95
|
+
| `landing_check` | Meaning |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `per-commit` | Every commit on the branch has a commit with the same patch id on the default branch. Covers rebase merges and cherry-picks. |
|
|
98
|
+
| `squashed` | A commit holding the branch's combined change (its tree on the merge base) has a commit with the same patch id on the default branch. Covers squash merges. |
|
|
99
|
+
| `head-moved` | A merged PR's head passes `squashed`, and every commit after that head passes `per-commit`. Covers a branch that moved on after its squash merge. |
|
|
100
|
+
|
|
101
|
+
`squashed` accepts any one commit on the default branch with the same net
|
|
102
|
+
patch, not necessarily one that came from this branch. That is acceptable only
|
|
103
|
+
because the content check, or the grant, stands behind it.
|
|
104
|
+
|
|
105
|
+
The squashed check builds one commit object with `git commit-tree` and no ref.
|
|
106
|
+
It leaves the index, the working tree and every ref alone, and needs no git
|
|
107
|
+
identity configured. Every git call of the check ignores `git replace` refs
|
|
108
|
+
(`GIT_NO_REPLACE_OBJECTS=1`).
|
|
109
|
+
|
|
110
|
+
The check decides what gets deleted, so a doubt is never evidence. None of
|
|
111
|
+
these count as landed:
|
|
112
|
+
|
|
113
|
+
- a commit that is not on the default branch, however many others are (the
|
|
114
|
+
commits that are missing are listed in `facts.unmatched_commits` and in the
|
|
115
|
+
investigation text when part of the work did land);
|
|
116
|
+
- a git call that fails, or output that cannot be read;
|
|
117
|
+
- a shallow clone, a missing merge base, a ref that does not resolve;
|
|
118
|
+
- an empty change: an empty commit, or a branch whose commits cancel out;
|
|
119
|
+
- any merge commit in the range, a plain merge of the default branch included,
|
|
120
|
+
for `per-commit` (neither `git cherry` nor `git log` shows its change); the
|
|
121
|
+
`squashed` check, which reads the combined change, can still pass;
|
|
122
|
+
- a squash merge that was edited by hand, because the patch ids differ.
|
|
123
|
+
|
|
124
|
+
A patch id says two changes are the same, not that they come from the same
|
|
125
|
+
branch, and it ignores whitespace. A one-line change that the default branch
|
|
126
|
+
also made independently, an indentation-only difference, or work that landed
|
|
127
|
+
and was then reverted all match by patch id; the content check does not agree
|
|
128
|
+
with them, so they keep needing the grant.
|
|
129
|
+
|
|
70
130
|
### Locks and running processes
|
|
71
131
|
|
|
72
132
|
A crashed coding agent leaves its worktree locked. The sweep reads one generic
|
|
@@ -189,7 +249,8 @@ one that says which it could not find.
|
|
|
189
249
|
| `--plan-out <path>` | Write the plan here; implies `--write-plan` |
|
|
190
250
|
| `--version` | Print `repo-cleaner <version>` and exit; it must be the first argument |
|
|
191
251
|
|
|
192
|
-
A checkout directory named `apply` is swept as `./apply
|
|
252
|
+
A checkout directory named `apply` is swept as `./apply`, and one named `dev` as
|
|
253
|
+
`./dev`: `dev` runs the tool from a checkout (see Running from a checkout).
|
|
193
254
|
|
|
194
255
|
## Plan and apply
|
|
195
256
|
|
|
@@ -298,6 +359,22 @@ sweep and `applyPlan`.
|
|
|
298
359
|
`git` on `PATH`, and `gh` when PR state should count. Authentication is `gh`'s
|
|
299
360
|
own — this package reads no token.
|
|
300
361
|
|
|
362
|
+
## Running from a checkout
|
|
363
|
+
|
|
364
|
+
To try a change to repo-cleaner with the `repo-cleaner` already on your PATH, link a
|
|
365
|
+
checkout of this package:
|
|
366
|
+
|
|
367
|
+
```sh
|
|
368
|
+
repo-cleaner dev link ~/src/osdlc/packages/ts/repo-cleaner # every `repo-cleaner` now runs that checkout
|
|
369
|
+
repo-cleaner dev status # which code runs, and why
|
|
370
|
+
repo-cleaner dev off # back to the installed copy
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
`REPO_CLEANER_HOME=<checkout> repo-cleaner …` does the same for one run. The checkout runs
|
|
374
|
+
from source under Bun, so it needs `bun` on PATH and the checkout's
|
|
375
|
+
dependencies installed and built. The installed copy says which checkout it
|
|
376
|
+
hands off to on stderr; `REPO_CLEANER_QUIET=1` silences that line.
|
|
377
|
+
|
|
301
378
|
## Licence
|
|
302
379
|
|
|
303
380
|
`MIT OR Apache-2.0`, at your option. See [LICENSE-MIT](./LICENSE-MIT) and [LICENSE-APACHE](./LICENSE-APACHE).
|
package/bin/repo-cleaner.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The published entry point: `npx @osdlc/repo-cleaner …` and a plain
|
|
3
|
-
// `node ./bin/repo-cleaner.js …` both land here. Requires the built dist/
|
|
4
|
-
// (`pnpm run build`).
|
|
5
|
-
import
|
|
6
|
-
import { main } from '../dist/cli.js'
|
|
7
|
-
import { VERSION } from '../dist/version.generated.js'
|
|
8
|
-
|
|
9
|
-
await runMain(main, { name: 'repo-cleaner', version: VERSION })
|
|
3
|
+
// `node ./bin/repo-cleaner.js …` both land here. Requires the built dist/
|
|
4
|
+
// (`pnpm run build`). See src/bin.ts.
|
|
5
|
+
import '../dist/bin.js'
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `repo-cleaner` runs: `bin/repo-cleaner.js` loads the built copy, and a development
|
|
3
|
+
* checkout that devlink selects runs this file under Bun.
|
|
4
|
+
*/
|
|
5
|
+
import { runMain } from '@osdlc/cli-tool';
|
|
6
|
+
import { devlinkHook } from '@osdlc/cli-tool/devlink';
|
|
7
|
+
import { main } from "./cli.js";
|
|
8
|
+
import { DEVLINK_SPEC } from "./devlink.js";
|
|
9
|
+
import { VERSION } from "./version.generated.js";
|
|
10
|
+
await runMain(main, { name: 'repo-cleaner', version: VERSION, devlink: devlinkHook(DEVLINK_SPEC) });
|
package/dist/branches.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local and remote branches: gather them, then decide what each one is.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three signals settle most branches. A merged pull request whose head commit is
|
|
5
5
|
* the branch tip says the work landed. Ancestry says the same thing without
|
|
6
6
|
* GitHub's help — if the branch tip is reachable from the default branch,
|
|
7
|
-
* every commit on it is already there.
|
|
8
|
-
*
|
|
7
|
+
* every commit on it is already there. Patch equivalence covers rebase merges,
|
|
8
|
+
* cherry-picks and squash merges, where the work is on the default branch
|
|
9
|
+
* under other commit ids. Any one is enough to call a branch spent; none
|
|
10
|
+
* leaves the branch for a person to look at.
|
|
9
11
|
*/
|
|
10
12
|
import { type Git } from '@osdlc/typed-git';
|
|
11
|
-
import { type PRInfo, type RepoInfo, type SweepItem } from './types.ts';
|
|
12
|
-
/** Why a merged PR does not prove that `tip` landed. */
|
|
13
|
-
export type PrEvidenceGap = 'other-base' | 'head-moved';
|
|
13
|
+
import { type PRInfo, type PrEvidenceGap, type RepoInfo, type SweepItem } from './types.ts';
|
|
14
14
|
/**
|
|
15
15
|
* True when merged `pr` proves `tip` landed: it targeted the default branch
|
|
16
16
|
* and its head commit is `tip`. Fork PRs never reach here; `fetchPrs` drops
|
package/dist/branches.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local and remote branches: gather them, then decide what each one is.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three signals settle most branches. A merged pull request whose head commit is
|
|
5
5
|
* the branch tip says the work landed. Ancestry says the same thing without
|
|
6
6
|
* GitHub's help — if the branch tip is reachable from the default branch,
|
|
7
|
-
* every commit on it is already there.
|
|
8
|
-
*
|
|
7
|
+
* every commit on it is already there. Patch equivalence covers rebase merges,
|
|
8
|
+
* cherry-picks and squash merges, where the work is on the default branch
|
|
9
|
+
* under other commit ids. Any one is enough to call a branch spent; none
|
|
10
|
+
* leaves the branch for a person to look at.
|
|
9
11
|
*/
|
|
10
12
|
import { shellJoin } from '@osdlc/command-seam';
|
|
11
13
|
import { forceWithLeaseArg } from '@osdlc/typed-git';
|
|
14
|
+
import { confirmationNote, judgeLanding, landingConfirmed, recordLanding, unmatchedSentence, } from "./landing.js";
|
|
12
15
|
import { authorizationsFor, landedRef, makeItem, } from "./types.js";
|
|
13
16
|
/**
|
|
14
17
|
* Five fields in one pass, so a repository costs one `for-each-ref`. The full
|
|
@@ -151,9 +154,16 @@ export function classifyLocalBranch(opts) {
|
|
|
151
154
|
const isAncestor = ancestor === true;
|
|
152
155
|
const pr = prs.get(name);
|
|
153
156
|
const { landed, gap } = recordPr(facts, pr, sha, repo);
|
|
154
|
-
|
|
155
|
-
|
|
157
|
+
// Rebase merges, cherry-picks and squash merges leave the tip off the default
|
|
158
|
+
// branch; the patches say whether the work is there anyway.
|
|
159
|
+
const patch = landed || isAncestor ? null : judgeLanding(repo, sha, pr, gap, git);
|
|
160
|
+
if (patch !== null)
|
|
161
|
+
recordLanding(facts, patch);
|
|
162
|
+
const patchLanded = patch !== null && patch.check !== null;
|
|
163
|
+
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
164
|
+
if (!landed && !isAncestor) {
|
|
156
165
|
facts['unique_commits'] = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
|
|
166
|
+
}
|
|
157
167
|
const requires = branchRequires(unmerged, pr);
|
|
158
168
|
const deleteCmd = [localDeleteCommand(name, sha)];
|
|
159
169
|
if (pr && landed) {
|
|
@@ -180,6 +190,18 @@ export function classifyLocalBranch(opts) {
|
|
|
180
190
|
requires,
|
|
181
191
|
});
|
|
182
192
|
}
|
|
193
|
+
if (patchLanded) {
|
|
194
|
+
return makeItem({
|
|
195
|
+
category: 'local_branch',
|
|
196
|
+
id: name,
|
|
197
|
+
classification: 'safe-to-delete',
|
|
198
|
+
action: 'delete',
|
|
199
|
+
reason: `patch-equivalent to commits on ${compareRef} (${patch.check}); ${confirmationNote(patch)}`,
|
|
200
|
+
facts,
|
|
201
|
+
commands: deleteCmd,
|
|
202
|
+
requires,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
183
205
|
if (pr && pr.state === 'OPEN')
|
|
184
206
|
return keep(`open PR #${pr.number}`, { requires });
|
|
185
207
|
const lead = ancestor === null
|
|
@@ -192,6 +214,9 @@ export function classifyLocalBranch(opts) {
|
|
|
192
214
|
: 'No PR found via `gh pr list`.');
|
|
193
215
|
if (pr && gap)
|
|
194
216
|
parts.push(gapSentence(pr, gap, repo));
|
|
217
|
+
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
|
218
|
+
if (unmatched !== null)
|
|
219
|
+
parts.push(unmatched);
|
|
195
220
|
parts.push(`Last commit: ${lastCommit} (${sha}).`);
|
|
196
221
|
return makeItem({
|
|
197
222
|
category: 'local_branch',
|
|
@@ -251,8 +276,14 @@ export function classifyRemoteBranch(opts) {
|
|
|
251
276
|
facts['ancestor_of_remote_main'] = isAncestor;
|
|
252
277
|
const pr = prs.get(name);
|
|
253
278
|
const { landed, gap } = recordPr(facts, pr, sha, repo);
|
|
254
|
-
|
|
255
|
-
|
|
279
|
+
// `sha` came from `ls-remote` and may not be in this clone; the check then
|
|
280
|
+
// finds nothing and the branch stays unlanded.
|
|
281
|
+
const patch = landed || isAncestor ? null : judgeLanding(repo, sha, pr, gap, git);
|
|
282
|
+
if (patch !== null)
|
|
283
|
+
recordLanding(facts, patch);
|
|
284
|
+
const patchLanded = patch !== null && patch.check !== null;
|
|
285
|
+
const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
|
|
286
|
+
if (!landed && !isAncestor) {
|
|
256
287
|
// Unknown (null) when the clone lacks `sha` or `remoteMain`, as above.
|
|
257
288
|
const counted = git.at(repo.root).try.aheadBehind(remoteMain, sha);
|
|
258
289
|
facts['unique_commits'] = counted.ok ? counted.value.ahead : null;
|
|
@@ -283,12 +314,27 @@ export function classifyRemoteBranch(opts) {
|
|
|
283
314
|
requires,
|
|
284
315
|
});
|
|
285
316
|
}
|
|
317
|
+
if (patchLanded) {
|
|
318
|
+
return makeItem({
|
|
319
|
+
category: 'remote_branch',
|
|
320
|
+
id,
|
|
321
|
+
classification: 'safe-to-delete',
|
|
322
|
+
action: 'delete',
|
|
323
|
+
reason: `patch-equivalent to commits on ${remoteMain} (${patch.check}); ${confirmationNote(patch)}`,
|
|
324
|
+
facts,
|
|
325
|
+
commands: deleteCmd,
|
|
326
|
+
requires,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
286
329
|
if (pr && pr.state === 'OPEN')
|
|
287
330
|
return keep(`open PR #${pr.number}`, { requires });
|
|
288
331
|
const parts = [`Remote branch \`${id}\` is not an ancestor of \`${remoteMain}\`.`];
|
|
289
332
|
parts.push(pr ? `Associated PR #${pr.number} is ${pr.state}.` : 'No PR found.');
|
|
290
333
|
if (pr && gap)
|
|
291
334
|
parts.push(gapSentence(pr, gap, repo));
|
|
335
|
+
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
|
336
|
+
if (unmatched !== null)
|
|
337
|
+
parts.push(unmatched);
|
|
292
338
|
parts.push('Decide whether to push more work, delete, or leave it as long-lived.');
|
|
293
339
|
return makeItem({
|
|
294
340
|
category: 'remote_branch',
|
package/dist/cli.js
CHANGED
|
@@ -86,8 +86,9 @@ const sweepCli = () => defineCli({
|
|
|
86
86
|
},
|
|
87
87
|
epilogue: `commands:
|
|
88
88
|
repo-cleaner apply --help delete the approved rows of a plan
|
|
89
|
+
repo-cleaner dev run repo-cleaner from a development checkout
|
|
89
90
|
|
|
90
|
-
A checkout directory named "apply" is swept as ./apply.
|
|
91
|
+
A checkout directory named "apply" or "dev" is swept as ./apply or ./dev.
|
|
91
92
|
|
|
92
93
|
config: ${configPath()}
|
|
93
94
|
plans: <durable home>/repo-cleaner/plans/ unless --plan-out`,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The devlink spec: how `repo-cleaner dev` recognises a repo-cleaner checkout and runs it.
|
|
3
|
+
*
|
|
4
|
+
* A checkout has `src/cli.ts` and a `package.json` named `@osdlc/repo-cleaner`; the
|
|
5
|
+
* installed package ships `dist/` alone, so it never counts. A selected
|
|
6
|
+
* checkout runs `src/bin.ts` under Bun. The cwd layer stays off: only
|
|
7
|
+
* `REPO_CLEANER_HOME` or `repo-cleaner dev link` hand off to a checkout.
|
|
8
|
+
*/
|
|
9
|
+
import { type ToolSpec } from '@osdlc/cli-tool/devlink';
|
|
10
|
+
export declare const DEVLINK_SPEC: ToolSpec;
|
package/dist/devlink.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The devlink spec: how `repo-cleaner dev` recognises a repo-cleaner checkout and runs it.
|
|
3
|
+
*
|
|
4
|
+
* A checkout has `src/cli.ts` and a `package.json` named `@osdlc/repo-cleaner`; the
|
|
5
|
+
* installed package ships `dist/` alone, so it never counts. A selected
|
|
6
|
+
* checkout runs `src/bin.ts` under Bun. The cwd layer stays off: only
|
|
7
|
+
* `REPO_CLEANER_HOME` or `repo-cleaner dev link` hand off to a checkout.
|
|
8
|
+
*/
|
|
9
|
+
import { validateSpec } from '@osdlc/cli-tool/devlink';
|
|
10
|
+
import { VENDOR_DIRS } from "./config.js";
|
|
11
|
+
export const DEVLINK_SPEC = validateSpec({
|
|
12
|
+
name: 'repo-cleaner',
|
|
13
|
+
marker: 'src/cli.ts',
|
|
14
|
+
packageName: '@osdlc/repo-cleaner',
|
|
15
|
+
entry: 'src/bin.ts',
|
|
16
|
+
launcher: 'bin/repo-cleaner.js',
|
|
17
|
+
vendorDirs: VENDOR_DIRS,
|
|
18
|
+
});
|
package/dist/discover.d.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* where its main checkout is, what its default branch is called, which remote
|
|
4
4
|
* to measure against, and whether `gh` can be asked about pull requests.
|
|
5
5
|
*/
|
|
6
|
-
import type
|
|
7
|
-
import {
|
|
6
|
+
import { type Git } from '@osdlc/typed-git';
|
|
7
|
+
import type { Gh } from '@osdlc/typed-gh';
|
|
8
8
|
import type { RepoInfo } from './types.ts';
|
|
9
9
|
/**
|
|
10
10
|
* Thrown when a path names no repository, or names one whose default branch
|
package/dist/discover.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* where its main checkout is, what its default branch is called, which remote
|
|
4
4
|
* to measure against, and whether `gh` can be asked about pull requests.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { parseRemoteRepo } from '@osdlc/typed-git';
|
|
7
7
|
/**
|
|
8
8
|
* Thrown when a path names no repository, or names one whose default branch
|
|
9
9
|
* cannot be determined. A single-repository caller lets it propagate; a
|
|
@@ -68,9 +68,9 @@ export function discoverRepo(hint, opts) {
|
|
|
68
68
|
}
|
|
69
69
|
if (!mainBranch)
|
|
70
70
|
throw new DiscoveryFailed(`could not determine the default branch of ${root}`);
|
|
71
|
-
//
|
|
72
|
-
// readable.
|
|
73
|
-
const slug = remote === null ? null :
|
|
71
|
+
// A short name for the report's header, so a fan-out over long paths stays
|
|
72
|
+
// readable. It also keys duplicate checkouts.
|
|
73
|
+
const slug = remote === null ? null : remoteSlugOf(git.at(root).try.remoteGetUrl(remote));
|
|
74
74
|
const currentBranch = git.at(root).headRef() ?? '';
|
|
75
75
|
// The two halves separately, not `ready()`: the report says "gh not
|
|
76
76
|
// available" and "gh not authenticated" differently, because they send a
|
|
@@ -79,10 +79,17 @@ export function discoverRepo(hint, opts) {
|
|
|
79
79
|
const ghAuthenticated = hasGh && gh.at(root).authenticated();
|
|
80
80
|
return { root, slug, mainBranch, remote, currentBranch, hasGh, ghAuthenticated, fetched: false };
|
|
81
81
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* `owner/name` for a github.com remote, `host/owner/name` for any other, or
|
|
84
|
+
* null when the URL names no repository. The host keeps two hosts' `a/b`
|
|
85
|
+
* apart.
|
|
86
|
+
*/
|
|
87
|
+
function remoteSlugOf(url) {
|
|
84
88
|
if (!url.ok || url.value === undefined || url.value === null)
|
|
85
89
|
return null;
|
|
86
|
-
const
|
|
87
|
-
|
|
90
|
+
const remote = parseRemoteRepo(url.value);
|
|
91
|
+
if (remote === null)
|
|
92
|
+
return null;
|
|
93
|
+
const slug = `${remote.owner}/${remote.repo}`;
|
|
94
|
+
return remote.host === 'github.com' ? slug : `${remote.host}/${slug}`;
|
|
88
95
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherRemoteBranches, localDeleteCommand, remoteDeleteCommand, resolveBranch, } from './branches.ts';
|
|
18
18
|
export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, type WorktreeBlocker, } from './worktrees.ts';
|
|
19
19
|
export { DiscoveryFailed, discoverRepo } from './discover.ts';
|
|
20
|
+
export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from './landing.ts';
|
|
20
21
|
export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
|
|
21
22
|
export type { Annotate, HostHooks } from './hooks.ts';
|
|
22
23
|
export { PLAN_VERSION, PlanReadError, planRepo, readPlan, stampedPath, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherRemoteBranches, localDeleteCommand, remoteDeleteCommand, resolveBranch, } from "./branches.js";
|
|
18
18
|
export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, } from "./worktrees.js";
|
|
19
19
|
export { DiscoveryFailed, discoverRepo } from "./discover.js";
|
|
20
|
+
export { assessLanding, patchEquivalent } from "./landing.js";
|
|
20
21
|
export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
|
|
21
22
|
export { PLAN_VERSION, PlanReadError, planRepo, readPlan, stampedPath, writePlan, } from "./plan.js";
|
|
22
23
|
export { ReceiptSchema } from "./receipt.js";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch equivalence: a third way to tell that a branch landed.
|
|
3
|
+
*
|
|
4
|
+
* A merge commit makes the branch tip reachable from the default branch, and a
|
|
5
|
+
* merged pull request at the tip says so too. A rebase merge, a cherry-pick
|
|
6
|
+
* and a squash merge do neither: the work is on the default branch under other
|
|
7
|
+
* commit ids. `git cherry` compares commits by patch id, which ignores the id,
|
|
8
|
+
* the message and the dates, so it can say "this commit's change is already
|
|
9
|
+
* there".
|
|
10
|
+
*
|
|
11
|
+
* A patch id is a heuristic, not proof. It reads only the changed lines, so an
|
|
12
|
+
* indentation-only change, a one-line edit that the landed ref also made
|
|
13
|
+
* independently, or a commit that landed and was later reverted can all look
|
|
14
|
+
* the same as work that landed. A patch-equivalent verdict therefore stands
|
|
15
|
+
* on its own only when a second, independent check agrees: merging the branch
|
|
16
|
+
* into the landed ref (`git merge-tree`) must change nothing, which shows the
|
|
17
|
+
* branch's content is already there. Without that, the verdict still names the
|
|
18
|
+
* check that passed, but `confirmed` is false and callers keep requiring the
|
|
19
|
+
* `abandon-unmerged` authorization.
|
|
20
|
+
*
|
|
21
|
+
* This code decides what gets deleted, so every doubt answers "not landed" or
|
|
22
|
+
* "not confirmed". A git call that fails, output that cannot be read, a
|
|
23
|
+
* shallow clone, a missing merge base, a commit that is not on the landed ref
|
|
24
|
+
* and a patch that is empty all end the same way: no evidence.
|
|
25
|
+
*/
|
|
26
|
+
import type { Git } from '@osdlc/typed-git';
|
|
27
|
+
import { type PRInfo, type PrEvidenceGap, type RepoInfo } from './types.ts';
|
|
28
|
+
/** Which check showed that the work is on the landed ref. */
|
|
29
|
+
export type LandingCheck =
|
|
30
|
+
/** Every commit on the branch has a patch-equivalent commit on the landed ref (rebase merge, cherry-pick). */
|
|
31
|
+
'per-commit'
|
|
32
|
+
/** The branch's combined change equals one commit on the landed ref (squash merge). */
|
|
33
|
+
| 'squashed'
|
|
34
|
+
/** A merged PR's head passes `squashed`, and every commit after it passes `per-commit`. */
|
|
35
|
+
| 'head-moved';
|
|
36
|
+
/** The outcome of {@link assessLanding}. */
|
|
37
|
+
export type Landing = {
|
|
38
|
+
check: LandingCheck;
|
|
39
|
+
/**
|
|
40
|
+
* True when `git merge-tree` of the landed ref and the tip gives exactly
|
|
41
|
+
* the landed ref's tree: the branch's content is already there. False
|
|
42
|
+
* when the merge changes something, conflicts, cannot run (git older
|
|
43
|
+
* than 2.38) or fails. Only a confirmed verdict drops `abandon-unmerged`.
|
|
44
|
+
*/
|
|
45
|
+
confirmed: boolean;
|
|
46
|
+
} | {
|
|
47
|
+
check: null;
|
|
48
|
+
/**
|
|
49
|
+
* Commits that have no patch-equivalent on the landed ref, as `<short sha> <subject>`.
|
|
50
|
+
* Filled only when the branch is partly on the landed ref, so a plain
|
|
51
|
+
* diverged branch does not list every commit it has.
|
|
52
|
+
*/
|
|
53
|
+
unmatched: string[];
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Whether the work on `tip` is already on the landed ref, judged by patch.
|
|
57
|
+
*
|
|
58
|
+
* Checked in order: every commit one by one (`per-commit`), the branch as one
|
|
59
|
+
* squashed change (`squashed`), and, with `prHead` set to the head of a merged
|
|
60
|
+
* pull request that is not `tip`, that head squashed plus each later commit
|
|
61
|
+
* one by one (`head-moved`). A branch with a commit that is not on the landed
|
|
62
|
+
* ref is never landed. A passing check also carries `confirmed`, the outcome
|
|
63
|
+
* of the content check ({@link contentConfirmed}).
|
|
64
|
+
*
|
|
65
|
+
* Reads only; it writes one unreferenced commit object and no ref. Any error
|
|
66
|
+
* returns `{ check: null, unmatched: [] }`: a check that cannot finish is not
|
|
67
|
+
* evidence.
|
|
68
|
+
*/
|
|
69
|
+
export declare function assessLanding(repo: RepoInfo, tip: string, git: Git, prHead?: string | null): Landing;
|
|
70
|
+
/**
|
|
71
|
+
* The patch-equivalence verdict for a branch the first two signals did not
|
|
72
|
+
* settle. A merged PR at an earlier head lets the check trust that head's
|
|
73
|
+
* squash merge and look at the commits after it.
|
|
74
|
+
*/
|
|
75
|
+
export declare function judgeLanding(repo: RepoInfo, tip: string, pr: PRInfo | undefined, gap: PrEvidenceGap | null, git: Git): Landing;
|
|
76
|
+
/** True when the verdict is a patch match that the content check agreed with, so no grant is needed. */
|
|
77
|
+
export declare function landingConfirmed(landing: Landing | null): boolean;
|
|
78
|
+
/** {@link assessLanding}, reduced to the check that passed, or null. */
|
|
79
|
+
export declare function patchEquivalent(repo: RepoInfo, tip: string, git: Git, prHead?: string | null): LandingCheck | null;
|
|
80
|
+
/** Write the landing evidence into a row's `facts`. */
|
|
81
|
+
export declare function recordLanding(facts: Record<string, unknown>, landing: Landing): void;
|
|
82
|
+
/**
|
|
83
|
+
* The part of a row's reason that says whether the content check agreed, for a
|
|
84
|
+
* patch-equivalent verdict.
|
|
85
|
+
*/
|
|
86
|
+
export declare function confirmationNote(landing: Extract<Landing, {
|
|
87
|
+
check: LandingCheck;
|
|
88
|
+
}>): string;
|
|
89
|
+
/** One sentence naming the commits that kept a partly landed branch from counting, or null. */
|
|
90
|
+
export declare function unmatchedSentence(landing: Landing, repo: RepoInfo): string | null;
|
package/dist/landing.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch equivalence: a third way to tell that a branch landed.
|
|
3
|
+
*
|
|
4
|
+
* A merge commit makes the branch tip reachable from the default branch, and a
|
|
5
|
+
* merged pull request at the tip says so too. A rebase merge, a cherry-pick
|
|
6
|
+
* and a squash merge do neither: the work is on the default branch under other
|
|
7
|
+
* commit ids. `git cherry` compares commits by patch id, which ignores the id,
|
|
8
|
+
* the message and the dates, so it can say "this commit's change is already
|
|
9
|
+
* there".
|
|
10
|
+
*
|
|
11
|
+
* A patch id is a heuristic, not proof. It reads only the changed lines, so an
|
|
12
|
+
* indentation-only change, a one-line edit that the landed ref also made
|
|
13
|
+
* independently, or a commit that landed and was later reverted can all look
|
|
14
|
+
* the same as work that landed. A patch-equivalent verdict therefore stands
|
|
15
|
+
* on its own only when a second, independent check agrees: merging the branch
|
|
16
|
+
* into the landed ref (`git merge-tree`) must change nothing, which shows the
|
|
17
|
+
* branch's content is already there. Without that, the verdict still names the
|
|
18
|
+
* check that passed, but `confirmed` is false and callers keep requiring the
|
|
19
|
+
* `abandon-unmerged` authorization.
|
|
20
|
+
*
|
|
21
|
+
* This code decides what gets deleted, so every doubt answers "not landed" or
|
|
22
|
+
* "not confirmed". A git call that fails, output that cannot be read, a
|
|
23
|
+
* shallow clone, a missing merge base, a commit that is not on the landed ref
|
|
24
|
+
* and a patch that is empty all end the same way: no evidence.
|
|
25
|
+
*/
|
|
26
|
+
import { landedRef } from "./types.js";
|
|
27
|
+
/** Cap on {@link Landing} `unmatched`. */
|
|
28
|
+
const UNMATCHED_SHOWN = 10;
|
|
29
|
+
/**
|
|
30
|
+
* `git replace` refs make git read one object as another, so a replaced commit
|
|
31
|
+
* could fake a merge base, a commit list or a patch. Every call of the check
|
|
32
|
+
* ignores them.
|
|
33
|
+
*/
|
|
34
|
+
const NO_REPLACE = { GIT_NO_REPLACE_OBJECTS: '1' };
|
|
35
|
+
/** Call options of every git call the check makes. */
|
|
36
|
+
const CALL = { env: NO_REPLACE };
|
|
37
|
+
/**
|
|
38
|
+
* The identity of the throwaway commit the squashed check builds. Fixed, so
|
|
39
|
+
* the check needs no git identity to be configured, and the commit holds
|
|
40
|
+
* nothing that depends on who runs it.
|
|
41
|
+
*/
|
|
42
|
+
const PROBE_ENV = {
|
|
43
|
+
...NO_REPLACE,
|
|
44
|
+
GIT_AUTHOR_NAME: 'repo-cleaner',
|
|
45
|
+
GIT_AUTHOR_EMAIL: 'repo-cleaner@localhost',
|
|
46
|
+
GIT_AUTHOR_DATE: '2000-01-01T00:00:00+0000',
|
|
47
|
+
GIT_COMMITTER_NAME: 'repo-cleaner',
|
|
48
|
+
GIT_COMMITTER_EMAIL: 'repo-cleaner@localhost',
|
|
49
|
+
GIT_COMMITTER_DATE: '2000-01-01T00:00:00+0000',
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Compare every commit in `base..tip` with `landed` by patch id.
|
|
53
|
+
*
|
|
54
|
+
* `git cherry` leaves merge commits out, and `git log --name-status` shows no
|
|
55
|
+
* diff for them, so every merge commit in the range counts as unmatched, a
|
|
56
|
+
* plain merge of the default branch included. The range is listed separately
|
|
57
|
+
* and must be the same set of commits that `cherry` reported. A branch with a
|
|
58
|
+
* merge commit therefore never passes `per-commit`; the `squashed` check, which
|
|
59
|
+
* looks at the combined change, is the one that can still pass. An empty range
|
|
60
|
+
* is not a match: there is nothing to compare, and ancestry already covers it.
|
|
61
|
+
*/
|
|
62
|
+
function compareRange(at, landed, tip, base) {
|
|
63
|
+
const range = base === null ? `${landed}..${tip}` : `${base}..${tip}`;
|
|
64
|
+
const listed = at.logLines({ range, format: '%H', ...CALL });
|
|
65
|
+
const changing = commitsWithChanges(at, range);
|
|
66
|
+
const wanted = new Set(listed);
|
|
67
|
+
const seen = new Map();
|
|
68
|
+
for (const entry of at.cherry(landed, tip, CALL)) {
|
|
69
|
+
if (wanted.has(entry.sha))
|
|
70
|
+
seen.set(entry.sha, entry.equivalent);
|
|
71
|
+
}
|
|
72
|
+
// `git cherry` calls two empty commits equivalent, so an empty commit proves
|
|
73
|
+
// nothing and never counts.
|
|
74
|
+
const unmatched = listed.filter((sha) => seen.get(sha) !== true || !changing.has(sha));
|
|
75
|
+
const matched = listed.filter((sha) => seen.get(sha) === true && changing.has(sha)).length;
|
|
76
|
+
return { all: listed.length > 0 && unmatched.length === 0, matched, unmatched };
|
|
77
|
+
}
|
|
78
|
+
/** The commits in `range` whose own change touches at least one path. Merge commits show no diff and are never listed. */
|
|
79
|
+
function commitsWithChanges(at, range) {
|
|
80
|
+
const marker = '\x1e';
|
|
81
|
+
const out = at.log({ range, format: 'format:%x1e%H', nameStatus: true, ...CALL });
|
|
82
|
+
const changing = new Set();
|
|
83
|
+
for (const record of out.split(marker)) {
|
|
84
|
+
const [sha, ...rest] = record.split('\n');
|
|
85
|
+
if (sha !== undefined && /^[0-9a-f]{40,64}$/.test(sha) && rest.some((l) => l.trim() !== '')) {
|
|
86
|
+
changing.add(sha);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return changing;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* True when the combined change from the merge base to `commitish` equals the
|
|
93
|
+
* patch of one commit on `landed`.
|
|
94
|
+
*
|
|
95
|
+
* A commit with `commitish`'s tree on the merge base is built without moving
|
|
96
|
+
* any ref, and `git cherry` compares it with the landed commits. The patch
|
|
97
|
+
* must not be empty: an empty change has no patch id, and a tree equal to the
|
|
98
|
+
* merge base's is refused before the question is asked.
|
|
99
|
+
*
|
|
100
|
+
* Any one landed commit with the same net patch is enough, not necessarily a
|
|
101
|
+
* commit that came from this branch. That is why a `squashed` verdict is
|
|
102
|
+
* only a hint until {@link contentConfirmed} agrees.
|
|
103
|
+
*/
|
|
104
|
+
function squashedMatches(at, landed, commitish) {
|
|
105
|
+
const base = at.mergeBase(landed, commitish, CALL);
|
|
106
|
+
if (base === null)
|
|
107
|
+
return false;
|
|
108
|
+
const tree = at.resolveRef(`${commitish}^{tree}`, CALL);
|
|
109
|
+
const baseTree = at.resolveRef(`${base}^{tree}`, CALL);
|
|
110
|
+
if (tree === null || baseTree === null || tree === baseTree)
|
|
111
|
+
return false;
|
|
112
|
+
const probe = at.commitTree(tree, { parents: [base], message: 'repo-cleaner: patch equivalence probe' }, { env: PROBE_ENV });
|
|
113
|
+
const entries = at.cherry(landed, probe, CALL);
|
|
114
|
+
return entries.length === 1 && entries[0].sha === probe && entries[0].equivalent;
|
|
115
|
+
}
|
|
116
|
+
/** `<short sha> <subject>` for each of `shas` found in `base..tip`, capped. */
|
|
117
|
+
function describeCommits(at, range, shas) {
|
|
118
|
+
const wanted = new Set(shas);
|
|
119
|
+
return at
|
|
120
|
+
.logLines({ range, format: '%H %h %s', ...CALL })
|
|
121
|
+
.map((line) => line.split(' '))
|
|
122
|
+
.filter(([sha]) => sha !== undefined && wanted.has(sha))
|
|
123
|
+
.map((parts) => parts.slice(1).join(' ').trim())
|
|
124
|
+
.slice(0, UNMATCHED_SHOWN);
|
|
125
|
+
}
|
|
126
|
+
function assess(repo, tip, git, prHead) {
|
|
127
|
+
const at = git.at(repo.root);
|
|
128
|
+
const none = { check: null, unmatched: [] };
|
|
129
|
+
// A shallow clone cuts history short, so the merge base and the commit list
|
|
130
|
+
// are not the real ones. Anything but a plain "false" is read as shallow.
|
|
131
|
+
if (at.revParse('--is-shallow-repository', CALL) !== 'false')
|
|
132
|
+
return none;
|
|
133
|
+
const landed = at.resolveRef(landedRef(repo), CALL);
|
|
134
|
+
const tipSha = at.resolveRef(`${tip}^{commit}`, CALL);
|
|
135
|
+
if (landed === null || tipSha === null)
|
|
136
|
+
return none;
|
|
137
|
+
const whole = compareRange(at, landed, tipSha, null);
|
|
138
|
+
if (whole.all)
|
|
139
|
+
return { check: 'per-commit' };
|
|
140
|
+
if (squashedMatches(at, landed, tipSha))
|
|
141
|
+
return { check: 'squashed' };
|
|
142
|
+
if (prHead !== null && prHead !== tipSha) {
|
|
143
|
+
const headSha = at.resolveRef(`${prHead}^{commit}`, CALL);
|
|
144
|
+
if (headSha !== null &&
|
|
145
|
+
at.isAncestor(headSha, tipSha, CALL) &&
|
|
146
|
+
squashedMatches(at, landed, headSha)) {
|
|
147
|
+
const after = compareRange(at, landed, tipSha, headSha);
|
|
148
|
+
if (after.all)
|
|
149
|
+
return { check: 'head-moved' };
|
|
150
|
+
// The squash merge is real, so the commits after it are what is left.
|
|
151
|
+
return {
|
|
152
|
+
check: null,
|
|
153
|
+
unmatched: describeCommits(at, `${headSha}..${tipSha}`, after.unmatched),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Some commits are on the landed ref and some are not: name the rest.
|
|
158
|
+
if (whole.matched > 0 && whole.unmatched.length > 0) {
|
|
159
|
+
return {
|
|
160
|
+
check: null,
|
|
161
|
+
unmatched: describeCommits(at, `${landed}..${tipSha}`, whole.unmatched),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return none;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The second, independent check: merging `tip` into the landed ref changes
|
|
168
|
+
* nothing.
|
|
169
|
+
*
|
|
170
|
+
* `git merge-tree --write-tree <landed> <tip>` computes the merge in the object
|
|
171
|
+
* store. It must be clean and its tree must equal the landed ref's tree, which
|
|
172
|
+
* shows the branch's content is already there and not just a similar patch.
|
|
173
|
+
* Needs git 2.38 or later; an older git, a failure, a conflict or a tree that
|
|
174
|
+
* is not a sha all mean "not confirmed". A tip whose work was landed and later
|
|
175
|
+
* reverted on the default branch is not confirmed either: the merge brings the
|
|
176
|
+
* work back.
|
|
177
|
+
*/
|
|
178
|
+
function contentConfirmed(at, landed, tipSha) {
|
|
179
|
+
const merged = at.mergeTree(landed, tipSha, CALL);
|
|
180
|
+
if (merged.status !== 'clean' || !/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(merged.tree))
|
|
181
|
+
return false;
|
|
182
|
+
const landedTree = at.resolveRef(`${landed}^{tree}`, CALL);
|
|
183
|
+
return landedTree !== null && landedTree === merged.tree;
|
|
184
|
+
}
|
|
185
|
+
/** {@link contentConfirmed} for `tip` against the landed ref; any error is false. */
|
|
186
|
+
function confirm(repo, tip, git) {
|
|
187
|
+
try {
|
|
188
|
+
const at = git.at(repo.root);
|
|
189
|
+
const landed = at.resolveRef(landedRef(repo), CALL);
|
|
190
|
+
const tipSha = at.resolveRef(`${tip}^{commit}`, CALL);
|
|
191
|
+
return landed !== null && tipSha !== null && contentConfirmed(at, landed, tipSha);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Whether the work on `tip` is already on the landed ref, judged by patch.
|
|
199
|
+
*
|
|
200
|
+
* Checked in order: every commit one by one (`per-commit`), the branch as one
|
|
201
|
+
* squashed change (`squashed`), and, with `prHead` set to the head of a merged
|
|
202
|
+
* pull request that is not `tip`, that head squashed plus each later commit
|
|
203
|
+
* one by one (`head-moved`). A branch with a commit that is not on the landed
|
|
204
|
+
* ref is never landed. A passing check also carries `confirmed`, the outcome
|
|
205
|
+
* of the content check ({@link contentConfirmed}).
|
|
206
|
+
*
|
|
207
|
+
* Reads only; it writes one unreferenced commit object and no ref. Any error
|
|
208
|
+
* returns `{ check: null, unmatched: [] }`: a check that cannot finish is not
|
|
209
|
+
* evidence.
|
|
210
|
+
*/
|
|
211
|
+
export function assessLanding(repo, tip, git, prHead = null) {
|
|
212
|
+
let verdict;
|
|
213
|
+
try {
|
|
214
|
+
verdict = assess(repo, tip, git, prHead);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return { check: null, unmatched: [] };
|
|
218
|
+
}
|
|
219
|
+
if (verdict.check === null)
|
|
220
|
+
return verdict;
|
|
221
|
+
return { check: verdict.check, confirmed: confirm(repo, tip, git) };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The patch-equivalence verdict for a branch the first two signals did not
|
|
225
|
+
* settle. A merged PR at an earlier head lets the check trust that head's
|
|
226
|
+
* squash merge and look at the commits after it.
|
|
227
|
+
*/
|
|
228
|
+
export function judgeLanding(repo, tip, pr, gap, git) {
|
|
229
|
+
const prHead = pr !== undefined && gap === 'head-moved' ? pr.headOid : null;
|
|
230
|
+
return assessLanding(repo, tip, git, prHead);
|
|
231
|
+
}
|
|
232
|
+
/** True when the verdict is a patch match that the content check agreed with, so no grant is needed. */
|
|
233
|
+
export function landingConfirmed(landing) {
|
|
234
|
+
return landing !== null && landing.check !== null && landing.confirmed;
|
|
235
|
+
}
|
|
236
|
+
/** {@link assessLanding}, reduced to the check that passed, or null. */
|
|
237
|
+
export function patchEquivalent(repo, tip, git, prHead = null) {
|
|
238
|
+
return assessLanding(repo, tip, git, prHead).check;
|
|
239
|
+
}
|
|
240
|
+
/** Write the landing evidence into a row's `facts`. */
|
|
241
|
+
export function recordLanding(facts, landing) {
|
|
242
|
+
if (landing.check !== null) {
|
|
243
|
+
facts['landing'] = 'patch-equivalent';
|
|
244
|
+
facts['landing_check'] = landing.check;
|
|
245
|
+
facts['landing_content_confirmed'] = landing.confirmed;
|
|
246
|
+
}
|
|
247
|
+
else if (landing.unmatched.length > 0) {
|
|
248
|
+
facts['unmatched_commits'] = landing.unmatched;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The part of a row's reason that says whether the content check agreed, for a
|
|
253
|
+
* patch-equivalent verdict.
|
|
254
|
+
*/
|
|
255
|
+
export function confirmationNote(landing) {
|
|
256
|
+
return landing.confirmed
|
|
257
|
+
? 'content confirmed by merge-tree'
|
|
258
|
+
: 'content not confirmed by merge-tree, so abandon-unmerged is required';
|
|
259
|
+
}
|
|
260
|
+
/** One sentence naming the commits that kept a partly landed branch from counting, or null. */
|
|
261
|
+
export function unmatchedSentence(landing, repo) {
|
|
262
|
+
if (landing.check !== null || landing.unmatched.length === 0)
|
|
263
|
+
return null;
|
|
264
|
+
return `Part of the work is on \`${landedRef(repo)}\`, but these commits are not (no patch-equivalent commit): ${landing.unmatched.join('; ')}.`;
|
|
265
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -26,7 +26,10 @@ export declare function authorizationsFor(flags: Partial<Record<Authorization, b
|
|
|
26
26
|
export interface RepoInfo {
|
|
27
27
|
/** The main checkout's root, even when the sweep was pointed at a worktree. */
|
|
28
28
|
root: string;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* `owner/name` for a github.com remote, `host/owner/name` for one on another
|
|
31
|
+
* host, or null when the remote names no repository.
|
|
32
|
+
*/
|
|
30
33
|
slug: string | null;
|
|
31
34
|
mainBranch: string;
|
|
32
35
|
remote: string | null;
|
|
@@ -40,6 +43,8 @@ export interface RepoInfo {
|
|
|
40
43
|
export declare function landedRef(repo: RepoInfo): string;
|
|
41
44
|
/** True when the repository has a remote the sweep did not fetch, so its refs may be stale. */
|
|
42
45
|
export declare function isUnfetched(repo: RepoInfo): boolean;
|
|
46
|
+
/** Why a merged PR does not prove that a tip landed. */
|
|
47
|
+
export type PrEvidenceGap = 'other-base' | 'head-moved';
|
|
43
48
|
/** A pull request, reduced to what classification consults. */
|
|
44
49
|
export interface PRInfo {
|
|
45
50
|
number: number;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This package's version, from its package.json when it was built. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.9.0";
|
package/dist/worktrees.js
CHANGED
|
@@ -10,6 +10,7 @@ import { isAbsolute, join, sep } from 'node:path';
|
|
|
10
10
|
import { shellJoin } from '@osdlc/command-seam';
|
|
11
11
|
import { nodeGuard } from '@osdlc/path-guard/node';
|
|
12
12
|
import { gapSentence, recordPr } from "./branches.js";
|
|
13
|
+
import { confirmationNote, judgeLanding, landingConfirmed, recordLanding, unmatchedSentence, } from "./landing.js";
|
|
13
14
|
import { busyHolders, isIdleHolder, parseLockOwner } from "./processes.js";
|
|
14
15
|
import { authorizationsFor, landedRef, makeItem, } from "./types.js";
|
|
15
16
|
/** The command line, not run, that removes the worktree at `path`. */
|
|
@@ -235,7 +236,14 @@ export function classifyWorktree(opts) {
|
|
|
235
236
|
facts['unique_commits'] = unpushed.length;
|
|
236
237
|
// A merged PR at HEAD lands every commit, even ones the fallback
|
|
237
238
|
// `compare_against` (a squash merge, a deleted upstream) counts as ahead.
|
|
238
|
-
|
|
239
|
+
// A rebase merge, cherry-pick or squash merge leaves HEAD off the default
|
|
240
|
+
// branch; the patches say whether the work is there anyway. Judged from HEAD,
|
|
241
|
+
// so a dirty tree is still caught by the dirty-file check below.
|
|
242
|
+
const patch = landed || !onDisk || head === null ? null : judgeLanding(repo, head, pr, gap, git);
|
|
243
|
+
if (patch !== null)
|
|
244
|
+
recordLanding(facts, patch);
|
|
245
|
+
const patchLanded = patch !== null && patch.check !== null;
|
|
246
|
+
const unlanded = unpushed.length > 0 && !landed && !landingConfirmed(patch);
|
|
239
247
|
const requires = authorizationsFor({
|
|
240
248
|
'abandon-unmerged': unlanded,
|
|
241
249
|
'discard-changes': dirtyFiles.length > 0,
|
|
@@ -289,6 +297,11 @@ export function classifyWorktree(opts) {
|
|
|
289
297
|
investigationParts: [
|
|
290
298
|
`Worktree ${path} has uncommitted work or unpushed commits.`,
|
|
291
299
|
`Branch: ${branch || '(detached)'}.`,
|
|
300
|
+
...(patchLanded && unlanded
|
|
301
|
+
? [
|
|
302
|
+
`Its commits are patch-equivalent to commits on ${landedRef(repo)} (${patch.check}), but ${confirmationNote(patch)}.`,
|
|
303
|
+
]
|
|
304
|
+
: []),
|
|
292
305
|
'Decide whether to preserve, push, or discard.',
|
|
293
306
|
],
|
|
294
307
|
});
|
|
@@ -305,6 +318,18 @@ export function classifyWorktree(opts) {
|
|
|
305
318
|
requires,
|
|
306
319
|
});
|
|
307
320
|
}
|
|
321
|
+
if (patchLanded) {
|
|
322
|
+
return makeItem({
|
|
323
|
+
category: 'worktree',
|
|
324
|
+
id: path,
|
|
325
|
+
classification: 'safe-to-delete',
|
|
326
|
+
action: 'remove',
|
|
327
|
+
reason: `clean; patch-equivalent to commits on ${landedRef(repo)} (${patch.check}); ${confirmationNote(patch)}`,
|
|
328
|
+
facts,
|
|
329
|
+
commands: safeRemove,
|
|
330
|
+
requires,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
308
333
|
if (branch && !branchExists) {
|
|
309
334
|
return makeItem({
|
|
310
335
|
category: 'worktree',
|
|
@@ -325,6 +350,9 @@ export function classifyWorktree(opts) {
|
|
|
325
350
|
parts.push(pr ? `PR #${pr.number} is ${pr.state}.` : 'No associated PR.');
|
|
326
351
|
if (pr && gap)
|
|
327
352
|
parts.push(gapSentence(pr, gap, repo));
|
|
353
|
+
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
|
354
|
+
if (unmatched !== null)
|
|
355
|
+
parts.push(unmatched);
|
|
328
356
|
return makeItem({
|
|
329
357
|
category: 'worktree',
|
|
330
358
|
id: path,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/repo-cleaner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "What has been merged into the default branch, and what can go away. Inventories local branches, worktrees and remote branches across one repository or many, classifying each with the evidence behind the verdict and the command that would act on it. Plans and applies approved deletes, compare-and-swap.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": "sksizer",
|
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"zod": "4.4.3",
|
|
52
|
+
"@osdlc/cli-tool": "^0.12.0",
|
|
52
53
|
"@osdlc/command-seam": "^0.14.0",
|
|
53
|
-
"@osdlc/cli-tool": "^0.11.0",
|
|
54
|
-
"@osdlc/path-guard": "^0.3.1",
|
|
55
54
|
"@osdlc/config-layers": "^0.8.0",
|
|
56
|
-
"@osdlc/
|
|
57
|
-
"@osdlc/typed-gh": "^0.12.
|
|
55
|
+
"@osdlc/path-guard": "^0.3.1",
|
|
56
|
+
"@osdlc/typed-gh": "^0.12.1",
|
|
57
|
+
"@osdlc/typed-git": "^0.11.1"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^26.1.0",
|