DOCTOR IMPROVEMENTS: visual improvements/grouping + add comprehensive tests + fix gosec warnings (#656)
* test(doctor): add comprehensive tests for fix and check functions Add edge case tests, e2e tests, and improve test coverage for: - database_test.go: database integrity and sync checks - git_test.go: git hooks, merge driver, sync branch tests - gitignore_test.go: gitignore validation - prefix_test.go: ID prefix handling - fix/fix_test.go: fix operations - fix/e2e_test.go: end-to-end fix scenarios - fix/fix_edge_cases_test.go: edge case handling * docs: add testing philosophy and anti-patterns guide - Create TESTING_PHILOSOPHY.md covering test pyramid, priority matrix, what NOT to test, and 5 anti-patterns with code examples - Add cross-reference from README_TESTING.md - Document beads-specific guidance (well-covered areas vs gaps) - Include target metrics (test-to-code ratio, execution time targets) * chore: revert .beads/ to upstream/main state * refactor(doctor): add category grouping and Ayu theme colors - Add Category field to DoctorCheck for organizing checks by type - Define category constants: Core, Git, Runtime, Data, Integration, Metadata - Update thanks command to use shared Ayu color palette from internal/ui - Simplify test fixtures by removing redundant test cases * fix(doctor): prevent test fork bomb and fix test failures - Add ErrTestBinary guard in getBdBinary() to prevent tests from recursively executing the test binary when calling bd subcommands - Update claude_test.go to use new check names (CLI Availability, Prime Documentation) - Fix syncbranch test path comparison by resolving symlinks (/var vs /private/var on macOS) - Fix permissions check to use exact comparison instead of bitmask - Fix UntrackedJSONL to use git commit --only to preserve staged changes - Fix MergeDriver edge case test by making both .git dir and config read-only - Add skipIfTestBinary helper for E2E tests that need real bd binary * test(doctor): skip read-only config test in CI environments GitHub Actions containers may have CAP_DAC_OVERRIDE or similar capabilities that allow writing to read-only files, causing the test to fail. Skip the test when CI=true or GITHUB_ACTIONS=true.
This commit is contained in:
@@ -8,8 +8,13 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrTestBinary is returned when getBdBinary detects it's running as a test binary.
|
||||
// This prevents fork bombs when tests call functions that execute bd subcommands.
|
||||
var ErrTestBinary = fmt.Errorf("running as test binary - cannot execute bd subcommands")
|
||||
|
||||
// getBdBinary returns the path to the bd binary to use for fix operations.
|
||||
// It prefers the current executable to avoid command injection attacks.
|
||||
// Returns ErrTestBinary if running as a test binary to prevent fork bombs.
|
||||
func getBdBinary() (string, error) {
|
||||
// Prefer current executable for security
|
||||
exe, err := os.Executable()
|
||||
@@ -17,8 +22,16 @@ func getBdBinary() (string, error) {
|
||||
// Resolve symlinks to get the real binary path
|
||||
realPath, err := filepath.EvalSymlinks(exe)
|
||||
if err == nil {
|
||||
return realPath, nil
|
||||
exe = realPath
|
||||
}
|
||||
|
||||
// Check if we're running as a test binary - this prevents fork bombs
|
||||
// when tests call functions that execute bd subcommands
|
||||
baseName := filepath.Base(exe)
|
||||
if strings.HasSuffix(baseName, ".test") || strings.Contains(baseName, ".test.") {
|
||||
return "", ErrTestBinary
|
||||
}
|
||||
|
||||
return exe, nil
|
||||
}
|
||||
|
||||
|
||||
1003
cmd/bd/doctor/fix/e2e_test.go
Normal file
1003
cmd/bd/doctor/fix/e2e_test.go
Normal file
File diff suppressed because it is too large
Load Diff
793
cmd/bd/doctor/fix/fix_edge_cases_test.go
Normal file
793
cmd/bd/doctor/fix/fix_edge_cases_test.go
Normal file
@@ -0,0 +1,793 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIsWithinWorkspace_PathTraversal tests path traversal attempts
|
||||
func TestIsWithinWorkspace_PathTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
candidate string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "simple dotdot traversal",
|
||||
candidate: filepath.Join(root, "..", "etc"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "dotdot in middle of path",
|
||||
candidate: filepath.Join(root, "subdir", "..", "..", "etc"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "multiple dotdot",
|
||||
candidate: filepath.Join(root, "..", "..", ".."),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "dotdot stays within workspace",
|
||||
candidate: filepath.Join(root, "a", "b", "..", "c"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "relative path with dotdot",
|
||||
candidate: filepath.Join(root, "subdir", "..", "file"),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isWithinWorkspace(root, tt.candidate)
|
||||
if got != tt.want {
|
||||
t.Errorf("isWithinWorkspace(%q, %q) = %v, want %v", root, tt.candidate, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateBeadsWorkspace_EdgeCases tests edge cases for workspace validation
|
||||
func TestValidateBeadsWorkspace_EdgeCases(t *testing.T) {
|
||||
t.Run("nested .beads directories", func(t *testing.T) {
|
||||
// Create a workspace with nested .beads directories
|
||||
dir := setupTestWorkspace(t)
|
||||
nestedDir := filepath.Join(dir, "subdir")
|
||||
nestedBeadsDir := filepath.Join(nestedDir, ".beads")
|
||||
if err := os.MkdirAll(nestedBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create nested .beads: %v", err)
|
||||
}
|
||||
|
||||
// Root workspace should be valid
|
||||
if err := validateBeadsWorkspace(dir); err != nil {
|
||||
t.Errorf("expected root workspace to be valid, got: %v", err)
|
||||
}
|
||||
|
||||
// Nested workspace should also be valid
|
||||
if err := validateBeadsWorkspace(nestedDir); err != nil {
|
||||
t.Errorf("expected nested workspace to be valid, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(".beads as a file not directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
beadsFile := filepath.Join(dir, ".beads")
|
||||
// Create .beads as a file instead of directory
|
||||
if err := os.WriteFile(beadsFile, []byte("not a directory"), 0600); err != nil {
|
||||
t.Fatalf("failed to create .beads file: %v", err)
|
||||
}
|
||||
|
||||
err := validateBeadsWorkspace(dir)
|
||||
// NOTE: Current implementation only checks if .beads exists via os.Stat,
|
||||
// but doesn't verify it's a directory. This test documents current behavior.
|
||||
// A future improvement could add IsDir() check.
|
||||
if err == nil {
|
||||
// Currently passes - implementation doesn't validate it's a directory
|
||||
t.Log(".beads exists as file - validation passes (edge case)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(".beads as symlink to directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create actual .beads directory elsewhere
|
||||
actualBeadsDir := filepath.Join(t.TempDir(), "actual_beads")
|
||||
if err := os.MkdirAll(actualBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create actual beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Create symlink .beads -> actual_beads
|
||||
symlinkPath := filepath.Join(dir, ".beads")
|
||||
if err := os.Symlink(actualBeadsDir, symlinkPath); err != nil {
|
||||
t.Skipf("symlink creation failed (may not be supported): %v", err)
|
||||
}
|
||||
|
||||
// Should be valid - symlink to directory is acceptable
|
||||
if err := validateBeadsWorkspace(dir); err != nil {
|
||||
t.Errorf("expected symlinked .beads directory to be valid, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(".beads as symlink to file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a file
|
||||
actualFile := filepath.Join(t.TempDir(), "actual_file")
|
||||
if err := os.WriteFile(actualFile, []byte("test"), 0600); err != nil {
|
||||
t.Fatalf("failed to create actual file: %v", err)
|
||||
}
|
||||
|
||||
// Create symlink .beads -> file
|
||||
symlinkPath := filepath.Join(dir, ".beads")
|
||||
if err := os.Symlink(actualFile, symlinkPath); err != nil {
|
||||
t.Skipf("symlink creation failed (may not be supported): %v", err)
|
||||
}
|
||||
|
||||
err := validateBeadsWorkspace(dir)
|
||||
// NOTE: os.Stat follows symlinks, so if symlink points to a file,
|
||||
// it just sees the file exists and returns no error.
|
||||
// Current implementation doesn't verify it's a directory.
|
||||
if err == nil {
|
||||
t.Log(".beads symlink to file - validation passes (edge case)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(".beads as broken symlink", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create symlink to non-existent target
|
||||
symlinkPath := filepath.Join(dir, ".beads")
|
||||
if err := os.Symlink("/nonexistent/target", symlinkPath); err != nil {
|
||||
t.Skipf("symlink creation failed (may not be supported): %v", err)
|
||||
}
|
||||
|
||||
err := validateBeadsWorkspace(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error when .beads is a broken symlink")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("relative path resolution", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
// Test with relative path
|
||||
originalWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(originalWd); err != nil {
|
||||
t.Logf("failed to restore working directory: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := os.Chdir(filepath.Dir(dir)); err != nil {
|
||||
t.Fatalf("failed to change directory: %v", err)
|
||||
}
|
||||
|
||||
relPath := filepath.Base(dir)
|
||||
if err := validateBeadsWorkspace(relPath); err != nil {
|
||||
t.Errorf("expected relative path to be valid, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFindJSONLPath_EdgeCases tests edge cases for finding JSONL files
|
||||
func TestFindJSONLPath_EdgeCases(t *testing.T) {
|
||||
t.Run("multiple JSONL files - issues.jsonl takes precedence", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
// Create both files
|
||||
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
beadsPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
if err := os.WriteFile(issuesPath, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(beadsPath, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("failed to create beads.jsonl: %v", err)
|
||||
}
|
||||
|
||||
path := findJSONLPath(beadsDir)
|
||||
if path != issuesPath {
|
||||
t.Errorf("expected %s, got %s", issuesPath, path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only beads.jsonl exists", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
beadsPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
if err := os.WriteFile(beadsPath, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("failed to create beads.jsonl: %v", err)
|
||||
}
|
||||
|
||||
path := findJSONLPath(beadsDir)
|
||||
if path != beadsPath {
|
||||
t.Errorf("expected %s, got %s", beadsPath, path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSONL file as symlink", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
// Create actual file
|
||||
actualFile := filepath.Join(t.TempDir(), "actual_issues.jsonl")
|
||||
if err := os.WriteFile(actualFile, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("failed to create actual file: %v", err)
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
symlinkPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if err := os.Symlink(actualFile, symlinkPath); err != nil {
|
||||
t.Skipf("symlink creation failed (may not be supported): %v", err)
|
||||
}
|
||||
|
||||
path := findJSONLPath(beadsDir)
|
||||
if path != symlinkPath {
|
||||
t.Errorf("expected symlink to be found: %s, got %s", symlinkPath, path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSONL file is directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
// Create issues.jsonl as directory instead of file
|
||||
issuesDir := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if err := os.MkdirAll(issuesDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create issues.jsonl dir: %v", err)
|
||||
}
|
||||
|
||||
path := findJSONLPath(beadsDir)
|
||||
// NOTE: Current implementation only checks if path exists via os.Stat,
|
||||
// but doesn't verify it's a regular file. Returns path even for directories.
|
||||
// This documents current behavior - a future improvement could add IsRegular() check.
|
||||
if path == issuesDir {
|
||||
t.Log("issues.jsonl exists as directory - findJSONLPath returns it (edge case)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no JSONL files present", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
path := findJSONLPath(beadsDir)
|
||||
if path != "" {
|
||||
t.Errorf("expected empty path, got %s", path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty beadsDir path", func(t *testing.T) {
|
||||
path := findJSONLPath("")
|
||||
if path != "" {
|
||||
t.Errorf("expected empty path for empty beadsDir, got %s", path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent beadsDir", func(t *testing.T) {
|
||||
path := findJSONLPath("/nonexistent/path/to/beads")
|
||||
if path != "" {
|
||||
t.Errorf("expected empty path for nonexistent beadsDir, got %s", path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGitHooks_EdgeCases tests GitHooks with edge cases
|
||||
func TestGitHooks_EdgeCases(t *testing.T) {
|
||||
// Skip if running as test binary (can't execute bd subcommands)
|
||||
skipIfTestBinary(t)
|
||||
|
||||
t.Run("hooks directory does not exist", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
|
||||
// Verify .git/hooks doesn't exist or remove it
|
||||
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||
_ = os.RemoveAll(hooksDir)
|
||||
|
||||
// GitHooks should create the directory via bd hooks install
|
||||
err := GitHooks(dir)
|
||||
if err != nil {
|
||||
t.Errorf("GitHooks should succeed when hooks directory doesn't exist, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify hooks directory was created
|
||||
if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
|
||||
t.Error("expected hooks directory to be created")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("git worktree with .git file", func(t *testing.T) {
|
||||
// Create main repo
|
||||
mainDir := setupTestGitRepo(t)
|
||||
|
||||
// Create a commit so we can create a worktree
|
||||
testFile := filepath.Join(mainDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
runGit(t, mainDir, "add", "test.txt")
|
||||
runGit(t, mainDir, "commit", "-m", "initial")
|
||||
|
||||
// Create a worktree
|
||||
worktreeDir := t.TempDir()
|
||||
runGit(t, mainDir, "worktree", "add", worktreeDir, "-b", "feature")
|
||||
|
||||
// Create .beads in worktree
|
||||
beadsDir := filepath.Join(worktreeDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads in worktree: %v", err)
|
||||
}
|
||||
|
||||
// GitHooks should work with worktrees where .git is a file
|
||||
err := GitHooks(worktreeDir)
|
||||
if err != nil {
|
||||
t.Errorf("GitHooks should work with git worktrees, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMergeDriver_EdgeCases tests MergeDriver with edge cases
|
||||
func TestMergeDriver_EdgeCases(t *testing.T) {
|
||||
t.Run("read-only git config file", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
gitDir := filepath.Join(dir, ".git")
|
||||
gitConfigPath := filepath.Join(gitDir, "config")
|
||||
|
||||
// Make both .git directory and config file read-only to truly prevent writes
|
||||
// (git might otherwise create a new file and rename it)
|
||||
if err := os.Chmod(gitConfigPath, 0400); err != nil {
|
||||
t.Fatalf("failed to make config read-only: %v", err)
|
||||
}
|
||||
if err := os.Chmod(gitDir, 0500); err != nil {
|
||||
t.Fatalf("failed to make .git read-only: %v", err)
|
||||
}
|
||||
|
||||
// Restore write permissions at the end
|
||||
defer func() {
|
||||
_ = os.Chmod(gitDir, 0700)
|
||||
_ = os.Chmod(gitConfigPath, 0600)
|
||||
}()
|
||||
|
||||
// MergeDriver should fail with read-only config
|
||||
err := MergeDriver(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error when git config is read-only")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("succeeds after config was previously set", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
|
||||
// Set the merge driver config initially
|
||||
err := MergeDriver(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("first MergeDriver call failed: %v", err)
|
||||
}
|
||||
|
||||
// Run again to verify it handles existing config
|
||||
err = MergeDriver(dir)
|
||||
if err != nil {
|
||||
t.Errorf("MergeDriver should succeed when config already exists, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify the config is still correct
|
||||
cmd := exec.Command("git", "config", "merge.beads.driver")
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get git config: %v", err)
|
||||
}
|
||||
|
||||
expected := "bd merge %A %O %A %B\n"
|
||||
if string(output) != expected {
|
||||
t.Errorf("expected %q, got %q", expected, string(output))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestUntrackedJSONL_EdgeCases tests UntrackedJSONL with edge cases
|
||||
func TestUntrackedJSONL_EdgeCases(t *testing.T) {
|
||||
t.Run("staged but uncommitted JSONL files", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
|
||||
// Create initial commit
|
||||
testFile := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
runGit(t, dir, "add", "test.txt")
|
||||
runGit(t, dir, "commit", "-m", "initial")
|
||||
|
||||
// Create a JSONL file and stage it but don't commit
|
||||
jsonlFile := filepath.Join(dir, ".beads", "deletions.jsonl")
|
||||
if err := os.WriteFile(jsonlFile, []byte(`{"id":"test-1","ts":"2024-01-01T00:00:00Z","by":"user"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("failed to create JSONL file: %v", err)
|
||||
}
|
||||
runGit(t, dir, "add", ".beads/deletions.jsonl")
|
||||
|
||||
// Check git status - should show staged file
|
||||
output := runGit(t, dir, "status", "--porcelain", ".beads/")
|
||||
if !strings.Contains(output, "A .beads/deletions.jsonl") {
|
||||
t.Logf("git status output: %s", output)
|
||||
t.Error("expected file to be staged")
|
||||
}
|
||||
|
||||
// UntrackedJSONL should not process staged files (only untracked)
|
||||
err := UntrackedJSONL(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
// File should still be staged, not committed again
|
||||
output = runGit(t, dir, "status", "--porcelain", ".beads/")
|
||||
if !strings.Contains(output, "A .beads/deletions.jsonl") {
|
||||
t.Error("file should still be staged after UntrackedJSONL")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed tracked and untracked JSONL files", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
|
||||
// Create initial commit with one JSONL file
|
||||
trackedFile := filepath.Join(dir, ".beads", "issues.jsonl")
|
||||
if err := os.WriteFile(trackedFile, []byte(`{"id":"test-1"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("failed to create tracked JSONL: %v", err)
|
||||
}
|
||||
runGit(t, dir, "add", ".beads/issues.jsonl")
|
||||
runGit(t, dir, "commit", "-m", "initial")
|
||||
|
||||
// Create an untracked JSONL file
|
||||
untrackedFile := filepath.Join(dir, ".beads", "deletions.jsonl")
|
||||
if err := os.WriteFile(untrackedFile, []byte(`{"id":"test-2"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("failed to create untracked JSONL: %v", err)
|
||||
}
|
||||
|
||||
// UntrackedJSONL should only process the untracked file
|
||||
err := UntrackedJSONL(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify untracked file was committed
|
||||
output := runGit(t, dir, "status", "--porcelain", ".beads/")
|
||||
if output != "" {
|
||||
t.Errorf("expected clean status, got: %s", output)
|
||||
}
|
||||
|
||||
// Verify both files are now tracked
|
||||
output = runGit(t, dir, "ls-files", ".beads/")
|
||||
if !strings.Contains(output, "issues.jsonl") || !strings.Contains(output, "deletions.jsonl") {
|
||||
t.Errorf("expected both files to be tracked, got: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSONL file outside .beads directory is ignored", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
|
||||
// Create initial commit
|
||||
testFile := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
runGit(t, dir, "add", "test.txt")
|
||||
runGit(t, dir, "commit", "-m", "initial")
|
||||
|
||||
// Create a JSONL file outside .beads
|
||||
outsideFile := filepath.Join(dir, "data.jsonl")
|
||||
if err := os.WriteFile(outsideFile, []byte(`{"test":"data"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("failed to create outside JSONL: %v", err)
|
||||
}
|
||||
|
||||
// UntrackedJSONL should ignore it
|
||||
err := UntrackedJSONL(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file is still untracked
|
||||
output := runGit(t, dir, "status", "--porcelain")
|
||||
if !strings.Contains(output, "?? data.jsonl") {
|
||||
t.Error("expected file outside .beads to remain untracked")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMigrateTombstones_EdgeCases tests MigrateTombstones with edge cases
|
||||
func TestMigrateTombstones_EdgeCases(t *testing.T) {
|
||||
t.Run("malformed deletions.jsonl with corrupt JSON", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
|
||||
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
||||
|
||||
// Create deletions.jsonl with mix of valid and malformed JSON
|
||||
content := `{"id":"valid-1","ts":"2024-01-01T00:00:00Z","by":"user1"}
|
||||
{corrupt json line without proper structure
|
||||
{"id":"valid-2","ts":"2024-01-02T00:00:00Z","by":"user2","reason":"cleanup"}
|
||||
{"incomplete":"object"
|
||||
{"id":"valid-3","ts":"2024-01-03T00:00:00Z","by":"user3"}
|
||||
`
|
||||
if err := os.WriteFile(deletionsPath, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to create deletions.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Create empty issues.jsonl
|
||||
if err := os.WriteFile(jsonlPath, []byte(""), 0600); err != nil {
|
||||
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Should succeed and migrate only valid records
|
||||
err := MigrateTombstones(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("expected MigrateTombstones to handle malformed JSON, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify only valid records were migrated
|
||||
resultBytes, err := os.ReadFile(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(resultBytes)), "\n")
|
||||
validCount := 0
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &issue); err == nil && issue.Status == "tombstone" {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
if validCount != 3 {
|
||||
t.Errorf("expected 3 valid tombstones, got %d", validCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deletions without ID field are skipped", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
|
||||
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
||||
|
||||
// Create deletions.jsonl with records missing ID
|
||||
content := `{"id":"valid-1","ts":"2024-01-01T00:00:00Z","by":"user"}
|
||||
{"ts":"2024-01-02T00:00:00Z","by":"user2"}
|
||||
{"id":"","ts":"2024-01-03T00:00:00Z","by":"user3"}
|
||||
{"id":"valid-2","ts":"2024-01-04T00:00:00Z","by":"user4"}
|
||||
`
|
||||
if err := os.WriteFile(deletionsPath, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to create deletions.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Create empty issues.jsonl
|
||||
if err := os.WriteFile(jsonlPath, []byte(""), 0600); err != nil {
|
||||
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
err := MigrateTombstones(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify only records with valid IDs were migrated
|
||||
resultBytes, err := os.ReadFile(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(resultBytes)), "\n")
|
||||
validCount := 0
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &issue); err == nil && issue.ID != "" {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
if validCount != 2 {
|
||||
t.Errorf("expected 2 valid tombstones, got %d", validCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles missing issues.jsonl", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
|
||||
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
||||
|
||||
// Create deletions.jsonl
|
||||
deletion := legacyDeletionRecord{
|
||||
ID: "test-123",
|
||||
Timestamp: time.Now(),
|
||||
Actor: "testuser",
|
||||
}
|
||||
data, _ := json.Marshal(deletion)
|
||||
if err := os.WriteFile(deletionsPath, append(data, '\n'), 0600); err != nil {
|
||||
t.Fatalf("failed to create deletions.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Don't create issues.jsonl - it should be created by MigrateTombstones
|
||||
|
||||
err := MigrateTombstones(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify issues.jsonl was created
|
||||
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||
t.Error("expected issues.jsonl to be created")
|
||||
}
|
||||
|
||||
// Verify tombstone was written
|
||||
content, err := os.ReadFile(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read issues.jsonl: %v", err)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
t.Error("expected tombstone to be written")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPermissions_EdgeCases tests Permissions with edge cases
|
||||
func TestPermissions_EdgeCases(t *testing.T) {
|
||||
t.Run("symbolic link to .beads directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create actual .beads directory elsewhere
|
||||
actualBeadsDir := filepath.Join(t.TempDir(), "actual-beads")
|
||||
if err := os.MkdirAll(actualBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create actual .beads: %v", err)
|
||||
}
|
||||
|
||||
// Create symlink to it
|
||||
symlinkPath := filepath.Join(dir, ".beads")
|
||||
if err := os.Symlink(actualBeadsDir, symlinkPath); err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
// Permissions should skip symlinked directories
|
||||
err := Permissions(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for symlinked .beads, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify target directory permissions were not changed
|
||||
info, err := os.Stat(actualBeadsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat actual .beads: %v", err)
|
||||
}
|
||||
|
||||
// Should still have 0755, not 0700
|
||||
if info.Mode().Perm() == 0700 {
|
||||
t.Error("symlinked directory permissions should not be changed to 0700")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symbolic link to database file", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
// Create actual database file elsewhere
|
||||
actualDbPath := filepath.Join(t.TempDir(), "actual-beads.db")
|
||||
if err := os.WriteFile(actualDbPath, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("failed to create actual db: %v", err)
|
||||
}
|
||||
|
||||
// Create symlink to it
|
||||
dbSymlinkPath := filepath.Join(dir, ".beads", "beads.db")
|
||||
if err := os.Symlink(actualDbPath, dbSymlinkPath); err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
// Permissions should skip symlinked files
|
||||
err := Permissions(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for symlinked db, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify target file permissions were not changed
|
||||
info, err := os.Stat(actualDbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat actual db: %v", err)
|
||||
}
|
||||
|
||||
// Should still have 0644, not 0600
|
||||
if info.Mode().Perm() == 0600 {
|
||||
t.Error("symlinked database permissions should not be changed to 0600")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixes incorrect .beads directory permissions", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
|
||||
// Set incorrect permissions (too permissive)
|
||||
if err := os.Chmod(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to set permissions: %v", err)
|
||||
}
|
||||
|
||||
err := Permissions(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify permissions were fixed to 0700
|
||||
info, err := os.Stat(beadsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat .beads: %v", err)
|
||||
}
|
||||
|
||||
if info.Mode().Perm() != 0700 {
|
||||
t.Errorf("expected permissions 0700, got %o", info.Mode().Perm())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixes incorrect database file permissions", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
||||
if err := os.WriteFile(dbPath, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("failed to create db: %v", err)
|
||||
}
|
||||
|
||||
err := Permissions(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify permissions were fixed to 0600
|
||||
info, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat db: %v", err)
|
||||
}
|
||||
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected permissions 0600, got %o", info.Mode().Perm())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles missing database file gracefully", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
// No database file exists
|
||||
err := Permissions(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error when database doesn't exist, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
630
cmd/bd/doctor/fix/fix_test.go
Normal file
630
cmd/bd/doctor/fix/fix_test.go
Normal file
@@ -0,0 +1,630 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// setupTestWorkspace creates a temporary directory with a .beads directory
|
||||
func setupTestWorkspace(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads directory: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// setupTestGitRepo creates a temporary git repository with a .beads directory
|
||||
func setupTestGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
// Initialize git repo
|
||||
cmd := exec.Command("git", "init")
|
||||
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
|
||||
}
|
||||
|
||||
// runGit runs a git command and returns output
|
||||
func runGit(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Logf("git %v: %s", args, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
// TestValidateBeadsWorkspace tests the workspace validation function
|
||||
func TestValidateBeadsWorkspace(t *testing.T) {
|
||||
t.Run("invalid path", func(t *testing.T) {
|
||||
err := validateBeadsWorkspace("/nonexistent/path/that/does/not/exist")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent path")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGitHooks_Validation tests GitHooks validation
|
||||
func TestGitHooks_Validation(t *testing.T) {
|
||||
t.Run("not a git repository", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
err := GitHooks(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-git repository")
|
||||
}
|
||||
if err.Error() != "not a git repository" {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMergeDriver_Validation tests MergeDriver validation
|
||||
func TestMergeDriver_Validation(t *testing.T) {
|
||||
t.Run("sets correct merge driver config", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
|
||||
err := MergeDriver(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the config was set
|
||||
cmd := exec.Command("git", "config", "merge.beads.driver")
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get git config: %v", err)
|
||||
}
|
||||
|
||||
expected := "bd merge %A %O %A %B\n"
|
||||
if string(output) != expected {
|
||||
t.Errorf("expected %q, got %q", expected, string(output))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDaemon_Validation tests Daemon validation
|
||||
func TestDaemon_Validation(t *testing.T) {
|
||||
t.Run("no socket - nothing to do", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
err := Daemon(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error when no socket exists, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDBJSONLSync_Validation tests DBJSONLSync validation
|
||||
func TestDBJSONLSync_Validation(t *testing.T) {
|
||||
t.Run("no database - nothing to do", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
err := DBJSONLSync(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error when no database exists, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no JSONL - nothing to do", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
// Create a database file
|
||||
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
||||
if err := os.WriteFile(dbPath, []byte("test"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test db: %v", err)
|
||||
}
|
||||
err := DBJSONLSync(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error when no JSONL exists, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSyncBranchConfig_Validation tests SyncBranchConfig validation
|
||||
func TestSyncBranchConfig_Validation(t *testing.T) {
|
||||
t.Run("not a git repository", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
err := SyncBranchConfig(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-git repository")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSyncBranchHealth_Validation tests SyncBranchHealth validation
|
||||
func TestSyncBranchHealth_Validation(t *testing.T) {
|
||||
t.Run("no main or master branch", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
// Create a commit on a different branch
|
||||
cmd := exec.Command("git", "checkout", "-b", "other")
|
||||
cmd.Dir = dir
|
||||
_ = cmd.Run()
|
||||
|
||||
// Create a file and commit
|
||||
testFile := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
cmd = exec.Command("git", "add", "test.txt")
|
||||
cmd.Dir = dir
|
||||
_ = cmd.Run()
|
||||
cmd = exec.Command("git", "commit", "-m", "initial")
|
||||
cmd.Dir = dir
|
||||
_ = cmd.Run()
|
||||
|
||||
err := SyncBranchHealth(dir, "beads-sync")
|
||||
if err == nil {
|
||||
t.Error("expected error when neither main nor master exists")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestUntrackedJSONL_Validation tests UntrackedJSONL validation
|
||||
func TestUntrackedJSONL_Validation(t *testing.T) {
|
||||
t.Run("not a git repository", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
err := UntrackedJSONL(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-git repository")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no untracked files", func(t *testing.T) {
|
||||
dir := setupTestGitRepo(t)
|
||||
err := UntrackedJSONL(dir)
|
||||
// Should succeed with no untracked files
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMigrateTombstones tests the MigrateTombstones function
|
||||
func TestMigrateTombstones(t *testing.T) {
|
||||
t.Run("no deletions.jsonl - nothing to migrate", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
err := MigrateTombstones(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error when no deletions.jsonl exists, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty deletions.jsonl", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
|
||||
if err := os.WriteFile(deletionsPath, []byte(""), 0600); err != nil {
|
||||
t.Fatalf("failed to create deletions.jsonl: %v", err)
|
||||
}
|
||||
err := MigrateTombstones(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty deletions.jsonl, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migrates deletions to tombstones", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
// Create deletions.jsonl with a deletion record
|
||||
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
|
||||
deletion := legacyDeletionRecord{
|
||||
ID: "test-123",
|
||||
Timestamp: time.Now(),
|
||||
Actor: "testuser",
|
||||
Reason: "test deletion",
|
||||
}
|
||||
data, _ := json.Marshal(deletion)
|
||||
if err := os.WriteFile(deletionsPath, append(data, '\n'), 0600); err != nil {
|
||||
t.Fatalf("failed to create deletions.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Create empty issues.jsonl
|
||||
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
||||
if err := os.WriteFile(jsonlPath, []byte(""), 0600); err != nil {
|
||||
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
err := MigrateTombstones(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify deletions.jsonl was renamed
|
||||
if _, err := os.Stat(deletionsPath); !os.IsNotExist(err) {
|
||||
t.Error("deletions.jsonl should have been renamed")
|
||||
}
|
||||
migratedPath := deletionsPath + ".migrated"
|
||||
if _, err := os.Stat(migratedPath); os.IsNotExist(err) {
|
||||
t.Error("deletions.jsonl.migrated should exist")
|
||||
}
|
||||
|
||||
// Verify tombstone was written to issues.jsonl
|
||||
content, err := os.ReadFile(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read issues.jsonl: %v", err)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
t.Error("expected tombstone to be written to issues.jsonl")
|
||||
}
|
||||
|
||||
// Verify the tombstone content
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(content[:len(content)-1], &issue); err != nil {
|
||||
t.Fatalf("failed to parse tombstone: %v", err)
|
||||
}
|
||||
if issue.ID != "test-123" {
|
||||
t.Errorf("expected ID test-123, got %s", issue.ID)
|
||||
}
|
||||
if issue.Status != "tombstone" {
|
||||
t.Errorf("expected status tombstone, got %s", issue.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips already existing tombstones", func(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
|
||||
// Create deletions.jsonl with a deletion record
|
||||
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
|
||||
deletion := legacyDeletionRecord{
|
||||
ID: "test-123",
|
||||
Timestamp: time.Now(),
|
||||
Actor: "testuser",
|
||||
}
|
||||
data, _ := json.Marshal(deletion)
|
||||
if err := os.WriteFile(deletionsPath, append(data, '\n'), 0600); err != nil {
|
||||
t.Fatalf("failed to create deletions.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Create issues.jsonl with an existing tombstone for the same ID
|
||||
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
||||
existingTombstone := map[string]interface{}{
|
||||
"id": "test-123",
|
||||
"status": "tombstone",
|
||||
}
|
||||
existingData, _ := json.Marshal(existingTombstone)
|
||||
if err := os.WriteFile(jsonlPath, append(existingData, '\n'), 0600); err != nil {
|
||||
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
originalContent, _ := os.ReadFile(jsonlPath)
|
||||
|
||||
err := MigrateTombstones(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify issues.jsonl was not modified (tombstone already exists)
|
||||
newContent, _ := os.ReadFile(jsonlPath)
|
||||
if string(newContent) != string(originalContent) {
|
||||
t.Error("issues.jsonl should not have been modified when tombstone already exists")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadLegacyDeletions tests the loadLegacyDeletions helper
|
||||
func TestLoadLegacyDeletions(t *testing.T) {
|
||||
t.Run("nonexistent file returns empty map", func(t *testing.T) {
|
||||
records, err := loadLegacyDeletions("/nonexistent/path")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
if len(records) != 0 {
|
||||
t.Errorf("expected empty map, got %d records", len(records))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parses valid deletions", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "deletions.jsonl")
|
||||
|
||||
deletion := legacyDeletionRecord{
|
||||
ID: "test-abc",
|
||||
Timestamp: time.Now(),
|
||||
Actor: "user",
|
||||
Reason: "testing",
|
||||
}
|
||||
data, _ := json.Marshal(deletion)
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0600); err != nil {
|
||||
t.Fatalf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
records, err := loadLegacyDeletions(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(records))
|
||||
}
|
||||
if records["test-abc"].Actor != "user" {
|
||||
t.Errorf("expected actor 'user', got %s", records["test-abc"].Actor)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips invalid lines", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "deletions.jsonl")
|
||||
|
||||
content := `{"id":"valid-1","ts":"2024-01-01T00:00:00Z","by":"user"}
|
||||
invalid json line
|
||||
{"id":"valid-2","ts":"2024-01-01T00:00:00Z","by":"user"}
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
records, err := loadLegacyDeletions(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("expected 2 valid records, got %d", len(records))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips records without ID", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "deletions.jsonl")
|
||||
|
||||
content := `{"id":"valid-1","ts":"2024-01-01T00:00:00Z","by":"user"}
|
||||
{"ts":"2024-01-01T00:00:00Z","by":"user"}
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
records, err := loadLegacyDeletions(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 valid record, got %d", len(records))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConvertLegacyDeletionToTombstone tests tombstone conversion
|
||||
func TestConvertLegacyDeletionToTombstone(t *testing.T) {
|
||||
t.Run("converts with all fields", func(t *testing.T) {
|
||||
ts := time.Now()
|
||||
record := legacyDeletionRecord{
|
||||
ID: "test-xyz",
|
||||
Timestamp: ts,
|
||||
Actor: "admin",
|
||||
Reason: "cleanup",
|
||||
}
|
||||
|
||||
tombstone := convertLegacyDeletionToTombstone(record)
|
||||
|
||||
if tombstone.ID != "test-xyz" {
|
||||
t.Errorf("expected ID test-xyz, got %s", tombstone.ID)
|
||||
}
|
||||
if tombstone.Status != "tombstone" {
|
||||
t.Errorf("expected status tombstone, got %s", tombstone.Status)
|
||||
}
|
||||
if tombstone.DeletedBy != "admin" {
|
||||
t.Errorf("expected DeletedBy admin, got %s", tombstone.DeletedBy)
|
||||
}
|
||||
if tombstone.DeleteReason != "cleanup" {
|
||||
t.Errorf("expected DeleteReason cleanup, got %s", tombstone.DeleteReason)
|
||||
}
|
||||
if tombstone.DeletedAt == nil {
|
||||
t.Error("expected DeletedAt to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles zero timestamp", func(t *testing.T) {
|
||||
record := legacyDeletionRecord{
|
||||
ID: "test-zero",
|
||||
Actor: "user",
|
||||
}
|
||||
|
||||
tombstone := convertLegacyDeletionToTombstone(record)
|
||||
|
||||
if tombstone.DeletedAt == nil {
|
||||
t.Error("expected DeletedAt to be set even with zero timestamp")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFindJSONLPath tests the findJSONLPath helper
|
||||
func TestFindJSONLPath(t *testing.T) {
|
||||
t.Run("returns empty for no JSONL", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := findJSONLPath(dir)
|
||||
if path != "" {
|
||||
t.Errorf("expected empty path, got %s", path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("finds issues.jsonl", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jsonlPath := filepath.Join(dir, "issues.jsonl")
|
||||
if err := os.WriteFile(jsonlPath, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("failed to create file: %v", err)
|
||||
}
|
||||
|
||||
path := findJSONLPath(dir)
|
||||
if path != jsonlPath {
|
||||
t.Errorf("expected %s, got %s", jsonlPath, path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("finds beads.jsonl as fallback", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jsonlPath := filepath.Join(dir, "beads.jsonl")
|
||||
if err := os.WriteFile(jsonlPath, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("failed to create file: %v", err)
|
||||
}
|
||||
|
||||
path := findJSONLPath(dir)
|
||||
if path != jsonlPath {
|
||||
t.Errorf("expected %s, got %s", jsonlPath, path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers issues.jsonl over beads.jsonl", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
issuesPath := filepath.Join(dir, "issues.jsonl")
|
||||
beadsPath := filepath.Join(dir, "beads.jsonl")
|
||||
if err := os.WriteFile(issuesPath, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(beadsPath, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("failed to create beads.jsonl: %v", err)
|
||||
}
|
||||
|
||||
path := findJSONLPath(dir)
|
||||
if path != issuesPath {
|
||||
t.Errorf("expected %s, got %s", issuesPath, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsWithinWorkspace tests the isWithinWorkspace helper
|
||||
func TestIsWithinWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
candidate string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same directory",
|
||||
candidate: root,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subdirectory",
|
||||
candidate: filepath.Join(root, "subdir"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "nested subdirectory",
|
||||
candidate: filepath.Join(root, "sub", "dir", "nested"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "parent directory",
|
||||
candidate: filepath.Dir(root),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "sibling directory",
|
||||
candidate: filepath.Join(filepath.Dir(root), "sibling"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isWithinWorkspace(root, tt.candidate)
|
||||
if got != tt.want {
|
||||
t.Errorf("isWithinWorkspace(%q, %q) = %v, want %v", root, tt.candidate, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestDBJSONLSync_MissingDatabase tests DBJSONLSync when database doesn't exist
|
||||
func TestDBJSONLSync_MissingDatabase(t *testing.T) {
|
||||
dir := setupTestWorkspace(t)
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
|
||||
// Create only JSONL file
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
issue := map[string]interface{}{
|
||||
"id": "test-no-db",
|
||||
"title": "No DB Test",
|
||||
"status": "open",
|
||||
}
|
||||
data, _ := json.Marshal(issue)
|
||||
if err := os.WriteFile(jsonlPath, append(data, '\n'), 0600); err != nil {
|
||||
t.Fatalf("failed to create jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Should return without error since there's nothing to sync
|
||||
err := DBJSONLSync(dir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error when database doesn't exist, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestSyncBranchConfig_BranchDoesNotExist tests fixing config when branch doesn't exist
|
||||
func TestSyncBranchConfig_BranchDoesNotExist(t *testing.T) {
|
||||
// Skip if running as test binary (can't execute bd subcommands)
|
||||
skipIfTestBinary(t)
|
||||
|
||||
dir := setupTestGitRepo(t)
|
||||
|
||||
// Try to run fix without any commits (no branch exists yet)
|
||||
err := SyncBranchConfig(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error when no branch exists")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "failed to get current branch") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchConfig_InvalidRemoteURL tests fix behavior with invalid remote
|
||||
func TestSyncBranchConfig_InvalidRemoteURL(t *testing.T) {
|
||||
// Skip if running as test binary (can't execute bd subcommands)
|
||||
skipIfTestBinary(t)
|
||||
|
||||
dir := setupTestGitRepo(t)
|
||||
|
||||
// Create initial commit
|
||||
testFile := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
runGit(t, dir, "add", "test.txt")
|
||||
runGit(t, dir, "commit", "-m", "initial commit")
|
||||
|
||||
// Add invalid remote
|
||||
runGit(t, dir, "remote", "add", "origin", "invalid://bad-url")
|
||||
|
||||
// Fix should still succeed - it only sets config, doesn't interact with remote
|
||||
err := SyncBranchConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with invalid remote: %v", err)
|
||||
}
|
||||
|
||||
// Verify config was set
|
||||
cmd := exec.Command("git", "config", "sync.branch")
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get sync.branch config: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(output)) == "" {
|
||||
t.Error("sync.branch config was not set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +48,9 @@ func Permissions(path string) error {
|
||||
// Ensure database has exactly 0600 permissions (owner rw only)
|
||||
expectedFileMode := os.FileMode(0600)
|
||||
currentPerms := dbInfo.Mode().Perm()
|
||||
requiredPerms := os.FileMode(0600)
|
||||
|
||||
// Check if we have both read and write for owner
|
||||
if currentPerms&requiredPerms != requiredPerms {
|
||||
// Check if permissions are not exactly 0600
|
||||
if currentPerms != expectedFileMode {
|
||||
if err := os.Chmod(dbPath, expectedFileMode); err != nil {
|
||||
return fmt.Errorf("failed to fix database permissions: %w", err)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ func UntrackedJSONL(path string) error {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Find untracked JSONL files
|
||||
cmd := exec.Command("git", "status", "--porcelain", ".beads/")
|
||||
// Use --untracked-files=all to show individual files, not just the directory
|
||||
cmd := exec.Command("git", "status", "--porcelain", "--untracked-files=all", ".beads/")
|
||||
cmd.Dir = path
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -67,9 +68,11 @@ func UntrackedJSONL(path string) error {
|
||||
fmt.Printf(" Staged %s\n", filepath.Base(file))
|
||||
}
|
||||
|
||||
// Commit the staged files
|
||||
// Commit only the JSONL files we staged (using --only to preserve other staged changes)
|
||||
commitMsg := "chore(beads): commit untracked JSONL files\n\nAuto-committed by bd doctor --fix (bd-pbj)"
|
||||
commitCmd := exec.Command("git", "commit", "-m", commitMsg)
|
||||
commitArgs := []string{"commit", "--only", "-m", commitMsg}
|
||||
commitArgs = append(commitArgs, untrackedFiles...)
|
||||
commitCmd := exec.Command("git", commitArgs...) // #nosec G204 -- untrackedFiles validated above
|
||||
commitCmd.Dir = path
|
||||
commitCmd.Stdout = os.Stdout
|
||||
commitCmd.Stderr = os.Stderr
|
||||
|
||||
Reference in New Issue
Block a user