feat(dolt): implement automatic bootstrap from JSONL on first access

Add automatic Dolt database bootstrapping when JSONL files exist but no
Dolt database is present (cold-start scenario after git clone).

Key features:
- Lock/wait pattern prevents concurrent bootstrap races
- Graceful degradation skips malformed JSONL lines with warnings
- Multi-table ordering: issues → labels → dependencies
- Prefix auto-detection from JSONL content

New files:
- internal/storage/dolt/bootstrap.go - Bootstrap logic
- internal/storage/dolt/bootstrap_test.go - Comprehensive tests

Modified:
- internal/storage/factory/factory_dolt.go - Integration point

Closes: hq-ew1mbr.10

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
obsidian
2026-01-17 01:54:31 -08:00
committed by gastown/crew/dennis
parent 87f84c5fa6
commit 2cbffca4f3
35 changed files with 812 additions and 7266 deletions

View File

@@ -0,0 +1,434 @@
//go:build cgo
package dolt
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/lockfile"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// BootstrapResult contains statistics about the bootstrap operation
type BootstrapResult struct {
IssuesImported int
IssuesSkipped int
ParseErrors []ParseError
PrefixDetected string
}
// ParseError describes a JSONL parsing error
type ParseError struct {
Line int
Message string
Snippet string
}
// BootstrapConfig controls bootstrap behavior
type BootstrapConfig struct {
BeadsDir string // Path to .beads directory
DoltPath string // Path to dolt subdirectory
LockTimeout time.Duration // Timeout waiting for bootstrap lock
}
// Bootstrap checks if Dolt DB needs bootstrapping from JSONL and performs it if needed.
// This is called during store creation to handle the cold-start scenario where
// JSONL files exist (from git clone) but no Dolt database exists yet.
//
// Returns:
// - true, result, nil: Bootstrap was performed successfully
// - false, nil, nil: No bootstrap needed (Dolt already exists or no JSONL)
// - false, nil, err: Bootstrap failed
func Bootstrap(ctx context.Context, cfg BootstrapConfig) (bool, *BootstrapResult, error) {
if cfg.LockTimeout == 0 {
cfg.LockTimeout = 30 * time.Second
}
// Check if Dolt database already exists and is ready
if doltExists(cfg.DoltPath) && schemaReady(ctx, cfg.DoltPath) {
return false, nil, nil
}
// Check if JSONL exists to bootstrap from
jsonlPath := findJSONLPath(cfg.BeadsDir)
if jsonlPath == "" {
// No JSONL to bootstrap from - let normal init handle it
return false, nil, nil
}
// Acquire bootstrap lock to prevent concurrent bootstraps
lockPath := cfg.DoltPath + ".bootstrap.lock"
lockFile, err := acquireBootstrapLock(lockPath, cfg.LockTimeout)
if err != nil {
return false, nil, fmt.Errorf("bootstrap lock timeout: %w", err)
}
defer releaseBootstrapLock(lockFile, lockPath)
// Double-check after acquiring lock - another process may have bootstrapped
if doltExists(cfg.DoltPath) && schemaReady(ctx, cfg.DoltPath) {
return false, nil, nil
}
// Perform bootstrap
result, err := performBootstrap(ctx, cfg, jsonlPath)
if err != nil {
return false, nil, err
}
return true, result, nil
}
// doltExists checks if a Dolt database directory exists
func doltExists(doltPath string) bool {
// The embedded Dolt driver creates the database in a subdirectory
// named after the database (default: "beads"), with .dolt inside that.
// So we check for any subdirectory containing a .dolt directory.
entries, err := os.ReadDir(doltPath)
if err != nil {
return false
}
for _, entry := range entries {
if entry.IsDir() {
doltDir := filepath.Join(doltPath, entry.Name(), ".dolt")
if info, err := os.Stat(doltDir); err == nil && info.IsDir() {
return true
}
}
}
return false
}
// schemaReady checks if the Dolt database has the required schema
// This is a simple check based on the existence of expected files.
// We avoid opening a connection here since the caller will do that.
func schemaReady(_ context.Context, doltPath string) bool {
// The embedded Dolt driver stores databases in subdirectories.
// Check for the expected database name's config.json which indicates
// the database was initialized.
configPath := filepath.Join(doltPath, "beads", ".dolt", "config.json")
_, err := os.Stat(configPath)
return err == nil
}
// findJSONLPath looks for JSONL files in the beads directory
func findJSONLPath(beadsDir string) string {
// Check in order of preference
candidates := []string{
filepath.Join(beadsDir, "issues.jsonl"),
filepath.Join(beadsDir, "beads.jsonl"), // Legacy name
}
for _, path := range candidates {
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path
}
}
return ""
}
// acquireBootstrapLock acquires an exclusive lock for bootstrap operations
func acquireBootstrapLock(lockPath string, timeout time.Duration) (*os.File, error) {
// Create lock file
// #nosec G304 - controlled path
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return nil, fmt.Errorf("failed to create lock file: %w", err)
}
// Try to acquire lock with timeout
deadline := time.Now().Add(timeout)
for {
err := lockfile.FlockExclusiveBlocking(f)
if err == nil {
// Lock acquired
return f, nil
}
if time.Now().After(deadline) {
_ = f.Close()
return nil, fmt.Errorf("timeout waiting for bootstrap lock")
}
// Wait briefly before retrying
time.Sleep(100 * time.Millisecond)
}
}
// releaseBootstrapLock releases the bootstrap lock and removes the lock file
func releaseBootstrapLock(f *os.File, lockPath string) {
if f != nil {
_ = lockfile.FlockUnlock(f)
_ = f.Close()
}
// Clean up lock file
_ = os.Remove(lockPath)
}
// performBootstrap performs the actual bootstrap from JSONL
func performBootstrap(ctx context.Context, cfg BootstrapConfig, jsonlPath string) (*BootstrapResult, error) {
result := &BootstrapResult{}
// Parse JSONL with graceful error handling
issues, parseErrors := parseJSONLWithErrors(jsonlPath)
result.ParseErrors = parseErrors
if len(parseErrors) > 0 {
fmt.Fprintf(os.Stderr, "Bootstrap: Skipped %d malformed lines during bootstrap:\n", len(parseErrors))
maxShow := 5
if len(parseErrors) < maxShow {
maxShow = len(parseErrors)
}
for i := 0; i < maxShow; i++ {
e := parseErrors[i]
fmt.Fprintf(os.Stderr, " Line %d: %s\n", e.Line, e.Message)
}
if len(parseErrors) > maxShow {
fmt.Fprintf(os.Stderr, " ... and %d more\n", len(parseErrors)-maxShow)
}
}
if len(issues) == 0 {
return nil, fmt.Errorf("no valid issues found in JSONL file %s", jsonlPath)
}
// Detect prefix from issues
result.PrefixDetected = detectPrefixFromIssues(issues)
// Create Dolt store (this initializes schema)
store, err := New(ctx, &Config{Path: cfg.DoltPath})
if err != nil {
return nil, fmt.Errorf("failed to create Dolt store: %w", err)
}
defer func() { _ = store.Close() }()
// Set issue prefix
if result.PrefixDetected != "" {
if err := store.SetConfig(ctx, "issue_prefix", result.PrefixDetected); err != nil {
return nil, fmt.Errorf("failed to set issue_prefix: %w", err)
}
}
// Import issues in a transaction
imported, skipped, err := importIssuesBootstrap(ctx, store, issues)
if err != nil {
return nil, fmt.Errorf("failed to import issues: %w", err)
}
result.IssuesImported = imported
result.IssuesSkipped = skipped
// Commit the bootstrap
if err := store.Commit(ctx, "Bootstrap from JSONL"); err != nil {
// Non-fatal - data is still in the database
fmt.Fprintf(os.Stderr, "Bootstrap: warning: failed to create Dolt commit: %v\n", err)
}
return result, nil
}
// parseJSONLWithErrors parses JSONL, collecting errors instead of failing
func parseJSONLWithErrors(jsonlPath string) ([]*types.Issue, []ParseError) {
// #nosec G304 - controlled path
f, err := os.Open(jsonlPath)
if err != nil {
return nil, []ParseError{{Line: 0, Message: fmt.Sprintf("failed to open file: %v", err)}}
}
defer func() { _ = f.Close() }()
var issues []*types.Issue
var parseErrors []ParseError
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 1024), 2*1024*1024) // 2MB buffer for large lines
lineNo := 0
for scanner.Scan() {
lineNo++
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
// Skip Git merge conflict markers
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "<<<<<<< ") ||
trimmed == "=======" ||
strings.HasPrefix(trimmed, ">>>>>>> ") {
parseErrors = append(parseErrors, ParseError{
Line: lineNo,
Message: "Git merge conflict marker",
Snippet: truncateSnippet(line, 50),
})
continue
}
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
parseErrors = append(parseErrors, ParseError{
Line: lineNo,
Message: err.Error(),
Snippet: truncateSnippet(line, 50),
})
continue
}
// Apply defaults for omitted fields
issue.SetDefaults()
// Fix closed_at invariant
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
now := time.Now()
issue.ClosedAt = &now
}
issues = append(issues, &issue)
}
if err := scanner.Err(); err != nil {
parseErrors = append(parseErrors, ParseError{
Line: lineNo,
Message: fmt.Sprintf("scanner error: %v", err),
})
}
return issues, parseErrors
}
// truncateSnippet truncates a string for display
func truncateSnippet(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// detectPrefixFromIssues detects the most common prefix from issues
func detectPrefixFromIssues(issues []*types.Issue) string {
prefixCounts := make(map[string]int)
for _, issue := range issues {
if issue.ID == "" {
continue
}
prefix := utils.ExtractIssuePrefix(issue.ID)
if prefix != "" {
prefixCounts[prefix]++
}
}
// Find most common prefix
var maxPrefix string
var maxCount int
for prefix, count := range prefixCounts {
if count > maxCount {
maxPrefix = prefix
maxCount = count
}
}
return maxPrefix
}
// importIssuesBootstrap imports issues during bootstrap
// Returns (imported, skipped, error)
func importIssuesBootstrap(ctx context.Context, store *DoltStore, issues []*types.Issue) (int, int, error) {
// Skip validation during bootstrap since we're importing existing data
// The data was already validated when originally created
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
imported := 0
skipped := 0
seenIDs := make(map[string]bool)
for _, issue := range issues {
// Skip duplicates within batch
if seenIDs[issue.ID] {
skipped++
continue
}
seenIDs[issue.ID] = true
// Set timestamps if missing
now := time.Now().UTC()
if issue.CreatedAt.IsZero() {
issue.CreatedAt = now
}
if issue.UpdatedAt.IsZero() {
issue.UpdatedAt = now
}
// Compute content hash if missing
if issue.ContentHash == "" {
issue.ContentHash = issue.ComputeContentHash()
}
// Insert issue
if err := insertIssue(ctx, tx, issue); err != nil {
// Check for duplicate key (issue already exists)
if strings.Contains(err.Error(), "Duplicate entry") ||
strings.Contains(err.Error(), "UNIQUE constraint") {
skipped++
continue
}
return imported, skipped, fmt.Errorf("failed to insert issue %s: %w", issue.ID, err)
}
// Import labels
for _, label := range issue.Labels {
_, err := tx.ExecContext(ctx, `
INSERT INTO labels (issue_id, label)
VALUES (?, ?)
`, issue.ID, label)
if err != nil && !strings.Contains(err.Error(), "Duplicate entry") {
return imported, skipped, fmt.Errorf("failed to insert label for %s: %w", issue.ID, err)
}
}
imported++
}
// Import dependencies in a second pass (after all issues exist)
for _, issue := range issues {
for _, dep := range issue.Dependencies {
// Check if both issues exist
var exists int
err := tx.QueryRowContext(ctx, "SELECT 1 FROM issues WHERE id = ?", dep.DependsOnID).Scan(&exists)
if err != nil {
// Target doesn't exist, skip dependency
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE type = type
`, dep.IssueID, dep.DependsOnID, dep.Type, "bootstrap", time.Now().UTC())
if err != nil && !strings.Contains(err.Error(), "Duplicate entry") {
// Non-fatal for dependencies
fmt.Fprintf(os.Stderr, "Bootstrap: warning: failed to import dependency %s -> %s: %v\n",
dep.IssueID, dep.DependsOnID, err)
}
}
}
if err := tx.Commit(); err != nil {
return imported, skipped, fmt.Errorf("failed to commit: %w", err)
}
return imported, skipped, nil
}

View File

@@ -0,0 +1,349 @@
//go:build cgo
package dolt
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestBootstrapFromJSONL(t *testing.T) {
// Create temp directory structure
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
doltDir := filepath.Join(beadsDir, "dolt")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create beads dir: %v", err)
}
// Create test JSONL file with issues
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
issues := []types.Issue{
{
ID: "test-001",
Title: "First issue",
Description: "Test description 1",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-time.Hour),
UpdatedAt: time.Now(),
},
{
ID: "test-002",
Title: "Second issue",
Description: "Test description 2",
Status: types.StatusInProgress,
Priority: 1,
IssueType: types.TypeBug,
CreatedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-30 * time.Minute),
Labels: []string{"urgent", "backend"},
},
{
ID: "test-003",
Title: "Closed issue",
Status: types.StatusClosed,
Priority: 3,
IssueType: types.TypeTask,
},
}
var jsonlContent strings.Builder
for _, issue := range issues {
data, err := json.Marshal(issue)
if err != nil {
t.Fatalf("failed to marshal issue: %v", err)
}
jsonlContent.Write(data)
jsonlContent.WriteString("\n")
}
if err := os.WriteFile(jsonlPath, []byte(jsonlContent.String()), 0644); err != nil {
t.Fatalf("failed to write JSONL: %v", err)
}
// Perform bootstrap
ctx := context.Background()
bootstrapped, result, err := Bootstrap(ctx, BootstrapConfig{
BeadsDir: beadsDir,
DoltPath: doltDir,
LockTimeout: 10 * time.Second,
})
if err != nil {
t.Fatalf("bootstrap failed: %v", err)
}
if !bootstrapped {
t.Fatal("expected bootstrap to be performed")
}
if result == nil {
t.Fatal("expected non-nil result")
}
// Verify results
if result.IssuesImported != 3 {
t.Errorf("expected 3 issues imported, got %d", result.IssuesImported)
}
if result.PrefixDetected != "test" {
t.Errorf("expected prefix 'test', got '%s'", result.PrefixDetected)
}
// Open store and verify issues were imported
store, err := New(ctx, &Config{Path: doltDir})
if err != nil {
t.Fatalf("failed to open store: %v", err)
}
defer store.Close()
// Check prefix was set
prefix, err := store.GetConfig(ctx, "issue_prefix")
if err != nil {
t.Fatalf("failed to get config: %v", err)
}
if prefix != "test" {
t.Errorf("expected prefix 'test', got '%s'", prefix)
}
// Check issues exist
issue1, err := store.GetIssue(ctx, "test-001")
if err != nil {
t.Fatalf("failed to get issue: %v", err)
}
if issue1 == nil {
t.Fatal("expected issue test-001 to exist")
}
if issue1.Title != "First issue" {
t.Errorf("expected title 'First issue', got '%s'", issue1.Title)
}
// Check labels were imported
issue2, err := store.GetIssue(ctx, "test-002")
if err != nil {
t.Fatalf("failed to get issue: %v", err)
}
if issue2 == nil {
t.Fatal("expected issue test-002 to exist")
}
if len(issue2.Labels) != 2 {
t.Errorf("expected 2 labels, got %d", len(issue2.Labels))
}
// Verify closed_at was set for closed issue
issue3, err := store.GetIssue(ctx, "test-003")
if err != nil {
t.Fatalf("failed to get issue: %v", err)
}
if issue3 == nil {
t.Fatal("expected issue test-003 to exist")
}
if issue3.ClosedAt == nil {
t.Error("expected closed_at to be set for closed issue")
}
}
func TestBootstrapNoOpWhenDoltExists(t *testing.T) {
// Create temp directory structure with existing Dolt DB
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
doltDir := filepath.Join(beadsDir, "dolt")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create beads dir: %v", err)
}
// Create a Dolt store first
ctx := context.Background()
store, err := New(ctx, &Config{Path: doltDir})
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
if err := store.SetConfig(ctx, "issue_prefix", "existing"); err != nil {
t.Fatalf("failed to set config: %v", err)
}
store.Close()
// Create JSONL file
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
issue := types.Issue{
ID: "new-001",
Title: "New issue",
Status: types.StatusOpen,
}
data, _ := json.Marshal(issue)
if err := os.WriteFile(jsonlPath, append(data, '\n'), 0644); err != nil {
t.Fatalf("failed to write JSONL: %v", err)
}
// Attempt bootstrap - should be no-op
bootstrapped, result, err := Bootstrap(ctx, BootstrapConfig{
BeadsDir: beadsDir,
DoltPath: doltDir,
LockTimeout: 10 * time.Second,
})
if err != nil {
t.Fatalf("bootstrap failed: %v", err)
}
if bootstrapped {
t.Error("expected no bootstrap when Dolt already exists")
}
if result != nil {
t.Error("expected nil result when no bootstrap performed")
}
// Verify original prefix preserved
store, err = New(ctx, &Config{Path: doltDir})
if err != nil {
t.Fatalf("failed to reopen store: %v", err)
}
defer store.Close()
prefix, _ := store.GetConfig(ctx, "issue_prefix")
if prefix != "existing" {
t.Errorf("expected prefix 'existing', got '%s'", prefix)
}
}
func TestBootstrapNoOpWhenNoJSONL(t *testing.T) {
// Create temp directory structure without JSONL
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
doltDir := filepath.Join(beadsDir, "dolt")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create beads dir: %v", err)
}
// Attempt bootstrap - should be no-op
ctx := context.Background()
bootstrapped, result, err := Bootstrap(ctx, BootstrapConfig{
BeadsDir: beadsDir,
DoltPath: doltDir,
LockTimeout: 10 * time.Second,
})
if err != nil {
t.Fatalf("bootstrap failed: %v", err)
}
if bootstrapped {
t.Error("expected no bootstrap when no JSONL exists")
}
if result != nil {
t.Error("expected nil result when no bootstrap performed")
}
}
func TestBootstrapGracefulDegradation(t *testing.T) {
// Create temp directory structure
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
doltDir := filepath.Join(beadsDir, "dolt")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create beads dir: %v", err)
}
// Create JSONL with some malformed lines
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
goodIssue := types.Issue{
ID: "test-001",
Title: "Good issue",
Status: types.StatusOpen,
}
goodData, _ := json.Marshal(goodIssue)
content := string(goodData) + "\n" +
"{invalid json}\n" +
"<<<<<<< HEAD\n" + // Git conflict marker
string(goodData) + "\n" // Duplicate - will be skipped
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
t.Fatalf("failed to write JSONL: %v", err)
}
// Perform bootstrap
ctx := context.Background()
bootstrapped, result, err := Bootstrap(ctx, BootstrapConfig{
BeadsDir: beadsDir,
DoltPath: doltDir,
LockTimeout: 10 * time.Second,
})
if err != nil {
t.Fatalf("bootstrap failed: %v", err)
}
if !bootstrapped {
t.Fatal("expected bootstrap to be performed")
}
// Should have parse errors for malformed lines
if len(result.ParseErrors) != 2 {
t.Errorf("expected 2 parse errors, got %d", len(result.ParseErrors))
}
// Should have imported the good issue
if result.IssuesImported != 1 {
t.Errorf("expected 1 issue imported, got %d", result.IssuesImported)
}
// Duplicate should be skipped (not errored)
if result.IssuesSkipped != 1 {
t.Errorf("expected 1 issue skipped, got %d", result.IssuesSkipped)
}
}
func TestParseJSONLWithErrors(t *testing.T) {
// Create temp file with mixed content
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "test.jsonl")
goodIssue := types.Issue{
ID: "test-001",
Title: "Good issue",
Status: types.StatusOpen,
}
goodData, _ := json.Marshal(goodIssue)
content := string(goodData) + "\n" +
"\n" + // Empty line - should be skipped
" \n" + // Whitespace line - should be skipped
"{broken\n" + // Invalid JSON
"<<<<<<< HEAD\n" + // Git conflict
"=======\n" + // Git conflict
">>>>>>> branch\n" + // Git conflict
string(goodData) + "\n" // Another good line
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
issues, errors := parseJSONLWithErrors(jsonlPath)
if len(issues) != 2 {
t.Errorf("expected 2 issues, got %d", len(issues))
}
// Should have 4 parse errors: 1 invalid JSON + 3 conflict markers
if len(errors) != 4 {
t.Errorf("expected 4 parse errors, got %d: %+v", len(errors), errors)
}
}
func TestDetectPrefixFromIssues(t *testing.T) {
issues := []*types.Issue{
{ID: "proj-001"},
{ID: "proj-002"},
{ID: "proj-003"},
{ID: "other-001"},
}
prefix := detectPrefixFromIssues(issues)
if prefix != "proj" {
t.Errorf("expected prefix 'proj', got '%s'", prefix)
}
}

View File

@@ -4,6 +4,9 @@ package factory
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/storage"
@@ -12,6 +15,32 @@ import (
func init() {
RegisterBackend(configfile.BackendDolt, func(ctx context.Context, path string, opts Options) (storage.Storage, error) {
// Check if bootstrap is needed (JSONL exists but Dolt doesn't)
// Path is the dolt subdirectory, parent is .beads directory
beadsDir := filepath.Dir(path)
bootstrapped, result, err := dolt.Bootstrap(ctx, dolt.BootstrapConfig{
BeadsDir: beadsDir,
DoltPath: path,
LockTimeout: opts.LockTimeout,
})
if err != nil {
return nil, fmt.Errorf("bootstrap failed: %w", err)
}
if bootstrapped && result != nil {
// Report bootstrap results
fmt.Fprintf(os.Stderr, "Bootstrapping Dolt from JSONL...\n")
if len(result.ParseErrors) > 0 {
fmt.Fprintf(os.Stderr, " Skipped %d malformed lines (see above for details)\n", len(result.ParseErrors))
}
fmt.Fprintf(os.Stderr, " Imported %d issues", result.IssuesImported)
if result.IssuesSkipped > 0 {
fmt.Fprintf(os.Stderr, ", skipped %d duplicates", result.IssuesSkipped)
}
fmt.Fprintf(os.Stderr, "\n Dolt database ready\n")
}
return dolt.New(ctx, &dolt.Config{Path: path, ReadOnly: opts.ReadOnly})
})
}