Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Git worktrees for sync #1831

Merged
merged 7 commits into from
Oct 21, 2024
Merged
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
64 changes: 39 additions & 25 deletions libs/git/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,21 @@ type Repository struct {
// directory where we process .gitignore files.
real bool

// root is the absolute path to the repository root.
root vfs.Path
// rootDir is the path to the root of the repository checkout.
// This can be either the main repository checkout or a worktree checkout.
// For more information about worktrees, see: https://git-scm.com/docs/git-worktree#_description.
rootDir vfs.Path

// gitDir is the equivalent of $GIT_DIR and points to the
// `.git` directory of a repository or a worktree directory.
// See https://git-scm.com/docs/git-worktree#_details for more information.
gitDir vfs.Path

// gitCommonDir is the equivalent of $GIT_COMMON_DIR and points to the
// `.git` directory of the main working tree (common between worktrees).
// This is equivalent to [gitDir] if this is the main working tree.
// See https://git-scm.com/docs/git-worktree#_details for more information.
gitCommonDir vfs.Path
pietern marked this conversation as resolved.
Show resolved Hide resolved

// ignore contains a list of ignore patterns indexed by the
// path prefix relative to the repository root.
Expand All @@ -44,12 +57,11 @@ type Repository struct {

// Root returns the absolute path to the repository root.
func (r *Repository) Root() string {
pietern marked this conversation as resolved.
Show resolved Hide resolved
return r.root.Native()
return r.rootDir.Native()
}

func (r *Repository) CurrentBranch() (string, error) {
// load .git/HEAD
ref, err := LoadReferenceFile(r.root, path.Join(GitDirectoryName, "HEAD"))
ref, err := LoadReferenceFile(r.gitDir, "HEAD")
if err != nil {
return "", err
}
Expand All @@ -65,8 +77,7 @@ func (r *Repository) CurrentBranch() (string, error) {
}

func (r *Repository) LatestCommit() (string, error) {
// load .git/HEAD
ref, err := LoadReferenceFile(r.root, path.Join(GitDirectoryName, "HEAD"))
ref, err := LoadReferenceFile(r.gitDir, "HEAD")
if err != nil {
return "", err
}
Expand All @@ -80,12 +91,12 @@ func (r *Repository) LatestCommit() (string, error) {
return ref.Content, nil
}

// read reference from .git/HEAD
// Read reference from $GIT_DIR/HEAD
branchHeadPath, err := ref.ResolvePath()
if err != nil {
return "", err
}
branchHeadRef, err := LoadReferenceFile(r.root, path.Join(GitDirectoryName, branchHeadPath))
branchHeadRef, err := LoadReferenceFile(r.gitCommonDir, branchHeadPath)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -125,20 +136,14 @@ func (r *Repository) loadConfig() error {
if err != nil {
return fmt.Errorf("unable to load user specific gitconfig: %w", err)
}
err = config.loadFile(r.root, ".git/config")
err = config.loadFile(r.gitCommonDir, "config")
if err != nil {
return fmt.Errorf("unable to load repository specific gitconfig: %w", err)
}
r.config = config
return nil
}

// newIgnoreFile constructs a new [ignoreRules] implementation backed by
// a file using the specified path relative to the repository root.
func (r *Repository) newIgnoreFile(relativeIgnoreFilePath string) ignoreRules {
return newIgnoreFile(r.root, relativeIgnoreFilePath)
}

// getIgnoreRules returns a slice of [ignoreRules] that apply
// for the specified prefix. The prefix must be cleaned by the caller.
// It lazily initializes an entry for the specified prefix if it
Expand All @@ -149,7 +154,7 @@ func (r *Repository) getIgnoreRules(prefix string) []ignoreRules {
return fs
}

r.ignore[prefix] = append(r.ignore[prefix], r.newIgnoreFile(path.Join(prefix, gitIgnoreFileName)))
r.ignore[prefix] = append(r.ignore[prefix], newIgnoreFile(r.rootDir, path.Join(prefix, gitIgnoreFileName)))
return r.ignore[prefix]
}

Expand Down Expand Up @@ -205,21 +210,30 @@ func (r *Repository) Ignore(relPath string) (bool, error) {

func NewRepository(path vfs.Path) (*Repository, error) {
real := true
rootPath, err := vfs.FindLeafInTree(path, GitDirectoryName)
rootDir, err := vfs.FindLeafInTree(path, GitDirectoryName)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
// Cannot find `.git` directory.
// Treat the specified path as a potential repository root.
// Treat the specified path as a potential repository root checkout.
real = false
rootPath = path
rootDir = path
}

// Derive $GIT_DIR and $GIT_COMMON_DIR paths if this is a real repository.
// If it isn't a real repository, they'll point to the (non-existent) `.git` directory.
gitDir, gitCommonDir, err := resolveGitDirs(rootDir)
if err != nil {
return nil, err
}

repo := &Repository{
real: real,
root: rootPath,
ignore: make(map[string][]ignoreRules),
real: real,
rootDir: rootDir,
gitDir: gitDir,
gitCommonDir: gitCommonDir,
ignore: make(map[string][]ignoreRules),
}

err = repo.loadConfig()
Expand Down Expand Up @@ -253,9 +267,9 @@ func NewRepository(path vfs.Path) (*Repository, error) {
".git",
}),
// Load repository-wide exclude file.
repo.newIgnoreFile(".git/info/exclude"),
newIgnoreFile(repo.gitCommonDir, "info/exclude"),
// Load root gitignore file.
repo.newIgnoreFile(".gitignore"),
newIgnoreFile(repo.rootDir, ".gitignore"),
}

return repo, nil
Expand Down
2 changes: 1 addition & 1 deletion libs/git/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func NewView(root vfs.Path) (*View, error) {

// Target path must be relative to the repository root path.
target := root.Native()
prefix := repo.root.Native()
prefix := repo.rootDir.Native()
if !strings.HasPrefix(target, prefix) {
return nil, fmt.Errorf("path %q is not within repository root %q", root.Native(), prefix)
}
Expand Down
123 changes: 123 additions & 0 deletions libs/git/worktree.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package git

import (
"bufio"
"errors"
"fmt"
"io/fs"
"path/filepath"
"strings"

"github.com/databricks/cli/libs/vfs"
)

func readLines(root vfs.Path, name string) ([]string, error) {
file, err := root.Open(name)
if err != nil {
return nil, err
}

defer file.Close()

var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}

return lines, scanner.Err()
}

// readGitDir reads the value of the `.git` file in a worktree.
func readGitDir(root vfs.Path) (string, error) {
lines, err := readLines(root, GitDirectoryName)
if err != nil {
return "", err
}

var gitDir string
for _, line := range lines {
parts := strings.SplitN(line, ": ", 2)
if len(parts) != 2 {
continue
}

if parts[0] == "gitdir" {
gitDir = strings.TrimSpace(parts[1])
}
}

if gitDir == "" {
return "", fmt.Errorf(`expected %q to contain a line with "gitdir: [...]"`, filepath.Join(root.Native(), GitDirectoryName))
}

return gitDir, nil
}

// readGitCommonDir reads the value of the `commondir` file in the `.git` directory of a worktree.
// This file typically contains "../.." to point to $GIT_COMMON_DIR.
func readGitCommonDir(gitDir vfs.Path) (string, error) {
lines, err := readLines(gitDir, "commondir")
if err != nil {
return "", err
}

if len(lines) == 0 {
return "", errors.New("file is empty")
}

return strings.TrimSpace(lines[0]), nil
}

// resolveGitDirs resolves the paths for $GIT_DIR and $GIT_COMMON_DIR.
// The path argument is the root of the checkout where (supposedly) a `.git` file or directory exists.
func resolveGitDirs(root vfs.Path) (vfs.Path, vfs.Path, error) {
fileInfo, err := root.Stat(GitDirectoryName)
if err != nil {
// If the `.git` file or directory does not exist, then this is not a git repository.
// Return paths that we know don't exist, so we do not need to perform nil checks in the caller.
if errors.Is(err, fs.ErrNotExist) {
gitDir := vfs.MustNew(filepath.Join(root.Native(), GitDirectoryName))
return gitDir, gitDir, nil
}
return nil, nil, err
}

// If the path is a directory, then it is the main working tree.
// Both $GIT_DIR and $GIT_COMMON_DIR point to the same directory.
if fileInfo.IsDir() {
gitDir := vfs.MustNew(filepath.Join(root.Native(), GitDirectoryName))
return gitDir, gitDir, nil
}

// If the path is not a directory, then it is a worktree.
// Read value for $GIT_DIR.
gitDirValue, err := readGitDir(root)
if err != nil {
return nil, nil, err
}

// Resolve $GIT_DIR.
var gitDir vfs.Path
if filepath.IsAbs(gitDirValue) {
gitDir = vfs.MustNew(gitDirValue)
} else {
gitDir = vfs.MustNew(filepath.Join(root.Native(), gitDirValue))
}

// Read value for $GIT_COMMON_DIR.
gitCommonDirValue, err := readGitCommonDir(gitDir)
if err != nil {
return nil, nil, fmt.Errorf(`expected "commondir" file in worktree git folder at %q: %w`, gitDir.Native(), err)
}

// Resolve $GIT_COMMON_DIR.
var gitCommonDir vfs.Path
if filepath.IsAbs(gitCommonDirValue) {
gitCommonDir = vfs.MustNew(gitCommonDirValue)
} else {
gitCommonDir = vfs.MustNew(filepath.Join(gitDir.Native(), gitCommonDirValue))
}

return gitDir, gitCommonDir, nil
}
108 changes: 108 additions & 0 deletions libs/git/worktree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package git

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

"github.com/databricks/cli/libs/vfs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func setupWorktree(t *testing.T) string {
var err error

tmpDir := t.TempDir()

// Checkout path
err = os.MkdirAll(filepath.Join(tmpDir, "my_worktree"), os.ModePerm)
require.NoError(t, err)

// Main $GIT_COMMON_DIR
err = os.MkdirAll(filepath.Join(tmpDir, ".git"), os.ModePerm)
require.NoError(t, err)

// Worktree $GIT_DIR
err = os.MkdirAll(filepath.Join(tmpDir, ".git/worktrees/my_worktree"), os.ModePerm)
require.NoError(t, err)

return tmpDir
}

func writeGitDir(t *testing.T, dir, content string) {
err := os.WriteFile(filepath.Join(dir, "my_worktree/.git"), []byte(content), os.ModePerm)
require.NoError(t, err)
}

func writeGitCommonDir(t *testing.T, dir, content string) {
err := os.WriteFile(filepath.Join(dir, ".git/worktrees/my_worktree/commondir"), []byte(content), os.ModePerm)
require.NoError(t, err)
}

func verifyCorrectDirs(t *testing.T, dir string) {
gitDir, gitCommonDir, err := resolveGitDirs(vfs.MustNew(filepath.Join(dir, "my_worktree")))
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, ".git/worktrees/my_worktree"), gitDir.Native())
assert.Equal(t, filepath.Join(dir, ".git"), gitCommonDir.Native())
}

func TestWorktreeResolveGitDir(t *testing.T) {
dir := setupWorktree(t)
writeGitCommonDir(t, dir, "../..")

t.Run("relative", func(t *testing.T) {
writeGitDir(t, dir, fmt.Sprintf("gitdir: %s", "../.git/worktrees/my_worktree"))
verifyCorrectDirs(t, dir)
})

t.Run("absolute", func(t *testing.T) {
writeGitDir(t, dir, fmt.Sprintf("gitdir: %s", filepath.Join(dir, ".git/worktrees/my_worktree")))
verifyCorrectDirs(t, dir)
})

t.Run("additional spaces", func(t *testing.T) {
writeGitDir(t, dir, fmt.Sprintf("gitdir: %s \n\n\n", "../.git/worktrees/my_worktree"))
verifyCorrectDirs(t, dir)
})

t.Run("empty", func(t *testing.T) {
writeGitDir(t, dir, "")

_, _, err := resolveGitDirs(vfs.MustNew(filepath.Join(dir, "my_worktree")))
assert.ErrorContains(t, err, ` to contain a line with "gitdir: [...]"`)
})
}

func TestWorktreeResolveCommonDir(t *testing.T) {
dir := setupWorktree(t)
writeGitDir(t, dir, fmt.Sprintf("gitdir: %s", "../.git/worktrees/my_worktree"))

t.Run("relative", func(t *testing.T) {
writeGitCommonDir(t, dir, "../..")
verifyCorrectDirs(t, dir)
})

t.Run("absolute", func(t *testing.T) {
writeGitCommonDir(t, dir, filepath.Join(dir, ".git"))
verifyCorrectDirs(t, dir)
})

t.Run("additional spaces", func(t *testing.T) {
writeGitCommonDir(t, dir, " ../.. \n\n\n")
verifyCorrectDirs(t, dir)
})

t.Run("empty", func(t *testing.T) {
writeGitCommonDir(t, dir, "")

_, _, err := resolveGitDirs(vfs.MustNew(filepath.Join(dir, "my_worktree")))
assert.ErrorContains(t, err, `expected "commondir" file in worktree git folder at `)
})

t.Run("missing", func(t *testing.T) {
_, _, err := resolveGitDirs(vfs.MustNew(filepath.Join(dir, "my_worktree")))
assert.ErrorContains(t, err, `expected "commondir" file in worktree git folder at `)
})
}