@erclx/canon 4.11.0 → 4.13.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.11.0",
4
+ "version": "4.13.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -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>"`, then move on.
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
@@ -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 |
@@ -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: 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 `git worktree remove` with a branch delete is the pair.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.11.0",
4
+ "version": "4.13.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -145,6 +145,12 @@ if (!commandCount) {
145
145
  process.exit(1)
146
146
  }
147
147
 
148
+ const MARK_SVG = (await Bun.file(`${PROJECT_ROOT}/assets/brand/mark.svg`).text()).trim()
149
+ if (!MARK_SVG) {
150
+ console.error("regen-hero: assets/brand/mark.svg read empty, refusing to write a hero with no mark")
151
+ process.exit(1)
152
+ }
153
+
148
154
  const escape = (value) =>
149
155
  value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
150
156
 
@@ -229,6 +235,7 @@ const values = {
229
235
  GOV_STACK_COUNT: String(gov.stacks.length),
230
236
  TOOLING_STACK_COUNT: String(toolingStacks.length),
231
237
  COMMAND_COUNT: String(commandCount),
238
+ MARK_SVG,
232
239
  SKILL_ENTRIES: markup(featured(skills, FEATURED_SKILLS)),
233
240
  RULE_ENTRIES: entries(deliveredRules),
234
241
  STANDARD_ENTRIES: entries(standards),
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}# Report which worktrees are reclaimable (list)${NC}`,
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`,
@@ -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 which are not')
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
- 'A worktree is reclaimable when all three hold: its branch has a merged',
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 `git worktree remove`',
75
- 'and a branch delete are the pair.',
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
- logStep('Refused')
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 the named command yourself.`,
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, the tree is clean, and no session holds 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
  }
@@ -307,7 +307,7 @@ export const TOKENS: DesignTokens = {
307
307
  'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
308
308
 
309
309
  iconography:
310
- 'No icon library is installed. The surfaces draw literal glyph characters, `✦` for the hero mark and `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing, and a custom icon has no place to load from.',
310
+ 'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
311
311
  }
312
312
 
313
313
  /** A role's value, or `undefined` where the record declares no such role. */
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(): Promise<string> {
14
- const result = await $`git rev-parse --show-toplevel`
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 process.cwd()
24
+ if (result.exitCode !== 0) return cwd
19
25
 
20
- return result.stdout.toString().trim() || process.cwd()
26
+ return result.stdout.toString().trim() || cwd
21
27
  }
22
28
 
23
29
  /**
@@ -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 { listWorktrees, type WorktreeEntry } from '@/worktree'
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, since a worktree is gitignored scratch and a directory
157
- * removed with them takes them nowhere recoverable. A failed read is separated
158
- * from a clean tree, because the two produce the same empty output and only one
159
- * of them is safe to act on.
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) return { readable: false, dirty: false }
191
+ if (result.exitCode !== 0)
192
+ return { readable: false, dirty: false, missing: false }
167
193
 
168
- return { readable: true, dirty: result.stdout.toString().trim().length > 0 }
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
- statuses[index] ?? { readable: false, dirty: false },
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
+ }