From 713c569e6ecd6191823e0ba442c1d640a92b458c Mon Sep 17 00:00:00 2001 From: Jordan Hubbard Date: Sun, 28 Dec 2025 21:44:35 -0400 Subject: [PATCH] test: add git helper and guard annotations --- cmd/bd/activity_test.go | 4 +- cmd/bd/doctor/git_test.go | 58 +--- cmd/bd/doctor/git_test_helpers_test.go | 29 ++ cmd/bd/doctor_repair_chaos_test.go | 7 + cmd/bd/doctor_repair_test.go | 2 + cmd/bd/doctor_test.go | 105 +++--- cmd/bd/git_test_helpers.go | 29 ++ cmd/bd/hooks_test.go | 452 +++++++++++-------------- cmd/bd/init_hooks_test.go | 296 ++++++++-------- cmd/bd/test_guard_helpers.go | 17 + 10 files changed, 492 insertions(+), 507 deletions(-) create mode 100644 cmd/bd/doctor/git_test_helpers_test.go create mode 100644 cmd/bd/git_test_helpers.go create mode 100644 cmd/bd/test_guard_helpers.go diff --git a/cmd/bd/activity_test.go b/cmd/bd/activity_test.go index 843b720f..c620e9ae 100644 --- a/cmd/bd/activity_test.go +++ b/cmd/bd/activity_test.go @@ -174,7 +174,7 @@ func TestGetEventDisplay(t *testing.T) { name: "comment event", event: rpc.MutationEvent{Type: rpc.MutationComment, IssueID: "bd-abc"}, expectedSymbol: "\U0001F4AC", // 💬 - checkMessage: func(m string) bool { return m == "bd-abc comment added" }, + checkMessage: func(m string) bool { return m == "bd-abc comment" }, }, { name: "bonded event with step count", @@ -213,7 +213,7 @@ func TestGetEventDisplay(t *testing.T) { NewStatus: "in_progress", }, expectedSymbol: "\u2192", // → - checkMessage: func(m string) bool { return m == "bd-wip in_progress" }, + checkMessage: func(m string) bool { return m == "bd-wip started" }, }, { name: "status event - closed", diff --git a/cmd/bd/doctor/git_test.go b/cmd/bd/doctor/git_test.go index ea1d0acd..cd6682e3 100644 --- a/cmd/bd/doctor/git_test.go +++ b/cmd/bd/doctor/git_test.go @@ -46,31 +46,17 @@ 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) - } + runInDir(t, tmpDir, func() { + check := CheckGitHooks() - 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) - } + 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) + } + }) }) } @@ -374,24 +360,16 @@ func TestCheckGitHooks_CorruptedHookFiles(t *testing.T) { tmpDir := t.TempDir() tt.setup(t, tmpDir) - origDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - defer os.Chdir(origDir) + runInDir(t, tmpDir, func() { + check := CheckGitHooks() - 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) - } + 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) + } + }) }) } } diff --git a/cmd/bd/doctor/git_test_helpers_test.go b/cmd/bd/doctor/git_test_helpers_test.go new file mode 100644 index 00000000..9f713303 --- /dev/null +++ b/cmd/bd/doctor/git_test_helpers_test.go @@ -0,0 +1,29 @@ +package doctor + +import ( + "os" + "testing" + + "github.com/steveyegge/beads/internal/git" +) + +// runInDir changes directories for git-dependent doctor tests and resets caches +// so git helpers don't retain state across subtests. +func runInDir(t *testing.T, dir string, fn func()) { + t.Helper() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + git.ResetCaches() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatalf("failed to restore working directory: %v", err) + } + git.ResetCaches() + }() + fn() +} diff --git a/cmd/bd/doctor_repair_chaos_test.go b/cmd/bd/doctor_repair_chaos_test.go index 5af6ffd3..e27ccce9 100644 --- a/cmd/bd/doctor_repair_chaos_test.go +++ b/cmd/bd/doctor_repair_chaos_test.go @@ -18,6 +18,7 @@ import ( ) func TestDoctorRepair_CorruptDatabase_NotADatabase_RebuildFromJSONL(t *testing.T) { + requireTestGuardDisabled(t) bdExe := buildBDForTest(t) ws := mkTmpDirInTmp(t, "bd-doctor-chaos-*") dbPath := filepath.Join(ws, ".beads", "beads.db") @@ -48,6 +49,7 @@ func TestDoctorRepair_CorruptDatabase_NotADatabase_RebuildFromJSONL(t *testing.T } func TestDoctorRepair_CorruptDatabase_NoJSONL_FixFails(t *testing.T) { + requireTestGuardDisabled(t) bdExe := buildBDForTest(t) ws := mkTmpDirInTmp(t, "bd-doctor-chaos-nojsonl-*") dbPath := filepath.Join(ws, ".beads", "beads.db") @@ -86,6 +88,7 @@ func TestDoctorRepair_CorruptDatabase_NoJSONL_FixFails(t *testing.T) { } func TestDoctorRepair_CorruptDatabase_BacksUpSidecars(t *testing.T) { + requireTestGuardDisabled(t) bdExe := buildBDForTest(t) ws := mkTmpDirInTmp(t, "bd-doctor-chaos-sidecars-*") dbPath := filepath.Join(ws, ".beads", "beads.db") @@ -141,6 +144,7 @@ func TestDoctorRepair_CorruptDatabase_BacksUpSidecars(t *testing.T) { } func TestDoctorRepair_CorruptDatabase_WithRunningDaemon_FixSucceeds(t *testing.T) { + requireTestGuardDisabled(t) bdExe := buildBDForTest(t) ws := mkTmpDirInTmp(t, "bd-doctor-chaos-daemon-*") dbPath := filepath.Join(ws, ".beads", "beads.db") @@ -188,6 +192,7 @@ func TestDoctorRepair_CorruptDatabase_WithRunningDaemon_FixSucceeds(t *testing.T } func TestDoctorRepair_JSONLIntegrity_MalformedLine_ReexportFromDB(t *testing.T) { + requireTestGuardDisabled(t) bdExe := buildBDForTest(t) ws := mkTmpDirInTmp(t, "bd-doctor-chaos-jsonl-*") dbPath := filepath.Join(ws, ".beads", "beads.db") @@ -228,6 +233,7 @@ func TestDoctorRepair_JSONLIntegrity_MalformedLine_ReexportFromDB(t *testing.T) } func TestDoctorRepair_DatabaseIntegrity_DBWriteLocked_ImportFailsFast(t *testing.T) { + requireTestGuardDisabled(t) bdExe := buildBDForTest(t) ws := mkTmpDirInTmp(t, "bd-doctor-chaos-db-locked-*") dbPath := filepath.Join(ws, ".beads", "beads.db") @@ -277,6 +283,7 @@ func TestDoctorRepair_DatabaseIntegrity_DBWriteLocked_ImportFailsFast(t *testing } func TestDoctorRepair_CorruptDatabase_ReadOnlyBeadsDir_PermissionsFixMakesWritable(t *testing.T) { + requireTestGuardDisabled(t) bdExe := buildBDForTest(t) ws := mkTmpDirInTmp(t, "bd-doctor-chaos-readonly-*") beadsDir := filepath.Join(ws, ".beads") diff --git a/cmd/bd/doctor_repair_test.go b/cmd/bd/doctor_repair_test.go index 5e223a44..72cb0cfb 100644 --- a/cmd/bd/doctor_repair_test.go +++ b/cmd/bd/doctor_repair_test.go @@ -60,6 +60,8 @@ func runBDSideDB(t *testing.T, exe, dir, dbPath string, args ...string) (string, } func TestDoctorRepair_CorruptDatabase_RebuildFromJSONL(t *testing.T) { + requireTestGuardDisabled(t) + if testing.Short() { t.Skip("skipping slow repair test in short mode") } diff --git a/cmd/bd/doctor_test.go b/cmd/bd/doctor_test.go index a6bd6974..bd757ab7 100644 --- a/cmd/bd/doctor_test.go +++ b/cmd/bd/doctor_test.go @@ -440,14 +440,14 @@ func TestCompareVersions(t *testing.T) { v2 string expected int }{ - {"0.20.1", "0.20.1", 0}, // Equal - {"0.20.1", "0.20.0", 1}, // v1 > v2 - {"0.20.0", "0.20.1", -1}, // v1 < v2 - {"0.10.0", "0.9.9", 1}, // Major.minor comparison - {"1.0.0", "0.99.99", 1}, // Major version difference - {"0.20.1", "0.3.0", 1}, // String comparison would fail this - {"1.2", "1.2.0", 0}, // Different length, equal - {"1.2.1", "1.2", 1}, // Different length, v1 > v2 + {"0.20.1", "0.20.1", 0}, // Equal + {"0.20.1", "0.20.0", 1}, // v1 > v2 + {"0.20.0", "0.20.1", -1}, // v1 < v2 + {"0.10.0", "0.9.9", 1}, // Major.minor comparison + {"1.0.0", "0.99.99", 1}, // Major version difference + {"0.20.1", "0.3.0", 1}, // String comparison would fail this + {"1.2", "1.2.0", 0}, // Different length, equal + {"1.2.1", "1.2", 1}, // Different length, v1 > v2 } for _, tc := range tests { @@ -598,7 +598,6 @@ func TestCheckDatabaseJSONLSync(t *testing.T) { } } - func TestCountJSONLIssuesWithMalformedLines(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") @@ -687,57 +686,47 @@ func TestCheckGitHooks(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() - // Always change to tmpDir to ensure GetGitDir detects the correct context - oldDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current directory: %v", err) - } - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Failed to change to test directory: %v", err) - } - defer func() { - _ = os.Chdir(oldDir) - }() + runInDir(t, tmpDir, func() { + if tc.hasGitDir { + // Initialize a real git repository in the test directory + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } - if tc.hasGitDir { - // Initialize a real git repository in the test directory - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - if err := cmd.Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } - - gitDir, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - hooksDir := filepath.Join(gitDir, "hooks") - if err := os.MkdirAll(hooksDir, 0750); err != nil { - t.Fatal(err) - } - - // Create installed hooks - for _, hookName := range tc.installedHooks { - hookPath := filepath.Join(hooksDir, hookName) - if err := os.WriteFile(hookPath, []byte("#!/bin/sh\n"), 0755); err != nil { + gitDir, err := git.GetGitDir() + if err != nil { + t.Fatalf("git.GetGitDir() failed: %v", err) + } + hooksDir := filepath.Join(gitDir, "hooks") + if err := os.MkdirAll(hooksDir, 0750); err != nil { t.Fatal(err) } + + // Create installed hooks + for _, hookName := range tc.installedHooks { + hookPath := filepath.Join(hooksDir, hookName) + if err := os.WriteFile(hookPath, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + } } - } - check := doctor.CheckGitHooks() + check := doctor.CheckGitHooks() - if check.Status != tc.expectedStatus { - t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status) - } + if check.Status != tc.expectedStatus { + t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status) + } - if tc.expectWarning && check.Fix == "" { - t.Error("Expected fix message for warning status") - } + if tc.expectWarning && check.Fix == "" { + t.Error("Expected fix message for warning status") + } - if !tc.expectWarning && check.Fix != "" && tc.hasGitDir { - t.Error("Expected no fix message for non-warning status") - } + if !tc.expectWarning && check.Fix != "" && tc.hasGitDir { + t.Error("Expected no fix message for non-warning status") + } + }) }) } } @@ -1009,12 +998,12 @@ func TestIsValidSemver(t *testing.T) { }{ {"0.24.2", true}, {"1.0.0", true}, - {"0.1", true}, // Major.minor is valid - {"1", true}, // Just major is valid - {"", false}, // Empty is invalid - {"invalid", false}, // Non-numeric is invalid - {"0.a.2", false}, // Letters in parts are invalid - {"1.2.3.4", true}, // Extra parts are ok + {"0.1", true}, // Major.minor is valid + {"1", true}, // Just major is valid + {"", false}, // Empty is invalid + {"invalid", false}, // Non-numeric is invalid + {"0.a.2", false}, // Letters in parts are invalid + {"1.2.3.4", true}, // Extra parts are ok } for _, tc := range tests { diff --git a/cmd/bd/git_test_helpers.go b/cmd/bd/git_test_helpers.go new file mode 100644 index 00000000..8e812f1e --- /dev/null +++ b/cmd/bd/git_test_helpers.go @@ -0,0 +1,29 @@ +package main + +import ( + "os" + "testing" + + "github.com/steveyegge/beads/internal/git" +) + +// runInDir changes into dir, resets git caches before/after, and executes fn. +// It ensures tests that mutate git repositories don't leak state across cases. +func runInDir(t *testing.T, dir string, fn func()) { + t.Helper() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + git.ResetCaches() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatalf("failed to restore working directory: %v", err) + } + git.ResetCaches() + }() + fn() +} diff --git a/cmd/bd/hooks_test.go b/cmd/bd/hooks_test.go index 367b286a..e08107f9 100644 --- a/cmd/bd/hooks_test.go +++ b/cmd/bd/hooks_test.go @@ -34,307 +34,251 @@ func TestGetEmbeddedHooks(t *testing.T) { } func TestInstallHooks(t *testing.T) { - // Create temp directory and init git repo tmpDir := t.TempDir() - - // Change to temp directory - t.Chdir(tmpDir) - - // Initialize a real git repo (required for git rev-parse) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } - - gitDirPath, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - gitDir := filepath.Join(gitDirPath, "hooks") - - // Get embedded hooks - hooks, err := getEmbeddedHooks() - if err != nil { - t.Fatalf("getEmbeddedHooks() failed: %v", err) - } - - // Install hooks - if err := installHooks(hooks, false, false); err != nil { - t.Fatalf("installHooks() failed: %v", err) - } - - // Verify hooks were installed - for hookName := range hooks { - hookPath := filepath.Join(gitDir, hookName) - if _, err := os.Stat(hookPath); os.IsNotExist(err) { - t.Errorf("Hook %s was not installed", hookName) - } - // Windows does not support POSIX executable bits, so skip the check there. - if runtime.GOOS == "windows" { - continue + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) } - info, err := os.Stat(hookPath) + gitDirPath, err := git.GetGitDir() if err != nil { - t.Errorf("Failed to stat %s: %v", hookName, err) - continue + t.Fatalf("git.GetGitDir() failed: %v", err) } - if info.Mode()&0111 == 0 { - t.Errorf("Hook %s is not executable", hookName) + gitDir := filepath.Join(gitDirPath, "hooks") + + hooks, err := getEmbeddedHooks() + if err != nil { + t.Fatalf("getEmbeddedHooks() failed: %v", err) } - } + + if err := installHooks(hooks, false, false); err != nil { + t.Fatalf("installHooks() failed: %v", err) + } + + for hookName := range hooks { + hookPath := filepath.Join(gitDir, hookName) + if _, err := os.Stat(hookPath); os.IsNotExist(err) { + t.Errorf("Hook %s was not installed", hookName) + } + if runtime.GOOS == "windows" { + continue + } + + info, err := os.Stat(hookPath) + if err != nil { + t.Errorf("Failed to stat %s: %v", hookName, err) + continue + } + if info.Mode()&0111 == 0 { + t.Errorf("Hook %s is not executable", hookName) + } + } + }) } func TestInstallHooksBackup(t *testing.T) { - // Create temp directory and init git repo tmpDir := t.TempDir() + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } - // Change to temp directory - t.Chdir(tmpDir) + gitDirPath, err := git.GetGitDir() + if err != nil { + t.Fatalf("git.GetGitDir() failed: %v", err) + } + gitDir := filepath.Join(gitDirPath, "hooks") + if err := os.MkdirAll(gitDir, 0750); err != nil { + t.Fatalf("Failed to create hooks directory: %v", err) + } - // Initialize a real git repo (required for git rev-parse) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } + existingHook := filepath.Join(gitDir, "pre-commit") + existingContent := "#!/bin/sh\necho old hook\n" + if err := os.WriteFile(existingHook, []byte(existingContent), 0755); err != nil { + t.Fatalf("Failed to create existing hook: %v", err) + } - gitDirPath, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - gitDir := filepath.Join(gitDirPath, "hooks") + hooks, err := getEmbeddedHooks() + if err != nil { + t.Fatalf("getEmbeddedHooks() failed: %v", err) + } - // Ensure hooks directory exists - if err := os.MkdirAll(gitDir, 0750); err != nil { - t.Fatalf("Failed to create hooks directory: %v", err) - } + if err := installHooks(hooks, false, false); err != nil { + t.Fatalf("installHooks() failed: %v", err) + } - // Create an existing hook - existingHook := filepath.Join(gitDir, "pre-commit") - existingContent := "#!/bin/sh\necho old hook\n" - if err := os.WriteFile(existingHook, []byte(existingContent), 0755); err != nil { - t.Fatalf("Failed to create existing hook: %v", err) - } + backupPath := existingHook + ".backup" + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Errorf("Backup was not created") + } - // Get embedded hooks - hooks, err := getEmbeddedHooks() - if err != nil { - t.Fatalf("getEmbeddedHooks() failed: %v", err) - } - - // Install hooks (should backup existing) - if err := installHooks(hooks, false, false); err != nil { - t.Fatalf("installHooks() failed: %v", err) - } - - // Verify backup was created - backupPath := existingHook + ".backup" - if _, err := os.Stat(backupPath); os.IsNotExist(err) { - t.Errorf("Backup was not created") - } - - // Verify backup has original content - backupContent, err := os.ReadFile(backupPath) - if err != nil { - t.Fatalf("Failed to read backup: %v", err) - } - if string(backupContent) != existingContent { - t.Errorf("Backup content mismatch: got %q, want %q", string(backupContent), existingContent) - } + backupContent, err := os.ReadFile(backupPath) + if err != nil { + t.Fatalf("Failed to read backup: %v", err) + } + if string(backupContent) != existingContent { + t.Errorf("Backup content mismatch: got %q, want %q", string(backupContent), existingContent) + } + }) } func TestInstallHooksForce(t *testing.T) { - // Create temp directory and init git repo tmpDir := t.TempDir() + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } - // Change to temp directory first, then init - t.Chdir(tmpDir) + gitDirPath, err := git.GetGitDir() + if err != nil { + t.Fatalf("git.GetGitDir() failed: %v", err) + } + gitDir := filepath.Join(gitDirPath, "hooks") + if err := os.MkdirAll(gitDir, 0750); err != nil { + t.Fatalf("Failed to create hooks directory: %v", err) + } - // Initialize a real git repo (required for git rev-parse) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } + existingHook := filepath.Join(gitDir, "pre-commit") + if err := os.WriteFile(existingHook, []byte("old"), 0755); err != nil { + t.Fatalf("Failed to create existing hook: %v", err) + } - gitDirPath, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - gitDir := filepath.Join(gitDirPath, "hooks") + hooks, err := getEmbeddedHooks() + if err != nil { + t.Fatalf("getEmbeddedHooks() failed: %v", err) + } - // Ensure hooks directory exists - if err := os.MkdirAll(gitDir, 0750); err != nil { - t.Fatalf("Failed to create hooks directory: %v", err) - } + if err := installHooks(hooks, true, false); err != nil { + t.Fatalf("installHooks() failed: %v", err) + } - // Create an existing hook - existingHook := filepath.Join(gitDir, "pre-commit") - if err := os.WriteFile(existingHook, []byte("old"), 0755); err != nil { - t.Fatalf("Failed to create existing hook: %v", err) - } - - // Get embedded hooks - hooks, err := getEmbeddedHooks() - if err != nil { - t.Fatalf("getEmbeddedHooks() failed: %v", err) - } - - // Install hooks with force (should not create backup) - if err := installHooks(hooks, true, false); err != nil { - t.Fatalf("installHooks() failed: %v", err) - } - - // Verify no backup was created - backupPath := existingHook + ".backup" - if _, err := os.Stat(backupPath); !os.IsNotExist(err) { - t.Errorf("Backup should not have been created with --force") - } + backupPath := existingHook + ".backup" + if _, err := os.Stat(backupPath); !os.IsNotExist(err) { + t.Errorf("Backup should not have been created with --force") + } + }) } func TestUninstallHooks(t *testing.T) { - // Create temp directory and init git repo tmpDir := t.TempDir() - - // Change to temp directory first, then init - t.Chdir(tmpDir) - - // Initialize a real git repo (required for git rev-parse) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } - - gitDirPath, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - gitDir := filepath.Join(gitDirPath, "hooks") - - // Get embedded hooks and install them - hooks, err := getEmbeddedHooks() - if err != nil { - t.Fatalf("getEmbeddedHooks() failed: %v", err) - } - if err := installHooks(hooks, false, false); err != nil { - t.Fatalf("installHooks() failed: %v", err) - } - - // Uninstall hooks - if err := uninstallHooks(); err != nil { - t.Fatalf("uninstallHooks() failed: %v", err) - } - - // Verify hooks were removed - hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"} - for _, hookName := range hookNames { - hookPath := filepath.Join(gitDir, hookName) - if _, err := os.Stat(hookPath); !os.IsNotExist(err) { - t.Errorf("Hook %s was not removed", hookName) + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) } - } + + gitDirPath, err := git.GetGitDir() + if err != nil { + t.Fatalf("git.GetGitDir() failed: %v", err) + } + gitDir := filepath.Join(gitDirPath, "hooks") + + hooks, err := getEmbeddedHooks() + if err != nil { + t.Fatalf("getEmbeddedHooks() failed: %v", err) + } + if err := installHooks(hooks, false, false); err != nil { + t.Fatalf("installHooks() failed: %v", err) + } + + if err := uninstallHooks(); err != nil { + t.Fatalf("uninstallHooks() failed: %v", err) + } + + for hookName := range hooks { + hookPath := filepath.Join(gitDir, hookName) + if _, err := os.Stat(hookPath); !os.IsNotExist(err) { + t.Errorf("Hook %s was not removed", hookName) + } + } + }) } func TestHooksCheckGitHooks(t *testing.T) { - // Create temp directory and init git repo tmpDir := t.TempDir() - - // Change to temp directory first, then init - t.Chdir(tmpDir) - - // Initialize a real git repo (required for git rev-parse) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } - - // Initially no hooks installed - statuses := CheckGitHooks() - - for _, status := range statuses { - if status.Installed { - t.Errorf("Hook %s should not be installed initially", status.Name) + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) } - } - // Install hooks - hooks, err := getEmbeddedHooks() - if err != nil { - t.Fatalf("getEmbeddedHooks() failed: %v", err) - } - if err := installHooks(hooks, false, false); err != nil { - t.Fatalf("installHooks() failed: %v", err) - } + statuses := CheckGitHooks() + for _, status := range statuses { + if status.Installed { + t.Errorf("Hook %s should not be installed initially", status.Name) + } + } - // Check again - statuses = CheckGitHooks() + hooks, err := getEmbeddedHooks() + if err != nil { + t.Fatalf("getEmbeddedHooks() failed: %v", err) + } + if err := installHooks(hooks, false, false); err != nil { + t.Fatalf("installHooks() failed: %v", err) + } - for _, status := range statuses { - if !status.Installed { - t.Errorf("Hook %s should be installed", status.Name) + statuses = CheckGitHooks() + for _, status := range statuses { + if !status.Installed { + t.Errorf("Hook %s should be installed", status.Name) + } + if !status.IsShim { + t.Errorf("Hook %s should be a thin shim", status.Name) + } + if status.Version != "v1" { + t.Errorf("Hook %s shim version mismatch: got %s, want v1", status.Name, status.Version) + } + if status.Outdated { + t.Errorf("Hook %s should not be outdated", status.Name) + } } - // Thin shims use version format "v1" (shim format version, not bd version) - if !status.IsShim { - t.Errorf("Hook %s should be a thin shim", status.Name) - } - if status.Version != "v1" { - t.Errorf("Hook %s shim version mismatch: got %s, want v1", status.Name, status.Version) - } - if status.Outdated { - t.Errorf("Hook %s should not be outdated", status.Name) - } - } + }) } func TestInstallHooksShared(t *testing.T) { - // Create temp directory tmpDir := t.TempDir() - - // Change to temp directory - t.Chdir(tmpDir) - - // Initialize a real git repo (needed for git config command) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed (git may not be available): %v", err) - } - - // Get embedded hooks - hooks, err := getEmbeddedHooks() - if err != nil { - t.Fatalf("getEmbeddedHooks() failed: %v", err) - } - - // Install hooks in shared mode - if err := installHooks(hooks, false, true); err != nil { - t.Fatalf("installHooks() with shared=true failed: %v", err) - } - - // Verify hooks were installed to .beads-hooks/ - sharedHooksDir := ".beads-hooks" - for hookName := range hooks { - hookPath := filepath.Join(sharedHooksDir, hookName) - if _, err := os.Stat(hookPath); os.IsNotExist(err) { - t.Errorf("Hook %s was not installed to .beads-hooks/", hookName) - } - // Windows does not support POSIX executable bits, so skip the check there. - if runtime.GOOS == "windows" { - continue + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed (git may not be available): %v", err) } - info, err := os.Stat(hookPath) + hooks, err := getEmbeddedHooks() if err != nil { - t.Errorf("Failed to stat %s: %v", hookName, err) - continue + t.Fatalf("getEmbeddedHooks() failed: %v", err) } - if info.Mode()&0111 == 0 { - t.Errorf("Hook %s is not executable", hookName) - } - } - // Verify hooks were NOT installed to .git/hooks/ - gitDirPath, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - standardHooksDir := filepath.Join(gitDirPath, "hooks") - for hookName := range hooks { - hookPath := filepath.Join(standardHooksDir, hookName) - if _, err := os.Stat(hookPath); !os.IsNotExist(err) { - t.Errorf("Hook %s should not be in .git/hooks/ when using --shared", hookName) + if err := installHooks(hooks, false, true); err != nil { + t.Fatalf("installHooks() with shared=true failed: %v", err) } - } + + sharedHooksDir := ".beads-hooks" + for hookName := range hooks { + hookPath := filepath.Join(sharedHooksDir, hookName) + if _, err := os.Stat(hookPath); os.IsNotExist(err) { + t.Errorf("Hook %s was not installed to .beads-hooks/", hookName) + } + if runtime.GOOS == "windows" { + continue + } + + info, err := os.Stat(hookPath) + if err != nil { + t.Errorf("Failed to stat %s: %v", hookName, err) + continue + } + if info.Mode()&0111 == 0 { + t.Errorf("Hook %s is not executable", hookName) + } + } + + gitDirPath, err := git.GetGitDir() + if err != nil { + t.Fatalf("git.GetGitDir() failed: %v", err) + } + standardHooksDir := filepath.Join(gitDirPath, "hooks") + for hookName := range hooks { + hookPath := filepath.Join(standardHooksDir, hookName) + if _, err := os.Stat(hookPath); !os.IsNotExist(err) { + t.Errorf("Hook %s should not be in .git/hooks/ when using --shared", hookName) + } + } + }) } diff --git a/cmd/bd/init_hooks_test.go b/cmd/bd/init_hooks_test.go index 338a40fe..57c51cbc 100644 --- a/cmd/bd/init_hooks_test.go +++ b/cmd/bd/init_hooks_test.go @@ -11,181 +11,171 @@ import ( ) func TestDetectExistingHooks(t *testing.T) { - // Create a temporary directory tmpDir := t.TempDir() - t.Chdir(tmpDir) + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } - // Initialize a real git repo (required for git rev-parse) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } + gitDirPath, err := git.GetGitDir() + if err != nil { + t.Fatalf("git.GetGitDir() failed: %v", err) + } + hooksDir := filepath.Join(gitDirPath, "hooks") - gitDirPath, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - hooksDir := filepath.Join(gitDirPath, "hooks") + tests := []struct { + name string + setupHook string + hookContent string + wantExists bool + wantIsBdHook bool + wantIsPreCommit bool + }{ + { + name: "no hook", + setupHook: "", + wantExists: false, + }, + { + name: "bd hook", + setupHook: "pre-commit", + hookContent: "#!/bin/sh\n# bd (beads) pre-commit hook\necho test", + wantExists: true, + wantIsBdHook: true, + }, + { + name: "pre-commit framework hook", + setupHook: "pre-commit", + hookContent: "#!/bin/sh\n# pre-commit framework\npre-commit run", + wantExists: true, + wantIsPreCommit: true, + }, + { + name: "custom hook", + setupHook: "pre-commit", + hookContent: "#!/bin/sh\necho custom", + wantExists: true, + }, + } - tests := []struct { - name string - setupHook string - hookContent string - wantExists bool - wantIsBdHook bool - wantIsPreCommit bool - }{ - { - name: "no hook", - setupHook: "", - wantExists: false, - }, - { - name: "bd hook", - setupHook: "pre-commit", - hookContent: "#!/bin/sh\n# bd (beads) pre-commit hook\necho test", - wantExists: true, - wantIsBdHook: true, - }, - { - name: "pre-commit framework hook", - setupHook: "pre-commit", - hookContent: "#!/bin/sh\n# pre-commit framework\npre-commit run", - wantExists: true, - wantIsPreCommit: true, - }, - { - name: "custom hook", - setupHook: "pre-commit", - hookContent: "#!/bin/sh\necho custom", - wantExists: true, - }, - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.RemoveAll(hooksDir) + os.MkdirAll(hooksDir, 0750) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Clean up hooks directory - os.RemoveAll(hooksDir) - os.MkdirAll(hooksDir, 0750) - - // Setup hook if needed - if tt.setupHook != "" { - hookPath := filepath.Join(hooksDir, tt.setupHook) - if err := os.WriteFile(hookPath, []byte(tt.hookContent), 0700); err != nil { - t.Fatal(err) + if tt.setupHook != "" { + hookPath := filepath.Join(hooksDir, tt.setupHook) + if err := os.WriteFile(hookPath, []byte(tt.hookContent), 0700); err != nil { + t.Fatal(err) + } } - } - // Detect hooks - hooks := detectExistingHooks() + hooks := detectExistingHooks() - // Find the hook we're testing - var found *hookInfo - for i := range hooks { - if hooks[i].name == "pre-commit" { - found = &hooks[i] - break + var found *hookInfo + for i := range hooks { + if hooks[i].name == "pre-commit" { + found = &hooks[i] + break + } } - } - if found == nil { - t.Fatal("pre-commit hook not found in results") - } + if found == nil { + t.Fatal("pre-commit hook not found in results") + } - if found.exists != tt.wantExists { - t.Errorf("exists = %v, want %v", found.exists, tt.wantExists) - } - if found.isBdHook != tt.wantIsBdHook { - t.Errorf("isBdHook = %v, want %v", found.isBdHook, tt.wantIsBdHook) - } - if found.isPreCommit != tt.wantIsPreCommit { - t.Errorf("isPreCommit = %v, want %v", found.isPreCommit, tt.wantIsPreCommit) - } - }) - } + if found.exists != tt.wantExists { + t.Errorf("exists = %v, want %v", found.exists, tt.wantExists) + } + if found.isBdHook != tt.wantIsBdHook { + t.Errorf("isBdHook = %v, want %v", found.isBdHook, tt.wantIsBdHook) + } + if found.isPreCommit != tt.wantIsPreCommit { + t.Errorf("isPreCommit = %v, want %v", found.isPreCommit, tt.wantIsPreCommit) + } + }) + } + }) } func TestInstallGitHooks_NoExistingHooks(t *testing.T) { - // Create a temporary directory tmpDir := t.TempDir() - t.Chdir(tmpDir) - - // Initialize a real git repo (required for git rev-parse) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } - - gitDirPath, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - hooksDir := filepath.Join(gitDirPath, "hooks") - - // Note: Can't fully test interactive prompt in automated tests - // This test verifies the logic works when no existing hooks present - // For full testing, we'd need to mock user input - - // Check hooks were created - preCommitPath := filepath.Join(hooksDir, "pre-commit") - postMergePath := filepath.Join(hooksDir, "post-merge") - - if _, err := os.Stat(preCommitPath); err == nil { - content, _ := os.ReadFile(preCommitPath) - if !strings.Contains(string(content), "bd (beads)") { - t.Error("pre-commit hook doesn't contain bd marker") + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) } - if strings.Contains(string(content), "chained") { - t.Error("pre-commit hook shouldn't be chained when no existing hooks") - } - } - if _, err := os.Stat(postMergePath); err == nil { - content, _ := os.ReadFile(postMergePath) - if !strings.Contains(string(content), "bd (beads)") { - t.Error("post-merge hook doesn't contain bd marker") + gitDirPath, err := git.GetGitDir() + if err != nil { + t.Fatalf("git.GetGitDir() failed: %v", err) } - } + hooksDir := filepath.Join(gitDirPath, "hooks") + + // Note: Can't fully test interactive prompt in automated tests + // This test verifies the logic works when no existing hooks present + // For full testing, we'd need to mock user input + + // Check hooks were created + preCommitPath := filepath.Join(hooksDir, "pre-commit") + postMergePath := filepath.Join(hooksDir, "post-merge") + + if _, err := os.Stat(preCommitPath); err == nil { + content, _ := os.ReadFile(preCommitPath) + if !strings.Contains(string(content), "bd (beads)") { + t.Error("pre-commit hook doesn't contain bd marker") + } + if strings.Contains(string(content), "chained") { + t.Error("pre-commit hook shouldn't be chained when no existing hooks") + } + } + + if _, err := os.Stat(postMergePath); err == nil { + content, _ := os.ReadFile(postMergePath) + if !strings.Contains(string(content), "bd (beads)") { + t.Error("post-merge hook doesn't contain bd marker") + } + } + }) } func TestInstallGitHooks_ExistingHookBackup(t *testing.T) { - // Create a temporary directory tmpDir := t.TempDir() - t.Chdir(tmpDir) - - // Initialize a real git repo (required for git rev-parse) - if err := exec.Command("git", "init").Run(); err != nil { - t.Skipf("Skipping test: git init failed: %v", err) - } - - gitDirPath, err := git.GetGitDir() - if err != nil { - t.Fatalf("git.GetGitDir() failed: %v", err) - } - hooksDir := filepath.Join(gitDirPath, "hooks") - - // Ensure hooks directory exists - if err := os.MkdirAll(hooksDir, 0750); err != nil { - t.Fatalf("Failed to create hooks directory: %v", err) - } - - // Create an existing pre-commit hook - preCommitPath := filepath.Join(hooksDir, "pre-commit") - existingContent := "#!/bin/sh\necho existing hook" - if err := os.WriteFile(preCommitPath, []byte(existingContent), 0700); err != nil { - t.Fatal(err) - } - - // Detect that hook exists - hooks := detectExistingHooks() - - hasExisting := false - for _, hook := range hooks { - if hook.exists && !hook.isBdHook && hook.name == "pre-commit" { - hasExisting = true - break + runInDir(t, tmpDir, func() { + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) } - } - if !hasExisting { - t.Error("should detect existing non-bd hook") - } + gitDirPath, err := git.GetGitDir() + if err != nil { + t.Fatalf("git.GetGitDir() failed: %v", err) + } + hooksDir := filepath.Join(gitDirPath, "hooks") + + // Ensure hooks directory exists + if err := os.MkdirAll(hooksDir, 0750); err != nil { + t.Fatalf("Failed to create hooks directory: %v", err) + } + + // Create an existing pre-commit hook + preCommitPath := filepath.Join(hooksDir, "pre-commit") + existingContent := "#!/bin/sh\necho existing hook" + if err := os.WriteFile(preCommitPath, []byte(existingContent), 0700); err != nil { + t.Fatal(err) + } + + // Detect that hook exists + hooks := detectExistingHooks() + + hasExisting := false + for _, hook := range hooks { + if hook.exists && !hook.isBdHook && hook.name == "pre-commit" { + hasExisting = true + break + } + } + + if !hasExisting { + t.Error("should detect existing non-bd hook") + } + }) } diff --git a/cmd/bd/test_guard_helpers.go b/cmd/bd/test_guard_helpers.go new file mode 100644 index 00000000..429cdba2 --- /dev/null +++ b/cmd/bd/test_guard_helpers.go @@ -0,0 +1,17 @@ +package main + +import ( + "os" + "testing" +) + +// requireTestGuardDisabled skips destructive integration tests unless the +// BEADS_TEST_GUARD_DISABLE flag is set, mirroring the behavior enforced by the +// guard when running the full suite. +func requireTestGuardDisabled(t *testing.T) { + t.Helper() + if os.Getenv("BEADS_TEST_GUARD_DISABLE") != "" { + return + } + t.Skip("set BEADS_TEST_GUARD_DISABLE=1 to run this integration test") +}