Skip to content

Commit 72420cb

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 72420cb

11 files changed

Lines changed: 628 additions & 30 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: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,23 @@ 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 {
48-
return errors.New("rebase --continue failed; resolve conflicts first")
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+
}
60+
return fmt.Errorf("rebase --continue failed; resolve conflicts first")
4961
}
5062
}
5163

@@ -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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func runSubmit(cmd *cobra.Command, args []string) error {
173173

174174
// Phase 1: Restack
175175
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 {
176+
if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef, nil, s); cascadeErr != nil {
177177
// Stash is saved in state for conflicts; restore on other errors
178178
if !errors.Is(cascadeErr, ErrConflict) && stashRef != "" {
179179
fmt.Println("Restoring auto-stashed changes...")

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
}

e2e/helpers_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,64 @@ func (e *TestEnv) AssertAncestor(ancestor, descendant string) {
285285
e.t.Errorf("expected %q to be ancestor of %q", ancestor, descendant)
286286
}
287287
}
288+
289+
// CreateWorktree creates a linked worktree for the given branch at the given path.
290+
func (e *TestEnv) CreateWorktree(branch, wtPath string) {
291+
e.t.Helper()
292+
cmd := exec.Command("git", "worktree", "add", wtPath, branch)
293+
cmd.Dir = e.WorkDir
294+
cmd.Env = append(os.Environ(), "GIT_EDITOR=cat")
295+
var stderr bytes.Buffer
296+
cmd.Stderr = &stderr
297+
if err := cmd.Run(); err != nil {
298+
e.t.Fatalf("git worktree add %s %s failed: %v\nstderr: %s", wtPath, branch, err, stderr.String())
299+
}
300+
}
301+
302+
// GitInWorktree executes a git command in a worktree directory.
303+
func (e *TestEnv) GitInWorktree(wtPath string, args ...string) string {
304+
e.t.Helper()
305+
306+
var stdout, stderr bytes.Buffer
307+
cmd := exec.Command("git", args...)
308+
cmd.Dir = wtPath
309+
cmd.Stdout = &stdout
310+
cmd.Stderr = &stderr
311+
cmd.Env = append(os.Environ(), "GIT_EDITOR=cat")
312+
313+
if err := cmd.Run(); err != nil {
314+
e.t.Fatalf("git (worktree %s) %s failed: %v\nstderr: %s", wtPath, strings.Join(args, " "), err, stderr.String())
315+
}
316+
317+
return strings.TrimSpace(stdout.String())
318+
}
319+
320+
// RunInDir executes gh-stack in a specific directory (e.g. a worktree).
321+
func (e *TestEnv) RunInDir(dir string, args ...string) *Result {
322+
e.t.Helper()
323+
324+
var stdout, stderr bytes.Buffer
325+
cmd := exec.Command(e.BinaryPath, args...)
326+
cmd.Dir = dir
327+
cmd.Stdout = &stdout
328+
cmd.Stderr = &stderr
329+
cmd.Env = append(os.Environ(), "GIT_EDITOR=cat")
330+
331+
err := cmd.Run()
332+
333+
result := &Result{
334+
Stdout: stdout.String(),
335+
Stderr: stderr.String(),
336+
ExitCode: 0,
337+
}
338+
339+
if err != nil {
340+
if exitErr, ok := err.(*exec.ExitError); ok {
341+
result.ExitCode = exitErr.ExitCode()
342+
} else {
343+
e.t.Fatalf("failed to run command in %s: %v", dir, err)
344+
}
345+
}
346+
347+
return result
348+
}

0 commit comments

Comments
 (0)