Implement JSONL export/import and shift to text-first architecture

This is a fundamental architectural shift from binary SQLite to JSONL as
the source of truth for git workflows.

## New Features

- `bd export --format=jsonl` - Export issues to JSON Lines format
- `bd import` - Import issues from JSONL (create new, update existing)
- `--skip-existing` flag for import to only create new issues

## Architecture Change

**Before:** Binary SQLite database committed to git
**After:** JSONL text files as source of truth, SQLite as ephemeral cache

Benefits:
- Git-friendly text format with clean diffs
- AI-resolvable merge conflicts (append-only is 95% conflict-free)
- Human-readable issue tracking in git
- No binary merge conflicts

## Documentation

- Updated README with JSONL-first workflow and git hooks
- Added TEXT_FORMATS.md analyzing JSONL vs CSV vs binary
- Updated GIT_WORKFLOW.md with historical context
- .gitignore now excludes *.db, includes .beads/*.jsonl

## Implementation Details

- Export sorts issues by ID for consistent diffs
- Import handles both creates and updates atomically
- Proper handling of pointer fields (EstimatedMinutes)
- All tests passing

## Breaking Changes

- Database files (*.db) should now be gitignored
- Use export/import workflow for git collaboration
- Git hooks recommended for automation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-10-12 01:17:50 -07:00
parent 9105059843
commit 15afb5ad17
25 changed files with 3322 additions and 129 deletions

View File

@@ -12,7 +12,7 @@ import (
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/steveyackey/beads/internal/types"
"github.com/steveyegge/beads/internal/types"
)
// SQLiteStorage implements the Storage interface using SQLite
@@ -94,7 +94,14 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
// Generate ID if not set (thread-safe)
if issue.ID == "" {
s.idMu.Lock()
issue.ID = fmt.Sprintf("bd-%d", s.nextID)
// Get prefix from config, default to "bd"
prefix, err := s.GetConfig(ctx, "issue_prefix")
if err != nil || prefix == "" {
prefix = "bd"
}
issue.ID = fmt.Sprintf("%s-%d", prefix, s.nextID)
s.nextID++
s.idMu.Unlock()
}
@@ -129,7 +136,11 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
}
// Record creation event
eventData, _ := json.Marshal(issue)
eventData, err := json.Marshal(issue)
if err != nil {
// Fall back to minimal description if marshaling fails
eventData = []byte(fmt.Sprintf(`{"id":"%s","title":"%s"}`, issue.ID, issue.Title))
}
eventDataStr := string(eventData)
_, err = tx.ExecContext(ctx, `
INSERT INTO events (issue_id, event_type, actor, new_value)
@@ -272,8 +283,16 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[
}
// Record event
oldData, _ := json.Marshal(oldIssue)
newData, _ := json.Marshal(updates)
oldData, err := json.Marshal(oldIssue)
if err != nil {
// Fall back to minimal description if marshaling fails
oldData = []byte(fmt.Sprintf(`{"id":"%s"}`, id))
}
newData, err := json.Marshal(updates)
if err != nil {
// Fall back to minimal description if marshaling fails
newData = []byte(`{}`)
}
oldDataStr := string(oldData)
newDataStr := string(newData)
@@ -365,7 +384,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
limitSQL := ""
if filter.Limit > 0 {
limitSQL = fmt.Sprintf(" LIMIT %d", filter.Limit)
limitSQL = " LIMIT ?"
args = append(args, filter.Limit)
}
querySQL := fmt.Sprintf(`
@@ -418,6 +438,25 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
return issues, nil
}
// SetConfig sets a configuration value
func (s *SQLiteStorage) SetConfig(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO config (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value
`, key, value)
return err
}
// GetConfig gets a configuration value
func (s *SQLiteStorage) GetConfig(ctx context.Context, key string) (string, error) {
var value string
err := s.db.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
// Close closes the database connection
func (s *SQLiteStorage) Close() error {
return s.db.Close()