Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/wtp/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ func parseWorktreesFromOutput(output string) []git.Worktree {
worktrees = append(worktrees, currentWorktree)
}

if len(worktrees) > 0 {
worktrees[0].Path = git.ResolveMainWorktreePath(worktrees[0].Path)
}

return worktrees
}

Expand Down
42 changes: 39 additions & 3 deletions internal/git/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package git
import (
stdErrors "errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
Expand Down Expand Up @@ -62,16 +63,50 @@ func (r *Repository) GetMainWorktreePath() (string, error) {
return parent, nil
}

// If commonDir doesn't end with .git, it's likely already the worktree path
// If commonDir doesn't end with .git, it is a bare repository or a submodule's
// git directory (.git/modules/<name>)
if !filepath.IsAbs(commonDir) {
absPath, absErr := filepath.Abs(filepath.Join(r.path, commonDir))
if absErr != nil {
return "", fmt.Errorf("failed to get absolute path: %w", absErr)
}
return absPath, nil
commonDir = absPath
}

return commonDir, nil
return ResolveMainWorktreePath(commonDir), nil
}

// ResolveMainWorktreePath returns the working tree of a git directory that sets
// core.worktree, such as a submodule's .git/modules/<name>. git worktree list
// reports that git directory as the main worktree path. Other paths, including
// bare repositories, are returned unchanged.
func ResolveMainWorktreePath(path string) string {
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
return path
}
if _, err := os.Stat(filepath.Join(path, "HEAD")); err != nil {
return path
}

cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = path
// An inherited GIT_DIR or GIT_WORK_TREE would make git ignore cmd.Dir
env := os.Environ()
cmd.Env = make([]string, 0, len(env))
for _, e := range env {
if !strings.HasPrefix(e, "GIT_DIR=") && !strings.HasPrefix(e, "GIT_WORK_TREE=") {
cmd.Env = append(cmd.Env, e)
}
}
output, err := cmd.Output()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err != nil {
return path
}

if topLevel := strings.TrimSpace(string(output)); topLevel != "" {
return topLevel
}
return path
}

