From c98f5827bf015ee496a58a49f2897d1646e2def7 Mon Sep 17 00:00:00 2001 From: Peter Chanthamynavong Date: Tue, 30 Dec 2025 18:00:50 -0800 Subject: [PATCH] fix(doctor): add redirect to .gitignore template for worktree support (#813) When bd worktree creates a worktree, it writes a .beads/redirect file pointing back to the main repo's .beads/. If this file is accidentally committed (e.g., via git add .), it causes "redirect target does not exist" warnings when cloned or used in other worktrees. Changes: - Add 'redirect' to GitignoreTemplate to prevent future accidental commits - Add 'redirect' to requiredPatterns so bd doctor detects outdated .gitignore - Add CheckRedirectNotTracked() to detect already-tracked redirect files - Add FixRedirectTracking() to untrack accidentally committed redirects Tests: 8 new tests covering template, detection, and fix scenarios --- cmd/bd/doctor/gitignore.go | 77 ++++++++ cmd/bd/doctor/gitignore_test.go | 341 ++++++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) diff --git a/cmd/bd/doctor/gitignore.go b/cmd/bd/doctor/gitignore.go index 0070e939..873d2828 100644 --- a/cmd/bd/doctor/gitignore.go +++ b/cmd/bd/doctor/gitignore.go @@ -26,6 +26,9 @@ last-touched # Local version tracking (prevents upgrade notification spam after git ops) .local_version +# Worktree redirect file (created by bd worktree, points to main repo's .beads) +redirect + # Legacy database files db.sqlite bd.db @@ -54,6 +57,7 @@ var requiredPatterns = []string{ "beads.left.meta.json", "beads.right.meta.json", "*.db?*", + "redirect", // worktree redirect files should never be committed } // CheckGitignore checks if .beads/.gitignore is up to date @@ -123,6 +127,79 @@ func FixGitignore() error { return nil } +// CheckRedirectNotTracked verifies that .beads/redirect is NOT tracked by git. +// Redirect files are created by bd worktree and should never be committed. +// If accidentally committed (e.g., via git add .), they cause "redirect target does not exist" +// warnings in other clones. +func CheckRedirectNotTracked() DoctorCheck { + redirectPath := filepath.Join(".beads", "redirect") + + // Check if file exists + if _, err := os.Stat(redirectPath); os.IsNotExist(err) { + // File doesn't exist - nothing to check + return DoctorCheck{ + Name: "Redirect Not Tracked", + Status: StatusOK, + Message: "No redirect file (not a worktree)", + } + } + + // Check if git tracks this file + // git ls-files exits 0 and outputs filename if tracked, empty if not + cmd := exec.Command("git", "ls-files", redirectPath) + output, err := cmd.Output() + if err != nil { + // Not in a git repo or other error - skip + return DoctorCheck{ + Name: "Redirect Not Tracked", + Status: StatusOK, + Message: "N/A (not a git repository)", + } + } + + if strings.TrimSpace(string(output)) != "" { + // File is tracked - this is bad + return DoctorCheck{ + Name: "Redirect Not Tracked", + Status: StatusWarning, + Message: "Redirect file is tracked by git", + Detail: "The .beads/redirect file was accidentally committed. This causes 'redirect target does not exist' warnings in other clones.", + Fix: "Run 'bd doctor --fix' to untrack, or manually: git rm --cached .beads/redirect", + } + } + + return DoctorCheck{ + Name: "Redirect Not Tracked", + Status: StatusOK, + Message: "Redirect file not tracked (correct)", + } +} + +// FixRedirectTracking untracks .beads/redirect if it was accidentally committed. +func FixRedirectTracking() error { + redirectPath := filepath.Join(".beads", "redirect") + + // Check if file exists + if _, err := os.Stat(redirectPath); os.IsNotExist(err) { + return nil // Nothing to fix + } + + // Check if tracked + cmd := exec.Command("git", "ls-files", redirectPath) + output, err := cmd.Output() + if err != nil { + return nil // Not in a git repo + } + + if strings.TrimSpace(string(output)) == "" { + return nil // Not tracked, nothing to fix + } + + // Untrack the file (keep it on disk) + cmd = exec.Command("git", "rm", "--cached", redirectPath) + return cmd.Run() +} + // CheckIssuesTracking verifies that issues.jsonl is tracked by git. // This catches cases where global gitignore patterns (e.g., *.jsonl) would // cause issues.jsonl to be ignored, breaking bd sync. diff --git a/cmd/bd/doctor/gitignore_test.go b/cmd/bd/doctor/gitignore_test.go index eae0e37c..3d3f26a5 100644 --- a/cmd/bd/doctor/gitignore_test.go +++ b/cmd/bd/doctor/gitignore_test.go @@ -2,6 +2,7 @@ package doctor import ( "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -1135,3 +1136,343 @@ func TestFixGitignore_SubdirectoryGitignore(t *testing.T) { t.Error("root .gitignore should not be modified") } } + +func TestGitignoreTemplate_ContainsRedirect(t *testing.T) { + // Worktree redirect files should be gitignored to prevent accidental commits. + // When bd worktree create runs, it creates .beads/redirect in the worktree. + // If this file is accidentally committed, it causes "redirect target does not exist" + // warnings in other clones/worktrees. + if !strings.Contains(GitignoreTemplate, "redirect") { + t.Error("GitignoreTemplate should contain 'redirect' pattern for worktree support") + } +} + +func TestRequiredPatterns_ContainsRedirect(t *testing.T) { + // The redirect pattern should be in requiredPatterns so that bd doctor + // can detect outdated .gitignore files missing this pattern. + found := false + for _, pattern := range requiredPatterns { + if pattern == "redirect" { + found = true + break + } + } + if !found { + t.Error("requiredPatterns should include 'redirect'") + } +} + +func TestCheckGitignore_DetectsMissingRedirect(t *testing.T) { + // Verify that CheckGitignore flags gitignore files missing the redirect pattern + tmpDir := t.TempDir() + + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Error(err) + } + }() + + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + // Create gitignore with all required patterns EXCEPT redirect + oldStyleContent := `# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json +last-touched + +# Local version tracking +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Merge artifacts +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json +` + gitignorePath := filepath.Join(beadsDir, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(oldStyleContent), 0600); err != nil { + t.Fatal(err) + } + + check := CheckGitignore() + + if check.Status != StatusWarning { + t.Errorf("Expected warning status for missing redirect, got %s", check.Status) + } + if !strings.Contains(check.Detail, "redirect") { + t.Errorf("Expected detail to mention 'redirect', got: %s", check.Detail) + } +} + +func TestCheckRedirectNotTracked_NoFile(t *testing.T) { + // When no redirect file exists, check should pass + tmpDir := t.TempDir() + + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Error(err) + } + }() + + // Create .beads but no redirect file + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + check := CheckRedirectNotTracked() + + if check.Status != StatusOK { + t.Errorf("Expected OK status when no redirect file, got %s", check.Status) + } +} + +func TestCheckRedirectNotTracked_UntrackedFile(t *testing.T) { + // When redirect file exists but is not tracked, check should pass + tmpDir := t.TempDir() + + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Error(err) + } + }() + + // Initialize git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "config", "user.email", "test@test.com").Run(); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "config", "user.name", "Test").Run(); err != nil { + t.Fatal(err) + } + + // Create .beads with redirect file (but don't commit it) + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + redirectPath := filepath.Join(beadsDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../../../.beads\n"), 0600); err != nil { + t.Fatal(err) + } + + check := CheckRedirectNotTracked() + + if check.Status != StatusOK { + t.Errorf("Expected OK status for untracked redirect, got %s: %s", check.Status, check.Message) + } +} + +func TestCheckRedirectNotTracked_TrackedFile(t *testing.T) { + // When redirect file is tracked by git, check should warn + tmpDir := t.TempDir() + + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Error(err) + } + }() + + // Initialize git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "config", "user.email", "test@test.com").Run(); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "config", "user.name", "Test").Run(); err != nil { + t.Fatal(err) + } + + // Create .beads with redirect file AND commit it (simulating accidental commit) + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + redirectPath := filepath.Join(beadsDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../../../.beads\n"), 0600); err != nil { + t.Fatal(err) + } + + // Stage and commit + if err := exec.Command("git", "add", redirectPath).Run(); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "commit", "-m", "accidentally commit redirect").Run(); err != nil { + t.Fatal(err) + } + + check := CheckRedirectNotTracked() + + if check.Status != StatusWarning { + t.Errorf("Expected Warning status for tracked redirect, got %s: %s", check.Status, check.Message) + } + if !strings.Contains(check.Fix, "git rm --cached") { + t.Errorf("Expected fix to mention 'git rm --cached', got: %s", check.Fix) + } +} + +func TestFixRedirectTracking(t *testing.T) { + // Verify that FixRedirectTracking untracks accidentally committed redirect files + tmpDir := t.TempDir() + + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Error(err) + } + }() + + // Initialize git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "config", "user.email", "test@test.com").Run(); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "config", "user.name", "Test").Run(); err != nil { + t.Fatal(err) + } + + // Create and commit redirect file + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + redirectPath := filepath.Join(beadsDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../../../.beads\n"), 0600); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "add", redirectPath).Run(); err != nil { + t.Fatal(err) + } + if err := exec.Command("git", "commit", "-m", "accidentally commit redirect").Run(); err != nil { + t.Fatal(err) + } + + // Verify it's tracked before fix + cmd := exec.Command("git", "ls-files", redirectPath) + output, _ := cmd.Output() + if strings.TrimSpace(string(output)) == "" { + t.Fatal("Redirect file should be tracked before fix") + } + + // Run fix + if err := FixRedirectTracking(); err != nil { + t.Fatalf("FixRedirectTracking failed: %v", err) + } + + // Verify it's no longer tracked + cmd = exec.Command("git", "ls-files", redirectPath) + output, _ = cmd.Output() + if strings.TrimSpace(string(output)) != "" { + t.Error("Redirect file should not be tracked after fix") + } + + // Verify file still exists on disk + if _, err := os.Stat(redirectPath); os.IsNotExist(err) { + t.Error("Redirect file should still exist on disk after fix") + } +} + +func TestFixGitignore_AddsRedirect(t *testing.T) { + // Verify that FixGitignore adds the redirect pattern to old-style gitignore files + tmpDir := t.TempDir() + + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Error(err) + } + }() + + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + // Create old-style gitignore without redirect + oldContent := `*.db +daemon.log +` + gitignorePath := filepath.Join(".beads", ".gitignore") + if err := os.WriteFile(gitignorePath, []byte(oldContent), 0600); err != nil { + t.Fatal(err) + } + + // Run fix + if err := FixGitignore(); err != nil { + t.Fatalf("FixGitignore failed: %v", err) + } + + // Verify redirect is now present + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + if !strings.Contains(string(content), "redirect") { + t.Error("Fixed .gitignore should contain 'redirect' pattern") + } +}