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
This commit is contained in:
committed by
GitHub
parent
aff38708e0
commit
c98f5827bf
@@ -26,6 +26,9 @@ last-touched
|
|||||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||||
.local_version
|
.local_version
|
||||||
|
|
||||||
|
# Worktree redirect file (created by bd worktree, points to main repo's .beads)
|
||||||
|
redirect
|
||||||
|
|
||||||
# Legacy database files
|
# Legacy database files
|
||||||
db.sqlite
|
db.sqlite
|
||||||
bd.db
|
bd.db
|
||||||
@@ -54,6 +57,7 @@ var requiredPatterns = []string{
|
|||||||
"beads.left.meta.json",
|
"beads.left.meta.json",
|
||||||
"beads.right.meta.json",
|
"beads.right.meta.json",
|
||||||
"*.db?*",
|
"*.db?*",
|
||||||
|
"redirect", // worktree redirect files should never be committed
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckGitignore checks if .beads/.gitignore is up to date
|
// CheckGitignore checks if .beads/.gitignore is up to date
|
||||||
@@ -123,6 +127,79 @@ func FixGitignore() error {
|
|||||||
return nil
|
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.
|
// CheckIssuesTracking verifies that issues.jsonl is tracked by git.
|
||||||
// This catches cases where global gitignore patterns (e.g., *.jsonl) would
|
// This catches cases where global gitignore patterns (e.g., *.jsonl) would
|
||||||
// cause issues.jsonl to be ignored, breaking bd sync.
|
// cause issues.jsonl to be ignored, breaking bd sync.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package doctor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1135,3 +1136,343 @@ func TestFixGitignore_SubdirectoryGitignore(t *testing.T) {
|
|||||||
t.Error("root .gitignore should not be modified")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user