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
51 changes: 50 additions & 1 deletion pkg/sources/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,50 @@ func (s *Git) CommitsScanned() uint64 {

const gitDirName = ".git"

// resolveGitDir resolves the actual git directory path for a repository.
// In a regular repository, .git is a directory containing the git data.
// In a git worktree, .git is a file containing a "gitdir: <path>" reference
// to the actual git directory location.
// This function handles both cases and returns the path to the actual git directory.
func resolveGitDir(repoPath string) (string, error) {
gitPath := filepath.Join(repoPath, gitDirName)

info, err := os.Stat(gitPath)
if err != nil {
return "", fmt.Errorf("failed to stat .git: %w", err)
}

// If .git is a directory, return it directly
if info.IsDir() {
return gitPath, nil
}

// .git is a file (worktree) - read and parse the gitdir reference
content, err := os.ReadFile(gitPath)
if err != nil {
return "", fmt.Errorf("failed to read .git file: %w", err)
}

// Parse "gitdir: <path>" format
line := strings.TrimSpace(string(content))
const gitdirPrefix = "gitdir: "
if !strings.HasPrefix(line, gitdirPrefix) {
return "", fmt.Errorf("invalid .git file format: expected 'gitdir: <path>', got %q", line)
}

gitdirPath := strings.TrimPrefix(line, gitdirPrefix)

// The path may be relative to the worktree directory
if !filepath.IsAbs(gitdirPath) {
gitdirPath = filepath.Join(repoPath, gitdirPath)
}

// Clean the path to resolve any ".." components
gitdirPath = filepath.Clean(gitdirPath)

return gitdirPath, nil
}

// getGitDir returns the likely path of the ".git" directory.
// If the repository is bare, it will be at the top-level; otherwise, it
// exists in the ".git" directory at the root of the working tree.
Expand Down Expand Up @@ -1346,8 +1390,13 @@ func PrepareRepo(ctx context.Context, uriString, clonePath string, trustLocalGit
// Note: To scan **un**staged changes in the future, we'd need to set core.worktree to the original path.
uriPath := normalizedURI.Path

originalIndexPath := filepath.Join(uriPath, gitDirName, "index")
// Resolve the actual git directory (handles both regular repos and worktrees)
originalGitDir, err := resolveGitDir(uriPath)
if err != nil {
return path, remote, fmt.Errorf("failed to resolve git directory: %w", err)
}

originalIndexPath := filepath.Join(originalGitDir, "index")
clonedIndexPath := filepath.Join(path, gitDirName, "index")

indexData, err := os.ReadFile(originalIndexPath)
Expand Down
107 changes: 107 additions & 0 deletions pkg/sources/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,113 @@ func TestPrepareRepoErrorPaths(t *testing.T) {
})
}

func TestResolveGitDir(t *testing.T) {
t.Parallel()

t.Run("regular repository with .git directory", func(t *testing.T) {
repoPath := setupTestRepo(t, "regular-repo")
addTestFileAndCommit(t, repoPath, "test.txt", "test content")

gitDir, err := resolveGitDir(repoPath)
assert.NoError(t, err)
assert.Equal(t, filepath.Join(repoPath, ".git"), gitDir)

// Verify it's actually a directory
info, err := os.Stat(gitDir)
assert.NoError(t, err)
assert.True(t, info.IsDir())
})

t.Run("git worktree with .git file", func(t *testing.T) {
// Create main repository
mainRepoPath := setupTestRepo(t, "main-repo")
addTestFileAndCommit(t, mainRepoPath, "test.txt", "test content")

// Create a worktree
worktreePath := filepath.Join(filepath.Dir(mainRepoPath), "worktree")
err := exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "worktree-branch").Run()
assert.NoError(t, err)

// Verify .git is a file in the worktree
gitPath := filepath.Join(worktreePath, ".git")
info, err := os.Stat(gitPath)
assert.NoError(t, err)
assert.False(t, info.IsDir(), ".git should be a file in a worktree")

// Test resolveGitDir
gitDir, err := resolveGitDir(worktreePath)
assert.NoError(t, err)
assert.NotEqual(t, gitPath, gitDir, "resolved git dir should be different from .git file path")

// Verify the resolved path is a valid git directory (should contain index)
indexPath := filepath.Join(gitDir, "index")
_, err = os.Stat(indexPath)
assert.NoError(t, err, "resolved git dir should contain index file")
})

t.Run("nonexistent repository", func(t *testing.T) {
_, err := resolveGitDir("/nonexistent/path")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to stat .git")
})

t.Run("invalid .git file content", func(t *testing.T) {
tempDir := t.TempDir()
gitPath := filepath.Join(tempDir, ".git")

// Create an invalid .git file (not starting with "gitdir: ")
err := os.WriteFile(gitPath, []byte("invalid content"), 0644)
assert.NoError(t, err)

_, err = resolveGitDir(tempDir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid .git file format")
})
}

func TestPrepareRepoWithWorktree(t *testing.T) {
t.Parallel()
ctx := context.Background()

// Create main repository with staged changes
mainRepoPath := setupTestRepo(t, "main-repo-worktree")
addTestFileAndCommit(t, mainRepoPath, "test.txt", "initial content")

// Create a worktree
worktreePath := filepath.Join(filepath.Dir(mainRepoPath), "test-worktree")
err := exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "worktree-branch").Run()
assert.NoError(t, err)

// Stage some changes in the worktree
testFile := filepath.Join(worktreePath, "test.txt")
assert.NoError(t, os.WriteFile(testFile, []byte("modified content in worktree"), 0644))
assert.NoError(t, exec.Command("git", "-C", worktreePath, "add", "test.txt").Run())

// Verify staged changes exist in worktree
output, err := exec.Command("git", "-C", worktreePath, "diff", "--cached").Output()
assert.NoError(t, err)
assert.Contains(t, string(output), "modified content in worktree", "Staged changes should exist in worktree")

t.Run("PrepareRepo should work with git worktree", func(t *testing.T) {
fileURI := "file://" + worktreePath
preparedPath, isRemote, err := PrepareRepo(ctx, fileURI, "", false, false)

assert.NoError(t, err, "PrepareRepo should succeed with git worktree")
assert.False(t, isRemote)
assert.NotEmpty(t, preparedPath)

// Verify the cloned repo has the staged changes preserved
if preparedPath != "" {
defer func() { _ = os.RemoveAll(preparedPath) }()
stagedOutput, err := exec.Command("git", "-C", preparedPath, "diff", "--cached").Output()
if err == nil && len(stagedOutput) > 0 {
assert.Contains(t, string(stagedOutput), "modified content in worktree",
"Staged changes should be preserved when cloning from worktree")
}
}
})
}

func TestNormalizeFileURI(t *testing.T) {
tests := []struct {
name string
Expand Down