feat: add ephemeral storage backend for wisps (bd-kwjh.2)

Add .beads-ephemeral/ storage support for ephemeral molecule tracking:

- FindEphemeralDir: locate ephemeral directory (sibling to .beads/)
- FindEphemeralDatabasePath: get DB path, create directory if needed
- NewEphemeralStorage: open ephemeral SQLite database
- EnsureEphemeralGitignore: add .beads-ephemeral/ to .gitignore
- IsEphemeralDatabase: check if a path is ephemeral storage

Part of the wisp storage epic for Gas Town patrol cycles.

🤖 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-22 00:14:16 -08:00
parent 7f0a5982dc
commit 2d719b189d
2 changed files with 391 additions and 0 deletions

View File

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

View File

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