Extracts duplicated path canonicalization logic (filepath.Abs + EvalSymlinks) into a reusable helper function utils.CanonicalizePath() in internal/utils/path.go. Changes: - Add internal/utils/path.go with CanonicalizePath() function - Add comprehensive tests in internal/utils/path_test.go - Replace inline canonicalization in beads.go:131-140 - Replace inline canonicalization in cmd/bd/main.go:446-454 - Replace inline canonicalization in cmd/bd/nodb.go:25-33 The new helper maintains identical behavior: 1. Converts path to absolute form via filepath.Abs 2. Resolves symlinks via filepath.EvalSymlinks 3. Falls back gracefully on errors (returns absPath if EvalSymlinks fails, returns original path if Abs fails) Fixes bd-efe8 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
212 lines
5.3 KiB
Go
212 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/config"
|
|
"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)
|
|
}
|
|
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: loaded %d issues from %s\n", len(issues), jsonlPath)
|
|
}
|
|
} else {
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: no existing %s, starting with empty database\n", jsonlPath)
|
|
}
|
|
}
|
|
|
|
// Detect and set prefix
|
|
prefix, err := detectPrefix(beadsDir, memStore)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to detect prefix: %w", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := memStore.SetConfig(ctx, "issue_prefix", prefix); err != nil {
|
|
return fmt.Errorf("failed to set prefix: %w", err)
|
|
}
|
|
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: using prefix '%s'\n", prefix)
|
|
}
|
|
|
|
// Set global store
|
|
store = memStore
|
|
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)
|
|
}
|
|
|
|
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"
|
|
func extractIssuePrefix(issueID string) string {
|
|
idx := strings.LastIndex(issueID, "-")
|
|
if idx <= 0 {
|
|
return ""
|
|
}
|
|
return issueID[:idx]
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Write atomically using common helper (handles temp file + rename + permissions)
|
|
if _, err := writeJSONLAtomic(jsonlPath, issues); err != nil {
|
|
return err
|
|
}
|
|
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: wrote %d issues to %s\n", len(issues), jsonlPath)
|
|
}
|
|
|
|
return nil
|
|
}
|