diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 1a10bcdd..4e18dd19 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -465,6 +465,11 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, vestigialWorktreesCheck) // Don't fail overall check for vestigial worktrees, just warn + // Check 14f: last-touched file tracking (runtime state shouldn't be committed) + lastTouchedTrackingCheck := convertWithCategory(doctor.CheckLastTouchedNotTracked(), doctor.CategoryGit) + result.Checks = append(result.Checks, lastTouchedTrackingCheck) + // Don't fail overall check for last-touched tracking, just warn + // Check 15: Git merge driver configuration mergeDriverCheck := convertWithCategory(doctor.CheckMergeDriver(path), doctor.CategoryGit) result.Checks = append(result.Checks, mergeDriverCheck) diff --git a/cmd/bd/doctor/gitignore.go b/cmd/bd/doctor/gitignore.go index c23fe8dc..cb02cdc9 100644 --- a/cmd/bd/doctor/gitignore.go +++ b/cmd/bd/doctor/gitignore.go @@ -520,6 +520,80 @@ func CheckNoVestigialSyncWorktrees() DoctorCheck { } } +// CheckLastTouchedNotTracked verifies that .beads/last-touched is NOT tracked by git. +// The last-touched file is local runtime state that should never be committed. +// If committed, it causes spurious diffs in other clones. +func CheckLastTouchedNotTracked() DoctorCheck { + lastTouchedPath := filepath.Join(".beads", "last-touched") + + // First check if the file exists + if _, err := os.Stat(lastTouchedPath); os.IsNotExist(err) { + // File doesn't exist - nothing to check + return DoctorCheck{ + Name: "Last-Touched Tracking", + Status: StatusOK, + Message: "No last-touched 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", lastTouchedPath) // #nosec G204 - args are hardcoded paths + output, err := cmd.Output() + if err != nil { + // Not in a git repo or git error - skip check + return DoctorCheck{ + Name: "Last-Touched 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: "Last-Touched Tracking", + Status: StatusOK, + Message: "last-touched file not tracked (correct)", + } + } + + // File is tracked - this is a problem + return DoctorCheck{ + Name: "Last-Touched Tracking", + Status: StatusWarning, + Message: "last-touched file is tracked by git", + Detail: "The .beads/last-touched file is local runtime state that should never be committed.", + Fix: "Run 'bd doctor --fix' to untrack, or manually: git rm --cached .beads/last-touched", + } +} + +// FixLastTouchedTracking untracks the .beads/last-touched file from git +func FixLastTouchedTracking() error { + lastTouchedPath := filepath.Join(".beads", "last-touched") + + // Check if file is actually tracked first + cmd := exec.Command("git", "ls-files", lastTouchedPath) // #nosec G204 - args are hardcoded paths + 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", lastTouchedPath) // #nosec G204 - args are hardcoded paths + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to untrack last-touched file: %w", err) + } + + return nil +} + // CheckSyncBranchGitignore checks if git index flags are set on issues.jsonl when sync.branch is configured. // Without these flags, the file appears modified in git status even though changes go to the sync branch. // GH#797, GH#801, GH#870. diff --git a/cmd/bd/doctor/gitignore_test.go b/cmd/bd/doctor/gitignore_test.go index 2f3a7b0c..8240964a 100644 --- a/cmd/bd/doctor/gitignore_test.go +++ b/cmd/bd/doctor/gitignore_test.go @@ -1408,3 +1408,236 @@ func TestRequiredPatterns_ContainsSyncStatePatterns(t *testing.T) { } } } + +// TestCheckLastTouchedNotTracked_NoFile verifies that check passes when no last-touched file exists +func TestCheckLastTouchedNotTracked_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 last-touched file + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + check := CheckLastTouchedNotTracked() + + if check.Status != StatusOK { + t.Errorf("Expected status %s, got %s", StatusOK, check.Status) + } + if check.Message != "No last-touched file present" { + t.Errorf("Expected message about no last-touched file, got: %s", check.Message) + } +} + +func TestCheckLastTouchedNotTracked_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 last-touched file + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + lastTouchedPath := filepath.Join(beadsDir, "last-touched") + if err := os.WriteFile(lastTouchedPath, []byte("bd-test1"), 0600); err != nil { + t.Fatal(err) + } + + check := CheckLastTouchedNotTracked() + + if check.Status != StatusOK { + t.Errorf("Expected status %s, got %s", StatusOK, check.Status) + } + if check.Message != "last-touched file not tracked (correct)" { + t.Errorf("Expected message about correct tracking, got: %s", check.Message) + } +} + +func TestCheckLastTouchedNotTracked_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 last-touched file + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + lastTouchedPath := filepath.Join(beadsDir, "last-touched") + if err := os.WriteFile(lastTouchedPath, []byte("bd-test1"), 0600); err != nil { + t.Fatal(err) + } + + // Stage (track) the last-touched file + gitAdd := exec.Command("git", "add", lastTouchedPath) + if err := gitAdd.Run(); err != nil { + t.Skipf("git add failed: %v", err) + } + + check := CheckLastTouchedNotTracked() + + if check.Status != StatusWarning { + t.Errorf("Expected status %s, got %s", StatusWarning, check.Status) + } + if check.Message != "last-touched 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 TestFixLastTouchedTracking(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 last-touched file + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + lastTouchedPath := filepath.Join(beadsDir, "last-touched") + if err := os.WriteFile(lastTouchedPath, []byte("bd-test1"), 0600); err != nil { + t.Fatal(err) + } + + // Stage (track) the last-touched file + gitAdd := exec.Command("git", "add", lastTouchedPath) + if err := gitAdd.Run(); err != nil { + t.Skipf("git add failed: %v", err) + } + + // Verify it's tracked before fix + checkBefore := CheckLastTouchedNotTracked() + if checkBefore.Status != StatusWarning { + t.Fatalf("Expected file to be tracked before fix, status: %s", checkBefore.Status) + } + + // Apply the fix + if err := FixLastTouchedTracking(); err != nil { + t.Fatalf("FixLastTouchedTracking failed: %v", err) + } + + // Verify it's no longer tracked after fix + checkAfter := CheckLastTouchedNotTracked() + if checkAfter.Status != StatusOK { + t.Errorf("Expected status %s after fix, got %s", StatusOK, checkAfter.Status) + } + + // Verify the file still exists locally + if _, err := os.Stat(lastTouchedPath); os.IsNotExist(err) { + t.Error("last-touched file should still exist after untracking") + } +} + +// TestGitignoreTemplate_ContainsLastTouched verifies that the .beads/.gitignore template +// includes last-touched to prevent it from being tracked. +func TestGitignoreTemplate_ContainsLastTouched(t *testing.T) { + if !strings.Contains(GitignoreTemplate, "last-touched") { + t.Error("GitignoreTemplate should contain 'last-touched' pattern") + } +} + +// TestRequiredPatterns_ContainsLastTouched verifies that bd doctor validates +// the presence of the last-touched pattern in .beads/.gitignore. +func TestRequiredPatterns_ContainsLastTouched(t *testing.T) { + found := false + for _, pattern := range requiredPatterns { + if pattern == "last-touched" { + found = true + break + } + } + if !found { + t.Error("requiredPatterns should include 'last-touched'") + } +} diff --git a/cmd/bd/doctor_fix.go b/cmd/bd/doctor_fix.go index e127e3e6..14873406 100644 --- a/cmd/bd/doctor_fix.go +++ b/cmd/bd/doctor_fix.go @@ -227,6 +227,8 @@ func applyFixList(path string, fixes []doctorCheck) { err = doctor.FixGitignore() case "Redirect Tracking": err = doctor.FixRedirectTracking() + case "Last-Touched Tracking": + err = doctor.FixLastTouchedTracking() case "Git Hooks": err = fix.GitHooks(path) case "Daemon Health":