@erclx/aitk 3.30.0 → 3.32.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-autoship/SKILL.md +11 -5
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +4 -4
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: claude-autoship
|
|
3
|
-
description: Chains implement → verify → review → ship after a feature plan is approved. Reads the plan for the current branch, runs the full pipeline in one session, and stops on any failure or non-minor review finding. Use when asked to "autoship", "ship this feature end to end", or "run the chain". Do NOT auto-trigger. Requires an approved plan file.
|
|
3
|
+
description: Chains implement → verify → review → ship after a feature plan is approved. Reads the plan the caller names, or the plan for the current branch when none is named, runs the full pipeline in one session, and stops on any failure or non-minor review finding. Use when asked to "autoship", "ship this feature end to end", or "run the chain". Do NOT auto-trigger. Requires an approved plan file.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -11,8 +11,8 @@ Chain the post-plan pipeline in a single run. Every step has a stop condition. S
|
|
|
11
11
|
## Guards
|
|
12
12
|
|
|
13
13
|
- All `.claude/plans/` and `.claude/review/` reads resolve at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`.
|
|
14
|
-
- Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.`
|
|
15
|
-
-
|
|
14
|
+
- Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.` Every later step keys its output on this slug, being the worktree, the review receipt, the branch, and the memory proposal, regardless of which plan Step 1 reads.
|
|
15
|
+
- Resolve `<plan>` in Step 1, ahead of any other read.
|
|
16
16
|
- If the working tree has uncommitted changes unrelated to the plan, stop: `❌ Uncommitted changes outside the plan. Commit or stash before autoshipping.`
|
|
17
17
|
|
|
18
18
|
## Diff baseline
|
|
@@ -39,7 +39,12 @@ If the two commands differ, the session is already in a linked worktree. Continu
|
|
|
39
39
|
|
|
40
40
|
## Step 1: read the plan
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Resolve `<plan>` in this order, stopping at the first match:
|
|
43
|
+
|
|
44
|
+
1. **Caller-supplied.** The invocation carried an argument. Accept it as a plan path or a bare slug, in the same position `claude-worktree` tier 0 accepts its name. A bare slug resolves to `.claude/plans/feature-<slug>.md`, and a path is taken as given, relative to the main worktree root. If it does not resolve to a file, stop: `❌ No plan at <path>. Path was supplied, not derived, so check it and re-run.`
|
|
45
|
+
2. **Derived.** `.claude/plans/feature-<slug>.md`, from the `<slug>` the Guards derived. If it does not exist, stop: `❌ No approved plan at .claude/plans/feature-<slug>.md. Run /claude-feature first.`
|
|
46
|
+
|
|
47
|
+
Read `<plan>` at the main worktree root. This file is the scope for this run.
|
|
43
48
|
|
|
44
49
|
Its sections and its answer contract are fixed by `${CLAUDE_SKILL_DIR}/../../standards/plan.md`. A blank `- Answer:` accepts the `- Suggested:` line above it, so an unanswered question is a decision this run executes rather than a reason to stop.
|
|
45
50
|
|
|
@@ -152,7 +157,8 @@ Every stop point leaves recoverable state. The user resumes manually from the ap
|
|
|
152
157
|
|
|
153
158
|
| Stop point | Recovery |
|
|
154
159
|
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
155
|
-
| No plan
|
|
160
|
+
| No plan (derived) | Run `/claude-feature` to create one |
|
|
161
|
+
| No plan (caller-supplied) | Check the path or slug passed to autoship, then re-run |
|
|
156
162
|
| No diff baseline | Fetch origin so a merge base resolves against `main`, then re-run autoship |
|
|
157
163
|
| Empty changed-file list | Re-run once the plan produces tracked output. Ship gitignored output outside the chain, never by tracking it. |
|
|
158
164
|
| Branch collision on worktree entry | `claude-worktree` Step 5 found `<slug>` already as a local branch. Resolve manually (rename or delete the stale branch), then re-run autoship. |
|
|
@@ -7,9 +7,9 @@ Run this at loop step 4, for a `## Run now` row whose plan is verified and whose
|
|
|
7
7
|
|
|
8
8
|
## Derive the candidate
|
|
9
9
|
|
|
10
|
-
Resolve `<slug>` from the row's plan the way `claude-worktree` Step 2 resolves a plan-matched name, per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Resolve `<type>` off that plan's `## Summary` and `**Files to touch:**` lines, per `${CLAUDE_SKILL_DIR}/../../standards/branch.md`, defaulting to `feat` when the lines settle nothing. The candidate branch is `<type>/<slug>`.
|
|
10
|
+
Resolve `<slug>` from `<plan>`, the row's plan file, the way `claude-worktree` Step 2 resolves a plan-matched name, per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Resolve `<type>` off that plan's `## Summary` and `**Files to touch:**` lines, per `${CLAUDE_SKILL_DIR}/../../standards/branch.md`, defaulting to `feat` when the lines settle nothing. The candidate branch is `<type>/<slug>`.
|
|
11
11
|
|
|
12
|
-
This is the branch the worker takes, not a guess at one it will derive for itself. Carry the exact string into the launch below. Both halves of that derivation have already disagreed in production: one run checked `docs/remaining-skill-verdicts` against a worker that took `docs/skill-verdicts-decide`, and a later one checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`. A check against a branch nobody uses verifies nothing, and the run
|
|
12
|
+
This is the branch the worker takes, not a guess at one it will derive for itself. Carry the exact string into the launch below. Both halves of that derivation have already disagreed in production: one run checked `docs/remaining-skill-verdicts` against a worker that took `docs/skill-verdicts-decide`, and a later one checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`. A check against a branch nobody uses verifies nothing, and a slug mismatch no longer fails the run downstream on its own, since `claude-autoship` now takes `<plan>` directly rather than resolving it from the worker's own branch. The check above is what has to catch a wrong candidate now.
|
|
13
13
|
|
|
14
14
|
## Check the branch is unclaimed
|
|
15
15
|
|
|
@@ -46,12 +46,12 @@ Name `<model>` on the launch, and pick it against the task rather than copying w
|
|
|
46
46
|
## Dispatch
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
claude --bg --model <model> -n "orchestrator-<slug>" "Run /aitk:claude-worktree <type>/<slug>, then /aitk:claude-autoship
|
|
49
|
+
claude --bg --model <model> -n "orchestrator-<slug>" "Run /aitk:claude-worktree <type>/<slug>, then /aitk:claude-autoship <plan>"
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
`--bg, --background` starts the session as a background agent and returns immediately, `-n, --name` sets the display name `aitk sessions list` reads back for the worker cap, and `--model` overrides the inheritance the section above measured.
|
|
53
53
|
|
|
54
|
-
The worktree call comes first and carries the branch as its argument, which is tier 0 of `claude-worktree` Step 2 and the only tier a caller can reach. `claude-autoship` Step 0 then finds the session already in a linked worktree and continues, which is a path it already documents.
|
|
54
|
+
The worktree call comes first and carries the branch as its argument, which is tier 0 of `claude-worktree` Step 2 and the only tier a caller can reach. `claude-autoship` Step 0 then finds the session already in a linked worktree and continues, which is a path it already documents. The autoship call carries `<plan>`, the same file this runbook already read to derive the branch, so its Step 1 takes it as the caller-supplied plan rather than re-deriving one from the slug the worker's branch happens to carry.
|
|
55
55
|
|
|
56
56
|
Naming the branch in prose instead was tried and closes nothing, because no tier of that ladder reads the prompt. A worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there. Four workers took the right branch that way, by inference rather than by contract, which is the same judgment both live disagreements came from.
|
|
57
57
|
|
|
@@ -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
|
+
}
|