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: "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, }, } 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") } } // ---------- 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) } }