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:
116
cmd/bd/init.go
116
cmd/bd/init.go
@@ -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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Install by default unless --skip-hooks is passed
|
||||
if !skipHooks && isGitRepo() && !hooksInstalled() {
|
||||
@@ -491,6 +509,7 @@ func init() {
|
||||
initCmd.Flags().Bool("contributor", false, "Run OSS contributor 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("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-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)")
|
||||
@@ -1463,6 +1482,103 @@ func setupGitExclude(verbose bool) error {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user