// GetWorktrees lists the worktrees associated with the repository.
Expand All @@ -88,6 +123,7 @@ func (r *Repository) GetWorktrees() ([]Worktree, error) {
// The first worktree in the list is always the main worktree
if len(worktrees) > 0 {
worktrees[0].IsMain = true
worktrees[0].Path = ResolveMainWorktreePath(worktrees[0].Path)
}

return worktrees, nil
Expand Down
75 changes: 75 additions & 0 deletions internal/git/repository_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,78 @@ func TestGetMainWorktreePath_GitCommandFailure(t *testing.T) {
_, err = repo.GetMainWorktreePath()
assert.Error(t, err)
}

// setupSubmoduleRepo creates a parent repository with a submodule at "sub" and
// returns the submodule checkout path.
func setupSubmoduleRepo(t *testing.T) string {
t.Helper()

childDir := setupTestRepo(t)
parentDir := setupTestRepo(t)
runCmd(t, parentDir, "git", "-c", "protocol.file.allow=always", "submodule", "add", childDir, "sub")

return filepath.Join(parentDir, "sub")
}

func assertSamePath(t *testing.T, expected, actual string) {
t.Helper()

expectedPath, err := filepath.EvalSymlinks(expected)
assert.NoError(t, err)
actualPath, err := filepath.EvalSymlinks(actual)
assert.NoError(t, err)
assert.Equal(t, expectedPath, actualPath)
}

func TestGetMainWorktreePath_Submodule(t *testing.T) {
submoduleDir := setupSubmoduleRepo(t)

repo, err := NewRepository(submoduleDir)
assert.NoError(t, err)

mainPath, err := repo.GetMainWorktreePath()
assert.NoError(t, err)
assertSamePath(t, submoduleDir, mainPath)
}

func TestGetWorktrees_SubmoduleLinkedWorktree(t *testing.T) {
submoduleDir := setupSubmoduleRepo(t)
linkedDir := filepath.Join(t.TempDir(), "feature")
runCmd(t, submoduleDir, "git", "worktree", "add", "-b", "feature", linkedDir)

repo, err := NewRepository(linkedDir)
assert.NoError(t, err)

worktrees, err := repo.GetWorktrees()
assert.NoError(t, err)
assert.Len(t, worktrees, 2)
assert.True(t, worktrees[0].IsMain)
assertSamePath(t, submoduleDir, worktrees[0].Path)

mainPath, err := repo.GetMainWorktreePath()
assert.NoError(t, err)
assertSamePath(t, submoduleDir, mainPath)
}

func TestResolveMainWorktreePath_BareRepositoryUnchanged(t *testing.T) {
bareDir := filepath.Join(t.TempDir(), "bare.git")
runCmd(t, filepath.Dir(bareDir), "git", "init", "--bare", bareDir)

assert.Equal(t, bareDir, ResolveMainWorktreePath(bareDir))
}

func TestResolveMainWorktreePath_RegularRepositoryUnchanged(t *testing.T) {
repoDir := setupTestRepo(t)

assert.Equal(t, repoDir, ResolveMainWorktreePath(repoDir))
}

func TestResolveMainWorktreePath_IgnoresInheritedGitEnv(t *testing.T) {
submoduleDir := setupSubmoduleRepo(t)
otherRepoDir := setupTestRepo(t)
t.Setenv("GIT_DIR", filepath.Join(otherRepoDir, ".git"))
t.Setenv("GIT_WORK_TREE", otherRepoDir)

gitDir := filepath.Join(filepath.Dir(submoduleDir), ".git", "modules", "sub")
assertSamePath(t, submoduleDir, ResolveMainWorktreePath(gitDir))
}
11 changes: 11 additions & 0 deletions test/e2e/framework/assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package framework

import (
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -152,6 +153,16 @@ func AssertEqual(t *testing.T, expected, actual any) {
assert.Equal(t, expected, actual)
}

// AssertSamePath compares two paths after resolving symlinks, such as /var and /private/var on macOS.
func AssertSamePath(t *testing.T, expected, actual string) {
t.Helper()
resolvedExpected, err := filepath.EvalSymlinks(expected)
assert.NoError(t, err)
resolvedActual, err := filepath.EvalSymlinks(actual)
assert.NoError(t, err)
assert.Equal(t, resolvedExpected, resolvedActual)
}

// AssertNotEqual compares two values and fails if they are equal.
func AssertNotEqual(t *testing.T, notExpected, actual any) {
t.Helper()
Expand Down
24 changes: 24 additions & 0 deletions test/e2e/framework/framework.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ func (e *TestEnvironment) CreateNonRepoDir(name string) *TestRepo {
}
}

// OpenRepo wraps an existing repository or worktree directory.
func (e *TestEnvironment) OpenRepo(path string) *TestRepo {
return &TestRepo{
env: e,
path: path,
}
}

// WriteFile writes file contents relative to the test environment root.
func (e *TestEnvironment) WriteFile(path, content string) {
e.writeFile(path, content)
Expand Down Expand Up @@ -240,6 +248,22 @@ func (r *TestRepo) RunWTP(args ...string) (string, error) {
return string(output), err
}

// AddSubmodule adds source as a submodule at name and returns the submodule checkout.
func (r *TestRepo) AddSubmodule(source *TestRepo, name string) *TestRepo {
r.env.t.Helper()

r.env.runInDir(r.path, "git", "-c", "protocol.file.allow=always", "submodule", "add", source.path, name)
submoduleDir := filepath.Join(r.path, name)
testutil.ConfigureTestRepo(r.env.t, submoduleDir, func(dir string, args ...string) {
r.env.runInDir(dir, "git", args...)
})

return &TestRepo{
env: r.env,
path: submoduleDir,
}
}

// CreateBranch creates a new branch in the repository.
func (r *TestRepo) CreateBranch(name string) {
r.env.runInDir(r.path, "git", "branch", name)
Expand Down
21 changes: 21 additions & 0 deletions test/e2e/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@ func TestShellIntegration(t *testing.T) {
)
})

t.Run("CDCommandInSubmoduleOutputsSubmoduleCheckout", func(t *testing.T) {
child := env.CreateTestRepo("shell-cd-submodule-child")
parent := env.CreateTestRepo("shell-cd-submodule-parent")
submodule := parent.AddSubmodule(child, "child")
submodule.CreateBranch("feature/sub")
_, err := submodule.RunWTP("add", "feature/sub")
framework.AssertNoError(t, err)

output, err := submodule.RunWTP("cd", "@")
framework.AssertNoError(t, err)
framework.AssertSamePath(t, submodule.Path(), strings.TrimSpace(output))

output, err = submodule.RunWTP("cd", "feature/sub")
framework.AssertNoError(t, err)
linked := env.OpenRepo(strings.TrimSpace(output))

output, err = linked.RunWTP("cd", "@")
framework.AssertNoError(t, err)
framework.AssertSamePath(t, submodule.Path(), strings.TrimSpace(output))
})

t.Run("CDCommandOutputsPath", func(t *testing.T) {
repo := env.CreateTestRepo("shell-cd-path")
repo.CreateBranch("test-branch")
Expand Down