@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-sweep.md +2 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/worktrees.md +62 -0
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/worktrees.ts +191 -0
- package/src/worktrees/reclaim.ts +306 -0
|
@@ -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.
|
package/docs/agents/commands.md
CHANGED
|
@@ -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 |
|
package/docs/agents/index.md
CHANGED
|
@@ -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
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
|
+
}
|