diff --git a/internal/beads/beads.go b/internal/beads/beads.go index f4e411fc..01b7c0a4 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -621,3 +621,109 @@ func FindAllDatabases() []DatabaseInfo { return databases } + +// EphemeralDirName is the default name for the ephemeral storage directory. +// This directory is a sibling to .beads/ and should be gitignored. +const EphemeralDirName = ".beads-ephemeral" + +// FindEphemeralDir locates or determines the ephemeral storage directory. +// The ephemeral directory is a sibling to the .beads directory. +// +// Returns the path to the ephemeral directory (which may not exist yet). +// Returns empty string if no .beads directory can be found. +func FindEphemeralDir() string { + beadsDir := FindBeadsDir() + if beadsDir == "" { + return "" + } + + // Ephemeral dir is a sibling to .beads + // e.g., /project/.beads -> /project/.beads-ephemeral + projectRoot := filepath.Dir(beadsDir) + return filepath.Join(projectRoot, EphemeralDirName) +} + +// FindEphemeralDatabasePath returns the path to the ephemeral database file. +// Creates the ephemeral directory if it doesn't exist. +// Returns empty string if no .beads directory can be found. +func FindEphemeralDatabasePath() (string, error) { + ephemeralDir := FindEphemeralDir() + if ephemeralDir == "" { + return "", fmt.Errorf("no .beads directory found") + } + + // Create ephemeral directory if it doesn't exist + if err := os.MkdirAll(ephemeralDir, 0755); err != nil { + return "", fmt.Errorf("creating ephemeral directory: %w", err) + } + + return filepath.Join(ephemeralDir, CanonicalDatabaseName), nil +} + +// NewEphemeralStorage opens the ephemeral database for wisp storage. +// Creates the database and directory if they don't exist. +// The ephemeral database uses the same schema as the main database. +func NewEphemeralStorage(ctx context.Context) (Storage, error) { + dbPath, err := FindEphemeralDatabasePath() + if err != nil { + return nil, err + } + + return sqlite.New(ctx, dbPath) +} + +// EnsureEphemeralGitignore ensures the ephemeral directory is gitignored. +// This should be called after creating the ephemeral directory. +func EnsureEphemeralGitignore() error { + beadsDir := FindBeadsDir() + if beadsDir == "" { + return fmt.Errorf("no .beads directory found") + } + + projectRoot := filepath.Dir(beadsDir) + gitignorePath := filepath.Join(projectRoot, ".gitignore") + + // Check if .gitignore exists and already contains the ephemeral dir + content, err := os.ReadFile(gitignorePath) + if err == nil { + // File exists, check if already gitignored + lines := strings.Split(string(content), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == EphemeralDirName || line == EphemeralDirName+"/" { + return nil // Already gitignored + } + } + } + + // Append to .gitignore (or create it) + f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("opening .gitignore: %w", err) + } + defer f.Close() + + // Add newline before if file doesn't end with one + if len(content) > 0 && content[len(content)-1] != '\n' { + if _, err := f.WriteString("\n"); err != nil { + return fmt.Errorf("writing to .gitignore: %w", err) + } + } + + // Add the ephemeral directory + if _, err := f.WriteString(EphemeralDirName + "/\n"); err != nil { + return fmt.Errorf("writing to .gitignore: %w", err) + } + + return nil +} + +// IsEphemeralDatabase checks if a database path is an ephemeral database. +// Returns true if the database is in a .beads-ephemeral directory. +func IsEphemeralDatabase(dbPath string) bool { + if dbPath == "" { + return false + } + dir := filepath.Dir(dbPath) + return filepath.Base(dir) == EphemeralDirName +} diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index 140baab8..22b4d531 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -1252,4 +1253,288 @@ func TestFindDatabasePath_WorktreeNoLocalDB(t *testing.T) { if resultResolved != mainDBPathResolved { t.Errorf("FindDatabasePath() = %q, want main repo shared db %q", result, mainDBPath) } +} + +// TestFindEphemeralDir tests that FindEphemeralDir returns the correct path +func TestFindEphemeralDir(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") + } + }() + + // Create temporary directory with .beads + tmpDir, err := os.MkdirTemp("", "beads-ephemeral-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + // Add a project file so it's recognized + if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Set BEADS_DIR + os.Setenv("BEADS_DIR", beadsDir) + + // FindEphemeralDir should return sibling directory + result := FindEphemeralDir() + expected := filepath.Join(tmpDir, EphemeralDirName) + + // Resolve symlinks for comparison + resultResolved, _ := filepath.EvalSymlinks(result) + expectedResolved, _ := filepath.EvalSymlinks(expected) + + if resultResolved != expectedResolved { + t.Errorf("FindEphemeralDir() = %q, want %q", result, expected) + } +} + +// TestFindEphemeralDir_NoBeadsDir tests that FindEphemeralDir returns empty string +// when no .beads directory exists +func TestFindEphemeralDir_NoBeadsDir(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 temporary directory without .beads + tmpDir, err := os.MkdirTemp("", "beads-no-ephemeral-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + t.Chdir(tmpDir) + + // FindEphemeralDir should return empty string + result := FindEphemeralDir() + if result != "" { + t.Errorf("FindEphemeralDir() = %q, want empty string", result) + } +} + +// TestFindEphemeralDatabasePath tests that FindEphemeralDatabasePath creates +// the ephemeral directory and returns the correct database path +func TestFindEphemeralDatabasePath(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") + } + }() + + // Create temporary directory with .beads + tmpDir, err := os.MkdirTemp("", "beads-ephdb-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + os.Setenv("BEADS_DIR", beadsDir) + + // FindEphemeralDatabasePath should create directory and return path + result, err := FindEphemeralDatabasePath() + if err != nil { + t.Fatalf("FindEphemeralDatabasePath() error = %v", err) + } + + expected := filepath.Join(tmpDir, EphemeralDirName, CanonicalDatabaseName) + + // Resolve symlinks for comparison + resultResolved, _ := filepath.EvalSymlinks(result) + expectedResolved, _ := filepath.EvalSymlinks(expected) + + if resultResolved != expectedResolved { + t.Errorf("FindEphemeralDatabasePath() = %q, want %q", result, expected) + } + + // Verify the directory was created + ephemeralDir := filepath.Join(tmpDir, EphemeralDirName) + if _, err := os.Stat(ephemeralDir); os.IsNotExist(err) { + t.Errorf("Ephemeral directory was not created: %q", ephemeralDir) + } +} + +// TestIsEphemeralDatabase tests that IsEphemeralDatabase correctly identifies +// ephemeral database paths +func TestIsEphemeralDatabase(t *testing.T) { + tests := []struct { + name string + dbPath string + expected bool + }{ + { + name: "empty path", + dbPath: "", + expected: false, + }, + { + name: "regular database", + dbPath: "/project/.beads/beads.db", + expected: false, + }, + { + name: "ephemeral database", + dbPath: "/project/.beads-ephemeral/beads.db", + expected: true, + }, + { + name: "nested ephemeral", + dbPath: "/some/deep/path/.beads-ephemeral/beads.db", + expected: true, + }, + { + name: "similar but not ephemeral", + dbPath: "/project/.beads-ephemeral-backup/beads.db", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsEphemeralDatabase(tt.dbPath) + if result != tt.expected { + t.Errorf("IsEphemeralDatabase(%q) = %v, want %v", tt.dbPath, result, tt.expected) + } + }) + } +} + +// TestEnsureEphemeralGitignore tests that EnsureEphemeralGitignore correctly +// adds the ephemeral directory to .gitignore +func TestEnsureEphemeralGitignore(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") + } + }() + + tests := []struct { + name string + existingContent string + expectAppend bool + }{ + { + name: "no existing gitignore", + existingContent: "", + expectAppend: true, + }, + { + name: "already gitignored", + existingContent: ".beads-ephemeral/\n", + expectAppend: false, + }, + { + name: "already gitignored without slash", + existingContent: ".beads-ephemeral\n", + expectAppend: false, + }, + { + name: "other entries only", + existingContent: "node_modules/\n.env\n", + expectAppend: true, + }, + { + name: "other entries no trailing newline", + existingContent: "node_modules/\n.env", + expectAppend: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory with .beads + tmpDir, err := os.MkdirTemp("", "beads-gitignore-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + os.Setenv("BEADS_DIR", beadsDir) + + // Create .gitignore if needed + gitignorePath := filepath.Join(tmpDir, ".gitignore") + if tt.existingContent != "" { + if err := os.WriteFile(gitignorePath, []byte(tt.existingContent), 0644); err != nil { + t.Fatal(err) + } + } + + // Call EnsureEphemeralGitignore + if err := EnsureEphemeralGitignore(); err != nil { + t.Fatalf("EnsureEphemeralGitignore() error = %v", err) + } + + // Read result + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + // Check if ephemeral dir is in gitignore + hasEntry := false + lines := strings.Split(string(content), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == EphemeralDirName || line == EphemeralDirName+"/" { + hasEntry = true + break + } + } + + if !hasEntry { + t.Errorf("EnsureEphemeralGitignore() did not add %s to .gitignore", EphemeralDirName) + } + + // Verify idempotent: calling again should not duplicate + if err := EnsureEphemeralGitignore(); err != nil { + t.Fatalf("EnsureEphemeralGitignore() second call error = %v", err) + } + + content2, _ := os.ReadFile(gitignorePath) + count := strings.Count(string(content2), EphemeralDirName) + if count > 1 { + t.Errorf("EnsureEphemeralGitignore() added duplicate entry (count=%d)", count) + } + }) + } } \ No newline at end of file