diff --git a/cmd/bd/fork_protection.go b/cmd/bd/fork_protection.go new file mode 100644 index 00000000..5c5c953a --- /dev/null +++ b/cmd/bd/fork_protection.go @@ -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 +} diff --git a/cmd/bd/fork_protection_test.go b/cmd/bd/fork_protection_test.go new file mode 100644 index 00000000..a1ed9e27 --- /dev/null +++ b/cmd/bd/fork_protection_test.go @@ -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) + } +} diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 8446d587..678d101a 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -195,6 +195,9 @@ var rootCmd = &cobra.Command{ actor = config.GetString("actor") } + // Protect forks from accidentally committing upstream issue database + ensureForkProtection() + // Performance profiling setup // When --profile is enabled, force direct mode to capture actual database operations // rather than just RPC serialization/network overhead. This gives accurate profiles