Files
beads/cmd/bd/init_stealth.go
Steve Yegge 7ea7aee411 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>
2025-12-28 18:43:09 -08:00

323 lines
10 KiB
Go

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
}