fix(fork-protection): only apply protection to actual beads forks (#823) (#828)

The fork protection logic incorrectly treated all repos where
origin != steveyegge/beads as forks, including user's own projects
that just use beads as a tool.

Changes:
- Add isForkOfBeads() that scans ALL remotes for steveyegge/beads
- Only apply protection when a beads-related remote exists
- Add git config opt-out: `git config beads.fork-protection false`
  (per-clone, never tracked, matches beads.role pattern)

Test coverage for 8 scenarios plus edge cases for config values.
This commit is contained in:
Peter Chanthamynavong
2026-01-01 10:51:22 -08:00
committed by GitHub
parent 00d0eb0192
commit f3f713d77a
3 changed files with 268 additions and 5 deletions

View File

@@ -13,21 +13,36 @@ import (
// ensureForkProtection prevents contributors from accidentally committing
// the upstream issue database when working in a fork.
//
// When we detect this is a fork (origin != steveyegge/beads), we add
// .beads/issues.jsonl to .git/info/exclude so it won't be staged.
// 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
// 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
}
// Check if already excluded
excludePath := filepath.Join(gitRoot, ".git", "info", "exclude")
if isAlreadyExcluded(excludePath) {
@@ -69,6 +84,32 @@ func isUpstreamRepo(gitRoot string) bool {
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