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:
Serhii
2025-11-30 22:35:18 +02:00
committed by GitHub
parent 7128524db9
commit 95fe36fb53
3 changed files with 175 additions and 2 deletions

View File

@@ -124,12 +124,18 @@ func TestTrackBdVersion_NoBeadsDir(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()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
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
origWd, _ := os.Getwd()

View File

@@ -230,9 +230,45 @@ func FindDatabasePath() string {
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
// 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).
// 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.
func FindBeadsDir() string {
// 1. Check BEADS_DIR environment variable (preferred)
@@ -255,7 +291,10 @@ func FindBeadsDir() string {
for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) {
beadsDir := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
return beadsDir
// Validate directory contains actual project files (bd-420)
if hasBeadsProjectFiles(beadsDir) {
return beadsDir
}
}
// Stop at git root to avoid finding unrelated directories (bd-c8x)

View File

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