Add omitempty JSON tags to Issue struct fields (Description, Status, Priority, IssueType) and SetDefaults method to apply proper defaults when importing JSONL with omitted fields. This reduces JSONL file size for minimal issues like notifications by not exporting empty/default values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
332 lines
10 KiB
Go
332 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/git"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/syncbranch"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// 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, "", ""
|
|
}
|
|
|
|
// localConfig represents the subset of config.yaml we need for auto-import and no-db detection.
|
|
// Using proper YAML parsing handles edge cases like comments, indentation, and special characters.
|
|
type localConfig struct {
|
|
SyncBranch string `yaml:"sync-branch"`
|
|
NoDb bool `yaml:"no-db"`
|
|
}
|
|
|
|
// isNoDbModeConfigured checks if no-db: true is set in config.yaml.
|
|
// Uses proper YAML parsing to avoid false matches in comments or nested keys.
|
|
func isNoDbModeConfigured(beadsDir string) bool {
|
|
configPath := filepath.Join(beadsDir, "config.yaml")
|
|
data, err := os.ReadFile(configPath) // #nosec G304 - config file path from beadsDir
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
var cfg localConfig
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return false
|
|
}
|
|
|
|
return cfg.NoDb
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
|
|
// Parse YAML properly to handle edge cases (comments, indentation, special chars)
|
|
var cfg localConfig
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return ""
|
|
}
|
|
|
|
return cfg.SyncBranch
|
|
}
|
|
|
|
// findBeadsDir finds the .beads directory in current or parent directories
|
|
func findBeadsDir() string {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Check if we're in a git worktree with sparse checkout
|
|
// In worktrees, .beads might not exist locally, so we need to resolve to the main repo
|
|
if git.IsWorktree() {
|
|
mainRepoRoot, err := git.GetMainRepoRoot()
|
|
if err == nil && mainRepoRoot != "" {
|
|
mainBeadsDir := filepath.Join(mainRepoRoot, ".beads")
|
|
if info, err := os.Stat(mainBeadsDir); err == nil && info.IsDir() {
|
|
resolved, err := filepath.EvalSymlinks(mainBeadsDir)
|
|
if err != nil {
|
|
return mainBeadsDir
|
|
}
|
|
return resolved
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
issue.SetDefaults() // Apply defaults for omitted fields (beads-399)
|
|
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
|
|
}
|
|
|
|
_, err = importIssuesCore(ctx, dbFilePath, store, issues, opts)
|
|
return err
|
|
}
|