Files
beads/cmd/bd/fork_protection.go
beads/crew/emma 363c5a3819 fix: bd sync fails with exit 128 in bare repo worktrees (GH#827)
Two fixes for bare repo worktree setups:

1. fork_protection.go: Use git.GetGitDir() instead of hardcoding .git
   In worktrees, .git is a file containing gitdir path, not a directory.
   Using GetGitDir() handles this correctly.

2. sync_git.go: Simplify gitHasBeadsChanges to use absolute paths
   The previous code used git -C main-repo-root status, but in bare
   repo worktrees GetMainRepoRoot() returns the parent of the bare repo
   which is not a valid working tree. Using absolute paths without -C
   lets git find the repo from cwd, which always works.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:05:05 -08:00

149 lines
4.6 KiB
Go

package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/git"
)
// ensureForkProtection prevents contributors from accidentally committing
// the upstream issue database when working in a fork.
//
// When we detect this is a fork (any remote points to steveyegge/beads),
// we add .beads/issues.jsonl to .git/info/exclude so it won't be staged.
// This is a per-clone setting that doesn't modify tracked files.
//
// Users can disable this with: git config beads.fork-protection false
func ensureForkProtection() {
// Find git root first (needed for git config check)
gitRoot := git.GetRepoRoot()
if gitRoot == "" {
return // Not in a git repo
}
// Check if fork protection is explicitly disabled via git config (GH#823)
// Use: git config beads.fork-protection false
if isForkProtectionDisabled(gitRoot) {
debug.Printf("fork protection: disabled via git config")
return
}
// Check if this is the upstream repo (maintainers)
if isUpstreamRepo(gitRoot) {
return // Maintainers can commit issues.jsonl
}
// Only protect actual forks - repos with any remote pointing to beads (GH#823)
// This prevents false positives on user's own projects that just use beads
if !isForkOfBeads(gitRoot) {
return // Not a fork of beads, user's own project
}
// Get actual git directory (handles worktrees where .git is a file) (GH#827)
gitDir, err := git.GetGitDir()
if err != nil {
debug.Printf("fork protection: failed to get git dir: %v", err)
return
}
// Check if already excluded
excludePath := filepath.Join(gitDir, "info", "exclude")
if isAlreadyExcluded(excludePath) {
return
}
// Add to .git/info/exclude
if err := addToExclude(excludePath); err != nil {
debug.Printf("fork protection: failed to update exclude: %v", err)
return
}
debug.Printf("Fork detected: .beads/issues.jsonl excluded from git staging")
}
// isUpstreamRepo checks if origin remote points to the upstream beads repo
func isUpstreamRepo(gitRoot string) bool {
cmd := exec.Command("git", "-C", gitRoot, "remote", "get-url", "origin")
out, err := cmd.Output()
if err != nil {
return false // Can't determine, assume fork for safety
}
remote := strings.TrimSpace(string(out))
// Check for upstream repo patterns
upstreamPatterns := []string{
"steveyegge/beads",
"git@github.com:steveyegge/beads",
"https://github.com/steveyegge/beads",
}
for _, pattern := range upstreamPatterns {
if strings.Contains(remote, pattern) {
return true
}
}
return false
}
// isForkOfBeads checks if ANY remote points to steveyegge/beads.
// This handles any remote naming convention (origin, upstream, github, etc.)
// and correctly identifies actual beads forks vs user's own projects. (GH#823)
func isForkOfBeads(gitRoot string) bool {
cmd := exec.Command("git", "-C", gitRoot, "remote", "-v")
out, err := cmd.Output()
if err != nil {
return false // No remotes or git error - not a fork
}
// If any remote URL contains steveyegge/beads, this is a beads-related repo
return strings.Contains(string(out), "steveyegge/beads")
}
// isForkProtectionDisabled checks if fork protection is disabled via git config.
// Users can opt out with: git config beads.fork-protection false
// Only exact "false" disables; any other value or unset means enabled.
func isForkProtectionDisabled(gitRoot string) bool {
cmd := exec.Command("git", "-C", gitRoot, "config", "--get", "beads.fork-protection")
out, err := cmd.Output()
if err != nil {
return false // Not set or error - default to enabled
}
return strings.TrimSpace(string(out)) == "false"
}
// isAlreadyExcluded checks if issues.jsonl is already in the exclude file
func isAlreadyExcluded(excludePath string) bool {
content, err := os.ReadFile(excludePath) //nolint:gosec // G304: path is constructed from git root, not user input
if err != nil {
return false // File doesn't exist or can't read, not excluded
}
return strings.Contains(string(content), ".beads/issues.jsonl")
}
// addToExclude adds the issues.jsonl pattern to .git/info/exclude
func addToExclude(excludePath string) error {
// Ensure the directory exists
dir := filepath.Dir(excludePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// Open for append (create if doesn't exist)
f, err := os.OpenFile(excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec // G302: .git/info/exclude should be world-readable
if err != nil {
return err
}
defer f.Close()
// Add our exclusion with a comment
_, err = f.WriteString("\n# Beads: prevent fork from committing upstream issue database\n.beads/issues.jsonl\n")
return err
}