Add worktree checkout to gh issue develop - #14136
Conversation
Teach gh issue develop to accept --worktree <path> together with --checkout, allowing a linked development branch to be checked out in a new Git worktree without changing the branch in the caller's current working tree. Validate that --worktree is non-empty, requires --checkout, and cannot be combined with --list. Resolve relative targets to absolute paths before invoking Git so later worktree-local operations use one stable path while paths containing spaces remain valid. Add git.Client.AddWorktree to handle both supported branch states. Existing local branches use git worktree add and are then fast-forwarded with git -C <worktree> pull --ff-only. Missing local branches are created in the worktree as tracking branches from the remote selected for the linked branch repository, including --branch-repo workflows. Keep the existing checkout behavior unchanged when --worktree is absent, and let git worktree add surface native errors for occupied paths or branches already checked out elsewhere. Cover flag parsing and validation, relative and spaced paths, existing and new local branches, cross-repository remote selection, command construction, and Git error propagation.
There was a problem hiding this comment.
Pull request overview
Adds worktree-based checkout support to gh issue develop --checkout.
Changes:
- Adds and validates the
--worktreeflag. - Creates or updates branches inside a new worktree.
- Adds Git helper and unit tests.
Show a summary per file
| File | Description |
|---|---|
pkg/cmd/issue/develop/develop.go |
Implements worktree checkout behavior. |
pkg/cmd/issue/develop/develop_test.go |
Tests flag handling and checkout flows. |
git/client.go |
Adds the AddWorktree helper. |
git/client_test.go |
Tests generated Git commands and errors. |
Review details
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Balanced
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
GitHub becomes an extension. A task already carries the join GitHub needs — one branch, named after the task's slug, checked out in every one of its repos — so "which PRs belong to this task" is a lookup rather than a guess, and everything here follows from having it. It reaches the screen in two places and nowhere else: one glyph on the task's row, tinted by the worst state across its PRs, and a `review` tab showing every PR and then one PR in full. ADR 0044 — a pane may be a contributed view. There was no non-terminal pane in v2; a leaf of the layout tree may now carry a component. The enforcement is the renderer never mounting TerminalPane for one, because TerminalPane attaches on mount and attaching spawns a pty. ADR 0045 — the app's PATH is harvested from the login shell. A Finder-launched .app is a child of launchd and inherits /usr/bin:/bin:/usr/sbin:/sbin, so the same exec call succeeds in `pnpm dev` and fails with ENOENT in the shipped app. Startup now asks `$SHELL -ilc` once and merges the answer into process.env: measured at 28 directories added in 159ms from a launchd-shaped PATH, and 0 when launched from a terminal. Read from pingdotgg/t3code, which solves the same problem the same way. tasks never learns what a pull request is. The glyph arrives through a new `tasks.cardFacts` point, so a second integration lands the same way. Two more seams the work needed: `sessions.write`, which brackets its paste iff the running program asked for bracketed paste, and a real secrets store behind Electron's safeStorage. The diff and the file tree are @pierre/diffs and @pierre/trees rather than hand-rolled. Three things measured on the way, each a comment in the code: GitHub sends hunks and not patches, so PatchDiff refuses one until a `diff --git` header is synthesised; a thread naming a file is not a thread the diff can show, since its line may have moved out of the change; and an effect whose callback prop is a dependency does not terminate when the parent passes an inline arrow. PR bodies render as markdown. They used to split on blank lines, on the argument that a body is two sentences — true of a human's PR and false of this app's, where an agent writes the headings, the fenced commands and the lists. Measured against cli/cli#14136. Verified against a real repo: auth through `gh auth token`, the GraphQL query by head branch, mergeStateStatus behind its preview media type, and REST pulls.listFiles for patches. Co-Authored-By: Claude Opus 5 <[email protected]>
babakks
left a comment
There was a problem hiding this comment.
Thanks for putting this together, @sergiou87! 🙏
Heads up that I haven't gone through the tests yet. I suspect the implementation will shift based on the points below, and the tests will follow, so I'll hold off reviewing them until things settle.
A few things I'd like us to address:
-
Replicate the worktree safety checks from
pr checkout: The symlink and other target safety validations thatpr checkoutperforms (rejecting leaf symlinks, non-directory targets, paths inside a different repository, paths inside an existing worktree, etc.) aren't present here. We should replicate them. Rather than duplicating, I think we should extract that logic frompr checkoutinto a shared package, probablypkg/cmd/pr/shared, since there's already precedent for sharing logic between PRs and issues there. @tidy-dev did a great job inpr checkoutfor worktrees covering various use cases and security related concerns. I think the big lift here is to just extract them into the shared package and happily reuse them here. -
Match
pr checkout's worktree reuse behavior: I think users will expectissue developandpr checkoutto behave the same way around worktrees, in particular the existing worktree reuse behavior thatpr checkoutprovides. That's another reason to pull thepr checkoutinternals into a shared place so both commands stay consistent. -
Add acceptance tests: We need acceptance tests under
acceptance/testdata/issue, mirroring the existingpr checkout --worktreecases inacceptance/testdata/pr/pr-checkout-worktree-*.txtar.
Extract worktree target validation and branch placement so issue develop inherits pr checkout safety and reuse behavior. Add unit and acceptance coverage for fresh, reused, and cross-repository worktrees.\n\nCo-authored-by: Copilot App <[email protected]>\nCopilot-Session: 2ce1b530-30dc-47e3-9c30-392f1368ddd8
Protect issue develop's existing nonfatal fast-forward behavior when reusing a linked worktree. Co-authored-by: Copilot App <[email protected]> Copilot-Session: 2ce1b530-30dc-47e3-9c30-392f1368ddd8
|
@babakks I’ve picked this up while Sergio is OOO and pushed updates addressing your review:
The targeted, full, race, lint, and live acceptance tests pass. When you have a chance, could you please take another look? |
Co-authored-by: Copilot App <[email protected]> Copilot-Session: 2ce1b530-30dc-47e3-9c30-392f1368ddd8
babakks
left a comment
There was a problem hiding this comment.
Thanks for taking this on, @sergiou87, and thanks to @tidy-dev for making the changes! 🎉 This is a really thorough piece of work, and I especially appreciate that you extracted the worktree logic into a shared package so gh pr checkout and gh issue develop stay in sync rather than growing two parallel implementations.
I went through the checkout.go refactor and confirmed it is a faithful, behavior preserving extraction (the path safety guard, the resolve logic, and all four command shapes are reproduced exactly), and the existing acceptance tests back that up. I also exercised the new issue develop behaviors by hand against a scratch repo: fast forwarding into a worktree, relative paths containing spaces, the current repo guard, reuse of an existing linked worktree, and the traditional no worktree checkout all work as expected. Nicely done. 🙏
One thing I would like your take on before we wrap up:
When --worktree targets an existing but non empty directory that is not a git worktree, git worktree add correctly fails, but by then CreateLinkedBranch has already run, so we are left with a linked branch on the remote and no local worktree. In pr checkout this is harmless since no branch is created, but in issue develop it leaves an orphan. Do you think ResolveWorktreeTarget should reject a non empty target up front so we fail before creating the branch, or is leaving that to git worktree add acceptable here?
Everything else is minor and I will leave those as inline notes. Thanks again for the care that went into this. 🍻
Re this, I'm happy to leave it for now, or have it as a follow up. So, I think we can merge this one out. |
Description
gh issue develop --checkoutcurrently checks a linked development branch out in the caller's current working copy. This makes it difficult to start work on an issue without interrupting whatever branch is already checked out there.This change adds
--worktree <path>togh issue develop. When it is combined with--checkout, the command creates the linked branch as usual but checks it out in a new Git worktree at the requested path. The original working copy remains on its current branch.The implementation supports both checkout paths used by
issue develop:git -C.The remote is still selected from the linked branch repository, so
--branch-repoworks when the issue and development branch belong to different repositories. Relative worktree paths are normalized to absolute paths, and paths containing spaces are passed to Git as individual arguments.The new flag requires
--checkout, rejects an explicitly blank path, and is mutually exclusive with--list. Without--worktree,gh issue developfollows its existing checkout behavior.A reusable
git.Client.AddWorktreehelper encapsulates the two supported Git operations:Acceptance scenarios cover same-repository and cross-repository worktree creation, both local branch states,
--name,--base, relative paths containing spaces, flag validation, occupied targets, and unchanged traditional checkout behavior.How did you test this change?
Flags
Testing gh issue develop --worktree flag validation
$ ../cli/bin/gh issue develop --help
Manage linked branches for an issue.
When using the
--baseflag, the new development branch will be created from the specifiedremote branch. The new branch will be configured as the base branch for pull requests created using
gh pr create.USAGE
gh issue develop { | } [flags]
FLAGS
-b, --base string Name of the remote branch you want to make your new branch from
--branch-repo string Name or URL of the repository where you want to create your new branch
-c, --checkout Checkout the branch after creating it
-l, --list List linked branches for the issue
-n, --name string Name of the branch to create
--worktree path Check out the branch into a worktree at the given path
INHERITED FLAGS
--help Show help for command
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
EXAMPLES
List branches for issue 123
$ gh issue develop --list 123
List branches for issue 123 in repo cli/cli
$ gh issue develop --list --repo cli/cli 123
Create a branch for issue 123 based on the my-feature branch
$ gh issue develop 123 --base my-feature
Create a branch for issue 123 and check it out
$ gh issue develop 123 --checkout
Create a branch for issue 123 and check it out in a worktree
$ gh issue develop 123 --checkout --worktree /path/to/worktree
Create a branch in repo monalisa/cli for issue 123 in repo cli/cli
$ gh issue develop 123 --repo cli/cli --branch-repo monalisa/cli
LEARN MORE
Use
gh <command> <subcommand> --helpfor more information about a command.Read the manual at https://cli.github.com/manual
Learn about exit codes using
gh help exit-codesLearn about accessibility experiences using
gh help accessibility$ ../cli/bin/gh issue develop 1 --repo sergiou87/test-repo --worktree ../unused-worktree
--worktree requires --checkout
Usage: gh issue develop { | } [flags]
Flags:
-b, --base string Name of the remote branch you want to make your new branch from
--branch-repo string Name or URL of the repository where you want to create your new branch
-c, --checkout Checkout the branch after creating it
-l, --list List linked branches for the issue
-n, --name string Name of the branch to create
--worktree path Check out the branch into a worktree at the given path
[exit status: 1]
PASS: rejected with expected error
$ ../cli/bin/gh issue develop 1 --repo sergiou87/test-repo --list --worktree ../unused-worktree
specify only one of
--listor--worktreeUsage: gh issue develop { | } [flags]
Flags:
-b, --base string Name of the remote branch you want to make your new branch from
--branch-repo string Name or URL of the repository where you want to create your new branch
-c, --checkout Checkout the branch after creating it
-l, --list List linked branches for the issue
-n, --name string Name of the branch to create
--worktree path Check out the branch into a worktree at the given path
[exit status: 1]
PASS: rejected with expected error
$ ../cli/bin/gh issue develop 1 --repo sergiou87/test-repo --checkout --worktree ''
--worktree cannot be blank
Usage: gh issue develop { | } [flags]
Flags:
-b, --base string Name of the remote branch you want to make your new branch from
--branch-repo string Name or URL of the repository where you want to create your new branch
-c, --checkout Checkout the branch after creating it
-l, --list List linked branches for the issue
-n, --name string Name of the branch to create
--worktree path Check out the branch into a worktree at the given path
[exit status: 1]
PASS: rejected with expected error
$ git -C ../cli diff --exit-code trunk...HEAD -- docs share
PASS: command help contains new source documentation; generated docs are unchanged
=== Focused unit coverage ===
$ (cd ../cli && go test ./git -run ^TestClientAddWorktree$)
ok github.com/cli/cli/v2/git (cached)
$ (cd ../cli && go test ./pkg/cmd/issue/develop -run ^(TestNewCmdDevelop|TestDevelopRun|TestCheckoutBranchWorktree)$)
ok github.com/cli/cli/v2/pkg/cmd/issue/develop (cached)
All flag and help checks passed.
Basic functionality
=== New local branch from remote; relative path with spaces ===
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --name issue-develop-wt-20260813140601-15620-new --base main --checkout --worktree ../issue\ develop\ worktrees\ 20260813140601-15620/new\ branch
github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-new
From https://github.com/sergiou87/test-repo
$ git branch --show-current
commit-msg-demo
PASS
$ git -C ../issue\ develop\ worktrees\ 20260813140601-15620/new\ branch rev-parse --show-toplevel
/Users/sergiou87/Developer/GitHub/issue develop worktrees 20260813140601-15620/new branch
PASS
$ git -C ../issue\ develop\ worktrees\ 20260813140601-15620/new\ branch branch --show-current
issue-develop-wt-20260813140601-15620-new
PASS
$ git -C ../issue\ develop\ worktrees\ 20260813140601-15620/new\ branch rev-parse --abbrev-ref @{upstream}
origin/issue-develop-wt-20260813140601-15620-new
PASS
$ git config --get branch.issue-develop-wt-20260813140601-15620-new.gh-merge-base
main
PASS
$ git worktree list
/Users/sergiou87/Developer/GitHub/test-repo 78b771c [commit-msg-demo]
/Users/sergiou87/Developer/GitHub/issue develop worktrees 20260813140601-15620/new branch 076bfa3 [issue-develop-wt-20260813140601-15620-new]
PASS: caller checkout unchanged; absolute worktree uses named branch and tracks origin
=== Fresh-worktree scope; rerun delegates failure to git ===
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --name issue-develop-wt-20260813140601-15620-new --checkout --worktree ../issue\ develop\ worktrees\ 20260813140601-15620/new\ branch
github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-new
failed to run git: Preparing worktree (checking out 'issue-develop-wt-20260813140601-15620-new')
fatal: '/Users/sergiou87/Developer/GitHub/issue develop worktrees 20260813140601-15620/new branch' already exists
[exit status: 1]
PASS: git rejected target as expected
=== Existing local branch reused in a new worktree ===
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --name issue-develop-wt-20260813140601-15620-existing --base main
github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-existing
From https://github.com/sergiou87/test-repo
$ git branch --track issue-develop-wt-20260813140601-15620-existing origin/issue-develop-wt-20260813140601-15620-existing
branch 'issue-develop-wt-20260813140601-15620-existing' set up to track 'origin/issue-develop-wt-20260813140601-15620-existing'.
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --name issue-develop-wt-20260813140601-15620-existing --base main --checkout --worktree ../issue\ develop\ worktrees\ 20260813140601-15620/existing\ branch
github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-existing
From https://github.com/sergiou87/test-repo
Already up to date.
$ git branch --show-current
commit-msg-demo
PASS
$ git -C ../issue\ develop\ worktrees\ 20260813140601-15620/existing\ branch branch --show-current
issue-develop-wt-20260813140601-15620-existing
PASS
$ git -C ../issue\ develop\ worktrees\ 20260813140601-15620/existing\ branch rev-parse --abbrev-ref @{upstream}
origin/issue-develop-wt-20260813140601-15620-existing
PASS
$ git config --get branch.issue-develop-wt-20260813140601-15620-existing.gh-merge-base
main
PASS
PASS: existing local branch checked out and fast-forwarded in worktree
=== Occupied path delegates failure to git ===
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --name issue-develop-wt-20260813140601-15620-occupied --base main
github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-occupied
From https://github.com/sergiou87/test-repo
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --name issue-develop-wt-20260813140601-15620-occupied --checkout --worktree ../issue\ develop\ worktrees\ 20260813140601-15620/occupied\ path
github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-occupied
failed to run git: Preparing worktree (new branch 'issue-develop-wt-20260813140601-15620-occupied')
fatal: '/Users/sergiou87/Developer/GitHub/issue develop worktrees 20260813140601-15620/occupied path' already exists
[exit status: 1]
PASS: git rejected target as expected
$ sed -n 1p ../issue\ develop\ worktrees\ 20260813140601-15620/occupied\ path/existing-file
do not overwrite
PASS
=== Existing behavior without --worktree, isolated in disposable clone ===
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --name issue-develop-wt-20260813140601-15620-legacy --base main
github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-legacy
From https://github.com/sergiou87/test-repo
$ git clone --no-local . ../issue\ develop\ worktrees\ 20260813140601-15620/legacy\ clone
Cloning into '../issue develop worktrees 20260813140601-15620/legacy clone'...
$ git -C ../issue\ develop\ worktrees\ 20260813140601-15620/legacy\ clone remote set-url origin https://github.com/sergiou87/test-repo.git
$ (cd ../issue\ develop\ worktrees\ 20260813140601-15620/legacy\ clone && env GH_PAGER=cat /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --name issue-develop-wt-20260813140601-15620-legacy --checkout)
github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-legacy
From https://github.com/sergiou87/test-repo
$ git -C ../issue\ develop\ worktrees\ 20260813140601-15620/legacy\ clone branch --show-current
issue-develop-wt-20260813140601-15620-legacy
PASS
$ git -C ../issue\ develop\ worktrees\ 20260813140601-15620/legacy\ clone rev-parse --abbrev-ref @{upstream}
origin/issue-develop-wt-20260813140601-15620-legacy
PASS
$ sh -c git\ -C\ "../issue\ develop\ worktrees\ 20260813140601-15620/legacy\ clone"\ worktree\ list\ --porcelain\ |\ grep\ -c\ '^worktree\ '
1
PASS
PASS: no-worktree checkout still switches checkout and creates no extra worktree
=== Linked branches created during test ===
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --list
issue-develop-wt-20260813140601-15620-new https://github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-new
issue-develop-wt-20260813140601-15620-existing https://github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-existing
issue-develop-wt-20260813140601-15620-occupied https://github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-occupied
issue-develop-wt-20260813140601-15620-legacy https://github.com/sergiou87/test-repo/tree/issue-develop-wt-20260813140601-15620-legacy
$ git branch --show-current
commit-msg-demo
PASS
PASS: all live same-repository scenarios passed; caller branch and status unchanged.
Cross-repo support
Issue repository: sergiou87/test-repo Branch repository: sergiou87-org/test-repo Branch repository base: main
$ git remote add issue-develop-cross-20260813144150-88754 https://github.com/sergiou87-org/test-repo.git
$ git remote -v
issue-develop-cross-20260813144150-88754 https://github.com/sergiou87-org/test-repo.git (fetch)
issue-develop-cross-20260813144150-88754 https://github.com/sergiou87-org/test-repo.git (push)
origin https://github.com/sergiou87/test-repo.git (fetch)
origin https://github.com/sergiou87/test-repo.git (push)
=== Cross-repository worktree checkout ===
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --branch-repo sergiou87-org/test-repo --name issue-develop-wt-20260813144150-88754-cross-repo --base main --checkout --worktree ../issue\ develop\ cross\ repo\ 20260813144150-88754/cross\ repo\ branch
github.com/sergiou87-org/test-repo/tree/issue-develop-wt-20260813144150-88754-cross-repo
From https://github.com/sergiou87-org/test-repo
$ git branch --show-current
commit-msg-demo
PASS
$ git -C ../issue\ develop\ cross\ repo\ 20260813144150-88754/cross\ repo\ branch rev-parse --show-toplevel
/Users/sergiou87/Developer/GitHub/issue develop cross repo 20260813144150-88754/cross repo branch
PASS
$ git -C ../issue\ develop\ cross\ repo\ 20260813144150-88754/cross\ repo\ branch branch --show-current
issue-develop-wt-20260813144150-88754-cross-repo
PASS
$ git -C ../issue\ develop\ cross\ repo\ 20260813144150-88754/cross\ repo\ branch rev-parse --abbrev-ref @{upstream}
issue-develop-cross-20260813144150-88754/issue-develop-wt-20260813144150-88754-cross-repo
PASS
$ git config --get branch.issue-develop-wt-20260813144150-88754-cross-repo.gh-merge-base
main
PASS
$ git remote get-url issue-develop-cross-20260813144150-88754
https://github.com/sergiou87-org/test-repo.git
PASS
$ git worktree list
/Users/sergiou87/Developer/GitHub/test-repo 78b771c [commit-msg-demo]
/Users/sergiou87/Developer/GitHub/issue develop cross repo 20260813144150-88754/cross repo branch 076bfa3 [issue-develop-wt-20260813144150-88754-cross-repo]
$ /Users/sergiou87/Developer/GitHub/cli/bin/gh issue develop 1 --repo sergiou87/test-repo --list
issue-develop-wt-20260813144150-88754-cross-repo https://github.com/sergiou87-org/test-repo/tree/issue-develop-wt-20260813144150-88754-cross-repo
PASS: cross-repo branch tracks remote matching --branch-repo; caller checkout unchanged.
Key points
git worktree adderrors.gh pr checkout --worktreeimplementation includes reuse, force, and detached-HEAD behavior. Refactoring that state machine was intentionally avoided becauseissue developonly needs the two fresh-worktree operations described above.Notes for reviewers
Start with
checkoutBranchinpkg/cmd/issue/develop/develop.go, which contains the behavior split between traditional checkout and worktree checkout. Then reviewgit.Client.AddWorktreeingit/client.gofor the exact Git argument forms.The command-constructor tests cover flag curation and validation, while the checkout tests cover existing and missing local branches, cross-repository remote selection, paths containing spaces, and Git error propagation.
Authorship and follow-up
Who wrote this:
Who answers review comments: