From 667f1ad36419d50f5ad461f0c5e8595d5beb3e97 Mon Sep 17 00:00:00 2001 From: Andy Postnikov Date: Sun, 8 Feb 2026 05:51:12 +0100 Subject: [PATCH] Fix compose not working in git worktrees In a git worktree, .git is a file (not a directory) pointing to the main repository's .git/worktrees// directory. The worktree-specific git dir only contains HEAD and a commondir file, while shared data (objects, refs, config, packed-refs) lives in the main .git directory. go-git's PlainOpen() uses EnableDotGitCommonDir: false by default, which means it follows the .git file to the worktree dir but does not read the commondir file to discover shared data. This causes operations like CommitObject() to fail because the objects database is not found in the worktree-specific directory. Replace git.PlainOpen() with git.PlainOpenWithOptions() using EnableDotGitCommonDir: true in both affected locations: - compose/builder.go: getVersionedMap() - used to determine versioned files when --skip-not-versioned is set. Without the fix, the error is silently caught and the flag has no effect in worktrees. - compose/git.go: EnsureLatest() - used to check if downloaded packages are up to date. Changed for consistency and robustness. EnableDotGitCommonDir: true is safe for non-worktree repos: the commondir file won't exist, so go-git falls through to normal behavior. Add tests verifying getVersionedMap() and PlainOpenWithOptions work correctly on both regular repositories and git worktrees. Co-Authored-By: Claude Opus 4.6 --- compose/builder.go | 2 +- compose/builder_test.go | 139 ++++++++++++++++++++++++++++++++++++++++ compose/git.go | 2 +- compose/git_test.go | 106 ++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 compose/builder_test.go create mode 100644 compose/git_test.go diff --git a/compose/builder.go b/compose/builder.go index 6b69370..a000bfa 100644 --- a/compose/builder.go +++ b/compose/builder.go @@ -156,7 +156,7 @@ func createBuilder(c *Composer, targetDir, sourceDir string, packages []*Package func getVersionedMap(gitDir string) (map[string]bool, error) { versionedFiles := make(map[string]bool) - repo, err := git.PlainOpen(gitDir) + repo, err := git.PlainOpenWithOptions(gitDir, &git.PlainOpenOptions{EnableDotGitCommonDir: true}) if err != nil { return versionedFiles, err } diff --git a/compose/builder_test.go b/compose/builder_test.go new file mode 100644 index 0000000..b09cc0a --- /dev/null +++ b/compose/builder_test.go @@ -0,0 +1,139 @@ +package compose + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func TestGetVersionedMap(t *testing.T) { + // Create a temporary git repo with some files. + repoDir := t.TempDir() + + repo, err := git.PlainInit(repoDir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + // Create test files. + files := []string{"file1.txt", "dir/file2.txt"} + for _, f := range files { + p := filepath.Join(repoDir, f) + if err := os.MkdirAll(filepath.Dir(p), 0750); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + if err := os.WriteFile(p, []byte("content"), 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + } + + // Stage and commit. + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + for _, f := range files { + if _, err := wt.Add(f); err != nil { + t.Fatalf("failed to add file: %v", err) + } + } + _, err = wt.Commit("initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "test", + Email: "test@test.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + versionedMap, err := getVersionedMap(repoDir) + if err != nil { + t.Fatalf("getVersionedMap failed: %v", err) + } + + // Verify files are in the map. + for _, f := range files { + if !versionedMap[f] { + t.Errorf("expected %q in versioned map", f) + } + } + + // Verify parent dir is in the map. + if !versionedMap["dir"] { + t.Error("expected 'dir' in versioned map") + } +} + +func TestGetVersionedMapWorktree(t *testing.T) { + // Create a temporary git repo. + repoDir := t.TempDir() + + repo, err := git.PlainInit(repoDir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + // Create a file and commit on main. + testFile := "file.txt" + if err := os.WriteFile(filepath.Join(repoDir, testFile), []byte("main content"), 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + if _, err := wt.Add(testFile); err != nil { + t.Fatalf("failed to add file: %v", err) + } + _, err = wt.Commit("initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "test", + Email: "test@test.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Create a branch for the worktree. + headRef, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + // Use git command to create the worktree, as go-git doesn't have + // a built-in worktree add command. + worktreeDir := t.TempDir() + cmd := testGitCommand(t, repoDir, "worktree", "add", worktreeDir, "-b", "test-branch", headRef.Hash().String()) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to create worktree: %v\n%s", err, out) + } + + // Verify the worktree has a .git file (not directory). + gitPath := filepath.Join(worktreeDir, ".git") + fi, err := os.Lstat(gitPath) + if err != nil { + t.Fatalf("failed to stat .git: %v", err) + } + if fi.IsDir() { + t.Fatal("expected .git to be a file in worktree, got directory") + } + + // This is the key test: getVersionedMap must work on a worktree. + versionedMap, err := getVersionedMap(worktreeDir) + if err != nil { + t.Fatalf("getVersionedMap failed on worktree: %v", err) + } + + if !versionedMap[testFile] { + t.Errorf("expected %q in versioned map from worktree", testFile) + } +} diff --git a/compose/git.go b/compose/git.go index 8284630..d28878d 100644 --- a/compose/git.go +++ b/compose/git.go @@ -156,7 +156,7 @@ func (g *gitDownloader) EnsureLatest(pkg *Package, downloadPath string) (bool, e return false, nil } - r, err := git.PlainOpen(downloadPath) + r, err := git.PlainOpenWithOptions(downloadPath, &git.PlainOpenOptions{EnableDotGitCommonDir: true}) if err != nil { g.k.Log().Debug("git init error", "err", err) return false, nil diff --git a/compose/git_test.go b/compose/git_test.go new file mode 100644 index 0000000..d74221e --- /dev/null +++ b/compose/git_test.go @@ -0,0 +1,106 @@ +package compose + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func testGitCommand(t *testing.T, dir string, args ...string) *exec.Cmd { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + return cmd +} + +func TestPlainOpenWorktree(t *testing.T) { + // Create a temporary git repo. + repoDir := t.TempDir() + + repo, err := git.PlainInit(repoDir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + // Create a file and commit. + testFile := "hello.txt" + if err := os.WriteFile(filepath.Join(repoDir, testFile), []byte("hello"), 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + if _, err := wt.Add(testFile); err != nil { + t.Fatalf("failed to add file: %v", err) + } + commitHash, err := wt.Commit("initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "test", + Email: "test@test.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Create a worktree using git CLI. + worktreeDir := t.TempDir() + cmd := testGitCommand(t, repoDir, "worktree", "add", worktreeDir, "-b", "wt-branch", commitHash.String()) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to create worktree: %v\n%s", err, out) + } + + // Verify .git is a file. + fi, err := os.Lstat(filepath.Join(worktreeDir, ".git")) + if err != nil { + t.Fatalf("failed to stat .git: %v", err) + } + if fi.IsDir() { + t.Fatal("expected .git to be a file in worktree, got directory") + } + + // PlainOpenWithOptions with EnableDotGitCommonDir should work. + r, err := git.PlainOpenWithOptions(worktreeDir, &git.PlainOpenOptions{EnableDotGitCommonDir: true}) + if err != nil { + t.Fatalf("PlainOpenWithOptions failed on worktree: %v", err) + } + + // Should be able to resolve HEAD. + head, err := r.Head() + if err != nil { + t.Fatalf("failed to get HEAD from worktree: %v", err) + } + + // Should be able to access objects (this is what fails without EnableDotGitCommonDir). + commit, err := r.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("failed to get commit object from worktree: %v", err) + } + + tree, err := commit.Tree() + if err != nil { + t.Fatalf("failed to get tree from worktree: %v", err) + } + + // Verify we can iterate files. + var files []string + err = tree.Files().ForEach(func(f *object.File) error { + files = append(files, f.Name) + return nil + }) + if err != nil { + t.Fatalf("failed to iterate files: %v", err) + } + + if len(files) != 1 || files[0] != testFile { + t.Errorf("expected [%s], got %v", testFile, files) + } +}