From ffa5de3181aa6a2102f2294a09a6ae3012c3eb38 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 13:36:11 -0800 Subject: [PATCH] Improve internal/beads test coverage from 48% to 80% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for fingerprint.go: - canonicalizeGitURL: URL normalization for HTTPS, SSH, SCP-style, HTTP, git protocol - ComputeRepoID: With/without remote, not-git-repo, canonical URL consistency - GetCloneID: Basic, different dirs, worktree, hostname inclusion Add tests for beads.go: - findDatabaseInBeadsDir: Direct tests for database discovery - FindAllDatabases: Multi-database discovery, no-database, stops-at-first Coverage: 48.4% → 80.2% (bd-tvu3) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads_test.go | 231 +++++++++++++ internal/beads/fingerprint_test.go | 507 +++++++++++++++++++++++++++++ 2 files changed, 738 insertions(+) create mode 100644 internal/beads/fingerprint_test.go diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index bcbd54d4..6a7df462 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -1427,6 +1427,237 @@ func TestIsWispDatabase(t *testing.T) { } } +// TestFindDatabaseInBeadsDir tests the database discovery within a .beads directory +func TestFindDatabaseInBeadsDir(t *testing.T) { + tests := []struct { + name string + files []string + configJSON string + expectDB string + warnOnIssues bool + }{ + { + name: "canonical beads.db only", + files: []string{"beads.db"}, + expectDB: "beads.db", + }, + { + name: "legacy bd.db only", + files: []string{"bd.db"}, + expectDB: "bd.db", + }, + { + name: "prefers beads.db over other db files", + files: []string{"custom.db", "beads.db", "other.db"}, + expectDB: "beads.db", + }, + { + name: "skips backup files", + files: []string{"beads.backup.db", "real.db"}, + expectDB: "real.db", + }, + { + name: "skips vc.db", + files: []string{"vc.db", "beads.db"}, + expectDB: "beads.db", + }, + { + name: "no db files returns empty", + files: []string{"readme.txt", "config.yaml"}, + expectDB: "", + }, + { + name: "only backup files returns empty", + files: []string{"beads.backup.db", "vc.db"}, + expectDB: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "beads-findindir-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create test files + for _, file := range tt.files { + path := filepath.Join(tmpDir, file) + if err := os.WriteFile(path, []byte{}, 0644); err != nil { + t.Fatal(err) + } + } + + // Write config.json if specified + if tt.configJSON != "" { + configPath := filepath.Join(tmpDir, "config.json") + if err := os.WriteFile(configPath, []byte(tt.configJSON), 0644); err != nil { + t.Fatal(err) + } + } + + result := findDatabaseInBeadsDir(tmpDir, tt.warnOnIssues) + + if tt.expectDB == "" { + if result != "" { + t.Errorf("findDatabaseInBeadsDir() = %q, want empty string", result) + } + } else { + expected := filepath.Join(tmpDir, tt.expectDB) + if result != expected { + t.Errorf("findDatabaseInBeadsDir() = %q, want %q", result, expected) + } + } + }) + } +} + +// TestFindAllDatabases tests the multi-database discovery +func TestFindAllDatabases(t *testing.T) { + // Save original state + originalEnv := os.Getenv("BEADS_DIR") + defer func() { + if originalEnv != "" { + os.Setenv("BEADS_DIR", originalEnv) + } else { + os.Unsetenv("BEADS_DIR") + } + }() + os.Unsetenv("BEADS_DIR") + + // Create temp directory structure + tmpDir, err := os.MkdirTemp("", "beads-findall-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create .beads directory with database + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + dbPath := filepath.Join(beadsDir, "beads.db") + if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Create subdirectory and change to it + subDir := filepath.Join(tmpDir, "sub", "nested") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + + t.Chdir(subDir) + + // FindAllDatabases should find the parent .beads + result := FindAllDatabases() + + if len(result) == 0 { + t.Error("FindAllDatabases() returned empty slice, expected at least one database") + } else { + // Verify the path matches + resultResolved, _ := filepath.EvalSymlinks(result[0].Path) + dbPathResolved, _ := filepath.EvalSymlinks(dbPath) + if resultResolved != dbPathResolved { + t.Errorf("FindAllDatabases()[0].Path = %q, want %q", result[0].Path, dbPath) + } + } +} + +// TestFindAllDatabases_NoDatabase tests FindAllDatabases when no database exists +func TestFindAllDatabases_NoDatabase(t *testing.T) { + // Save original state + originalEnv := os.Getenv("BEADS_DIR") + defer func() { + if originalEnv != "" { + os.Setenv("BEADS_DIR", originalEnv) + } else { + os.Unsetenv("BEADS_DIR") + } + }() + os.Unsetenv("BEADS_DIR") + + // Create temp directory without .beads + tmpDir, err := os.MkdirTemp("", "beads-findall-nodb-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + t.Chdir(tmpDir) + + // FindAllDatabases should return empty slice (not nil) + result := FindAllDatabases() + + if result == nil { + t.Error("FindAllDatabases() returned nil, expected empty slice") + } + if len(result) != 0 { + t.Errorf("FindAllDatabases() returned %d databases, expected 0", len(result)) + } +} + +// TestFindAllDatabases_StopsAtFirst tests that FindAllDatabases stops at first .beads found +func TestFindAllDatabases_StopsAtFirst(t *testing.T) { + // Save original state + originalEnv := os.Getenv("BEADS_DIR") + defer func() { + if originalEnv != "" { + os.Setenv("BEADS_DIR", originalEnv) + } else { + os.Unsetenv("BEADS_DIR") + } + }() + os.Unsetenv("BEADS_DIR") + + // Create temp directory structure with nested .beads dirs + tmpDir, err := os.MkdirTemp("", "beads-findall-nested-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create parent .beads + parentBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(parentBeadsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parentBeadsDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Create child project with its own .beads + childDir := filepath.Join(tmpDir, "child") + childBeadsDir := filepath.Join(childDir, ".beads") + if err := os.MkdirAll(childBeadsDir, 0755); err != nil { + t.Fatal(err) + } + childDBPath := filepath.Join(childBeadsDir, "beads.db") + if err := os.WriteFile(childDBPath, []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Change to child directory + t.Chdir(childDir) + + // FindAllDatabases should return only the child's database (stops at first) + result := FindAllDatabases() + + if len(result) != 1 { + t.Errorf("FindAllDatabases() returned %d databases, expected 1 (should stop at first)", len(result)) + } + + if len(result) > 0 { + resultResolved, _ := filepath.EvalSymlinks(result[0].Path) + childDBResolved, _ := filepath.EvalSymlinks(childDBPath) + if resultResolved != childDBResolved { + t.Errorf("FindAllDatabases() found %q, expected child database %q", result[0].Path, childDBPath) + } + } +} + // TestEnsureWispGitignore tests that EnsureWispGitignore correctly // adds the wisp directory to .gitignore func TestEnsureWispGitignore(t *testing.T) { diff --git a/internal/beads/fingerprint_test.go b/internal/beads/fingerprint_test.go new file mode 100644 index 00000000..807b0357 --- /dev/null +++ b/internal/beads/fingerprint_test.go @@ -0,0 +1,507 @@ +package beads + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestCanonicalizeGitURL tests URL normalization for various git URL formats +func TestCanonicalizeGitURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + // HTTPS URLs + { + name: "https basic", + input: "https://github.com/user/repo", + expected: "github.com/user/repo", + }, + { + name: "https with .git suffix", + input: "https://github.com/user/repo.git", + expected: "github.com/user/repo", + }, + { + name: "https with trailing slash", + input: "https://github.com/user/repo/", + expected: "github.com/user/repo", + }, + { + name: "https uppercase host", + input: "https://GitHub.COM/User/Repo.git", + expected: "github.com/User/Repo", + }, + { + name: "https with port 443", + input: "https://github.com:443/user/repo.git", + expected: "github.com/user/repo", + }, + { + name: "https with custom port", + input: "https://gitlab.company.com:8443/user/repo.git", + expected: "gitlab.company.com:8443/user/repo", + }, + + // SSH URLs (protocol style) + { + name: "ssh protocol basic", + input: "ssh://git@github.com/user/repo.git", + expected: "github.com/user/repo", + }, + { + name: "ssh with port 22", + input: "ssh://git@github.com:22/user/repo.git", + expected: "github.com/user/repo", + }, + { + name: "ssh with custom port", + input: "ssh://git@gitlab.company.com:2222/user/repo.git", + expected: "gitlab.company.com:2222/user/repo", + }, + + // SCP-style URLs (git@host:path) + { + name: "scp style basic", + input: "git@github.com:user/repo.git", + expected: "github.com/user/repo", + }, + { + name: "scp style without .git", + input: "git@github.com:user/repo", + expected: "github.com/user/repo", + }, + { + name: "scp style uppercase host", + input: "git@GITHUB.COM:User/Repo.git", + expected: "github.com/User/Repo", + }, + { + name: "scp style with trailing slash", + input: "git@github.com:user/repo/", + expected: "github.com/user/repo", + }, + { + name: "scp style deep path", + input: "git@gitlab.com:org/team/project/repo.git", + expected: "gitlab.com/org/team/project/repo", + }, + + // HTTP URLs (less common but valid) + { + name: "http basic", + input: "http://github.com/user/repo.git", + expected: "github.com/user/repo", + }, + { + name: "http with port 80", + input: "http://github.com:80/user/repo.git", + expected: "github.com/user/repo", + }, + + // Git protocol + { + name: "git protocol", + input: "git://github.com/user/repo.git", + expected: "github.com/user/repo", + }, + + // Whitespace handling + { + name: "with leading whitespace", + input: " https://github.com/user/repo.git", + expected: "github.com/user/repo", + }, + { + name: "with trailing whitespace", + input: "https://github.com/user/repo.git ", + expected: "github.com/user/repo", + }, + { + name: "with newline", + input: "https://github.com/user/repo.git\n", + expected: "github.com/user/repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := canonicalizeGitURL(tt.input) + if err != nil { + t.Fatalf("canonicalizeGitURL(%q) error = %v", tt.input, err) + } + if result != tt.expected { + t.Errorf("canonicalizeGitURL(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// TestCanonicalizeGitURL_LocalPath tests that local paths are handled +func TestCanonicalizeGitURL_LocalPath(t *testing.T) { + // Create a temp directory to use as a "local path" + tmpDir := t.TempDir() + + // Local absolute path + result, err := canonicalizeGitURL(tmpDir) + if err != nil { + t.Fatalf("canonicalizeGitURL(%q) error = %v", tmpDir, err) + } + + // Should return a forward-slash path + if strings.Contains(result, "\\") { + t.Errorf("canonicalizeGitURL(%q) = %q, should use forward slashes", tmpDir, result) + } +} + +// TestCanonicalizeGitURL_WindowsPath tests Windows path detection +func TestCanonicalizeGitURL_WindowsPath(t *testing.T) { + // This tests the Windows path detection logic (C:/) + // The function should NOT treat "C:/foo/bar" as an scp-style URL + tests := []struct { + input string + expected string + }{ + // These are NOT scp-style URLs - they're Windows paths + {"C:/Users/test/repo", "C:/Users/test/repo"}, + {"D:/projects/myrepo", "D:/projects/myrepo"}, + } + + for _, tt := range tests { + result, err := canonicalizeGitURL(tt.input) + if err != nil { + t.Fatalf("canonicalizeGitURL(%q) error = %v", tt.input, err) + } + // Should preserve the Windows path structure (forward slashes) + if !strings.Contains(result, "/") { + t.Errorf("canonicalizeGitURL(%q) = %q, expected path with slashes", tt.input, result) + } + } +} + +// TestComputeRepoID_WithRemote tests ComputeRepoID when remote.origin.url exists +func TestComputeRepoID_WithRemote(t *testing.T) { + // Create temporary directory for test repo + tmpDir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Configure git user + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = tmpDir + _ = cmd.Run() + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = tmpDir + _ = cmd.Run() + + // Set remote.origin.url + cmd = exec.Command("git", "remote", "add", "origin", "https://github.com/user/test-repo.git") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("git remote add failed: %v", err) + } + + // Change to repo dir + t.Chdir(tmpDir) + + // ComputeRepoID should return a consistent hash + result1, err := ComputeRepoID() + if err != nil { + t.Fatalf("ComputeRepoID() error = %v", err) + } + + // Should be a 32-character hex string (16 bytes) + if len(result1) != 32 { + t.Errorf("ComputeRepoID() = %q, expected 32 character hex string", result1) + } + + // Should be consistent across calls + result2, err := ComputeRepoID() + if err != nil { + t.Fatalf("ComputeRepoID() second call error = %v", err) + } + if result1 != result2 { + t.Errorf("ComputeRepoID() not consistent: %q vs %q", result1, result2) + } +} + +// TestComputeRepoID_NoRemote tests ComputeRepoID when no remote exists +func TestComputeRepoID_NoRemote(t *testing.T) { + // Create temporary directory for test repo + tmpDir := t.TempDir() + + // Initialize git repo (no remote) + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Change to repo dir + t.Chdir(tmpDir) + + // ComputeRepoID should fall back to using the local path + result, err := ComputeRepoID() + if err != nil { + t.Fatalf("ComputeRepoID() error = %v", err) + } + + // Should still return a 32-character hex string + if len(result) != 32 { + t.Errorf("ComputeRepoID() = %q, expected 32 character hex string", result) + } +} + +// TestComputeRepoID_NotGitRepo tests ComputeRepoID when not in a git repo +func TestComputeRepoID_NotGitRepo(t *testing.T) { + // Create temporary directory that is NOT a git repo + tmpDir := t.TempDir() + + t.Chdir(tmpDir) + + // ComputeRepoID should return an error + _, err := ComputeRepoID() + if err == nil { + t.Error("ComputeRepoID() expected error for non-git directory, got nil") + } + if !strings.Contains(err.Error(), "not a git repository") { + t.Errorf("ComputeRepoID() error = %q, expected 'not a git repository'", err.Error()) + } +} + +// TestComputeRepoID_DifferentRemotesSameCanonical tests that different URL formats +// for the same repo produce the same ID +func TestComputeRepoID_DifferentRemotesSameCanonical(t *testing.T) { + remotes := []string{ + "https://github.com/user/repo.git", + "git@github.com:user/repo.git", + "ssh://git@github.com/user/repo.git", + } + + var ids []string + + for _, remote := range remotes { + tmpDir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Set remote + cmd = exec.Command("git", "remote", "add", "origin", remote) + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("git remote add failed for %q: %v", remote, err) + } + + t.Chdir(tmpDir) + + id, err := ComputeRepoID() + if err != nil { + t.Fatalf("ComputeRepoID() for remote %q error = %v", remote, err) + } + ids = append(ids, id) + } + + // All IDs should be the same since they point to the same canonical repo + for i := 1; i < len(ids); i++ { + if ids[i] != ids[0] { + t.Errorf("ComputeRepoID() produced different IDs for same repo:\n remote[0]=%q id=%s\n remote[%d]=%q id=%s", + remotes[0], ids[0], i, remotes[i], ids[i]) + } + } +} + +// TestGetCloneID_Basic tests GetCloneID returns a consistent ID +func TestGetCloneID_Basic(t *testing.T) { + // Create temporary directory for test repo + tmpDir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + t.Chdir(tmpDir) + + // GetCloneID should return a consistent hash + result1, err := GetCloneID() + if err != nil { + t.Fatalf("GetCloneID() error = %v", err) + } + + // Should be a 16-character hex string (8 bytes) + if len(result1) != 16 { + t.Errorf("GetCloneID() = %q, expected 16 character hex string", result1) + } + + // Should be consistent across calls + result2, err := GetCloneID() + if err != nil { + t.Fatalf("GetCloneID() second call error = %v", err) + } + if result1 != result2 { + t.Errorf("GetCloneID() not consistent: %q vs %q", result1, result2) + } +} + +// TestGetCloneID_DifferentDirs tests GetCloneID produces different IDs for different clones +func TestGetCloneID_DifferentDirs(t *testing.T) { + ids := make(map[string]string) + + for i := 0; i < 3; i++ { + tmpDir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + t.Chdir(tmpDir) + + id, err := GetCloneID() + if err != nil { + t.Fatalf("GetCloneID() error = %v", err) + } + + // Each clone should have a unique ID + if prev, exists := ids[id]; exists { + t.Errorf("GetCloneID() produced duplicate ID %q for dirs %q and %q", id, prev, tmpDir) + } + ids[id] = tmpDir + } +} + +// TestGetCloneID_NotGitRepo tests GetCloneID when not in a git repo +func TestGetCloneID_NotGitRepo(t *testing.T) { + // Create temporary directory that is NOT a git repo + tmpDir := t.TempDir() + + t.Chdir(tmpDir) + + // GetCloneID should return an error + _, err := GetCloneID() + if err == nil { + t.Error("GetCloneID() expected error for non-git directory, got nil") + } + if !strings.Contains(err.Error(), "not a git repository") { + t.Errorf("GetCloneID() error = %q, expected 'not a git repository'", err.Error()) + } +} + +// TestGetCloneID_IncludesHostname tests that GetCloneID includes hostname +// to differentiate the same path on different machines +func TestGetCloneID_IncludesHostname(t *testing.T) { + // This test verifies the concept - we can't actually test different hostnames + // but we can verify that the same path produces the same ID on this machine + tmpDir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + t.Chdir(tmpDir) + + hostname, _ := os.Hostname() + id, err := GetCloneID() + if err != nil { + t.Fatalf("GetCloneID() error = %v", err) + } + + // Just verify we got a valid ID - we can't test different hostnames + // but the implementation includes hostname in the hash + if len(id) != 16 { + t.Errorf("GetCloneID() = %q, expected 16 character hex string (hostname=%s)", id, hostname) + } +} + +// TestGetCloneID_Worktree tests GetCloneID in a worktree +func TestGetCloneID_Worktree(t *testing.T) { + // Create temporary directory for test + tmpDir := t.TempDir() + + // Initialize main git repo + mainRepoDir := filepath.Join(tmpDir, "main-repo") + if err := os.MkdirAll(mainRepoDir, 0755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("git", "init") + cmd.Dir = mainRepoDir + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Configure git user + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = mainRepoDir + _ = cmd.Run() + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = mainRepoDir + _ = cmd.Run() + + // Create initial commit (required for worktree) + dummyFile := filepath.Join(mainRepoDir, "README.md") + if err := os.WriteFile(dummyFile, []byte("# Test\n"), 0644); err != nil { + t.Fatal(err) + } + cmd = exec.Command("git", "add", "README.md") + cmd.Dir = mainRepoDir + _ = cmd.Run() + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = mainRepoDir + if err := cmd.Run(); err != nil { + t.Fatalf("git commit failed: %v", err) + } + + // Create a worktree + worktreeDir := filepath.Join(tmpDir, "worktree") + cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD") + cmd.Dir = mainRepoDir + if err := cmd.Run(); err != nil { + t.Fatalf("git worktree add failed: %v", err) + } + defer func() { + cmd := exec.Command("git", "worktree", "remove", worktreeDir) + cmd.Dir = mainRepoDir + _ = cmd.Run() + }() + + // Get IDs from both locations + t.Chdir(mainRepoDir) + mainID, err := GetCloneID() + if err != nil { + t.Fatalf("GetCloneID() in main repo error = %v", err) + } + + t.Chdir(worktreeDir) + worktreeID, err := GetCloneID() + if err != nil { + t.Fatalf("GetCloneID() in worktree error = %v", err) + } + + // Worktree should have a DIFFERENT ID than main repo + // because they're different paths (different clones conceptually) + if mainID == worktreeID { + t.Errorf("GetCloneID() returned same ID for main repo and worktree - should be different") + } +}