diff --git a/pkg/cli/git.go b/pkg/cli/git.go index 527b2105e97..953405525b7 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -18,8 +18,8 @@ import ( var gitLog = logger.New("cli:git") func isGitRepo() bool { - cmd := exec.Command("git", "rev-parse", "--git-dir") - return cmd.Run() == nil + _, err := gitutil.FindGitRoot() + return err == nil } // findGitRootForPath finds the root directory of the git repository containing the specified path @@ -41,13 +41,11 @@ func findGitRootForPath(path string) (string, error) { // Use the directory containing the file dir := filepath.Dir(absPath) - // Run git command in the file's directory - cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel") - output, err := cmd.Output() + // Find git root using filesystem traversal from the file's directory + gitRoot, err := gitutil.FindGitRootFrom(dir) if err != nil { return "", fmt.Errorf("failed to get repository root for path %s: %w", path, err) } - gitRoot := strings.TrimSpace(string(output)) gitLog.Printf("Found git root for path: %s", gitRoot) return gitRoot, nil } diff --git a/pkg/gitutil/gitutil.go b/pkg/gitutil/gitutil.go index 5ffb76626ea..497d9c92b17 100644 --- a/pkg/gitutil/gitutil.go +++ b/pkg/gitutil/gitutil.go @@ -1,7 +1,9 @@ package gitutil import ( + "errors" "fmt" + "os" "os/exec" "path/filepath" "regexp" @@ -75,18 +77,68 @@ func ExtractBaseRepo(repoPath string) string { } // FindGitRoot finds the root directory of the git repository. -// Returns an error if not in a git repository or if the git command fails. +// Uses pure Go filesystem traversal to avoid requiring the git executable, +// which can fail when the binary runs under Rosetta 2 on macOS ARM64 or in +// environments where git is not on PATH. +// Returns an error if not in a git repository. func FindGitRoot() (string, error) { log.Print("Finding git root directory") - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - output, err := cmd.Output() + + dir, err := os.Getwd() + if err != nil { + log.Printf("Failed to get current directory: %v", err) + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + root, err := FindGitRootFrom(dir) if err != nil { log.Printf("Failed to find git root: %v", err) - return "", fmt.Errorf("not in a git repository or git command failed: %w", err) + return "", err + } + + log.Printf("Found git root: %s", root) + return root, nil +} + +// FindGitRootFrom finds the root directory of the git repository starting from +// the given directory. It traverses upward until it finds a .git entry (file or +// directory) or reaches the filesystem root. +// Returns an error if not in a git repository. +func FindGitRootFrom(startDir string) (string, error) { + dir, err := filepath.Abs(startDir) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute path for %q: %w", startDir, err) + } + dir = filepath.Clean(dir) + for { + gitPath := filepath.Join(dir, ".git") + info, err := os.Stat(gitPath) + if err == nil { + // .git exists — accept if it's a directory (normal repo) or a + // regular file (worktree / git-submodule pointer). + if info.IsDir() { + return dir, nil + } + // Worktree marker: must be a regular file beginning with "gitdir:" + if info.Mode().IsRegular() { + data, readErr := os.ReadFile(gitPath) + if readErr != nil { + return "", fmt.Errorf("failed to read .git file at %q: %w", gitPath, readErr) + } + if strings.HasPrefix(strings.TrimSpace(string(data)), "gitdir:") { + return dir, nil + } + } + } else if !errors.Is(err, os.ErrNotExist) { + // Unexpected error (e.g. permission denied) — surface it. + return "", fmt.Errorf("failed to stat %q: %w", gitPath, err) + } + parent := filepath.Dir(dir) + if parent == dir { + return "", errors.New("not in a git repository") + } + dir = parent } - gitRoot := strings.TrimSpace(string(output)) - log.Printf("Found git root: %s", gitRoot) - return gitRoot, nil } // ReadFileFromHEADWithRoot is like ReadFileFromHEAD but accepts a pre-computed git diff --git a/pkg/gitutil/gitutil_test.go b/pkg/gitutil/gitutil_test.go index bfb87542295..ec542ac4e57 100644 --- a/pkg/gitutil/gitutil_test.go +++ b/pkg/gitutil/gitutil_test.go @@ -3,6 +3,7 @@ package gitutil import ( + "os" "path/filepath" "testing" @@ -293,6 +294,90 @@ func TestFindGitRoot(t *testing.T) { }) } +func TestFindGitRootFrom(t *testing.T) { + t.Run("returns git root from the repository root itself", func(t *testing.T) { + gitRoot, err := FindGitRoot() + require.NoError(t, err, "must be inside a git repository") + + root, err := FindGitRootFrom(gitRoot) + require.NoError(t, err, "FindGitRootFrom should succeed when starting from the git root") + assert.Equal(t, gitRoot, root, "FindGitRootFrom from git root should return git root") + }) + + t.Run("returns git root from a subdirectory", func(t *testing.T) { + gitRoot, err := FindGitRoot() + require.NoError(t, err, "must be inside a git repository") + + // Create a temporary subdirectory inside the repo to avoid depending on + // specific repo layout (e.g. pkg/ may not exist in all test environments). + subDir, mkdirErr := os.MkdirTemp(gitRoot, "test-subdir-*") + require.NoError(t, mkdirErr, "should create temp subdir inside git repo") + defer os.RemoveAll(subDir) + + root, err := FindGitRootFrom(subDir) + require.NoError(t, err, "FindGitRootFrom should succeed from a subdirectory") + assert.Equal(t, gitRoot, root, "FindGitRootFrom from subdirectory should return the git root") + }) + + t.Run("returns error when starting outside any git repository", func(t *testing.T) { + tmpDir := t.TempDir() + // Create a nested directory that is definitely not a git repo + nonRepoDir := filepath.Join(tmpDir, "not-a-git-repo", "subdir") + require.NoError(t, os.MkdirAll(nonRepoDir, 0755), "should create nested temp dir") + + _, err := FindGitRootFrom(nonRepoDir) + require.Error(t, err, "FindGitRootFrom should return error outside a git repository") + assert.Contains(t, err.Error(), "not in a git repository", "error should mention not in git repository") + }) + + t.Run("returns git root when .git is a worktree marker file", func(t *testing.T) { + // Simulate a git worktree: the repo root has a .git *file* (not dir) + // whose content begins with "gitdir: /some/path" + tmpDir := t.TempDir() + repoRoot := filepath.Join(tmpDir, "worktree-repo") + require.NoError(t, os.MkdirAll(repoRoot, 0755)) + + // Write a valid worktree .git file + gitFile := filepath.Join(repoRoot, ".git") + require.NoError(t, os.WriteFile(gitFile, []byte("gitdir: /tmp/real-repo/.git/worktrees/myworktree\n"), 0644)) + + // Start from the root itself + root, err := FindGitRootFrom(repoRoot) + require.NoError(t, err, "FindGitRootFrom should detect a worktree .git file") + assert.Equal(t, repoRoot, root) + + // Start from a subdirectory inside the worktree + subDir := filepath.Join(repoRoot, "pkg", "sub") + require.NoError(t, os.MkdirAll(subDir, 0755)) + root, err = FindGitRootFrom(subDir) + require.NoError(t, err, "FindGitRootFrom should detect worktree root from a subdirectory") + assert.Equal(t, repoRoot, root) + }) + + t.Run("ignores non-worktree .git files without gitdir prefix", func(t *testing.T) { + // A plain file named .git that does NOT start with "gitdir:" should not + // be treated as a valid repo root. + tmpDir := t.TempDir() + repoRoot := filepath.Join(tmpDir, "fake-git-file") + require.NoError(t, os.MkdirAll(repoRoot, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, ".git"), []byte("not a valid git file\n"), 0644)) + + _, err := FindGitRootFrom(repoRoot) + require.Error(t, err, "FindGitRootFrom should not accept a .git file without gitdir: prefix") + assert.Contains(t, err.Error(), "not in a git repository") + }) + + t.Run("handles relative path input", func(t *testing.T) { + // "." should resolve to os.Getwd(). Skip gracefully if the working + // directory is not inside a git repository (e.g. some CI containers). + root, err := FindGitRootFrom(".") + if err != nil { + t.Skipf("skipping: working directory is not inside a git repository (%v)", err) + } + assert.NotEmpty(t, root) + }) +} + func TestReadFileFromHEADWithRoot(t *testing.T) { t.Run("reads a committed file with pre-computed root", func(t *testing.T) { gitRoot, err := FindGitRoot() diff --git a/pkg/gitutil/spec_test.go b/pkg/gitutil/spec_test.go index a397821337f..170ef35c161 100644 --- a/pkg/gitutil/spec_test.go +++ b/pkg/gitutil/spec_test.go @@ -270,8 +270,9 @@ func TestSpec_PublicAPI_IsValidFullSHA(t *testing.T) { // FindGitRoot as described in the package README.md. // // Specification: Returns the absolute path of the root directory of the current -// Git repository by running `git rev-parse --show-toplevel`. Returns an error -// if the working directory is not inside a Git repository. +// Git repository using pure Go filesystem traversal (looks for .git in the +// current directory and its parents). Returns an error if the working directory +// is not inside a Git repository. func TestSpec_PublicAPI_FindGitRoot(t *testing.T) { t.Run("returns non-empty absolute path when in git repository", func(t *testing.T) { root, err := FindGitRoot()