Skip to content

Commit cd62e06

Browse files
committed
feat: add --worktrees flag for restack and sync
When a stack branch is checked out in a linked git worktree, `git checkout` fails, blocking `restack` and `sync`. The new `--worktrees` flag detects linked worktrees up front and rebases those branches directly in their worktree directories instead. - Add `ListWorktrees`, `RebaseHere`, `RebaseOntoHere`, and `GetResolvedGitDir` to `internal/git` - Fix `IsRebaseInProgress` to use resolved git dir (works in linked worktrees where `.git` is a file, not a directory) - Persist worktree map in `CascadeState` so `continue`/`abort` operate on the correct worktree after conflicts - Wrap worktree-related failures with actionable error messages - Add unit tests, E2E tests, and README documentation Closes #33
1 parent 593cfd4 commit cd62e06

12 files changed

Lines changed: 638 additions & 37 deletions

File tree

README.md

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -296,10 +296,11 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`.
296296

297297
#### restack Flags
298298

299-
| Flag | Description |
300-
| ----------- | --------------------------------------------- |
301-
| `--only` | Only restack current branch, not descendants |
302-
| `--dry-run` | Show what would be done |
299+
| Flag | Description |
300+
| ------------- | -------------------------------------------------------- |
301+
| `--only` | Only restack current branch, not descendants |
302+
| `--dry-run` | Show what would be done |
303+
| `--worktrees` | Rebase branches checked out in linked worktrees in-place |
303304

304305
### continue
305306

@@ -321,10 +322,11 @@ This is the command to run when upstream changes have occurred (e.g., a PR in yo
321322

322323
#### sync Flags
323324

324-
| Flag | Description |
325-
| -------------- | ----------------------- |
326-
| `--no-restack` | Skip restacking branches |
327-
| `--dry-run` | Show what would be done |
325+
| Flag | Description |
326+
| -------------- | -------------------------------------------------------- |
327+
| `--no-restack` | Skip restacking branches |
328+
| `--dry-run` | Show what would be done |
329+
| `--worktrees` | Rebase branches checked out in linked worktrees in-place |
328330

329331
### undo
330332

@@ -349,6 +351,26 @@ Snapshots are stored in `.git/stack-undo/` and archived to `.git/stack-undo/done
349351
| `--force` | Skip confirmation prompt |
350352
| `--dry-run` | Show what would be restored without doing it |
351353

