fix: hide issues.jsonl from git status when sync.branch configured (GH#870)

When sync.branch is configured, issues.jsonl appears modified in git status
even though changes go to the sync branch. This is confusing for users and
risks accidental commits to the wrong branch.

Implementation:
- Added SyncBranchGitignore() to set git index flags (assume-unchanged,
  skip-worktree) on issues.jsonl when sync.branch is configured
- For untracked files, adds to .git/info/exclude instead
- Called automatically from bd sync after successful sync-branch sync
- Added bd doctor check and fix for this issue
- Added HasSyncBranchGitignoreFlags() to check current flag state
- Added ClearSyncBranchGitignore() to remove flags when sync.branch disabled

Fixes: GH#870 (duplicate of GH#797, GH#801)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
dave
2026-01-03 21:07:32 -08:00
committed by Steve Yegge
parent 62e4eaf7c1
commit 6a5c289af3
5 changed files with 269 additions and 0 deletions

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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":

View File

@@ -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