feat: create worktrees from GitHub issues - #107
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings
wt issueto master in the shape #100 specifies, wired to thewt::namingmodule #96 landed and the fallback #98 asks for.Closes #96
Closes #98
Closes #100
Why these three together
wt issuedoes 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 theWorkspaceAPI, cargofeature gates and the advisory repo lock. That collapses three issues
into one deliverable:
src/naming.rsalready landed (feat: expose wt as a consumable worktree library #102) and meets every acceptancecriterion, but has zero callers. Its second criterion ("
issue.rscallsinto this module") needs the command to exist.
naming::fallbackexists; nothing wires it in.the ported command takes.
What
wt issuedoesFetches the issue, proposes a conventional
type/123-slugbranch and a shortimplementation 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:
naming::fallback+ a notenaming::fallback+ a noteThe only thing that still fails is a bad
--model/--effortvalue, which isthe user's to fix. Worktree creation never depends on model behaviour.
The
TYPE/{number}-SLUGcontract comes fromwt::naming: the prompt fragmentthe 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 namestill 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 itthrough 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 outsideColumn::ALL),Worktree::issueand the JSONissuefield are.No inherited-stdio special case in the shell wrappers. #92 forced
wt issueonto the
WT_CD_FILEhandoff because the agent needed a real terminal. Thewrappers 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 aTTY, 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_inwrites 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 enclosethe 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.effortkeys keep working asdeprecated aliases onto the same layer fields, and
wt pr open --model/--effortkeep 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 aremoval.
Deliberately deferred
#100's "delete
spec::prompt_argv/apply_effort/parse_result" bullet isnot 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_withinsrc/agent/mod.rs). Deletingthem would delete working behaviour.
Adopting
agent-textwas considered and rejected for this PR: it needs thefirst
#[cfg(feature = "agent")]in the tree, makestokioa hard transitivedependency (the opposite of #93's lean-library goal), costs
AgentModelitsCopyacross four TUI files, and carries #92's silent default changes(Sonnet→Haiku, Medium→Low, Codex-by-default) that would break
wt pr open --aifor existing users. I'll file a follow-up issue carrying that bullet.
Also not included:
wt issue --json,--start, and the base-stalenesspre-flight that
wt newruns.One defect found and fixed in review
preview_target(added for the confirmation prompt) derived the directoryslug's fallback hash from the branch's own tip, while
create_inderives itfrom 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 thatis exactly where it matters: the prompt would confirm
repo-and then createrepo-<basehash>. Both now share onetarget_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:
mise run testmise run lint-D warnings)mise run format-checkmise run check-core--no-default-features) + 621 (--features cli) passedmise run coverageconvco check origin/master..HEADcommands/issue.rsis 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-coreearned its keep twice here: it caughtdefault_base_refneeding tomove from the TUI re-export group to the CLI one, and a new test referencing a
cli-gated function without being gated itself.