354+
## Working with Git Worktrees
355+
356+
If you use [git worktrees](https://git-scm.com/docs/git-worktree) to check out multiple stack branches simultaneously, `git checkout` will refuse to switch to a branch that's already checked out in another worktree. This means `restack` and `sync` will fail when they try to check out those branches.
357+
358+
The `--worktrees` flag solves this. When set, **gh-stack** detects linked worktrees up front and rebases those branches directly in their worktree directories instead of checking them out:
359+
360+
```bash
361+
# Restack with worktree-aware rebasing
362+
gh stack restack --worktrees
363+
364+
# Sync with worktree-aware rebasing
365+
gh stack sync --worktrees
366+
```
367+
368+
If a rebase conflict occurs in a worktree branch, **gh-stack** will tell you which worktree directory to resolve it in. After resolving, run `gh stack continue` from the main repository as usual—**gh-stack** remembers which worktree the conflict lives in.
369+
370+
> [!NOTE]
371+
>
372+
> The `--worktrees` flag is opt-in. Without it, **gh-stack** behaves exactly as before. If none of your stack branches are checked out in linked worktrees, the flag is a harmless no-op.
373+
352374
## How It Works
353375

354376
**gh-stack** stores metadata in your local `.git/config`:

cmd/abort.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,22 @@ func runAbort(cmd *cobra.Command, args []string) error {
3939
return errors.New("no operation in progress")
4040
}
4141

42+
// Determine the correct git instance for rebase operations.
43+
// If the conflicting branch is in a linked worktree, operate there.
44+
rebaseGit := g
45+
wtPath := ""
46+
if p, ok := st.Worktrees[st.Current]; ok && p != "" {
47+
wtPath = p
48+
rebaseGit = git.New(wtPath)
49+
}
50+
4251
// Abort rebase if in progress
43-
if g.IsRebaseInProgress() {
52+
if rebaseGit.IsRebaseInProgress() {
4453
fmt.Println("Aborting rebase...")
45-
if err := g.RebaseAbort(); err != nil {
54+
if err := rebaseGit.RebaseAbort(); err != nil {
55+
if wtPath != "" {
56+
return fmt.Errorf("failed to abort rebase in worktree at %s for branch %s: %w", wtPath, st.Current, err)
57+
}
4658
return fmt.Errorf("failed to abort rebase: %w", err)
4759
}
4860
}

cmd/cascade.go

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@ var cascadeCmd = &cobra.Command{
2727
}
2828

2929
var (
30-
cascadeOnlyFlag bool
31-
cascadeDryRunFlag bool
30+
cascadeOnlyFlag bool
31+
cascadeDryRunFlag bool
32+
cascadeWorktreesFlag bool
3233
)
3334

3435
func init() {
3536
cascadeCmd.Flags().BoolVar(&cascadeOnlyFlag, "only", false, "only restack current branch, not descendants")
3637
cascadeCmd.Flags().BoolVar(&cascadeDryRunFlag, "dry-run", false, "show what would be done")
38+
cascadeCmd.Flags().BoolVar(&cascadeWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place")
3739
rootCmd.AddCommand(cascadeCmd)
3840
}
3941

@@ -91,7 +93,17 @@ func runCascade(cmd *cobra.Command, args []string) error {
9193
}
9294
}
9395

94-
err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, s)
96+
// Build worktree map if --worktrees flag is set
97+
var worktrees map[string]string
98+
if cascadeWorktreesFlag {
99+
var wtErr error
100+
worktrees, wtErr = g.ListWorktrees()
101+
if wtErr != nil {
102+
return fmt.Errorf("failed to list worktrees: %w", wtErr)
103+
}
104+
}
105+
106+
err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, worktrees, s)
95107

96108
// Restore auto-stashed changes after operation (unless conflict, which saves stash in state)
97109
if stashRef != "" && !errors.Is(err, ErrConflict) {
@@ -107,7 +119,9 @@ func runCascade(cmd *cobra.Command, args []string) error {
107119
// doCascadeWithState performs cascade and saves state with the given operation type.
108120
// allBranches is the complete list of branches for submit operations (used for push/PR after continue).
109121
// stashRef is the commit hash of auto-stashed changes (if any), persisted to state on conflict.
110-
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web, pushOnly bool, allBranches []string, stashRef string, s *style.Style) error {
122+
// worktrees maps branch names to linked worktree paths. When non-nil, branches in
123+
// the map are rebased directly in their worktree directory instead of being checked out.
124+
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web, pushOnly bool, allBranches []string, stashRef string, worktrees map[string]string, s *style.Style) error {
111125
originalBranch, err := g.CurrentBranch()
112126
if err != nil {
113127
return err
@@ -153,22 +167,44 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
153167
}
154168
}
155169

170+
// Determine if this branch lives in a linked worktree
171+
wtPath := ""
172+
if worktrees != nil {
173+
wtPath = worktrees[b.Name]
174+
}
175+
156176
if useOnto {
157177
fmt.Printf("Restacking %s onto %s %s...\n", s.Branch(b.Name), s.Branch(parent), s.Muted("(using fork point)"))
158178
} else {
159179
fmt.Printf("Restacking %s onto %s...\n", s.Branch(b.Name), s.Branch(parent))
160180
}
161181

162-
// Checkout and rebase
163-
if err := g.Checkout(b.Name); err != nil {
164-
return err
165-
}
166-
167182
var rebaseErr error
168-
if useOnto {
169-
rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name)
183+
if wtPath != "" {
184+
// Branch is checked out in a linked worktree -- rebase there directly
185+
fmt.Printf(" %s\n", s.Muted(fmt.Sprintf("Using worktree at %s for %s", wtPath, b.Name)))
186+
gitWt := git.New(wtPath)
187+
if useOnto {
188+
rebaseErr = gitWt.RebaseOntoHere(parent, storedForkPoint)
189+
} else {
190+
rebaseErr = gitWt.RebaseHere(parent)
191+
}
192+
// If git failed for a non-conflict reason (e.g. worktree dir was removed),
193+
// wrap the error with context so the user knows which worktree we tried.
194+
if rebaseErr != nil && !gitWt.IsRebaseInProgress() {
195+
return fmt.Errorf("rebase of %s in worktree at %s failed (was the worktree removed or moved?): %w", b.Name, wtPath, rebaseErr)
196+
}
170197
} else {
171-
rebaseErr = g.Rebase(parent)
198+
// Normal flow: checkout + rebase in the main repo
199+
if err := g.Checkout(b.Name); err != nil {
200+
return err
201+
}
202+
203+
if useOnto {
204+
rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name)
205+
} else {
206+
rebaseErr = g.Rebase(parent)
207+
}
172208
}
173209

174210
if rebaseErr != nil {
@@ -188,10 +224,14 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
188224
PushOnly: pushOnly,
189225
Branches: allBranches,
190226
StashRef: stashRef,
227+
Worktrees: worktrees,
191228
}
192229
_ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually
193230

194231
fmt.Printf("\n%s %s\n", s.FailureIcon(), s.Error("CONFLICT: Resolve conflicts and run 'gh stack continue', or 'gh stack abort' to cancel."))
232+
if wtPath != "" {
233+
fmt.Printf("Resolve conflicts in worktree: %s\n", wtPath)
234+
}
195235
fmt.Printf("Remaining branches: %v\n", remaining)
196236
if stashRef != "" {
197237
fmt.Println(s.Muted("Note: Your uncommitted changes are stashed and will be restored when you continue or abort."))

cmd/continue.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,22 @@ func runContinue(cmd *cobra.Command, args []string) error {
4141
return errors.New("no operation in progress")
4242
}
4343

44+
// Determine the correct git instance for rebase operations.
45+
// If the conflicting branch is in a linked worktree, operate there.
46+
rebaseGit := g
47+
wtPath := ""
48+
if p, ok := st.Worktrees[st.Current]; ok && p != "" {
49+
wtPath = p
50+
rebaseGit = git.New(wtPath)
51+
}
52+
4453
// Complete the in-progress rebase
45-
if g.IsRebaseInProgress() {
54+
if rebaseGit.IsRebaseInProgress() {
4655
fmt.Println("Continuing rebase...")
47-
if rebaseErr := g.RebaseContinue(); rebaseErr != nil {
56+
if rebaseErr := rebaseGit.RebaseContinue(); rebaseErr != nil {
57+
if wtPath != "" {
58+
return fmt.Errorf("rebase --continue failed in worktree at %s for branch %s; resolve conflicts there first", wtPath, st.Current)
59+
}
4860
return errors.New("rebase --continue failed; resolve conflicts first")
4961
}
5062
}
@@ -74,7 +86,7 @@ func runContinue(cmd *cobra.Command, args []string) error {
7486
// Remove state file before continuing (will be recreated if conflict)
7587
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
7688

77-
if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef, s); cascadeErr != nil {
89+
if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef, st.Worktrees, s); cascadeErr != nil {
7890
// Stash handling is done by doCascadeWithState (conflict saves in state, errors restore)
7991
if !errors.Is(cascadeErr, ErrConflict) && st.StashRef != "" {
8092
fmt.Println("Restoring auto-stashed changes...")

cmd/submit.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func runSubmit(cmd *cobra.Command, args []string) error {
7070
return errors.New("--push-only and --web cannot be used together: --push-only skips all PR operations")
7171
}
7272
if submitFromFlag != "" && submitCurrentOnlyFlag {
73-
return fmt.Errorf("--from and --current-only cannot be used together")
73+
return errors.New("--from and --current-only cannot be used together")
7474
}
7575

7676
cwd, err := os.Getwd()
@@ -126,19 +126,20 @@ func runSubmit(cmd *cobra.Command, args []string) error {
126126
} else {
127127
// Determine the starting node for branch collection
128128
var startNode *tree.Node
129-
if submitFromFlag == "HEAD" {
129+
switch {
130+
case submitFromFlag == "HEAD":
130131
// --from without value: resolve to current branch (old behavior)
131132
startNode = tree.FindNode(root, currentBranch)
132133
if startNode == nil {
133134
return fmt.Errorf("branch %q is not tracked in the stack\n\nTo add it, run:\n gh stack adopt %s # to stack on %s\n gh stack adopt -p <parent> # to stack on a different branch", currentBranch, trunk, trunk)
134135
}
135-
} else if submitFromFlag != "" && submitFromFlag != trunk {
136+
case submitFromFlag != "" && submitFromFlag != trunk:
136137
// --from=<branch>: use specified branch
137138
startNode = tree.FindNode(root, submitFromFlag)
138139
if startNode == nil {
139140
return fmt.Errorf("branch %q is not tracked in the stack", submitFromFlag)
140141
}
141-
} else {
142+
default:
142143
// Default (no --from, or --from=<trunk>): entire stack
143144
startNode = root
144145
}
@@ -173,7 +174,7 @@ func runSubmit(cmd *cobra.Command, args []string) error {
173174

174175
// Phase 1: Restack
175176
fmt.Println(s.Bold("=== Phase 1: Restack ==="))
176-
if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef, s); cascadeErr != nil {
177+
if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef, nil, s); cascadeErr != nil {
177178
// Stash is saved in state for conflicts; restore on other errors
178179
if !errors.Is(cascadeErr, ErrConflict) && stashRef != "" {
179180
fmt.Println("Restoring auto-stashed changes...")

cmd/submit_internal_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package cmd
88

99
import (
10+
"errors"
1011
"fmt"
1112
"testing"
1213
)
@@ -285,22 +286,22 @@ func TestIsBaseBranchInvalidError(t *testing.T) {
285286
},
286287
{
287288
name: "unrelated error",
288-
err: fmt.Errorf("network timeout"),
289+
err: errors.New("network timeout"),
289290
want: false,
290291
},
291292
{
292293
name: "exact GitHub 422 error",
293-
err: fmt.Errorf("failed to create PR: HTTP 422: Validation Failed (https://api.github.com/repos/owner/repo/pulls)\nPullRequest.base is invalid"),
294+
err: errors.New("failed to create PR: HTTP 422: Validation Failed (https://api.github.com/repos/owner/repo/pulls)\nPullRequest.base is invalid"),
294295
want: true,
295296
},
296297
{
297298
name: "short form",
298-
err: fmt.Errorf("base is invalid"),
299+
err: errors.New("base is invalid"),
299300
want: true,
300301
},
301302
{
302303
name: "wrapped error",
303-
err: fmt.Errorf("something went wrong: %w", fmt.Errorf("PullRequest.base is invalid")),
304+
err: fmt.Errorf("something went wrong: %w", errors.New("PullRequest.base is invalid")),
304305
want: true,
305306
},
306307
}

cmd/sync.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ var syncCmd = &cobra.Command{
2727
var (
2828
syncNoCascadeFlag bool
2929
syncDryRunFlag bool
30+
syncWorktreesFlag bool
3031
)
3132

3233
func init() {
3334
syncCmd.Flags().BoolVar(&syncNoCascadeFlag, "no-restack", false, "skip restacking branches")
3435
syncCmd.Flags().BoolVar(&syncDryRunFlag, "dry-run", false, "show what would be done")
36+
syncCmd.Flags().BoolVar(&syncWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place")
3537
rootCmd.AddCommand(syncCmd)
3638
}
3739

@@ -348,11 +350,21 @@ func runSync(cmd *cobra.Command, args []string) error {
348350
return err
349351
}
350352

353+
// Build worktree map if --worktrees flag is set
354+
var worktrees map[string]string
355+
if syncWorktreesFlag {
356+
var wtErr error
357+
worktrees, wtErr = g.ListWorktrees()
358+
if wtErr != nil {
359+
return fmt.Errorf("failed to list worktrees: %w", wtErr)
360+
}
361+
}
362+
351363
// Cascade from trunk's children
352364
for _, child := range root.Children {
353365
allBranches := []*tree.Node{child}
354366
allBranches = append(allBranches, tree.GetDescendants(child)...)
355-
if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, s); err != nil {
367+
if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, worktrees, s); err != nil {
356368
if errors.Is(err, ErrConflict) {
357369
hitConflict = true
358370
}

0 commit comments

Comments
 (0)