Files
beads/cmd/bd/nodb.go
beads/crew/grip 85ac1e126b fix(nodb): set cmdCtx.StoreActive in initializeNoDbMode (GH#897)
The bug: initializeNoDbMode() was setting the legacy global storeActive
but not cmdCtx.StoreActive. When ensureStoreActive() checked
isStoreActive(), it used cmdCtx.StoreActive (which was false), causing
the JSONL-only mode error even when --no-db was passed.

The fix: Use accessor functions (lockStore, setStore, setStoreActive,
unlockStore) which set both the legacy globals and cmdCtx fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:24:18 -08:00

240 lines
6.2 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/storage/memory"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// initializeNoDbMode sets up in-memory storage from JSONL file
// This is called when --no-db flag is set
func initializeNoDbMode() error {
// Find .beads directory
var beadsDir string
// Check BEADS_DIR environment variable first
if envDir := os.Getenv("BEADS_DIR"); envDir != "" {
// Canonicalize the path
beadsDir = utils.CanonicalizePath(envDir)
} else {
// Fall back to current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
beadsDir = filepath.Join(cwd, ".beads")
}
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return fmt.Errorf("no .beads directory found (hint: run 'bd init' first or set BEADS_DIR)")
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create memory storage
memStore := memory.New(jsonlPath)
// Try to load from JSONL if it exists
if _, err := os.Stat(jsonlPath); err == nil {
issues, err := loadIssuesFromJSONL(jsonlPath)
if err != nil {
return fmt.Errorf("failed to load issues from %s: %w", jsonlPath, err)
}
if err := memStore.LoadFromIssues(issues); err != nil {
return fmt.Errorf("failed to load issues into memory: %w", err)
}
debug.Logf("loaded %d issues from %s", len(issues), jsonlPath)
} else {
debug.Logf("no existing %s, starting with empty database", jsonlPath)
}
// Detect and set prefix
prefix, err := detectPrefix(beadsDir, memStore)
if err != nil {
return fmt.Errorf("failed to detect prefix: %w", err)
}
ctx := rootCtx
if err := memStore.SetConfig(ctx, "issue_prefix", prefix); err != nil {
return fmt.Errorf("failed to set prefix: %w", err)
}
debug.Logf("using prefix '%s'", prefix)
// Set global store and mark as active (fixes bd comment --no-db)
// GH#897: Use accessor functions to also set cmdCtx fields, not just globals
lockStore()
setStore(memStore)
setStoreActive(true)
unlockStore()
return nil
}
// loadIssuesFromJSONL reads all issues from a JSONL file
func loadIssuesFromJSONL(path string) ([]*types.Issue, error) {
// nolint:gosec // G304: path is validated JSONL file from findJSONLPath
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var issues []*types.Issue
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
// Skip empty lines
if strings.TrimSpace(line) == "" {
continue
}
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
return nil, fmt.Errorf("line %d: %w", lineNum, err)
}
issue.SetDefaults() // Apply defaults for omitted fields (beads-399)
issues = append(issues, &issue)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return issues, nil
}
// detectPrefix detects the issue prefix to use in --no-db mode
// Priority:
// 1. issue-prefix from config.yaml (if set)
// 2. Common prefix from existing issues (if all share same prefix)
// 3. Current directory name (fallback)
func detectPrefix(_ string, memStore *memory.MemoryStorage) (string, error) {
// Check config.yaml for issue-prefix
configPrefix := config.GetString("issue-prefix")
if configPrefix != "" {
return configPrefix, nil
}
// Check existing issues for common prefix
issues := memStore.GetAllIssues()
if len(issues) > 0 {
// Extract prefix from first issue
firstPrefix := extractIssuePrefix(issues[0].ID)
// Check if all issues share the same prefix
allSame := true
for _, issue := range issues {
if extractIssuePrefix(issue.ID) != firstPrefix {
allSame = false
break
}
}
if allSame && firstPrefix != "" {
return firstPrefix, nil
}
// If issues have mixed prefixes, we can't auto-detect
if !allSame {
return "", fmt.Errorf("issues have mixed prefixes, please set issue-prefix in .beads/config.yaml")
}
}
// Fallback to directory name
cwd, err := os.Getwd()
if err != nil {
return "bd", nil // Ultimate fallback
}
prefix := filepath.Base(cwd)
// Sanitize prefix (remove special characters, use only alphanumeric and hyphens)
prefix = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A') // Convert to lowercase
}
return -1 // Remove character
}, prefix)
if prefix == "" {
prefix = "bd"
}
return prefix, nil
}
// extractIssuePrefix extracts the prefix from an issue ID like "bd-123" -> "bd"
// Uses the last hyphen before a numeric suffix, so "beads-vscode-1" -> "beads-vscode"
func extractIssuePrefix(issueID string) string {
// Try last hyphen first (handles multi-part prefixes like "beads-vscode-1")
lastIdx := strings.LastIndex(issueID, "-")
if lastIdx <= 0 {
return ""
}
suffix := issueID[lastIdx+1:]
// Check if suffix is numeric
if len(suffix) > 0 {
numPart := suffix
if dotIdx := strings.Index(suffix, "."); dotIdx > 0 {
numPart = suffix[:dotIdx]
}
var num int
if _, err := fmt.Sscanf(numPart, "%d", &num); err == nil {
return issueID[:lastIdx]
}
}
// Suffix is not numeric, fall back to first hyphen
firstIdx := strings.Index(issueID, "-")
if firstIdx <= 0 {
return ""
}
return issueID[:firstIdx]
}
// writeIssuesToJSONL writes all issues from memory storage to JSONL file atomically
func writeIssuesToJSONL(memStore *memory.MemoryStorage, beadsDir string) error {
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Get all issues from memory storage
issues := memStore.GetAllIssues()
// Filter out wisps - they should never be exported to JSONL
// Wisps exist only in SQLite and are shared via .beads/redirect, not JSONL.
filtered := make([]*types.Issue, 0, len(issues))
for _, issue := range issues {
if !issue.Ephemeral {
filtered = append(filtered, issue)
}
}
issues = filtered
// Write atomically using common helper (handles temp file + rename + permissions)
if _, err := writeJSONLAtomic(jsonlPath, issues); err != nil {
return err
}
debug.Logf("wrote %d issues to %s", len(issues), jsonlPath)
return nil
}