Files
beads/cmd/bd/fork_protection_test.go
Peter Chanthamynavong f3f713d77a 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.
2026-01-01 10:51:22 -08:00

336 lines
10 KiB
Go

package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// setupGitRepoForForkTest creates a temporary git repository for testing
func setupGitRepoForForkTest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
// Create .beads directory
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads directory: %v", err)
}
// Initialize git repo
cmd := exec.Command("git", "init", "--initial-branch=main")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
// Configure git user
cmd = exec.Command("git", "config", "user.email", "test@test.com")
cmd.Dir = dir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = dir
_ = cmd.Run()
return dir
}
// addRemote adds a git remote to the test repo
func addRemote(t *testing.T, dir, name, url string) {
t.Helper()
cmd := exec.Command("git", "remote", "add", name, url)
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to add remote %s: %v", name, err)
}
}
// ============================================================================
// Test isUpstreamRepo (existing tests, updated)
// ============================================================================
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)
}
})
}
}
// Test 1: Upstream maintainer (origin = steveyegge/beads)
func TestIsUpstreamRepo_Maintainer(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "https://github.com/steveyegge/beads.git")
if !isUpstreamRepo(dir) {
t.Error("expected isUpstreamRepo to return true for steveyegge/beads")
}
}
// Test 1b: Upstream maintainer with SSH URL
func TestIsUpstreamRepo_MaintainerSSH(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "git@github.com:steveyegge/beads.git")
if !isUpstreamRepo(dir) {
t.Error("expected isUpstreamRepo to return true for SSH steveyegge/beads")
}
}
// Test isUpstreamRepo with non-beads origin
func TestIsUpstreamRepo_NotUpstream(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "https://github.com/peterkc/beads.git")
if isUpstreamRepo(dir) {
t.Error("expected isUpstreamRepo to return false for fork origin")
}
}
// Test isUpstreamRepo with no origin
func TestIsUpstreamRepo_NoOrigin(t *testing.T) {
dir := setupGitRepoForForkTest(t)
// Don't add origin remote
if isUpstreamRepo(dir) {
t.Error("expected isUpstreamRepo to return false when no origin exists")
}
}
// ============================================================================
// Test isForkOfBeads (new tests for GH#823)
// ============================================================================
// Test 2: Fork (standard) - origin=fork, upstream=beads
func TestIsForkOfBeads_StandardFork(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "https://github.com/peterkc/beads.git")
addRemote(t, dir, "upstream", "https://github.com/steveyegge/beads.git")
if !isForkOfBeads(dir) {
t.Error("expected isForkOfBeads to return true for standard fork setup")
}
}
// Test 3: Fork (custom naming) - origin=fork, github=beads
func TestIsForkOfBeads_CustomNaming(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "https://github.com/peterkc/beads.git")
addRemote(t, dir, "github", "https://github.com/steveyegge/beads.git")
if !isForkOfBeads(dir) {
t.Error("expected isForkOfBeads to return true for custom remote naming")
}
}
// Test 4: User's own project (no beads remote) - THE BUG CASE
func TestIsForkOfBeads_UserProject(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "https://github.com/mycompany/myapp.git")
if isForkOfBeads(dir) {
t.Error("expected isForkOfBeads to return false for user's own project")
}
}
// Test 5: User's project with unrelated upstream - THE BUG CASE
func TestIsForkOfBeads_UserProjectWithUpstream(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "https://github.com/mycompany/myapp.git")
addRemote(t, dir, "upstream", "https://github.com/other/repo.git")
if isForkOfBeads(dir) {
t.Error("expected isForkOfBeads to return false for user's project with unrelated upstream")
}
}
// Test 6: No remotes
func TestIsForkOfBeads_NoRemotes(t *testing.T) {
dir := setupGitRepoForForkTest(t)
// Don't add any remotes
if isForkOfBeads(dir) {
t.Error("expected isForkOfBeads to return false when no remotes exist")
}
}
// Test SSH URL detection
func TestIsForkOfBeads_SSHRemote(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "git@github.com:peterkc/beads.git")
addRemote(t, dir, "upstream", "git@github.com:steveyegge/beads.git")
if !isForkOfBeads(dir) {
t.Error("expected isForkOfBeads to return true for SSH upstream")
}
}
// ============================================================================
// Test isAlreadyExcluded (existing tests)
// ============================================================================
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")
}
}
// ============================================================================
// Test addToExclude (existing tests)
// ============================================================================
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)
}
}
// ============================================================================
// Test isForkProtectionDisabled (git config opt-out)
// ============================================================================
// Test isForkProtectionDisabled with various git config values
func TestIsForkProtectionDisabled(t *testing.T) {
tests := []struct {
name string
config string // value to set, empty = don't set
expected bool
}{
{"not set", "", false},
{"set to false", "false", true},
{"set to true", "true", false},
{"set to other", "disabled", false}, // only "false" disables
{"set to FALSE", "FALSE", false}, // case-sensitive
{"set to 0", "0", false}, // only "false" disables
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := setupGitRepoForForkTest(t)
if tt.config != "" {
cmd := exec.Command("git", "-C", dir, "config", "beads.fork-protection", tt.config)
if err := cmd.Run(); err != nil {
t.Fatalf("failed to set git config: %v", err)
}
}
result := isForkProtectionDisabled(dir)
if result != tt.expected {
t.Errorf("isForkProtectionDisabled() = %v, want %v (config=%q)", result, tt.expected, tt.config)
}
})
}
}
// Test 8: Config opt-out via git config (replaces YAML config)
func TestConfigOptOut_GitConfig(t *testing.T) {
dir := setupGitRepoForForkTest(t)
addRemote(t, dir, "origin", "https://github.com/peterkc/beads.git")
addRemote(t, dir, "upstream", "https://github.com/steveyegge/beads.git")
// Verify this IS a fork of beads
if !isForkOfBeads(dir) {
t.Fatal("expected isForkOfBeads to return true for test setup")
}
// Set git config opt-out
cmd := exec.Command("git", "-C", dir, "config", "beads.fork-protection", "false")
if err := cmd.Run(); err != nil {
t.Fatalf("failed to set git config: %v", err)
}
// Verify opt-out is detected
if !isForkProtectionDisabled(dir) {
t.Error("expected isForkProtectionDisabled to return true after setting git config")
}
}