Files
beads/cmd/bd/autoimport.go
Steve Yegge e701e34c38 fix: resolve symlink paths in autoimport.go for macOS
Both findBeadsDir() and findGitRoot() now use filepath.EvalSymlinks()
to resolve to canonical paths before comparison. This fixes the issue
where filepath.Rel() fails on macOS because /var is a symlink to
/private/var, causing path mismatches.

Fixes: TestDatabaseReinitialization failures on macOS

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 16:52:16 -08:00

297 lines
9.1 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// checkAndAutoImport checks if the database is empty but git has issues.
// If so, it automatically imports them and returns true.
// Returns false if no import was needed or if import failed.
func checkAndAutoImport(ctx context.Context, store storage.Storage) bool {
// Don't auto-import if auto-import is explicitly disabled
if noAutoImport {
return false
}
// Check if database has any issues
stats, err := store.GetStatistics(ctx)
if err != nil || stats.TotalIssues > 0 {
// Either error checking or DB has issues - don't auto-import
return false
}
// Database is empty - check if git has issues
issueCount, jsonlPath, gitRef := checkGitForIssues()
if issueCount == 0 {
// No issues in git either
return false
}
// Found issues in git! Auto-import them
if !jsonOutput {
fmt.Fprintf(os.Stderr, "Found 0 issues in database but %d in git. Importing...\n", issueCount)
}
// Import from git
if err := importFromGit(ctx, dbPath, store, jsonlPath, gitRef); err != nil {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
fmt.Fprintf(os.Stderr, "Try manually: git show %s:%s | bd import -i /dev/stdin\n", gitRef, jsonlPath)
}
return false
}
if !jsonOutput {
fmt.Fprintf(os.Stderr, "Successfully imported %d issues from git.\n\n", issueCount)
}
return true
}
// checkGitForIssues checks if git has issues in .beads/beads.jsonl or issues.jsonl
// When sync-branch is configured, reads from that branch; otherwise reads from HEAD.
// Returns (issue_count, relative_jsonl_path, git_ref)
func checkGitForIssues() (int, string, string) {
// Try to find .beads directory
beadsDir := findBeadsDir()
if beadsDir == "" {
return 0, "", ""
}
// Construct relative path from git root
gitRoot := findGitRoot()
if gitRoot == "" {
return 0, "", ""
}
// Resolve symlinks to ensure consistent paths for filepath.Rel()
// This is necessary because on macOS, /var is a symlink to /private/var,
// and git rev-parse returns the resolved path while os.Getwd() may not.
resolvedBeadsDir, err := filepath.EvalSymlinks(beadsDir)
if err != nil {
return 0, "", ""
}
beadsDir = resolvedBeadsDir
resolvedGitRoot, err := filepath.EvalSymlinks(gitRoot)
if err != nil {
return 0, "", ""
}
gitRoot = resolvedGitRoot
// Clean paths to ensure consistent separators
beadsDir = filepath.Clean(beadsDir)
gitRoot = filepath.Clean(gitRoot)
relBeads, err := filepath.Rel(gitRoot, beadsDir)
if err != nil {
return 0, "", ""
}
// Determine which branch to read from (bd-0is fix)
// If sync-branch is configured in local config.yaml, use it; otherwise fall back to HEAD
// We read sync-branch directly from local config file rather than using cached global config
// to handle cases where CWD has changed since config initialization (e.g., in tests)
gitRef := "HEAD"
syncBranch := getLocalSyncBranch(beadsDir)
if syncBranch != "" {
// Check if the sync branch exists (locally or on remote)
// Try origin/<branch> first (more likely to exist in fresh clones),
// then local <branch>
for _, ref := range []string{"origin/" + syncBranch, syncBranch} {
cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", ref) // #nosec G204
if err := cmd.Run(); err == nil {
gitRef = ref
break
}
}
}
// Try canonical JSONL filenames in precedence order (issues.jsonl is canonical)
candidates := []string{
filepath.Join(relBeads, "issues.jsonl"),
filepath.Join(relBeads, "beads.jsonl"),
}
for _, relPath := range candidates {
// Use ToSlash for git path compatibility on Windows
gitPath := filepath.ToSlash(relPath)
cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 - git command with safe args
output, err := cmd.Output()
if err == nil && len(output) > 0 {
lines := bytes.Count(output, []byte("\n"))
if lines > 0 {
return lines, relPath, gitRef
}
}
}
return 0, "", ""
}
// getLocalSyncBranch reads sync-branch from the local config.yaml file.
// This reads directly from the file rather than using cached config to handle
// cases where CWD has changed since config initialization.
func getLocalSyncBranch(beadsDir string) string {
// First check environment variable (highest priority)
if envBranch := os.Getenv(syncbranch.EnvVar); envBranch != "" {
return envBranch
}
// Read config.yaml directly from the .beads directory
configPath := filepath.Join(beadsDir, "config.yaml")
data, err := os.ReadFile(configPath) // #nosec G304 - config file path from findBeadsDir
if err != nil {
return ""
}
// Simple YAML parsing for sync-branch key
// Format: "sync-branch: value" or "sync-branch: \"value\""
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "sync-branch:") {
value := strings.TrimPrefix(line, "sync-branch:")
value = strings.TrimSpace(value)
// Remove quotes if present
value = strings.Trim(value, "\"'")
return value
}
}
return ""
}
// findBeadsDir finds the .beads directory in current or parent directories
func findBeadsDir() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
beadsDir := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
// Resolve symlinks to get canonical path (fixes macOS /var -> /private/var)
resolved, err := filepath.EvalSymlinks(beadsDir)
if err != nil {
return beadsDir // Fall back to unresolved if EvalSymlinks fails
}
return resolved
}
parent := filepath.Dir(dir)
if parent == dir {
// Reached root
break
}
dir = parent
}
return ""
}
// findGitRoot finds the git repository root
func findGitRoot() string {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return ""
}
root := string(bytes.TrimSpace(output))
// Normalize path for the current OS
// Git on Windows may return paths with forward slashes (C:/Users/...)
// or Unix-style paths (/c/Users/...), convert to native format
if runtime.GOOS == "windows" {
if len(root) > 0 && root[0] == '/' && len(root) >= 3 && root[2] == '/' {
// Convert /c/Users/... to C:\Users\...
root = strings.ToUpper(string(root[1])) + ":" + filepath.FromSlash(root[2:])
} else {
// Convert C:/Users/... to C:\Users\...
root = filepath.FromSlash(root)
}
}
// Resolve symlinks to get canonical path (fixes macOS /var -> /private/var)
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
return root // Fall back to unresolved if EvalSymlinks fails
}
return resolved
}
// importFromGit imports issues from git at the specified ref (bd-0is: supports sync-branch)
func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage, jsonlPath, gitRef string) error {
// Get content from git (use ToSlash for Windows compatibility)
gitPath := filepath.ToSlash(jsonlPath)
cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 - git command with safe args
jsonlData, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to read from git: %w", err)
}
// Parse JSONL data
scanner := bufio.NewScanner(bytes.NewReader(jsonlData))
// Increase buffer size to handle large JSONL lines (e.g., big descriptions)
scanner.Buffer(make([]byte, 0, 1024*1024), 64*1024*1024) // allow up to 64MB per line
var issues []*types.Issue
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
return fmt.Errorf("failed to parse issue: %w", err)
}
issues = append(issues, &issue)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to scan JSONL: %w", err)
}
// CRITICAL (bd-166): Set issue_prefix from first imported issue if missing
// This prevents derivePrefixFromPath fallback which caused duplicate issues
if len(issues) > 0 {
configuredPrefix, err := store.GetConfig(ctx, "issue_prefix")
if err == nil && strings.TrimSpace(configuredPrefix) == "" {
// Database has no prefix configured - derive from first issue
firstPrefix := utils.ExtractIssuePrefix(issues[0].ID)
if firstPrefix != "" {
if err := store.SetConfig(ctx, "issue_prefix", firstPrefix); err != nil {
return fmt.Errorf("failed to set issue_prefix from imported issues: %w", err)
}
}
}
}
// Use existing import logic with auto-resolve collisions
// Note: SkipPrefixValidation allows mixed prefixes during auto-import
// (but now we set the prefix first, so CreateIssue won't use filename fallback)
opts := ImportOptions{
DryRun: false,
SkipUpdate: false,
SkipPrefixValidation: true, // Auto-import is lenient about prefixes
NoGitHistory: true, // Skip git history backfill during auto-import (bd-4pv)
}
_, err = importIssuesCore(ctx, dbFilePath, store, issues, opts)
return err
}