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:
322
cmd/bd/init_stealth.go
Normal file
322
cmd/bd/init_stealth.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user