From 5c3a40156c64633f13ea4b033f24fa5cd68f0522 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 21:38:05 -0800 Subject: [PATCH] feat(init): Auto-detect fork repos and offer to configure .git/info/exclude (GH#742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/bd/init.go | 116 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 42dc6894..5f08ebb8 100644 --- a/cmd/bd/init.go +++ b/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.