diff --git a/cmd/bd/init.go b/cmd/bd/init.go index aa3ac1e1..f6bb5082 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -6,10 +6,8 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "strings" - "time" "github.com/spf13/cobra" "github.com/steveyegge/beads/cmd/bd/doctor" @@ -516,562 +514,6 @@ func init() { 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) @@ -1132,191 +574,6 @@ func migrateOldDatabases(targetPath string, quiet bool) error { 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 '. -# 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 - -# Update issue status -bd update --status in_progress -bd update --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) { @@ -1385,315 +642,6 @@ func readFirstIssueFromGit(jsonlPath, gitRef string) (*types.Issue, error) { 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 -} - -// setupForkExclude configures .git/info/exclude for fork workflows (GH#742) -// Adds beads files and Claude artifacts to keep PRs to upstream clean. -// This is separate from stealth mode - fork protection is specifically about -// preventing beads/Claude files from appearing in upstream PRs. -func setupForkExclude(verbose bool) error { - 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)) - excludePath := filepath.Join(gitDirPath, "info", "exclude") - - // Ensure info directory exists - if err := os.MkdirAll(filepath.Join(gitDirPath, "info"), 0755); err != nil { - return fmt.Errorf("failed to create git info directory: %w", err) - } - - // Read existing content - var existingContent string - // #nosec G304 - git config path - if content, err := os.ReadFile(excludePath); err == nil { - existingContent = string(content) - } - - // Patterns to add for fork protection - patterns := []string{".beads/", "**/RECOVERY*.md", "**/SESSION*.md"} - var toAdd []string - for _, p := range patterns { - // Check for exact line match (pattern alone on a line) - // This avoids false positives like ".beads/issues.jsonl" matching ".beads/" - if !containsExactPattern(existingContent, p) { - toAdd = append(toAdd, p) - } - } - - if len(toAdd) == 0 { - if verbose { - fmt.Printf("%s Git exclude already configured\n", ui.RenderPass("✓")) - } - return nil - } - - // Append patterns - newContent := existingContent - if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 { - newContent += "\n" - } - newContent += "\n# Beads fork protection (bd init)\n" - for _, p := range toAdd { - newContent += p + "\n" - } - - // #nosec G306 - config file needs 0644 - if err := os.WriteFile(excludePath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write git exclude: %w", err) - } - - if verbose { - fmt.Printf("\n%s Added to .git/info/exclude:\n", ui.RenderPass("✓")) - for _, p := range toAdd { - fmt.Printf(" %s\n", p) - } - fmt.Println("\nNote: .git/info/exclude is local-only and won't affect upstream.") - } - return nil -} - -// containsExactPattern checks if content contains the pattern as an exact line -// This avoids false positives like ".beads/issues.jsonl" matching ".beads/" -func containsExactPattern(content, pattern string) bool { - for _, line := range strings.Split(content, "\n") { - if strings.TrimSpace(line) == pattern { - return true - } - } - return false -} - -// promptForkExclude asks if user wants to configure .git/info/exclude for fork workflow (GH#742) -func promptForkExclude(upstreamURL string, quiet bool) bool { - if quiet { - return false // Don't prompt in quiet mode - } - - fmt.Printf("\n%s Detected fork (upstream: %s)\n\n", ui.RenderAccent("▶"), upstreamURL) - fmt.Println("Would you like to configure .git/info/exclude to keep beads files local?") - fmt.Println("This prevents beads from appearing in PRs to upstream.") - fmt.Print("\n[Y/n]: ") - - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(strings.ToLower(response)) - - // Default to yes (empty or "y" or "yes") - return response == "" || response == "y" || response == "yes" -} - -// 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) @@ -1755,174 +703,3 @@ Aborting.`, ui.RenderWarn("⚠"), dbPath, ui.RenderAccent("bd list"), prefix) 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 # View issue details -bd update --status in_progress # Claim work -bd close # 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 -} diff --git a/cmd/bd/init_agent.go b/cmd/bd/init_agent.go new file mode 100644 index 00000000..a74d10a1 --- /dev/null +++ b/cmd/bd/init_agent.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/steveyegge/beads/internal/ui" +) + +// 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 # View issue details +bd update --status in_progress # Claim work +bd close # 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 +} diff --git a/cmd/bd/init_git_hooks.go b/cmd/bd/init_git_hooks.go new file mode 100644 index 00000000..d4d1eb77 --- /dev/null +++ b/cmd/bd/init_git_hooks.go @@ -0,0 +1,476 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/steveyegge/beads/internal/git" + "github.com/steveyegge/beads/internal/ui" +) + +// 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") + preCommitContent := buildPreCommitHook(chainHooks, existingHooks) + + // post-merge hook + postMergePath := filepath.Join(hooksDir, "post-merge") + postMergeContent := buildPostMergeHook(chainHooks, existingHooks) + + // 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 +} + +// buildPreCommitHook generates the pre-commit hook content +func buildPreCommitHook(chainHooks bool, existingHooks []hookInfo) string { + if chainHooks { + // Find existing pre-commit hook + var existingPreCommit string + for _, hook := range existingHooks { + if hook.name == "pre-commit" && hook.exists && !hook.isBdHook { + existingPreCommit = hook.path + ".old" + // Note: caller handles the rename + break + } + } + + return `#!/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 + +` + preCommitHookBody() + } + + return `#!/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. + +` + preCommitHookBody() +} + +// preCommitHookBody returns the common pre-commit hook logic +func preCommitHookBody() string { + return `# 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 +` +} + +// buildPostMergeHook generates the post-merge hook content +func buildPostMergeHook(chainHooks bool, existingHooks []hookInfo) string { + if chainHooks { + // Find existing post-merge hook + var existingPostMerge string + for _, hook := range existingHooks { + if hook.name == "post-merge" && hook.exists && !hook.isBdHook { + existingPostMerge = hook.path + ".old" + break + } + } + + return `#!/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 + +` + postMergeHookBody() + } + + return `#!/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. + +` + postMergeHookBody() +} + +// postMergeHookBody returns the common post-merge hook logic +func postMergeHookBody() string { + return `# 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 +` +} + +// 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 +} diff --git a/cmd/bd/init_stealth.go b/cmd/bd/init_stealth.go new file mode 100644 index 00000000..ece57b6e --- /dev/null +++ b/cmd/bd/init_stealth.go @@ -0,0 +1,322 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/steveyegge/beads/internal/ui" +) + +// 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 +} + +// setupForkExclude configures .git/info/exclude for fork workflows (GH#742) +// Adds beads files and Claude artifacts to keep PRs to upstream clean. +// This is separate from stealth mode - fork protection is specifically about +// preventing beads/Claude files from appearing in upstream PRs. +func setupForkExclude(verbose bool) error { + 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)) + excludePath := filepath.Join(gitDirPath, "info", "exclude") + + // Ensure info directory exists + if err := os.MkdirAll(filepath.Join(gitDirPath, "info"), 0755); err != nil { + return fmt.Errorf("failed to create git info directory: %w", err) + } + + // Read existing content + var existingContent string + // #nosec G304 - git config path + if content, err := os.ReadFile(excludePath); err == nil { + existingContent = string(content) + } + + // Patterns to add for fork protection + patterns := []string{".beads/", "**/RECOVERY*.md", "**/SESSION*.md"} + var toAdd []string + for _, p := range patterns { + // Check for exact line match (pattern alone on a line) + // This avoids false positives like ".beads/issues.jsonl" matching ".beads/" + if !containsExactPattern(existingContent, p) { + toAdd = append(toAdd, p) + } + } + + if len(toAdd) == 0 { + if verbose { + fmt.Printf("%s Git exclude already configured\n", ui.RenderPass("✓")) + } + return nil + } + + // Append patterns + newContent := existingContent + if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 { + newContent += "\n" + } + newContent += "\n# Beads fork protection (bd init)\n" + for _, p := range toAdd { + newContent += p + "\n" + } + + // #nosec G306 - config file needs 0644 + if err := os.WriteFile(excludePath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write git exclude: %w", err) + } + + if verbose { + fmt.Printf("\n%s Added to .git/info/exclude:\n", ui.RenderPass("✓")) + for _, p := range toAdd { + fmt.Printf(" %s\n", p) + } + fmt.Println("\nNote: .git/info/exclude is local-only and won't affect upstream.") + } + return nil +} + +// containsExactPattern checks if content contains the pattern as an exact line +// This avoids false positives like ".beads/issues.jsonl" matching ".beads/" +func containsExactPattern(content, pattern string) bool { + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) == pattern { + return true + } + } + return false +} + +// promptForkExclude asks if user wants to configure .git/info/exclude for fork workflow (GH#742) +func promptForkExclude(upstreamURL string, quiet bool) bool { + if quiet { + return false // Don't prompt in quiet mode + } + + fmt.Printf("\n%s Detected fork (upstream: %s)\n\n", ui.RenderAccent("▶"), upstreamURL) + fmt.Println("Would you like to configure .git/info/exclude to keep beads files local?") + fmt.Println("This prevents beads from appearing in PRs to upstream.") + fmt.Print("\n[Y/n]: ") + + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + + // Default to yes (empty or "y" or "yes") + return response == "" || response == "y" || response == "yes" +} + +// 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 +} diff --git a/cmd/bd/init_templates.go b/cmd/bd/init_templates.go new file mode 100644 index 00000000..a9231142 --- /dev/null +++ b/cmd/bd/init_templates.go @@ -0,0 +1,193 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +// 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 '. +# 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 + +# Update issue status +bd update --status in_progress +bd update --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 +}