Skip to content

feat: create worktrees from GitHub issues - #107

Merged
justin13888 merged 9 commits into
masterfrom
feat/100-issue-worktrees
Aug 29, 2026
Merged

justin13888 merged 9 commits into
masterfrom
feat/100-issue-worktrees

Conversation

@justin13888

Copy link
Copy Markdown
Contributor

Brings wt issue to master in the shape #100 specifies, wired to the
wt::naming module #96 landed and the fallback #98 asks for.

Closes #96
Closes #98
Closes #100

Why these three together

wt issue does not exist on master. It lives only in the still-open
#92, which is 9 commits behind a master that has since renamed
worktree_service.rs → worktree/, added the Workspace API, cargo
feature gates and the advisory repo lock. That collapses three issues
into one deliverable:

What wt issue does

Fetches the issue, proposes a conventional type/123-slug branch and a short
implementation brief, lets you edit both, creates (or reuses) the worktree, and
records the link. Then it stops — no agent is launched. Handing the work to
an agent is karet's job over ACP.

Generation is best-effort, which is the whole point of #98:

generation outcome branch brief
valid as generated as generated
malformed branch naming::fallback + a note kept — independent of the branch
unavailable / timeout / unparseable / errored naming::fallback + a note empty

The only thing that still fails is a bad --model/--effort value, which is
the user's to fix. Worktree creation never depends on model behaviour.

The TYPE/{number}-SLUG contract comes from wt::naming: the prompt fragment
the model is given and the validator its answer is checked against are the same
code, so they cannot drift.

Decisions worth reviewing

The contract binds the model, not the user. After the review prompts, an
edited branch only has to be a legal git branch. The issue link lives in
wt.<branch>.issueNumber, never in the branch name, so an off-contract name
still resolves back to its issue — it gets a note, not an error. Swapping the
note for a ? is a one-line change if you'd rather it were strict.

The brief is persisted as wt.<branch>.issueBrief, so karet can read it
through the library rather than paying to regenerate it.

No TUI issue surface. #92 added a picker whose Enter key suspended ratatui
and handed the terminal to the agent; that path is what #100 deletes, so none of
it is ported. Column::Issue (opt-in, deliberately outside Column::ALL),
Worktree::issue and the JSON issue field are.

No inherited-stdio special case in the shell wrappers. #92 forced wt issue
onto the WT_CD_FILE handoff because the agent needed a real terminal. The
wrappers capture stdout only — stdin and stderr already reach the prompts,
exactly as wt new's own prompts rely on. Forcing it would also make stdout a
TTY, so the path would be printed and cd'd into: double output. A regression
test asserts no wrapper reintroduces it.

Locking. Recording the link is a locked read-check-write against a freshly
discovered
repository: create_in writes metadata through the git subprocess,
but gix snapshots config at open, so the session handle is stale. It also cannot
use Workspace::write_meta, which takes the lock itself and so cannot enclose
the preceding read — the advisory lock is not reentrant.

Not breaking

[agent.generation] is added as the canonical home for the generation profile,
but the released flat agent.model / agent.effort keys keep working as
deprecated aliases onto the same layer fields, and wt pr open --model/--effort
keep their names. Defaults are unchanged (Claude / Sonnet / Medium), so the AI
PR auto-fill behaves exactly as before. No major bump.

[agent.work] is refused with a pointer to karet — a new-key rejection, not a
removal.

Deliberately deferred

#100's "delete spec::prompt_argv / apply_effort / parse_result" bullet is
not done, on purpose.
It describes them as dead code left by the agent-text
migration — but that migration has not landed here, and on master all three
have a live production caller
(run_with in src/agent/mod.rs). Deleting
them would delete working behaviour.

