The --stealth flag was writing absolute paths to global gitignore, but git pattern matching does not support absolute paths in gitignore files. Patterns are interpreted relative to the gitignore location, making absolute paths like /home/user/project/.beads/ never match. Fix: Use .git/info/exclude which is the git-recommended location for user-specific, per-repository ignores. Patterns here are relative to repo root, so .beads/ works correctly. Benefits: - Patterns actually work (fixes the bug) - Per-repo isolation (stealth in one repo does not affect others) - No global gitignore pollution - Follows git best practices for user-local ignores Generated with Claude Code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1809 lines
59 KiB
Go
1809 lines
59 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/cmd/bd/doctor"
|
|
"github.com/steveyegge/beads/internal/beads"
|
|
"github.com/steveyegge/beads/internal/config"
|
|
"github.com/steveyegge/beads/internal/configfile"
|
|
"github.com/steveyegge/beads/internal/git"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/syncbranch"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
var initCmd = &cobra.Command{
|
|
Use: "init",
|
|
GroupID: "setup",
|
|
Short: "Initialize bd in the current directory",
|
|
Long: `Initialize bd in the current directory by creating a .beads/ directory
|
|
and database file. Optionally specify a custom issue prefix.
|
|
|
|
With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite database.
|
|
|
|
With --stealth: configures per-repository git settings for invisible beads usage:
|
|
• .git/info/exclude to prevent beads files from being committed
|
|
• Claude Code settings with bd onboard instruction
|
|
Perfect for personal use without affecting repo collaborators.`,
|
|
Run: func(cmd *cobra.Command, _ []string) {
|
|
prefix, _ := cmd.Flags().GetString("prefix")
|
|
quiet, _ := cmd.Flags().GetBool("quiet")
|
|
branch, _ := cmd.Flags().GetString("branch")
|
|
contributor, _ := cmd.Flags().GetBool("contributor")
|
|
team, _ := cmd.Flags().GetBool("team")
|
|
stealth, _ := cmd.Flags().GetBool("stealth")
|
|
skipMergeDriver, _ := cmd.Flags().GetBool("skip-merge-driver")
|
|
skipHooks, _ := cmd.Flags().GetBool("skip-hooks")
|
|
force, _ := cmd.Flags().GetBool("force")
|
|
|
|
// Initialize config (PersistentPreRun doesn't run for init command)
|
|
if err := config.Initialize(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to initialize config: %v\n", err)
|
|
// Non-fatal - continue with defaults
|
|
}
|
|
|
|
// Safety guard: check for existing JSONL with issues (bd-emg)
|
|
// This prevents accidental re-initialization in fresh clones
|
|
if !force {
|
|
if err := checkExistingBeadsData(prefix); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Handle stealth mode setup
|
|
if stealth {
|
|
if err := setupStealthMode(!quiet); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error setting up stealth mode: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// In stealth mode, skip git hooks and merge driver installation
|
|
// since we handle it globally
|
|
skipHooks = true
|
|
skipMergeDriver = true
|
|
}
|
|
|
|
// Check BEADS_DB environment variable if --db flag not set
|
|
// (PersistentPreRun doesn't run for init command)
|
|
if dbPath == "" {
|
|
if envDB := os.Getenv("BEADS_DB"); envDB != "" {
|
|
dbPath = envDB
|
|
}
|
|
}
|
|
|
|
// Determine prefix with precedence: flag > config > auto-detect from git > auto-detect from directory name
|
|
if prefix == "" {
|
|
// Try to get from config file
|
|
prefix = config.GetString("issue-prefix")
|
|
}
|
|
|
|
// auto-detect prefix from first issue in JSONL file
|
|
if prefix == "" {
|
|
issueCount, jsonlPath, gitRef := checkGitForIssues()
|
|
if issueCount > 0 {
|
|
firstIssue, err := readFirstIssueFromGit(jsonlPath, gitRef)
|
|
if firstIssue != nil && err == nil {
|
|
prefix = utils.ExtractIssuePrefix(firstIssue.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// auto-detect prefix from directory name
|
|
if prefix == "" {
|
|
// Auto-detect from directory name
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
prefix = filepath.Base(cwd)
|
|
}
|
|
|
|
// Normalize prefix: strip trailing hyphens
|
|
// The hyphen is added automatically during ID generation
|
|
prefix = strings.TrimRight(prefix, "-")
|
|
|
|
// Create database
|
|
// Use global dbPath if set via --db flag or BEADS_DB env var, otherwise default to .beads/beads.db
|
|
initDBPath := dbPath
|
|
if initDBPath == "" {
|
|
initDBPath = filepath.Join(".beads", beads.CanonicalDatabaseName)
|
|
}
|
|
|
|
// Migrate old database files if they exist
|
|
if err := migrateOldDatabases(initDBPath, quiet); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error during database migration: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Determine if we should create .beads/ directory in CWD or main repo root
|
|
// For worktrees, .beads should always be in the main repository root
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Check if we're in a git worktree
|
|
isWorktree := git.IsWorktree()
|
|
var beadsDir string
|
|
if isWorktree {
|
|
// For worktrees, .beads should be in the main repository root
|
|
mainRepoRoot, err := git.GetMainRepoRoot()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to get main repository root: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
beadsDir = filepath.Join(mainRepoRoot, ".beads")
|
|
} else {
|
|
// For regular repos, use current directory
|
|
beadsDir = filepath.Join(cwd, ".beads")
|
|
}
|
|
|
|
// Prevent nested .beads directories
|
|
// Check if current working directory is inside a .beads directory
|
|
if strings.Contains(filepath.Clean(cwd), string(filepath.Separator)+".beads"+string(filepath.Separator)) ||
|
|
strings.HasSuffix(filepath.Clean(cwd), string(filepath.Separator)+".beads") {
|
|
fmt.Fprintf(os.Stderr, "Error: cannot initialize bd inside a .beads directory\n")
|
|
fmt.Fprintf(os.Stderr, "Current directory: %s\n", cwd)
|
|
fmt.Fprintf(os.Stderr, "Please run 'bd init' from outside the .beads directory.\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
initDBDir := filepath.Dir(initDBPath)
|
|
|
|
// Convert both to absolute paths for comparison
|
|
beadsDirAbs, err := filepath.Abs(beadsDir)
|
|
if err != nil {
|
|
beadsDirAbs = filepath.Clean(beadsDir)
|
|
}
|
|
initDBDirAbs, err := filepath.Abs(initDBDir)
|
|
if err != nil {
|
|
initDBDirAbs = filepath.Clean(initDBDir)
|
|
}
|
|
|
|
useLocalBeads := filepath.Clean(initDBDirAbs) == filepath.Clean(beadsDirAbs)
|
|
|
|
if useLocalBeads {
|
|
// Create .beads directory
|
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create .beads directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Handle --no-db mode: create issues.jsonl file instead of database
|
|
if noDb {
|
|
// Create empty issues.jsonl file
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
|
// nolint:gosec // G306: JSONL file needs to be readable by other tools
|
|
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create issues.jsonl: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Create empty interactions.jsonl file (append-only agent audit log)
|
|
interactionsPath := filepath.Join(beadsDir, "interactions.jsonl")
|
|
if _, err := os.Stat(interactionsPath); os.IsNotExist(err) {
|
|
// nolint:gosec // G306: JSONL file needs to be readable by other tools
|
|
if err := os.WriteFile(interactionsPath, []byte{}, 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create interactions.jsonl: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Create metadata.json for --no-db mode
|
|
cfg := configfile.DefaultConfig()
|
|
if err := cfg.Save(beadsDir); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
|
|
// Create config.yaml with no-db: true
|
|
if err := createConfigYaml(beadsDir, true); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
|
|
// Create README.md
|
|
if err := createReadme(beadsDir); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create README.md: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
|
|
if !quiet {
|
|
fmt.Printf("\n%s bd initialized successfully in --no-db mode!\n\n", ui.RenderPass("✓"))
|
|
fmt.Printf(" Mode: %s\n", ui.RenderAccent("no-db (JSONL-only)"))
|
|
fmt.Printf(" Issues file: %s\n", ui.RenderAccent(jsonlPath))
|
|
fmt.Printf(" Issue prefix: %s\n", ui.RenderAccent(prefix))
|
|
fmt.Printf(" Issues will be named: %s\n\n", ui.RenderAccent(prefix+"-<hash> (e.g., "+prefix+"-a3f2dd)"))
|
|
fmt.Printf("Run %s to get started.\n\n", ui.RenderAccent("bd --no-db quickstart"))
|
|
}
|
|
return
|
|
}
|
|
|
|
// Create/update .gitignore in .beads directory (idempotent - always update to latest)
|
|
gitignorePath := filepath.Join(beadsDir, ".gitignore")
|
|
if err := os.WriteFile(gitignorePath, []byte(doctor.GitignoreTemplate), 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create/update .gitignore: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
|
|
// Ensure interactions.jsonl exists (append-only agent audit log)
|
|
interactionsPath := filepath.Join(beadsDir, "interactions.jsonl")
|
|
if _, err := os.Stat(interactionsPath); os.IsNotExist(err) {
|
|
// nolint:gosec // G306: JSONL file needs to be readable by other tools
|
|
if err := os.WriteFile(interactionsPath, []byte{}, 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create interactions.jsonl: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure parent directory exists for the database
|
|
if err := os.MkdirAll(initDBDir, 0750); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create database directory %s: %v\n", initDBDir, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx := rootCtx
|
|
store, err := sqlite.New(ctx, initDBPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to create database: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// === CONFIGURATION METADATA (Pattern A: Fatal) ===
|
|
// Configuration metadata is essential for core functionality and must succeed.
|
|
// These settings define fundamental behavior (issue IDs, sync workflow).
|
|
// Failure here indicates a serious problem that prevents normal operation.
|
|
|
|
// Set the issue prefix in config
|
|
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err)
|
|
_ = store.Close()
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Set sync.branch: use explicit --branch flag, or auto-detect current branch
|
|
// This ensures bd sync --status works after bd init (bd-flil)
|
|
if branch == "" && isGitRepo() {
|
|
// Auto-detect current branch if not specified
|
|
currentBranch, err := getGitBranch()
|
|
if err == nil && currentBranch != "" {
|
|
branch = currentBranch
|
|
}
|
|
}
|
|
|
|
if branch != "" {
|
|
if err := syncbranch.Set(ctx, store, branch); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to set sync branch: %v\n", err)
|
|
_ = store.Close()
|
|
os.Exit(1)
|
|
}
|
|
if !quiet {
|
|
fmt.Printf(" Sync branch: %s\n", branch)
|
|
}
|
|
}
|
|
|
|
// === TRACKING METADATA (Pattern B: Warn and Continue) ===
|
|
// Tracking metadata enhances functionality (diagnostics, version checks, collision detection)
|
|
// but the system works without it. Failures here degrade gracefully - we warn but continue.
|
|
// Examples: bd_version enables upgrade warnings, repo_id/clone_id help with collision detection.
|
|
|
|
// Store the bd version in metadata (for version mismatch detection)
|
|
if err := store.SetMetadata(ctx, "bd_version", Version); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to store version metadata: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
|
|
// Compute and store repository fingerprint
|
|
repoID, err := beads.ComputeRepoID()
|
|
if err != nil {
|
|
if !quiet {
|
|
fmt.Fprintf(os.Stderr, "Warning: could not compute repository ID: %v\n", err)
|
|
}
|
|
} else {
|
|
if err := store.SetMetadata(ctx, "repo_id", repoID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to set repo_id: %v\n", err)
|
|
} else if !quiet {
|
|
fmt.Printf(" Repository ID: %s\n", repoID[:8])
|
|
}
|
|
}
|
|
|
|
// Store clone-specific ID
|
|
cloneID, err := beads.GetCloneID()
|
|
if err != nil {
|
|
if !quiet {
|
|
fmt.Fprintf(os.Stderr, "Warning: could not compute clone ID: %v\n", err)
|
|
}
|
|
} else {
|
|
if err := store.SetMetadata(ctx, "clone_id", cloneID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to set clone_id: %v\n", err)
|
|
} else if !quiet {
|
|
fmt.Printf(" Clone ID: %s\n", cloneID)
|
|
}
|
|
}
|
|
|
|
// Create or preserve metadata.json for database metadata (bd-zai fix)
|
|
if useLocalBeads {
|
|
// First, check if metadata.json already exists
|
|
existingCfg, err := configfile.Load(beadsDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to load existing metadata.json: %v\n", err)
|
|
}
|
|
|
|
var cfg *configfile.Config
|
|
if existingCfg != nil {
|
|
// Preserve existing config
|
|
cfg = existingCfg
|
|
} else {
|
|
// Create new config, detecting JSONL filename from existing files
|
|
cfg = configfile.DefaultConfig()
|
|
// Check if beads.jsonl exists but issues.jsonl doesn't (legacy)
|
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
beadsPath := filepath.Join(beadsDir, "beads.jsonl")
|
|
if _, err := os.Stat(beadsPath); err == nil {
|
|
if _, err := os.Stat(issuesPath); os.IsNotExist(err) {
|
|
cfg.JSONLExport = "beads.jsonl" // Legacy filename
|
|
}
|
|
}
|
|
}
|
|
if err := cfg.Save(beadsDir); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
|
|
// Create config.yaml template
|
|
if err := createConfigYaml(beadsDir, false); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
|
|
// Create README.md
|
|
if err := createReadme(beadsDir); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create README.md: %v\n", err)
|
|
// Non-fatal - continue anyway
|
|
}
|
|
}
|
|
|
|
// Check if git has existing issues to import (fresh clone scenario)
|
|
issueCount, jsonlPath, gitRef := checkGitForIssues()
|
|
if issueCount > 0 {
|
|
if !quiet {
|
|
fmt.Fprintf(os.Stderr, "\n✓ Database initialized. Found %d issues in git, importing...\n", issueCount)
|
|
}
|
|
|
|
if err := importFromGit(ctx, initDBPath, store, jsonlPath, gitRef); err != nil {
|
|
if !quiet {
|
|
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)
|
|
}
|
|
// Non-fatal - continue with empty database
|
|
} else if !quiet {
|
|
fmt.Fprintf(os.Stderr, "✓ Successfully imported %d issues from git.\n\n", issueCount)
|
|
}
|
|
}
|
|
|
|
// Run contributor wizard if --contributor flag is set
|
|
if contributor {
|
|
if err := runContributorWizard(ctx, store); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error running contributor wizard: %v\n", err)
|
|
_ = store.Close()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Run team wizard if --team flag is set
|
|
if team {
|
|
if err := runTeamWizard(ctx, store); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error running team wizard: %v\n", err)
|
|
_ = store.Close()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
if err := store.Close(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
|
|
}
|
|
|
|
// Check if we're in a git repo and hooks aren't installed
|
|
// Install by default unless --skip-hooks is passed
|
|
if !skipHooks && isGitRepo() && !hooksInstalled() {
|
|
if err := installGitHooks(); err != nil && !quiet {
|
|
fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", ui.RenderWarn("⚠"), err)
|
|
fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd doctor --fix"))
|
|
}
|
|
}
|
|
|
|
// Check if we're in a git repo and merge driver isn't configured
|
|
// Install by default unless --skip-merge-driver is passed
|
|
if !skipMergeDriver && isGitRepo() && !mergeDriverInstalled() {
|
|
if err := installMergeDriver(); err != nil && !quiet {
|
|
fmt.Fprintf(os.Stderr, "\n%s Failed to install merge driver: %v\n", ui.RenderWarn("⚠"), err)
|
|
fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd doctor --fix"))
|
|
}
|
|
}
|
|
|
|
// Add "landing the plane" instructions to AGENTS.md and @AGENTS.md
|
|
// Skip in stealth mode (user wants invisible setup) and quiet mode (suppress all output)
|
|
if !stealth {
|
|
addLandingThePlaneInstructions(!quiet)
|
|
}
|
|
|
|
// Skip output if quiet mode
|
|
if quiet {
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\n%s bd initialized successfully!\n\n", ui.RenderPass("✓"))
|
|
fmt.Printf(" Database: %s\n", ui.RenderAccent(initDBPath))
|
|
fmt.Printf(" Issue prefix: %s\n", ui.RenderAccent(prefix))
|
|
fmt.Printf(" Issues will be named: %s\n\n", ui.RenderAccent(prefix+"-<hash> (e.g., "+prefix+"-a3f2dd)"))
|
|
fmt.Printf("Run %s to get started.\n\n", ui.RenderAccent("bd quickstart"))
|
|
|
|
// Run bd doctor diagnostics to catch setup issues early (bd-zwtq)
|
|
doctorResult := runDiagnostics(cwd)
|
|
// Check if there are any warnings or errors (not just critical failures)
|
|
hasIssues := false
|
|
for _, check := range doctorResult.Checks {
|
|
if check.Status != statusOK {
|
|
hasIssues = true
|
|
break
|
|
}
|
|
}
|
|
if hasIssues {
|
|
fmt.Printf("%s Setup incomplete. Some issues were detected:\n", ui.RenderWarn("⚠"))
|
|
// Show just the warnings/errors, not all checks
|
|
for _, check := range doctorResult.Checks {
|
|
if check.Status != statusOK {
|
|
fmt.Printf(" • %s: %s\n", check.Name, check.Message)
|
|
}
|
|
}
|
|
fmt.Printf("\nRun %s to see details and fix these issues.\n\n", ui.RenderAccent("bd doctor --fix"))
|
|
}
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
initCmd.Flags().StringP("prefix", "p", "", "Issue prefix (default: current directory name)")
|
|
initCmd.Flags().BoolP("quiet", "q", false, "Suppress output (quiet mode)")
|
|
initCmd.Flags().StringP("branch", "b", "", "Git branch for beads commits (default: current branch)")
|
|
initCmd.Flags().Bool("contributor", false, "Run OSS contributor setup wizard")
|
|
initCmd.Flags().Bool("team", false, "Run team workflow setup wizard")
|
|
initCmd.Flags().Bool("stealth", false, "Enable stealth mode: global gitattributes and gitignore, no local repo tracking")
|
|
initCmd.Flags().Bool("skip-hooks", false, "Skip git hooks installation")
|
|
initCmd.Flags().Bool("skip-merge-driver", false, "Skip git merge driver setup")
|
|
initCmd.Flags().Bool("force", false, "Force re-initialization even if JSONL already has issues (may cause data loss)")
|
|
rootCmd.AddCommand(initCmd)
|
|
}
|
|
|
|
// hooksInstalled checks if bd git hooks are installed
|
|
func hooksInstalled() bool {
|
|
gitDir, err := git.GetGitDir()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
preCommit := filepath.Join(gitDir, "hooks", "pre-commit")
|
|
postMerge := filepath.Join(gitDir, "hooks", "post-merge")
|
|
|
|
// Check if both hooks exist
|
|
_, err1 := os.Stat(preCommit)
|
|
_, err2 := os.Stat(postMerge)
|
|
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
|
|
// Verify they're bd hooks by checking for signature comment
|
|
// #nosec G304 - controlled path from git directory
|
|
preCommitContent, err := os.ReadFile(preCommit)
|
|
if err != nil || !strings.Contains(string(preCommitContent), "bd (beads) pre-commit hook") {
|
|
return false
|
|
}
|
|
|
|
// #nosec G304 - controlled path from git directory
|
|
postMergeContent, err := os.ReadFile(postMerge)
|
|
if err != nil || !strings.Contains(string(postMergeContent), "bd (beads) post-merge hook") {
|
|
return false
|
|
}
|
|
|
|
// Verify hooks are executable
|
|
preCommitInfo, err := os.Stat(preCommit)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if preCommitInfo.Mode().Perm()&0111 == 0 {
|
|
return false // Not executable
|
|
}
|
|
|
|
postMergeInfo, err := os.Stat(postMerge)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if postMergeInfo.Mode().Perm()&0111 == 0 {
|
|
return false // Not executable
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// hookInfo contains information about an existing hook
|
|
type hookInfo struct {
|
|
name string
|
|
path string
|
|
exists bool
|
|
isBdHook bool
|
|
isPreCommit bool
|
|
content string
|
|
}
|
|
|
|
// detectExistingHooks scans for existing git hooks
|
|
func detectExistingHooks() []hookInfo {
|
|
gitDir, err := git.GetGitDir()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
hooksDir := filepath.Join(gitDir, "hooks")
|
|
hooks := []hookInfo{
|
|
{name: "pre-commit", path: filepath.Join(hooksDir, "pre-commit")},
|
|
{name: "post-merge", path: filepath.Join(hooksDir, "post-merge")},
|
|
{name: "pre-push", path: filepath.Join(hooksDir, "pre-push")},
|
|
}
|
|
|
|
for i := range hooks {
|
|
content, err := os.ReadFile(hooks[i].path)
|
|
if err == nil {
|
|
hooks[i].exists = true
|
|
hooks[i].content = string(content)
|
|
hooks[i].isBdHook = strings.Contains(hooks[i].content, "bd (beads)")
|
|
// Only detect pre-commit framework if not a bd hook
|
|
if !hooks[i].isBdHook {
|
|
hooks[i].isPreCommit = strings.Contains(hooks[i].content, "pre-commit run") ||
|
|
strings.Contains(hooks[i].content, ".pre-commit-config")
|
|
}
|
|
}
|
|
}
|
|
|
|
return hooks
|
|
}
|
|
|
|
// promptHookAction asks user what to do with existing hooks
|
|
func promptHookAction(existingHooks []hookInfo) string {
|
|
fmt.Printf("\n%s Found existing git hooks:\n", ui.RenderWarn("⚠"))
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
hookType := "custom script"
|
|
if hook.isPreCommit {
|
|
hookType = "pre-commit framework"
|
|
}
|
|
fmt.Printf(" - %s (%s)\n", hook.name, hookType)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\nHow should bd proceed?\n")
|
|
fmt.Printf(" [1] Chain with existing hooks (recommended)\n")
|
|
fmt.Printf(" [2] Overwrite existing hooks\n")
|
|
fmt.Printf(" [3] Skip git hooks installation\n")
|
|
fmt.Printf("Choice [1-3]: ")
|
|
|
|
var response string
|
|
_, _ = fmt.Scanln(&response)
|
|
response = strings.TrimSpace(response)
|
|
|
|
return response
|
|
}
|
|
|
|
// installGitHooks installs git hooks inline (no external dependencies)
|
|
func installGitHooks() error {
|
|
gitDir, err := git.GetGitDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hooksDir := filepath.Join(gitDir, "hooks")
|
|
|
|
// Ensure hooks directory exists
|
|
if err := os.MkdirAll(hooksDir, 0750); err != nil {
|
|
return fmt.Errorf("failed to create hooks directory: %w", err)
|
|
}
|
|
|
|
// Detect existing hooks
|
|
existingHooks := detectExistingHooks()
|
|
|
|
// Check if any non-bd hooks exist
|
|
hasExistingHooks := false
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
hasExistingHooks = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Determine installation mode
|
|
chainHooks := false
|
|
if hasExistingHooks {
|
|
choice := promptHookAction(existingHooks)
|
|
switch choice {
|
|
case "1", "":
|
|
chainHooks = true
|
|
case "2":
|
|
// Overwrite mode - backup existing hooks
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
timestamp := time.Now().Format("20060102-150405")
|
|
backup := hook.path + ".backup-" + timestamp
|
|
if err := os.Rename(hook.path, backup); err != nil {
|
|
return fmt.Errorf("failed to backup %s: %w", hook.name, err)
|
|
}
|
|
fmt.Printf(" Backed up %s to %s\n", hook.name, filepath.Base(backup))
|
|
}
|
|
}
|
|
case "3":
|
|
fmt.Printf("Skipping git hooks installation.\n")
|
|
fmt.Printf("You can install manually later with: %s\n", ui.RenderAccent("./examples/git-hooks/install.sh"))
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("invalid choice: %s", choice)
|
|
}
|
|
}
|
|
|
|
// pre-commit hook
|
|
preCommitPath := filepath.Join(hooksDir, "pre-commit")
|
|
var preCommitContent string
|
|
|
|
if chainHooks {
|
|
// Find existing pre-commit hook
|
|
var existingPreCommit string
|
|
for _, hook := range existingHooks {
|
|
if hook.name == "pre-commit" && hook.exists && !hook.isBdHook {
|
|
// Move to .pre-commit-old
|
|
oldPath := hook.path + ".old"
|
|
if err := os.Rename(hook.path, oldPath); err != nil {
|
|
return fmt.Errorf("failed to move existing pre-commit: %w", err)
|
|
}
|
|
existingPreCommit = oldPath
|
|
break
|
|
}
|
|
}
|
|
|
|
preCommitContent = `#!/bin/sh
|
|
#
|
|
# bd (beads) pre-commit hook (chained)
|
|
#
|
|
# This hook chains bd functionality with your existing pre-commit hook.
|
|
|
|
# Run existing hook first
|
|
if [ -x "` + existingPreCommit + `" ]; then
|
|
"` + existingPreCommit + `" "$@"
|
|
EXIT_CODE=$?
|
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
exit $EXIT_CODE
|
|
fi
|
|
fi
|
|
|
|
# Check if bd is available
|
|
if ! command -v bd >/dev/null 2>&1; then
|
|
echo "Warning: bd command not found, skipping pre-commit flush" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Check if we're in a bd workspace
|
|
# For worktrees, .beads is in the main repository root, not the worktree
|
|
BEADS_DIR=""
|
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
# Check if we're in a worktree
|
|
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
|
# Worktree: .beads is in main repo root
|
|
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
|
|
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
|
|
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
|
|
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
|
|
fi
|
|
else
|
|
# Regular repo: check current directory
|
|
if [ -d .beads ]; then
|
|
BEADS_DIR=".beads"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$BEADS_DIR" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Flush pending changes to JSONL
|
|
if ! bd sync --flush-only >/dev/null 2>&1; then
|
|
echo "Error: Failed to flush bd changes to JSONL" >&2
|
|
echo "Run 'bd sync --flush-only' manually to diagnose" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# If the JSONL file was modified, stage it
|
|
# For worktrees, the JSONL is in the main repo's working tree, not the worktree,
|
|
# so we can't use git add. Skip this step for worktrees.
|
|
if [ -f "$BEADS_DIR/issues.jsonl" ]; then
|
|
if [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ]; then
|
|
# Regular repo: file is in the working tree, safe to add
|
|
git add "$BEADS_DIR/issues.jsonl" 2>/dev/null || true
|
|
fi
|
|
# For worktrees: .beads is in the main repo's working tree, not this worktree
|
|
# Git rejects adding files outside the worktree, so we skip it.
|
|
# The main repo will see the changes on the next pull/sync.
|
|
fi
|
|
|
|
exit 0
|
|
`
|
|
} else {
|
|
preCommitContent = `#!/bin/sh
|
|
#
|
|
# bd (beads) pre-commit hook
|
|
#
|
|
# This hook ensures that any pending bd issue changes are flushed to
|
|
# .beads/issues.jsonl before the commit is created, preventing the
|
|
# race condition where daemon auto-flush fires after the commit.
|
|
|
|
# Check if bd is available
|
|
if ! command -v bd >/dev/null 2>&1; then
|
|
echo "Warning: bd command not found, skipping pre-commit flush" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Check if we're in a bd workspace
|
|
# For worktrees, .beads is in the main repository root, not the worktree
|
|
BEADS_DIR=""
|
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
# Check if we're in a worktree
|
|
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
|
# Worktree: .beads is in main repo root
|
|
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
|
|
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
|
|
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
|
|
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
|
|
fi
|
|
else
|
|
# Regular repo: check current directory
|
|
if [ -d .beads ]; then
|
|
BEADS_DIR=".beads"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$BEADS_DIR" ]; then
|
|
# Not a bd workspace, nothing to do
|
|
exit 0
|
|
fi
|
|
|
|
# Flush pending changes to JSONL
|
|
# Use --flush-only to skip git operations (we're already in a git hook)
|
|
# Suppress output unless there's an error
|
|
if ! bd sync --flush-only >/dev/null 2>&1; then
|
|
echo "Error: Failed to flush bd changes to JSONL" >&2
|
|
echo "Run 'bd sync --flush-only' manually to diagnose" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# If the JSONL file was modified, stage it
|
|
# For worktrees, the JSONL is in the main repo's working tree, not the worktree,
|
|
# so we can't use git add. Skip this step for worktrees.
|
|
if [ -f "$BEADS_DIR/issues.jsonl" ]; then
|
|
if [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ]; then
|
|
# Regular repo: file is in the working tree, safe to add
|
|
git add "$BEADS_DIR/issues.jsonl" 2>/dev/null || true
|
|
fi
|
|
# For worktrees: .beads is in the main repo's working tree, not this worktree
|
|
# Git rejects adding files outside the worktree, so we skip it.
|
|
# The main repo will see the changes on the next pull/sync.
|
|
fi
|
|
|
|
exit 0
|
|
`
|
|
}
|
|
|
|
// post-merge hook
|
|
postMergePath := filepath.Join(hooksDir, "post-merge")
|
|
var postMergeContent string
|
|
|
|
if chainHooks {
|
|
// Find existing post-merge hook
|
|
var existingPostMerge string
|
|
for _, hook := range existingHooks {
|
|
if hook.name == "post-merge" && hook.exists && !hook.isBdHook {
|
|
// Move to .post-merge-old
|
|
oldPath := hook.path + ".old"
|
|
if err := os.Rename(hook.path, oldPath); err != nil {
|
|
return fmt.Errorf("failed to move existing post-merge: %w", err)
|
|
}
|
|
existingPostMerge = oldPath
|
|
break
|
|
}
|
|
}
|
|
|
|
postMergeContent = `#!/bin/sh
|
|
#
|
|
# bd (beads) post-merge hook (chained)
|
|
#
|
|
# This hook chains bd functionality with your existing post-merge hook.
|
|
|
|
# Run existing hook first
|
|
if [ -x "` + existingPostMerge + `" ]; then
|
|
"` + existingPostMerge + `" "$@"
|
|
EXIT_CODE=$?
|
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
exit $EXIT_CODE
|
|
fi
|
|
fi
|
|
|
|
# Check if bd is available
|
|
if ! command -v bd >/dev/null 2>&1; then
|
|
echo "Warning: bd command not found, skipping post-merge import" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Check if we're in a bd workspace
|
|
# For worktrees, .beads is in the main repository root, not the worktree
|
|
BEADS_DIR=""
|
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
# Check if we're in a worktree
|
|
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
|
# Worktree: .beads is in main repo root
|
|
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
|
|
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
|
|
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
|
|
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
|
|
fi
|
|
else
|
|
# Regular repo: check current directory
|
|
if [ -d .beads ]; then
|
|
BEADS_DIR=".beads"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$BEADS_DIR" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Check if issues.jsonl exists and was updated
|
|
if [ ! -f "$BEADS_DIR/issues.jsonl" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Import the updated JSONL
|
|
if ! bd import -i "$BEADS_DIR/issues.jsonl" >/dev/null 2>&1; then
|
|
echo "Warning: Failed to import bd changes after merge" >&2
|
|
echo "Run 'bd import -i $BEADS_DIR/issues.jsonl' manually to see the error" >&2
|
|
fi
|
|
|
|
exit 0
|
|
`
|
|
} else {
|
|
postMergeContent = `#!/bin/sh
|
|
#
|
|
# bd (beads) post-merge hook
|
|
#
|
|
# This hook imports updated issues from .beads/issues.jsonl after a
|
|
# git pull or merge, ensuring the database stays in sync with git.
|
|
|
|
# Check if bd is available
|
|
if ! command -v bd >/dev/null 2>&1; then
|
|
echo "Warning: bd command not found, skipping post-merge import" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Check if we're in a bd workspace
|
|
# For worktrees, .beads is in the main repository root, not the worktree
|
|
BEADS_DIR=""
|
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
# Check if we're in a worktree
|
|
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
|
# Worktree: .beads is in main repo root
|
|
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
|
|
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
|
|
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
|
|
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
|
|
fi
|
|
else
|
|
# Regular repo: check current directory
|
|
if [ -d .beads ]; then
|
|
BEADS_DIR=".beads"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$BEADS_DIR" ]; then
|
|
# Not a bd workspace, nothing to do
|
|
exit 0
|
|
fi
|
|
|
|
# Check if issues.jsonl exists and was updated
|
|
if [ ! -f "$BEADS_DIR/issues.jsonl" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Import the updated JSONL
|
|
# The auto-import feature should handle this, but we force it here
|
|
# to ensure immediate sync after merge
|
|
if ! bd import -i "$BEADS_DIR/issues.jsonl" >/dev/null 2>&1; then
|
|
echo "Warning: Failed to import bd changes after merge" >&2
|
|
echo "Run 'bd import -i $BEADS_DIR/issues.jsonl' manually to see the error" >&2
|
|
# Don't fail the merge, just warn
|
|
fi
|
|
|
|
exit 0
|
|
`
|
|
}
|
|
|
|
// Write pre-commit hook (executable scripts need 0700)
|
|
// #nosec G306 - git hooks must be executable
|
|
if err := os.WriteFile(preCommitPath, []byte(preCommitContent), 0700); err != nil {
|
|
return fmt.Errorf("failed to write pre-commit hook: %w", err)
|
|
}
|
|
|
|
// Write post-merge hook (executable scripts need 0700)
|
|
// #nosec G306 - git hooks must be executable
|
|
if err := os.WriteFile(postMergePath, []byte(postMergeContent), 0700); err != nil {
|
|
return fmt.Errorf("failed to write post-merge hook: %w", err)
|
|
}
|
|
|
|
if chainHooks {
|
|
fmt.Printf("%s Chained bd hooks with existing hooks\n", ui.RenderPass("✓"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// mergeDriverInstalled checks if bd merge driver is configured correctly
|
|
func mergeDriverInstalled() bool {
|
|
// Check git config for merge driver
|
|
cmd := exec.Command("git", "config", "merge.beads.driver")
|
|
output, err := cmd.Output()
|
|
if err != nil || len(output) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Check if using old invalid placeholders (%L/%R from versions <0.24.0)
|
|
// Git only supports %O (base), %A (current), %B (other)
|
|
driverConfig := strings.TrimSpace(string(output))
|
|
if strings.Contains(driverConfig, "%L") || strings.Contains(driverConfig, "%R") {
|
|
// Stale config with invalid placeholders - needs repair
|
|
return false
|
|
}
|
|
|
|
// Check if .gitattributes has the merge driver configured
|
|
gitattributesPath := ".gitattributes"
|
|
content, err := os.ReadFile(gitattributesPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Look for beads JSONL merge attribute (either canonical or legacy filename)
|
|
hasCanonical := strings.Contains(string(content), ".beads/issues.jsonl") &&
|
|
strings.Contains(string(content), "merge=beads")
|
|
hasLegacy := strings.Contains(string(content), ".beads/beads.jsonl") &&
|
|
strings.Contains(string(content), "merge=beads")
|
|
return hasCanonical || hasLegacy
|
|
}
|
|
|
|
// installMergeDriver configures git to use bd merge for JSONL files
|
|
func installMergeDriver() error {
|
|
// Configure git merge driver
|
|
cmd := exec.Command("git", "config", "merge.beads.driver", "bd merge %A %O %A %B")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to configure git merge driver: %w\n%s", err, output)
|
|
}
|
|
|
|
cmd = exec.Command("git", "config", "merge.beads.name", "bd JSONL merge driver")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
// Non-fatal, the name is just descriptive
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to set merge driver name: %v\n%s", err, output)
|
|
}
|
|
|
|
// Create or update .gitattributes
|
|
gitattributesPath := ".gitattributes"
|
|
|
|
// Read existing .gitattributes if it exists
|
|
var existingContent string
|
|
content, err := os.ReadFile(gitattributesPath)
|
|
if err == nil {
|
|
existingContent = string(content)
|
|
}
|
|
|
|
// Check if beads merge driver is already configured
|
|
// Check for either pattern (issues.jsonl is canonical, beads.jsonl is legacy)
|
|
hasBeadsMerge := (strings.Contains(existingContent, ".beads/issues.jsonl") ||
|
|
strings.Contains(existingContent, ".beads/beads.jsonl")) &&
|
|
strings.Contains(existingContent, "merge=beads")
|
|
|
|
if !hasBeadsMerge {
|
|
// Append beads merge driver configuration (issues.jsonl is canonical)
|
|
beadsMergeAttr := "\n# Use bd merge for beads JSONL files\n.beads/issues.jsonl merge=beads\n"
|
|
|
|
newContent := existingContent
|
|
if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 {
|
|
newContent += "\n"
|
|
}
|
|
newContent += beadsMergeAttr
|
|
|
|
// Write updated .gitattributes (0644 is standard for .gitattributes)
|
|
// #nosec G306 - .gitattributes needs to be readable
|
|
if err := os.WriteFile(gitattributesPath, []byte(newContent), 0644); err != nil {
|
|
return fmt.Errorf("failed to update .gitattributes: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateOldDatabases detects and migrates old database files to beads.db
|
|
func migrateOldDatabases(targetPath string, quiet bool) error {
|
|
targetDir := filepath.Dir(targetPath)
|
|
targetName := filepath.Base(targetPath)
|
|
|
|
// If target already exists, no migration needed
|
|
if _, err := os.Stat(targetPath); err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Create .beads directory if it doesn't exist
|
|
if err := os.MkdirAll(targetDir, 0750); err != nil {
|
|
return fmt.Errorf("failed to create .beads directory: %w", err)
|
|
}
|
|
|
|
// Look for existing .db files in the .beads directory
|
|
pattern := filepath.Join(targetDir, "*.db")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to search for existing databases: %w", err)
|
|
}
|
|
|
|
// Filter out the target file name and any backup files
|
|
var oldDBs []string
|
|
for _, match := range matches {
|
|
baseName := filepath.Base(match)
|
|
if baseName != targetName && !strings.HasSuffix(baseName, ".backup.db") {
|
|
oldDBs = append(oldDBs, match)
|
|
}
|
|
}
|
|
|
|
if len(oldDBs) == 0 {
|
|
// No old databases to migrate
|
|
return nil
|
|
}
|
|
|
|
if len(oldDBs) > 1 {
|
|
// Multiple databases found - ambiguous, require manual intervention
|
|
return fmt.Errorf("multiple database files found in %s: %v\nPlease manually rename the correct database to %s and remove others",
|
|
targetDir, oldDBs, targetName)
|
|
}
|
|
|
|
// Migrate the single old database
|
|
oldDB := oldDBs[0]
|
|
if !quiet {
|
|
fmt.Fprintf(os.Stderr, "→ Migrating database: %s → %s\n", filepath.Base(oldDB), targetName)
|
|
}
|
|
|
|
// Rename the old database to the new canonical name
|
|
if err := os.Rename(oldDB, targetPath); err != nil {
|
|
return fmt.Errorf("failed to migrate database %s to %s: %w", oldDB, targetPath, err)
|
|
}
|
|
|
|
if !quiet {
|
|
fmt.Fprintf(os.Stderr, "✓ Database migration complete\n\n")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createConfigYaml creates the config.yaml template in the specified directory
|
|
func createConfigYaml(beadsDir string, noDbMode bool) error {
|
|
configYamlPath := filepath.Join(beadsDir, "config.yaml")
|
|
|
|
// Skip if already exists
|
|
if _, err := os.Stat(configYamlPath); err == nil {
|
|
return nil
|
|
}
|
|
|
|
noDbLine := "# no-db: false"
|
|
if noDbMode {
|
|
noDbLine = "no-db: true # JSONL-only mode, no SQLite database"
|
|
}
|
|
|
|
configYamlTemplate := fmt.Sprintf(`# Beads Configuration File
|
|
# This file configures default behavior for all bd commands in this repository
|
|
# All settings can also be set via environment variables (BD_* prefix)
|
|
# or overridden with command-line flags
|
|
|
|
# Issue prefix for this repository (used by bd init)
|
|
# If not set, bd init will auto-detect from directory name
|
|
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
|
|
# issue-prefix: ""
|
|
|
|
# Use no-db mode: load from JSONL, no SQLite, write back after each command
|
|
# When true, bd will use .beads/issues.jsonl as the source of truth
|
|
# instead of SQLite database
|
|
%s
|
|
|
|
# Disable daemon for RPC communication (forces direct database access)
|
|
# no-daemon: false
|
|
|
|
# Disable auto-flush of database to JSONL after mutations
|
|
# no-auto-flush: false
|
|
|
|
# Disable auto-import from JSONL when it's newer than database
|
|
# no-auto-import: false
|
|
|
|
# Enable JSON output by default
|
|
# json: false
|
|
|
|
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
|
|
# actor: ""
|
|
|
|
# Path to database (overridden by BEADS_DB or --db)
|
|
# db: ""
|
|
|
|
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
|
|
# auto-start-daemon: true
|
|
|
|
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
|
|
# flush-debounce: "5s"
|
|
|
|
# Git branch for beads commits (bd sync will commit to this branch)
|
|
# IMPORTANT: Set this for team projects so all clones use the same sync branch.
|
|
# This setting persists across clones (unlike database config which is gitignored).
|
|
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
|
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
|
|
# sync-branch: "beads-sync"
|
|
|
|
# Multi-repo configuration (experimental - bd-307)
|
|
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
|
# repos:
|
|
# primary: "." # Primary repo (where this database lives)
|
|
# additional: # Additional repos to hydrate from (read-only)
|
|
# - ~/beads-planning # Personal planning repo
|
|
# - ~/work-planning # Work planning repo
|
|
|
|
# Integration settings (access with 'bd config get/set')
|
|
# These are stored in the database, not in this file:
|
|
# - jira.url
|
|
# - jira.project
|
|
# - linear.url
|
|
# - linear.api-key
|
|
# - github.org
|
|
# - github.repo
|
|
`, noDbLine)
|
|
|
|
if err := os.WriteFile(configYamlPath, []byte(configYamlTemplate), 0600); err != nil {
|
|
return fmt.Errorf("failed to write config.yaml: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createReadme creates the README.md file in the .beads directory
|
|
func createReadme(beadsDir string) error {
|
|
readmePath := filepath.Join(beadsDir, "README.md")
|
|
|
|
// Skip if already exists
|
|
if _, err := os.Stat(readmePath); err == nil {
|
|
return nil
|
|
}
|
|
|
|
readmeTemplate := `# Beads - AI-Native Issue Tracking
|
|
|
|
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
|
|
|
|
## What is Beads?
|
|
|
|
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
|
|
|
|
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
|
|
|
|
## Quick Start
|
|
|
|
### Essential Commands
|
|
|
|
` + "```bash" + `
|
|
# Create new issues
|
|
bd create "Add user authentication"
|
|
|
|
# View all issues
|
|
bd list
|
|
|
|
# View issue details
|
|
bd show <issue-id>
|
|
|
|
# Update issue status
|
|
bd update <issue-id> --status in_progress
|
|
bd update <issue-id> --status done
|
|
|
|
# Sync with git remote
|
|
bd sync
|
|
` + "```" + `
|
|
|
|
### Working with Issues
|
|
|
|
Issues in Beads are:
|
|
- **Git-native**: Stored in ` + "`.beads/issues.jsonl`" + ` and synced like code
|
|
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
|
|
- **Branch-aware**: Issues can follow your branch workflow
|
|
- **Always in sync**: Auto-syncs with your commits
|
|
|
|
## Why Beads?
|
|
|
|
✨ **AI-Native Design**
|
|
- Built specifically for AI-assisted development workflows
|
|
- CLI-first interface works seamlessly with AI coding agents
|
|
- No context switching to web UIs
|
|
|
|
🚀 **Developer Focused**
|
|
- Issues live in your repo, right next to your code
|
|
- Works offline, syncs when you push
|
|
- Fast, lightweight, and stays out of your way
|
|
|
|
🔧 **Git Integration**
|
|
- Automatic sync with git commits
|
|
- Branch-aware issue tracking
|
|
- Intelligent JSONL merge resolution
|
|
|
|
## Get Started with Beads
|
|
|
|
Try Beads in your own projects:
|
|
|
|
` + "```bash" + `
|
|
# Install Beads
|
|
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
|
|
|
|
# Initialize in your repo
|
|
bd init
|
|
|
|
# Create your first issue
|
|
bd create "Try out Beads"
|
|
` + "```" + `
|
|
|
|
## Learn More
|
|
|
|
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
|
|
- **Quick Start Guide**: Run ` + "`bd quickstart`" + `
|
|
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
|
|
|
|
---
|
|
|
|
*Beads: Issue tracking that moves at the speed of thought* ⚡
|
|
`
|
|
|
|
// Write README.md (0644 is standard for markdown files)
|
|
// #nosec G306 - README needs to be readable
|
|
if err := os.WriteFile(readmePath, []byte(readmeTemplate), 0644); err != nil {
|
|
return fmt.Errorf("failed to write README.md: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// readFirstIssueFromJSONL reads the first issue from a JSONL file
|
|
func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
|
|
// #nosec G304 -- helper reads JSONL file chosen by current bd command
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open JSONL file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
|
|
// skip empty lines
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err == nil {
|
|
return &issue, nil
|
|
} else {
|
|
// Skip malformed lines with warning
|
|
fmt.Fprintf(os.Stderr, "Warning: skipping malformed JSONL line %d: %v\n", lineNum, err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("error reading JSONL file: %w", err)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// readFirstIssueFromGit reads the first issue from a git ref (bd-0is: supports sync-branch)
|
|
func readFirstIssueFromGit(jsonlPath, gitRef string) (*types.Issue, 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
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read from git: %w", err)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(output))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
// skip empty lines
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err == nil {
|
|
return &issue, nil
|
|
}
|
|
// Skip malformed lines silently (called during auto-detection)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("error scanning git content: %w", err)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// setupStealthMode configures git settings for stealth operation
|
|
// Uses .git/info/exclude (per-repository) instead of global gitignore because:
|
|
// - Global gitignore doesn't support absolute paths (GitHub #704)
|
|
// - .git/info/exclude is designed for user-specific, repo-local ignores
|
|
// - Patterns are relative to repo root, so ".beads/" works correctly
|
|
func setupStealthMode(verbose bool) error {
|
|
// Setup per-repository git exclude file
|
|
if err := setupGitExclude(verbose); err != nil {
|
|
return fmt.Errorf("failed to setup git exclude: %w", err)
|
|
}
|
|
|
|
// Setup claude settings
|
|
if err := setupClaudeSettings(verbose); err != nil {
|
|
return fmt.Errorf("failed to setup claude settings: %w", err)
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf("\n%s Stealth mode configured successfully!\n\n", ui.RenderPass("✓"))
|
|
fmt.Printf(" Git exclude: %s\n", ui.RenderAccent(".git/info/exclude configured"))
|
|
fmt.Printf(" Claude settings: %s\n\n", ui.RenderAccent("configured with bd onboard instruction"))
|
|
fmt.Printf("Your beads setup is now %s - other repo collaborators won't see any beads-related files.\n\n", ui.RenderAccent("invisible"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setupGitExclude configures .git/info/exclude to ignore beads and claude files
|
|
// This is the correct approach for per-repository user-specific ignores (GitHub #704).
|
|
// Unlike global gitignore, patterns here are relative to the repo root.
|
|
func setupGitExclude(verbose bool) error {
|
|
// Find the .git directory (handles both regular repos and worktrees)
|
|
gitDir, err := exec.Command("git", "rev-parse", "--git-dir").Output()
|
|
if err != nil {
|
|
return fmt.Errorf("not a git repository")
|
|
}
|
|
gitDirPath := strings.TrimSpace(string(gitDir))
|
|
|
|
// Path to the exclude file
|
|
excludePath := filepath.Join(gitDirPath, "info", "exclude")
|
|
|
|
// Ensure the info directory exists
|
|
infoDir := filepath.Join(gitDirPath, "info")
|
|
if err := os.MkdirAll(infoDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create git info directory: %w", err)
|
|
}
|
|
|
|
// Read existing exclude file if it exists
|
|
var existingContent string
|
|
// #nosec G304 - git config path
|
|
if content, err := os.ReadFile(excludePath); err == nil {
|
|
existingContent = string(content)
|
|
}
|
|
|
|
// Use relative patterns (these work correctly in .git/info/exclude)
|
|
beadsPattern := ".beads/"
|
|
claudePattern := ".claude/settings.local.json"
|
|
|
|
hasBeads := strings.Contains(existingContent, beadsPattern)
|
|
hasClaude := strings.Contains(existingContent, claudePattern)
|
|
|
|
if hasBeads && hasClaude {
|
|
if verbose {
|
|
fmt.Printf("Git exclude already configured for stealth mode\n")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Append missing patterns
|
|
newContent := existingContent
|
|
if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 {
|
|
newContent += "\n"
|
|
}
|
|
|
|
if !hasBeads || !hasClaude {
|
|
newContent += "\n# Beads stealth mode (added by bd init --stealth)\n"
|
|
}
|
|
|
|
if !hasBeads {
|
|
newContent += beadsPattern + "\n"
|
|
}
|
|
if !hasClaude {
|
|
newContent += claudePattern + "\n"
|
|
}
|
|
|
|
// Write the updated exclude file
|
|
// #nosec G306 - config file needs 0644
|
|
if err := os.WriteFile(excludePath, []byte(newContent), 0644); err != nil {
|
|
return fmt.Errorf("failed to write git exclude file: %w", err)
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf("Configured git exclude for stealth mode: %s\n", excludePath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setupGlobalGitIgnore configures global gitignore to ignore beads and claude files for a specific project
|
|
// DEPRECATED: This function uses absolute paths which don't work in gitignore (GitHub #704).
|
|
// Use setupGitExclude instead for new code.
|
|
func setupGlobalGitIgnore(homeDir string, projectPath string, verbose bool) error {
|
|
// Check if user already has a global gitignore file configured
|
|
cmd := exec.Command("git", "config", "--global", "core.excludesfile")
|
|
output, err := cmd.Output()
|
|
|
|
var ignorePath string
|
|
|
|
if err == nil && len(output) > 0 {
|
|
// User has already configured a global gitignore file, use it
|
|
ignorePath = strings.TrimSpace(string(output))
|
|
|
|
// Expand tilde if present (git config may return ~/... which Go doesn't expand)
|
|
if strings.HasPrefix(ignorePath, "~/") {
|
|
ignorePath = filepath.Join(homeDir, ignorePath[2:])
|
|
} else if ignorePath == "~" {
|
|
ignorePath = homeDir
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf("Using existing configured global gitignore file: %s\n", ignorePath)
|
|
}
|
|
} else {
|
|
// No global gitignore file configured, check if standard location exists
|
|
configDir := filepath.Join(homeDir, ".config", "git")
|
|
standardIgnorePath := filepath.Join(configDir, "ignore")
|
|
|
|
if _, err := os.Stat(standardIgnorePath); err == nil {
|
|
// Standard global gitignore file exists, use it
|
|
// No need to set git config - git automatically uses this standard location
|
|
ignorePath = standardIgnorePath
|
|
if verbose {
|
|
fmt.Printf("Using existing global gitignore file: %s\n", ignorePath)
|
|
}
|
|
} else {
|
|
// No global gitignore file exists, create one in standard location
|
|
// No need to set git config - git automatically uses this standard location
|
|
ignorePath = standardIgnorePath
|
|
|
|
// Ensure config directory exists
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create git config directory: %w", err)
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf("Creating new global gitignore file: %s\n", ignorePath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read existing ignore file if it exists
|
|
var existingContent string
|
|
// #nosec G304 - user config path
|
|
if content, err := os.ReadFile(ignorePath); err == nil {
|
|
existingContent = string(content)
|
|
}
|
|
|
|
// Use absolute paths for this specific project (fixes GitHub #538)
|
|
// This allows other projects to use beads openly while this one stays stealth
|
|
beadsPattern := projectPath + "/.beads/"
|
|
claudePattern := projectPath + "/.claude/settings.local.json"
|
|
|
|
hasBeads := strings.Contains(existingContent, beadsPattern)
|
|
hasClaude := strings.Contains(existingContent, claudePattern)
|
|
|
|
if hasBeads && hasClaude {
|
|
if verbose {
|
|
fmt.Printf("Global gitignore already configured for stealth mode in %s\n", projectPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Append missing patterns
|
|
newContent := existingContent
|
|
if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 {
|
|
newContent += "\n"
|
|
}
|
|
|
|
if !hasBeads || !hasClaude {
|
|
newContent += fmt.Sprintf("\n# Beads stealth mode: %s (added by bd init --stealth)\n", projectPath)
|
|
}
|
|
|
|
if !hasBeads {
|
|
newContent += beadsPattern + "\n"
|
|
}
|
|
if !hasClaude {
|
|
newContent += claudePattern + "\n"
|
|
}
|
|
|
|
// Write the updated ignore file
|
|
// #nosec G306 - config file needs 0644
|
|
if err := os.WriteFile(ignorePath, []byte(newContent), 0644); err != nil {
|
|
fmt.Printf("\nUnable to write to %s (file is read-only)\n\n", ignorePath)
|
|
fmt.Printf("To enable stealth mode, add these lines to your global gitignore:\n\n")
|
|
if !hasBeads || !hasClaude {
|
|
fmt.Printf("# Beads stealth mode: %s\n", projectPath)
|
|
}
|
|
if !hasBeads {
|
|
fmt.Printf("%s\n", beadsPattern)
|
|
}
|
|
if !hasClaude {
|
|
fmt.Printf("%s\n", claudePattern)
|
|
}
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf("Configured global gitignore for stealth mode in %s\n", projectPath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkExistingBeadsData checks for existing database files
|
|
// and returns an error if found (safety guard for bd-emg)
|
|
//
|
|
// Note: This only blocks when a database already exists (workspace is initialized).
|
|
// Fresh clones with JSONL but no database are allowed - init will create the database
|
|
// and import from JSONL automatically (bd-4h9: fixes circular dependency with doctor --fix).
|
|
//
|
|
// For worktrees, checks the main repository root instead of current directory
|
|
// since worktrees should share the database with the main repository.
|
|
func checkExistingBeadsData(prefix string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return nil // Can't determine CWD, allow init to proceed
|
|
}
|
|
|
|
// Determine where to check for .beads directory
|
|
var beadsDir string
|
|
if git.IsWorktree() {
|
|
// For worktrees, .beads should be in the main repository root
|
|
mainRepoRoot, err := git.GetMainRepoRoot()
|
|
if err != nil {
|
|
return nil // Can't determine main repo root, allow init to proceed
|
|
}
|
|
beadsDir = filepath.Join(mainRepoRoot, ".beads")
|
|
} else {
|
|
// For regular repos, check current directory
|
|
beadsDir = filepath.Join(cwd, ".beads")
|
|
}
|
|
|
|
// Check if .beads directory exists
|
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
|
return nil // No .beads directory, safe to init
|
|
}
|
|
|
|
// Check for existing database file
|
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
|
if _, err := os.Stat(dbPath); err == nil {
|
|
return fmt.Errorf(`
|
|
%s Found existing database: %s
|
|
|
|
This workspace is already initialized.
|
|
|
|
To use the existing database:
|
|
Just run bd commands normally (e.g., %s)
|
|
|
|
To completely reinitialize (data loss warning):
|
|
rm -rf .beads && bd init --prefix %s
|
|
|
|
Aborting.`, ui.RenderWarn("⚠"), dbPath, ui.RenderAccent("bd list"), prefix)
|
|
}
|
|
|
|
// Fresh clones (JSONL exists but no database) are allowed - init will
|
|
// create the database and import from JSONL automatically.
|
|
// This fixes the circular dependency where init told users to run
|
|
// "bd doctor --fix" but doctor couldn't create a database (bd-4h9).
|
|
|
|
return nil // No database found, safe to init
|
|
}
|
|
|
|
// landingThePlaneSection is the "landing the plane" instructions for AI agents
|
|
// This gets appended to AGENTS.md and @AGENTS.md during bd init
|
|
const landingThePlaneSection = `
|
|
## Landing the Plane (Session Completion)
|
|
|
|
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until ` + "`git push`" + ` succeeds.
|
|
|
|
**MANDATORY WORKFLOW:**
|
|
|
|
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
|
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
|
3. **Update issue status** - Close finished work, update in-progress items
|
|
4. **PUSH TO REMOTE** - This is MANDATORY:
|
|
` + "```bash" + `
|
|
git pull --rebase
|
|
bd sync
|
|
git push
|
|
git status # MUST show "up to date with origin"
|
|
` + "```" + `
|
|
5. **Clean up** - Clear stashes, prune remote branches
|
|
6. **Verify** - All changes committed AND pushed
|
|
7. **Hand off** - Provide context for next session
|
|
|
|
**CRITICAL RULES:**
|
|
- Work is NOT complete until ` + "`git push`" + ` succeeds
|
|
- NEVER stop before pushing - that leaves work stranded locally
|
|
- NEVER say "ready to push when you are" - YOU must push
|
|
- If push fails, resolve and retry until it succeeds
|
|
`
|
|
|
|
// addLandingThePlaneInstructions adds "landing the plane" instructions to AGENTS.md
|
|
func addLandingThePlaneInstructions(verbose bool) {
|
|
// File to update (AGENTS.md is the standard comprehensive documentation file)
|
|
agentFile := "AGENTS.md"
|
|
|
|
if err := updateAgentFile(agentFile, verbose); err != nil {
|
|
// Non-fatal - continue with other files
|
|
if verbose {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to update %s: %v\n", agentFile, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateAgentFile creates or updates an agent instructions file with landing the plane section
|
|
func updateAgentFile(filename string, verbose bool) error {
|
|
// Check if file exists
|
|
//nolint:gosec // G304: filename comes from hardcoded list in addLandingThePlaneInstructions
|
|
content, err := os.ReadFile(filename)
|
|
if os.IsNotExist(err) {
|
|
// File doesn't exist - create it with basic structure
|
|
newContent := fmt.Sprintf(`# Agent Instructions
|
|
|
|
This project uses **bd** (beads) for issue tracking. Run `+"`bd onboard`"+` to get started.
|
|
|
|
## Quick Reference
|
|
|
|
`+"```bash"+`
|
|
bd ready # Find available work
|
|
bd show <id> # View issue details
|
|
bd update <id> --status in_progress # Claim work
|
|
bd close <id> # Complete work
|
|
bd sync # Sync with git
|
|
`+"```"+`
|
|
%s
|
|
`, landingThePlaneSection)
|
|
|
|
// #nosec G306 - markdown needs to be readable
|
|
if err := os.WriteFile(filename, []byte(newContent), 0644); err != nil {
|
|
return fmt.Errorf("failed to create %s: %w", filename, err)
|
|
}
|
|
if verbose {
|
|
fmt.Printf(" %s Created %s with landing-the-plane instructions\n", ui.RenderPass("✓"), filename)
|
|
}
|
|
return nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("failed to read %s: %w", filename, err)
|
|
}
|
|
|
|
// File exists - check if it already has landing the plane section
|
|
if strings.Contains(string(content), "Landing the Plane") {
|
|
if verbose {
|
|
fmt.Printf(" %s already has landing-the-plane instructions\n", filename)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Append the landing the plane section
|
|
newContent := string(content)
|
|
if !strings.HasSuffix(newContent, "\n") {
|
|
newContent += "\n"
|
|
}
|
|
newContent += landingThePlaneSection
|
|
|
|
// #nosec G306 - markdown needs to be readable
|
|
if err := os.WriteFile(filename, []byte(newContent), 0644); err != nil {
|
|
return fmt.Errorf("failed to update %s: %w", filename, err)
|
|
}
|
|
if verbose {
|
|
fmt.Printf(" %s Added landing-the-plane instructions to %s\n", ui.RenderPass("✓"), filename)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// setupClaudeSettings creates or updates .claude/settings.local.json with onboard instruction
|
|
func setupClaudeSettings(verbose bool) error {
|
|
claudeDir := ".claude"
|
|
settingsPath := filepath.Join(claudeDir, "settings.local.json")
|
|
|
|
// Create .claude directory if it doesn't exist
|
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create .claude directory: %w", err)
|
|
}
|
|
|
|
// Check if settings.local.json already exists
|
|
var existingSettings map[string]interface{}
|
|
// #nosec G304 - user config path
|
|
if content, err := os.ReadFile(settingsPath); err == nil {
|
|
if err := json.Unmarshal(content, &existingSettings); err != nil {
|
|
// Don't silently overwrite - the user has a file with invalid JSON
|
|
// that likely contains important settings they don't want to lose
|
|
return fmt.Errorf("existing %s contains invalid JSON: %w\nPlease fix the JSON syntax manually before running bd init", settingsPath, err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
// File exists but couldn't be read (permissions issue, etc.)
|
|
return fmt.Errorf("failed to read existing %s: %w", settingsPath, err)
|
|
} else {
|
|
// File doesn't exist - create new empty settings
|
|
existingSettings = make(map[string]interface{})
|
|
}
|
|
|
|
// Add or update the prompt with onboard instruction
|
|
onboardPrompt := "Before starting any work, run 'bd onboard' to understand the current project state and available issues."
|
|
|
|
// Check if prompt already contains onboard instruction
|
|
if promptValue, exists := existingSettings["prompt"]; exists {
|
|
if promptStr, ok := promptValue.(string); ok {
|
|
if strings.Contains(promptStr, "bd onboard") {
|
|
if verbose {
|
|
fmt.Printf("Claude settings already configured with bd onboard instruction\n")
|
|
}
|
|
return nil
|
|
}
|
|
// Update existing prompt to include onboard instruction
|
|
existingSettings["prompt"] = promptStr + "\n\n" + onboardPrompt
|
|
} else {
|
|
// Existing prompt is not a string, replace it
|
|
existingSettings["prompt"] = onboardPrompt
|
|
}
|
|
} else {
|
|
// Add new prompt with onboard instruction
|
|
existingSettings["prompt"] = onboardPrompt
|
|
}
|
|
|
|
// Write updated settings
|
|
updatedContent, err := json.MarshalIndent(existingSettings, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal settings JSON: %w", err)
|
|
}
|
|
|
|
// #nosec G306 - config file needs 0644
|
|
if err := os.WriteFile(settingsPath, updatedContent, 0644); err != nil {
|
|
return fmt.Errorf("failed to write claude settings: %w", err)
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf("Configured Claude settings with bd onboard instruction\n")
|
|
}
|
|
|
|
return nil
|
|
}
|