feat(doctor): add fresh clone detection check (bd-4ew)
Add CheckFreshClone function that detects when JSONL contains issues but no database exists. Recommends 'bd init --prefix <detected-prefix>' to hydrate the database. This check appears early in doctor output to guide users on fresh clones. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -451,6 +451,14 @@ func runDiagnostics(path string) doctorResult {
|
||||
return result
|
||||
}
|
||||
|
||||
// Check 1a: Fresh clone detection (bd-4ew)
|
||||
// Must come early - if this is a fresh clone, other checks may be misleading
|
||||
freshCloneCheck := convertDoctorCheck(doctor.CheckFreshClone(path))
|
||||
result.Checks = append(result.Checks, freshCloneCheck)
|
||||
if freshCloneCheck.Status == statusWarning || freshCloneCheck.Status == statusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
// Check 2: Database version
|
||||
dbCheck := checkDatabaseVersion(path)
|
||||
result.Checks = append(result.Checks, dbCheck)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
)
|
||||
|
||||
@@ -330,3 +333,132 @@ func CheckDatabaseConfig(repoPath string) DoctorCheck {
|
||||
" 3. Or rename the files to match the configuration",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckFreshClone detects if this is a fresh clone that needs 'bd init'.
|
||||
// A fresh clone has JSONL with issues but no database file.
|
||||
// bd-4ew: Recommend 'bd init --prefix <detected-prefix>' for fresh clones.
|
||||
func CheckFreshClone(repoPath string) DoctorCheck {
|
||||
beadsDir := filepath.Join(repoPath, ".beads")
|
||||
|
||||
// Check if .beads/ exists
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Fresh Clone",
|
||||
Status: "ok",
|
||||
Message: "N/A (no .beads directory)",
|
||||
}
|
||||
}
|
||||
|
||||
// Find the JSONL file
|
||||
var jsonlPath string
|
||||
var jsonlName string
|
||||
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
||||
testPath := filepath.Join(beadsDir, name)
|
||||
if _, err := os.Stat(testPath); err == nil {
|
||||
jsonlPath = testPath
|
||||
jsonlName = name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// No JSONL file - not a fresh clone situation
|
||||
if jsonlPath == "" {
|
||||
return DoctorCheck{
|
||||
Name: "Fresh Clone",
|
||||
Status: "ok",
|
||||
Message: "N/A (no JSONL file)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if database exists
|
||||
var dbPath string
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||
dbPath = cfg.DatabasePath(beadsDir)
|
||||
} else {
|
||||
// Fall back to canonical database name
|
||||
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
}
|
||||
|
||||
// If database exists, not a fresh clone
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
return DoctorCheck{
|
||||
Name: "Fresh Clone",
|
||||
Status: "ok",
|
||||
Message: "Database exists",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if JSONL has any issues (empty JSONL = not really a fresh clone)
|
||||
issueCount, prefix := countJSONLIssuesAndPrefix(jsonlPath)
|
||||
if issueCount == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Fresh Clone",
|
||||
Status: "ok",
|
||||
Message: fmt.Sprintf("JSONL exists but is empty (%s)", jsonlName),
|
||||
}
|
||||
}
|
||||
|
||||
// This is a fresh clone! JSONL has issues but no database.
|
||||
fixCmd := "bd init"
|
||||
if prefix != "" {
|
||||
fixCmd = fmt.Sprintf("bd init --prefix %s", prefix)
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Fresh Clone",
|
||||
Status: "warning",
|
||||
Message: fmt.Sprintf("Fresh clone detected (%d issues in %s, no database)", issueCount, jsonlName),
|
||||
Detail: "This appears to be a freshly cloned repository.\n" +
|
||||
" The JSONL file contains issues but no local database exists.\n" +
|
||||
" Run 'bd init' to create the database and import existing issues.",
|
||||
Fix: fmt.Sprintf("Run '%s' to initialize the database and import issues", fixCmd),
|
||||
}
|
||||
}
|
||||
|
||||
// countJSONLIssuesAndPrefix counts issues in a JSONL file and detects the most common prefix.
|
||||
func countJSONLIssuesAndPrefix(jsonlPath string) (int, string) {
|
||||
file, err := os.Open(jsonlPath) //nolint:gosec
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
count := 0
|
||||
prefixCounts := make(map[string]int)
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &issue); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if issue.ID != "" {
|
||||
count++
|
||||
// Extract prefix (everything before the last dash)
|
||||
if lastDash := strings.LastIndex(issue.ID, "-"); lastDash > 0 {
|
||||
prefix := issue.ID[:lastDash]
|
||||
prefixCounts[prefix]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find most common prefix
|
||||
var mostCommonPrefix string
|
||||
maxCount := 0
|
||||
for prefix, cnt := range prefixCounts {
|
||||
if cnt > maxCount {
|
||||
maxCount = cnt
|
||||
mostCommonPrefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
return count, mostCommonPrefix
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -362,3 +363,116 @@ func TestCheckLegacyJSONLConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFreshClone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hasBeadsDir bool
|
||||
jsonlFile string // name of JSONL file to create
|
||||
jsonlIssues []string // issue IDs to put in JSONL
|
||||
hasDatabase bool
|
||||
expectedStatus string
|
||||
expectPrefix string // expected prefix in fix message
|
||||
}{
|
||||
{
|
||||
name: "no beads directory",
|
||||
hasBeadsDir: false,
|
||||
expectedStatus: "ok",
|
||||
},
|
||||
{
|
||||
name: "no JSONL file",
|
||||
hasBeadsDir: true,
|
||||
jsonlFile: "",
|
||||
expectedStatus: "ok",
|
||||
},
|
||||
{
|
||||
name: "database exists",
|
||||
hasBeadsDir: true,
|
||||
jsonlFile: "issues.jsonl",
|
||||
jsonlIssues: []string{"bd-abc", "bd-def"},
|
||||
hasDatabase: true,
|
||||
expectedStatus: "ok",
|
||||
},
|
||||
{
|
||||
name: "empty JSONL",
|
||||
hasBeadsDir: true,
|
||||
jsonlFile: "issues.jsonl",
|
||||
jsonlIssues: []string{},
|
||||
hasDatabase: false,
|
||||
expectedStatus: "ok",
|
||||
},
|
||||
{
|
||||
name: "fresh clone with issues.jsonl (bd-4ew)",
|
||||
hasBeadsDir: true,
|
||||
jsonlFile: "issues.jsonl",
|
||||
jsonlIssues: []string{"bd-abc", "bd-def", "bd-ghi"},
|
||||
hasDatabase: false,
|
||||
expectedStatus: "warning",
|
||||
expectPrefix: "bd",
|
||||
},
|
||||
{
|
||||
name: "fresh clone with beads.jsonl",
|
||||
hasBeadsDir: true,
|
||||
jsonlFile: "beads.jsonl",
|
||||
jsonlIssues: []string{"proj-1", "proj-2"},
|
||||
hasDatabase: false,
|
||||
expectedStatus: "warning",
|
||||
expectPrefix: "proj",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
|
||||
if tt.hasBeadsDir {
|
||||
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create JSONL file with issues
|
||||
if tt.jsonlFile != "" {
|
||||
jsonlPath := filepath.Join(beadsDir, tt.jsonlFile)
|
||||
file, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, issueID := range tt.jsonlIssues {
|
||||
issue := map[string]string{"id": issueID, "title": "Test issue"}
|
||||
data, _ := json.Marshal(issue)
|
||||
file.Write(data)
|
||||
file.WriteString("\n")
|
||||
}
|
||||
file.Close()
|
||||
}
|
||||
|
||||
// Create database if needed
|
||||
if tt.hasDatabase {
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
if err := os.WriteFile(dbPath, []byte("fake db"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
check := CheckFreshClone(tmpDir)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s (message: %s)", tt.expectedStatus, check.Status, check.Message)
|
||||
}
|
||||
|
||||
if tt.expectedStatus == "warning" {
|
||||
if check.Fix == "" {
|
||||
t.Error("Expected fix message for warning, got empty string")
|
||||
}
|
||||
if tt.expectPrefix != "" && !strings.Contains(check.Fix, tt.expectPrefix) {
|
||||
t.Errorf("Expected fix to contain prefix %q, got: %s", tt.expectPrefix, check.Fix)
|
||||
}
|
||||
if !strings.Contains(check.Fix, "bd init") {
|
||||
t.Error("Expected fix to mention 'bd init'")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user