feat(doctor): add check for last-touched file tracking
Add bd doctor check that warns if .beads/last-touched is tracked by git. This file is local runtime state that should never be committed, as it causes spurious diffs in other clones. - CheckLastTouchedNotTracked() detects if file is git-tracked - FixLastTouchedTracking() untracks with git rm --cached - Comprehensive tests for all scenarios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
8ea1f970e1
commit
3854bb29e9
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user