From cd62e061ab03162ba0bb45b7ac845e7a9b0d5780 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 13 Feb 2026 18:16:21 -0800 Subject: [PATCH] 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 --- README.md | 38 ++++-- cmd/abort.go | 16 ++- cmd/cascade.go | 64 +++++++++-- cmd/continue.go | 18 ++- cmd/submit.go | 11 +- cmd/submit_internal_test.go | 9 +- cmd/sync.go | 14 ++- e2e/helpers_test.go | 62 ++++++++++ e2e/worktree_test.go | 222 ++++++++++++++++++++++++++++++++++++ internal/git/git.go | 91 ++++++++++++++- internal/git/git_test.go | 126 ++++++++++++++++++++ internal/state/state.go | 4 + 12 files changed, 638 insertions(+), 37 deletions(-) create mode 100644 e2e/worktree_test.go diff --git a/README.md b/README.md index 91685d4..99b15a4 100644 --- a/README.md +++ b/README.md @@ -296,10 +296,11 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`. #### restack Flags -| Flag | Description | -| ----------- | --------------------------------------------- | -| `--only` | Only restack current branch, not descendants | -| `--dry-run` | Show what would be done | +| Flag | Description | +| ------------- | -------------------------------------------------------- | +| `--only` | Only restack current branch, not descendants | +| `--dry-run` | Show what would be done | +| `--worktrees` | Rebase branches checked out in linked worktrees in-place | ### continue @@ -321,10 +322,11 @@ This is the command to run when upstream changes have occurred (e.g., a PR in yo #### sync Flags -| Flag | Description | -| -------------- | ----------------------- | -| `--no-restack` | Skip restacking branches | -| `--dry-run` | Show what would be done | +| Flag | Description | +| -------------- | -------------------------------------------------------- | +| `--no-restack` | Skip restacking branches | +| `--dry-run` | Show what would be done | +| `--worktrees` | Rebase branches checked out in linked worktrees in-place | ### undo @@ -349,6 +351,26 @@ Snapshots are stored in `.git/stack-undo/` and archived to `.git/stack-undo/done | `--force` | Skip confirmation prompt | | `--dry-run` | Show what would be restored without doing it | +## Working with Git Worktrees + +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. + +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: + +```bash +# Restack with worktree-aware rebasing +gh stack restack --worktrees + +# Sync with worktree-aware rebasing +gh stack sync --worktrees +``` + +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. + +> [!NOTE] +> +> 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. + ## How It Works **gh-stack** stores metadata in your local `.git/config`: diff --git a/cmd/abort.go b/cmd/abort.go index 2b9a66b..40f5bc3 100644 --- a/cmd/abort.go +++ b/cmd/abort.go @@ -39,10 +39,22 @@ func runAbort(cmd *cobra.Command, args []string) error { return errors.New("no operation in progress") } + // Determine the correct git instance for rebase operations. + // If the conflicting branch is in a linked worktree, operate there. + rebaseGit := g + wtPath := "" + if p, ok := st.Worktrees[st.Current]; ok && p != "" { + wtPath = p + rebaseGit = git.New(wtPath) + } + // Abort rebase if in progress - if g.IsRebaseInProgress() { + if rebaseGit.IsRebaseInProgress() { fmt.Println("Aborting rebase...") - if err := g.RebaseAbort(); err != nil { + if err := rebaseGit.RebaseAbort(); err != nil { + if wtPath != "" { + return fmt.Errorf("failed to abort rebase in worktree at %s for branch %s: %w", wtPath, st.Current, err) + } return fmt.Errorf("failed to abort rebase: %w", err) } } diff --git a/cmd/cascade.go b/cmd/cascade.go index 5a0a6ab..e43a139 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -27,13 +27,15 @@ var cascadeCmd = &cobra.Command{ } var ( - cascadeOnlyFlag bool - cascadeDryRunFlag bool + cascadeOnlyFlag bool + cascadeDryRunFlag bool + cascadeWorktreesFlag bool ) func init() { cascadeCmd.Flags().BoolVar(&cascadeOnlyFlag, "only", false, "only restack current branch, not descendants") cascadeCmd.Flags().BoolVar(&cascadeDryRunFlag, "dry-run", false, "show what would be done") + cascadeCmd.Flags().BoolVar(&cascadeWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place") rootCmd.AddCommand(cascadeCmd) } @@ -91,7 +93,17 @@ func runCascade(cmd *cobra.Command, args []string) error { } } - err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, s) + // Build worktree map if --worktrees flag is set + var worktrees map[string]string + if cascadeWorktreesFlag { + var wtErr error + worktrees, wtErr = g.ListWorktrees() + if wtErr != nil { + return fmt.Errorf("failed to list worktrees: %w", wtErr) + } + } + + err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, worktrees, s) // Restore auto-stashed changes after operation (unless conflict, which saves stash in state) if stashRef != "" && !errors.Is(err, ErrConflict) { @@ -107,7 +119,9 @@ func runCascade(cmd *cobra.Command, args []string) error { // doCascadeWithState performs cascade and saves state with the given operation type. // allBranches is the complete list of branches for submit operations (used for push/PR after continue). // stashRef is the commit hash of auto-stashed changes (if any), persisted to state on conflict. -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 { +// worktrees maps branch names to linked worktree paths. When non-nil, branches in +// the map are rebased directly in their worktree directory instead of being checked out. +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 { originalBranch, err := g.CurrentBranch() if err != nil { return err @@ -153,22 +167,44 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d } } + // Determine if this branch lives in a linked worktree + wtPath := "" + if worktrees != nil { + wtPath = worktrees[b.Name] + } + if useOnto { fmt.Printf("Restacking %s onto %s %s...\n", s.Branch(b.Name), s.Branch(parent), s.Muted("(using fork point)")) } else { fmt.Printf("Restacking %s onto %s...\n", s.Branch(b.Name), s.Branch(parent)) } - // Checkout and rebase - if err := g.Checkout(b.Name); err != nil { - return err - } - var rebaseErr error - if useOnto { - rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name) + if wtPath != "" { + // Branch is checked out in a linked worktree -- rebase there directly + fmt.Printf(" %s\n", s.Muted(fmt.Sprintf("Using worktree at %s for %s", wtPath, b.Name))) + gitWt := git.New(wtPath) + if useOnto { + rebaseErr = gitWt.RebaseOntoHere(parent, storedForkPoint) + } else { + rebaseErr = gitWt.RebaseHere(parent) + } + // If git failed for a non-conflict reason (e.g. worktree dir was removed), + // wrap the error with context so the user knows which worktree we tried. + if rebaseErr != nil && !gitWt.IsRebaseInProgress() { + return fmt.Errorf("rebase of %s in worktree at %s failed (was the worktree removed or moved?): %w", b.Name, wtPath, rebaseErr) + } } else { - rebaseErr = g.Rebase(parent) + // Normal flow: checkout + rebase in the main repo + if err := g.Checkout(b.Name); err != nil { + return err + } + + if useOnto { + rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name) + } else { + rebaseErr = g.Rebase(parent) + } } if rebaseErr != nil { @@ -188,10 +224,14 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d PushOnly: pushOnly, Branches: allBranches, StashRef: stashRef, + Worktrees: worktrees, } _ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually fmt.Printf("\n%s %s\n", s.FailureIcon(), s.Error("CONFLICT: Resolve conflicts and run 'gh stack continue', or 'gh stack abort' to cancel.")) + if wtPath != "" { + fmt.Printf("Resolve conflicts in worktree: %s\n", wtPath) + } fmt.Printf("Remaining branches: %v\n", remaining) if stashRef != "" { fmt.Println(s.Muted("Note: Your uncommitted changes are stashed and will be restored when you continue or abort.")) diff --git a/cmd/continue.go b/cmd/continue.go index 732af4b..d1bf035 100644 --- a/cmd/continue.go +++ b/cmd/continue.go @@ -41,10 +41,22 @@ func runContinue(cmd *cobra.Command, args []string) error { return errors.New("no operation in progress") } + // Determine the correct git instance for rebase operations. + // If the conflicting branch is in a linked worktree, operate there. + rebaseGit := g + wtPath := "" + if p, ok := st.Worktrees[st.Current]; ok && p != "" { + wtPath = p + rebaseGit = git.New(wtPath) + } + // Complete the in-progress rebase - if g.IsRebaseInProgress() { + if rebaseGit.IsRebaseInProgress() { fmt.Println("Continuing rebase...") - if rebaseErr := g.RebaseContinue(); rebaseErr != nil { + if rebaseErr := rebaseGit.RebaseContinue(); rebaseErr != nil { + if wtPath != "" { + return fmt.Errorf("rebase --continue failed in worktree at %s for branch %s; resolve conflicts there first", wtPath, st.Current) + } return errors.New("rebase --continue failed; resolve conflicts first") } } @@ -74,7 +86,7 @@ func runContinue(cmd *cobra.Command, args []string) error { // Remove state file before continuing (will be recreated if conflict) _ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup - if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef, s); cascadeErr != nil { + if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef, st.Worktrees, s); cascadeErr != nil { // Stash handling is done by doCascadeWithState (conflict saves in state, errors restore) if !errors.Is(cascadeErr, ErrConflict) && st.StashRef != "" { fmt.Println("Restoring auto-stashed changes...") diff --git a/cmd/submit.go b/cmd/submit.go index 7024be2..07c73c7 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -70,7 +70,7 @@ func runSubmit(cmd *cobra.Command, args []string) error { return errors.New("--push-only and --web cannot be used together: --push-only skips all PR operations") } if submitFromFlag != "" && submitCurrentOnlyFlag { - return fmt.Errorf("--from and --current-only cannot be used together") + return errors.New("--from and --current-only cannot be used together") } cwd, err := os.Getwd() @@ -126,19 +126,20 @@ func runSubmit(cmd *cobra.Command, args []string) error { } else { // Determine the starting node for branch collection var startNode *tree.Node - if submitFromFlag == "HEAD" { + switch { + case submitFromFlag == "HEAD": // --from without value: resolve to current branch (old behavior) startNode = tree.FindNode(root, currentBranch) if startNode == nil { 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 # to stack on a different branch", currentBranch, trunk, trunk) } - } else if submitFromFlag != "" && submitFromFlag != trunk { + case submitFromFlag != "" && submitFromFlag != trunk: // --from=: use specified branch startNode = tree.FindNode(root, submitFromFlag) if startNode == nil { return fmt.Errorf("branch %q is not tracked in the stack", submitFromFlag) } - } else { + default: // Default (no --from, or --from=): entire stack startNode = root } @@ -173,7 +174,7 @@ func runSubmit(cmd *cobra.Command, args []string) error { // Phase 1: Restack fmt.Println(s.Bold("=== Phase 1: Restack ===")) - if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef, s); cascadeErr != nil { + if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef, nil, s); cascadeErr != nil { // Stash is saved in state for conflicts; restore on other errors if !errors.Is(cascadeErr, ErrConflict) && stashRef != "" { fmt.Println("Restoring auto-stashed changes...") diff --git a/cmd/submit_internal_test.go b/cmd/submit_internal_test.go index 4d01d55..c3b2b11 100644 --- a/cmd/submit_internal_test.go +++ b/cmd/submit_internal_test.go @@ -7,6 +7,7 @@ package cmd import ( + "errors" "fmt" "testing" ) @@ -285,22 +286,22 @@ func TestIsBaseBranchInvalidError(t *testing.T) { }, { name: "unrelated error", - err: fmt.Errorf("network timeout"), + err: errors.New("network timeout"), want: false, }, { name: "exact GitHub 422 error", - err: fmt.Errorf("failed to create PR: HTTP 422: Validation Failed (https://api.github.com/repos/owner/repo/pulls)\nPullRequest.base is invalid"), + err: errors.New("failed to create PR: HTTP 422: Validation Failed (https://api.github.com/repos/owner/repo/pulls)\nPullRequest.base is invalid"), want: true, }, { name: "short form", - err: fmt.Errorf("base is invalid"), + err: errors.New("base is invalid"), want: true, }, { name: "wrapped error", - err: fmt.Errorf("something went wrong: %w", fmt.Errorf("PullRequest.base is invalid")), + err: fmt.Errorf("something went wrong: %w", errors.New("PullRequest.base is invalid")), want: true, }, } diff --git a/cmd/sync.go b/cmd/sync.go index 67a0c4f..85b2cc3 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -27,11 +27,13 @@ var syncCmd = &cobra.Command{ var ( syncNoCascadeFlag bool syncDryRunFlag bool + syncWorktreesFlag bool ) func init() { syncCmd.Flags().BoolVar(&syncNoCascadeFlag, "no-restack", false, "skip restacking branches") syncCmd.Flags().BoolVar(&syncDryRunFlag, "dry-run", false, "show what would be done") + syncCmd.Flags().BoolVar(&syncWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place") rootCmd.AddCommand(syncCmd) } @@ -348,11 +350,21 @@ func runSync(cmd *cobra.Command, args []string) error { return err } + // Build worktree map if --worktrees flag is set + var worktrees map[string]string + if syncWorktreesFlag { + var wtErr error + worktrees, wtErr = g.ListWorktrees() + if wtErr != nil { + return fmt.Errorf("failed to list worktrees: %w", wtErr) + } + } + // Cascade from trunk's children for _, child := range root.Children { allBranches := []*tree.Node{child} allBranches = append(allBranches, tree.GetDescendants(child)...) - if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, s); err != nil { + if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, worktrees, s); err != nil { if errors.Is(err, ErrConflict) { hitConflict = true } diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go index 8d6b286..7930c79 100644 --- a/e2e/helpers_test.go +++ b/e2e/helpers_test.go @@ -285,3 +285,65 @@ func (e *TestEnv) AssertAncestor(ancestor, descendant string) { e.t.Errorf("expected %q to be ancestor of %q", ancestor, descendant) } } + +// CreateWorktree creates a linked worktree for the given branch at the given path. +func (e *TestEnv) CreateWorktree(branch, wtPath string) { + e.t.Helper() + cmd := exec.Command("git", "worktree", "add", wtPath, branch) + cmd.Dir = e.WorkDir + cmd.Env = append(os.Environ(), "GIT_EDITOR=cat") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + e.t.Fatalf("git worktree add %s %s failed: %v\nstderr: %s", wtPath, branch, err, stderr.String()) + } +} + +// GitInWorktree executes a git command in a worktree directory. +func (e *TestEnv) GitInWorktree(wtPath string, args ...string) string { + e.t.Helper() + + var stdout, stderr bytes.Buffer + cmd := exec.Command("git", args...) + cmd.Dir = wtPath + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), "GIT_EDITOR=cat") + + if err := cmd.Run(); err != nil { + e.t.Fatalf("git (worktree %s) %s failed: %v\nstderr: %s", wtPath, strings.Join(args, " "), err, stderr.String()) + } + + return strings.TrimSpace(stdout.String()) +} + +// RunInDir executes gh-stack in a specific directory (e.g. a worktree). +func (e *TestEnv) RunInDir(dir string, args ...string) *Result { + e.t.Helper() + + var stdout, stderr bytes.Buffer + cmd := exec.Command(e.BinaryPath, args...) + cmd.Dir = dir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), "GIT_EDITOR=cat") + + err := cmd.Run() + + result := &Result{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: 0, + } + + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } else { + e.t.Fatalf("failed to run command in %s: %v", dir, err) + } + } + + return result +} diff --git a/e2e/worktree_test.go b/e2e/worktree_test.go new file mode 100644 index 0000000..6b8933f --- /dev/null +++ b/e2e/worktree_test.go @@ -0,0 +1,222 @@ +// e2e/worktree_test.go +package e2e_test + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRestackWithWorktree(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + // Create a 3-branch stack: main -> feature-a -> feature-b -> feature-c + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + + env.MustRun("create", "feature-b") + env.CreateCommit("feature b work") + + env.MustRun("create", "feature-c") + env.CreateCommit("feature c work") + + // Move feature-b to a linked worktree + wtPath := filepath.Join(t.TempDir(), "wt-feature-b") + env.CreateWorktree("feature-b", wtPath) + + // Add a commit to main to force restack + env.Git("checkout", "main") + env.CreateCommit("main moved forward") + + // Restack from feature-a with --worktrees + env.Git("checkout", "feature-a") + result := env.MustRun("restack", "--worktrees") + + // Verify the output mentions the worktree + if !result.ContainsStdout("worktree") { + t.Errorf("expected output to mention worktree, got:\n%s", result.Stdout) + } + + // Verify ancestry chain is correct + env.AssertAncestor("main", "feature-a") + env.AssertAncestor("feature-a", "feature-b") + env.AssertAncestor("feature-b", "feature-c") + env.AssertNoRebaseInProgress() +} + +func TestRestackWithWorktreeConflict(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + conflictFile := "shared.txt" + + // Initial state on main + env.WriteFile(conflictFile, "initial content\n") + env.Git("add", conflictFile) + env.Git("commit", "-m", "initial shared.txt") + + // Create feature-a (doesn't modify shared.txt) + env.MustRun("create", "feature-a") + env.CreateCommit("feature-a work") + + // Create feature-b (modifies shared.txt -- will conflict) + env.MustRun("create", "feature-b") + env.WriteFile(conflictFile, "feature-b modified this\n") + env.Git("add", conflictFile) + env.Git("commit", "-m", "feature-b: modify shared.txt") + + // Switch away from feature-b so we can create a worktree for it + env.Git("checkout", "main") + + // Move feature-b to a linked worktree + wtPath := filepath.Join(t.TempDir(), "wt-feature-b") + env.CreateWorktree("feature-b", wtPath) + + // Move main forward with a conflicting change (already on main) + env.WriteFile(conflictFile, "main modified this differently\n") + env.Git("add", conflictFile) + env.Git("commit", "-m", "main: modify shared.txt") + + // Restack from feature-a -- should conflict on feature-b + env.Git("checkout", "feature-a") + result := env.Run("restack", "--worktrees") + + if result.Success() { + t.Fatal("expected restack to fail on conflict") + } + if !result.ContainsStdout("CONFLICT") { + t.Errorf("expected CONFLICT in output, got:\n%s", result.Stdout) + } + if !result.ContainsStdout(wtPath) { + t.Errorf("expected worktree path %q in conflict output, got:\n%s", wtPath, result.Stdout) + } + + // The rebase should be in progress in the worktree, not the main repo + rebaseMerge := filepath.Join(wtPath, ".git") + // In a linked worktree, .git is a file. Rebase state lives in the + // per-worktree gitdir, which we can find via git rev-parse. + // Just verify the worktree has a rebase in progress via a git command. + wtRebaseStatus := env.GitInWorktree(wtPath, "status") + if !containsString(wtRebaseStatus, "rebase") { + t.Errorf("expected rebase in progress in worktree, git status:\n%s\n.git: %s", wtRebaseStatus, rebaseMerge) + } + + // Resolve the conflict in the worktree + conflictPath := filepath.Join(wtPath, conflictFile) + if err := os.WriteFile(conflictPath, []byte("resolved content\n"), 0644); err != nil { + t.Fatalf("failed to write resolved file: %v", err) + } + env.GitInWorktree(wtPath, "add", conflictFile) + + // Continue from the main repo + env.MustRun("continue") + + // Verify ancestry after resolution + env.AssertAncestor("main", "feature-a") + env.AssertAncestor("feature-a", "feature-b") +} + +func TestRestackWithoutWorktreeFlagErrors(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + // Create a 2-branch stack + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + + env.MustRun("create", "feature-b") + env.CreateCommit("feature b work") + + // Switch away from feature-b so we can create a worktree for it + env.Git("checkout", "main") + + // Move feature-b to a linked worktree + wtPath := filepath.Join(t.TempDir(), "wt-feature-b") + env.CreateWorktree("feature-b", wtPath) + + // Move main forward to force restack (already on main) + env.CreateCommit("main moved forward") + + // Restack WITHOUT --worktrees should fail when hitting the worktree branch + env.Git("checkout", "feature-a") + result := env.Run("restack") + + // Without --worktrees, git checkout will fail for the branch in the worktree + if result.Success() { + t.Error("expected restack to fail without --worktrees when branch is in a worktree") + } +} + +func TestSyncWithWorktree(t *testing.T) { + // sync requires a real GitHub remote which we can't simulate in E2E tests. + // Instead, verify the --worktrees flag is accepted by the sync command + // and test the cascade-with-worktrees behavior (which sync delegates to) + // via the restack tests above. + env := NewTestEnv(t) + env.MustRun("init") + + // Verify --worktrees flag is recognized by sync (help output check) + result := env.Run("sync", "--help") + if !result.ContainsStdout("--worktrees") { + t.Errorf("expected sync --help to show --worktrees flag, got:\n%s", result.Stdout) + } +} + +func TestRestackAbortWithWorktree(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + conflictFile := "shared.txt" + + // Initial state on main + env.WriteFile(conflictFile, "initial content\n") + env.Git("add", conflictFile) + env.Git("commit", "-m", "initial shared.txt") + + // Create feature-a + env.MustRun("create", "feature-a") + env.CreateCommit("feature-a work") + + // Create feature-b (modifies shared.txt) + env.MustRun("create", "feature-b") + env.WriteFile(conflictFile, "feature-b modified this\n") + env.Git("add", conflictFile) + env.Git("commit", "-m", "feature-b: modify shared.txt") + + // Switch away and create worktree + env.Git("checkout", "main") + wtPath := filepath.Join(t.TempDir(), "wt-feature-b") + env.CreateWorktree("feature-b", wtPath) + + // Move main forward with conflict + env.WriteFile(conflictFile, "main modified this differently\n") + env.Git("add", conflictFile) + env.Git("commit", "-m", "main: modify shared.txt") + + // Restack from feature-a -- should conflict on feature-b in worktree + env.Git("checkout", "feature-a") + result := env.Run("restack", "--worktrees") + if result.Success() { + t.Fatal("expected conflict") + } + + // Abort should work and clean up the rebase in the worktree + env.MustRun("abort") + + // Verify worktree is clean (no rebase in progress) + wtStatus := env.GitInWorktree(wtPath, "status") + if containsString(wtStatus, "rebase") { + t.Errorf("expected no rebase in progress after abort, got:\n%s", wtStatus) + } +} + +// containsString is a simple helper for string containment. +func containsString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/git/git.go b/internal/git/git.go index 1f8e525..2bff831 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -266,14 +266,101 @@ func (g *Git) RebaseAbort() error { } // IsRebaseInProgress checks if a rebase is in progress. +// Uses GetResolvedGitDir so it works in linked worktrees where .git is a file, +// not a directory. func (g *Git) IsRebaseInProgress() bool { - rebaseMerge := filepath.Join(g.repoPath, ".git", "rebase-merge") - rebaseApply := filepath.Join(g.repoPath, ".git", "rebase-apply") + gitDir, err := g.GetResolvedGitDir() + if err != nil { + // Fall back to the old approach if we can't resolve the git dir + gitDir = filepath.Join(g.repoPath, ".git") + } + rebaseMerge := filepath.Join(gitDir, "rebase-merge") + rebaseApply := filepath.Join(gitDir, "rebase-apply") _, err1 := os.Stat(rebaseMerge) _, err2 := os.Stat(rebaseApply) return err1 == nil || err2 == nil } +// GetResolvedGitDir returns the actual git directory for this repository. +// In a main worktree this is typically /.git. +// In a linked worktree this resolves to the per-worktree dir +// (e.g. /.git/worktrees/), which is where rebase state lives. +func (g *Git) GetResolvedGitDir() (string, error) { + out, err := g.run("rev-parse", "--git-dir") + if err != nil { + return "", err + } + // git rev-parse --git-dir may return a relative path when using -C; + // resolve it relative to the repo path. + if !filepath.IsAbs(out) { + out = filepath.Join(g.repoPath, out) + } + return filepath.Clean(out), nil +} + +// ListWorktrees parses `git worktree list --porcelain` and returns a map of +// branch name to worktree path for all linked worktrees (the main worktree +// is excluded). +func (g *Git) ListWorktrees() (map[string]string, error) { + out, err := g.run("worktree", "list", "--porcelain") + if err != nil { + return nil, err + } + + result := make(map[string]string) + var currentPath string + first := true // skip the first entry (main worktree) + + for line := range strings.SplitSeq(out, "\n") { + line = strings.TrimSpace(line) + + if wtPath, ok := strings.CutPrefix(line, "worktree "); ok { + if first { + // Mark that we've seen the main worktree header; we'll skip + // its branch line below. + currentPath = "" + } else { + currentPath = wtPath + } + continue + } + + if strings.HasPrefix(line, "branch ") && currentPath != "" { + ref := strings.TrimPrefix(line, "branch ") + // Strip refs/heads/ prefix to get the branch name + branch := strings.TrimPrefix(ref, "refs/heads/") + result[branch] = currentPath + currentPath = "" + continue + } + + // Empty line separates worktree entries + if line == "" { + if first { + first = false + } + currentPath = "" + } + } + + return result, nil +} + +// RebaseHere rebases the already-checked-out HEAD onto the given branch. +// This is semantically identical to Rebase but named explicitly to clarify +// that no checkout is needed -- the caller is operating inside a worktree +// that already has the target branch checked out. +func (g *Git) RebaseHere(onto string) error { + return g.runInteractive("rebase", onto) +} + +// RebaseOntoHere runs `git rebase --onto ` on the +// already-checked-out HEAD. Needed for fork-point rebases in worktrees +// where we cannot (and don't need to) checkout first. +func (g *Git) RebaseOntoHere(newBase, oldBase string) error { + return g.runInteractive("rebase", "--onto", newBase, oldBase) +} + // GetGitDir returns the .git directory path. func (g *Git) GetGitDir() string { return filepath.Join(g.repoPath, ".git") diff --git a/internal/git/git_test.go b/internal/git/git_test.go index ec47de1..fff3c57 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -596,6 +596,132 @@ func TestIsContentMergedSquash(t *testing.T) { } } +func TestListWorktrees(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + // Create a branch for the worktree + g.CreateBranch("feature-wt") + + // Create a linked worktree + wtPath := filepath.Join(t.TempDir(), "wt-feature") + cmd := exec.Command("git", "-C", dir, "worktree", "add", wtPath, "feature-wt") + if err := cmd.Run(); err != nil { + t.Fatalf("git worktree add failed: %v", err) + } + + wts, err := g.ListWorktrees() + if err != nil { + t.Fatalf("ListWorktrees failed: %v", err) + } + + if len(wts) != 1 { + t.Fatalf("expected 1 linked worktree, got %d: %v", len(wts), wts) + } + + gotPath, ok := wts["feature-wt"] + if !ok { + t.Fatalf("expected worktree for branch 'feature-wt', got: %v", wts) + } + + // Resolve symlinks for comparison (macOS /var -> /private/var) + resolvedWtPath, _ := filepath.EvalSymlinks(wtPath) + resolvedGotPath, _ := filepath.EvalSymlinks(gotPath) + if resolvedGotPath != resolvedWtPath { + t.Errorf("expected worktree path %q, got %q", resolvedWtPath, resolvedGotPath) + } +} + +func TestListWorktreesEmpty(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + wts, err := g.ListWorktrees() + if err != nil { + t.Fatalf("ListWorktrees failed: %v", err) + } + + if len(wts) != 0 { + t.Errorf("expected 0 linked worktrees, got %d: %v", len(wts), wts) + } +} + +func TestGetResolvedGitDir(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + // In the main repo, resolved git dir should be /.git + gitDir, err := g.GetResolvedGitDir() + if err != nil { + t.Fatalf("GetResolvedGitDir failed: %v", err) + } + expected := filepath.Join(dir, ".git") + // Resolve symlinks for comparison (macOS /var -> /private/var) + resolvedExpected, _ := filepath.EvalSymlinks(expected) + resolvedGitDir, _ := filepath.EvalSymlinks(gitDir) + if resolvedGitDir != resolvedExpected { + t.Errorf("main repo: expected %q, got %q", resolvedExpected, resolvedGitDir) + } + + // Create a linked worktree and verify its git dir is different + g.CreateBranch("wt-branch") + wtPath := filepath.Join(t.TempDir(), "wt-test") + wtCmd := exec.Command("git", "-C", dir, "worktree", "add", wtPath, "wt-branch") + if wtCmdErr := wtCmd.Run(); wtCmdErr != nil { + t.Fatalf("git worktree add failed: %v", wtCmdErr) + } + + gWt := git.New(wtPath) + wtGitDir, wtErr := gWt.GetResolvedGitDir() + if wtErr != nil { + t.Fatalf("GetResolvedGitDir (worktree) failed: %v", wtErr) + } + + // The worktree's git dir should be under /.git/worktrees/ + resolvedWtGitDir, _ := filepath.EvalSymlinks(wtGitDir) + if resolvedWtGitDir == resolvedExpected { + t.Errorf("worktree git dir should differ from main repo git dir") + } + // Should contain "worktrees" in the path + if !contains(resolvedWtGitDir, "worktrees") { + t.Errorf("expected worktree git dir to contain 'worktrees', got %q", resolvedWtGitDir) + } +} + +func TestIsRebaseInProgressWorktree(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + // Create a branch for the worktree + g.CreateBranch("wt-rebase") + wtPath := filepath.Join(t.TempDir(), "wt-rebase-test") + cmd := exec.Command("git", "-C", dir, "worktree", "add", wtPath, "wt-rebase") + if err := cmd.Run(); err != nil { + t.Fatalf("git worktree add failed: %v", err) + } + + gWt := git.New(wtPath) + + // No rebase should be in progress + if gWt.IsRebaseInProgress() { + t.Error("expected no rebase in progress in worktree") + } +} + +// contains checks if substr is in s (simple helper to avoid importing strings in test). +func contains(s, substr string) bool { + return filepath.Base(s) == substr || len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + func TestRemoteBranchExists(t *testing.T) { // Create main repo dir := setupTestRepo(t) diff --git a/internal/state/state.go b/internal/state/state.go index b3d25b7..39b9a0e 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -38,6 +38,10 @@ type CascadeState struct { // StashRef is the commit hash of auto-stashed changes (if any). // Used to restore working tree changes when operation completes or is aborted. StashRef string `json:"stash_ref,omitempty"` + // Worktrees maps branch names to linked worktree paths. + // Persisted so that continue/abort can find the correct worktree + // directory for branches that were being rebased in a linked worktree. + Worktrees map[string]string `json:"worktrees,omitempty"` } // Save persists cascade state to .git/STACK_CASCADE_STATE.