feat(doctor): add sync divergence check for JSONL/SQLite/git (GH#885)
Add CheckSyncDivergence doctor check that detects: - JSONL on disk differs from git HEAD version - SQLite last_import_time does not match JSONL mtime - Uncommitted .beads/ changes exist Each issue includes auto-fix suggestions (bd sync, bd export, git commit). Multiple divergence issues result in error status. Part of GH#885 recovery mechanism. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
44a5c3a0ec
commit
68f5bb24f8
@@ -368,6 +368,14 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
result.OverallOK = false
|
result.OverallOK = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check 9a: Sync divergence (JSONL/SQLite/git) - GH#885
|
||||||
|
syncDivergenceCheck := convertWithCategory(doctor.CheckSyncDivergence(path), doctor.CategoryData)
|
||||||
|
result.Checks = append(result.Checks, syncDivergenceCheck)
|
||||||
|
if syncDivergenceCheck.Status == statusError {
|
||||||
|
result.OverallOK = false
|
||||||
|
}
|
||||||
|
// Warning-level divergence is informational, doesn't fail overall
|
||||||
|
|
||||||
// Check 9: Permissions
|
// Check 9: Permissions
|
||||||
permCheck := convertWithCategory(doctor.CheckPermissions(path), doctor.CategoryCore)
|
permCheck := convertWithCategory(doctor.CheckPermissions(path), doctor.CategoryCore)
|
||||||
result.Checks = append(result.Checks, permCheck)
|
result.Checks = append(result.Checks, permCheck)
|
||||||
|
|||||||
287
cmd/bd/doctor/sync_divergence.go
Normal file
287
cmd/bd/doctor/sync_divergence.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// Package doctor provides diagnostic checks for beads installations.
|
||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/ncruces/go-sqlite3/driver"
|
||||||
|
_ "github.com/ncruces/go-sqlite3/embed"
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncDivergenceIssue represents a specific type of sync divergence detected.
|
||||||
|
type SyncDivergenceIssue struct {
|
||||||
|
Type string // "jsonl_git_mismatch", "sqlite_mtime_stale", "uncommitted_beads"
|
||||||
|
Description string
|
||||||
|
FixCommand string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSyncDivergence checks for sync divergence issues between JSONL, SQLite, and git.
|
||||||
|
// This is part of GH#885 fix: recovery mechanism.
|
||||||
|
//
|
||||||
|
// Detects:
|
||||||
|
// 1. JSONL on disk differs from git HEAD version
|
||||||
|
// 2. SQLite last_import_time does not match JSONL mtime
|
||||||
|
// 3. Uncommitted .beads/ changes exist
|
||||||
|
func CheckSyncDivergence(path string) DoctorCheck {
|
||||||
|
// Check if we're in a git repository
|
||||||
|
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||||
|
cmd.Dir = path
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Sync Divergence",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (not a git repository)",
|
||||||
|
Category: CategoryData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow redirect to resolve actual beads directory
|
||||||
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||||
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Sync Divergence",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (no .beads directory)",
|
||||||
|
Category: CategoryData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []SyncDivergenceIssue
|
||||||
|
|
||||||
|
// Check 1: JSONL differs from git HEAD
|
||||||
|
jsonlIssue := checkJSONLGitDivergence(path, beadsDir)
|
||||||
|
if jsonlIssue != nil {
|
||||||
|
issues = append(issues, *jsonlIssue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: SQLite last_import_time vs JSONL mtime
|
||||||
|
mtimeIssue := checkSQLiteMtimeDivergence(path, beadsDir)
|
||||||
|
if mtimeIssue != nil {
|
||||||
|
issues = append(issues, *mtimeIssue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: Uncommitted .beads/ changes
|
||||||
|
uncommittedIssue := checkUncommittedBeadsChanges(path, beadsDir)
|
||||||
|
if uncommittedIssue != nil {
|
||||||
|
issues = append(issues, *uncommittedIssue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Sync Divergence",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "JSONL, SQLite, and git are in sync",
|
||||||
|
Category: CategoryData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build detail and fix messages
|
||||||
|
var details []string
|
||||||
|
var fixes []string
|
||||||
|
for _, issue := range issues {
|
||||||
|
details = append(details, issue.Description)
|
||||||
|
if issue.FixCommand != "" {
|
||||||
|
fixes = append(fixes, issue.FixCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := StatusWarning
|
||||||
|
if len(issues) > 1 {
|
||||||
|
// Multiple divergence issues are more serious
|
||||||
|
status = StatusError
|
||||||
|
}
|
||||||
|
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Sync Divergence",
|
||||||
|
Status: status,
|
||||||
|
Message: fmt.Sprintf("%d sync divergence issue(s) detected", len(issues)),
|
||||||
|
Detail: strings.Join(details, "\n"),
|
||||||
|
Fix: strings.Join(fixes, " OR "),
|
||||||
|
Category: CategoryData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkJSONLGitDivergence checks if JSONL on disk differs from git HEAD version.
|
||||||
|
func checkJSONLGitDivergence(path, beadsDir string) *SyncDivergenceIssue {
|
||||||
|
// Find JSONL file
|
||||||
|
jsonlPath := findJSONLFile(beadsDir)
|
||||||
|
if jsonlPath == "" {
|
||||||
|
return nil // No JSONL file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path for git commands
|
||||||
|
relPath, err := filepath.Rel(path, jsonlPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is tracked by git
|
||||||
|
cmd := exec.Command("git", "ls-files", "--error-unmatch", relPath)
|
||||||
|
cmd.Dir = path
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// File not tracked by git
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare current file with HEAD
|
||||||
|
cmd = exec.Command("git", "diff", "--quiet", "HEAD", "--", relPath)
|
||||||
|
cmd.Dir = path
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Exit code non-zero means there are differences
|
||||||
|
return &SyncDivergenceIssue{
|
||||||
|
Type: "jsonl_git_mismatch",
|
||||||
|
Description: fmt.Sprintf("JSONL file differs from git HEAD: %s", filepath.Base(jsonlPath)),
|
||||||
|
FixCommand: "git add .beads/ && git commit -m 'sync beads'",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSQLiteMtimeDivergence checks if SQLite last_import_time matches JSONL mtime.
|
||||||
|
func checkSQLiteMtimeDivergence(path, beadsDir string) *SyncDivergenceIssue {
|
||||||
|
// Get database path
|
||||||
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if database exists
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
return nil // No database
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find JSONL file
|
||||||
|
jsonlPath := findJSONLFile(beadsDir)
|
||||||
|
if jsonlPath == "" {
|
||||||
|
return nil // No JSONL file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get JSONL mtime
|
||||||
|
jsonlInfo, err := os.Stat(jsonlPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
jsonlMtime := jsonlInfo.ModTime()
|
||||||
|
|
||||||
|
// Get last_import_time from database
|
||||||
|
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var lastImportTimeStr string
|
||||||
|
err = db.QueryRow("SELECT value FROM config WHERE key = 'last_import_time'").Scan(&lastImportTimeStr)
|
||||||
|
if err != nil {
|
||||||
|
// No last_import_time recorded - this is a potential issue
|
||||||
|
return &SyncDivergenceIssue{
|
||||||
|
Type: "sqlite_mtime_stale",
|
||||||
|
Description: "No last_import_time recorded in database (may need sync)",
|
||||||
|
FixCommand: "bd sync --import-only",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse last_import_time
|
||||||
|
lastImportTime, err := time.Parse(time.RFC3339, lastImportTimeStr)
|
||||||
|
if err != nil {
|
||||||
|
// Try Unix timestamp format
|
||||||
|
var unixTs int64
|
||||||
|
if _, err := fmt.Sscanf(lastImportTimeStr, "%d", &unixTs); err == nil {
|
||||||
|
lastImportTime = time.Unix(unixTs, 0)
|
||||||
|
} else {
|
||||||
|
return nil // Can't parse, skip this check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare times with a 2-second tolerance (filesystem mtime precision varies)
|
||||||
|
timeDiff := jsonlMtime.Sub(lastImportTime)
|
||||||
|
if timeDiff < 0 {
|
||||||
|
timeDiff = -timeDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeDiff > 2*time.Second {
|
||||||
|
if jsonlMtime.After(lastImportTime) {
|
||||||
|
return &SyncDivergenceIssue{
|
||||||
|
Type: "sqlite_mtime_stale",
|
||||||
|
Description: fmt.Sprintf("JSONL is newer than last import (%s > %s)", jsonlMtime.Format(time.RFC3339), lastImportTime.Format(time.RFC3339)),
|
||||||
|
FixCommand: "bd sync --import-only",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &SyncDivergenceIssue{
|
||||||
|
Type: "sqlite_mtime_stale",
|
||||||
|
Description: fmt.Sprintf("Database import time is newer than JSONL (%s > %s)", lastImportTime.Format(time.RFC3339), jsonlMtime.Format(time.RFC3339)),
|
||||||
|
FixCommand: "bd export",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUncommittedBeadsChanges checks if there are uncommitted changes in .beads/ directory.
|
||||||
|
func checkUncommittedBeadsChanges(path, beadsDir string) *SyncDivergenceIssue {
|
||||||
|
// Get relative path of beads dir
|
||||||
|
relBeadsDir, err := filepath.Rel(path, beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
relBeadsDir = ".beads"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for uncommitted changes in .beads/
|
||||||
|
cmd := exec.Command("git", "status", "--porcelain", "--", relBeadsDir)
|
||||||
|
cmd.Dir = path
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil // Can't run git status
|
||||||
|
}
|
||||||
|
|
||||||
|
status := strings.TrimSpace(string(out))
|
||||||
|
if status == "" {
|
||||||
|
return nil // No uncommitted changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count changed files
|
||||||
|
lines := strings.Split(status, "\n")
|
||||||
|
fileCount := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.TrimSpace(line) != "" {
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SyncDivergenceIssue{
|
||||||
|
Type: "uncommitted_beads",
|
||||||
|
Description: fmt.Sprintf("Uncommitted .beads/ changes (%d file(s))", fileCount),
|
||||||
|
FixCommand: "bd sync",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findJSONLFile finds the JSONL file in the beads directory.
|
||||||
|
func findJSONLFile(beadsDir string) string {
|
||||||
|
// Check metadata.json for custom JSONL name
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
||||||
|
if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) {
|
||||||
|
p := cfg.JSONLPath(beadsDir)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try standard names
|
||||||
|
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
||||||
|
p := filepath.Join(beadsDir, name)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
369
cmd/bd/doctor/sync_divergence_test.go
Normal file
369
cmd/bd/doctor/sync_divergence_test.go
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckSyncDivergence(t *testing.T) {
|
||||||
|
t.Run("not a git repo", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-sync-div-*")
|
||||||
|
check := CheckSyncDivergence(dir)
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Errorf("status=%q want %q", check.Status, StatusOK)
|
||||||
|
}
|
||||||
|
if !strings.Contains(check.Message, "N/A") {
|
||||||
|
t.Errorf("message=%q want N/A", check.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no beads directory", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-sync-div-nb-*")
|
||||||
|
// Don't use initRepo which creates .beads
|
||||||
|
runGit(t, dir, "init", "-b", "main")
|
||||||
|
runGit(t, dir, "config", "user.email", "test@test.com")
|
||||||
|
runGit(t, dir, "config", "user.name", "Test User")
|
||||||
|
commitFile(t, dir, "README.md", "# test\n", "initial")
|
||||||
|
|
||||||
|
check := CheckSyncDivergence(dir)
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Errorf("status=%q want %q", check.Status, StatusOK)
|
||||||
|
}
|
||||||
|
if !strings.Contains(check.Message, "N/A") {
|
||||||
|
t.Errorf("message=%q want N/A", check.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all synced", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-sync-div-ok-*")
|
||||||
|
initRepo(t, dir, "main")
|
||||||
|
|
||||||
|
// Create .beads with JSONL
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and commit JSONL
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
jsonlContent := `{"id":"test-1","title":"Test issue","status":"open"}` + "\n"
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
commitFile(t, dir, ".beads/issues.jsonl", jsonlContent, "add issues")
|
||||||
|
|
||||||
|
check := CheckSyncDivergence(dir)
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Errorf("status=%q want %q (msg=%q detail=%q)", check.Status, StatusOK, check.Message, check.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uncommitted beads changes", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-sync-div-unc-*")
|
||||||
|
initRepo(t, dir, "main")
|
||||||
|
|
||||||
|
// Create .beads with JSONL and commit it
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
jsonlContent := `{"id":"test-1","title":"Test issue","status":"open"}` + "\n"
|
||||||
|
commitFile(t, dir, ".beads/issues.jsonl", jsonlContent, "add issues")
|
||||||
|
|
||||||
|
// Now modify the file without committing
|
||||||
|
// This triggers both jsonl_git_mismatch AND uncommitted_beads
|
||||||
|
newContent := jsonlContent + `{"id":"test-2","title":"Another issue","status":"open"}` + "\n"
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(newContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := CheckSyncDivergence(dir)
|
||||||
|
// Multiple divergence issues = error status
|
||||||
|
if check.Status != StatusError {
|
||||||
|
t.Errorf("status=%q want %q (msg=%q)", check.Status, StatusError, check.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(check.Detail, "Uncommitted") {
|
||||||
|
t.Errorf("detail=%q want to mention uncommitted", check.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSONL differs from git HEAD", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-sync-div-diff-*")
|
||||||
|
initRepo(t, dir, "main")
|
||||||
|
|
||||||
|
// Create .beads with JSONL and commit it
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
jsonlContent := `{"id":"test-1","title":"Test issue","status":"open"}` + "\n"
|
||||||
|
commitFile(t, dir, ".beads/issues.jsonl", jsonlContent, "add issues")
|
||||||
|
|
||||||
|
// Modify without committing
|
||||||
|
newContent := `{"id":"test-1","title":"Test issue","status":"closed"}` + "\n"
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(newContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := CheckSyncDivergence(dir)
|
||||||
|
if check.Status != StatusWarning && check.Status != StatusError {
|
||||||
|
t.Errorf("status=%q want warning or error (msg=%q)", check.Status, check.Message)
|
||||||
|
}
|
||||||
|
// Should detect either JSONL differs or uncommitted changes
|
||||||
|
if !strings.Contains(check.Detail, "JSONL") && !strings.Contains(check.Detail, "Uncommitted") {
|
||||||
|
t.Errorf("detail=%q want to mention JSONL or uncommitted", check.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSQLiteMtimeDivergence(t *testing.T) {
|
||||||
|
t.Run("no database", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-mtime-nodb-*")
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := checkSQLiteMtimeDivergence(dir, beadsDir)
|
||||||
|
if issue != nil {
|
||||||
|
t.Errorf("expected nil issue for no database, got %+v", issue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no JSONL", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-mtime-nojsonl-*")
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a dummy database
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = db.Exec("CREATE TABLE issues (id TEXT)")
|
||||||
|
_, _ = db.Exec("CREATE TABLE config (key TEXT, value TEXT)")
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
issue := checkSQLiteMtimeDivergence(dir, beadsDir)
|
||||||
|
if issue != nil {
|
||||||
|
t.Errorf("expected nil issue for no JSONL, got %+v", issue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no last_import_time", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-mtime-noimport-*")
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database without last_import_time
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = db.Exec("CREATE TABLE issues (id TEXT)")
|
||||||
|
_, _ = db.Exec("CREATE TABLE config (key TEXT, value TEXT)")
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
// Create JSONL
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1"}`+"\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := checkSQLiteMtimeDivergence(dir, beadsDir)
|
||||||
|
if issue == nil {
|
||||||
|
t.Error("expected issue for missing last_import_time")
|
||||||
|
} else if issue.Type != "sqlite_mtime_stale" {
|
||||||
|
t.Errorf("type=%q want sqlite_mtime_stale", issue.Type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("times match", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-mtime-match-*")
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JSONL first
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1"}`+"\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get JSONL mtime
|
||||||
|
jsonlInfo, _ := os.Stat(jsonlPath)
|
||||||
|
importTime := jsonlInfo.ModTime()
|
||||||
|
|
||||||
|
// Create database with matching last_import_time
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = db.Exec("CREATE TABLE issues (id TEXT)")
|
||||||
|
_, _ = db.Exec("CREATE TABLE config (key TEXT, value TEXT)")
|
||||||
|
_, _ = db.Exec("INSERT INTO config (key, value) VALUES (?, ?)",
|
||||||
|
"last_import_time", importTime.Format(time.RFC3339))
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
issue := checkSQLiteMtimeDivergence(dir, beadsDir)
|
||||||
|
if issue != nil {
|
||||||
|
t.Errorf("expected nil issue for matching times, got %+v", issue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSONL newer than import", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-mtime-newer-*")
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database with old last_import_time
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = db.Exec("CREATE TABLE issues (id TEXT)")
|
||||||
|
_, _ = db.Exec("CREATE TABLE config (key TEXT, value TEXT)")
|
||||||
|
oldTime := time.Now().Add(-1 * time.Hour)
|
||||||
|
_, _ = db.Exec("INSERT INTO config (key, value) VALUES (?, ?)",
|
||||||
|
"last_import_time", oldTime.Format(time.RFC3339))
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
// Create JSONL (will have current mtime)
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1"}`+"\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := checkSQLiteMtimeDivergence(dir, beadsDir)
|
||||||
|
if issue == nil {
|
||||||
|
t.Error("expected issue for JSONL newer than import")
|
||||||
|
} else {
|
||||||
|
if issue.Type != "sqlite_mtime_stale" {
|
||||||
|
t.Errorf("type=%q want sqlite_mtime_stale", issue.Type)
|
||||||
|
}
|
||||||
|
if !strings.Contains(issue.FixCommand, "import") {
|
||||||
|
t.Errorf("fix=%q want import command", issue.FixCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckUncommittedBeadsChanges(t *testing.T) {
|
||||||
|
t.Run("no uncommitted changes", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-uncommit-clean-*")
|
||||||
|
initRepo(t, dir, "main")
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonlContent := `{"id":"test-1"}` + "\n"
|
||||||
|
commitFile(t, dir, ".beads/issues.jsonl", jsonlContent, "add issues")
|
||||||
|
|
||||||
|
issue := checkUncommittedBeadsChanges(dir, beadsDir)
|
||||||
|
if issue != nil {
|
||||||
|
t.Errorf("expected nil issue for clean state, got %+v", issue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uncommitted changes present", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-uncommit-dirty-*")
|
||||||
|
initRepo(t, dir, "main")
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonlContent := `{"id":"test-1"}` + "\n"
|
||||||
|
commitFile(t, dir, ".beads/issues.jsonl", jsonlContent, "add issues")
|
||||||
|
|
||||||
|
// Modify without committing
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
newContent := jsonlContent + `{"id":"test-2"}` + "\n"
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(newContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := checkUncommittedBeadsChanges(dir, beadsDir)
|
||||||
|
if issue == nil {
|
||||||
|
t.Error("expected issue for uncommitted changes")
|
||||||
|
} else {
|
||||||
|
if issue.Type != "uncommitted_beads" {
|
||||||
|
t.Errorf("type=%q want uncommitted_beads", issue.Type)
|
||||||
|
}
|
||||||
|
if !strings.Contains(issue.Description, "Uncommitted") {
|
||||||
|
t.Errorf("description=%q want Uncommitted", issue.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindJSONLFile(t *testing.T) {
|
||||||
|
t.Run("issues.jsonl", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-findjsonl-*")
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(`{}`+"\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := findJSONLFile(beadsDir)
|
||||||
|
if found != jsonlPath {
|
||||||
|
t.Errorf("found=%q want %q", found, jsonlPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("beads.jsonl", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-findjsonl2-*")
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte(`{}`+"\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := findJSONLFile(beadsDir)
|
||||||
|
if found != jsonlPath {
|
||||||
|
t.Errorf("found=%q want %q", found, jsonlPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no jsonl", func(t *testing.T) {
|
||||||
|
dir := mkTmpDirInTmp(t, "bd-findjsonl3-*")
|
||||||
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := findJSONLFile(beadsDir)
|
||||||
|
if found != "" {
|
||||||
|
t.Errorf("found=%q want empty", found)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user