@erclx/canon 4.12.0 → 4.14.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-feedback-file/SKILL.md +2 -0
- package/claude/skills/canon-feedback-triage/SKILL.md +2 -2
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +2 -2
- package/docs/agents/key-changes.md +17 -10
- package/docs/agents/worktrees.md +26 -2
- package/package.json +1 -1
- package/src/cli.ts +2 -1
- package/src/commands/pr.ts +15 -7
- package/src/commands/upgrade.ts +41 -4
- package/src/commands/worktrees.ts +188 -26
- package/src/pr/bijection.ts +62 -8
- package/src/pr/paths.ts +41 -10
- package/src/worktree.ts +10 -4
- package/src/worktrees/reclaim.ts +50 -9
- package/src/worktrees/remove.ts +158 -0
|
@@ -69,6 +69,8 @@ Detect whether `canon` is on PATH:
|
|
|
69
69
|
command -v canon >/dev/null 2>&1
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
Before either pipe below, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the composed block. `.canon/review/` is gitignored, so no hook reaches either write, and this scan is the only gate. The banned-character half runs on both branches, including the local write. The phase-label half applies only on the `--github` branch, where the block reaches a remote.
|
|
73
|
+
|
|
72
74
|
If present, pipe the block to `canon feedback`. The CLI writes to its own repo's `.canon/review/feedback/feedback-<slug>-<ts>.md` and prints the absolute path on stdout:
|
|
73
75
|
|
|
74
76
|
```bash
|
|
@@ -45,7 +45,7 @@ State the class and the one-line reason per issue before routing. Do not batch u
|
|
|
45
45
|
|
|
46
46
|
- Direct fix: rename the branch to a conventional name (invoke `git-branch`), make the edit, then open the PR with `git-pr`.
|
|
47
47
|
- Plan-worthy: invoke `claude-feature` with the issue body as the feature description. Let it write the plan and stop. Hand the plan slug back to the user. Do not implement.
|
|
48
|
-
- Needs clarification: `gh issue comment <n> --body "<one question>"`,
|
|
48
|
+
- Needs clarification: run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the question first, since it reaches the remote with nothing else checking it and this scan is the only gate. Then `gh issue comment <n> --body "<one question>"`, and move on.
|
|
49
49
|
|
|
50
50
|
Match one issue to one branch and one PR. A single feedback issue is a single unit of work.
|
|
51
51
|
|
|
@@ -54,7 +54,7 @@ Match one issue to one branch and one PR. A single feedback issue is a single un
|
|
|
54
54
|
Link every fix back to its issue so the queue drains on merge.
|
|
55
55
|
|
|
56
56
|
- For a PR-backed fix, add a `Closes #<n>` line to the PR body so GitHub closes the issue on merge. When `git-pr` regenerates the body, keep that line.
|
|
57
|
-
- For a fix that ships without a PR, close it directly: `gh issue close <n> --comment "Fixed in <commit or PR url>."`
|
|
57
|
+
- For a fix that ships without a PR, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the close message first, since it reaches the remote with nothing else checking it and this scan is the only gate. Then close it directly: `gh issue close <n> --comment "Fixed in <commit or PR url>."`
|
|
58
58
|
- For a plan-worthy route, leave the issue open. It closes when the resulting PR merges with its `Closes #<n>` line.
|
|
59
59
|
|
|
60
60
|
## Notes
|
|
@@ -119,7 +119,7 @@ Test every ticked box too, bounded to one naming a file or a command. Confirm wh
|
|
|
119
119
|
|
|
120
120
|
Test what `## Key Changes` claims against the diff it describes, with `canon pr key-changes <number> --json`. This repository squash-merges, so that section becomes the commit message and the record on the trunk once the branch is gone, which is the same thing a stale ticked box corrupts. Branch on the record rather than on the exit code, which a shell function wrapping `canon` can flatten to zero.
|
|
121
121
|
|
|
122
|
-
Read `unmet` as a `should-fix` finding under the same `**PR body**` block the stale box takes, one entry per path, quoting the bullet the record carries on `preview` and naming the `head` the comparison ran at. Read the bullet before filing it, since one class survives the reader: a bullet citing where something is defined while claiming an edit elsewhere, which puts a real path in the claim region and points the change at a locative the path does not name. Report that as the class rather than as a stale claim. A body is edited between pushes, so the reading is true at that commit and can be false a minute later. Read `unnamed`
|
|
122
|
+
Read `unmet` as a `should-fix` finding under the same `**PR body**` block the stale box takes, one entry per path, quoting the bullet the record carries on `preview` and naming the `head` the comparison ran at. Read the bullet before filing it, since one class survives the reader: a bullet citing where something is defined while claiming an edit elsewhere, which puts a real path in the claim region and points the change at a locative the path does not name. Report that as the class rather than as a stale claim. A body is edited between pushes, so the reading is true at that commit and can be false a minute later. Read `unnamed` for yourself and raise nothing off the count. The class still covers a real omission and equally a generated asset or a regenerated index that earns no bullet, and the entries the reader can name as owing none are already held apart under `incidental`. Open the section and confirm no bullet names the file before putting a single one to the author, because this instruction has sent the question to three pull requests in one day over bullets that had named the files all along, and one of those authors added bullets nobody needed. Report `unresolved` and `incidental` nowhere: the first was judged in neither direction and the second owes no bullet, so neither says anything about the body.
|
|
123
123
|
|
|
124
124
|
A `reason` of `no-claims` means the reader resolved no path out of a section that exists, which is the extractor failing over prose rather than the body being wrong. Say so and raise nothing. `no-section` is the body carrying no such heading, which is a finding only where the pull request template asks for one.
|
|
125
125
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -51,6 +51,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
51
51
|
| `canon migrate record-tree` | Repoint the old-root citations inside the records themselves, scoped to the live folders and reporting every line without `--write` (`--root`, `--json`) |
|
|
52
52
|
| `canon sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
|
|
53
53
|
| `canon worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
|
|
54
|
+
| `canon worktrees reclaim` | Remove every reclaimable worktree and the branch behind it, reporting without acting under `--dry-run` |
|
|
54
55
|
| `canon comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
|
|
55
56
|
| `canon context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
|
|
56
57
|
| `canon markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
|
package/docs/agents/index.md
CHANGED
|
@@ -23,7 +23,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
23
23
|
- [Indexes](indexes.md): Flags, exit codes, and JSON shape for canon indexes regen, plus when it auto-stages what it rewrote
|
|
24
24
|
- [Install and sync](install-and-sync.md): What each install and sync verb writes, refuses, or leaves alone, and how drift is attributed in a target project
|
|
25
25
|
- [Intake](intake.md): Reading intake folder counts and items, the three read states an item can be in, landing a batch of selections in one cluster, the refusal reasons, and why a call is scoped to one file
|
|
26
|
-
- [Key Changes bijection](key-changes.md): Comparing the files a pull request body's Key Changes names against its own diff, the two directions and
|
|
26
|
+
- [Key Changes bijection](key-changes.md): Comparing the files a pull request body's Key Changes names against its own diff, the two directions and the split inside each that decides what is worth raising, the span rules the extractor was measured into, and the three refusals that separate a clean pass from a read that produced nothing
|
|
27
27
|
- [Label coverage](label-coverage.md): Reading a changed set against the pull request label map, the two tables it matches, how a gap is separated from a decision, and why an absent map is an answer
|
|
28
28
|
- [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
|
|
29
29
|
- [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
|
|
@@ -45,4 +45,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
45
45
|
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
46
46
|
- [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
|
|
47
47
|
- [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
|
|
48
|
-
- [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
|
|
48
|
+
- [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, removing the ones that are, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Key Changes bijection
|
|
3
|
-
description: Comparing the files a pull request body's Key Changes names against its own diff, the two directions and
|
|
3
|
+
description: Comparing the files a pull request body's Key Changes names against its own diff, the two directions and the split inside each that decides what is worth raising, the span rules the extractor was measured into, and the three refusals that separate a clean pass from a read that produced nothing
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Key Changes bijection
|
|
@@ -32,19 +32,24 @@ That view caps its file list at 100 rows and says nothing about having done so,
|
|
|
32
32
|
|
|
33
33
|
They are reported apart because they want different tolerances.
|
|
34
34
|
|
|
35
|
-
- **`unmet`** is a whole path the body claims and the diff does not carry. This is the graded direction and it sets the exit code. A bullet naming an untouched file is wrong more often than not, and it corrupts the record that reaches the trunk.
|
|
36
|
-
- **`unnamed`** is a changed file no bullet reached
|
|
37
|
-
- **`
|
|
35
|
+
- **`unmet`** is a whole path the body claims ahead of its bullet's first comma and the diff does not carry. This is the graded direction and it sets the exit code. A bullet naming an untouched file is wrong more often than not, and it corrupts the record that reaches the trunk.
|
|
36
|
+
- **`unnamed`** is a changed file no bullet reached that a reader might have wanted one for. Reported with no grade, since a change can be too small to describe and still be correctly absent. Grading it would fire on nearly every branch.
|
|
37
|
+
- **`incidental`** is a changed file no bullet reached that owes none: a test beside its subject, anything under a fixture or snapshot folder, and a lockfile a package manager writes. Held apart so the count above reads, and reported rather than dropped so a run still says what it set aside.
|
|
38
|
+
- **`unresolved`** is a path the reading could not judge either way. Two causes reach it: a path written partially, such as `claude-worker/SKILL.md` for a file under `claude/skills/`, and a path past its bullet's first comma.
|
|
38
39
|
|
|
39
|
-
A partial path can credit a changed file and can never accuse one
|
|
40
|
+
Each direction splits on one question, which is whether the evidence is strong enough to raise with a person. A partial path and a trailing path can each credit a changed file and can never accuse one, because nothing separates a path written short from a path written wrong, or a second claim from a file cited for context. Neither split drops anything: what comes out of `unmet` lands in `unresolved` and what comes out of `unnamed` lands in `incidental`, so a count a reader can act on never costs a file the run stayed silent about.
|
|
41
|
+
|
|
42
|
+
A generated asset and a regenerated index belong in the incidental class and are deliberately absent from it, because neither has a spelling that holds outside one project. Guessing at one would set aside a file that did owe a bullet, which hides a real omission, where leaving them out only leaves the count where it already was.
|
|
40
43
|
|
|
41
44
|
## What counts as a claim
|
|
42
45
|
|
|
43
46
|
Only `## Key Changes` is read. `## Technical Context` legitimately names files a branch never touched, such as an install stamp inside a target, so widening the read manufactures a finding out of every argument an author made for the change.
|
|
44
47
|
|
|
45
|
-
Inside the section,
|
|
48
|
+
Inside the section, every backticked span in a bullet is read, and the bullet's first comma outside a span divides the ones that can accuse from the ones that can only credit. That one lever was chosen by measurement. Over the 23 merged pull requests in this repository carrying the section, reading whole bullets reported 16 paths as claimed-but-untouched and every one was a file the body named for context. Cutting at the comma left 110 claims of the original 149 and took the false reports to 2. A list of sixteen clause-opening words tried beside it removed nothing the comma had not already removed, because this corpus punctuates every one of them.
|
|
49
|
+
|
|
50
|
+
The cut used to decide whether a span was read at all, and the paths past the comma fell out of the claim set into `unnamed`. That was accepted on the ground that the ungraded direction does no damage, and it did: `claude-pr-review` read `unnamed` as a question to put to the branch author, so on 2026-09-01 the question went to three pull requests over bullets that had named the files all along. Reading the whole bullet as claims outright is the obvious repair and the corpus refuses it, taking `unmet` from 10 to 19 over the 40 most recent merged pull requests carrying the section, with all nine additions in the context class the cut exists to exclude. Reading the whole bullet and gating the accusation on the cut gives claims 243 to 314 and unnamed 1124 to 1058 with `unmet` identical entry for entry.
|
|
46
51
|
|
|
47
|
-
A span
|
|
52
|
+
A span anywhere in the bullet has to survive all of these:
|
|
48
53
|
|
|
49
54
|
| Rule | What it keeps out |
|
|
50
55
|
| ------------------------------------------------- | ------------------------------------------------------ |
|
|
@@ -88,16 +93,18 @@ An empty extraction read as a clean pass is the failure shape this repository ha
|
|
|
88
93
|
|
|
89
94
|
## What it was measured against
|
|
90
95
|
|
|
91
|
-
Driven over the 40 most recent merged pull requests in this repository, 23 of which carry the section, the shipped verb reported zero unmet paths and one `no-claims` refusal. The naive extractor it replaced reported 16.
|
|
96
|
+
Driven over the 40 most recent merged pull requests in this repository at the time, 23 of which carry the section, the shipped verb reported zero unmet paths and one `no-claims` refusal. The naive extractor it replaced reported 16.
|
|
97
|
+
|
|
98
|
+
The re-measurement behind the trailing-path split is a wider corpus and its numbers do not compare to those. It takes the 40 most recent merged pull requests that carry the section, which reaches back through 90 merges rather than 40, and the reader reports 10 unmet over it. Those 10 predate the split and survive it entry for entry, which is the check that the widening moved nothing into the graded direction. Three name a gitignored path a diff can never carry and one is the definition-site class named below. The remaining six were not run down, so the residual over that corpus is 10 rather than zero and how much of it is the reader rather than the bodies is unmeasured.
|
|
92
99
|
|
|
93
100
|
The first body written after that corpus closed produced two, which is what the no-change marker and the open class above come from. One was the disclaiming bullet and the rule now covers it at no cost to the corpus. The other is the definition-site class, left open with the report naming it.
|
|
94
101
|
|
|
95
102
|
The bound is that the corpus is one repository writing to one house style. A project that punctuates differently loses claims to the comma cut rather than gaining false ones, since every rule above drops rather than invents, so the check degrades toward silence rather than toward noise.
|
|
96
103
|
|
|
97
|
-
What it cannot see is a bullet claiming a change to a file the branch did touch for another reason, a path written without backticks
|
|
104
|
+
What it cannot see is a bullet claiming a change to a file the branch did touch for another reason, and a path written without backticks. A claim in a second coordinated clause after the comma is read and credited, and it stays out of the graded direction, since nothing here separates it from a file the same clause cites for context.
|
|
98
105
|
|
|
99
106
|
One class stays open and is named rather than closed. A bullet can cite where something is defined while claiming an edit somewhere else, as `#1274` does with "Name the slug transform in `standards/slug.md` inline in the same step". The path is a definition site, the edit target is the step, and separating the two needs the sentence parsed rather than cut. A finding on such a bullet is this class rather than a stale claim, and a reader weighing an `unmet` path checks whether the bullet's real target is a locative the path does not name.
|
|
100
107
|
|
|
101
108
|
## Where it runs
|
|
102
109
|
|
|
103
|
-
`claude-pr-review` Step 3 calls it and files an `unmet` path as a `should-fix` finding under the `**PR body**` block the stale ticked box already takes, since what both corrupt is the merge record rather than a file in the diff. A body is edited between pushes, so a finding names the head the comparison ran at.
|
|
110
|
+
`claude-pr-review` Step 3 calls it and files an `unmet` path as a `should-fix` finding under the `**PR body**` block the stale ticked box already takes, since what both corrupt is the merge record rather than a file in the diff. A body is edited between pushes, so a finding names the head the comparison ran at. `unnamed` is read there and raised off no count, since the step's own history is a question sent over bullets that already answered it. `unresolved` and `incidental` are reported nowhere.
|
package/docs/agents/worktrees.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Worktrees
|
|
3
|
-
description: Reporting which worktrees are reclaimable, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
|
|
3
|
+
description: Reporting which worktrees are reclaimable, removing the ones that are, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Worktrees
|
|
@@ -24,6 +24,25 @@ Exit codes: `0` every worktree was read, `1` refused. The refusal carries a `rea
|
|
|
24
24
|
|
|
25
25
|
An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `reason` rather than the exit when a skill consumes this.
|
|
26
26
|
|
|
27
|
+
## Reclaim
|
|
28
|
+
|
|
29
|
+
`canon worktrees reclaim` removes every worktree the reading above called reclaimable, and the branch behind each one.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
canon worktrees reclaim --dry-run
|
|
33
|
+
canon worktrees reclaim
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Option | Behavior |
|
|
37
|
+
| ----------- | ------------------------------------------- |
|
|
38
|
+
| `--dry-run` | Report what would be removed without acting |
|
|
39
|
+
|
|
40
|
+
Removal sits on the default path rather than behind an apply flag. Report-only is what the `list` verb already did, and what it produced was a hand cleanup: eight directories deleted outside git in one afternoon, each leaving the registration and the branch that `git worktree remove` would have taken with it. A flag a reader has to remember is one nobody passes the first time, and the first time is when the directories are still there.
|
|
41
|
+
|
|
42
|
+
Each entry unlocks, removes, then deletes its branch, and the sequence is the same whether the directory still stands or is already gone. A run carrying a directory that is gone sweeps stale registrations once between the removals and the branch deletes, since a branch git still reads as held by a worktree cannot be deleted. That sweep is the one step reaching past the reclaimable set, because git takes no path to scope it: it clears the bookkeeping for every directory already gone, refused entries included, and deletes no branch and no directory of its own.
|
|
43
|
+
|
|
44
|
+
Exit codes: `0` every reclaimable worktree was removed or there were none, `1` refused or a removal failed. A reading that could not reach the merge state refuses every entry rather than falling back to a default, since that state is the one input deciding whether a branch is safe to delete.
|
|
45
|
+
|
|
27
46
|
## What makes a worktree reclaimable
|
|
28
47
|
|
|
29
48
|
All three hold: its branch has a merged pull request, its working tree is clean, and no live session holds the directory. Each alone has a case where removal loses something, so `refusals` names every failing condition rather than the first.
|
|
@@ -31,6 +50,7 @@ All three hold: its branch has a merged pull request, its working tree is clean,
|
|
|
31
50
|
| Refusal | What it means |
|
|
32
51
|
| ------------------------ | ------------------------------------------------------- |
|
|
33
52
|
| `main-worktree` | The main worktree, which is never reclaimable |
|
|
53
|
+
| `current-worktree` | The worktree the command is running in |
|
|
34
54
|
| `detached-head` | No branch, so nothing names a pull request |
|
|
35
55
|
| `no-merged-pull-request` | Its branch has no merged pull request |
|
|
36
56
|
| `uncommitted-changes` | Work no history stands behind, untracked files included |
|
|
@@ -39,6 +59,10 @@ All three hold: its branch has a merged pull request, its working tree is clean,
|
|
|
39
59
|
|
|
40
60
|
Uncommitted work is the condition that gates rather than warns. A worktree is gitignored scratch with no history behind it, so a directory removed with unstaged changes takes them somewhere nothing recovers.
|
|
41
61
|
|
|
62
|
+
A directory already deleted by hand is clean rather than unreadable, so the merged and session checks decide it like any other worktree. Reading the two together is what reported eight reclaimable worktrees as unreadable, since `git status` exits the same way for a path that is gone and one it could not read.
|
|
63
|
+
|
|
64
|
+
The worktree the command runs in refuses on position rather than on state. Git removes the directory a caller is standing in without complaint, and every later call scoped to that directory then fails, so a run that took it would leave the branches after it undeleted and report the failures against the worktrees rather than the cause.
|
|
65
|
+
|
|
42
66
|
## Why the pull request rather than git ancestry
|
|
43
67
|
|
|
44
68
|
Ancestry is the reading anyone reaches for and it fails in both directions on a repository that squash merges. A merged branch is never an ancestor of the trunk there, so `git merge-base --is-ancestor` calls shipped work unmerged. Measured against nine worktrees, it named five of six lingering branches unmerged, each of which had a merged pull request, reporting them 2 to 6 commits ahead.
|
|
@@ -52,7 +76,7 @@ The read is one `gh pr list --state merged` for the whole repository rather than
|
|
|
52
76
|
`route` names which one applies rather than choosing it, since picking wrong strands state.
|
|
53
77
|
|
|
54
78
|
- `session`: a live session holds the directory, and `claude rm <name>` removes the session and its worktree together. The `sessions` field carries the names, and a name is whatever string the session was launched under, spaces included, so quote it.
|
|
55
|
-
- `worktree`: the session has ended, and `
|
|
79
|
+
- `worktree`: the session has ended, and `canon worktrees reclaim` is what runs the remove and the branch delete.
|
|
56
80
|
- `null`: the main worktree, which no removal shape reaches.
|
|
57
81
|
|
|
58
82
|
A held worktree is refused rather than reported reclaimable, and its route is reported for whoever decides to act on it. Deleting a directory underneath a live session is the case that has to refuse.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -83,7 +83,7 @@ function showHelp(): void {
|
|
|
83
83
|
`${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, size, push, pull)${NC}`,
|
|
84
84
|
`${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
|
|
85
85
|
`${GREY}│${NC} targets [cmd] ${GREY}# Report the projects this toolkit installed into (list, pulls)${NC}`,
|
|
86
|
-
`${GREY}│${NC} worktrees [cmd] ${GREY}#
|
|
86
|
+
`${GREY}│${NC} worktrees [cmd] ${GREY}# Reclaim the worktrees whose branches merged (list, reclaim)${NC}`,
|
|
87
87
|
`${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
|
|
88
88
|
`${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
|
|
89
89
|
`${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
|
|
@@ -137,6 +137,7 @@ function showHelp(): void {
|
|
|
137
137
|
`${GREY}│${NC} canon records push --json`,
|
|
138
138
|
`${GREY}│${NC} canon sessions list --json`,
|
|
139
139
|
`${GREY}│${NC} canon worktrees list --json`,
|
|
140
|
+
`${GREY}│${NC} canon worktrees reclaim --dry-run`,
|
|
140
141
|
`${GREY}│${NC} canon secrets scan --json`,
|
|
141
142
|
`${GREY}│${NC} canon deps audit --json`,
|
|
142
143
|
`${GREY}│${NC} canon labels audit --json`,
|
package/src/commands/pr.ts
CHANGED
|
@@ -107,12 +107,14 @@ export function register(program: Command): void {
|
|
|
107
107
|
'bullet names leaves it incomplete.',
|
|
108
108
|
'',
|
|
109
109
|
'The two directions carry different weight:',
|
|
110
|
-
|
|
111
|
-
'
|
|
112
|
-
' unnamed a changed file no bullet reached
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
-
'
|
|
110
|
+
" unmet a whole path the body claims ahead of its bullet's first",
|
|
111
|
+
' comma and the diff does not carry, the graded direction',
|
|
112
|
+
' unnamed a changed file no bullet reached that a reader might have',
|
|
113
|
+
' wanted one for, reported without a grade',
|
|
114
|
+
' incidental a changed file no bullet reached that owes none: a test, a',
|
|
115
|
+
' fixture, or a lockfile, held apart so the count above reads',
|
|
116
|
+
' unresolved a path written partially, or one past its first comma,',
|
|
117
|
+
' which can credit a changed file and never accuse one',
|
|
116
118
|
'',
|
|
117
119
|
`Only ## ${KEY_CHANGES} is read. ## Technical Context legitimately names`,
|
|
118
120
|
'files a branch never touched, so widening the read manufactures findings.',
|
|
@@ -361,11 +363,16 @@ async function runKeyChanges(
|
|
|
361
363
|
)
|
|
362
364
|
}
|
|
363
365
|
}
|
|
366
|
+
if (report.incidental.length > 0) {
|
|
367
|
+
logInfo(
|
|
368
|
+
`${plural(report.incidental.length, 'further changed file')} set aside as owing no bullet, whole in the --json record.`,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
364
371
|
|
|
365
372
|
if (report.unresolved.length > 0) {
|
|
366
373
|
logStep('Unresolved')
|
|
367
374
|
logInfo(
|
|
368
|
-
`${plural(report.unresolved.length, 'path')} written partially, so neither direction judged it.`,
|
|
375
|
+
`${plural(report.unresolved.length, 'path')} written partially or trailing its bullet's first comma, so neither direction judged it.`,
|
|
369
376
|
)
|
|
370
377
|
for (const claim of report.unresolved) logInfo(claim.path)
|
|
371
378
|
}
|
|
@@ -384,6 +391,7 @@ async function runKeyChanges(
|
|
|
384
391
|
claims: report.claims,
|
|
385
392
|
unmet: report.unmet,
|
|
386
393
|
unnamed: report.unnamed,
|
|
394
|
+
incidental: report.incidental,
|
|
387
395
|
unresolved: report.unresolved,
|
|
388
396
|
})}\n`,
|
|
389
397
|
)
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -32,6 +32,13 @@ interface UpgradeRecord {
|
|
|
32
32
|
readonly latest?: string
|
|
33
33
|
readonly state: 'upgraded' | 'current' | 'cancelled' | 'refused'
|
|
34
34
|
readonly reason?: string
|
|
35
|
+
/**
|
|
36
|
+
* One rendered line for a caller that reports the outcome without parsing
|
|
37
|
+
* the rest of the record, such as `.husky/post-merge`. `current` reuses
|
|
38
|
+
* `describeSkew` verbatim so its wording never drifts from the line `canon
|
|
39
|
+
* sync --check` and `canon claude skills drift` already report.
|
|
40
|
+
*/
|
|
41
|
+
readonly message: string
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export function register(program: Command): void {
|
|
@@ -116,6 +123,7 @@ async function runUpgrade(opts: UpgradeOptions): Promise<number> {
|
|
|
116
123
|
...base(before, manager, command, skew),
|
|
117
124
|
after: before,
|
|
118
125
|
state: 'current',
|
|
126
|
+
message: describeSkew(skew),
|
|
119
127
|
})
|
|
120
128
|
return 0
|
|
121
129
|
}
|
|
@@ -152,7 +160,11 @@ async function applyUpgrade(
|
|
|
152
160
|
if (!proceed) {
|
|
153
161
|
logWarn('Cancelled')
|
|
154
162
|
outro()
|
|
155
|
-
emit(opts, {
|
|
163
|
+
emit(opts, {
|
|
164
|
+
...base(before, manager, command, skew),
|
|
165
|
+
state: 'cancelled',
|
|
166
|
+
message: `Cancelled. ${describeSkew(skew)}`,
|
|
167
|
+
})
|
|
156
168
|
return 0
|
|
157
169
|
}
|
|
158
170
|
|
|
@@ -169,10 +181,11 @@ async function applyUpgrade(
|
|
|
169
181
|
})
|
|
170
182
|
|
|
171
183
|
if (result.exitCode !== 0) {
|
|
184
|
+
const target = latestOf(skew) ?? UNKNOWN_LABEL
|
|
172
185
|
return refuse(
|
|
173
186
|
opts,
|
|
174
187
|
before,
|
|
175
|
-
`\`${command.join(' ')}\` exited ${result.exitCode}. Run it yourself to read what it reported.`,
|
|
188
|
+
`\`${command.join(' ')}\` exited ${result.exitCode} moving from ${before} to ${target}. Run it yourself to read what it reported.`,
|
|
176
189
|
manager,
|
|
177
190
|
)
|
|
178
191
|
}
|
|
@@ -186,16 +199,23 @@ async function applyUpgrade(
|
|
|
186
199
|
...base(before, manager, command, skew),
|
|
187
200
|
after,
|
|
188
201
|
state: 'upgraded',
|
|
202
|
+
message: upgradedMessage(before, after),
|
|
189
203
|
})
|
|
190
204
|
return 0
|
|
191
205
|
}
|
|
192
206
|
|
|
207
|
+
export function upgradedMessage(before: string, after: string): string {
|
|
208
|
+
return after === before
|
|
209
|
+
? `Reinstalled ${after}, unchanged.`
|
|
210
|
+
: `Upgraded ${before} to ${after}.`
|
|
211
|
+
}
|
|
212
|
+
|
|
193
213
|
function base(
|
|
194
214
|
before: string,
|
|
195
215
|
manager: Manager,
|
|
196
216
|
command: readonly string[],
|
|
197
217
|
skew: SkewReport,
|
|
198
|
-
): Omit<UpgradeRecord, 'state'> {
|
|
218
|
+
): Omit<UpgradeRecord, 'state' | 'message'> {
|
|
199
219
|
const latest = latestOf(skew)
|
|
200
220
|
|
|
201
221
|
return {
|
|
@@ -221,11 +241,28 @@ function refuse(
|
|
|
221
241
|
before,
|
|
222
242
|
state: 'refused',
|
|
223
243
|
reason,
|
|
244
|
+
message: reason,
|
|
224
245
|
})
|
|
225
246
|
return 1
|
|
226
247
|
}
|
|
227
248
|
|
|
228
249
|
function emit(opts: UpgradeOptions, record: UpgradeRecord): void {
|
|
229
250
|
if (opts.json !== true) return
|
|
230
|
-
process.stdout.write(
|
|
251
|
+
process.stdout.write(
|
|
252
|
+
`${JSON.stringify({ ...record, message: singleLine(record.message) })}\n`,
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* `message` is the one field carrying arbitrary text: `describeSkew`'s
|
|
258
|
+
* `unknown` branch embeds a registry error verbatim, and a registry answering
|
|
259
|
+
* with HTML produces one already carrying a double quote. `.husky/post-merge`
|
|
260
|
+
* reads this field with a pattern rather than a parser, so a quote makes it
|
|
261
|
+
* through `JSON.stringify` as an escaped `\"` that the pattern stops at,
|
|
262
|
+
* truncating the line it prints. Collapsing whitespace and swapping the quote
|
|
263
|
+
* for an apostrophe here, once, is what keeps every emitter of `message` from
|
|
264
|
+
* having to reason about that reader.
|
|
265
|
+
*/
|
|
266
|
+
export function singleLine(text: string): string {
|
|
267
|
+
return text.replace(/\s+/g, ' ').replace(/"/g, "'")
|
|
231
268
|
}
|
|
@@ -2,6 +2,7 @@ import type { Command } from 'commander'
|
|
|
2
2
|
import {
|
|
3
3
|
intro,
|
|
4
4
|
logInfo,
|
|
5
|
+
logRemove,
|
|
5
6
|
logStep,
|
|
6
7
|
logWarn,
|
|
7
8
|
outro,
|
|
@@ -10,17 +11,26 @@ import {
|
|
|
10
11
|
} from '@/ui'
|
|
11
12
|
import {
|
|
12
13
|
type Refusal,
|
|
14
|
+
type ReclaimReport,
|
|
13
15
|
reclaimReport,
|
|
14
16
|
type Unreadable,
|
|
15
17
|
type WorktreeVerdict,
|
|
16
18
|
} from '@/worktrees/reclaim'
|
|
19
|
+
import { mainWorktreeRoot } from '@/worktree'
|
|
20
|
+
import { removeReclaimable, type RemovalOutcome } from '@/worktrees/remove'
|
|
17
21
|
|
|
18
22
|
interface ListCommandOptions {
|
|
19
23
|
readonly json?: boolean
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
interface ReclaimCommandOptions {
|
|
27
|
+
readonly dryRun?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
const REFUSALS: Record<Refusal, string> = {
|
|
23
31
|
'main-worktree': 'the main worktree, which is never reclaimable',
|
|
32
|
+
'current-worktree':
|
|
33
|
+
'the worktree this command is running in, so removing it would take the run with it',
|
|
24
34
|
'detached-head': 'detached HEAD, so no branch names a pull request',
|
|
25
35
|
'no-merged-pull-request': 'no merged pull request for its branch',
|
|
26
36
|
'uncommitted-changes': 'uncommitted work no history stands behind',
|
|
@@ -37,10 +47,37 @@ const UNREADABLE: Record<Unreadable, string> = {
|
|
|
37
47
|
'The session roster could not be read, so nothing was read about which worktrees are still held.',
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
const CONDITIONS = [
|
|
51
|
+
'A worktree is reclaimable when all three hold: its branch has a merged',
|
|
52
|
+
'pull request, its working tree is clean, and no live session holds it.',
|
|
53
|
+
'Each alone has a case where removal loses something, so "refusals"',
|
|
54
|
+
'carries every failing condition rather than the first.',
|
|
55
|
+
'',
|
|
56
|
+
'The merged state comes from gh rather than from git ancestry. A',
|
|
57
|
+
'repository that squash merges never makes a merged branch an ancestor',
|
|
58
|
+
'of its trunk, so ancestry calls shipped work unmerged and calls an',
|
|
59
|
+
'abandoned branch sitting at a release commit merged.',
|
|
60
|
+
'',
|
|
61
|
+
'A directory already deleted by hand reads as clean rather than as',
|
|
62
|
+
'unreadable, so the merged and session checks decide it like any other.',
|
|
63
|
+
'',
|
|
64
|
+
'The main worktree is never reclaimable, and neither is the one this',
|
|
65
|
+
'command is running in, since git removes the directory a caller stands',
|
|
66
|
+
'in and every later call scoped to it then fails.',
|
|
67
|
+
'',
|
|
68
|
+
'An unreadable input refuses the whole reading rather than reporting',
|
|
69
|
+
'every worktree as not reclaimable, since an absent merge state and a',
|
|
70
|
+
'branch with no merged pull request produce the same empty answer.',
|
|
71
|
+
'',
|
|
72
|
+
'The merge read covers the most recent 200 merged pull requests. A',
|
|
73
|
+
'worktree older than that reads as having none and is refused, which',
|
|
74
|
+
'keeps a directory rather than removing one.',
|
|
75
|
+
]
|
|
76
|
+
|
|
40
77
|
export function register(program: Command): void {
|
|
41
78
|
const worktrees = program
|
|
42
79
|
.command('worktrees')
|
|
43
|
-
.description('Report which worktrees are reclaimable and
|
|
80
|
+
.description('Report which worktrees are reclaimable, and reclaim them')
|
|
44
81
|
.helpOption('-h, --help', 'Show this help message')
|
|
45
82
|
|
|
46
83
|
worktrees
|
|
@@ -58,29 +95,13 @@ export function register(program: Command): void {
|
|
|
58
95
|
' 0 every worktree was read',
|
|
59
96
|
' 1 refused, with the reason on stderr',
|
|
60
97
|
'',
|
|
61
|
-
|
|
62
|
-
'pull request, its working tree is clean, and no live session holds it.',
|
|
63
|
-
'Each alone has a case where removal loses something, so "refusals"',
|
|
64
|
-
'carries every failing condition rather than the first.',
|
|
65
|
-
'',
|
|
66
|
-
'The merged state comes from gh rather than from git ancestry. A',
|
|
67
|
-
'repository that squash merges never makes a merged branch an ancestor',
|
|
68
|
-
'of its trunk, so ancestry calls shipped work unmerged and calls an',
|
|
69
|
-
'abandoned branch sitting at a release commit merged.',
|
|
98
|
+
...CONDITIONS,
|
|
70
99
|
'',
|
|
71
100
|
'This reports and removes nothing. "route" names which removal shape',
|
|
72
101
|
'applies: "session" when a live session holds the directory, where',
|
|
73
102
|
'`claude rm <name>` takes the session and its worktree together, and',
|
|
74
|
-
'"worktree" when the session has ended, where `
|
|
75
|
-
'and a branch delete
|
|
76
|
-
'',
|
|
77
|
-
'An unreadable input refuses the whole reading rather than reporting',
|
|
78
|
-
'every worktree as not reclaimable, since an absent merge state and a',
|
|
79
|
-
'branch with no merged pull request produce the same empty answer.',
|
|
80
|
-
'',
|
|
81
|
-
'The merge read covers the most recent 200 merged pull requests. A',
|
|
82
|
-
'worktree older than that reads as having none and is refused, which',
|
|
83
|
-
'keeps a directory rather than removing one.',
|
|
103
|
+
'"worktree" when the session has ended, where `canon worktrees',
|
|
104
|
+
'reclaim` is the pair of a remove and a branch delete.',
|
|
84
105
|
'',
|
|
85
106
|
'Examples:',
|
|
86
107
|
' canon worktrees list',
|
|
@@ -91,6 +112,64 @@ export function register(program: Command): void {
|
|
|
91
112
|
.action(async (opts: ListCommandOptions) => {
|
|
92
113
|
process.exitCode = await runList(opts)
|
|
93
114
|
})
|
|
115
|
+
|
|
116
|
+
worktrees
|
|
117
|
+
.command('reclaim')
|
|
118
|
+
.description(
|
|
119
|
+
'Remove every reclaimable worktree and the branch behind it, unless --dry-run',
|
|
120
|
+
)
|
|
121
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
122
|
+
.option('--dry-run', 'Report what would be removed without removing it')
|
|
123
|
+
.addHelpText(
|
|
124
|
+
'after',
|
|
125
|
+
[
|
|
126
|
+
'',
|
|
127
|
+
'Exit codes:',
|
|
128
|
+
' 0 every reclaimable worktree was removed, or there were none',
|
|
129
|
+
' 1 refused, or a removal failed, with the reason on stderr',
|
|
130
|
+
'',
|
|
131
|
+
...CONDITIONS,
|
|
132
|
+
'',
|
|
133
|
+
'Removal runs on the default path rather than behind an apply flag,',
|
|
134
|
+
'because report-only is what produced the hand cleanup this verb was',
|
|
135
|
+
'filed against. Each removal unlocks the worktree, removes it, then',
|
|
136
|
+
'deletes its branch, and a run carrying a directory that is already',
|
|
137
|
+
'gone sweeps stale registrations once before the branch deletes.',
|
|
138
|
+
'',
|
|
139
|
+
'It deletes only what a reading called reclaimable. A worktree a live',
|
|
140
|
+
'session holds is refused rather than removed, since `claude rm <name>`',
|
|
141
|
+
'is what takes a session and its worktree together.',
|
|
142
|
+
'',
|
|
143
|
+
'The stale-registration sweep is the one step that reaches wider, since',
|
|
144
|
+
'git takes no path to scope it. It clears the bookkeeping for every',
|
|
145
|
+
'directory already gone, refused entries included, and deletes no branch',
|
|
146
|
+
'and no directory of its own.',
|
|
147
|
+
'',
|
|
148
|
+
'Examples:',
|
|
149
|
+
' canon worktrees reclaim --dry-run',
|
|
150
|
+
' canon worktrees reclaim',
|
|
151
|
+
'',
|
|
152
|
+
].join('\n'),
|
|
153
|
+
)
|
|
154
|
+
.action(async (opts: ReclaimCommandOptions) => {
|
|
155
|
+
process.exitCode = await runReclaim(opts)
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Frames an unreadable reading the same way for both verbs.
|
|
161
|
+
*
|
|
162
|
+
* The merge state is the one input that decides whether a branch is safe to
|
|
163
|
+
* delete, so a reading that could not reach it refuses every entry rather than
|
|
164
|
+
* falling back to a default. Sharing the frame keeps the removal from growing a
|
|
165
|
+
* second answer to the question the report already answers.
|
|
166
|
+
*/
|
|
167
|
+
function reportUnreadable(
|
|
168
|
+
report: Extract<ReclaimReport, { kind: 'unreadable' }>,
|
|
169
|
+
): void {
|
|
170
|
+
logStep('Refused')
|
|
171
|
+
logWarn(UNREADABLE[report.reason])
|
|
172
|
+
logInfo(report.detail)
|
|
94
173
|
}
|
|
95
174
|
|
|
96
175
|
async function runList(opts: ListCommandOptions): Promise<number> {
|
|
@@ -99,9 +178,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
99
178
|
intro('canon worktrees list')
|
|
100
179
|
|
|
101
180
|
if (report.kind === 'unreadable') {
|
|
102
|
-
|
|
103
|
-
logWarn(UNREADABLE[report.reason])
|
|
104
|
-
logInfo(report.detail)
|
|
181
|
+
reportUnreadable(report)
|
|
105
182
|
outro()
|
|
106
183
|
|
|
107
184
|
if (opts.json) {
|
|
@@ -133,6 +210,86 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
133
210
|
return 0
|
|
134
211
|
}
|
|
135
212
|
|
|
213
|
+
async function runReclaim(opts: ReclaimCommandOptions): Promise<number> {
|
|
214
|
+
const dryRun = opts.dryRun ?? false
|
|
215
|
+
const cwd = process.cwd()
|
|
216
|
+
const report = await reclaimReport({ cwd })
|
|
217
|
+
|
|
218
|
+
intro(
|
|
219
|
+
dryRun ? 'canon worktrees reclaim (dry-run)' : 'canon worktrees reclaim',
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if (report.kind === 'unreadable') {
|
|
223
|
+
reportUnreadable(report)
|
|
224
|
+
outro()
|
|
225
|
+
return 1
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const reclaimable = report.worktrees.filter((entry) => entry.reclaimable)
|
|
229
|
+
|
|
230
|
+
logStep('Reclaimable')
|
|
231
|
+
if (reclaimable.length === 0) {
|
|
232
|
+
logInfo('None. Every worktree fails at least one condition.')
|
|
233
|
+
outro()
|
|
234
|
+
return 0
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
logInfo(plural(reclaimable.length, 'worktree'))
|
|
238
|
+
pipeOutput(reclaimable.map(describe).join('\n'))
|
|
239
|
+
|
|
240
|
+
if (dryRun) {
|
|
241
|
+
logStep('Dry run')
|
|
242
|
+
logWarn('Nothing was removed. Run this again without --dry-run to remove.')
|
|
243
|
+
outro()
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Every git call in the removal is scoped to the main root rather than to the
|
|
248
|
+
// caller's directory, which a removal earlier in the same run can delete out
|
|
249
|
+
// from under the ones after it. The reading above stays on the caller's
|
|
250
|
+
// directory, since that is what tells it which worktree it is standing in.
|
|
251
|
+
const removal = await removeReclaimable(reclaimable, {
|
|
252
|
+
cwd: await mainWorktreeRoot(),
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
logStep('Removed')
|
|
256
|
+
for (const outcome of removal.outcomes) reportOutcome(outcome)
|
|
257
|
+
if (removal.pruned) {
|
|
258
|
+
logInfo(
|
|
259
|
+
'Swept the stale worktree registrations left by a deleted directory.',
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const failed = removal.outcomes.filter((outcome) => !outcome.removed)
|
|
264
|
+
if (failed.length > 0) {
|
|
265
|
+
logWarn(
|
|
266
|
+
`${plural(failed.length, 'worktree')} could not be removed. Read the reason above and clear it by hand.`,
|
|
267
|
+
)
|
|
268
|
+
outro()
|
|
269
|
+
return 1
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
outro()
|
|
273
|
+
return 0
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Names what happened to one entry, with the failing step where it did not close. */
|
|
277
|
+
function reportOutcome(outcome: RemovalOutcome): void {
|
|
278
|
+
const branch = outcome.branch ?? 'detached'
|
|
279
|
+
|
|
280
|
+
if (outcome.removed) {
|
|
281
|
+
logRemove(`${outcome.path} ${branch}`)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const step =
|
|
286
|
+
outcome.failedAt === 'branch'
|
|
287
|
+
? 'the worktree went but its branch stayed'
|
|
288
|
+
: 'the worktree could not be removed'
|
|
289
|
+
logWarn(`${outcome.path} ${branch}: ${step}.`)
|
|
290
|
+
if (outcome.detail) pipeOutput(outcome.detail)
|
|
291
|
+
}
|
|
292
|
+
|
|
136
293
|
function reportWorktrees(worktrees: readonly WorktreeVerdict[]): void {
|
|
137
294
|
logStep('Worktrees')
|
|
138
295
|
|
|
@@ -153,7 +310,7 @@ function reportWorktrees(worktrees: readonly WorktreeVerdict[]): void {
|
|
|
153
310
|
}
|
|
154
311
|
|
|
155
312
|
logWarn(
|
|
156
|
-
`${plural(reclaimable.length, 'worktree')} can be reclaimed. This reports and removes nothing, so run
|
|
313
|
+
`${plural(reclaimable.length, 'worktree')} can be reclaimed. This reports and removes nothing, so run \`canon worktrees reclaim\` to clear them.`,
|
|
157
314
|
)
|
|
158
315
|
}
|
|
159
316
|
|
|
@@ -183,9 +340,14 @@ function describe(verdict: WorktreeVerdict): string {
|
|
|
183
340
|
return `${head}\n Refused: ${reasons.join('; ')}.${routes.join('')}`
|
|
184
341
|
}
|
|
185
342
|
|
|
343
|
+
// The directory being gone already is the state that used to refuse, so the
|
|
344
|
+
// row says so rather than leaving a reader to wonder what it is acting on.
|
|
345
|
+
const tree = verdict.missing
|
|
346
|
+
? 'its directory is already gone'
|
|
347
|
+
: 'the tree is clean'
|
|
348
|
+
|
|
186
349
|
return [
|
|
187
350
|
head,
|
|
188
|
-
` Reclaimable. Pull request #${verdict.pullRequest} merged it,
|
|
189
|
-
` Remove with: git worktree remove ${verdict.path} && git branch -D ${held}`,
|
|
351
|
+
` Reclaimable. Pull request #${verdict.pullRequest} merged it, ${tree}, and no session holds it.`,
|
|
190
352
|
].join('\n')
|
|
191
353
|
}
|
package/src/pr/bijection.ts
CHANGED
|
@@ -24,17 +24,35 @@ export interface BijectionReport {
|
|
|
24
24
|
*/
|
|
25
25
|
readonly unmet: readonly PathClaim[]
|
|
26
26
|
/**
|
|
27
|
-
* Claims
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* Claims no changed file answers that the comparison could not judge either
|
|
28
|
+
* way, reported so a run says what it declined rather than counting them met.
|
|
29
|
+
*
|
|
30
|
+
* Two causes land here. A claim whose first segment names no entry in the
|
|
31
|
+
* tree is a path written partially, and one past its bullet's first comma is
|
|
32
|
+
* a path the reader cannot separate from a file cited for context. Both are
|
|
33
|
+
* evidence strong enough to credit a changed file and too weak to accuse one,
|
|
34
|
+
* so neither reaches `unmet`, and the cause is on the claim rather than in a
|
|
35
|
+
* bucket of its own: a reader acts on both the same way, by opening the
|
|
36
|
+
* bullet on `preview`.
|
|
30
37
|
*/
|
|
31
38
|
readonly unresolved: readonly PathClaim[]
|
|
32
39
|
/**
|
|
33
|
-
* Changed files no claim reaches
|
|
34
|
-
*
|
|
35
|
-
*
|
|
40
|
+
* Changed files no claim reaches that a reader might have wanted a bullet
|
|
41
|
+
* for. Reported without a severity, since a change can be too small to
|
|
42
|
+
* describe and still be correctly absent from the section.
|
|
36
43
|
*/
|
|
37
44
|
readonly unnamed: readonly string[]
|
|
45
|
+
/**
|
|
46
|
+
* Changed files no claim reaches that owe no bullet in the first place.
|
|
47
|
+
*
|
|
48
|
+
* Held apart rather than dropped, so a run still says what it set aside.
|
|
49
|
+
* `#1331` reported seven unnamed files of which four were a test or a
|
|
50
|
+
* fixture, which is what makes the raw count unreadable: a number mixing
|
|
51
|
+
* files that owe a bullet with files that never could cannot be acted on at
|
|
52
|
+
* any value, and the reviewing skill reads `unnamed` as a question to a
|
|
53
|
+
* branch author.
|
|
54
|
+
*/
|
|
55
|
+
readonly incidental: readonly string[]
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
export type Bijection =
|
|
@@ -50,6 +68,32 @@ export interface BijectionInput {
|
|
|
50
68
|
readonly title?: string
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
/**
|
|
72
|
+
* A changed file that owes no bullet, so its absence from the section is not a
|
|
73
|
+
* gap a reader would want reported.
|
|
74
|
+
*
|
|
75
|
+
* Three classes, each conventional rather than named for this repository: a
|
|
76
|
+
* test beside the subject it covers, anything under a fixture or snapshot
|
|
77
|
+
* folder, and a lockfile a package manager writes. All three change constantly
|
|
78
|
+
* as a consequence of work the section describes in its own terms, which is why
|
|
79
|
+
* a body naming them reads as noise rather than as diligence.
|
|
80
|
+
*
|
|
81
|
+
* A generated asset and a regenerated index belong in the class and are
|
|
82
|
+
* deliberately absent, because neither has a spelling that holds outside one
|
|
83
|
+
* project. Guessing at one would set aside a file that did owe a bullet, which
|
|
84
|
+
* is the direction that hides a real omission, where leaving them out only
|
|
85
|
+
* leaves the count where it already was.
|
|
86
|
+
*/
|
|
87
|
+
const INCIDENTAL: readonly RegExp[] = [
|
|
88
|
+
/(?:^|\/)[^/]+\.(?:test|spec)\.[A-Za-z0-9]+$/,
|
|
89
|
+
/(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|fixtures|testdata)\//,
|
|
90
|
+
/(?:^|\/)(?:bun\.lockb?|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|Gemfile\.lock|poetry\.lock|uv\.lock|composer\.lock|go\.sum)$/,
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
function owesNoBullet(path: string): boolean {
|
|
94
|
+
return INCIDENTAL.some((pattern) => pattern.test(path))
|
|
95
|
+
}
|
|
96
|
+
|
|
53
97
|
/**
|
|
54
98
|
* Whether one changed path is the file, or a file under the folder, a claim
|
|
55
99
|
* names.
|
|
@@ -80,6 +124,13 @@ function covers(claim: PathClaim, path: string): boolean {
|
|
|
80
124
|
* correct. Merging them into one count would either grade the second or excuse
|
|
81
125
|
* the first.
|
|
82
126
|
*
|
|
127
|
+
* Each direction then splits again on the same question, which is whether the
|
|
128
|
+
* evidence is strong enough to raise with a person. A claim reaches `unmet`
|
|
129
|
+
* only when it is both whole and leading, and a changed file reaches `unnamed`
|
|
130
|
+
* only when a bullet was owed for it. What each split sets aside is still
|
|
131
|
+
* reported, under `unresolved` and `incidental`, so a count a reader can act on
|
|
132
|
+
* never comes at the price of a file the run stayed silent about.
|
|
133
|
+
*
|
|
83
134
|
* Pure, so the whole judgment is testable against a fixture. The caller reads
|
|
84
135
|
* the body, the changed set, and the tree roots and hands all three in.
|
|
85
136
|
*/
|
|
@@ -104,10 +155,12 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
104
155
|
const hits = input.changed.filter((path) => covers(claim, path))
|
|
105
156
|
for (const path of hits) named.add(path)
|
|
106
157
|
if (hits.length > 0) continue
|
|
107
|
-
if (claim.anchored) unmet.push(claim)
|
|
158
|
+
if (claim.anchored && claim.leading) unmet.push(claim)
|
|
108
159
|
else unresolved.push(claim)
|
|
109
160
|
}
|
|
110
161
|
|
|
162
|
+
const reached = input.changed.filter((path) => !named.has(path))
|
|
163
|
+
|
|
111
164
|
return {
|
|
112
165
|
kind: 'measured',
|
|
113
166
|
head: input.head,
|
|
@@ -115,7 +168,8 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
115
168
|
claims: read.claims,
|
|
116
169
|
unmet,
|
|
117
170
|
unresolved,
|
|
118
|
-
unnamed:
|
|
171
|
+
unnamed: reached.filter((path) => !owesNoBullet(path)),
|
|
172
|
+
incidental: reached.filter(owesNoBullet),
|
|
119
173
|
}
|
|
120
174
|
}
|
|
121
175
|
|
package/src/pr/paths.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface PathClaim {
|
|
|
24
24
|
* genuinely wrong one.
|
|
25
25
|
*/
|
|
26
26
|
readonly anchored: boolean
|
|
27
|
+
/**
|
|
28
|
+
* True when the span sits ahead of the bullet's first comma.
|
|
29
|
+
*
|
|
30
|
+
* A trailing span is a path the bullet names without the reader being able to
|
|
31
|
+
* tell a second claim from a file cited for context, so it credits a changed
|
|
32
|
+
* file and never accuses one. Same asymmetry as `anchored`, on a second
|
|
33
|
+
* question: both mark evidence strong enough to confirm and too weak to
|
|
34
|
+
* accuse with.
|
|
35
|
+
*/
|
|
36
|
+
readonly leading: boolean
|
|
27
37
|
/** The span exactly as the body wrote it, before the line suffix came off. */
|
|
28
38
|
readonly span: string
|
|
29
39
|
/** One-based index of the bullet inside the section. */
|
|
@@ -101,7 +111,7 @@ function maskSpans(text: string): string {
|
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
/**
|
|
104
|
-
* The part of a bullet that
|
|
114
|
+
* The part of a bullet that can accuse, which ends at its first comma.
|
|
105
115
|
*
|
|
106
116
|
* This is the one lever that separates a claim from a mention, and it was
|
|
107
117
|
* chosen by measurement rather than by grammar. Over the 23 merged pull
|
|
@@ -113,10 +123,27 @@ function maskSpans(text: string): string {
|
|
|
113
123
|
* removed nothing the comma had not already removed, because this corpus
|
|
114
124
|
* punctuates every one of them.
|
|
115
125
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* without grading
|
|
126
|
+
* The cut used to bound what the scan collected at all, and that is the half
|
|
127
|
+
* that came out. A path past the comma was left out of the claim set entirely
|
|
128
|
+
* and fell to the unnamed direction, accepted here on the ground that the
|
|
129
|
+
* direction reports without grading. It graded anyway, one consumer removed:
|
|
130
|
+
* `claude-pr-review` reads `unnamed` as a question to put to the branch author,
|
|
131
|
+
* and on 2026-09-01 the question went to three pull requests over bullets that
|
|
132
|
+
* had named the files all along, with `#1329` gaining bullets it did not need.
|
|
133
|
+
*
|
|
134
|
+
* Collecting the whole bullet as claims was measured against the 40 most recent
|
|
135
|
+
* merged pull requests carrying the section and is not the repair: it takes
|
|
136
|
+
* `unmet` from 10 to 19, and every one of the nine is the class the cut was
|
|
137
|
+
* tuned to exclude, as in `#1276`'s "following the frame, the stream split, and
|
|
138
|
+
* the exit ladder `src/commands/labels.ts` already carries". So the scan reads
|
|
139
|
+
* the whole bullet and this cut decides `leading` instead, which is what lets a
|
|
140
|
+
* trailing path credit a changed file while `unmet` stays where the corpus put
|
|
141
|
+
* it. The same 40 give claims 243 to 314, unnamed 1124 to 1058, and unmet 10
|
|
142
|
+
* either way.
|
|
143
|
+
*
|
|
144
|
+
* `disclaimsChange` reads this region too, and for its own reason rather than
|
|
145
|
+
* by sharing this one: a disclaimer opens its bullet, so the cut reaches every
|
|
146
|
+
* one the corpus carries, and `NO_CHANGE` below records what depends on that.
|
|
120
147
|
*/
|
|
121
148
|
function claimRegion(bullet: string): string {
|
|
122
149
|
const at = maskSpans(bullet).indexOf(',')
|
|
@@ -149,8 +176,8 @@ function claimRegion(bullet: string): string {
|
|
|
149
176
|
const NO_CHANGE =
|
|
150
177
|
/\b(?:untouched|unchanged)\b|\bas written\b|^\s*(?:do not|don't|never)\b/i
|
|
151
178
|
|
|
152
|
-
function disclaimsChange(
|
|
153
|
-
return NO_CHANGE.test(maskSpans(
|
|
179
|
+
function disclaimsChange(bullet: string): boolean {
|
|
180
|
+
return NO_CHANGE.test(maskSpans(claimRegion(bullet)))
|
|
154
181
|
}
|
|
155
182
|
|
|
156
183
|
/**
|
|
@@ -296,12 +323,12 @@ export function extractKeyChangePaths(
|
|
|
296
323
|
trimmed.length > PREVIEW_LIMIT
|
|
297
324
|
? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
|
|
298
325
|
: trimmed
|
|
299
|
-
|
|
300
|
-
if (disclaimsChange(region)) continue
|
|
326
|
+
if (disclaimsChange(trimmed)) continue
|
|
301
327
|
|
|
328
|
+
const accusesBefore = claimRegion(trimmed).length
|
|
302
329
|
let claimed = false
|
|
303
330
|
|
|
304
|
-
for (const match of
|
|
331
|
+
for (const match of trimmed.matchAll(BACKTICKED)) {
|
|
305
332
|
const span = match[1] ?? ''
|
|
306
333
|
const bare = span.replace(LINE_SUFFIX, '')
|
|
307
334
|
|
|
@@ -317,6 +344,9 @@ export function extractKeyChangePaths(
|
|
|
317
344
|
const resolved = resolveSpan(bare)
|
|
318
345
|
if (resolved === undefined) continue
|
|
319
346
|
claimed = true
|
|
347
|
+
// First occurrence wins, across bullets as well as inside one. A path
|
|
348
|
+
// named trailing here and leading three bullets later keeps the weaker
|
|
349
|
+
// reading, which loses an accusation and can never add one.
|
|
320
350
|
if (seen.has(resolved.path)) continue
|
|
321
351
|
seen.add(resolved.path)
|
|
322
352
|
|
|
@@ -324,6 +354,7 @@ export function extractKeyChangePaths(
|
|
|
324
354
|
path: resolved.path,
|
|
325
355
|
directory: resolved.directory,
|
|
326
356
|
anchored: roots.has(resolved.path.slice(0, resolved.path.indexOf('/'))),
|
|
357
|
+
leading: (match.index ?? 0) < accusesBefore,
|
|
327
358
|
span,
|
|
328
359
|
bullet: index + 1,
|
|
329
360
|
preview,
|
package/src/worktree.ts
CHANGED
|
@@ -9,15 +9,21 @@ import { gitEnv } from '@/git-env'
|
|
|
9
9
|
* files the session has edited only if it resolves the root this way. The
|
|
10
10
|
* working directory is not a substitute, since a caller invoking from a
|
|
11
11
|
* subdirectory would resolve a root holding none of the trees a verb reads.
|
|
12
|
+
*
|
|
13
|
+
* The directory to resolve from is an argument so a caller holding a root of
|
|
14
|
+
* its own asks about that one rather than about wherever the process happens to
|
|
15
|
+
* stand, which is what a verb driven by an injected root needs.
|
|
12
16
|
*/
|
|
13
|
-
export async function currentWorktreeRoot(
|
|
14
|
-
|
|
17
|
+
export async function currentWorktreeRoot(
|
|
18
|
+
cwd: string = process.cwd(),
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const result = await $`git -C ${cwd} rev-parse --show-toplevel`
|
|
15
21
|
.env(gitEnv())
|
|
16
22
|
.quiet()
|
|
17
23
|
.nothrow()
|
|
18
|
-
if (result.exitCode !== 0) return
|
|
24
|
+
if (result.exitCode !== 0) return cwd
|
|
19
25
|
|
|
20
|
-
return result.stdout.toString().trim() ||
|
|
26
|
+
return result.stdout.toString().trim() || cwd
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/**
|
package/src/worktrees/reclaim.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $ } from 'bun'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
3
4
|
import { gitEnv } from '@/git-env'
|
|
4
5
|
import {
|
|
5
6
|
repositoryOf,
|
|
@@ -7,7 +8,11 @@ import {
|
|
|
7
8
|
resolveSessions,
|
|
8
9
|
type SessionReport,
|
|
9
10
|
} from '@/sessions/resolve'
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
currentWorktreeRoot,
|
|
13
|
+
listWorktrees,
|
|
14
|
+
type WorktreeEntry,
|
|
15
|
+
} from '@/worktree'
|
|
11
16
|
|
|
12
17
|
const GH_TIMEOUT_MS = 30_000
|
|
13
18
|
|
|
@@ -21,6 +26,7 @@ const MERGED_LIMIT = 200
|
|
|
21
26
|
/** Why one worktree cannot be reclaimed, one entry per failing condition. */
|
|
22
27
|
export type Refusal =
|
|
23
28
|
| 'main-worktree'
|
|
29
|
+
| 'current-worktree'
|
|
24
30
|
| 'detached-head'
|
|
25
31
|
| 'no-merged-pull-request'
|
|
26
32
|
| 'uncommitted-changes'
|
|
@@ -52,6 +58,11 @@ export interface WorktreeVerdict {
|
|
|
52
58
|
/** The names of the live sessions holding this worktree, which is what `claude rm` takes. */
|
|
53
59
|
readonly sessions: readonly string[]
|
|
54
60
|
readonly route: RemovalRoute
|
|
61
|
+
/**
|
|
62
|
+
* True when the directory is already gone and only the registration remains,
|
|
63
|
+
* which is what decides whether a sweep is owed after the removals.
|
|
64
|
+
*/
|
|
65
|
+
readonly missing: boolean
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export interface MergedPullRequest {
|
|
@@ -71,6 +82,11 @@ export interface StatusReport {
|
|
|
71
82
|
/** False when the status read itself failed, so a clean `dirty` says nothing. */
|
|
72
83
|
readonly readable: boolean
|
|
73
84
|
readonly dirty: boolean
|
|
85
|
+
/**
|
|
86
|
+
* True when no directory stands at the path. Readable and clean rather than
|
|
87
|
+
* unreadable, since nothing is there to hold work.
|
|
88
|
+
*/
|
|
89
|
+
readonly missing: boolean
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
export type ReclaimReport =
|
|
@@ -153,19 +169,33 @@ async function mergedPullRequests(cwd: string): Promise<MergedReport> {
|
|
|
153
169
|
/**
|
|
154
170
|
* Reports whether a worktree holds work no history is behind.
|
|
155
171
|
*
|
|
156
|
-
* Untracked files count
|
|
157
|
-
* removed with them takes them nowhere
|
|
158
|
-
*
|
|
159
|
-
*
|
|
172
|
+
* Untracked files count for a directory that still exists, since a worktree is
|
|
173
|
+
* gitignored scratch and a directory removed with them takes them nowhere
|
|
174
|
+
* recoverable.
|
|
175
|
+
*
|
|
176
|
+
* Three states rather than two. A directory that is gone holds nothing at all,
|
|
177
|
+
* so it reads as clean and leaves the merged and session checks to decide. A
|
|
178
|
+
* read that failed is separated from both, because it produces the same empty
|
|
179
|
+
* output as a clean tree and only one of them is safe to act on. The directory
|
|
180
|
+
* test runs first because `git status` exits 128 for a missing path and for an
|
|
181
|
+
* unreadable one alike, which folded the two into one refusal and reported
|
|
182
|
+
* every hand-deleted worktree as unreadable.
|
|
160
183
|
*/
|
|
161
184
|
async function worktreeStatus(path: string): Promise<StatusReport> {
|
|
185
|
+
if (!existsSync(path)) return { readable: true, dirty: false, missing: true }
|
|
186
|
+
|
|
162
187
|
const result = await $`git -C ${path} status --porcelain`
|
|
163
188
|
.env(gitEnv())
|
|
164
189
|
.quiet()
|
|
165
190
|
.nothrow()
|
|
166
|
-
if (result.exitCode !== 0)
|
|
191
|
+
if (result.exitCode !== 0)
|
|
192
|
+
return { readable: false, dirty: false, missing: false }
|
|
167
193
|
|
|
168
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
readable: true,
|
|
196
|
+
dirty: result.stdout.toString().trim().length > 0,
|
|
197
|
+
missing: false,
|
|
198
|
+
}
|
|
169
199
|
}
|
|
170
200
|
|
|
171
201
|
/**
|
|
@@ -195,6 +225,7 @@ function holders(
|
|
|
195
225
|
function verdict(
|
|
196
226
|
entry: WorktreeEntry,
|
|
197
227
|
isMain: boolean,
|
|
228
|
+
isCurrent: boolean,
|
|
198
229
|
status: StatusReport,
|
|
199
230
|
merged: ReadonlyMap<string, number>,
|
|
200
231
|
sessions: readonly ResolvedSession[],
|
|
@@ -206,6 +237,13 @@ function verdict(
|
|
|
206
237
|
const refusals: Refusal[] = []
|
|
207
238
|
|
|
208
239
|
if (isMain) refusals.push('main-worktree')
|
|
240
|
+
// Git removes the directory a caller is standing in without complaint, and
|
|
241
|
+
// every later call scoped to that directory then fails, so a run that took it
|
|
242
|
+
// would leave the branches behind it undeleted and report the failures as the
|
|
243
|
+
// worktrees' own. The main worktree is already refused above, so this names
|
|
244
|
+
// only a linked one.
|
|
245
|
+
else if (isCurrent) refusals.push('current-worktree')
|
|
246
|
+
|
|
209
247
|
if (entry.branch === null) refusals.push('detached-head')
|
|
210
248
|
else if (pullRequest === null) refusals.push('no-merged-pull-request')
|
|
211
249
|
|
|
@@ -226,6 +264,7 @@ function verdict(
|
|
|
226
264
|
// here rather than in the reporter keeps the record and the framed output
|
|
227
265
|
// answering the same way, since the two consumers act on different halves.
|
|
228
266
|
route: isMain ? null : held.length > 0 ? 'session' : 'worktree',
|
|
267
|
+
missing: status.missing,
|
|
229
268
|
}
|
|
230
269
|
}
|
|
231
270
|
|
|
@@ -259,11 +298,12 @@ export async function reclaimReport(
|
|
|
259
298
|
const readStatus = opts.worktreeStatus ?? worktreeStatus
|
|
260
299
|
const resolve = opts.resolve ?? resolveSessions
|
|
261
300
|
|
|
262
|
-
const [entries, merged, sessions, repository] = await Promise.all([
|
|
301
|
+
const [entries, merged, sessions, repository, current] = await Promise.all([
|
|
263
302
|
listAll(cwd),
|
|
264
303
|
readMerged(cwd),
|
|
265
304
|
resolve(),
|
|
266
305
|
repositoryOf(cwd),
|
|
306
|
+
currentWorktreeRoot(cwd),
|
|
267
307
|
])
|
|
268
308
|
|
|
269
309
|
if (merged.kind === 'unreadable') {
|
|
@@ -295,7 +335,8 @@ export async function reclaimReport(
|
|
|
295
335
|
verdict(
|
|
296
336
|
entry,
|
|
297
337
|
index === 0,
|
|
298
|
-
|
|
338
|
+
entry.path === current,
|
|
339
|
+
statuses[index] ?? { readable: false, dirty: false, missing: false },
|
|
299
340
|
byBranch,
|
|
300
341
|
sessions.sessions,
|
|
301
342
|
repository,
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
import type { WorktreeVerdict } from '@/worktrees/reclaim'
|
|
4
|
+
|
|
5
|
+
/** Which half of the removal a failure landed on, so a reader repairs the right one. */
|
|
6
|
+
export type RemovalStep = 'remove' | 'branch'
|
|
7
|
+
|
|
8
|
+
export interface RemovalOutcome {
|
|
9
|
+
readonly path: string
|
|
10
|
+
readonly branch: string | null
|
|
11
|
+
readonly removed: boolean
|
|
12
|
+
/** Null on success, so a reader branches on the step rather than on a message. */
|
|
13
|
+
readonly failedAt: RemovalStep | null
|
|
14
|
+
readonly detail: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RemovalReport {
|
|
18
|
+
readonly outcomes: readonly RemovalOutcome[]
|
|
19
|
+
/** True when the stale-registration sweep ran, which only a missing directory owes. */
|
|
20
|
+
readonly pruned: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GitResult {
|
|
24
|
+
readonly exitCode: number
|
|
25
|
+
readonly stderr: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RemovalOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Where every git call runs. Required rather than defaulted, since the one
|
|
31
|
+
* value a default could carry is the process directory and that is the unsafe
|
|
32
|
+
* one: a caller standing in a worktree this run removes loses the ground the
|
|
33
|
+
* calls after it resolve against. A compiler error is what a caller gets
|
|
34
|
+
* instead of that failure.
|
|
35
|
+
*/
|
|
36
|
+
readonly cwd: string
|
|
37
|
+
readonly git?: (cwd: string, args: readonly string[]) => Promise<GitResult>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function runGit(
|
|
41
|
+
cwd: string,
|
|
42
|
+
args: readonly string[],
|
|
43
|
+
): Promise<GitResult> {
|
|
44
|
+
const result = await $`git -C ${cwd} ${args}`.env(gitEnv()).quiet().nothrow()
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
exitCode: result.exitCode,
|
|
48
|
+
stderr: result.stderr.toString().trim(),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Removes every worktree a reading called reclaimable, and the branch behind it.
|
|
54
|
+
*
|
|
55
|
+
* The sequence is unlock, remove, then delete the branch, and it is the same
|
|
56
|
+
* whether the directory still exists or is already gone. `git worktree remove`
|
|
57
|
+
* takes a stale registration as readily as a live directory, measured at git
|
|
58
|
+
* 2.43.0, so the missing case needs no command of its own.
|
|
59
|
+
*
|
|
60
|
+
* Every command runs from `opts.cwd`, which belongs at the main worktree root
|
|
61
|
+
* and never at the directory a caller happens to stand in. A removal earlier in
|
|
62
|
+
* the run deletes that directory, and each `git -C` after it fails against a
|
|
63
|
+
* path that is gone, so the branches behind those entries survive and the run
|
|
64
|
+
* reports the failures against the worktrees rather than against the removal
|
|
65
|
+
* that pulled the ground out.
|
|
66
|
+
*
|
|
67
|
+
* The unlock runs on every entry and its result is not read, since `git worktree
|
|
68
|
+
* unlock` exits 128 with "is not locked" for the ordinary case. Reading the lock
|
|
69
|
+
* state first would buy a second parse of the porcelain to answer what the next
|
|
70
|
+
* command answers anyway, and a lock that genuinely refuses to lift surfaces as
|
|
71
|
+
* the remove failing rather than being swallowed.
|
|
72
|
+
*
|
|
73
|
+
* `git worktree prune` sweeps once after the removals rather than per entry,
|
|
74
|
+
* because it clears every stale registration in one pass and takes no path to
|
|
75
|
+
* scope it. It runs ahead of the branch deletes rather than at the very end: a
|
|
76
|
+
* branch git still reads as held by a worktree cannot be deleted, and a sweep
|
|
77
|
+
* placed after that pass would leave the branch standing with nothing left in
|
|
78
|
+
* the listing to find it by.
|
|
79
|
+
*/
|
|
80
|
+
export async function removeReclaimable(
|
|
81
|
+
verdicts: readonly WorktreeVerdict[],
|
|
82
|
+
opts: RemovalOptions,
|
|
83
|
+
): Promise<RemovalReport> {
|
|
84
|
+
const { cwd } = opts
|
|
85
|
+
const git = opts.git ?? runGit
|
|
86
|
+
const targets = verdicts.filter((entry) => entry.reclaimable)
|
|
87
|
+
|
|
88
|
+
const removals: { target: WorktreeVerdict; result: GitResult }[] = []
|
|
89
|
+
for (const target of targets) {
|
|
90
|
+
await git(cwd, ['worktree', 'unlock', target.path])
|
|
91
|
+
removals.push({
|
|
92
|
+
target,
|
|
93
|
+
result: await git(cwd, ['worktree', 'remove', target.path]),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Reported from the sweep's own exit rather than from whether it was owed, so
|
|
98
|
+
// a run that tried and failed does not read as one that cleared the tree.
|
|
99
|
+
const sweep = targets.some((target) => target.missing)
|
|
100
|
+
? await git(cwd, ['worktree', 'prune'])
|
|
101
|
+
: null
|
|
102
|
+
const pruned = sweep !== null && sweep.exitCode === 0
|
|
103
|
+
|
|
104
|
+
const outcomes: RemovalOutcome[] = []
|
|
105
|
+
for (const { target, result } of removals) {
|
|
106
|
+
outcomes.push(await deleteBranch(target, result, cwd, git))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { outcomes, pruned }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Closes one entry by deleting its branch, or reports why it was left standing.
|
|
114
|
+
*
|
|
115
|
+
* A remove that failed against a directory which still exists stops the entry
|
|
116
|
+
* here. Git refuses to delete a branch a registered worktree holds, and forcing
|
|
117
|
+
* past that strands a registration pointing at a branch that no longer exists.
|
|
118
|
+
* A missing directory carries on, since the sweep above is what clears the
|
|
119
|
+
* registration in that case and the delete reports for itself when it did not.
|
|
120
|
+
*/
|
|
121
|
+
async function deleteBranch(
|
|
122
|
+
target: WorktreeVerdict,
|
|
123
|
+
removal: GitResult,
|
|
124
|
+
cwd: string,
|
|
125
|
+
git: (cwd: string, args: readonly string[]) => Promise<GitResult>,
|
|
126
|
+
): Promise<RemovalOutcome> {
|
|
127
|
+
const { path, branch } = target
|
|
128
|
+
|
|
129
|
+
if (removal.exitCode !== 0 && !target.missing) {
|
|
130
|
+
return {
|
|
131
|
+
path,
|
|
132
|
+
branch,
|
|
133
|
+
removed: false,
|
|
134
|
+
failedAt: 'remove',
|
|
135
|
+
detail: removal.stderr || null,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A reclaimable verdict always names a branch, since a detached head is one of
|
|
140
|
+
// the refusals. The read is here so a caller passing a verdict of its own is
|
|
141
|
+
// answered rather than handed a delete with no name in it.
|
|
142
|
+
if (branch === null) {
|
|
143
|
+
return { path, branch, removed: true, failedAt: null, detail: null }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const deleted = await git(cwd, ['branch', '-D', branch])
|
|
147
|
+
if (deleted.exitCode !== 0) {
|
|
148
|
+
return {
|
|
149
|
+
path,
|
|
150
|
+
branch,
|
|
151
|
+
removed: false,
|
|
152
|
+
failedAt: 'branch',
|
|
153
|
+
detail: deleted.stderr || null,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { path, branch, removed: true, failedAt: null, detail: null }
|
|
158
|
+
}
|