diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index a6ad8a94..c004083f 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -457,6 +457,11 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, orphanedIssuesCheck) // Don't fail overall check for orphaned issues, just warn + // Check 17c: Sync branch gitignore flags (GH#870) + syncBranchGitignoreCheck := convertWithCategory(doctor.CheckSyncBranchGitignore(), doctor.CategoryGit) + result.Checks = append(result.Checks, syncBranchGitignoreCheck) + // Don't fail overall check for sync branch gitignore, just warn + // Check 18: Deletions manifest (legacy, now replaced by tombstones) deletionsCheck := convertWithCategory(doctor.CheckDeletionsManifest(path), doctor.CategoryMetadata) result.Checks = append(result.Checks, deletionsCheck) diff --git a/cmd/bd/doctor/fix/sync_branch.go b/cmd/bd/doctor/fix/sync_branch.go index e595e067..b20a1c19 100644 --- a/cmd/bd/doctor/fix/sync_branch.go +++ b/cmd/bd/doctor/fix/sync_branch.go @@ -2,7 +2,9 @@ package fix import ( "fmt" + "os" "os/exec" + "path/filepath" "strings" ) @@ -147,3 +149,162 @@ func SyncBranchHealth(path, syncBranch string) error { fmt.Printf(" ✓ Recreated %s from %s and pushed\n", syncBranch, mainBranch) return nil } + +// SyncBranchGitignore sets git index flags to hide .beads/issues.jsonl from git status +// when sync.branch is configured. This prevents the file from showing as modified on +// the main branch while actual data lives on the sync branch. (GH#797, GH#801, GH#870) +// +// Sets both flags for comprehensive hiding: +// - assume-unchanged: Performance optimization, skips file stat check +// - skip-worktree: Clear error message if user tries explicit `git add` +func SyncBranchGitignore(path string) error { + if err := validateBeadsWorkspace(path); err != nil { + return err + } + + // Find the .beads directory + beadsDir := filepath.Join(path, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + return fmt.Errorf(".beads directory not found at %s", beadsDir) + } + + // Check if issues.jsonl exists and is tracked by git + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + // File doesn't exist, nothing to hide + return nil + } + + // Check if file is tracked by git + cmd := exec.Command("git", "ls-files", "--error-unmatch", jsonlPath) + cmd.Dir = path + if err := cmd.Run(); err != nil { + // File is not tracked - add to .git/info/exclude instead + return addToGitExclude(path, ".beads/issues.jsonl") + } + + // File is tracked - set both git index flags + // These must be separate commands (git quirk) + cmd = exec.Command("git", "update-index", "--assume-unchanged", jsonlPath) + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to set assume-unchanged: %w\n%s", err, out) + } + + cmd = exec.Command("git", "update-index", "--skip-worktree", jsonlPath) + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + // Revert assume-unchanged if skip-worktree fails + revertCmd := exec.Command("git", "update-index", "--no-assume-unchanged", jsonlPath) + revertCmd.Dir = path + _ = revertCmd.Run() + return fmt.Errorf("failed to set skip-worktree: %w\n%s", err, out) + } + + fmt.Println(" ✓ Set git index flags to hide .beads/issues.jsonl") + return nil +} + +// ClearSyncBranchGitignore removes git index flags from .beads/issues.jsonl. +// Called when sync.branch is disabled to restore normal git tracking. +func ClearSyncBranchGitignore(path string) error { + beadsDir := filepath.Join(path, ".beads") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + return nil // File doesn't exist, nothing to do + } + + // Check if file is tracked + cmd := exec.Command("git", "ls-files", "--error-unmatch", jsonlPath) + cmd.Dir = path + if err := cmd.Run(); err != nil { + return nil // Not tracked, nothing to clear + } + + // Clear both flags + cmd = exec.Command("git", "update-index", "--no-assume-unchanged", jsonlPath) + cmd.Dir = path + _ = cmd.Run() // Ignore errors - flag might not be set + + cmd = exec.Command("git", "update-index", "--no-skip-worktree", jsonlPath) + cmd.Dir = path + _ = cmd.Run() // Ignore errors - flag might not be set + + return nil +} + +// HasSyncBranchGitignoreFlags checks if git index flags are set on .beads/issues.jsonl +func HasSyncBranchGitignoreFlags(path string) (bool, bool, error) { + beadsDir := filepath.Join(path, ".beads") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + return false, false, nil + } + + // Get file status from git ls-files -v + // 'h' = assume-unchanged, 'S' = skip-worktree + cmd := exec.Command("git", "ls-files", "-v", jsonlPath) + cmd.Dir = path + output, err := cmd.Output() + if err != nil { + return false, false, nil // File not tracked + } + + line := strings.TrimSpace(string(output)) + if len(line) == 0 { + return false, false, nil + } + + // First character indicates status: + // 'H' = tracked, 'h' = assume-unchanged, 'S' = skip-worktree + firstChar := line[0] + hasAssumeUnchanged := firstChar == 'h' + hasSkipWorktree := firstChar == 'S' + + return hasAssumeUnchanged, hasSkipWorktree, nil +} + +// addToGitExclude adds a pattern to .git/info/exclude +func addToGitExclude(path, pattern string) error { + // Get git directory + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = path + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get git directory: %w", err) + } + + gitDir := strings.TrimSpace(string(output)) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(path, gitDir) + } + + excludePath := filepath.Join(gitDir, "info", "exclude") + + // Create info directory if needed + if err := os.MkdirAll(filepath.Dir(excludePath), 0755); err != nil { + return fmt.Errorf("failed to create info directory: %w", err) + } + + // Check if pattern already exists + content, _ := os.ReadFile(excludePath) + if strings.Contains(string(content), pattern) { + return nil // Already excluded + } + + // Append pattern + f, err := os.OpenFile(excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open exclude file: %w", err) + } + defer f.Close() + + if _, err := f.WriteString(pattern + "\n"); err != nil { + return fmt.Errorf("failed to write exclude pattern: %w", err) + } + + fmt.Printf(" ✓ Added %s to .git/info/exclude\n", pattern) + return nil +} diff --git a/cmd/bd/doctor/gitignore.go b/cmd/bd/doctor/gitignore.go index 8bf0458b..4b117575 100644 --- a/cmd/bd/doctor/gitignore.go +++ b/cmd/bd/doctor/gitignore.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/steveyegge/beads/cmd/bd/doctor/fix" "github.com/steveyegge/beads/internal/syncbranch" ) @@ -262,3 +263,93 @@ func FixRedirectTracking() error { 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. +func CheckSyncBranchGitignore() DoctorCheck { + // Only relevant when sync.branch is configured + branch := syncbranch.GetFromYAML() + if branch == "" { + return DoctorCheck{ + Name: "Sync Branch Gitignore", + Status: StatusOK, + Message: "N/A (sync.branch not configured)", + } + } + + issuesPath := filepath.Join(".beads", "issues.jsonl") + + // Check if file exists + if _, err := os.Stat(issuesPath); os.IsNotExist(err) { + return DoctorCheck{ + Name: "Sync Branch Gitignore", + Status: StatusOK, + Message: "No issues.jsonl yet", + } + } + + // Check if file is tracked by git + cmd := exec.Command("git", "ls-files", "--error-unmatch", issuesPath) // #nosec G204 - args are hardcoded paths + if err := cmd.Run(); err != nil { + // File is not tracked - check if it's excluded + return DoctorCheck{ + Name: "Sync Branch Gitignore", + Status: StatusOK, + Message: "issues.jsonl is not tracked (via .gitignore or exclude)", + } + } + + // File is tracked - check for git index flags + cwd, err := os.Getwd() + if err != nil { + return DoctorCheck{ + Name: "Sync Branch Gitignore", + Status: StatusWarning, + Message: "Cannot determine current directory", + } + } + + hasAssumeUnchanged, hasSkipWorktree, err := fix.HasSyncBranchGitignoreFlags(cwd) + if err != nil { + return DoctorCheck{ + Name: "Sync Branch Gitignore", + Status: StatusWarning, + Message: "Cannot check git index flags", + Detail: err.Error(), + } + } + + if hasAssumeUnchanged || hasSkipWorktree { + return DoctorCheck{ + Name: "Sync Branch Gitignore", + Status: StatusOK, + Message: "Git index flags set (issues.jsonl hidden from git status)", + } + } + + // No flags set - this is the problem case + return DoctorCheck{ + Name: "Sync Branch Gitignore", + Status: StatusWarning, + Message: "issues.jsonl shows as modified (missing git index flags)", + Detail: fmt.Sprintf("sync.branch='%s' configured but issues.jsonl appears in git status", branch), + Fix: "Run 'bd doctor --fix' or 'bd sync' to set git index flags", + } +} + +// FixSyncBranchGitignore sets git index flags on issues.jsonl when sync.branch is configured. +func FixSyncBranchGitignore() error { + // Only relevant when sync.branch is configured + branch := syncbranch.GetFromYAML() + if branch == "" { + return nil // Not in sync-branch mode, nothing to do + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("cannot determine current directory: %w", err) + } + + return fix.SyncBranchGitignore(cwd) +} diff --git a/cmd/bd/doctor_fix.go b/cmd/bd/doctor_fix.go index f6ce5e1a..e127e3e6 100644 --- a/cmd/bd/doctor_fix.go +++ b/cmd/bd/doctor_fix.go @@ -251,6 +251,8 @@ func applyFixList(path string, fixes []doctorCheck) { // No auto-fix: sync-branch should be added to config.yaml (version controlled) fmt.Printf(" ⚠ Add 'sync-branch: beads-sync' to .beads/config.yaml\n") continue + case "Sync Branch Gitignore": + err = doctor.FixSyncBranchGitignore() case "Database Config": err = fix.DatabaseConfig(path) case "JSONL Config": diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index a94ed2bf..f01ef372 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/steveyegge/beads/cmd/bd/doctor/fix" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/debug" @@ -835,6 +836,15 @@ Use --merge to merge the sync branch back to main branch.`, // Non-fatal - just means git status will show modified files debug.Logf("sync: failed to restore .beads/ from branch: %v", err) } + + // GH#870: Set git index flags to hide .beads/issues.jsonl from git status. + // This prevents the file from appearing modified on main when using sync-branch. + if cwd, err := os.Getwd(); err == nil { + if err := fix.SyncBranchGitignore(cwd); err != nil { + debug.Logf("sync: failed to set gitignore flags: %v", err) + } + } + // Skip final flush in PersistentPostRun - we've already exported to sync branch // and restored the working directory to match the current branch skipFinalFlush = true