package doctor import ( "database/sql" "os" "os/exec" "path/filepath" "strings" "testing" _ "github.com/ncruces/go-sqlite3/driver" _ "github.com/ncruces/go-sqlite3/embed" ) // setupGitRepo creates a temporary git repository for testing func setupGitRepo(t *testing.T) string { t.Helper() dir := t.TempDir() // Create .beads directory beadsDir := filepath.Join(dir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } // Initialize git repo with 'main' as default branch (modern git convention) cmd := exec.Command("git", "init", "--initial-branch=main") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to init git repo: %v", err) } // Configure git user for commits cmd = exec.Command("git", "config", "user.email", "test@test.com") cmd.Dir = dir _ = cmd.Run() cmd = exec.Command("git", "config", "user.name", "Test User") cmd.Dir = dir _ = cmd.Run() return dir } func TestCheckGitHooks(t *testing.T) { // This test needs to run in a git repository // We test the basic case where hooks are not installed t.Run("not in git repo returns N/A", func(t *testing.T) { // Save current directory origDir, err := os.Getwd() if err != nil { t.Fatal(err) } defer func() { if err := os.Chdir(origDir); err != nil { t.Fatalf("failed to restore directory: %v", err) } }() // Change to a non-git directory tmpDir := t.TempDir() if err := os.Chdir(tmpDir); err != nil { t.Fatal(err) } check := CheckGitHooks() if check.Status != StatusOK { t.Errorf("expected status %q, got %q", StatusOK, check.Status) } if check.Message != "N/A (not a git repository)" { t.Errorf("unexpected message: %s", check.Message) } }) } func TestCheckMergeDriver(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string expectMessage string }{ { name: "not a git repo", setup: func(t *testing.T, dir string) { // Just create .beads directory, no git // CheckMergeDriver uses global git detection beadsDir := filepath.Join(dir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } }, expectedStatus: "warning", // Uses global git detection, so still checks expectMessage: "", // Message varies }, { name: "merge driver not configured", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) }, expectedStatus: "warning", expectMessage: "Git merge driver not configured", }, { name: "correct config", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) cmd := exec.Command("git", "config", "merge.beads.driver", "bd merge %A %O %A %B") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to set git config: %v", err) } }, expectedStatus: "ok", expectMessage: "Correctly configured", }, { name: "incorrect config with old placeholders", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) cmd := exec.Command("git", "config", "merge.beads.driver", "bd merge %L %O %A %R") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to set git config: %v", err) } }, expectedStatus: "error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) check := CheckMergeDriver(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message) } if tt.expectMessage != "" && check.Message != tt.expectMessage { t.Errorf("expected message %q, got %q", tt.expectMessage, check.Message) } }) } } func TestCheckSyncBranchConfig(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string }{ { name: "no .beads directory", setup: func(t *testing.T, dir string) { // Empty directory, no .beads }, expectedStatus: "ok", }, { name: "not a git repo", setup: func(t *testing.T, dir string) { beadsDir := filepath.Join(dir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } }, expectedStatus: "ok", }, { name: "no remote configured", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) }, expectedStatus: "ok", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) check := CheckSyncBranchConfig(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message) } }) } } func TestCheckSyncBranchHealth(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string expectMessage string }{ { name: "no sync branch configured (uses global git detection)", setup: func(t *testing.T, dir string) { // CheckSyncBranchHealth uses global git.GetGitDir() detection // which checks from current working directory, not the path parameter beadsDir := filepath.Join(dir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } }, expectedStatus: "ok", expectMessage: "N/A (no sync branch configured)", }, { name: "no sync branch configured with git", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) }, expectedStatus: "ok", expectMessage: "N/A (no sync branch configured)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) check := CheckSyncBranchHealth(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message) } if tt.expectMessage != "" && check.Message != tt.expectMessage { t.Errorf("expected message %q, got %q", tt.expectMessage, check.Message) } }) } } func TestCheckSyncBranchHookCompatibility(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string }{ { name: "sync-branch not configured", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) }, expectedStatus: "ok", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) check := CheckSyncBranchHookCompatibility(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q", tt.expectedStatus, check.Status) } }) } } // setupGitRepoInDir initializes a git repo in the given directory with .beads func setupGitRepoInDir(t *testing.T, dir string) { t.Helper() // Create .beads directory beadsDir := filepath.Join(dir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } // Initialize git repo with 'main' as default branch (modern git convention) cmd := exec.Command("git", "init", "--initial-branch=main") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to init git repo: %v", err) } // Configure git user for commits cmd = exec.Command("git", "config", "user.email", "test@test.com") cmd.Dir = dir _ = cmd.Run() cmd = exec.Command("git", "config", "user.name", "Test User") cmd.Dir = dir _ = cmd.Run() } // Edge case tests for CheckGitHooks func TestCheckGitHooks_CorruptedHookFiles(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string expectInMsg string }{ { name: "pre-commit hook is directory instead of file", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) // create pre-commit as directory instead of file os.MkdirAll(filepath.Join(hooksDir, "pre-commit"), 0755) // create valid post-merge and pre-push hooks os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte("#!/bin/sh\nbd sync\n"), 0755) os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte("#!/bin/sh\nbd sync\n"), 0755) }, // os.Stat reports directories as existing, so CheckGitHooks sees it as installed expectedStatus: "ok", expectInMsg: "All recommended hooks installed", }, { name: "hook file with no execute permissions", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) // create hooks but with no execute permissions os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nbd sync\n"), 0644) os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte("#!/bin/sh\nbd sync\n"), 0644) os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte("#!/bin/sh\nbd sync\n"), 0644) }, expectedStatus: "ok", expectInMsg: "All recommended hooks installed", }, { name: "empty hook file", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) // create empty hook files os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte(""), 0755) os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte(""), 0755) os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte(""), 0755) }, expectedStatus: "ok", expectInMsg: "All recommended hooks installed", }, { name: "hook file with binary content", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) // create hooks with binary content binaryContent := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD} os.WriteFile(filepath.Join(hooksDir, "pre-commit"), binaryContent, 0755) os.WriteFile(filepath.Join(hooksDir, "post-merge"), binaryContent, 0755) os.WriteFile(filepath.Join(hooksDir, "pre-push"), binaryContent, 0755) }, expectedStatus: "ok", expectInMsg: "All recommended hooks installed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) origDir, err := os.Getwd() if err != nil { t.Fatal(err) } defer os.Chdir(origDir) if err := os.Chdir(tmpDir); err != nil { t.Fatal(err) } check := CheckGitHooks() if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message) } if tt.expectInMsg != "" && !strings.Contains(check.Message, tt.expectInMsg) { t.Errorf("expected message to contain %q, got %q", tt.expectInMsg, check.Message) } }) } } // Edge case tests for CheckMergeDriver func TestCheckMergeDriver_PartiallyConfigured(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string expectInMsg string }{ { name: "only merge.beads.name configured", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) cmd := exec.Command("git", "config", "merge.beads.name", "Beads merge driver") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to set git config: %v", err) } }, expectedStatus: "warning", expectInMsg: "not configured", }, { name: "empty merge driver config", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) cmd := exec.Command("git", "config", "merge.beads.driver", "") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to set git config: %v", err) } }, // git config trims to empty string, which is non-standard expectedStatus: "warning", expectInMsg: "Non-standard", }, { name: "merge driver with extra spaces", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) cmd := exec.Command("git", "config", "merge.beads.driver", " bd merge %A %O %A %B ") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to set git config: %v", err) } }, // git config stores the value with spaces, but the code trims it expectedStatus: "ok", expectInMsg: "Correctly configured", }, { name: "merge driver with wrong bd path", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) cmd := exec.Command("git", "config", "merge.beads.driver", "/usr/local/bin/bd merge %A %O %A %B") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to set git config: %v", err) } }, expectedStatus: "warning", expectInMsg: "Non-standard", }, { name: "merge driver with only two placeholders", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) cmd := exec.Command("git", "config", "merge.beads.driver", "bd merge %A %O") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to set git config: %v", err) } }, expectedStatus: "warning", expectInMsg: "Non-standard", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) check := CheckMergeDriver(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message) } if tt.expectInMsg != "" && !strings.Contains(check.Message, tt.expectInMsg) { t.Errorf("expected message to contain %q, got %q", tt.expectInMsg, check.Message) } }) } } // Edge case tests for CheckSyncBranchConfig func TestCheckSyncBranchConfig_MultipleRemotes(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string expectInMsg string }{ { name: "multiple remotes without sync-branch", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) // add multiple remotes cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/user/repo.git") cmd.Dir = dir _ = cmd.Run() cmd = exec.Command("git", "remote", "add", "upstream", "https://github.com/upstream/repo.git") cmd.Dir = dir _ = cmd.Run() }, expectedStatus: "warning", expectInMsg: "not configured", }, { name: "multiple remotes with sync-branch configured via env", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) // add multiple remotes cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/user/repo.git") cmd.Dir = dir _ = cmd.Run() cmd = exec.Command("git", "remote", "add", "upstream", "https://github.com/upstream/repo.git") cmd.Dir = dir _ = cmd.Run() // use env var to configure sync-branch since config package reads from cwd os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "ok", expectInMsg: "Configured", }, { name: "no remotes at all", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) }, expectedStatus: "ok", expectInMsg: "no remote configured", }, { name: "on sync branch itself via env (error case)", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) // create and checkout sync branch cmd := exec.Command("git", "checkout", "-b", "beads-sync") cmd.Dir = dir _ = cmd.Run() // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "error", expectInMsg: "On sync branch", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) check := CheckSyncBranchConfig(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message) } if tt.expectInMsg != "" && !strings.Contains(check.Message, tt.expectInMsg) { t.Errorf("expected message to contain %q, got %q", tt.expectInMsg, check.Message) } }) } } // Edge case tests for CheckSyncBranchHealth func TestCheckSyncBranchHealth_DetachedHEAD(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string expectInMsg string }{ { name: "detached HEAD without sync-branch", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) // create initial commit testFile := filepath.Join(dir, "test.txt") os.WriteFile(testFile, []byte("test"), 0644) cmd := exec.Command("git", "add", "test.txt") cmd.Dir = dir _ = cmd.Run() cmd = exec.Command("git", "commit", "-m", "initial commit") cmd.Dir = dir _ = cmd.Run() // detach HEAD cmd = exec.Command("git", "checkout", "HEAD^0") cmd.Dir = dir _ = cmd.Run() }, expectedStatus: "ok", expectInMsg: "no sync branch configured", }, { name: "detached HEAD with sync-branch configured via env", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) // create initial commit testFile := filepath.Join(dir, "test.txt") os.WriteFile(testFile, []byte("test"), 0644) cmd := exec.Command("git", "add", "test.txt") cmd.Dir = dir _ = cmd.Run() cmd = exec.Command("git", "commit", "-m", "initial commit") cmd.Dir = dir _ = cmd.Run() // create sync branch cmd = exec.Command("git", "branch", "beads-sync") cmd.Dir = dir _ = cmd.Run() // detach HEAD cmd = exec.Command("git", "checkout", "HEAD^0") cmd.Dir = dir _ = cmd.Run() // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "ok", expectInMsg: "remote", // no remote configured, so returns "N/A (remote ... not found)" }, { name: "sync branch exists but remote doesn't", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) // create initial commit testFile := filepath.Join(dir, "test.txt") os.WriteFile(testFile, []byte("test"), 0644) cmd := exec.Command("git", "add", "test.txt") cmd.Dir = dir _ = cmd.Run() cmd = exec.Command("git", "commit", "-m", "initial commit") cmd.Dir = dir _ = cmd.Run() // create sync branch cmd = exec.Command("git", "branch", "beads-sync") cmd.Dir = dir _ = cmd.Run() // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "ok", expectInMsg: "remote", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) check := CheckSyncBranchHealth(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message) } if tt.expectInMsg != "" && !strings.Contains(check.Message, tt.expectInMsg) { t.Errorf("expected message to contain %q, got %q", tt.expectInMsg, check.Message) } }) } } // Edge case tests for CheckSyncBranchHookCompatibility func TestCheckSyncBranchHookCompatibility_OldHookFormat(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) expectedStatus string expectInMsg string }{ { name: "old hook without version marker", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) // create old-style pre-push hook without version gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) hookContent := "#!/bin/sh\n# Old hook without version\nbd sync\n" os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte(hookContent), 0755) // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "warning", expectInMsg: "not a bd hook", }, { name: "hook with version 0.28.0 (old format)", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) hookContent := "#!/bin/sh\n# bd-hooks-version: 0.28.0\nbd sync\n" os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte(hookContent), 0755) // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "error", expectInMsg: "incompatible", }, { name: "hook with version 0.29.0 (compatible)", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) hookContent := "#!/bin/sh\n# bd-hooks-version: 0.29.0\nbd sync\n" os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte(hookContent), 0755) // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "ok", expectInMsg: "compatible", }, { name: "hook with malformed version", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) hookContent := "#!/bin/sh\n# bd-hooks-version: invalid\nbd sync\n" os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte(hookContent), 0755) // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "error", expectInMsg: "incompatible", }, { name: "hook with version marker but no value", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) gitDir := filepath.Join(dir, ".git") hooksDir := filepath.Join(gitDir, "hooks") os.MkdirAll(hooksDir, 0755) hookContent := "#!/bin/sh\n# bd-hooks-version:\nbd sync\n" os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte(hookContent), 0755) // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "warning", expectInMsg: "Could not determine", }, { name: "hook in shared hooks directory (core.hooksPath)", setup: func(t *testing.T, dir string) { setupGitRepoInDir(t, dir) // create shared hooks directory sharedHooksDir := filepath.Join(dir, ".git-hooks") os.MkdirAll(sharedHooksDir, 0755) hookContent := "#!/bin/sh\n# bd-hooks-version: 0.29.0\nbd sync\n" os.WriteFile(filepath.Join(sharedHooksDir, "pre-push"), []byte(hookContent), 0755) // configure core.hooksPath cmd := exec.Command("git", "config", "core.hooksPath", ".git-hooks") cmd.Dir = dir _ = cmd.Run() // use env var to configure sync-branch os.Setenv("BEADS_SYNC_BRANCH", "beads-sync") t.Cleanup(func() { os.Unsetenv("BEADS_SYNC_BRANCH") }) }, expectedStatus: "ok", expectInMsg: "compatible", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) check := CheckSyncBranchHookCompatibility(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message) } if tt.expectInMsg != "" && !strings.Contains(check.Message, tt.expectInMsg) { t.Errorf("expected message to contain %q, got %q", tt.expectInMsg, check.Message) } }) } } // Tests for CheckOrphanedIssues func TestCheckOrphanedIssues_NoGitRepo(t *testing.T) { tmpDir := t.TempDir() // No git init - just a plain directory check := CheckOrphanedIssues(tmpDir) if check.Status != StatusOK { t.Errorf("expected status %q, got %q", StatusOK, check.Status) } if !strings.Contains(check.Message, "not a git repository") { t.Errorf("expected message about not a git repository, got %q", check.Message) } } func TestCheckOrphanedIssues_NoBeadsDir(t *testing.T) { tmpDir := t.TempDir() // Initialize git repo WITHOUT creating .beads cmd := exec.Command("git", "init") cmd.Dir = tmpDir if err := cmd.Run(); err != nil { t.Fatal(err) } check := CheckOrphanedIssues(tmpDir) if check.Status != StatusOK { t.Errorf("expected status %q, got %q", StatusOK, check.Status) } if !strings.Contains(check.Message, "no .beads directory") { t.Errorf("expected message about no .beads directory, got %q", check.Message) } } func TestCheckOrphanedIssues_NoDatabase(t *testing.T) { tmpDir := t.TempDir() setupGitRepoInDir(t, tmpDir) // Create .beads directory but no database beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } check := CheckOrphanedIssues(tmpDir) if check.Status != StatusOK { t.Errorf("expected status %q, got %q", StatusOK, check.Status) } if !strings.Contains(check.Message, "no database") { t.Errorf("expected message about no database, got %q", check.Message) } } func TestCheckOrphanedIssues_NoOpenIssues(t *testing.T) { tmpDir := t.TempDir() setupGitRepoInDir(t, tmpDir) // Create .beads directory and database beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } // Create a minimal SQLite database with schema dbPath := filepath.Join(beadsDir, "beads.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatal(err) } defer db.Close() // Create tables _, err = db.Exec(` CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT); INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd'); `) if err != nil { t.Fatal(err) } check := CheckOrphanedIssues(tmpDir) if check.Status != StatusOK { t.Errorf("expected status %q, got %q", StatusOK, check.Status) } if !strings.Contains(check.Message, "No open issues") { t.Errorf("expected message about no open issues, got %q", check.Message) } } func TestCheckOrphanedIssues_OpenIssueNotInCommits(t *testing.T) { tmpDir := t.TempDir() setupGitRepoInDir(t, tmpDir) // Create .beads directory and database beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } // Create database with an open issue dbPath := filepath.Join(beadsDir, "beads.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatal(err) } _, err = db.Exec(` CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT); INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd'); INSERT INTO issues (id, status) VALUES ('bd-abc', 'open'); `) if err != nil { t.Fatal(err) } db.Close() // Create a commit without the issue reference readme := filepath.Join(tmpDir, "README.md") if err := os.WriteFile(readme, []byte("# Test"), 0644); err != nil { t.Fatal(err) } cmd := exec.Command("git", "add", "README.md") cmd.Dir = tmpDir _ = cmd.Run() cmd = exec.Command("git", "commit", "-m", "Initial commit") cmd.Dir = tmpDir _ = cmd.Run() check := CheckOrphanedIssues(tmpDir) if check.Status != StatusOK { t.Errorf("expected status %q, got %q", StatusOK, check.Status) } if !strings.Contains(check.Message, "No issues referenced") { t.Errorf("expected message about no issues referenced, got %q", check.Message) } } func TestCheckOrphanedIssues_OpenIssueInCommit(t *testing.T) { tmpDir := t.TempDir() setupGitRepoInDir(t, tmpDir) // Create .beads directory and database beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } // Create database with an open issue dbPath := filepath.Join(beadsDir, "beads.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatal(err) } _, err = db.Exec(` CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT); INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd'); INSERT INTO issues (id, status) VALUES ('bd-abc', 'open'); `) if err != nil { t.Fatal(err) } db.Close() // Create a commit WITH the issue reference readme := filepath.Join(tmpDir, "README.md") if err := os.WriteFile(readme, []byte("# Test"), 0644); err != nil { t.Fatal(err) } cmd := exec.Command("git", "add", "README.md") cmd.Dir = tmpDir _ = cmd.Run() cmd = exec.Command("git", "commit", "-m", "Fix bug (bd-abc)") cmd.Dir = tmpDir _ = cmd.Run() check := CheckOrphanedIssues(tmpDir) if check.Status != StatusWarning { t.Errorf("expected status %q, got %q (message: %s)", StatusWarning, check.Status, check.Message) } if !strings.Contains(check.Message, "1 issue(s) referenced") { t.Errorf("expected message about 1 issue referenced, got %q", check.Message) } if !strings.Contains(check.Detail, "bd-abc") { t.Errorf("expected detail to contain bd-abc, got %q", check.Detail) } } func TestCheckOrphanedIssues_ClosedIssueInCommit(t *testing.T) { tmpDir := t.TempDir() setupGitRepoInDir(t, tmpDir) // Create .beads directory and database beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } // Create database with a CLOSED issue dbPath := filepath.Join(beadsDir, "beads.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatal(err) } _, err = db.Exec(` CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT); INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd'); INSERT INTO issues (id, status) VALUES ('bd-abc', 'closed'); `) if err != nil { t.Fatal(err) } db.Close() // Create a commit with the issue reference readme := filepath.Join(tmpDir, "README.md") if err := os.WriteFile(readme, []byte("# Test"), 0644); err != nil { t.Fatal(err) } cmd := exec.Command("git", "add", "README.md") cmd.Dir = tmpDir _ = cmd.Run() cmd = exec.Command("git", "commit", "-m", "Fix bug (bd-abc)") cmd.Dir = tmpDir _ = cmd.Run() check := CheckOrphanedIssues(tmpDir) // Should be OK because the issue is closed if check.Status != StatusOK { t.Errorf("expected status %q, got %q (message: %s)", StatusOK, check.Status, check.Message) } } func TestCheckOrphanedIssues_HierarchicalIssueID(t *testing.T) { tmpDir := t.TempDir() setupGitRepoInDir(t, tmpDir) // Create .beads directory and database beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatal(err) } // Create database with a hierarchical issue ID dbPath := filepath.Join(beadsDir, "beads.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatal(err) } _, err = db.Exec(` CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT); INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd'); INSERT INTO issues (id, status) VALUES ('bd-abc.1', 'open'); `) if err != nil { t.Fatal(err) } db.Close() // Create a commit with the hierarchical issue reference readme := filepath.Join(tmpDir, "README.md") if err := os.WriteFile(readme, []byte("# Test"), 0644); err != nil { t.Fatal(err) } cmd := exec.Command("git", "add", "README.md") cmd.Dir = tmpDir _ = cmd.Run() cmd = exec.Command("git", "commit", "-m", "Fix subtask (bd-abc.1)") cmd.Dir = tmpDir _ = cmd.Run() check := CheckOrphanedIssues(tmpDir) if check.Status != StatusWarning { t.Errorf("expected status %q, got %q (message: %s)", StatusWarning, check.Status, check.Message) } if !strings.Contains(check.Detail, "bd-abc.1") { t.Errorf("expected detail to contain bd-abc.1, got %q", check.Detail) } }