fix: prevent .beads/redirect from being committed (GH#814)

- Add redirect to GitignoreTemplate with explanatory comment
- Add redirect to requiredPatterns for outdated gitignore detection
- Add CheckRedirectNotTracked() to detect already-tracked redirect files
- Add FixRedirectTracking() to untrack via git rm --cached
- Register check in bd doctor under Git Integration category
- Add 6 tests for the new functionality

The redirect file contains a relative path that only works in the
original worktree. When committed, it causes warnings in other clones:
"Warning: redirect target does not exist"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-30 18:27:18 -08:00
parent 584608a14e
commit 310d374264
4 changed files with 298 additions and 404 deletions

View File

@@ -412,9 +412,9 @@ func runDiagnostics(path string) doctorResult {
result.Checks = append(result.Checks, issuesTrackingCheck)
// Don't fail overall check for tracking issues, just warn
// Check 14b: Redirect file not tracked (worktree support)
redirectCheck := convertWithCategory(doctor.CheckRedirectNotTracked(), doctor.CategoryGit)
result.Checks = append(result.Checks, redirectCheck)
// Check 14b: redirect file tracking (worktree redirect files shouldn't be committed)
redirectTrackingCheck := convertWithCategory(doctor.CheckRedirectNotTracked(), doctor.CategoryGit)
result.Checks = append(result.Checks, redirectTrackingCheck)
// Don't fail overall check for redirect tracking, just warn
// Check 15: Git merge driver configuration

View File

@@ -1,6 +1,7 @@
package doctor
import (
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -21,18 +22,18 @@ daemon.log
daemon.pid
bd.sock
sync-state.json
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
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
@@ -57,7 +58,7 @@ var requiredPatterns = []string{
"beads.left.meta.json",
"beads.right.meta.json",
"*.db?*",
"redirect", // worktree redirect files should never be committed
"redirect",
}
// CheckGitignore checks if .beads/.gitignore is up to date
@@ -127,79 +128,6 @@ 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.
@@ -244,3 +172,77 @@ func CheckIssuesTracking() DoctorCheck {
Message: "issues.jsonl is tracked by git",
}
}
// CheckRedirectNotTracked verifies that .beads/redirect is NOT tracked by git.
// Redirect files contain relative paths that only work in the original worktree.
// If committed, they cause warnings in other clones where the path is invalid.
func CheckRedirectNotTracked() DoctorCheck {
redirectPath := filepath.Join(".beads", "redirect")
// First check if the file exists
if _, err := os.Stat(redirectPath); os.IsNotExist(err) {
// File doesn't exist - nothing to check
return DoctorCheck{
Name: "Redirect Tracking",
Status: StatusOK,
Message: "No redirect file present",
}
}
// Check if git considers this file tracked
// git ls-files exits 0 and outputs the filename if tracked, empty if untracked
cmd := exec.Command("git", "ls-files", redirectPath)
output, err := cmd.Output()
if err != nil {
// Not in a git repo or git error - skip check
return DoctorCheck{
Name: "Redirect Tracking",
Status: StatusOK,
Message: "N/A (not a git repository)",
}
}
trackedPath := strings.TrimSpace(string(output))
if trackedPath == "" {
// File exists but is not tracked - this is correct
return DoctorCheck{
Name: "Redirect Tracking",
Status: StatusOK,
Message: "redirect file not tracked (correct)",
}
}
// File is tracked - this is a problem
return DoctorCheck{
Name: "Redirect Tracking",
Status: StatusWarning,
Message: "redirect file is tracked by git",
Detail: "The .beads/redirect file contains a relative path that only works in this worktree. When committed, it causes warnings in other clones.",
Fix: "Run 'bd doctor --fix' to untrack, or manually: git rm --cached .beads/redirect",
}
}
// FixRedirectTracking untracks the .beads/redirect file from git
func FixRedirectTracking() error {
redirectPath := filepath.Join(".beads", "redirect")
// Check if file is actually tracked first
cmd := exec.Command("git", "ls-files", redirectPath)
output, err := cmd.Output()
if err != nil {
return nil // Not a git repo, nothing to do
}
trackedPath := strings.TrimSpace(string(output))
if trackedPath == "" {
return nil // Not tracked, nothing to do
}
// Untrack the file (keeps the local copy)
cmd = exec.Command("git", "rm", "--cached", redirectPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to untrack redirect file: %w", err)
}
return nil
}

View File

@@ -1137,19 +1137,226 @@ func TestFixGitignore_SubdirectoryGitignore(t *testing.T) {
}
}
func TestCheckRedirectNotTracked_NoFile(t *testing.T) {
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 directory 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 status %s, got %s", StatusOK, check.Status)
}
if check.Message != "No redirect file present" {
t.Errorf("Expected message about no redirect file, got: %s", check.Message)
}
}
func TestCheckRedirectNotTracked_FileExistsNotTracked(t *testing.T) {
// Skip on Windows as git behavior may differ
if runtime.GOOS == "windows" {
t.Skip("Skipping git-based test on Windows")
}
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
gitInit := exec.Command("git", "init")
if err := gitInit.Run(); err != nil {
t.Skipf("git init failed: %v", err)
}
// Create .beads directory with 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"), 0600); err != nil {
t.Fatal(err)
}
check := CheckRedirectNotTracked()
if check.Status != StatusOK {
t.Errorf("Expected status %s, got %s", StatusOK, check.Status)
}
if check.Message != "redirect file not tracked (correct)" {
t.Errorf("Expected message about correct tracking, got: %s", check.Message)
}
}
func TestCheckRedirectNotTracked_FileTracked(t *testing.T) {
// Skip on Windows as git behavior may differ
if runtime.GOOS == "windows" {
t.Skip("Skipping git-based test on Windows")
}
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
gitInit := exec.Command("git", "init")
if err := gitInit.Run(); err != nil {
t.Skipf("git init failed: %v", err)
}
// Configure git user for commits
exec.Command("git", "config", "user.email", "test@test.com").Run()
exec.Command("git", "config", "user.name", "Test").Run()
// Create .beads directory with 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"), 0600); err != nil {
t.Fatal(err)
}
// Stage (track) the redirect file
gitAdd := exec.Command("git", "add", redirectPath)
if err := gitAdd.Run(); err != nil {
t.Skipf("git add failed: %v", err)
}
check := CheckRedirectNotTracked()
if check.Status != StatusWarning {
t.Errorf("Expected status %s, got %s", StatusWarning, check.Status)
}
if check.Message != "redirect file is tracked by git" {
t.Errorf("Expected message about tracked file, got: %s", check.Message)
}
if check.Fix == "" {
t.Error("Expected fix message to be present")
}
}
func TestFixRedirectTracking(t *testing.T) {
// Skip on Windows as git behavior may differ
if runtime.GOOS == "windows" {
t.Skip("Skipping git-based test on Windows")
}
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
gitInit := exec.Command("git", "init")
if err := gitInit.Run(); err != nil {
t.Skipf("git init failed: %v", err)
}
// Configure git user for commits
exec.Command("git", "config", "user.email", "test@test.com").Run()
exec.Command("git", "config", "user.name", "Test").Run()
// Create .beads directory with 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"), 0600); err != nil {
t.Fatal(err)
}
// Stage (track) the redirect file
gitAdd := exec.Command("git", "add", redirectPath)
if err := gitAdd.Run(); err != nil {
t.Skipf("git add failed: %v", err)
}
// Verify it's tracked
lsFiles := exec.Command("git", "ls-files", redirectPath)
output, _ := lsFiles.Output()
if strings.TrimSpace(string(output)) == "" {
t.Fatal("redirect file should be tracked before fix")
}
// Run the fix
if err := FixRedirectTracking(); err != nil {
t.Fatalf("FixRedirectTracking failed: %v", err)
}
// Verify it's no longer tracked
lsFiles = exec.Command("git", "ls-files", redirectPath)
output, _ = lsFiles.Output()
if strings.TrimSpace(string(output)) != "" {
t.Error("redirect file should be untracked after fix")
}
// Verify the local file still exists
if _, err := os.Stat(redirectPath); os.IsNotExist(err) {
t.Error("redirect file should still exist locally after untracking")
}
}
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.
// Verify the template contains the redirect pattern
if !strings.Contains(GitignoreTemplate, "redirect") {
t.Error("GitignoreTemplate should contain 'redirect' pattern for worktree support")
t.Error("GitignoreTemplate should contain 'redirect' pattern")
}
}
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.
// Verify requiredPatterns includes redirect
found := false
for _, pattern := range requiredPatterns {
if pattern == "redirect" {
@@ -1161,318 +1368,3 @@ func TestRequiredPatterns_ContainsRedirect(t *testing.T) {
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")
}
}

View File

@@ -225,7 +225,7 @@ func applyFixList(path string, fixes []doctorCheck) {
switch check.Name {
case "Gitignore":
err = doctor.FixGitignore()
case "Redirect Not Tracked":
case "Redirect Tracking":
err = doctor.FixRedirectTracking()
case "Git Hooks":
err = fix.GitHooks(path)