Skip to content

Commit efd7568

Browse files
steveyeggeMelsovCOZYclaude
committed
feat: bd bootstrap executes recovery actions instead of printing advice
Make bootstrap actually run init, restore, and sync operations rather than just printing manual commands. Adds confirmation prompt, proper error handling, and tests for action detection logic. Cherry-picked from PR #2516. Co-authored-by: Melsov Yernur <MelsovCOZY@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e42d222 commit efd7568

File tree

2 files changed

+190
-10
lines changed

2 files changed

+190
-10
lines changed

cmd/bd/bootstrap.go

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package main
22

33
import (
4+
"bufio"
5+
"context"
46
"fmt"
57
"os"
68
"path/filepath"
9+
"strings"
710

811
"github.com/spf13/cobra"
912
"github.com/steveyegge/beads/internal/beads"
1013
"github.com/steveyegge/beads/internal/config"
1114
"github.com/steveyegge/beads/internal/configfile"
1215
"github.com/steveyegge/beads/internal/doltserver"
16+
"github.com/steveyegge/beads/internal/storage/dolt"
17+
"golang.org/x/term"
1318
)
1419

1520
var bootstrapCmd = &cobra.Command{
@@ -77,7 +82,10 @@ Examples:
7782
}
7883

7984
// Execute the plan
80-
executeBootstrapPlan(plan, cfg)
85+
if err := executeBootstrapPlan(plan, cfg); err != nil {
86+
fmt.Fprintf(os.Stderr, "Bootstrap failed: %v\n", err)
87+
os.Exit(1)
88+
}
8189
},
8290
}
8391

@@ -153,19 +161,120 @@ func printBootstrapPlan(plan BootstrapPlan) {
153161
}
154162
}
155163

156-
func executeBootstrapPlan(plan BootstrapPlan, cfg *configfile.Config) {
164+
// confirmPrompt asks the user to confirm an action. Returns true if the user
165+
// confirms or if stdin is not a terminal (non-interactive/CI contexts).
166+
func confirmPrompt(message string) bool {
167+
if !term.IsTerminal(int(os.Stdin.Fd())) {
168+
return true
169+
}
170+
fmt.Fprintf(os.Stderr, "%s [Y/n] ", message)
171+
reader := bufio.NewReader(os.Stdin)
172+
line, _ := reader.ReadString('\n')
173+
line = strings.TrimSpace(strings.ToLower(line))
174+
return line == "" || line == "y" || line == "yes"
175+
}
176+
177+
func executeBootstrapPlan(plan BootstrapPlan, cfg *configfile.Config) error {
178+
if !confirmPrompt("Proceed?") {
179+
fmt.Fprintf(os.Stderr, "Aborted.\n")
180+
return nil
181+
}
182+
183+
ctx := context.Background()
184+
157185
switch plan.Action {
158186
case "sync":
159-
fmt.Printf("Syncing from %s...\n", plan.SyncRemote)
160-
fmt.Printf("Run: bd init --prefix %s\n", inferPrefix(cfg))
161-
fmt.Printf("(bd init detects sync.git-remote and bootstraps non-destructively)\n")
187+
return executeSyncAction(ctx, plan, cfg)
162188
case "restore":
163-
fmt.Printf("Restoring from backup...\n")
164-
fmt.Printf("Run: bd backup restore\n")
189+
return executeRestoreAction(ctx, plan, cfg)
165190
case "init":
166-
fmt.Printf("Creating fresh database...\n")
167-
fmt.Printf("Run: bd init --prefix %s\n", inferPrefix(cfg))
191+
return executeInitAction(ctx, plan, cfg)
192+
}
193+
return nil
194+
}
195+
196+
func executeInitAction(ctx context.Context, plan BootstrapPlan, cfg *configfile.Config) error {
197+
doltDir := doltserver.ResolveDoltDir(plan.BeadsDir)
198+
if err := os.MkdirAll(doltDir, 0o750); err != nil {
199+
return fmt.Errorf("create dolt directory: %w", err)
200+
}
201+
202+
prefix := inferPrefix(cfg)
203+
dbName := cfg.GetDoltDatabase()
204+
205+
store, err := dolt.New(ctx, &dolt.Config{
206+
Path: doltDir,
207+
Database: dbName,
208+
CreateIfMissing: true,
209+
AutoStart: true,
210+
BeadsDir: plan.BeadsDir,
211+
})
212+
if err != nil {
213+
return fmt.Errorf("create database: %w", err)
214+
}
215+
defer func() { _ = store.Close() }()
216+
217+
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
218+
return fmt.Errorf("set issue prefix: %w", err)
219+
}
220+
if err := store.Commit(ctx, "bd bootstrap"); err != nil {
221+
return fmt.Errorf("commit: %w", err)
222+
}
223+
224+
fmt.Fprintf(os.Stderr, "Created fresh database with prefix %q\n", prefix)
225+
return nil
226+
}
227+
228+
func executeRestoreAction(ctx context.Context, plan BootstrapPlan, cfg *configfile.Config) error {
229+
doltDir := doltserver.ResolveDoltDir(plan.BeadsDir)
230+
if err := os.MkdirAll(doltDir, 0o750); err != nil {
231+
return fmt.Errorf("create dolt directory: %w", err)
232+
}
233+
234+
prefix := inferPrefix(cfg)
235+
dbName := cfg.GetDoltDatabase()
236+
237+
store, err := dolt.New(ctx, &dolt.Config{
238+
Path: doltDir,
239+
Database: dbName,
240+
CreateIfMissing: true,
241+
AutoStart: true,
242+
BeadsDir: plan.BeadsDir,
243+
})
244+
if err != nil {
245+
return fmt.Errorf("create database: %w", err)
246+
}
247+
defer func() { _ = store.Close() }()
248+
249+
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
250+
return fmt.Errorf("set issue prefix: %w", err)
251+
}
252+
if err := store.Commit(ctx, "bd bootstrap: init"); err != nil {
253+
return fmt.Errorf("commit init: %w", err)
254+
}
255+
256+
result, err := runBackupRestore(ctx, store, plan.BackupDir, false)
257+
if err != nil {
258+
return fmt.Errorf("restore from backup: %w", err)
259+
}
260+
261+
fmt.Fprintf(os.Stderr, "Restored from backup: %d issues, %d comments, %d dependencies, %d labels\n",
262+
result.Issues, result.Comments, result.Dependencies, result.Labels)
263+
return nil
264+
}
265+
266+
func executeSyncAction(ctx context.Context, plan BootstrapPlan, cfg *configfile.Config) error {
267+
doltDir := doltserver.ResolveDoltDir(plan.BeadsDir)
268+
dbName := cfg.GetDoltDatabase()
269+
270+
synced, err := dolt.BootstrapFromGitRemoteWithDB(ctx, doltDir, plan.SyncRemote, dbName)
271+
if err != nil {
272+
return fmt.Errorf("sync from remote: %w", err)
273+
}
274+
if synced {
275+
fmt.Fprintf(os.Stderr, "Synced database from %s\n", plan.SyncRemote)
168276
}
277+
return nil
169278
}
170279

