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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user