diff --git a/internal/git/git.go b/internal/git/git.go index 1e03f0fd..54097198 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -183,6 +183,15 @@ func (r *Repo) Cmd(ctx context.Context, args []string, env []string) *exec.Cmd { return cmd } +// runInDir runs a git command from the specified directory instead of the repo directory. +// This is used when a branch is checked out in a different worktree. +func (r *Repo) runInDir(ctx context.Context, dir string, opts *RunOpts) (*Output, error) { + saved := r.repoDir + r.repoDir = dir + defer func() { r.repoDir = saved }() + return r.Run(ctx, opts) +} + func (r *Repo) Run(ctx context.Context, opts *RunOpts) (*Output, error) { cmd := r.Cmd(ctx, opts.Args, opts.Env) var stdout, stderr bytes.Buffer diff --git a/internal/git/rebase.go b/internal/git/rebase.go index 6ad0cf04..5575520c 100644 --- a/internal/git/rebase.go +++ b/internal/git/rebase.go @@ -54,7 +54,18 @@ func (r *Repo) Rebase(ctx context.Context, opts RebaseOpts) (*Output, error) { args = append(args, "--onto", opts.Onto) } args = append(args, opts.Upstream) + + // If the branch is checked out in another worktree, run the rebase from + // that worktree directory instead. git rebase first does a checkout + // which fails if the branch is in a different worktree. if opts.Branch != "" { + worktreePath, err := r.WorktreeForBranch(ctx, opts.Branch) + if err != nil { + return nil, err + } + if worktreePath != "" { + return r.runInDir(ctx, worktreePath, &RunOpts{Args: args}) + } args = append(args, opts.Branch) } diff --git a/internal/git/worktree.go b/internal/git/worktree.go new file mode 100644 index 00000000..d0dc061e --- /dev/null +++ b/internal/git/worktree.go @@ -0,0 +1,38 @@ +package git + +import ( + "context" + "path/filepath" + "strings" +) + +// WorktreeForBranch returns the worktree path where the given branch is checked out, +// or an empty string if the branch is not checked out in any worktree. +// The branch should be in short format (e.g., "my-branch"). +func (r *Repo) WorktreeForBranch(ctx context.Context, branch string) (string, error) { + out, err := r.Run(ctx, &RunOpts{ + Args: []string{"worktree", "list", "--porcelain"}, + ExitError: true, + }) + if err != nil { + return "", err + } + + repoDir, _ := filepath.EvalSymlinks(r.repoDir) + var currentWorktree string + for line := range strings.SplitSeq(string(out.Stdout), "\n") { + if path, ok := strings.CutPrefix(line, "worktree "); ok { + currentWorktree = path + } + if ref, ok := strings.CutPrefix(line, "branch "); ok { + shortName := strings.TrimPrefix(ref, "refs/heads/") + if shortName == branch { + resolved, _ := filepath.EvalSymlinks(currentWorktree) + if resolved != repoDir { + return currentWorktree, nil + } + } + } + } + return "", nil +} diff --git a/internal/git/worktree_test.go b/internal/git/worktree_test.go new file mode 100644 index 00000000..8bde6e06 --- /dev/null +++ b/internal/git/worktree_test.go @@ -0,0 +1,80 @@ +package git_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/aviator-co/av/internal/git" + "github.com/aviator-co/av/internal/git/gittest" + "github.com/stretchr/testify/require" +) + +func TestWorktreeForBranch(t *testing.T) { + repo := gittest.NewTempRepo(t) + avRepo := repo.AsAvGitRepo() + + // Create a feature branch with a commit. + repo.Git(t, "checkout", "-b", "feature-1") + repo.CommitFile(t, "feature1.txt", "feature 1") + repo.Git(t, "checkout", "main") + + // feature-1 is not checked out in any worktree, so should return empty. + wt, err := avRepo.WorktreeForBranch(t.Context(), "feature-1") + require.NoError(t, err) + require.Empty(t, wt) + + // Add a worktree for feature-1. + wtPath := t.TempDir() + repo.Git(t, "worktree", "add", wtPath, "feature-1") + + // Now feature-1 should be detected in the worktree. + wt, err = avRepo.WorktreeForBranch(t.Context(), "feature-1") + require.NoError(t, err) + // Resolve symlinks for comparison (macOS /var -> /private/var). + resolvedWt, _ := filepath.EvalSymlinks(wt) + resolvedExpected, _ := filepath.EvalSymlinks(wtPath) + require.Equal(t, resolvedExpected, resolvedWt) + + // main is checked out in the main repo, not a different worktree. + wt, err = avRepo.WorktreeForBranch(t.Context(), "main") + require.NoError(t, err) + require.Empty(t, wt) +} + +func TestRebaseInWorktree(t *testing.T) { + repo := gittest.NewTempRepo(t) + avRepo := repo.AsAvGitRepo() + + // Create a stack: main -> feature-1 -> feature-2 + repo.Git(t, "checkout", "-b", "feature-1") + repo.CommitFile(t, "feature1.txt", "feature 1") + repo.Git(t, "checkout", "-b", "feature-2") + repo.CommitFile(t, "feature2.txt", "feature 2") + repo.Git(t, "checkout", "main") + + // Add a new commit on main to create something to restack onto. + repo.CommitFile(t, "main-update.txt", "main update") + + // Put feature-1 in a separate worktree. + wtPath := t.TempDir() + repo.Git(t, "worktree", "add", wtPath, "feature-1") + + // Rebase feature-1 onto main (simulating restack). + // This would fail without worktree-aware rebase because feature-1 + // is checked out in another worktree. + mainHash := strings.TrimSpace(repo.Git(t, "rev-parse", "main")) + + // Get the merge-base for feature-1 and main (the original branch point). + mergeBase := strings.TrimSpace(repo.Git(t, "merge-base", "main", "feature-1")) + + result, err := avRepo.RebaseParse(t.Context(), git.RebaseOpts{ + Branch: "feature-1", + Upstream: mergeBase, + Onto: mainHash, + }) + require.NoError(t, err) + require.NotNil(t, result) + // The rebase should succeed (updated or already up to date). + require.NotEqual(t, git.RebaseConflict, result.Status) +}