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)
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user