171280
func inferPrefix(cfg *configfile.Config) string {
@@ -180,5 +289,4 @@ func inferPrefix(cfg *configfile.Config) string {
180289
func init() {
181290
bootstrapCmd.Flags().Bool("dry-run", false, "Show what would be done without doing it")
182291
rootCmd.AddCommand(bootstrapCmd)
183-
readOnlyCommands["bootstrap"] = true
184292
}

cmd/bd/bootstrap_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//go:build cgo
2+
3+
package main
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/steveyegge/beads/internal/configfile"
11+
)
12+
13+
func TestDetectBootstrapAction_NoneWhenDatabaseExists(t *testing.T) {
14+
tmpDir := t.TempDir()
15+
beadsDir := filepath.Join(tmpDir, ".beads")
16+
if err := os.MkdirAll(beadsDir, 0o750); err != nil {
17+
t.Fatal(err)
18+
}
19+
20+
// Create dolt directory with content so it's detected as existing
21+
doltDir := filepath.Join(beadsDir, "dolt")
22+
if err := os.MkdirAll(filepath.Join(doltDir, "beads"), 0o750); err != nil {
23+
t.Fatal(err)
24+
}
25+
26+
cfg := configfile.DefaultConfig()
27+
plan := detectBootstrapAction(beadsDir, cfg)
28+
29+
if plan.Action != "none" {
30+
t.Errorf("action = %q, want %q", plan.Action, "none")
31+
}
32+
if !plan.HasExisting {
33+
t.Error("HasExisting = false, want true")
34+
}
35+
}
36+
37+
func TestDetectBootstrapAction_RestoreWhenBackupExists(t *testing.T) {
38+
tmpDir := t.TempDir()
39+
beadsDir := filepath.Join(tmpDir, ".beads")
40+
backupDir := filepath.Join(beadsDir, "backup")
41+
if err := os.MkdirAll(backupDir, 0o750); err != nil {
42+
t.Fatal(err)
43+
}
44+
if err := os.WriteFile(filepath.Join(backupDir, "issues.jsonl"), []byte("{}"), 0o644); err != nil {
45+
t.Fatal(err)
46+
}
47+
48+
cfg := configfile.DefaultConfig()
49+
plan := detectBootstrapAction(beadsDir, cfg)
50+
51+
if plan.Action != "restore" {
52+
t.Errorf("action = %q, want %q", plan.Action, "restore")
53+
}
54+
if plan.BackupDir != backupDir {
55+
t.Errorf("BackupDir = %q, want %q", plan.BackupDir, backupDir)
56+
}
57+
}
58+
59+
func TestDetectBootstrapAction_InitWhenNothingExists(t *testing.T) {
60+
tmpDir := t.TempDir()
61+
beadsDir := filepath.Join(tmpDir, ".beads")
62+
if err := os.MkdirAll(beadsDir, 0o750); err != nil {
63+
t.Fatal(err)
64+
}
65+
66+
cfg := configfile.DefaultConfig()
67+
plan := detectBootstrapAction(beadsDir, cfg)
68+
69+
if plan.Action != "init" {
70+
t.Errorf("action = %q, want %q", plan.Action, "init")
71+
}
72+
}

0 commit comments

Comments
 (0)