From 95fe36fb530ac06b55d5b6e8fe1c42b8d891ab15 Mon Sep 17 00:00:00 2001 From: Serhii Date: Sun, 30 Nov 2025 22:35:18 +0200 Subject: [PATCH] fix: validate project files in FindBeadsDir (bd-420) (#424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/bd/version_tracking_test.go | 8 +- internal/beads/beads.go | 41 +++++++++- internal/beads/beads_test.go | 128 ++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/cmd/bd/version_tracking_test.go b/cmd/bd/version_tracking_test.go index 3f6f6e54..15f92da5 100644 --- a/cmd/bd/version_tracking_test.go +++ b/cmd/bd/version_tracking_test.go @@ -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() diff --git a/internal/beads/beads.go b/internal/beads/beads.go index c1e32747..1a64a9dd 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -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) diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index bd9a5fc3..ce47d6a2 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -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