diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index bbda723f..5cd3a596 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -43,6 +43,7 @@ var rigAddCmd = &cobra.Command{ This creates a rig container with: - config.json Rig configuration - .beads/ Rig-level issue tracking (initialized) + - .beads-ephemeral/ Local runtime tracking (gitignored) - refinery/rig/ Canonical main clone - mayor/rig/ Mayor's working clone - crew/main/ Default human workspace @@ -192,6 +193,7 @@ func runRigAdd(cmd *cobra.Command, args []string) error { fmt.Printf(" %s/\n", name) fmt.Printf(" ├── config.json\n") fmt.Printf(" ├── .beads/ (prefix: %s)\n", newRig.Config.Prefix) + fmt.Printf(" ├── .beads-ephemeral/ (local runtime tracking)\n") fmt.Printf(" ├── refinery/rig/ (canonical main)\n") fmt.Printf(" ├── mayor/rig/ (mayor's clone)\n") fmt.Printf(" ├── crew/%s/ (your workspace)\n", rigAddCrew) diff --git a/internal/rig/manager.go b/internal/rig/manager.go index ba4435a9..780e8bf6 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -278,6 +278,11 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { return nil, fmt.Errorf("initializing beads: %w", err) } + // Initialize ephemeral beads for wisp/molecule tracking + if err := m.initEphemeralBeads(rigPath); err != nil { + return nil, fmt.Errorf("initializing ephemeral beads: %w", err) + } + // Register in town config m.config.Rigs[opts.Name] = config.RigEntry{ GitURL: opts.GitURL, @@ -351,6 +356,67 @@ func (m *Manager) initBeads(rigPath, prefix string) error { return nil } +// initEphemeralBeads initializes the ephemeral beads database at rig level. +// Ephemeral beads are local-only (no sync-branch) and used for runtime tracking +// of wisps and molecules. +func (m *Manager) initEphemeralBeads(rigPath string) error { + beadsDir := filepath.Join(rigPath, ".beads-ephemeral") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + return err + } + + // Initialize as a git repo (for local versioning, not for sync) + cmd := exec.Command("git", "init") + cmd.Dir = beadsDir + if err := cmd.Run(); err != nil { + return fmt.Errorf("git init: %w", err) + } + + // Create ephemeral config (no sync-branch needed) + configPath := filepath.Join(beadsDir, "config.yaml") + configContent := "ephemeral: true\n# No sync-branch - ephemeral is local only\n" + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + return err + } + + // Add .beads-ephemeral/ to .gitignore if not already present + gitignorePath := filepath.Join(rigPath, ".gitignore") + return m.ensureGitignoreEntry(gitignorePath, ".beads-ephemeral/") +} + +// ensureGitignoreEntry adds an entry to .gitignore if it doesn't already exist. +func (m *Manager) ensureGitignoreEntry(gitignorePath, entry string) error { + // Read existing content + content, err := os.ReadFile(gitignorePath) + if err != nil && !os.IsNotExist(err) { + return err + } + + // Check if entry already exists + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.TrimSpace(line) == entry { + return nil // Already present + } + } + + // Append entry + f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return 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 err + } + } + _, err = f.WriteString(entry + "\n") + return err +} + // deriveBeadsPrefix generates a beads prefix from a rig name. // Examples: "gastown" -> "gt", "my-project" -> "mp", "foo" -> "foo" func deriveBeadsPrefix(name string) string { diff --git a/internal/rig/manager_test.go b/internal/rig/manager_test.go index 42711d21..254be297 100644 --- a/internal/rig/manager_test.go +++ b/internal/rig/manager_test.go @@ -192,3 +192,108 @@ func TestRigSummary(t *testing.T) { t.Error("expected HasRefinery = false") } } + +func TestInitEphemeralBeads(t *testing.T) { + root, rigsConfig := setupTestTown(t) + manager := NewManager(root, rigsConfig, git.NewGit(root)) + + rigPath := filepath.Join(root, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + if err := manager.initEphemeralBeads(rigPath); err != nil { + t.Fatalf("initEphemeralBeads: %v", err) + } + + // Verify directory was created + ephemeralPath := filepath.Join(rigPath, ".beads-ephemeral") + if _, err := os.Stat(ephemeralPath); os.IsNotExist(err) { + t.Error(".beads-ephemeral/ was not created") + } + + // Verify it's a git repo + gitPath := filepath.Join(ephemeralPath, ".git") + if _, err := os.Stat(gitPath); os.IsNotExist(err) { + t.Error(".beads-ephemeral/ was not initialized as git repo") + } + + // Verify config.yaml was created with ephemeral: true + configPath := filepath.Join(ephemeralPath, "config.yaml") + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("reading config.yaml: %v", err) + } + if string(content) != "ephemeral: true\n# No sync-branch - ephemeral is local only\n" { + t.Errorf("config.yaml content = %q, want ephemeral: true with comment", string(content)) + } + + // Verify .gitignore was updated + gitignorePath := filepath.Join(rigPath, ".gitignore") + ignoreContent, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("reading .gitignore: %v", err) + } + if string(ignoreContent) != ".beads-ephemeral/\n" { + t.Errorf(".gitignore content = %q, want .beads-ephemeral/", string(ignoreContent)) + } +} + +func TestEnsureGitignoreEntry_AddsEntry(t *testing.T) { + root, rigsConfig := setupTestTown(t) + manager := NewManager(root, rigsConfig, git.NewGit(root)) + + gitignorePath := filepath.Join(root, ".gitignore") + + if err := manager.ensureGitignoreEntry(gitignorePath, ".test-entry/"); err != nil { + t.Fatalf("ensureGitignoreEntry: %v", err) + } + + content, _ := os.ReadFile(gitignorePath) + if string(content) != ".test-entry/\n" { + t.Errorf("content = %q, want .test-entry/", string(content)) + } +} + +func TestEnsureGitignoreEntry_DoesNotDuplicate(t *testing.T) { + root, rigsConfig := setupTestTown(t) + manager := NewManager(root, rigsConfig, git.NewGit(root)) + + gitignorePath := filepath.Join(root, ".gitignore") + + // Pre-populate with the entry + if err := os.WriteFile(gitignorePath, []byte(".test-entry/\n"), 0644); err != nil { + t.Fatalf("writing .gitignore: %v", err) + } + + if err := manager.ensureGitignoreEntry(gitignorePath, ".test-entry/"); err != nil { + t.Fatalf("ensureGitignoreEntry: %v", err) + } + + content, _ := os.ReadFile(gitignorePath) + if string(content) != ".test-entry/\n" { + t.Errorf("content = %q, want single .test-entry/", string(content)) + } +} + +func TestEnsureGitignoreEntry_AppendsToExisting(t *testing.T) { + root, rigsConfig := setupTestTown(t) + manager := NewManager(root, rigsConfig, git.NewGit(root)) + + gitignorePath := filepath.Join(root, ".gitignore") + + // Pre-populate with existing entries + if err := os.WriteFile(gitignorePath, []byte("node_modules/\n*.log\n"), 0644); err != nil { + t.Fatalf("writing .gitignore: %v", err) + } + + if err := manager.ensureGitignoreEntry(gitignorePath, ".test-entry/"); err != nil { + t.Fatalf("ensureGitignoreEntry: %v", err) + } + + content, _ := os.ReadFile(gitignorePath) + expected := "node_modules/\n*.log\n.test-entry/\n" + if string(content) != expected { + t.Errorf("content = %q, want %q", string(content), expected) + } +}