Adopting agent-text was considered and rejected for this PR: it needs the
first #[cfg(feature = "agent")] in the tree, makes tokio a hard transitive
dependency (the opposite of #93's lean-library goal), costs AgentModel its
Copy across four TUI files, and carries #92's silent default changes
(Sonnet→Haiku, Medium→Low, Codex-by-default) that would break wt pr open --ai
for existing users. I'll file a follow-up issue carrying that bullet.

Also not included: wt issue --json, --start, and the base-staleness
pre-flight that wt new runs.

One defect found and fixed in review

preview_target (added for the confirmation prompt) derived the directory
slug's fallback hash from the branch's own tip, while create_in derives it
from the base the branch forks from — so for a new branch they disagreed. It
only surfaces for a branch name that slugifies to nothing (_, say), but that
is exactly where it matters: the prompt would confirm repo- and then create
repo-<basehash>. Both now share one target_slug.

Two tests, because one is not enough: the first pins that preview and creation
agree, but sharing a helper makes that true even if the shared rule is wrong.
The second pins the rule itself and fails with repo- if it regresses.

Validation

Every command run on this branch:

gate result
mise run test 878 passed, 0 failed
mise run lint clean (-D warnings)
mise run format-check clean
mise run check-core 361 (--no-default-features) + 621 (--features cli) passed
mise run coverage 92.64% lines (gate: 80%)
convco check origin/master..HEAD no errors in 8 commits

commands/issue.rs is at 97.3% line coverage. #98's four required cases —
valid generation, malformed branch, agent unavailable, agent timeout — each have
a test, and the timeout one exercises real production code rather than a fake:
sh -c "sleep 30" with a 100 ms deadline returns in well under a second.

check-core earned its keep twice here: it caught default_base_ref needing to
move from the TUI re-export group to the CLI one, and a new test referencing a
cli-gated function without being gated itself.

Adds the issue half of the `gh` boundary, so a later `wt issue` can fetch
an issue's title, body, labels, type and milestone without reaching past
the `GhClient` seam.

`list_open_issues` and `view_issue` are *defaulted* trait methods that
return `Error::GhUnavailable`, rather than required ones: a third-party
`GhClient` implementation stays source-compatible and only opts in when
it actually supports the issue workflow.

Every optional field on the new types carries `#[serde(default)]`, so a
sparse `gh` response — a repository that uses no issue types, labels or
milestones — parses rather than failing. The requested field set is
deliberately narrow: comments, assignees, reactions and project data are
not fetched, since nothing downstream reads them and they dominate the
token cost of an issue.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
Adds the `wt.<branch>.issue*` metadata a worktree needs to remember which
GitHub issue it belongs to, and surfaces it on the row model.

The link deliberately lives in git config rather than in the branch name,
so a branch that does not follow the `TYPE/{number}-SLUG` convention is
still resolvable back to its issue. `issueBrief` persists the generated
implementation brief so an embedder (karet) can read it back instead of
paying to regenerate it.

`Column::Issue` is deliberately absent from `Column::ALL`: it is opt-in
via `list.columns`, so the default table is unchanged. A test pins that
intent, since adding the variant to `ALL` is an easy and silent mistake.

`MetaUpdate` gains the four matching fields, and the two existing PR call
sites now spread `..MetaUpdate::default()` rather than naming every
field — a PR checkout leaves an existing issue link untouched instead of
clobbering it.

No `wt.schema` bump: the keys are purely additive, and `read_meta` maps
missing keys to `None`, which `SCHEMA_VERSION`'s own contract names as
the additive-safe case. `clear_meta` needs no change either, since it
removes the whole `wt.<branch>` section; its test now writes every key
the section can hold to prove that.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
Gives the generation agent a named profile, so the settings `wt` owns
have an obvious home and a place for `wt issue` to read from.

`[agent.generation]` is the canonical location. The released flat
`agent.model` / `agent.effort` keys keep working as deprecated aliases
onto the same two layer fields, so no existing configuration needs
migrating and there is no major version bump. Setting a key both ways in
one file is an error rather than a last-one-wins race, since table
iteration order is not something a user should have to reason about.

`[agent.work]` is refused with a pointer to karet. It is a new key, so
rejecting it removes nothing; it exists to make the split explicit,
because `wt` deliberately keeps generation and hands agent execution to
karet.

Defaults are unchanged (Claude / Sonnet / Medium) and `wt pr open`'s
`--model` / `--effort` flags keep their names, so the AI PR auto-fill
behaves exactly as before. `provider` accepts only `claude` today; it
exists so the field is forward-compatible when more agents are supported.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
`Command::output()` waits forever, so an agent that hangs hangs `wt`.
That is tolerable for a PR draft the user is watching, but not for
`wt issue`, where generation is best-effort and must never be able to
block worktree creation.

Adds `AgentOptions::timeout`. `None` waits indefinitely and remains the
default, so the PR auto-fill and version detection behave exactly as
before; both existing call sites pass it explicitly.

The deadline cannot be expressed with `output()`, which blocks until the
pipes close, so the timeout path spawns the child, drains stdout and
stderr on their own threads, and polls `try_wait` until the deadline.
Both pipes are drained concurrently because a child that fills the stderr
buffer would otherwise block forever with only stdout being read. Killing
the child closes its pipes, which is what lets the reader threads finish.

`AgentOptions` stays `Copy` — `Option<Duration>` is `Copy`, and the TUI
compose state and `pr_open`'s assertions depend on that.

Tested against a real subprocess rather than a fake: `sh -c "sleep 30"`
with a 100ms deadline returns a timeout in well under a second, and the
deadline path is also shown to return stdout intact and to still map a
non-zero exit (with its stderr) to `Error::Subprocess`.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
`wt issue <n>` fetches a GitHub issue, proposes a conventional branch
name and an implementation brief, creates (or reuses) the worktree, and
records the link — then stops. It does not launch a coding agent: running
the work belongs to karet over ACP (issue #100).

Generation is best-effort, and that is the point of issue #98. A model
that returns nonsense, an agent that is not installed, and an agent that
hangs all degrade to a deterministic fallback branch derived from the
issue's own labels, then its type, then `feat`. A malformed branch keeps
the generated brief, since the two are independent; a failed call leaves
the brief empty. The only thing that still fails is a bad --model value,
which is the user's to fix. Worktree creation never depends on model
behaviour.

The `TYPE/{number}-SLUG` contract comes from `wt::naming` (issue #96),
which until now had no callers: the prompt fragment the model is given
and the validator its answer is checked against are the same code, so
they cannot drift.

The contract binds the model, not the user. After the review prompts, an
edited branch only has to be a legal git branch — the issue link lives in
`wt.<branch>.issueNumber`, never in the branch name, so an off-contract
name still resolves back to its issue. It gets a note, not an error.

Creation goes through the worktree service rather than shelling into
`wt new`, so the confirmation preview and the real target come from one
`preview_target` helper. Recording the link is a locked read-check-write
against a *freshly discovered* repository: `create_in` writes metadata
through the git subprocess, but gix snapshots config at open, so the
session handle is stale. It also cannot use `Workspace::write_meta`,
which takes the lock itself and so cannot enclose the preceding read —
the advisory lock is not reentrant.

The reporting tail `wt new` had is extracted to `report_created` so both
commands report copy outcomes and hook warnings identically.
`default_base_ref` moves from the TUI re-export group to the CLI one,
since a CLI command now resolves its base from `origin/HEAD`.

Deliberately not included: agent execution in any form, the TUI issue
surface, `--json`, `--start`, and the base-staleness pre-flight.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
…ppers

Adds the `issue-numbers` completion kind and wires `issue` into all five
shell wrappers, so `wt issue <TAB>` offers open issue numbers and the
resulting worktree is cd'd into like `wt new`'s.

Completion is best-effort, exactly like `pr-numbers`: it runs on every
keystroke, so an unauthenticated or unreachable `gh` yields an empty list
rather than an error. A test pins that.

Notably `issue` gets **no** inherited-stdio special case. It is an
interactive command, which invites the assumption that it needs the
`--start`-style `WT_CD_FILE` handoff, but the wrappers capture *stdout*
only — stdin and stderr already reach the review prompts, exactly as
`wt new`'s base-staleness and submodule prompts already rely on. Forcing
the handoff would also make stdout a terminal, so the path would be
printed to the user *and* cd'd into: double output. A regression test
asserts no wrapper reintroduces it.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
Adds the `wt issue` feature bullet and quick-start line, documents the
`[agent.generation]` profile (noting the flat keys still work and that
`[agent.work]` belongs to karet), and widens the `gh` setup step, which
said PR commands were the only thing needing authentication.

The bullet leads with the fallback rather than the generation, because
that is the property a reader needs to trust the command: a missing,
hung, or nonsensical agent degrades to a deterministic name instead of
blocking the worktree.

Also corrects the `agent` cargo-feature comment. It claimed the feature
would gate `agent-text` "once the issue flow lands" — the issue flow
lands here without it, and gating it that way would be wrong anyway:
`wt issue` has to work with no agent at all.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
`preview_target` (added for `wt issue`'s confirmation prompt) derived the
directory slug's fallback hash from the branch's own tip, while
`create_in` derives it from the base the branch forks from. For a *new*
branch the branch has no tip, so the two disagreed.

It only surfaces for a branch name that slugifies to nothing — `_`, say —
because the hash is consulted only as rule 5's fallback. But that is
exactly where it matters: `wt issue` would confirm `repo-` and then
create `repo-<basehash>`.

Both now go through one `target_slug`, which also subsumes the base-ref
existence check `create_in` did inline, so `base_commit` is gone rather
than duplicated.

Two tests, because one is not enough: the first pins that preview and
creation agree, but sharing a helper makes that true even if the shared
rule is wrong. The second pins the rule itself — a slugless branch is
named after the base short hash — and fails with `repo-` if the
derivation regresses.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
The deadline killed the child but then joined the pipe-reader threads
unconditionally. Killing a process does not close pipes a *grandchild*
still holds, so the readers blocked until that grandchild exited —
reinstating the full wait behind a timeout that looked like it worked.
CI caught it: the test took the entire 30s.

An agent CLI that is a wrapper script has exactly this shape, so this was
not a theoretical case; it is the shape the timeout most needs to handle.

On timeout the readers are now detached rather than joined. Their output
is unwanted by definition, and they end on their own once the pipes
close. The success path still joins, so stdout and stderr are unaffected.

The test now uses `sleep 30 & wait` to force a grandchild. Plain
`sleep 30` was too weak: a shell that `exec`s into it leaves no
grandchild, which is why this passed locally and failed on CI.

Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo
@justin13888
justin13888 merged commit 1d773b8 into master Aug 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant