refactor: deduplicate FindJSONLInDir function (bd-8a5)

Extract shared JSONL file discovery logic to internal/utils/path.go.
Both autoimport and beads packages now use this shared implementation.

Changes:
- Add utils.FindJSONLInDir with common logic
- Update autoimport.go to use utils.FindJSONLInDir
- Update beads.go to delegate to utils.FindJSONLInDir
- Update server_export_import_auto.go to use utils.FindJSONLInDir
- Move FindJSONLInDir test to utils/path_test.go
- Fix pre-existing duplicate countIssuesInJSONLFile in init.go

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-28 23:07:53 -08:00
parent 52fe60859a
commit 40b07045c7
8 changed files with 201 additions and 256 deletions

View File

@@ -15,6 +15,7 @@ import (
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// Notifier handles user notifications during import
@@ -65,9 +66,9 @@ func AutoImportIfNewer(ctx context.Context, store storage.Storage, dbPath string
notify = NewStderrNotifier(debug.Enabled())
}
// Find JSONL using database directory (same logic as beads.FindJSONLPath)
// Find JSONL using database directory
dbDir := filepath.Dir(dbPath)
jsonlPath := FindJSONLInDir(dbDir)
jsonlPath := utils.FindJSONLInDir(dbDir)
if jsonlPath == "" {
notify.Debugf("auto-import skipped, JSONL not found")
return nil
@@ -282,7 +283,7 @@ func CheckStaleness(ctx context.Context, store storage.Storage, dbPath string) (
// Find JSONL using database directory
dbDir := filepath.Dir(dbPath)
jsonlPath := FindJSONLInDir(dbDir)
jsonlPath := utils.FindJSONLInDir(dbDir)
stat, err := os.Stat(jsonlPath)
if err != nil {
@@ -297,46 +298,3 @@ func CheckStaleness(ctx context.Context, store storage.Storage, dbPath string) (
return stat.ModTime().After(lastImportTime), nil
}
// FindJSONLInDir finds the JSONL file in the given directory.
// It prefers issues.jsonl over other .jsonl files to prevent accidentally
// reading/writing to deletions.jsonl or merge artifacts (bd-tqo fix).
// Always returns a path (defaults to issues.jsonl if nothing suitable found).
func FindJSONLInDir(dbDir string) string {
pattern := filepath.Join(dbDir, "*.jsonl")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
// Default to issues.jsonl if glob fails or no matches
return filepath.Join(dbDir, "issues.jsonl")
}
// Prefer issues.jsonl over other .jsonl files (bd-tqo fix)
// This prevents accidentally using deletions.jsonl or merge artifacts
for _, match := range matches {
if filepath.Base(match) == "issues.jsonl" {
return match
}
}
// Fall back to beads.jsonl for legacy support
for _, match := range matches {
if filepath.Base(match) == "beads.jsonl" {
return match
}
}
// Last resort: use first match (but skip deletions.jsonl and merge artifacts)
for _, match := range matches {
base := filepath.Base(match)
// Skip deletions manifest and merge artifacts
if base == "deletions.jsonl" ||
base == "beads.base.jsonl" ||
base == "beads.left.jsonl" ||
base == "beads.right.jsonl" {
continue
}
return match
}
// If only deletions/merge files exist, default to issues.jsonl
return filepath.Join(dbDir, "issues.jsonl")
}

View File

@@ -518,78 +518,3 @@ func TestStderrNotifier(t *testing.T) {
})
}
// TestFindJSONLInDir tests that FindJSONLInDir correctly prefers issues.jsonl
// and avoids deletions.jsonl and merge artifacts (bd-tqo fix)
func TestFindJSONLInDir(t *testing.T) {
tests := []struct {
name string
files []string
expected string
}{
{
name: "only issues.jsonl",
files: []string{"issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "issues.jsonl and deletions.jsonl - prefers issues",
files: []string{"deletions.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "issues.jsonl with merge artifacts - prefers issues",
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "beads.jsonl as legacy fallback",
files: []string{"beads.jsonl"},
expected: "beads.jsonl",
},
{
name: "issues.jsonl preferred over beads.jsonl",
files: []string{"beads.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "only deletions.jsonl - returns default issues.jsonl",
files: []string{"deletions.jsonl"},
expected: "issues.jsonl",
},
{
name: "only merge artifacts - returns default issues.jsonl",
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl"},
expected: "issues.jsonl",
},
{
name: "no files - returns default issues.jsonl",
files: []string{},
expected: "issues.jsonl",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-findjsonl-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create test files
for _, file := range tt.files {
path := filepath.Join(tmpDir, file)
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
}
result := FindJSONLInDir(tmpDir)
got := filepath.Base(result)
if got != tt.expected {
t.Errorf("FindJSONLInDir() = %q, want %q", got, tt.expected)
}
})
}
}

View File

@@ -278,41 +278,8 @@ func FindJSONLPath(dbPath string) string {
return ""
}
// Get the directory containing the database
dbDir := filepath.Dir(dbPath)
// Look for existing .jsonl files in the .beads directory
pattern := filepath.Join(dbDir, "*.jsonl")
matches, err := filepath.Glob(pattern)
if err == nil && len(matches) > 0 {
// bd-6xd: Prefer issues.jsonl over beads.jsonl (canonical name)
for _, match := range matches {
if filepath.Base(match) == "issues.jsonl" {
return match
}
}
// bd-tqo: Fall back to beads.jsonl for legacy support
for _, match := range matches {
if filepath.Base(match) == "beads.jsonl" {
return match
}
}
// bd-tqo: Skip deletions.jsonl and merge artifacts to prevent corruption
for _, match := range matches {
base := filepath.Base(match)
if base == "deletions.jsonl" ||
base == "beads.base.jsonl" ||
base == "beads.left.jsonl" ||
base == "beads.right.jsonl" {
continue
}
return match
}
// If only deletions/merge files exist, fall through to default
}
// bd-6xd: Default to issues.jsonl (canonical name)
return filepath.Join(dbDir, "issues.jsonl")
// Get the directory containing the database and delegate to shared utility
return utils.FindJSONLInDir(filepath.Dir(dbPath))
}
// DatabaseInfo contains information about a discovered beads database

View File

@@ -18,6 +18,7 @@ import (
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// handleExport handles the export operation
@@ -443,7 +444,7 @@ func (s *Server) triggerExport(ctx context.Context, store storage.Storage, dbPat
// Find JSONL path using database directory
// Use FindJSONLInDir to prefer issues.jsonl over other .jsonl files (bd-tqo fix)
dbDir := filepath.Dir(dbPath)
jsonlPath := autoimport.FindJSONLInDir(dbDir)
jsonlPath := utils.FindJSONLInDir(dbDir)
// Get all issues from storage
sqliteStore, ok := store.(*sqlite.SQLiteStorage)

View File

@@ -5,6 +5,56 @@ import (
"path/filepath"
)
// FindJSONLInDir finds the JSONL file in the given .beads directory.
// It prefers issues.jsonl over other .jsonl files to prevent accidentally
// reading/writing to deletions.jsonl or merge artifacts (bd-tqo fix).
// Always returns a path (defaults to issues.jsonl if nothing suitable found).
//
// Search order:
// 1. issues.jsonl (canonical name)
// 2. beads.jsonl (legacy support)
// 3. Any other .jsonl file except deletions/merge artifacts
// 4. Default to issues.jsonl
func FindJSONLInDir(dbDir string) string {
pattern := filepath.Join(dbDir, "*.jsonl")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
// Default to issues.jsonl if glob fails or no matches
return filepath.Join(dbDir, "issues.jsonl")
}
// Prefer issues.jsonl over other .jsonl files (bd-tqo fix)
// This prevents accidentally using deletions.jsonl or merge artifacts
for _, match := range matches {
if filepath.Base(match) == "issues.jsonl" {
return match
}
}
// Fall back to beads.jsonl for legacy support
for _, match := range matches {
if filepath.Base(match) == "beads.jsonl" {
return match
}
}
// Last resort: use first match (but skip deletions.jsonl and merge artifacts)
for _, match := range matches {
base := filepath.Base(match)
// Skip deletions manifest and merge artifacts
if base == "deletions.jsonl" ||
base == "beads.base.jsonl" ||
base == "beads.left.jsonl" ||
base == "beads.right.jsonl" {
continue
}
return match
}
// If only deletions/merge files exist, default to issues.jsonl
return filepath.Join(dbDir, "issues.jsonl")
}
// CanonicalizePath converts a path to its canonical form by:
// 1. Converting to absolute path
// 2. Resolving symlinks

View File

@@ -72,6 +72,82 @@ func TestCanonicalizePath(t *testing.T) {
}
}
// TestFindJSONLInDir tests that FindJSONLInDir correctly prefers issues.jsonl
// and avoids deletions.jsonl and merge artifacts (bd-tqo fix)
func TestFindJSONLInDir(t *testing.T) {
tests := []struct {
name string
files []string
expected string
}{
{
name: "only issues.jsonl",
files: []string{"issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "issues.jsonl and deletions.jsonl - prefers issues",
files: []string{"deletions.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "issues.jsonl with merge artifacts - prefers issues",
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "beads.jsonl as legacy fallback",
files: []string{"beads.jsonl"},
expected: "beads.jsonl",
},
{
name: "issues.jsonl preferred over beads.jsonl",
files: []string{"beads.jsonl", "issues.jsonl"},
expected: "issues.jsonl",
},
{
name: "only deletions.jsonl - returns default issues.jsonl",
files: []string{"deletions.jsonl"},
expected: "issues.jsonl",
},
{
name: "only merge artifacts - returns default issues.jsonl",
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl"},
expected: "issues.jsonl",
},
{
name: "no files - returns default issues.jsonl",
files: []string{},
expected: "issues.jsonl",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-findjsonl-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create test files
for _, file := range tt.files {
path := filepath.Join(tmpDir, file)
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
}
result := FindJSONLInDir(tmpDir)
got := filepath.Base(result)
if got != tt.expected {
t.Errorf("FindJSONLInDir() = %q, want %q", got, tt.expected)
}
})
}
}
func TestCanonicalizePathSymlink(t *testing.T) {
// Create a temporary directory
tmpDir := t.TempDir()