@osdlc/repo-cleaner 0.6.0 → 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 CHANGED
@@ -1,5 +1,54 @@
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
+
45
+ ## 0.6.1
46
+
47
+ ### Patch Changes
48
+
49
+ - Updated dependencies [a4ca07d]
50
+ - @osdlc/typed-gh@0.12.0
51
+
3
52
  ## 0.6.0
4
53
 
5
54
  ### Minor 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
- Two signals settle most items, and either alone is enough:
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).
@@ -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/cli.js
4
- // (`pnpm run build`).
5
- import { runMain } from '@osdlc/cli-tool'
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
@@ -0,0 +1,5 @@
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
+ export {};
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) });
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Local and remote branches: gather them, then decide what each one is.
3
3
  *
4
- * Two signals settle most branches. A merged pull request whose head commit is
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. Either one is enough to call a branch
8
- * spent; neither leaves the branch for a person to look at.
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
- * Two signals settle most branches. A merged pull request whose head commit is
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. Either one is enough to call a branch
8
- * spent; neither leaves the branch for a person to look at.
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
- const unmerged = !landed && !isAncestor;
155
- if (unmerged)
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
- const unmerged = !landed && !isAncestor;
255
- if (unmerged) {
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;
@@ -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
+ });
@@ -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 { Git } from '@osdlc/typed-git';
7
- import { type Gh } from '@osdlc/typed-gh';
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 { ownerRepoSlug } from '@osdlc/typed-gh';
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
- // `owner/name` for the report's header, so a fan-out over long paths stays
72
- // readable. A remote that is not a recognisable host coordinate answers null.
73
- const slug = remote === null ? null : ownerRepoSlugOf(git.at(root).try.remoteGetUrl(remote), gh, root);
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
- /** `owner/name` from a remote URL outcome, or null when it is not one. */
83
- function ownerRepoSlugOf(url, gh, root) {
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 coord = gh.at(root).ownerRepo(url.value);
87
- return coord === null ? null : ownerRepoSlug(coord);
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;
@@ -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
- /** `owner/name` when the remote is a recognisable host coordinate. */
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.6.0";
2
+ export declare const VERSION = "0.9.0";
@@ -1,3 +1,3 @@
1
1
  // Generated by cli-tool-stamp-version from package.json. Do not edit.
2
2
  /** This package's version, from its package.json when it was built. */
3
- export const VERSION = '0.6.0';
3
+ export 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
- const unlanded = unpushed.length > 0 && !landed;
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.6.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.11.0",
52
+ "@osdlc/cli-tool": "^0.12.0",
53
53
  "@osdlc/command-seam": "^0.14.0",
54
54
  "@osdlc/config-layers": "^0.8.0",
55
55
  "@osdlc/path-guard": "^0.3.1",
56
- "@osdlc/typed-gh": "^0.11.1",
57
- "@osdlc/typed-git": "^0.10.0"
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",