Files
kandev/apps/backend/internal/worktree/submodule_test.go
T
José Almeida e86b6655a3 fix(worktree): handle submodules in worktree creation (#579)
* fix(worktree): handle submodules in worktree creation

Repos with git submodules failed during worktree creation when git-crypt
was also present. The `git checkout HEAD -- .` command in
unlockGitCryptAndCheckout() tried to process submodule gitlink entries,
but git resolved their git dirs relative to the worktree-specific git
dir instead of the common dir, causing "not a git repository" errors.

- Detect submodule paths via `git ls-tree -r HEAD` (mode 160000)
- Exclude submodule paths from git-crypt checkout using :(exclude) pathspecs
- Initialize submodules after worktree creation via `git submodule update --init`
  (non-fatal — unreachable URLs are logged as warnings)
- Add submodule init to all worktree creation paths (git-crypt and non-git-crypt)

* fix(worktree): address review — recursive submodule init, consistent call sites

- Add --recursive to git submodule update --init for nested submodules
- Call initSubmodules unconditionally in git-crypt path (matching manager.go)

* fix(worktree): restore submodule index entries after git-crypt checkout

The :(exclude) pathspec used to skip submodules during git checkout also
prevents their gitlink entries from being written to the index. This
leaves git status showing them as staged deletions.

After checkout, run git reset HEAD for each excluded submodule path to
copy the gitlink entry from HEAD back into the index.

* test(worktree): add git status assertion to submodule unlock test

Validate that the index reset after :(exclude) checkout leaves no
staged deletions for submodule paths.

* fix(worktree): use literal pathspec for submodule exclusion

Use :(literal,exclude) instead of :(exclude) to prevent glob
metacharacters in submodule paths from being interpreted as patterns.

* fix(worktree): fix initSubmodules docstring to match --recursive flag
2026-04-14 15:59:25 +01:00

233 lines
7.5 KiB
Go

package worktree
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
func allowFileProtocol(t *testing.T) {
t.Helper()
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "protocol.file.allow")
t.Setenv("GIT_CONFIG_VALUE_0", "always")
}
func initRepoWithSubmodule(t *testing.T) (repoPath, submodulePath string) {
t.Helper()
allowFileProtocol(t)
// Create the submodule repository first.
submodulePath = t.TempDir()
runGit(t, submodulePath, "init", "-b", "main")
runGit(t, submodulePath, "config", "user.email", "test@example.com")
runGit(t, submodulePath, "config", "user.name", "Test User")
runGit(t, submodulePath, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(submodulePath, "lib.txt"), []byte("library code\n"), 0644); err != nil {
t.Fatalf("write lib.txt: %v", err)
}
runGit(t, submodulePath, "add", ".")
runGit(t, submodulePath, "commit", "-m", "initial submodule commit")
// Create the main repository.
repoPath = t.TempDir()
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test User")
runGit(t, repoPath, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(repoPath, "main.txt"), []byte("main code\n"), 0644); err != nil {
t.Fatalf("write main.txt: %v", err)
}
runGit(t, repoPath, "add", ".")
runGit(t, repoPath, "commit", "-m", "initial main commit")
// Add the submodule.
runGit(t, repoPath, "submodule", "add", submodulePath, "libs/external")
runGit(t, repoPath, "commit", "-m", "add submodule")
return repoPath, submodulePath
}
func TestGetSubmodulePaths(t *testing.T) {
repoPath, _ := initRepoWithSubmodule(t)
paths, err := getSubmodulePaths(context.Background(), repoPath)
if err != nil {
t.Fatalf("getSubmodulePaths() error: %v", err)
}
if len(paths) != 1 || paths[0] != "libs/external" {
t.Errorf("getSubmodulePaths() = %v, want [libs/external]", paths)
}
}
func TestGetSubmodulePaths_NoSubmodules(t *testing.T) {
repoPath := t.TempDir()
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test User")
runGit(t, repoPath, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(repoPath, "file.txt"), []byte("content\n"), 0644); err != nil {
t.Fatal(err)
}
runGit(t, repoPath, "add", ".")
runGit(t, repoPath, "commit", "-m", "initial")
paths, err := getSubmodulePaths(context.Background(), repoPath)
if err != nil {
t.Fatalf("getSubmodulePaths() error: %v", err)
}
if len(paths) != 0 {
t.Errorf("getSubmodulePaths() = %v, want empty", paths)
}
}
func TestGetSubmodulePaths_MultipleSubmodules(t *testing.T) {
allowFileProtocol(t)
// Create two submodule repos.
sub1 := t.TempDir()
runGit(t, sub1, "init", "-b", "main")
runGit(t, sub1, "config", "user.email", "test@example.com")
runGit(t, sub1, "config", "user.name", "Test User")
runGit(t, sub1, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(sub1, "a.txt"), []byte("a\n"), 0644); err != nil {
t.Fatal(err)
}
runGit(t, sub1, "add", ".")
runGit(t, sub1, "commit", "-m", "init")
sub2 := t.TempDir()
runGit(t, sub2, "init", "-b", "main")
runGit(t, sub2, "config", "user.email", "test@example.com")
runGit(t, sub2, "config", "user.name", "Test User")
runGit(t, sub2, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(sub2, "b.txt"), []byte("b\n"), 0644); err != nil {
t.Fatal(err)
}
runGit(t, sub2, "add", ".")
runGit(t, sub2, "commit", "-m", "init")
// Main repo with both submodules.
repoPath := t.TempDir()
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test User")
runGit(t, repoPath, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(repoPath, "root.txt"), []byte("root\n"), 0644); err != nil {
t.Fatal(err)
}
runGit(t, repoPath, "add", ".")
runGit(t, repoPath, "commit", "-m", "initial")
runGit(t, repoPath, "submodule", "add", sub1, "vendor/alpha")
runGit(t, repoPath, "submodule", "add", sub2, "vendor/beta")
runGit(t, repoPath, "commit", "-m", "add submodules")
paths, err := getSubmodulePaths(context.Background(), repoPath)
if err != nil {
t.Fatalf("getSubmodulePaths() error: %v", err)
}
sort.Strings(paths)
if len(paths) != 2 || paths[0] != "vendor/alpha" || paths[1] != "vendor/beta" {
t.Errorf("getSubmodulePaths() = %v, want [vendor/alpha vendor/beta]", paths)
}
}
func TestGetSubmodulePaths_WorktreeNoCheckout(t *testing.T) {
repoPath, _ := initRepoWithSubmodule(t)
wtPath := filepath.Join(t.TempDir(), "wt")
runGit(t, repoPath, "worktree", "add", "--no-checkout", "-b", "test-branch", wtPath, "main")
paths, err := getSubmodulePaths(context.Background(), wtPath)
if err != nil {
t.Fatalf("getSubmodulePaths() in --no-checkout worktree error: %v", err)
}
if len(paths) != 1 || paths[0] != "libs/external" {
t.Errorf("getSubmodulePaths() = %v, want [libs/external]", paths)
}
}
func TestInitSubmodules(t *testing.T) {
repoPath, _ := initRepoWithSubmodule(t)
// Create a worktree — submodule won't be initialized.
wtPath := filepath.Join(t.TempDir(), "wt")
runGit(t, repoPath, "worktree", "add", "-b", "sub-test", wtPath, "main")
// Submodule directory should exist but be empty (not initialized).
subDir := filepath.Join(wtPath, "libs", "external")
entries, err := os.ReadDir(subDir)
if err != nil {
t.Fatalf("submodule dir should exist: %v", err)
}
if len(entries) != 0 {
t.Fatalf("submodule dir should be empty before init, got %d entries", len(entries))
}
// Run initSubmodules.
log := newTestLogger()
m := &Manager{logger: log}
m.initSubmodules(context.Background(), wtPath)
// Submodule should now have content.
content, err := os.ReadFile(filepath.Join(subDir, "lib.txt"))
if err != nil {
t.Fatalf("read submodule file after init: %v", err)
}
if strings.TrimSpace(string(content)) != "library code" {
t.Errorf("submodule lib.txt = %q, want %q", string(content), "library code")
}
}
func TestCreateWorktree_WithSubmodules(t *testing.T) {
repoPath, _ := initRepoWithSubmodule(t)
cfg := newTestConfig(t)
log := newTestLogger()
store := newMockStore()
mgr, err := NewManager(cfg, store, log)
if err != nil {
t.Fatalf("NewManager: %v", err)
}
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "sub-task-1",
SessionID: "sub-session-1",
TaskTitle: "Submodule Test",
RepositoryID: "repo-sub",
RepositoryPath: repoPath,
BaseBranch: "main",
})
if err != nil {
t.Fatalf("Create() failed: %v", err)
}
// Main file should be present.
mainContent, err := os.ReadFile(filepath.Join(wt.Path, "main.txt"))
if err != nil {
t.Fatalf("read main.txt: %v", err)
}
if strings.TrimSpace(string(mainContent)) != "main code" {
t.Errorf("main.txt = %q, want %q", string(mainContent), "main code")
}
// Submodule should be initialized with content.
subContent, err := os.ReadFile(filepath.Join(wt.Path, "libs", "external", "lib.txt"))
if err != nil {
t.Fatalf("read submodule lib.txt: %v", err)
}
if strings.TrimSpace(string(subContent)) != "library code" {
t.Errorf("submodule lib.txt = %q, want %q", string(subContent), "library code")
}
// Working tree should be clean.
status := strings.TrimSpace(runGit(t, wt.Path, "status", "--porcelain"))
if status != "" {
t.Errorf("worktree is not clean: %s", status)
}
}