Skip to content
Merged
10 changes: 4 additions & 6 deletions pkg/cli/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
66 changes: 59 additions & 7 deletions pkg/gitutil/gitutil.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package gitutil

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -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)
Comment on lines +107 to +136
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
Expand Down
85 changes: 85 additions & 0 deletions pkg/gitutil/gitutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package gitutil

import (
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -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")

Comment on lines +297 to +301
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()
Expand Down
5 changes: 3 additions & 2 deletions pkg/gitutil/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading