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>
240 lines
6.2 KiB
Go
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
|
|
}
|