feat(init): Auto-detect fork repos and offer to configure .git/info/exclude (GH#742)

When bd init runs in a forked repo (detected via upstream remote), prompt
the user to configure .git/info/exclude to keep beads files local.

- Add --setup-exclude flag for manual trigger
- Add fork detection to init flow (reuses detectForkSetup)
- Add setupForkExclude() to configure .git/info/exclude with:
  .beads/, **/RECOVERY*.md, **/SESSION*.md
- Add promptForkExclude() with Y/n prompt (default yes)
- Add containsExactPattern() helper for precise pattern matching

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-25 21:38:05 -08:00
parent bd5fc214c3
commit 5c3a40156c

View File

@@ -426,6 +426,24 @@ With --stealth: configures per-repository git settings for invisible beads usage
fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
} }
// Fork detection: offer to configure .git/info/exclude (GH#742)
setupExclude, _ := cmd.Flags().GetBool("setup-exclude")
if setupExclude {
// Manual flag - always configure
if err := setupForkExclude(!quiet); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to configure git exclude: %v\n", err)
}
} else if !stealth && isGitRepo() {
// Auto-detect fork and prompt (skip if stealth - it handles exclude already)
if isFork, upstreamURL := detectForkSetup(); isFork {
if promptForkExclude(upstreamURL, quiet) {
if err := setupForkExclude(!quiet); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to configure git exclude: %v\n", err)
}
}
}
}
// Check if we're in a git repo and hooks aren't installed // Check if we're in a git repo and hooks aren't installed
// Install by default unless --skip-hooks is passed // Install by default unless --skip-hooks is passed
if !skipHooks && isGitRepo() && !hooksInstalled() { if !skipHooks && isGitRepo() && !hooksInstalled() {
@@ -491,6 +509,7 @@ func init() {
initCmd.Flags().Bool("contributor", false, "Run OSS contributor setup wizard") initCmd.Flags().Bool("contributor", false, "Run OSS contributor setup wizard")
initCmd.Flags().Bool("team", false, "Run team workflow setup wizard") initCmd.Flags().Bool("team", false, "Run team workflow setup wizard")
initCmd.Flags().Bool("stealth", false, "Enable stealth mode: global gitattributes and gitignore, no local repo tracking") initCmd.Flags().Bool("stealth", false, "Enable stealth mode: global gitattributes and gitignore, no local repo tracking")
initCmd.Flags().Bool("setup-exclude", false, "Configure .git/info/exclude to keep beads files local (for forks)")
initCmd.Flags().Bool("skip-hooks", false, "Skip git hooks installation") initCmd.Flags().Bool("skip-hooks", false, "Skip git hooks installation")
initCmd.Flags().Bool("skip-merge-driver", false, "Skip git merge driver setup") initCmd.Flags().Bool("skip-merge-driver", false, "Skip git merge driver setup")
initCmd.Flags().Bool("force", false, "Force re-initialization even if JSONL already has issues (may cause data loss)") initCmd.Flags().Bool("force", false, "Force re-initialization even if JSONL already has issues (may cause data loss)")
@@ -1463,6 +1482,103 @@ func setupGitExclude(verbose bool) error {
return nil 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 // 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). // DEPRECATED: This function uses absolute paths which don't work in gitignore (GitHub #704).
// Use setupGitExclude instead for new code. // Use setupGitExclude instead for new code.