feat: auto-protect forks from committing upstream issue database
When bd detects it's running in a fork (origin != steveyegge/beads), automatically add .beads/issues.jsonl to .git/info/exclude. This prevents contributors from accidentally including issue database changes in their PRs. The exclusion is: - Per-clone (doesn't modify tracked files - One-time setup (checks if already excluded) - Silent (only logs in debug mode) Maintainers (origin = steveyegge/beads) are not affected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> EOF )
This commit is contained in:
99
cmd/bd/fork_protection.go
Normal file
99
cmd/bd/fork_protection.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// This is a per-clone setting that doesn't modify tracked files.
|
||||||
|
func ensureForkProtection() {
|
||||||
|
// Find git root (reuses existing findGitRoot from autoimport.go)
|
||||||
|
gitRoot := findGitRoot()
|
||||||
|
if gitRoot == "" {
|
||||||
|
return // Not in a git repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the upstream repo (maintainers)
|
||||||
|
if isUpstreamRepo(gitRoot) {
|
||||||
|
return // Maintainers can commit issues.jsonl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already excluded
|
||||||
|
excludePath := filepath.Join(gitRoot, ".git", "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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
113
cmd/bd/fork_protection_test.go
Normal file
113
cmd/bd/fork_protection_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsUpstreamRepo(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remote string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"ssh upstream", "git@github.com:steveyegge/beads.git", true},
|
||||||
|
{"https upstream", "https://github.com/steveyegge/beads.git", true},
|
||||||
|
{"https upstream no .git", "https://github.com/steveyegge/beads", true},
|
||||||
|
{"fork ssh", "git@github.com:contributor/beads.git", false},
|
||||||
|
{"fork https", "https://github.com/contributor/beads.git", false},
|
||||||
|
{"different repo", "git@github.com:someone/other-project.git", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Verify the pattern matching logic matches what isUpstreamRepo uses
|
||||||
|
upstreamPatterns := []string{
|
||||||
|
"steveyegge/beads",
|
||||||
|
"git@github.com:steveyegge/beads",
|
||||||
|
"https://github.com/steveyegge/beads",
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := false
|
||||||
|
for _, pattern := range upstreamPatterns {
|
||||||
|
if strings.Contains(tt.remote, pattern) {
|
||||||
|
matches = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches != tt.expected {
|
||||||
|
t.Errorf("remote %q: expected upstream=%v, got %v", tt.remote, tt.expected, matches)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAlreadyExcluded(t *testing.T) {
|
||||||
|
// Create temp file with exclusion
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
excludePath := filepath.Join(tmpDir, "exclude")
|
||||||
|
|
||||||
|
// Test non-existent file
|
||||||
|
if isAlreadyExcluded(excludePath) {
|
||||||
|
t.Error("expected non-existent file to return false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test file without exclusion
|
||||||
|
if err := os.WriteFile(excludePath, []byte("*.log\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if isAlreadyExcluded(excludePath) {
|
||||||
|
t.Error("expected file without exclusion to return false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test file with exclusion
|
||||||
|
if err := os.WriteFile(excludePath, []byte("*.log\n.beads/issues.jsonl\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !isAlreadyExcluded(excludePath) {
|
||||||
|
t.Error("expected file with exclusion to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddToExclude(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
infoDir := filepath.Join(tmpDir, ".git", "info")
|
||||||
|
excludePath := filepath.Join(infoDir, "exclude")
|
||||||
|
|
||||||
|
// Test creating new file
|
||||||
|
if err := addToExclude(excludePath); err != nil {
|
||||||
|
t.Fatalf("addToExclude failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(excludePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read exclude file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(content), ".beads/issues.jsonl") {
|
||||||
|
t.Errorf("exclude file missing .beads/issues.jsonl: %s", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test appending to existing file
|
||||||
|
if err := os.WriteFile(excludePath, []byte("*.log\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := addToExclude(excludePath); err != nil {
|
||||||
|
t.Fatalf("addToExclude append failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err = os.ReadFile(excludePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read exclude file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(content), "*.log") {
|
||||||
|
t.Errorf("exclude file missing original content: %s", content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), ".beads/issues.jsonl") {
|
||||||
|
t.Errorf("exclude file missing .beads/issues.jsonl: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,9 @@ var rootCmd = &cobra.Command{
|
|||||||
actor = config.GetString("actor")
|
actor = config.GetString("actor")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protect forks from accidentally committing upstream issue database
|
||||||
|
ensureForkProtection()
|
||||||
|
|
||||||
// Performance profiling setup
|
// Performance profiling setup
|
||||||
// When --profile is enabled, force direct mode to capture actual database operations
|
// When --profile is enabled, force direct mode to capture actual database operations
|
||||||
// rather than just RPC serialization/network overhead. This gives accurate profiles
|
// rather than just RPC serialization/network overhead. This gives accurate profiles
|
||||||
|
|||||||
Reference in New Issue
Block a user