fix: validate project files in FindBeadsDir (bd-420) (#424)
FindBeadsDir() now checks for actual beads project files before returning a .beads directory. This prevents false positives when ~/.beads/ exists only for daemon registry (registry.json). Changes: - Add hasBeadsProjectFiles() helper that checks for: - metadata.json or config.yaml (project config) - *.db files (excluding backups and vc.db) - *.jsonl files (JSONL-only mode) - Update FindBeadsDir() to validate directories during tree search - Add comprehensive tests for project file detection - Update version_tracking_test.go to create project files Fixes #420 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -124,12 +124,18 @@ func TestTrackBdVersion_NoBeadsDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackBdVersion_FirstRun(t *testing.T) {
|
func TestTrackBdVersion_FirstRun(t *testing.T) {
|
||||||
// Create temp .beads directory
|
// Create temp .beads directory with a project file (bd-420)
|
||||||
|
// FindBeadsDir now requires actual project files, not just directory existence
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
t.Fatalf("Failed to create .beads: %v", err)
|
t.Fatalf("Failed to create .beads: %v", err)
|
||||||
}
|
}
|
||||||
|
// Create a database file so FindBeadsDir finds this directory
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create db file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Change to temp directory
|
// Change to temp directory
|
||||||
origWd, _ := os.Getwd()
|
origWd, _ := os.Getwd()
|
||||||
|
|||||||
@@ -230,9 +230,45 @@ func FindDatabasePath() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasBeadsProjectFiles checks if a .beads directory contains actual project files.
|
||||||
|
// Returns true if the directory contains any of:
|
||||||
|
// - metadata.json or config.yaml (project configuration)
|
||||||
|
// - Any *.db file (excluding backups and vc.db)
|
||||||
|
// - Any *.jsonl file (JSONL-only mode or git-tracked issues)
|
||||||
|
//
|
||||||
|
// Returns false for directories that only contain daemon registry files (bd-420).
|
||||||
|
// This prevents FindBeadsDir from returning ~/.beads/ which only has registry.json.
|
||||||
|
func hasBeadsProjectFiles(beadsDir string) bool {
|
||||||
|
// Check for project configuration files
|
||||||
|
if _, err := os.Stat(filepath.Join(beadsDir, "metadata.json")); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(beadsDir, "config.yaml")); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for database files (excluding backups and vc.db)
|
||||||
|
dbMatches, _ := filepath.Glob(filepath.Join(beadsDir, "*.db"))
|
||||||
|
for _, match := range dbMatches {
|
||||||
|
baseName := filepath.Base(match)
|
||||||
|
if !strings.Contains(baseName, ".backup") && baseName != "vc.db" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for JSONL files (JSONL-only mode or fresh clone)
|
||||||
|
jsonlMatches, _ := filepath.Glob(filepath.Join(beadsDir, "*.jsonl"))
|
||||||
|
if len(jsonlMatches) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// FindBeadsDir finds the .beads/ directory in the current directory tree
|
// FindBeadsDir finds the .beads/ directory in the current directory tree
|
||||||
// Returns empty string if not found. Supports both database and JSONL-only mode.
|
// Returns empty string if not found. Supports both database and JSONL-only mode.
|
||||||
// Stops at the git repository root to avoid finding unrelated directories (bd-c8x).
|
// Stops at the git repository root to avoid finding unrelated directories (bd-c8x).
|
||||||
|
// Validates that the directory contains actual project files (bd-420).
|
||||||
// This is useful for commands that need to detect beads projects without requiring a database.
|
// This is useful for commands that need to detect beads projects without requiring a database.
|
||||||
func FindBeadsDir() string {
|
func FindBeadsDir() string {
|
||||||
// 1. Check BEADS_DIR environment variable (preferred)
|
// 1. Check BEADS_DIR environment variable (preferred)
|
||||||
@@ -255,8 +291,11 @@ func FindBeadsDir() string {
|
|||||||
for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) {
|
for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) {
|
||||||
beadsDir := filepath.Join(dir, ".beads")
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
||||||
|
// Validate directory contains actual project files (bd-420)
|
||||||
|
if hasBeadsProjectFiles(beadsDir) {
|
||||||
return beadsDir
|
return beadsDir
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop at git root to avoid finding unrelated directories (bd-c8x)
|
// Stop at git root to avoid finding unrelated directories (bd-c8x)
|
||||||
if gitRoot != "" && dir == gitRoot {
|
if gitRoot != "" && dir == gitRoot {
|
||||||
|
|||||||
@@ -282,6 +282,134 @@ func TestFindJSONLPathSkipsDeletions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHasBeadsProjectFiles verifies that hasBeadsProjectFiles correctly
|
||||||
|
// distinguishes between project directories and daemon-only directories (bd-420)
|
||||||
|
func TestHasBeadsProjectFiles(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
files []string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty directory",
|
||||||
|
files: []string{},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "daemon registry only",
|
||||||
|
files: []string{"registry.json", "registry.lock"},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has database",
|
||||||
|
files: []string{"beads.db"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has issues.jsonl",
|
||||||
|
files: []string{"issues.jsonl"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has metadata.json",
|
||||||
|
files: []string{"metadata.json"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has config.yaml",
|
||||||
|
files: []string{"config.yaml"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ignores backup db",
|
||||||
|
files: []string{"beads.backup.db"},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ignores vc.db",
|
||||||
|
files: []string{"vc.db"},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "real db with backup",
|
||||||
|
files: []string{"beads.db", "beads.backup.db"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "beads-project-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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := hasBeadsProjectFiles(tmpDir)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("hasBeadsProjectFiles() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindBeadsDirSkipsDaemonRegistry verifies that FindBeadsDir skips
|
||||||
|
// directories containing only daemon registry files (bd-420)
|
||||||
|
func TestFindBeadsDirSkipsDaemonRegistry(t *testing.T) {
|
||||||
|
// Save original state
|
||||||
|
originalEnv := os.Getenv("BEADS_DIR")
|
||||||
|
originalWd, _ := os.Getwd()
|
||||||
|
defer func() {
|
||||||
|
if originalEnv != "" {
|
||||||
|
os.Setenv("BEADS_DIR", originalEnv)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BEADS_DIR")
|
||||||
|
}
|
||||||
|
os.Chdir(originalWd)
|
||||||
|
}()
|
||||||
|
os.Unsetenv("BEADS_DIR")
|
||||||
|
|
||||||
|
// Create temp directory structure
|
||||||
|
tmpDir, err := os.MkdirTemp("", "beads-daemon-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Create .beads with only daemon registry files (should be skipped)
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(beadsDir, "registry.json"), []byte("[]"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change to temp dir
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should NOT find the daemon-only directory
|
||||||
|
result := FindBeadsDir()
|
||||||
|
if result != "" {
|
||||||
|
// Resolve symlinks for comparison
|
||||||
|
resultResolved, _ := filepath.EvalSymlinks(result)
|
||||||
|
beadsDirResolved, _ := filepath.EvalSymlinks(beadsDir)
|
||||||
|
if resultResolved == beadsDirResolved {
|
||||||
|
t.Errorf("FindBeadsDir() should skip daemon-only directory, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindDatabasePathHomeDefault(t *testing.T) {
|
func TestFindDatabasePathHomeDefault(t *testing.T) {
|
||||||
// This test verifies that if no database is found, it falls back to home directory
|
// This test verifies that if no database is found, it falls back to home directory
|
||||||
// We can't reliably test this without modifying the home directory, so we'll skip
|
// We can't reliably test this without modifying the home directory, so we'll skip
|
||||||
|
|||||||
Reference in New Issue
Block a user