Improve internal/beads test coverage from 48% to 80%

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 13:36:11 -08:00
parent 7c4b0690e2
commit ffa5de3181
2 changed files with 738 additions and 0 deletions

View File

@@ -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) {

View File

@@ -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")
}
}