e86b6655a3
* 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
643 lines
19 KiB
Go
643 lines
19 KiB
Go
package worktree
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestHasGitCryptFilter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "file with git-crypt filter",
|
|
content: "secretfile filter=git-crypt diff=git-crypt\n*.key filter=git-crypt diff=git-crypt",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "file without git-crypt filter",
|
|
content: "*.txt text\n*.bin binary",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "empty file",
|
|
content: "",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "filter with different name",
|
|
content: "*.lfs filter=lfs diff=lfs",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "git-crypt in comment",
|
|
content: "# This uses filter=git-crypt for encryption\n*.txt text",
|
|
expected: true, // We still detect it - conservative approach
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create temp file
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, ".gitattributes")
|
|
if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
result := hasGitCryptFilter(tmpFile)
|
|
if result != tt.expected {
|
|
t.Errorf("hasGitCryptFilter() = %v, want %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasGitCryptFilter_NonexistentFile(t *testing.T) {
|
|
result := hasGitCryptFilter("/nonexistent/path/.gitattributes")
|
|
if result {
|
|
t.Error("hasGitCryptFilter() should return false for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestIsGitCryptSmudgeError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
output string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "smudge filter git-crypt failed",
|
|
output: "fatal: tools/secrets/config.yml: smudge filter git-crypt failed",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "external filter git-crypt smudge failed",
|
|
output: "error: external filter '\"git-crypt\" smudge' failed 1",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "external filter without quotes",
|
|
output: "error: external filter 'git-crypt smudge' failed",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "portuguese locale error",
|
|
output: "error: filtro externo 'git-crypt smudge' falhou 1",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "portuguese fatal with git-crypt smudge command",
|
|
output: "fatal: tools/config.yml: filtro de mancha git-crypt smudge falhou",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "german locale error",
|
|
output: "Fehler: externer Filter 'git-crypt smudge' fehlgeschlagen",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "unrelated git error",
|
|
output: "fatal: pathspec 'foo' did not match any files",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "empty output",
|
|
output: "",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "branch checkout error",
|
|
output: "fatal: 'main' is already checked out at '/path/to/worktree'",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "only git-crypt without smudge",
|
|
output: "git-crypt: some other error",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := isGitCryptSmudgeError(tt.output)
|
|
if result != tt.expected {
|
|
t.Errorf("isGitCryptSmudgeError(%q) = %v, want %v", tt.output, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUsesGitCrypt(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a fake git repo structure
|
|
gitDir := filepath.Join(tmpDir, ".git")
|
|
if err := os.MkdirAll(filepath.Join(gitDir, "info"), 0755); err != nil {
|
|
t.Fatalf("failed to create .git/info: %v", err)
|
|
}
|
|
|
|
log := newTestLogger()
|
|
m := &Manager{logger: log}
|
|
|
|
// Test 1: No .gitattributes
|
|
if m.usesGitCrypt(tmpDir) {
|
|
t.Error("usesGitCrypt() should return false when no .gitattributes exists")
|
|
}
|
|
|
|
// Test 2: .gitattributes without git-crypt
|
|
gitattributes := filepath.Join(tmpDir, ".gitattributes")
|
|
if err := os.WriteFile(gitattributes, []byte("*.txt text\n"), 0644); err != nil {
|
|
t.Fatalf("failed to write .gitattributes: %v", err)
|
|
}
|
|
if m.usesGitCrypt(tmpDir) {
|
|
t.Error("usesGitCrypt() should return false when .gitattributes has no git-crypt")
|
|
}
|
|
|
|
// Test 3: .gitattributes with git-crypt
|
|
if err := os.WriteFile(gitattributes, []byte("secrets/* filter=git-crypt diff=git-crypt\n"), 0644); err != nil {
|
|
t.Fatalf("failed to write .gitattributes: %v", err)
|
|
}
|
|
if !m.usesGitCrypt(tmpDir) {
|
|
t.Error("usesGitCrypt() should return true when .gitattributes has git-crypt")
|
|
}
|
|
}
|
|
|
|
func TestIsGitCryptUnlocked(t *testing.T) {
|
|
t.Run("returns false when dir does not exist", func(t *testing.T) {
|
|
if isGitCryptUnlocked("/nonexistent/git-crypt") {
|
|
t.Error("expected false for nonexistent directory")
|
|
}
|
|
})
|
|
|
|
t.Run("returns false when keys subdir is empty (locked)", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "keys"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if isGitCryptUnlocked(dir) {
|
|
t.Error("expected false for empty keys directory (locked repo)")
|
|
}
|
|
})
|
|
|
|
t.Run("returns true when keys subdir has entries (unlocked)", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
defaultKey := filepath.Join(dir, "keys", "default")
|
|
if err := os.MkdirAll(defaultKey, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !isGitCryptUnlocked(dir) {
|
|
t.Error("expected true when keys directory has entries (unlocked repo)")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------- E2E tests requiring git-crypt binary ----------
|
|
|
|
// skipIfNoGitCrypt skips the test if the git-crypt binary is not available.
|
|
func skipIfNoGitCrypt(t *testing.T) {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("git-crypt"); err != nil {
|
|
t.Skip("git-crypt not installed, skipping e2e test")
|
|
}
|
|
}
|
|
|
|
// initGitCryptRepo creates a temporary git repository with git-crypt
|
|
// initialised and a mix of encrypted and plain-text files committed.
|
|
// Returns the repo path. The caller owns cleanup via t.TempDir().
|
|
func initGitCryptRepo(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
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")
|
|
|
|
// Initialise git-crypt (generates symmetric key in .git/git-crypt/).
|
|
cmd := exec.Command("git-crypt", "init")
|
|
cmd.Dir = repoPath
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git-crypt init failed: %v\n%s", err, out)
|
|
}
|
|
|
|
// Set up .gitattributes: secret.txt is encrypted, public.txt is not.
|
|
if err := os.WriteFile(
|
|
filepath.Join(repoPath, ".gitattributes"),
|
|
[]byte("secret.txt filter=git-crypt diff=git-crypt\n"),
|
|
0644,
|
|
); err != nil {
|
|
t.Fatalf("write .gitattributes: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repoPath, "secret.txt"), []byte("top-secret-value\n"), 0644); err != nil {
|
|
t.Fatalf("write secret.txt: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repoPath, "public.txt"), []byte("public-value\n"), 0644); err != nil {
|
|
t.Fatalf("write public.txt: %v", err)
|
|
}
|
|
|
|
runGit(t, repoPath, "add", ".")
|
|
runGit(t, repoPath, "commit", "-m", "initial commit with git-crypt")
|
|
|
|
// Also create a feature branch for the "existing branch" test.
|
|
runGit(t, repoPath, "branch", "feature/encrypted-branch")
|
|
|
|
return repoPath
|
|
}
|
|
|
|
func TestCreateWorktree_GitCryptNewBranch(t *testing.T) {
|
|
skipIfNoGitCrypt(t)
|
|
|
|
cfg := newTestConfig(t)
|
|
log := newTestLogger()
|
|
store := newMockStore()
|
|
|
|
mgr, err := NewManager(cfg, store, log)
|
|
if err != nil {
|
|
t.Fatalf("NewManager: %v", err)
|
|
}
|
|
|
|
repoPath := initGitCryptRepo(t)
|
|
|
|
wt, err := mgr.Create(context.Background(), CreateRequest{
|
|
TaskID: "gc-task-1",
|
|
SessionID: "gc-session-1",
|
|
TaskTitle: "Git Crypt New Branch",
|
|
RepositoryID: "repo-gc",
|
|
RepositoryPath: repoPath,
|
|
BaseBranch: "main",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() failed: %v", err)
|
|
}
|
|
|
|
// The worktree must exist and be on a new branch.
|
|
if wt.Path == "" {
|
|
t.Fatal("worktree path is empty")
|
|
}
|
|
gotBranch := strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "--abbrev-ref", "HEAD"))
|
|
if gotBranch == "main" {
|
|
t.Fatal("expected a new branch, got main")
|
|
}
|
|
|
|
// Encrypted file must be decrypted in the worktree.
|
|
secret, err := os.ReadFile(filepath.Join(wt.Path, "secret.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read secret.txt: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(secret)) != "top-secret-value" {
|
|
t.Errorf("secret.txt = %q, want %q", string(secret), "top-secret-value\n")
|
|
}
|
|
|
|
// Plain-text file must also be present.
|
|
pub, err := os.ReadFile(filepath.Join(wt.Path, "public.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read public.txt: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(pub)) != "public-value" {
|
|
t.Errorf("public.txt = %q, want %q", string(pub), "public-value\n")
|
|
}
|
|
|
|
// Working tree must be clean.
|
|
status := strings.TrimSpace(runGit(t, wt.Path, "status", "--porcelain"))
|
|
if status != "" {
|
|
t.Errorf("worktree is not clean: %s", status)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktree_GitCryptExistingBranch(t *testing.T) {
|
|
skipIfNoGitCrypt(t)
|
|
|
|
cfg := newTestConfig(t)
|
|
log := newTestLogger()
|
|
store := newMockStore()
|
|
|
|
mgr, err := NewManager(cfg, store, log)
|
|
if err != nil {
|
|
t.Fatalf("NewManager: %v", err)
|
|
}
|
|
|
|
repoPath := initGitCryptRepo(t)
|
|
|
|
wt, err := mgr.Create(context.Background(), CreateRequest{
|
|
TaskID: "gc-task-2",
|
|
SessionID: "gc-session-2",
|
|
TaskTitle: "Git Crypt Existing Branch",
|
|
RepositoryID: "repo-gc",
|
|
RepositoryPath: repoPath,
|
|
BaseBranch: "main",
|
|
CheckoutBranch: "feature/encrypted-branch",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() failed: %v", err)
|
|
}
|
|
|
|
gotBranch := strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "--abbrev-ref", "HEAD"))
|
|
if gotBranch != "feature/encrypted-branch" {
|
|
t.Fatalf("branch = %q, want %q", gotBranch, "feature/encrypted-branch")
|
|
}
|
|
|
|
// Encrypted file must be decrypted.
|
|
secret, err := os.ReadFile(filepath.Join(wt.Path, "secret.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read secret.txt: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(secret)) != "top-secret-value" {
|
|
t.Errorf("secret.txt = %q, want %q", string(secret), "top-secret-value\n")
|
|
}
|
|
|
|
status := strings.TrimSpace(runGit(t, wt.Path, "status", "--porcelain"))
|
|
if status != "" {
|
|
t.Errorf("worktree is not clean: %s", status)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktree_GitCryptDetection(t *testing.T) {
|
|
skipIfNoGitCrypt(t)
|
|
|
|
log := newTestLogger()
|
|
m := &Manager{logger: log}
|
|
|
|
repoPath := initGitCryptRepo(t)
|
|
|
|
if !m.usesGitCrypt(repoPath) {
|
|
t.Error("usesGitCrypt() should return true for a git-crypt initialised repo")
|
|
}
|
|
|
|
// Verify that the object store actually has encrypted content.
|
|
raw := runGit(t, repoPath, "show", "HEAD:secret.txt")
|
|
if strings.Contains(raw, "top-secret-value") {
|
|
t.Error("expected encrypted blob in object store, got plaintext")
|
|
}
|
|
}
|
|
|
|
func TestUnlockGitCryptAndCheckout_ManualWorktree(t *testing.T) {
|
|
skipIfNoGitCrypt(t)
|
|
|
|
log := newTestLogger()
|
|
m := &Manager{logger: log}
|
|
|
|
repoPath := initGitCryptRepo(t)
|
|
|
|
// Manually create a worktree with --no-checkout (simulates what manager does).
|
|
wtPath := filepath.Join(t.TempDir(), "manual-wt")
|
|
runGit(t, repoPath, "worktree", "add", "-b", "manual-test", "--no-checkout", wtPath, "main")
|
|
|
|
// The worktree should have no files checked out.
|
|
entries, _ := os.ReadDir(wtPath)
|
|
fileCount := 0
|
|
for _, e := range entries {
|
|
if !strings.HasPrefix(e.Name(), ".") {
|
|
fileCount++
|
|
}
|
|
}
|
|
if fileCount != 0 {
|
|
t.Fatalf("expected 0 non-dot files after --no-checkout, got %d", fileCount)
|
|
}
|
|
|
|
// Now run unlockGitCryptAndCheckout.
|
|
if err := m.unlockGitCryptAndCheckout(context.Background(), wtPath); err != nil {
|
|
t.Fatalf("unlockGitCryptAndCheckout() failed: %v", err)
|
|
}
|
|
|
|
// Verify files are checked out and decrypted.
|
|
secret, err := os.ReadFile(filepath.Join(wtPath, "secret.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read secret.txt after unlock: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(secret)) != "top-secret-value" {
|
|
t.Errorf("secret.txt = %q, want %q", string(secret), "top-secret-value\n")
|
|
}
|
|
|
|
pub, err := os.ReadFile(filepath.Join(wtPath, "public.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read public.txt after unlock: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(pub)) != "public-value" {
|
|
t.Errorf("public.txt = %q, want %q", string(pub), "public-value\n")
|
|
}
|
|
|
|
status := strings.TrimSpace(runGit(t, wtPath, "status", "--porcelain"))
|
|
if status != "" {
|
|
t.Errorf("worktree not clean after unlock+checkout: %s", status)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktree_GitCryptLockedRepo(t *testing.T) {
|
|
skipIfNoGitCrypt(t)
|
|
|
|
cfg := newTestConfig(t)
|
|
log := newTestLogger()
|
|
store := newMockStore()
|
|
|
|
mgr, err := NewManager(cfg, store, log)
|
|
if err != nil {
|
|
t.Fatalf("NewManager: %v", err)
|
|
}
|
|
|
|
repoPath := initGitCryptRepo(t)
|
|
|
|
// Export the key before locking (git-crypt lock removes it from .git/).
|
|
keyFile := filepath.Join(t.TempDir(), "exported.key")
|
|
exportCmd := exec.Command("git-crypt", "export-key", keyFile)
|
|
exportCmd.Dir = repoPath
|
|
if out, err := exportCmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git-crypt export-key failed: %v\n%s", err, out)
|
|
}
|
|
|
|
// Lock the repo — removes keys, re-encrypts working tree files.
|
|
lockCmd := exec.Command("git-crypt", "lock")
|
|
lockCmd.Dir = repoPath
|
|
if out, err := lockCmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git-crypt lock failed: %v\n%s", err, out)
|
|
}
|
|
|
|
// Verify the repo is locked (secret.txt should be encrypted binary).
|
|
secretContent, _ := os.ReadFile(filepath.Join(repoPath, "secret.txt"))
|
|
if strings.Contains(string(secretContent), "top-secret-value") {
|
|
t.Fatal("expected secret.txt to be encrypted after lock")
|
|
}
|
|
|
|
// Create a worktree from the locked repo — should succeed.
|
|
wt, err := mgr.Create(context.Background(), CreateRequest{
|
|
TaskID: "gc-locked-1",
|
|
SessionID: "gc-locked-session-1",
|
|
TaskTitle: "Git Crypt Locked Repo",
|
|
RepositoryID: "repo-gc-locked",
|
|
RepositoryPath: repoPath,
|
|
BaseBranch: "main",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() with locked repo should succeed, got: %v", err)
|
|
}
|
|
|
|
// Public file must be present and readable.
|
|
pub, err := os.ReadFile(filepath.Join(wt.Path, "public.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read public.txt: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(pub)) != "public-value" {
|
|
t.Errorf("public.txt = %q, want %q", string(pub), "public-value")
|
|
}
|
|
|
|
// Encrypted file should exist but be binary (not decrypted).
|
|
secret, err := os.ReadFile(filepath.Join(wt.Path, "secret.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read secret.txt: %v", err)
|
|
}
|
|
if strings.Contains(string(secret), "top-secret-value") {
|
|
t.Error("secret.txt should be encrypted in worktree of locked repo")
|
|
}
|
|
|
|
// Verify that unlock-after-create works: the user can provide keys mid-session
|
|
// and git-crypt unlock should succeed, overwriting our smudge=cat setting.
|
|
unlockCmd := exec.Command("git-crypt", "unlock", keyFile)
|
|
unlockCmd.Dir = wt.Path
|
|
if out, unlockErr := unlockCmd.CombinedOutput(); unlockErr != nil {
|
|
t.Fatalf("git-crypt unlock in worktree should work: %v\n%s", unlockErr, out)
|
|
}
|
|
|
|
// After unlock, secret.txt should be decrypted.
|
|
secret, err = os.ReadFile(filepath.Join(wt.Path, "secret.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read secret.txt after unlock: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(secret)) != "top-secret-value" {
|
|
t.Errorf("secret.txt after unlock = %q, want %q", string(secret), "top-secret-value")
|
|
}
|
|
}
|
|
|
|
// initGitCryptRepoWithSubmodule creates a git-crypt repo that also has a submodule.
|
|
func initGitCryptRepoWithSubmodule(t *testing.T) (repoPath, submodulePath string) {
|
|
t.Helper()
|
|
allowFileProtocol(t)
|
|
|
|
// Create a submodule repository.
|
|
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 repo with git-crypt.
|
|
repoPath = initGitCryptRepo(t)
|
|
|
|
// Add the submodule.
|
|
runGit(t, repoPath, "submodule", "add", submodulePath, "schemas/proto")
|
|
runGit(t, repoPath, "commit", "-m", "add submodule")
|
|
|
|
return repoPath, submodulePath
|
|
}
|
|
|
|
func TestCreateWorktree_GitCryptWithSubmodules(t *testing.T) {
|
|
skipIfNoGitCrypt(t)
|
|
|
|
repoPath, _ := initGitCryptRepoWithSubmodule(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: "gc-sub-task-1",
|
|
SessionID: "gc-sub-session-1",
|
|
TaskTitle: "Git Crypt With Submodule",
|
|
RepositoryID: "repo-gc-sub",
|
|
RepositoryPath: repoPath,
|
|
BaseBranch: "main",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() failed: %v", err)
|
|
}
|
|
|
|
// Encrypted file must be decrypted.
|
|
secret, err := os.ReadFile(filepath.Join(wt.Path, "secret.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read secret.txt: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(secret)) != "top-secret-value" {
|
|
t.Errorf("secret.txt = %q, want %q", string(secret), "top-secret-value\n")
|
|
}
|
|
|
|
// Public file must be present.
|
|
pub, err := os.ReadFile(filepath.Join(wt.Path, "public.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read public.txt: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(pub)) != "public-value" {
|
|
t.Errorf("public.txt = %q, want %q", string(pub), "public-value\n")
|
|
}
|
|
|
|
// Submodule must be initialized with content.
|
|
subContent, err := os.ReadFile(filepath.Join(wt.Path, "schemas", "proto", "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 must be clean.
|
|
status := strings.TrimSpace(runGit(t, wt.Path, "status", "--porcelain"))
|
|
if status != "" {
|
|
t.Errorf("worktree is not clean: %s", status)
|
|
}
|
|
}
|
|
|
|
func TestUnlockGitCryptAndCheckout_WithSubmodules(t *testing.T) {
|
|
skipIfNoGitCrypt(t)
|
|
|
|
repoPath, _ := initGitCryptRepoWithSubmodule(t)
|
|
log := newTestLogger()
|
|
m := &Manager{logger: log}
|
|
|
|
// Create a worktree with --no-checkout (simulates what manager does for git-crypt).
|
|
wtPath := filepath.Join(t.TempDir(), "manual-wt")
|
|
runGit(t, repoPath, "worktree", "add", "-b", "submodule-test", "--no-checkout", wtPath, "main")
|
|
|
|
if err := m.unlockGitCryptAndCheckout(context.Background(), wtPath); err != nil {
|
|
t.Fatalf("unlockGitCryptAndCheckout() failed: %v", err)
|
|
}
|
|
|
|
// Encrypted file must be decrypted.
|
|
secret, err := os.ReadFile(filepath.Join(wtPath, "secret.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read secret.txt: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(secret)) != "top-secret-value" {
|
|
t.Errorf("secret.txt = %q, want %q", string(secret), "top-secret-value\n")
|
|
}
|
|
|
|
// Submodule must be initialized.
|
|
subContent, err := os.ReadFile(filepath.Join(wtPath, "schemas", "proto", "lib.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read submodule lib.txt after unlock: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(subContent)) != "library code" {
|
|
t.Errorf("submodule lib.txt = %q, want %q", string(subContent), "library code")
|
|
}
|
|
|
|
// Working tree must be clean — the index reset for excluded submodule
|
|
// gitlinks must have restored their entries.
|
|
status := strings.TrimSpace(runGit(t, wtPath, "status", "--porcelain"))
|
|
if status != "" {
|
|
t.Errorf("worktree not clean after unlock+checkout: %s", status)
|
|
}
|
|
}
|