Files
beads/cmd/bd/doctor/gitignore.go
Steve Yegge 310d374264 fix: prevent .beads/redirect from being committed (GH#814)
- Add redirect to GitignoreTemplate with explanatory comment
- Add redirect to requiredPatterns for outdated gitignore detection
- Add CheckRedirectNotTracked() to detect already-tracked redirect files
- Add FixRedirectTracking() to untrack via git rm --cached
- Register check in bd doctor under Git Integration category
- Add 6 tests for the new functionality

The redirect file contains a relative path that only works in the
original worktree. When committed, it causes warnings in other clones:
"Warning: redirect target does not exist"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 18:29:03 -08:00

249 lines
6.9 KiB
Go

package doctor
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GitignoreTemplate is the canonical .beads/.gitignore content
const GitignoreTemplate = `# SQLite databases
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
# Daemon runtime files
daemon.lock
daemon.log
daemon.pid
bd.sock
sync-state.json
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Legacy database files
db.sqlite
bd.db
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
beads.left.jsonl
beads.left.meta.json
beads.right.jsonl
beads.right.meta.json
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
# They would override fork protection in .git/info/exclude, allowing
# contributors to accidentally commit upstream issue databases.
# The JSONL files (issues.jsonl, interactions.jsonl) and config files
# are tracked by git by default since no pattern above ignores them.
`
// requiredPatterns are patterns that MUST be in .beads/.gitignore
var requiredPatterns = []string{
"beads.base.jsonl",
"beads.left.jsonl",
"beads.right.jsonl",
"beads.base.meta.json",
"beads.left.meta.json",
"beads.right.meta.json",
"*.db?*",
"redirect",
}
// CheckGitignore checks if .beads/.gitignore is up to date
func CheckGitignore() DoctorCheck {
gitignorePath := filepath.Join(".beads", ".gitignore")
// Check if file exists
content, err := os.ReadFile(gitignorePath) // #nosec G304 -- path is hardcoded
if err != nil {
return DoctorCheck{
Name: "Gitignore",
Status: "warning",
Message: ".beads/.gitignore not found",
Fix: "Run: bd init (safe to re-run) or bd doctor --fix",
}
}
// Check for required patterns
contentStr := string(content)
var missing []string
for _, pattern := range requiredPatterns {
if !strings.Contains(contentStr, pattern) {
missing = append(missing, pattern)
}
}
if len(missing) > 0 {
return DoctorCheck{
Name: "Gitignore",
Status: "warning",
Message: "Outdated .beads/.gitignore (missing merge artifact patterns)",
Detail: "Missing: " + strings.Join(missing, ", "),
Fix: "Run: bd doctor --fix or bd init (safe to re-run)",
}
}
return DoctorCheck{
Name: "Gitignore",
Status: "ok",
Message: "Up to date",
}
}
// FixGitignore updates .beads/.gitignore to the current template
func FixGitignore() error {
gitignorePath := filepath.Join(".beads", ".gitignore")
// If file exists and is read-only, fix permissions first
if info, err := os.Stat(gitignorePath); err == nil {
if info.Mode().Perm()&0200 == 0 { // No write permission for owner
if err := os.Chmod(gitignorePath, 0600); err != nil {
return err
}
}
}
// Write canonical template with secure file permissions
if err := os.WriteFile(gitignorePath, []byte(GitignoreTemplate), 0600); err != nil {
return err
}
// Ensure permissions are set correctly (some systems respect umask)
if err := os.Chmod(gitignorePath, 0600); err != nil {
return err
}
return nil
}
// CheckIssuesTracking verifies that issues.jsonl is tracked by git.
// This catches cases where global gitignore patterns (e.g., *.jsonl) would
// cause issues.jsonl to be ignored, breaking bd sync.
func CheckIssuesTracking() DoctorCheck {
issuesPath := filepath.Join(".beads", "issues.jsonl")
// First check if the file exists
if _, err := os.Stat(issuesPath); os.IsNotExist(err) {
// File doesn't exist yet - not an error, bd init may not have been run
return DoctorCheck{
Name: "Issues Tracking",
Status: "ok",
Message: "No issues.jsonl yet (will be created on first issue)",
}
}
// Check if git considers this file ignored
// git check-ignore exits 0 if ignored, 1 if not ignored, 128 if error
cmd := exec.Command("git", "check-ignore", "-q", issuesPath)
err := cmd.Run()
if err == nil {
// Exit code 0 means the file IS ignored - this is bad
// Get details about what's ignoring it
detailCmd := exec.Command("git", "check-ignore", "-v", issuesPath)
output, _ := detailCmd.Output()
detail := strings.TrimSpace(string(output))
return DoctorCheck{
Name: "Issues Tracking",
Status: "warning",
Message: "issues.jsonl is ignored by git (bd sync will fail)",
Detail: detail,
Fix: "Check global gitignore: git config --global core.excludesfile",
}
}
// Exit code 1 means not ignored (good), any other error we ignore
return DoctorCheck{
Name: "Issues Tracking",
Status: "ok",
Message: "issues.jsonl is tracked by git",
}
}
// CheckRedirectNotTracked verifies that .beads/redirect is NOT tracked by git.
// Redirect files contain relative paths that only work in the original worktree.
// If committed, they cause warnings in other clones where the path is invalid.
func CheckRedirectNotTracked() DoctorCheck {
redirectPath := filepath.Join(".beads", "redirect")
// First check if the file exists
if _, err := os.Stat(redirectPath); os.IsNotExist(err) {
// File doesn't exist - nothing to check
return DoctorCheck{
Name: "Redirect Tracking",
Status: StatusOK,
Message: "No redirect 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", redirectPath)
output, err := cmd.Output()
if err != nil {
// Not in a git repo or git error - skip check
return DoctorCheck{
Name: "Redirect 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: "Redirect Tracking",
Status: StatusOK,
Message: "redirect file not tracked (correct)",
}
}
// File is tracked - this is a problem
return DoctorCheck{
Name: "Redirect Tracking",
Status: StatusWarning,
Message: "redirect file is tracked by git",
Detail: "The .beads/redirect file contains a relative path that only works in this worktree. When committed, it causes warnings in other clones.",
Fix: "Run 'bd doctor --fix' to untrack, or manually: git rm --cached .beads/redirect",
}
}
// FixRedirectTracking untracks the .beads/redirect file from git
func FixRedirectTracking() error {
redirectPath := filepath.Join(".beads", "redirect")
// Check if file is actually tracked first
cmd := exec.Command("git", "ls-files", redirectPath)
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", redirectPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to untrack redirect file: %w", err)
}
return nil
}