@erclx/aitk 3.30.0 → 3.31.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": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.30.0",
4
+ "version": "3.31.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -10,6 +10,8 @@ Sweep the board as orchestrator after merging. Run this once per batch of merges
10
10
  3. Re-verify every plan already written, not only the ones this sweep writes. A queued plan goes stale from whatever merged while it waited, and the loop's verify step fires at handoff rather than after a merge, so nothing else catches it. Grep each construct the plan names and count the sites against its claim, then open each file rather than trusting its account.
11
11
  4. Re-check any precondition a plan states about live state outside the repository. A remote branch, an open issue, or an installed version was true when the plan was written and is not a fact about the tree.
12
12
 
13
+ Run `aitk worktrees list` after the pull, which is the first point at which the merges this sweep followed are readable. It owns the reclaim rule and it removes nothing, so read its rows and act on the ones you mean to. Never substitute a git ancestry check for it.
14
+
13
15
  Run `aitk tasks validate` once the board is rewritten and before reporting it. It resolves every plan pointer, accounts every task file against the board and the backlog both, tests the `## Run now` file sets for overlap, and re-takes the two blocker kinds a command can settle. It reports and never writes, so fix each row it names and run it again. A finding it reports is a board defect rather than a task finding, so it goes nowhere but the board.
14
16
 
15
17
  Then run `orchestrator-parked.md` over the rows the validator listed as untested. Those carry the three blocker kinds no command settles, and the merge this sweep followed changed the tree under every one of them at once while the refill above re-read none. Take the untested rows alone rather than the whole board, since the validator already answered the rest.
@@ -42,6 +42,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
42
42
  | `aitk records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
43
43
  | `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
44
44
  | `aitk sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
45
+ | `aitk worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
45
46
  | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
46
47
  | `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
47
48
  | `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
