doctor: add JSONL integrity check/fix and harden repairs
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
@@ -353,9 +353,23 @@ func applyFixesInteractive(path string, issues []doctorCheck) {
|
|||||||
|
|
||||||
// applyFixList applies a list of fixes and reports results
|
// applyFixList applies a list of fixes and reports results
|
||||||
func applyFixList(path string, fixes []doctorCheck) {
|
func applyFixList(path string, fixes []doctorCheck) {
|
||||||
// Run corruption recovery before any operations that need a healthy SQLite DB.
|
// Apply fixes in a dependency-aware order.
|
||||||
priority := map[string]int{
|
// Rough dependency chain:
|
||||||
"Database Integrity": 0,
|
// permissions/daemon cleanup → config sanity → DB integrity/migrations → DB↔JSONL sync.
|
||||||
|
order := []string{
|
||||||
|
"Permissions",
|
||||||
|
"Daemon Health",
|
||||||
|
"Database Config",
|
||||||
|
"JSONL Config",
|
||||||
|
"Database Integrity",
|
||||||
|
"Database",
|
||||||
|
"Schema Compatibility",
|
||||||
|
"JSONL Integrity",
|
||||||
|
"DB-JSONL Sync",
|
||||||
|
}
|
||||||
|
priority := make(map[string]int, len(order))
|
||||||
|
for i, name := range order {
|
||||||
|
priority[name] = i
|
||||||
}
|
}
|
||||||
slices.SortStableFunc(fixes, func(a, b doctorCheck) int {
|
slices.SortStableFunc(fixes, func(a, b doctorCheck) int {
|
||||||
pa, oka := priority[a.Name]
|
pa, oka := priority[a.Name]
|
||||||
@@ -411,6 +425,8 @@ func applyFixList(path string, fixes []doctorCheck) {
|
|||||||
err = fix.DatabaseConfig(path)
|
err = fix.DatabaseConfig(path)
|
||||||
case "JSONL Config":
|
case "JSONL Config":
|
||||||
err = fix.LegacyJSONLConfig(path)
|
err = fix.LegacyJSONLConfig(path)
|
||||||
|
case "JSONL Integrity":
|
||||||
|
err = fix.JSONLIntegrity(path)
|
||||||
case "Deletions Manifest":
|
case "Deletions Manifest":
|
||||||
err = fix.MigrateTombstones(path)
|
err = fix.MigrateTombstones(path)
|
||||||
case "Untracked Files":
|
case "Untracked Files":
|
||||||
@@ -711,6 +727,13 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
result.Checks = append(result.Checks, configValuesCheck)
|
result.Checks = append(result.Checks, configValuesCheck)
|
||||||
// Don't fail overall check for config value warnings, just warn
|
// Don't fail overall check for config value warnings, just warn
|
||||||
|
|
||||||
|
// Check 7b: JSONL integrity (malformed lines, missing IDs)
|
||||||
|
jsonlIntegrityCheck := convertWithCategory(doctor.CheckJSONLIntegrity(path), doctor.CategoryData)
|
||||||
|
result.Checks = append(result.Checks, jsonlIntegrityCheck)
|
||||||
|
if jsonlIntegrityCheck.Status == statusWarning || jsonlIntegrityCheck.Status == statusError {
|
||||||
|
result.OverallOK = false
|
||||||
|
}
|
||||||
|
|
||||||
// Check 8: Daemon health
|
// Check 8: Daemon health
|
||||||
daemonCheck := convertWithCategory(doctor.CheckDaemonStatus(path, Version), doctor.CategoryRuntime)
|
daemonCheck := convertWithCategory(doctor.CheckDaemonStatus(path, Version), doctor.CategoryRuntime)
|
||||||
result.Checks = append(result.Checks, daemonCheck)
|
result.Checks = append(result.Checks, daemonCheck)
|
||||||
|
|||||||
@@ -301,15 +301,30 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
|
|||||||
// CheckDatabaseJSONLSync checks if database and JSONL are in sync
|
// CheckDatabaseJSONLSync checks if database and JSONL are in sync
|
||||||
func CheckDatabaseJSONLSync(path string) DoctorCheck {
|
func CheckDatabaseJSONLSync(path string) DoctorCheck {
|
||||||
beadsDir := filepath.Join(path, ".beads")
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
|
||||||
|
|
||||||
// Find JSONL file
|
// Resolve database path (respects metadata.json override).
|
||||||
var jsonlPath string
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
testPath := filepath.Join(beadsDir, name)
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
if _, err := os.Stat(testPath); err == nil {
|
}
|
||||||
jsonlPath = testPath
|
|
||||||
break
|
// Find JSONL file (respects metadata.json override when set).
|
||||||
|
jsonlPath := ""
|
||||||
|
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 {
|
||||||
|
jsonlPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonlPath == "" {
|
||||||
|
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
||||||
|
testPath := filepath.Join(beadsDir, name)
|
||||||
|
if _, err := os.Stat(testPath); err == nil {
|
||||||
|
jsonlPath = testPath
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,11 +407,16 @@ func CheckDatabaseJSONLSync(path string) DoctorCheck {
|
|||||||
|
|
||||||
// Use JSONL error if we got it earlier
|
// Use JSONL error if we got it earlier
|
||||||
if jsonlErr != nil {
|
if jsonlErr != nil {
|
||||||
|
fixMsg := "Run 'bd doctor --fix' to attempt recovery"
|
||||||
|
if strings.Contains(jsonlErr.Error(), "malformed") {
|
||||||
|
fixMsg = "Run 'bd doctor --fix' to back up and regenerate the JSONL from the database"
|
||||||
|
}
|
||||||
return DoctorCheck{
|
return DoctorCheck{
|
||||||
Name: "DB-JSONL Sync",
|
Name: "DB-JSONL Sync",
|
||||||
Status: StatusWarning,
|
Status: StatusWarning,
|
||||||
Message: "Unable to read JSONL file",
|
Message: "Unable to read JSONL file",
|
||||||
Detail: jsonlErr.Error(),
|
Detail: jsonlErr.Error(),
|
||||||
|
Fix: fixMsg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ func DatabaseIntegrity(path string) error {
|
|||||||
|
|
||||||
beadsDir := filepath.Join(absPath, ".beads")
|
beadsDir := filepath.Join(absPath, ".beads")
|
||||||
|
|
||||||
|
// Best-effort: stop any running daemon to reduce the chance of DB file locks.
|
||||||
|
_ = Daemon(absPath)
|
||||||
|
|
||||||
// Resolve database path (respects metadata.json database override).
|
// Resolve database path (respects metadata.json database override).
|
||||||
var dbPath string
|
var dbPath string
|
||||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
@@ -39,9 +42,11 @@ func DatabaseIntegrity(path string) error {
|
|||||||
// Find JSONL source of truth.
|
// Find JSONL source of truth.
|
||||||
jsonlPath := ""
|
jsonlPath := ""
|
||||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
||||||
candidate := cfg.JSONLPath(beadsDir)
|
if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) {
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
candidate := cfg.JSONLPath(beadsDir)
|
||||||
jsonlPath = candidate
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
jsonlPath = candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if jsonlPath == "" {
|
if jsonlPath == "" {
|
||||||
@@ -61,7 +66,12 @@ func DatabaseIntegrity(path string) error {
|
|||||||
ts := time.Now().UTC().Format("20060102T150405Z")
|
ts := time.Now().UTC().Format("20060102T150405Z")
|
||||||
backupDB := dbPath + "." + ts + ".corrupt.backup.db"
|
backupDB := dbPath + "." + ts + ".corrupt.backup.db"
|
||||||
if err := os.Rename(dbPath, backupDB); err != nil {
|
if err := os.Rename(dbPath, backupDB); err != nil {
|
||||||
return fmt.Errorf("failed to back up database: %w", err)
|
// Retry once after attempting to kill daemons again (helps on platforms with strict file locks).
|
||||||
|
_ = Daemon(absPath)
|
||||||
|
if err2 := os.Rename(dbPath, backupDB); err2 != nil {
|
||||||
|
// Prefer the original error (more likely root cause).
|
||||||
|
return fmt.Errorf("failed to back up database: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
||||||
sidecar := dbPath + suffix
|
sidecar := dbPath + suffix
|
||||||
@@ -84,7 +94,7 @@ func DatabaseIntegrity(path string) error {
|
|||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
// Best-effort rollback: attempt to restore the backup, preserving any partial init output.
|
// Best-effort rollback: attempt to restore the original DB, while preserving the backup.
|
||||||
failedTS := time.Now().UTC().Format("20060102T150405Z")
|
failedTS := time.Now().UTC().Format("20060102T150405Z")
|
||||||
if _, statErr := os.Stat(dbPath); statErr == nil {
|
if _, statErr := os.Stat(dbPath); statErr == nil {
|
||||||
failedDB := dbPath + "." + failedTS + ".failed.init.db"
|
failedDB := dbPath + "." + failedTS + ".failed.init.db"
|
||||||
@@ -93,9 +103,11 @@ func DatabaseIntegrity(path string) error {
|
|||||||
_ = os.Rename(dbPath+suffix, failedDB+suffix)
|
_ = os.Rename(dbPath+suffix, failedDB+suffix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = os.Rename(backupDB, dbPath)
|
_ = copyFile(backupDB, dbPath)
|
||||||
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
||||||
_ = os.Rename(backupDB+suffix, dbPath+suffix)
|
if _, statErr := os.Stat(backupDB + suffix); statErr == nil {
|
||||||
|
_ = copyFile(backupDB+suffix, dbPath+suffix)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to rebuild database from JSONL: %w (backup: %s)", err, backupDB)
|
return fmt.Errorf("failed to rebuild database from JSONL: %w (backup: %s)", err, backupDB)
|
||||||
}
|
}
|
||||||
|
|||||||
105
cmd/bd/doctor/fix/jsonl_integrity.go
Normal file
105
cmd/bd/doctor/fix/jsonl_integrity.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package fix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
|
"github.com/steveyegge/beads/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONLIntegrity backs up a malformed JSONL export and regenerates it from the database.
|
||||||
|
// This is safe only when a database exists and is readable.
|
||||||
|
func JSONLIntegrity(path string) error {
|
||||||
|
if err := validateBeadsWorkspace(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(absPath, ".beads")
|
||||||
|
|
||||||
|
// Resolve db path.
|
||||||
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cannot auto-repair JSONL: no database found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve JSONL export path.
|
||||||
|
jsonlPath := ""
|
||||||
|
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 {
|
||||||
|
jsonlPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonlPath == "" {
|
||||||
|
p := utils.FindJSONLInDir(beadsDir)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
jsonlPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonlPath == "" {
|
||||||
|
return fmt.Errorf("cannot auto-repair JSONL: no JSONL file found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back up the JSONL.
|
||||||
|
ts := time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
backup := jsonlPath + "." + ts + ".corrupt.backup.jsonl"
|
||||||
|
if err := os.Rename(jsonlPath, backup); err != nil {
|
||||||
|
return fmt.Errorf("failed to back up JSONL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binary, err := getBdBinary()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Rename(backup, jsonlPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export from DB.
|
||||||
|
cmd := newBdCmd(binary, "--db", dbPath, "export", "-o", jsonlPath, "--force")
|
||||||
|
cmd.Dir = absPath
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Best-effort rollback: restore the original JSONL, but keep the backup.
|
||||||
|
failedTS := time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
if _, statErr := os.Stat(jsonlPath); statErr == nil {
|
||||||
|
failed := jsonlPath + "." + failedTS + ".failed.regen.jsonl"
|
||||||
|
_ = os.Rename(jsonlPath, failed)
|
||||||
|
}
|
||||||
|
_ = copyFile(backup, jsonlPath)
|
||||||
|
return fmt.Errorf("failed to regenerate JSONL from database: %w (backup: %s)", err, backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src) // #nosec G304 -- src is within the workspace
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = out.Close() }()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DatabaseVersion fixes database version mismatches by running bd migrate,
|
// DatabaseVersion fixes database version mismatches by running bd migrate,
|
||||||
@@ -22,12 +25,15 @@ func DatabaseVersion(path string) error {
|
|||||||
|
|
||||||
// Check if database exists - if not, run init instead of migrate (bd-4h9)
|
// Check if database exists - if not, run init instead of migrate (bd-4h9)
|
||||||
beadsDir := filepath.Join(path, ".beads")
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
// No database - this is a fresh clone, run bd init
|
// No database - this is a fresh clone, run bd init
|
||||||
fmt.Println("→ No database found, running 'bd init' to hydrate from JSONL...")
|
fmt.Println("→ No database found, running 'bd init' to hydrate from JSONL...")
|
||||||
cmd := newBdCmd(bdBinary, "init")
|
cmd := newBdCmd(bdBinary, "--db", dbPath, "init")
|
||||||
cmd.Dir = path
|
cmd.Dir = path
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
@@ -40,7 +46,7 @@ func DatabaseVersion(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Database exists - run bd migrate
|
// Database exists - run bd migrate
|
||||||
cmd := newBdCmd(bdBinary, "migrate")
|
cmd := newBdCmd(bdBinary, "--db", dbPath, "migrate")
|
||||||
cmd.Dir = path // Set working directory without changing process dir
|
cmd.Dir = path // Set working directory without changing process dir
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|||||||
@@ -37,13 +37,23 @@ func DBJSONLSync(path string) error {
|
|||||||
|
|
||||||
// Find JSONL file
|
// Find JSONL file
|
||||||
var jsonlPath string
|
var jsonlPath string
|
||||||
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
||||||
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
|
if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) {
|
||||||
|
p := cfg.JSONLPath(beadsDir)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
jsonlPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonlPath == "" {
|
||||||
|
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
|
||||||
|
|
||||||
if _, err := os.Stat(issuesJSONL); err == nil {
|
if _, err := os.Stat(issuesJSONL); err == nil {
|
||||||
jsonlPath = issuesJSONL
|
jsonlPath = issuesJSONL
|
||||||
} else if _, err := os.Stat(beadsJSONL); err == nil {
|
} else if _, err := os.Stat(beadsJSONL); err == nil {
|
||||||
jsonlPath = beadsJSONL
|
jsonlPath = beadsJSONL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if both database and JSONL exist
|
// Check if both database and JSONL exist
|
||||||
@@ -103,8 +113,8 @@ func DBJSONLSync(path string) error {
|
|||||||
|
|
||||||
if syncDirection == "export" {
|
if syncDirection == "export" {
|
||||||
// Export DB to JSONL file (must specify -o to write to file, not stdout)
|
// Export DB to JSONL file (must specify -o to write to file, not stdout)
|
||||||
jsonlOutputPath := filepath.Join(beadsDir, "issues.jsonl")
|
jsonlOutputPath := jsonlPath
|
||||||
exportCmd := newBdCmd(bdBinary, "export", "-o", jsonlOutputPath, "--force")
|
exportCmd := newBdCmd(bdBinary, "--db", dbPath, "export", "-o", jsonlOutputPath, "--force")
|
||||||
exportCmd.Dir = path // Set working directory without changing process dir
|
exportCmd.Dir = path // Set working directory without changing process dir
|
||||||
exportCmd.Stdout = os.Stdout
|
exportCmd.Stdout = os.Stdout
|
||||||
exportCmd.Stderr = os.Stderr
|
exportCmd.Stderr = os.Stderr
|
||||||
@@ -114,7 +124,7 @@ func DBJSONLSync(path string) error {
|
|||||||
|
|
||||||
// Staleness check uses last_import_time. After exporting, JSONL mtime is newer,
|
// Staleness check uses last_import_time. After exporting, JSONL mtime is newer,
|
||||||
// so mark the DB as fresh by running a no-op import (skip existing issues).
|
// so mark the DB as fresh by running a no-op import (skip existing issues).
|
||||||
markFreshCmd := newBdCmd(bdBinary, "import", "-i", jsonlOutputPath, "--force", "--skip-existing", "--no-git-history")
|
markFreshCmd := newBdCmd(bdBinary, "--db", dbPath, "import", "-i", jsonlOutputPath, "--force", "--skip-existing", "--no-git-history")
|
||||||
markFreshCmd.Dir = path
|
markFreshCmd.Dir = path
|
||||||
markFreshCmd.Stdout = os.Stdout
|
markFreshCmd.Stdout = os.Stdout
|
||||||
markFreshCmd.Stderr = os.Stderr
|
markFreshCmd.Stderr = os.Stderr
|
||||||
@@ -125,7 +135,7 @@ func DBJSONLSync(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
importCmd := newBdCmd(bdBinary, "sync", "--import-only")
|
importCmd := newBdCmd(bdBinary, "--db", dbPath, "sync", "--import-only")
|
||||||
importCmd.Dir = path // Set working directory without changing process dir
|
importCmd.Dir = path // Set working directory without changing process dir
|
||||||
importCmd.Stdout = os.Stdout
|
importCmd.Stdout = os.Stdout
|
||||||
importCmd.Stderr = os.Stderr
|
importCmd.Stderr = os.Stderr
|
||||||
|
|||||||
123
cmd/bd/doctor/jsonl_integrity.go
Normal file
123
cmd/bd/doctor/jsonl_integrity.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
|
"github.com/steveyegge/beads/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CheckJSONLIntegrity(path string) DoctorCheck {
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
|
||||||
|
// Resolve JSONL path.
|
||||||
|
jsonlPath := ""
|
||||||
|
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 {
|
||||||
|
jsonlPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonlPath == "" {
|
||||||
|
// Fall back to a best-effort discovery within .beads/.
|
||||||
|
p := utils.FindJSONLInDir(beadsDir)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
jsonlPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonlPath == "" {
|
||||||
|
return DoctorCheck{Name: "JSONL Integrity", Status: StatusOK, Message: "N/A (no JSONL file)"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort scan for malformed lines.
|
||||||
|
f, err := os.Open(jsonlPath) // #nosec G304 -- jsonlPath is within the workspace
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "JSONL Integrity",
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: "Unable to read JSONL file",
|
||||||
|
Detail: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var malformed int
|
||||||
|
var examples []string
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
lineNo := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNo++
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &v); err != nil || v.ID == "" {
|
||||||
|
malformed++
|
||||||
|
if len(examples) < 5 {
|
||||||
|
if err != nil {
|
||||||
|
examples = append(examples, fmt.Sprintf("line %d: %v", lineNo, err))
|
||||||
|
} else {
|
||||||
|
examples = append(examples, fmt.Sprintf("line %d: missing id", lineNo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "JSONL Integrity",
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: "Unable to scan JSONL file",
|
||||||
|
Detail: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if malformed == 0 {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "JSONL Integrity",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: fmt.Sprintf("%s looks valid", filepath.Base(jsonlPath)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a database, we can auto-repair by re-exporting from DB.
|
||||||
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "JSONL Integrity",
|
||||||
|
Status: StatusError,
|
||||||
|
Message: fmt.Sprintf("%s has %d malformed line(s)", filepath.Base(jsonlPath), malformed),
|
||||||
|
Detail: strings.Join(examples, "\n"),
|
||||||
|
Fix: "Restore the JSONL file from git or from a backup (no database available for auto-repair).",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "JSONL Integrity",
|
||||||
|
Status: StatusError,
|
||||||
|
Message: fmt.Sprintf("%s has %d malformed line(s)", filepath.Base(jsonlPath), malformed),
|
||||||
|
Detail: strings.Join(examples, "\n"),
|
||||||
|
Fix: "Run 'bd doctor --fix' to back up the JSONL and regenerate it from the database.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSystemJSONLFilename(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "deletions.jsonl", "interactions.jsonl", "molecules.jsonl":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
43
cmd/bd/doctor/jsonl_integrity_test.go
Normal file
43
cmd/bd/doctor/jsonl_integrity_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckJSONLIntegrity_MalformedLine(t *testing.T) {
|
||||||
|
ws := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(ws, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte("{\"id\":\"t-1\"}\n{not json}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Ensure DB exists so check suggests auto-repair.
|
||||||
|
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := CheckJSONLIntegrity(ws)
|
||||||
|
if check.Status != StatusError {
|
||||||
|
t.Fatalf("expected StatusError, got %v (%s)", check.Status, check.Message)
|
||||||
|
}
|
||||||
|
if check.Fix == "" {
|
||||||
|
t.Fatalf("expected Fix guidance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckJSONLIntegrity_NoJSONL(t *testing.T) {
|
||||||
|
ws := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(ws, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check := CheckJSONLIntegrity(ws)
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Fatalf("expected StatusOK, got %v (%s)", check.Status, check.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDoctorRepair_CorruptDatabase_NotADatabase_RebuildFromJSONL(t *testing.T) {
|
func TestDoctorRepair_CorruptDatabase_NotADatabase_RebuildFromJSONL(t *testing.T) {
|
||||||
@@ -131,3 +135,171 @@ func TestDoctorRepair_CorruptDatabase_BacksUpSidecars(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDoctorRepair_CorruptDatabase_WithRunningDaemon_FixSucceeds(t *testing.T) {
|
||||||
|
bdExe := buildBDForTest(t)
|
||||||
|
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-daemon-*")
|
||||||
|
dbPath := filepath.Join(ws, ".beads", "beads.db")
|
||||||
|
jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl")
|
||||||
|
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
|
||||||
|
t.Fatalf("bd init failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
|
||||||
|
t.Fatalf("bd create failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
|
||||||
|
t.Fatalf("bd export failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := startDaemonForChaosTest(t, bdExe, ws, dbPath)
|
||||||
|
defer func() {
|
||||||
|
if cmd.Process != nil && (cmd.ProcessState == nil || !cmd.ProcessState.Exited()) {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_, _ = cmd.Process.Wait()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Corrupt the DB.
|
||||||
|
if err := os.WriteFile(dbPath, []byte("not a database"), 0644); err != nil {
|
||||||
|
t.Fatalf("corrupt db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
|
||||||
|
t.Fatalf("bd doctor --fix failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we can cleanly stop the daemon afterwards (repair shouldn't wedge it).
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
select {
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("expected daemon to exit when killed")
|
||||||
|
case <-done:
|
||||||
|
// ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorRepair_JSONLIntegrity_MalformedLine_ReexportFromDB(t *testing.T) {
|
||||||
|
bdExe := buildBDForTest(t)
|
||||||
|
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-jsonl-*")
|
||||||
|
dbPath := filepath.Join(ws, ".beads", "beads.db")
|
||||||
|
jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl")
|
||||||
|
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
|
||||||
|
t.Fatalf("bd init failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
|
||||||
|
t.Fatalf("bd create failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
|
||||||
|
t.Fatalf("bd export failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corrupt JSONL (leave DB intact).
|
||||||
|
f, err := os.OpenFile(jsonlPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open jsonl: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.WriteString("{not json}\n"); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
t.Fatalf("append corrupt jsonl: %v", err)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
|
||||||
|
t.Fatalf("bd doctor --fix failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(jsonlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read jsonl: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "{not json}") {
|
||||||
|
t.Fatalf("expected JSONL to be regenerated without corrupt line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorRepair_CorruptDatabase_ReadOnlyBeadsDir_PermissionsFixMakesWritable(t *testing.T) {
|
||||||
|
bdExe := buildBDForTest(t)
|
||||||
|
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-readonly-*")
|
||||||
|
beadsDir := filepath.Join(ws, ".beads")
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
|
||||||
|
t.Fatalf("bd init failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
|
||||||
|
t.Fatalf("bd create failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
|
||||||
|
t.Fatalf("bd export failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corrupt the DB.
|
||||||
|
if err := os.Truncate(dbPath, 64); err != nil {
|
||||||
|
t.Fatalf("truncate db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make .beads read-only; the Permissions fix should make it writable again.
|
||||||
|
if err := os.Chmod(beadsDir, 0555); err != nil {
|
||||||
|
t.Fatalf("chmod beads dir: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(beadsDir, 0755) })
|
||||||
|
|
||||||
|
if out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
|
||||||
|
t.Fatalf("expected bd doctor --fix to succeed (permissions auto-fix), got: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat beads dir: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm()&0200 == 0 {
|
||||||
|
t.Fatalf("expected .beads to be writable after permissions fix, mode=%v", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDaemonForChaosTest(t *testing.T, bdExe, ws, dbPath string) *exec.Cmd {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(bdExe, "--db", dbPath, "daemon", "--start", "--foreground", "--local", "--interval", "10m")
|
||||||
|
cmd.Dir = ws
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
// Inherit environment, but explicitly ensure daemon mode is allowed.
|
||||||
|
env := make([]string, 0, len(os.Environ())+1)
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
if strings.HasPrefix(e, "BEADS_NO_DAEMON=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env = append(env, e)
|
||||||
|
}
|
||||||
|
cmd.Env = env
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Fatalf("start daemon: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for socket to appear.
|
||||||
|
sock := filepath.Join(ws, ".beads", "bd.sock")
|
||||||
|
deadline := time.Now().Add(8 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if _, err := os.Stat(sock); err == nil {
|
||||||
|
// Put the process back into the caller's control.
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
cmd.Stderr = io.Discard
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_ = cmd.Wait()
|
||||||
|
t.Fatalf("daemon failed to start (no socket: %s)\nstdout:\n%s\nstderr:\n%s", sock, stdout.String(), stderr.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
118
cmd/bd/test_repo_beads_guard_test.go
Normal file
118
cmd/bd/test_repo_beads_guard_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guardrail: ensure the cmd/bd test suite does not touch the real repo .beads state.
|
||||||
|
// Disable with BEADS_TEST_GUARD_DISABLE=1 (useful when running tests while actively using beads).
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv("BEADS_TEST_GUARD_DISABLE") != "" {
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot := findRepoRoot()
|
||||||
|
if repoRoot == "" {
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
repoBeadsDir := filepath.Join(repoRoot, ".beads")
|
||||||
|
if _, err := os.Stat(repoBeadsDir); err != nil {
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
watch := []string{
|
||||||
|
"beads.db",
|
||||||
|
"beads.db-wal",
|
||||||
|
"beads.db-shm",
|
||||||
|
"beads.db-journal",
|
||||||
|
"issues.jsonl",
|
||||||
|
"beads.jsonl",
|
||||||
|
"metadata.json",
|
||||||
|
"interactions.jsonl",
|
||||||
|
"deletions.jsonl",
|
||||||
|
"molecules.jsonl",
|
||||||
|
"daemon.lock",
|
||||||
|
"daemon.pid",
|
||||||
|
"bd.sock",
|
||||||
|
}
|
||||||
|
|
||||||
|
before := snapshotFiles(repoBeadsDir, watch)
|
||||||
|
code := m.Run()
|
||||||
|
after := snapshotFiles(repoBeadsDir, watch)
|
||||||
|
|
||||||
|
if diff := diffSnapshots(before, after); diff != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: test suite modified repo .beads state:\n%s\n", diff)
|
||||||
|
if code == 0 {
|
||||||
|
code = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileSnap struct {
|
||||||
|
exists bool
|
||||||
|
size int64
|
||||||
|
modUnix int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotFiles(dir string, names []string) map[string]fileSnap {
|
||||||
|
out := make(map[string]fileSnap, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
out[name] = fileSnap{exists: false}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[name] = fileSnap{exists: true, size: info.Size(), modUnix: info.ModTime().UnixNano()}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffSnapshots(before, after map[string]fileSnap) string {
|
||||||
|
var out string
|
||||||
|
for name, b := range before {
|
||||||
|
a := after[name]
|
||||||
|
if b.exists != a.exists {
|
||||||
|
out += fmt.Sprintf("- %s: exists %v → %v\n", name, b.exists, a.exists)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !b.exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.size != a.size || b.modUnix != a.modUnix {
|
||||||
|
out += fmt.Sprintf("- %s: size %d → %d, mtime %s → %s\n",
|
||||||
|
name,
|
||||||
|
b.size,
|
||||||
|
a.size,
|
||||||
|
time.Unix(0, b.modUnix).UTC().Format(time.RFC3339Nano),
|
||||||
|
time.Unix(0, a.modUnix).UTC().Format(time.RFC3339Nano),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRepoRoot() string {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil {
|
||||||
|
return wd
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(wd)
|
||||||
|
if parent == wd {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
wd = parent
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user