refactor: Split init.go from 1928 to 705 lines (bd-xtf5)

Extract init.go into multiple focused files:

- init_git_hooks.go: Git hooks installation and merge driver setup (~480 lines)
- init_stealth.go: Stealth mode and fork/exclude configuration (~310 lines)
- init_agent.go: AGENTS.md and Claude settings setup (~170 lines)
- init_templates.go: config.yaml and README.md templates (~180 lines)

This reduces init.go from 1928 lines to 705 lines, meeting the <800 line target.
Each extracted file handles a cohesive set of functionality.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-28 17:27:31 -08:00
parent f7dd2eaa8c
commit 7ea7aee411
5 changed files with 1173 additions and 1223 deletions

File diff suppressed because it is too large Load Diff

182
cmd/bd/init_agent.go Normal file
View File

@@ -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 <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
}

476
cmd/bd/init_git_hooks.go Normal file
View File

@@ -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
}

322
cmd/bd/init_stealth.go Normal file
View File

@@ -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
}

193
cmd/bd/init_templates.go Normal file
View File

@@ -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 <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
}