@@ -39,3 +39,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
39
39
  - [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
40
40
  - [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
41
41
  - [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
42
+ - [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
@@ -0,0 +1,62 @@
1
+ ---
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
4
+ ---
5
+
6
+ # Worktrees
7
+
8
+ ## List
9
+
10
+ `aitk worktrees list` reports every worktree of the current repository with a reclaim verdict and the reason behind it.
11
+
12
+ ```bash
13
+ aitk worktrees list
14
+ aitk worktrees list --json
15
+ ```
16
+
17
+ | Option | Behavior |
18
+ | -------- | --------------------------------------- |
19
+ | `--json` | Add a machine-readable record on stdout |
20
+
21
+ It reads and removes nothing. The question it answers is which worktrees the work has finished with, which nothing in the loop asks: a worktree is created per feature and removed by nobody, so directories left by shipped work accumulate for the life of the checkout.
22
+
23
+ Exit codes: `0` every worktree was read, `1` refused. The refusal carries a `reason` of `gh-missing`, `gh-failed`, or `sessions-unreadable`.
24
+
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
+
27
+ ## What makes a worktree reclaimable
28
+
29
+ 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.
30
+
31
+ | Refusal | What it means |
32
+ | ------------------------ | ------------------------------------------------------- |
33
+ | `main-worktree` | The main worktree, which is never reclaimable |
34
+ | `detached-head` | No branch, so nothing names a pull request |
35
+ | `no-merged-pull-request` | Its branch has no merged pull request |
36
+ | `uncommitted-changes` | Work no history stands behind, untracked files included |
37
+ | `unreadable-worktree` | The working tree status could not be read |
38
+ | `held-by-session` | A live session still holds the directory |
39
+
40
+ 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
+
42
+ ## Why the pull request rather than git ancestry
43
+
44
+ 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.
45
+
46
+ The one branch it did call merged was the one that had to stay. That branch had no pull request at all, sat at a release commit, and its worktree held finished work outside any commit. So the cheap test kept every directory safe to remove and offered the only one that was not.
47
+
48
+ The read is one `gh pr list --state merged` for the whole repository rather than one call per worktree, which would be a network round trip inside a loop. It covers the most recent 200 merges, so a worktree older than that reads as having none and is refused, which fails in the direction that keeps a directory.
49
+
50
+ ## Two removal shapes
51
+
52
+ `route` names which one applies rather than choosing it, since picking wrong strands state.
53
+
54
+ - `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.
56
+ - `null`: the main worktree, which no removal shape reaches.
57
+
58
+ 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.
59
+
60
+ ## What an unreadable input does
61
+
62
+ It refuses the whole report rather than producing verdicts around the gap. An absent merge state and a branch with no merged pull request return the same empty answer, and so do an absent session roster and a worktree nobody holds. Reporting the second when it was the first is a false clean, and here that ends in a removal rather than in a warning.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.30.0",
4
+ "version": "3.31.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -27,6 +27,7 @@ import { register as context } from '@/commands/context'
27
27
  import { register as markdown } from '@/commands/markdown'
28
28
  import { register as records } from '@/commands/records'
29
29
  import { register as sessions } from '@/commands/sessions'
30
+ import { register as worktrees } from '@/commands/worktrees'
30
31
  import { register as audits } from '@/commands/audits'
31
32
  import { register as secrets } from '@/commands/secrets'
32
33
  import { register as deps } from '@/commands/deps'
@@ -71,6 +72,7 @@ function showHelp(): void {
71
72
  `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
72
73
  `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, size, push, pull)${NC}`,
73
74
  `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
75
+ `${GREY}│${NC} worktrees [cmd] ${GREY}# Report which worktrees are reclaimable (list)${NC}`,
74
76
  `${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
75
77
  `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
76
78
  `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
@@ -117,6 +119,7 @@ function showHelp(): void {
117
119
  `${GREY}│${NC} aitk records size --json`,
118
120
  `${GREY}│${NC} aitk records push --json`,
119
121
  `${GREY}│${NC} aitk sessions list --json`,
122
+ `${GREY}│${NC} aitk worktrees list --json`,
120
123
  `${GREY}│${NC} aitk secrets scan --json`,
121
124
  `${GREY}│${NC} aitk deps audit --json`,
122
125
  `${GREY}│${NC} aitk labels audit --json`,
@@ -167,6 +170,7 @@ context(program)
167
170
  markdown(program)
168
171
  records(program)
169
172
  sessions(program)
173
+ worktrees(program)
170
174
  secrets(program)
171
175
  deps(program)
172
176
  labels(program)
@@ -0,0 +1,191 @@
1
+ import type { Command } from 'commander'
2
+ import {
3
+ intro,
4
+ logInfo,
5
+ logStep,
6
+ logWarn,
7
+ outro,
8
+ pipeOutput,
9
+ plural,
10
+ } from '@/ui'
11
+ import {
12
+ type Refusal,
13
+ reclaimReport,
14
+ type Unreadable,
15
+ type WorktreeVerdict,
16
+ } from '@/worktrees/reclaim'
17
+
18
+ interface ListCommandOptions {
19
+ readonly json?: boolean
20
+ }
21
+
22
+ const REFUSALS: Record<Refusal, string> = {
23
+ 'main-worktree': 'the main worktree, which is never reclaimable',
24
+ 'detached-head': 'detached HEAD, so no branch names a pull request',
25
+ 'no-merged-pull-request': 'no merged pull request for its branch',
26
+ 'uncommitted-changes': 'uncommitted work no history stands behind',
27
+ 'unreadable-worktree': 'the working tree could not be read',
28
+ 'held-by-session': 'a live session still holds it',
29
+ }
30
+
31
+ const UNREADABLE: Record<Unreadable, string> = {
32
+ 'gh-missing':
33
+ 'gh is not on the path, so the merge state could not be read. Install it and run this again.',
34
+ 'gh-failed':
35
+ 'The pull request read failed, so every worktree would report as unmerged whatever its real state. Authenticate gh and run this again.',
36
+ 'sessions-unreadable':
37
+ 'The session roster could not be read, so nothing was read about which worktrees are still held.',
38
+ }
39
+
40
+ export function register(program: Command): void {
41
+ const worktrees = program
42
+ .command('worktrees')
43
+ .description('Report which worktrees are reclaimable and which are not')
44
+ .helpOption('-h, --help', 'Show this help message')
45
+
46
+ worktrees
47
+ .command('list')
48
+ .description(
49
+ 'Report every worktree with a reclaim verdict and the reason behind it',
50
+ )
51
+ .helpOption('-h, --help', 'Show this help message')
52
+ .option('--json', 'Add a machine-readable record on stdout')
53
+ .addHelpText(
54
+ 'after',
55
+ [
56
+ '',
57
+ 'Exit codes:',
58
+ ' 0 every worktree was read',
59
+ ' 1 refused, with the reason on stderr',
60
+ '',
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.',
70
+ '',
71
+ 'This reports and removes nothing. "route" names which removal shape',
72
+ 'applies: "session" when a live session holds the directory, where',
73
+ '`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.',
84
+ '',
85
+ 'Examples:',
86
+ ' aitk worktrees list',
87
+ ' aitk worktrees list --json',
88
+ '',
89
+ ].join('\n'),
90
+ )
91
+ .action(async (opts: ListCommandOptions) => {
92
+ process.exitCode = await runList(opts)
93
+ })
94
+ }
95
+
96
+ async function runList(opts: ListCommandOptions): Promise<number> {
97
+ const report = await reclaimReport({ cwd: process.cwd() })
98
+
99
+ intro('aitk worktrees list')
100
+
101
+ if (report.kind === 'unreadable') {
102
+ logStep('Refused')
103
+ logWarn(UNREADABLE[report.reason])
104
+ logInfo(report.detail)
105
+ outro()
106
+
107
+ if (opts.json) {
108
+ process.stdout.write(
109
+ `${JSON.stringify({
110
+ reason: report.reason,
111
+ detail: report.detail,
112
+ worktrees: [],
113
+ })}\n`,
114
+ )
115
+ }
116
+
117
+ return 1
118
+ }
119
+
120
+ reportWorktrees(report.worktrees)
121
+ outro()
122
+
123
+ if (opts.json) {
124
+ process.stdout.write(
125
+ `${JSON.stringify({
126
+ reason: null,
127
+ detail: null,
128
+ worktrees: report.worktrees,
129
+ })}\n`,
130
+ )
131
+ }
132
+
133
+ return 0
134
+ }
135
+
136
+ function reportWorktrees(worktrees: readonly WorktreeVerdict[]): void {
137
+ logStep('Worktrees')
138
+
139
+ if (worktrees.length === 0) {
140
+ logInfo('No worktree resolved here. Run this inside a repository.')
141
+ return
142
+ }
143
+
144
+ logInfo(plural(worktrees.length, 'worktree'))
145
+ pipeOutput(worktrees.map(describe).join('\n'))
146
+
147
+ const reclaimable = worktrees.filter((entry) => entry.reclaimable)
148
+
149
+ logStep('Reclaimable')
150
+ if (reclaimable.length === 0) {
151
+ logInfo('None. Every worktree fails at least one condition.')
152
+ return
153
+ }
154
+
155
+ logWarn(
156
+ `${plural(reclaimable.length, 'worktree')} can be reclaimed. This reports and removes nothing, so run the named command yourself.`,
157
+ )
158
+ }
159
+
160
+ /**
161
+ * Names the evidence beside the conclusion on every row.
162
+ *
163
+ * The reading has two consumers and they act on different halves. The sweep
164
+ * reads the verdict to decide what to offer, and a person reads it to decide
165
+ * what to remove, so a row phrased for the first alone leaves the second
166
+ * removing a directory on a conclusion it cannot check.
167
+ */
168
+ function describe(verdict: WorktreeVerdict): string {
169
+ const held = verdict.branch ?? 'detached'
170
+ const head = `${verdict.path} ${held}`
171
+
172
+ if (!verdict.reclaimable) {
173
+ const reasons = verdict.refusals.map((refusal) => REFUSALS[refusal])
174
+ // A name is quoted because a session carries whatever string it was
175
+ // launched under, spaces included, and one command per name because a
176
+ // joined list reads as a single argument.
177
+ const routes =
178
+ verdict.route === 'session'
179
+ ? verdict.sessions.map(
180
+ (name) => `\n Removal there goes through: claude rm '${name}'`,
181
+ )
182
+ : []
183
+ return `${head}\n Refused: ${reasons.join('; ')}.${routes.join('')}`
184
+ }
185
+
186
+ return [
187
+ 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}`,
190
+ ].join('\n')
191
+ }
@@ -0,0 +1,306 @@
1
+ import { $ } from 'bun'
2
+ import { execa } from 'execa'
3
+ import { gitEnv } from '@/git-env'
4
+ import {
5
+ repositoryOf,
6
+ type ResolvedSession,
7
+ resolveSessions,
8
+ type SessionReport,
9
+ } from '@/sessions/resolve'
10
+ import { listWorktrees, type WorktreeEntry } from '@/worktree'
11
+
12
+ const GH_TIMEOUT_MS = 30_000
13
+
14
+ /**
15
+ * How many merged pull requests one read covers. A worktree older than this
16
+ * many merges reads as having none and is refused, which is the safe direction:
17
+ * the failure keeps a directory rather than removing one.
18
+ */
19
+ const MERGED_LIMIT = 200
20
+
21
+ /** Why one worktree cannot be reclaimed, one entry per failing condition. */
22
+ export type Refusal =
23
+ | 'main-worktree'
24
+ | 'detached-head'
25
+ | 'no-merged-pull-request'
26
+ | 'uncommitted-changes'
27
+ | 'unreadable-worktree'
28
+ | 'held-by-session'
29
+
30
+ /** Why the whole reading was refused, so no verdict was produced at all. */
31
+ export type Unreadable = 'gh-missing' | 'gh-failed' | 'sessions-unreadable'
32
+
33
+ /**
34
+ * Which removal shape applies. `session` removes the background session and its
35
+ * worktree together, and `worktree` removes a directory whose session has
36
+ * ended. Picking the wrong one strands state, so this is reported rather than
37
+ * assumed.
38
+ */
39
+ export type Route = 'session' | 'worktree'
40
+
41
+ /** No removal shape reaches the main worktree, which is what `null` says. */
42
+ export type RemovalRoute = Route | null
43
+
44
+ export interface WorktreeVerdict {
45
+ readonly path: string
46
+ readonly branch: string | null
47
+ readonly reclaimable: boolean
48
+ /** Every failing condition, so a reader sees what to fix rather than a bare refusal. */
49
+ readonly refusals: readonly Refusal[]
50
+ /** The pull request that retired the branch, so a report can name what it read. */
51
+ readonly pullRequest: number | null
52
+ /** The names of the live sessions holding this worktree, which is what `claude rm` takes. */
53
+ readonly sessions: readonly string[]
54
+ readonly route: RemovalRoute
55
+ }
56
+
57
+ export interface MergedPullRequest {
58
+ readonly branch: string
59
+ readonly number: number
60
+ }
61
+
62
+ export type MergedReport =
63
+ | { readonly kind: 'read'; readonly merged: readonly MergedPullRequest[] }
64
+ | {
65
+ readonly kind: 'unreadable'
66
+ readonly reason: Extract<Unreadable, 'gh-missing' | 'gh-failed'>
67
+ readonly detail: string
68
+ }
69
+
70
+ export interface StatusReport {
71
+ /** False when the status read itself failed, so a clean `dirty` says nothing. */
72
+ readonly readable: boolean
73
+ readonly dirty: boolean
74
+ }
75
+
76
+ export type ReclaimReport =
77
+ | {
78
+ readonly kind: 'unreadable'
79
+ readonly reason: Unreadable
80
+ readonly detail: string
81
+ }
82
+ | { readonly kind: 'read'; readonly worktrees: readonly WorktreeVerdict[] }
83
+
84
+ export interface ReclaimOptions {
85
+ readonly cwd?: string
86
+ readonly listWorktrees?: (cwd: string) => Promise<readonly WorktreeEntry[]>
87
+ readonly mergedPullRequests?: (cwd: string) => Promise<MergedReport>
88
+ readonly worktreeStatus?: (path: string) => Promise<StatusReport>
89
+ readonly resolve?: () => Promise<SessionReport>
90
+ }
91
+
92
+ /**
93
+ * Reads the pull request state for the whole repository in one call.
94
+ *
95
+ * One call rather than one per worktree, since the per-worktree shape is a
96
+ * network round trip inside a loop and the branches being matched are already
97
+ * known before any of them runs.
98
+ */
99
+ async function mergedPullRequests(cwd: string): Promise<MergedReport> {
100
+ if (Bun.which('gh') === null) {
101
+ return {
102
+ kind: 'unreadable',
103
+ reason: 'gh-missing',
104
+ detail: 'gh is not on the path, so no merge state could be read.',
105
+ }
106
+ }
107
+
108
+ const args = [
109
+ 'pr',
110
+ 'list',
111
+ '--state',
112
+ 'merged',
113
+ '--limit',
114
+ String(MERGED_LIMIT),
115
+ '--json',
116
+ 'headRefName,number',
117
+ ]
118
+
119
+ try {
120
+ // `gh` resolves its repository through git, so it reads the same
121
+ // resolution variables a hook exports and they take precedence over `cwd`.
122
+ // A run from inside one would answer with another repository's merged
123
+ // branches, and a branch name that recurs across repositories would then
124
+ // match a merge that happened somewhere else and read a live worktree as
125
+ // reclaimable, which is the unsafe direction on an unrecoverable removal.
126
+ const result = await execa('gh', args, {
127
+ cwd,
128
+ timeout: GH_TIMEOUT_MS,
129
+ env: gitEnv(),
130
+ extendEnv: false,
131
+ })
132
+ const rows = JSON.parse(result.stdout) as readonly {
133
+ headRefName: string
134
+ number: number
135
+ }[]
136
+
137
+ return {
138
+ kind: 'read',
139
+ merged: rows.map((row) => ({
140
+ branch: row.headRefName,
141
+ number: row.number,
142
+ })),
143
+ }
144
+ } catch (error) {
145
+ return {
146
+ kind: 'unreadable',
147
+ reason: 'gh-failed',
148
+ detail: error instanceof Error ? error.message : String(error),
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Reports whether a worktree holds work no history is behind.
155
+ *
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.
160
+ */
161
+ async function worktreeStatus(path: string): Promise<StatusReport> {
162
+ const result = await $`git -C ${path} status --porcelain`
163
+ .env(gitEnv())
164
+ .quiet()
165
+ .nothrow()
166
+ if (result.exitCode !== 0) return { readable: false, dirty: false }
167
+
168
+ return { readable: true, dirty: result.stdout.toString().trim().length > 0 }
169
+ }
170
+
171
+ /**
172
+ * Names the live sessions holding one worktree.
173
+ *
174
+ * The path match is the direct reading and the branch match is what survives a
175
+ * path spelled differently on either side, such as a symlinked temporary
176
+ * directory. The branch half is scoped to the repository, since a branch name
177
+ * identifies a branch inside one and nothing across a machine.
178
+ */
179
+ function holders(
180
+ entry: WorktreeEntry,
181
+ sessions: readonly ResolvedSession[],
182
+ repository: string | null,
183
+ ): readonly string[] {
184
+ return sessions
185
+ .filter(
186
+ (candidate) =>
187
+ candidate.worktree === entry.path ||
188
+ (entry.branch !== null &&
189
+ candidate.branch === entry.branch &&
190
+ candidate.repository === repository),
191
+ )
192
+ .map((candidate) => candidate.name)
193
+ }
194
+
195
+ function verdict(
196
+ entry: WorktreeEntry,
197
+ isMain: boolean,
198
+ status: StatusReport,
199
+ merged: ReadonlyMap<string, number>,
200
+ sessions: readonly ResolvedSession[],
201
+ repository: string | null,
202
+ ): WorktreeVerdict {
203
+ const held = holders(entry, sessions, repository)
204
+ const pullRequest =
205
+ entry.branch === null ? null : (merged.get(entry.branch) ?? null)
206
+ const refusals: Refusal[] = []
207
+
208
+ if (isMain) refusals.push('main-worktree')
209
+ if (entry.branch === null) refusals.push('detached-head')
210
+ else if (pullRequest === null) refusals.push('no-merged-pull-request')
211
+
212
+ if (!status.readable) refusals.push('unreadable-worktree')
213
+ else if (status.dirty) refusals.push('uncommitted-changes')
214
+
215
+ if (held.length > 0) refusals.push('held-by-session')
216
+
217
+ return {
218
+ path: entry.path,
219
+ branch: entry.branch,
220
+ reclaimable: refusals.length === 0,
221
+ refusals,
222
+ pullRequest,
223
+ sessions: held,
224
+ // No removal shape reaches the main worktree, and reporting one there
225
+ // offers a command whose only effect is to break the checkout. Deciding it
226
+ // here rather than in the reporter keeps the record and the framed output
227
+ // answering the same way, since the two consumers act on different halves.
228
+ route: isMain ? null : held.length > 0 ? 'session' : 'worktree',
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Reports which worktrees are reclaimable and which are not, with the reason on
234
+ * each.
235
+ *
236
+ * Reclaimable means all three of a merged pull request, a clean working tree,
237
+ * and no live session holding the directory. Each alone has a case where
238
+ * removal loses something, and removal is unrecoverable here since a worktree
239
+ * is gitignored and no history stands behind it.
240
+ *
241
+ * The pull request is what decides the first condition rather than git
242
+ * ancestry. A repository that squash merges never makes a merged branch an
243
+ * ancestor of its trunk, so the ancestry reading calls shipped work unmerged
244
+ * and calls an abandoned branch sitting at a release commit merged, which is
245
+ * wrong in the one direction that removes a directory.
246
+ *
247
+ * An unreadable input refuses the whole reading rather than producing verdicts
248
+ * around it. An absent merge state and a branch with no merged pull request
249
+ * produce the same empty answer, as do an absent session roster and a worktree
250
+ * nobody holds, and reporting the second when it was the first is a false clean
251
+ * that ends in a removal.
252
+ */
253
+ export async function reclaimReport(
254
+ opts: ReclaimOptions = {},
255
+ ): Promise<ReclaimReport> {
256
+ const cwd = opts.cwd ?? process.cwd()
257
+ const listAll = opts.listWorktrees ?? listWorktrees
258
+ const readMerged = opts.mergedPullRequests ?? mergedPullRequests
259
+ const readStatus = opts.worktreeStatus ?? worktreeStatus
260
+ const resolve = opts.resolve ?? resolveSessions
261
+
262
+ const [entries, merged, sessions, repository] = await Promise.all([
263
+ listAll(cwd),
264
+ readMerged(cwd),
265
+ resolve(),
266
+ repositoryOf(cwd),
267
+ ])
268
+
269
+ if (merged.kind === 'unreadable') {
270
+ return {
271
+ kind: 'unreadable',
272
+ reason: merged.reason,
273
+ detail: merged.detail,
274
+ }
275
+ }
276
+
277
+ if (sessions.kind !== 'resolved') {
278
+ return {
279
+ kind: 'unreadable',
280
+ reason: 'sessions-unreadable',
281
+ detail: `No session registry at ${sessions.dir}, so nothing was read about which worktrees are still held.`,
282
+ }
283
+ }
284
+
285
+ const byBranch = new Map(
286
+ merged.merged.map((request) => [request.branch, request.number]),
287
+ )
288
+ const statuses = await Promise.all(
289
+ entries.map((entry) => readStatus(entry.path)),
290
+ )
291
+
292
+ // `git worktree list` puts the main worktree first, which is the only signal
293
+ // separating it from a linked one in the porcelain output.
294
+ const worktrees = entries.map((entry, index) =>
295
+ verdict(
296
+ entry,
297
+ index === 0,
298
+ statuses[index] ?? { readable: false, dirty: false },
299
+ byBranch,
300
+ sessions.sessions,
301
+ repository,
302
+ ),
303
+ )
304
+
305
+ return { kind: 'read', worktrees }
306
+ }