Files
beads/cmd/bd/doctor/fix/e2e_test.go
beads/crew/giles 2fb6fd074a fix(ci): address Windows test failures and timeout
- Extract inode function to platform-specific files (inode_unix.go,
  inode_windows.go) to fix syscall.Stat_t compile error on Windows
- Add skipOnWindows helper and skip Unix permission/symlink tests
  on Windows where chmod semantics differ
- Increase Windows test timeout from 10m to 20m since full test
  suite runs slower without race detector

Fixes Windows CI failures introduced when PR #904 expanded Windows
testing from smoke tests to full test suite.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 12:59:26 -08:00

1007 lines
31 KiB
Go

package fix
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// skipIfTestBinary skips the test if running as a test binary.
// E2E tests that need to execute 'bd' subcommands cannot run in test mode.
func skipIfTestBinary(t *testing.T) {
t.Helper()
_, err := getBdBinary()
if errors.Is(err, ErrTestBinary) {
t.Skip("skipping E2E test: running as test binary")
}
}
// =============================================================================
// End-to-End Fix Tests
// =============================================================================
// TestGitHooks_E2E tests the full GitHooks fix flow
func TestGitHooks_E2E(t *testing.T) {
// Skip if bd binary not available or running as test binary
skipIfTestBinary(t)
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd binary not in PATH, skipping e2e test")
}
t.Run("installs hooks in git repo", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Verify no hooks exist initially
hooksDir := filepath.Join(dir, ".git", "hooks")
preCommit := filepath.Join(hooksDir, "pre-commit")
if _, err := os.Stat(preCommit); err == nil {
t.Skip("pre-commit hook already exists, skipping")
}
// Run fix
err := GitHooks(dir)
if err != nil {
t.Fatalf("GitHooks fix failed: %v", err)
}
// Verify hooks were installed
if _, err := os.Stat(preCommit); os.IsNotExist(err) {
t.Error("pre-commit hook was not installed")
}
// Check hook content has bd reference
content, err := os.ReadFile(preCommit)
if err != nil {
t.Fatalf("failed to read hook: %v", err)
}
if !strings.Contains(string(content), "bd") {
t.Error("hook doesn't contain bd reference")
}
})
}
// TestUntrackedJSONL_E2E tests the full UntrackedJSONL fix flow
func TestUntrackedJSONL_E2E(t *testing.T) {
t.Run("commits untracked JSONL files", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit so we can make more commits
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Test\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
// Create untracked JSONL file in .beads
jsonlPath := filepath.Join(dir, ".beads", "deletions.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1"}`+"\n"), 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
// Verify it's untracked
output := runGit(t, dir, "status", "--porcelain", ".beads/")
if !strings.Contains(output, "??") {
t.Fatalf("expected untracked file, got: %s", output)
}
// Run fix
err := UntrackedJSONL(dir)
if err != nil {
t.Fatalf("UntrackedJSONL fix failed: %v", err)
}
// Verify file was committed
output = runGit(t, dir, "status", "--porcelain", ".beads/")
if strings.Contains(output, "??") {
t.Error("JSONL file still untracked after fix")
}
// Verify commit was made
output = runGit(t, dir, "log", "--oneline", "-1")
if !strings.Contains(output, "untracked JSONL") {
t.Errorf("expected commit message about untracked JSONL, got: %s", output)
}
})
t.Run("handles no untracked files gracefully", func(t *testing.T) {
dir := setupTestGitRepo(t)
// No untracked files - should succeed without error
err := UntrackedJSONL(dir)
if err != nil {
t.Errorf("expected no error with no untracked files, got: %v", err)
}
})
}
// TestMergeDriver_E2E tests the full MergeDriver fix flow
func TestMergeDriver_E2E(t *testing.T) {
t.Run("sets correct merge driver config", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Run fix
err := MergeDriver(dir)
if err != nil {
t.Fatalf("MergeDriver fix failed: %v", err)
}
// Verify config was set
output := runGit(t, dir, "config", "--get", "merge.beads.driver")
expected := "bd merge %A %O %A %B"
if strings.TrimSpace(output) != expected {
t.Errorf("expected merge driver %q, got %q", expected, output)
}
})
t.Run("fixes incorrect config", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Set incorrect config first
runGit(t, dir, "config", "merge.beads.driver", "bd merge %L %O %A %R")
// Run fix
err := MergeDriver(dir)
if err != nil {
t.Fatalf("MergeDriver fix failed: %v", err)
}
// Verify config was corrected
output := runGit(t, dir, "config", "--get", "merge.beads.driver")
expected := "bd merge %A %O %A %B"
if strings.TrimSpace(output) != expected {
t.Errorf("expected corrected merge driver %q, got %q", expected, output)
}
})
}
// TestSyncBranchHealth_E2E tests the full SyncBranchHealth fix flow
func TestSyncBranchHealth_E2E(t *testing.T) {
t.Run("resets sync branch when behind main", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit on main
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Initial\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
// Create main branch and add more commits
runGit(t, dir, "branch", "-M", "main")
testFile2 := filepath.Join(dir, "file2.md")
if err := os.WriteFile(testFile2, []byte("content"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "file2.md")
runGit(t, dir, "commit", "-m", "second commit on main")
// Create beads-sync branch from an earlier commit
runGit(t, dir, "branch", "beads-sync", "HEAD~1")
// Verify beads-sync is behind
output := runGit(t, dir, "rev-list", "--count", "beads-sync..main")
if !strings.Contains(output, "1") {
t.Logf("beads-sync should be 1 commit behind main, got: %s", output)
}
// Configure git remote (needed for push operations)
remoteDir := t.TempDir()
runGit(t, remoteDir, "init", "--bare")
runGit(t, dir, "remote", "add", "origin", remoteDir)
runGit(t, dir, "push", "-u", "origin", "main")
runGit(t, dir, "push", "origin", "beads-sync")
// Run fix
err := SyncBranchHealth(dir, "beads-sync")
if err != nil {
t.Fatalf("SyncBranchHealth fix failed: %v", err)
}
// Verify beads-sync is now at same commit as main
mainHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "main"))
syncHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "beads-sync"))
if mainHash != syncHash {
t.Errorf("expected beads-sync to match main commit\nmain: %s\nsync: %s", mainHash, syncHash)
}
})
t.Run("resets sync branch when ahead of main", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit on main
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Initial\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
runGit(t, dir, "branch", "-M", "main")
// Configure git remote
remoteDir := t.TempDir()
runGit(t, remoteDir, "init", "--bare")
runGit(t, dir, "remote", "add", "origin", remoteDir)
runGit(t, dir, "push", "-u", "origin", "main")
// Create beads-sync and add extra commit
runGit(t, dir, "checkout", "-b", "beads-sync")
extraFile := filepath.Join(dir, "extra.md")
if err := os.WriteFile(extraFile, []byte("extra"), 0644); err != nil {
t.Fatalf("failed to create extra file: %v", err)
}
runGit(t, dir, "add", "extra.md")
runGit(t, dir, "commit", "-m", "extra commit on beads-sync")
runGit(t, dir, "push", "-u", "origin", "beads-sync")
// Switch back to main
runGit(t, dir, "checkout", "main")
// Verify beads-sync is ahead
output := runGit(t, dir, "rev-list", "--count", "main..beads-sync")
if !strings.Contains(output, "1") {
t.Logf("beads-sync should be 1 commit ahead of main, got: %s", output)
}
// Run fix
err := SyncBranchHealth(dir, "beads-sync")
if err != nil {
t.Fatalf("SyncBranchHealth fix failed: %v", err)
}
// Verify beads-sync is now at same commit as main
mainHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "main"))
syncHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "beads-sync"))
if mainHash != syncHash {
t.Errorf("expected beads-sync to match main commit\nmain: %s\nsync: %s", mainHash, syncHash)
}
})
t.Run("resets sync branch when diverged from main", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit on main
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Initial\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
runGit(t, dir, "branch", "-M", "main")
// Configure git remote
remoteDir := t.TempDir()
runGit(t, remoteDir, "init", "--bare")
runGit(t, dir, "remote", "add", "origin", remoteDir)
runGit(t, dir, "push", "-u", "origin", "main")
// Create beads-sync from initial commit
runGit(t, dir, "checkout", "-b", "beads-sync")
syncFile := filepath.Join(dir, "sync-only.md")
if err := os.WriteFile(syncFile, []byte("sync content"), 0644); err != nil {
t.Fatalf("failed to create sync file: %v", err)
}
runGit(t, dir, "add", "sync-only.md")
runGit(t, dir, "commit", "-m", "divergent commit on beads-sync")
runGit(t, dir, "push", "-u", "origin", "beads-sync")
// Add different commit to main
runGit(t, dir, "checkout", "main")
mainFile := filepath.Join(dir, "main-only.md")
if err := os.WriteFile(mainFile, []byte("main content"), 0644); err != nil {
t.Fatalf("failed to create main file: %v", err)
}
runGit(t, dir, "add", "main-only.md")
runGit(t, dir, "commit", "-m", "divergent commit on main")
runGit(t, dir, "push", "origin", "main")
// Verify branches have diverged
behindOutput := runGit(t, dir, "rev-list", "--count", "beads-sync..main")
aheadOutput := runGit(t, dir, "rev-list", "--count", "main..beads-sync")
if strings.TrimSpace(behindOutput) == "0" || strings.TrimSpace(aheadOutput) == "0" {
t.Logf("branches should have diverged, behind: %s, ahead: %s", behindOutput, aheadOutput)
}
// Run fix
err := SyncBranchHealth(dir, "beads-sync")
if err != nil {
t.Fatalf("SyncBranchHealth fix failed: %v", err)
}
// Verify beads-sync is now at same commit as main
mainHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "main"))
syncHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "beads-sync"))
if mainHash != syncHash {
t.Errorf("expected beads-sync to match main commit\nmain: %s\nsync: %s", mainHash, syncHash)
}
// Verify sync-only file no longer exists
if _, err := os.Stat(syncFile); err == nil {
runGit(t, dir, "checkout", "beads-sync")
if _, err := os.Stat(syncFile); err == nil {
t.Error("sync-only.md should not exist after reset to main")
}
runGit(t, dir, "checkout", "main")
}
})
t.Run("handles master as main branch", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit on master
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Initial\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
runGit(t, dir, "branch", "-M", "master")
// Configure git remote
remoteDir := t.TempDir()
runGit(t, remoteDir, "init", "--bare")
runGit(t, dir, "remote", "add", "origin", remoteDir)
runGit(t, dir, "push", "-u", "origin", "master")
// Create beads-sync
runGit(t, dir, "checkout", "-b", "beads-sync")
runGit(t, dir, "push", "-u", "origin", "beads-sync")
runGit(t, dir, "checkout", "master")
// Add commit to master
testFile2 := filepath.Join(dir, "file2.md")
if err := os.WriteFile(testFile2, []byte("content"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "file2.md")
runGit(t, dir, "commit", "-m", "second commit on master")
runGit(t, dir, "push", "origin", "master")
// Run fix
err := SyncBranchHealth(dir, "beads-sync")
if err != nil {
t.Fatalf("SyncBranchHealth fix failed: %v", err)
}
// Verify beads-sync is now at same commit as master
masterHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "master"))
syncHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "beads-sync"))
if masterHash != syncHash {
t.Errorf("expected beads-sync to match master commit\nmaster: %s\nsync: %s", masterHash, syncHash)
}
})
t.Run("fails when on sync branch", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Initial\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
runGit(t, dir, "branch", "-M", "main")
// Configure git remote (needed if git detects worktree)
remoteDir := t.TempDir()
runGit(t, remoteDir, "init", "--bare")
runGit(t, dir, "remote", "add", "origin", remoteDir)
runGit(t, dir, "push", "-u", "origin", "main")
// Create beads-sync and checkout
runGit(t, dir, "checkout", "-b", "beads-sync")
runGit(t, dir, "push", "-u", "origin", "beads-sync")
// Run fix should fail when on the sync branch
// Note: This may succeed if git detects a worktree and can reset it
// The key behavior is that it handles the case appropriately
err := SyncBranchHealth(dir, "beads-sync")
if err == nil {
// If it succeeded, verify the branch was properly reset
mainHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "main"))
syncHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "beads-sync"))
if mainHash != syncHash {
t.Error("if fix succeeded on current branch, it should reset properly")
}
t.Skip("fix succeeded on current branch (worktree detected)")
}
if !strings.Contains(err.Error(), "currently on") && !strings.Contains(err.Error(), "checkout") {
t.Errorf("expected error to mention being on branch, got: %v", err)
}
})
t.Run("creates sync branch if it does not exist", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit on main
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Initial\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
runGit(t, dir, "branch", "-M", "main")
// Configure git remote
remoteDir := t.TempDir()
runGit(t, remoteDir, "init", "--bare")
runGit(t, dir, "remote", "add", "origin", remoteDir)
runGit(t, dir, "push", "-u", "origin", "main")
// Verify beads-sync does not exist
output := runGit(t, dir, "branch", "--list", "beads-sync")
if strings.Contains(output, "beads-sync") {
t.Fatalf("beads-sync should not exist yet")
}
// Run fix
err := SyncBranchHealth(dir, "beads-sync")
if err != nil {
t.Fatalf("SyncBranchHealth fix failed: %v", err)
}
// Verify beads-sync was created and matches main
output = runGit(t, dir, "branch", "--list", "beads-sync")
if !strings.Contains(output, "beads-sync") {
t.Error("beads-sync should have been created")
}
mainHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "main"))
syncHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "beads-sync"))
if mainHash != syncHash {
t.Errorf("expected beads-sync to match main commit\nmain: %s\nsync: %s", mainHash, syncHash)
}
})
}
// =============================================================================
// Error Handling Tests
// =============================================================================
// TestGetBdBinary_Errors tests getBdBinary error scenarios
func TestGetBdBinary_Errors(t *testing.T) {
t.Run("returns current executable when available", func(t *testing.T) {
path, err := getBdBinary()
if err != nil {
// This is expected in test environment if bd isn't the test binary
t.Logf("getBdBinary returned error (expected in test): %v", err)
return
}
if path == "" {
t.Error("expected non-empty path")
}
})
}
// TestGitCommandFailures tests handling of git command failures
func TestGitCommandFailures(t *testing.T) {
t.Run("SyncBranchConfig fails without git", func(t *testing.T) {
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Not a git repo - should fail
err := SyncBranchConfig(dir)
if err == nil {
t.Error("expected error for non-git directory")
}
})
t.Run("SyncBranchHealth fails without main/master", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create an orphan branch (no main/master)
runGit(t, dir, "checkout", "--orphan", "orphan-branch")
testFile := filepath.Join(dir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "test.txt")
runGit(t, dir, "commit", "-m", "orphan commit")
// Delete main if it exists
_ = exec.Command("git", "-C", dir, "branch", "-D", "main").Run()
_ = exec.Command("git", "-C", dir, "branch", "-D", "master").Run()
err := SyncBranchHealth(dir, "beads-sync")
if err == nil {
t.Error("expected error when neither main nor master exists")
}
if !strings.Contains(err.Error(), "main") && !strings.Contains(err.Error(), "master") {
t.Errorf("error should mention main/master, got: %v", err)
}
})
}
// TestFilePermissionErrors tests handling of file permission issues
func TestFilePermissionErrors(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping permission tests when running as root")
}
t.Run("Permissions handles read-only directory", func(t *testing.T) {
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create a file
dbPath := filepath.Join(beadsDir, "beads.db")
if err := os.WriteFile(dbPath, []byte("test"), 0644); err != nil {
t.Fatal(err)
}
// Make directory read-only
if err := os.Chmod(beadsDir, 0444); err != nil {
t.Fatal(err)
}
defer func() {
// Restore permissions for cleanup
_ = os.Chmod(beadsDir, 0755)
}()
// Permissions fix should handle this gracefully
err := Permissions(dir)
// May succeed or fail depending on what needs fixing
// The key is it shouldn't panic
_ = err
})
}
// =============================================================================
// Gitignore Tests
// =============================================================================
// TestFixGitignore_PartialPatterns tests FixGitignore with existing partial patterns
func TestFixGitignore_PartialPatterns(t *testing.T) {
// Note: FixGitignore is in the main doctor package, not fix package
// These tests would go in gitignore_test.go in the doctor package
// Here we test the common validation used by fixes
t.Run("validateBeadsWorkspace requires .beads directory", func(t *testing.T) {
dir := t.TempDir()
err := validateBeadsWorkspace(dir)
if err == nil {
t.Error("expected error for missing .beads directory")
}
})
t.Run("validateBeadsWorkspace accepts valid workspace", func(t *testing.T) {
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
err := validateBeadsWorkspace(dir)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
// =============================================================================
// Edge Case E2E Tests
// =============================================================================
// TestGitHooksWithExistingHooks_E2E tests preserving existing non-bd hooks
func TestGitHooksWithExistingHooks_E2E(t *testing.T) {
// Skip if bd binary not available or running as test binary
skipIfTestBinary(t)
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd binary not in PATH, skipping e2e test")
}
t.Run("preserves existing non-bd hooks", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create a custom pre-commit hook
hooksDir := filepath.Join(dir, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0755); err != nil {
t.Fatalf("failed to create hooks directory: %v", err)
}
preCommit := filepath.Join(hooksDir, "pre-commit")
customHookContent := "#!/bin/sh\n# Custom hook\necho \"Running custom pre-commit hook\"\nexit 0\n"
if err := os.WriteFile(preCommit, []byte(customHookContent), 0755); err != nil {
t.Fatalf("failed to create custom hook: %v", err)
}
// Run fix to install bd hooks
err := GitHooks(dir)
if err != nil {
t.Fatalf("GitHooks fix failed: %v", err)
}
// Verify hook still exists and is executable
info, err := os.Stat(preCommit)
if err != nil {
t.Fatalf("pre-commit hook disappeared: %v", err)
}
if info.Mode().Perm()&0111 == 0 {
t.Error("hook should be executable")
}
// Read hook content
content, err := os.ReadFile(preCommit)
if err != nil {
t.Fatalf("failed to read hook: %v", err)
}
hookContent := string(content)
// Verify bd hook was installed (should contain bd reference)
if !strings.Contains(hookContent, "bd") {
t.Error("hook should contain bd reference after installation")
}
// Note: The exact preservation behavior depends on 'bd hooks install' implementation
// This test verifies the fix runs without destroying existing hooks
})
}
// TestUntrackedJSONLWithUncommittedChanges_E2E tests handling uncommitted changes
func TestUntrackedJSONLWithUncommittedChanges_E2E(t *testing.T) {
t.Run("commits untracked JSONL with staged changes present", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Test\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
// Create untracked JSONL file
jsonlPath := filepath.Join(dir, ".beads", "deletions.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1"}`+"\n"), 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
// Create staged changes
testFile2 := filepath.Join(dir, "file2.md")
if err := os.WriteFile(testFile2, []byte("staged content"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "file2.md")
// Run fix
err := UntrackedJSONL(dir)
if err != nil {
t.Fatalf("UntrackedJSONL fix failed: %v", err)
}
// Verify JSONL was committed
output := runGit(t, dir, "status", "--porcelain", ".beads/")
if strings.Contains(output, "??") && strings.Contains(output, "deletions.jsonl") {
t.Error("JSONL file still untracked after fix")
}
// Verify staged changes are still staged (not committed by fix)
output = runGit(t, dir, "status", "--porcelain", "file2.md")
if !strings.Contains(output, "A ") && !strings.Contains(output, "file2.md") {
t.Error("staged changes should remain staged")
}
})
t.Run("commits untracked JSONL with unstaged changes present", func(t *testing.T) {
dir := setupTestGitRepo(t)
// Create initial commit
testFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(testFile, []byte("# Test\n"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-m", "initial commit")
// Create untracked JSONL file
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-2"}`+"\n"), 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
// Create unstaged changes to existing file
if err := os.WriteFile(testFile, []byte("# Test Modified\n"), 0644); err != nil {
t.Fatalf("failed to modify test file: %v", err)
}
// Verify unstaged changes exist
statusOutput := runGit(t, dir, "status", "--porcelain")
if !strings.Contains(statusOutput, " M ") && !strings.Contains(statusOutput, "README.md") {
t.Logf("expected unstaged changes, got: %s", statusOutput)
}
// Run fix
err := UntrackedJSONL(dir)
if err != nil {
t.Fatalf("UntrackedJSONL fix failed: %v", err)
}
// Verify JSONL was committed
output := runGit(t, dir, "status", "--porcelain", ".beads/")
if strings.Contains(output, "??") && strings.Contains(output, "issues.jsonl") {
t.Error("JSONL file still untracked after fix")
}
// Verify unstaged changes remain unstaged
output = runGit(t, dir, "status", "--porcelain", "README.md")
if !strings.Contains(output, " M") {
t.Error("unstaged changes should remain unstaged")
}
})
}
// TestMergeDriverWithLockedConfig_E2E tests handling when git config is locked
func TestMergeDriverWithLockedConfig_E2E(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping permission tests when running as root")
}
t.Run("handles read-only git config file", func(t *testing.T) {
// Skip on macOS - file owner can bypass read-only permissions
if runtime.GOOS == "darwin" {
t.Skip("skipping on macOS: file owner can write to read-only files")
}
// Skip in CI - containers may have CAP_DAC_OVERRIDE or other capabilities
// that bypass file permission checks
if os.Getenv("CI") == "true" || os.Getenv("GITHUB_ACTIONS") == "true" {
t.Skip("skipping in CI: container may bypass file permission checks")
}
dir := setupTestGitRepo(t)
gitConfigPath := filepath.Join(dir, ".git", "config")
// Make git config read-only
if err := os.Chmod(gitConfigPath, 0444); err != nil {
t.Fatalf("failed to make config read-only: %v", err)
}
defer func() {
// Restore permissions for cleanup
_ = os.Chmod(gitConfigPath, 0644)
}()
// Run fix - should fail gracefully
err := MergeDriver(dir)
if err == nil {
t.Fatal("expected error when git config is read-only")
}
// Verify error message is meaningful
if !strings.Contains(err.Error(), "failed to update git merge driver config") {
t.Errorf("error should mention config update failure, got: %v", err)
}
})
t.Run("succeeds when config directory is writable", func(t *testing.T) {
dir := setupTestGitRepo(t)
gitDir := filepath.Join(dir, ".git")
gitConfigPath := filepath.Join(gitDir, "config")
// Ensure git directory and config are writable
if err := os.Chmod(gitDir, 0755); err != nil {
t.Fatalf("failed to set git dir permissions: %v", err)
}
if err := os.Chmod(gitConfigPath, 0644); err != nil {
t.Fatalf("failed to set config permissions: %v", err)
}
// Run fix
err := MergeDriver(dir)
if err != nil {
t.Fatalf("MergeDriver fix should succeed with writable config: %v", err)
}
// Verify config was set
output := runGit(t, dir, "config", "--get", "merge.beads.driver")
expected := "bd merge %A %O %A %B"
if strings.TrimSpace(output) != expected {
t.Errorf("expected merge driver %q, got %q", expected, output)
}
})
}
// TestPermissionsWithWrongPermissions_E2E tests fixing wrong permissions on .beads
func TestPermissionsWithWrongPermissions_E2E(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping Unix permission test on Windows")
}
if os.Getuid() == 0 {
t.Skip("skipping permission tests when running as root")
}
t.Run("fixes .beads directory with wrong permissions", func(t *testing.T) {
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Set wrong permissions (too permissive)
if err := os.Chmod(beadsDir, 0777); err != nil {
t.Fatal(err)
}
// Verify wrong permissions
info, err := os.Stat(beadsDir)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() == 0700 {
t.Skip("permissions already correct")
}
// Run fix
err = Permissions(dir)
if err != nil {
t.Fatalf("Permissions fix failed: %v", err)
}
// Verify permissions were fixed
info, err = os.Stat(beadsDir)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0700 {
t.Errorf("expected permissions 0700, got %04o", info.Mode().Perm())
}
})
t.Run("fixes database file with wrong permissions", func(t *testing.T) {
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0700); err != nil {
t.Fatal(err)
}
// Create database file with wrong permissions
dbPath := filepath.Join(beadsDir, "beads.db")
if err := os.WriteFile(dbPath, []byte("test"), 0644); err != nil {
t.Fatal(err)
}
// Set wrong permissions (too permissive)
if err := os.Chmod(dbPath, 0666); err != nil {
t.Fatal(err)
}
// Run fix
err := Permissions(dir)
if err != nil {
t.Fatalf("Permissions fix failed: %v", err)
}
// Verify permissions were fixed
info, err := os.Stat(dbPath)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Errorf("expected permissions 0600, got %04o", info.Mode().Perm())
}
})
t.Run("fixes database file without read permission", func(t *testing.T) {
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0700); err != nil {
t.Fatal(err)
}
// Create database file
dbPath := filepath.Join(beadsDir, "beads.db")
if err := os.WriteFile(dbPath, []byte("test"), 0200); err != nil {
t.Fatal(err)
}
// Run fix
err := Permissions(dir)
if err != nil {
t.Fatalf("Permissions fix failed: %v", err)
}
// Verify permissions were fixed to include read
info, err := os.Stat(dbPath)
if err != nil {
t.Fatal(err)
}
perms := info.Mode().Perm()
if perms&0400 == 0 {
t.Error("database should have read permission for owner")
}
if perms != 0600 {
t.Errorf("expected permissions 0600, got %04o", perms)
}
})
t.Run("handles .beads directory without write permission", func(t *testing.T) {
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0700); err != nil {
t.Fatal(err)
}
// Create a test file in .beads
testFile := filepath.Join(beadsDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
t.Fatal(err)
}
// Make .beads read-only (no write, no execute)
if err := os.Chmod(beadsDir, 0400); err != nil {
t.Fatal(err)
}
// Restore permissions for cleanup
defer func() {
_ = os.Chmod(beadsDir, 0700)
}()
// Run fix - should restore write permission
err := Permissions(dir)
if err != nil {
t.Fatalf("Permissions fix failed: %v", err)
}
// Verify directory now has correct permissions
info, err := os.Stat(beadsDir)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0700 {
t.Errorf("expected permissions 0700, got %04o", info.Mode().Perm())
}
})
t.Run("handles multiple files with wrong permissions", func(t *testing.T) {
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0777); err != nil {
t.Fatal(err)
}
// Create database with wrong permissions
dbPath := filepath.Join(beadsDir, "beads.db")
if err := os.WriteFile(dbPath, []byte("db"), 0666); err != nil {
t.Fatal(err)
}
// Run fix
err := Permissions(dir)
if err != nil {
t.Fatalf("Permissions fix failed: %v", err)
}
// Verify both directory and file were fixed
dirInfo, err := os.Stat(beadsDir)
if err != nil {
t.Fatal(err)
}
if dirInfo.Mode().Perm() != 0700 {
t.Errorf("expected directory permissions 0700, got %04o", dirInfo.Mode().Perm())
}
dbInfo, err := os.Stat(dbPath)
if err != nil {
t.Fatal(err)
}
if dbInfo.Mode().Perm() != 0600 {
t.Errorf("expected database permissions 0600, got %04o", dbInfo.Mode().Perm())
}
})
}
// Note: Helper functions setupTestGitRepo and runGit are defined in fix_test.go