Add multi-repo CLI commands and integrate with daemon sync

This commit is contained in:
Steve Yegge
2025-11-04 16:32:47 -08:00
parent 13c552a239
commit 67710e4a0c
2 changed files with 260 additions and 1 deletions

View File

@@ -12,11 +12,28 @@ import (
"github.com/steveyegge/beads"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// exportToJSONLWithStore exports issues to JSONL using the provided store
// exportToJSONLWithStore exports issues to JSONL using the provided store.
// If multi-repo mode is configured, routes issues to their respective JSONL files.
// Otherwise, exports to a single JSONL file.
func exportToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPath string) error {
// Try multi-repo export first
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if ok {
results, err := sqliteStore.ExportToMultiRepo(ctx)
if err != nil {
return fmt.Errorf("multi-repo export failed: %w", err)
}
if results != nil {
// Multi-repo mode active - export succeeded
return nil
}
}
// Single-repo mode - use existing logic
// Get all issues
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
@@ -120,6 +137,20 @@ func exportToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat
// importToJSONLWithStore imports issues from JSONL using the provided store
func importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPath string) error {
// Try multi-repo import first
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if ok {
results, err := sqliteStore.HydrateFromMultiRepo(ctx)
if err != nil {
return fmt.Errorf("multi-repo import failed: %w", err)
}
if results != nil {
// Multi-repo mode active - import succeeded
return nil
}
}
// Single-repo mode - use existing logic
// Read JSONL file
file, err := os.Open(jsonlPath) // #nosec G304 - controlled path from config
if err != nil {

228
cmd/bd/repo.go Normal file
View File

@@ -0,0 +1,228 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
)
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Manage multiple repository configuration",
Long: `Configure and manage multiple repository support for multi-clone sync.
Examples:
bd repo add ~/.beads-planning # Add planning repo
bd repo add ../other-repo "notes" # Add with alias
bd repo list # Show all configured repos
bd repo remove notes # Remove by alias
bd repo remove ~/.beads-planning # Remove by path`,
}
var repoAddCmd = &cobra.Command{
Use: "add <path> [alias]",
Short: "Add an additional repository to sync",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := ensureDirectMode("repo add requires direct database access"); err != nil {
return err
}
ctx := context.Background()
path := args[0]
var alias string
if len(args) > 1 {
alias = args[1]
}
// Use path as key if no alias provided
key := alias
if key == "" {
key = path
}
// Get existing repos
existing, err := getRepoConfig(ctx, store)
if err != nil {
return fmt.Errorf("failed to get existing repos: %w", err)
}
existing[key] = path
// Save back
if err := setRepoConfig(ctx, store, existing); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
if jsonOutput {
result := map[string]interface{}{
"added": true,
"key": key,
"path": path,
}
return json.NewEncoder(os.Stdout).Encode(result)
}
fmt.Printf("Added repository: %s → %s\n", key, path)
return nil
},
}
var repoRemoveCmd = &cobra.Command{
Use: "remove <key>",
Short: "Remove a repository from sync configuration",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := ensureDirectMode("repo remove requires direct database access"); err != nil {
return err
}
ctx := context.Background()
key := args[0]
// Get existing repos
existing, err := getRepoConfig(ctx, store)
if err != nil {
return fmt.Errorf("failed to get existing repos: %w", err)
}
path, exists := existing[key]
if !exists {
return fmt.Errorf("repository not found: %s", key)
}
delete(existing, key)
// Save back
if err := setRepoConfig(ctx, store, existing); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
if jsonOutput {
result := map[string]interface{}{
"removed": true,
"key": key,
"path": path,
}
return json.NewEncoder(os.Stdout).Encode(result)
}
fmt.Printf("Removed repository: %s → %s\n", key, path)
return nil
},
}
var repoListCmd = &cobra.Command{
Use: "list",
Short: "List all configured repositories",
RunE: func(cmd *cobra.Command, args []string) error {
if err := ensureDirectMode("repo list requires direct database access"); err != nil {
return err
}
ctx := context.Background()
repos, err := getRepoConfig(ctx, store)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if jsonOutput {
result := map[string]interface{}{
"primary": ".",
"additional": repos,
}
return json.NewEncoder(os.Stdout).Encode(result)
}
fmt.Println("Primary repository: .")
if len(repos) == 0 {
fmt.Println("No additional repositories configured")
} else {
fmt.Println("\nAdditional repositories:")
for key, path := range repos {
fmt.Printf(" %s → %s\n", key, path)
}
}
return nil
},
}
var repoSyncCmd = &cobra.Command{
Use: "sync",
Short: "Manually trigger multi-repo sync",
RunE: func(cmd *cobra.Command, args []string) error {
if err := ensureDirectMode("repo sync requires direct database access"); err != nil {
return err
}
ctx := context.Background()
// Import from all repos
jsonlPath := findJSONLPath()
if err := importToJSONLWithStore(ctx, store, jsonlPath); err != nil {
return fmt.Errorf("import failed: %w", err)
}
// Export to all repos
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
return fmt.Errorf("export failed: %w", err)
}
if jsonOutput {
result := map[string]interface{}{
"synced": true,
}
return json.NewEncoder(os.Stdout).Encode(result)
}
fmt.Println("Multi-repo sync complete")
return nil
},
}
// Helper functions for repo config management
func getRepoConfig(ctx context.Context, store storage.Storage) (map[string]string, error) {
value, err := store.GetConfig(ctx, "repos.additional")
if err != nil {
if strings.Contains(err.Error(), "not found") {
return make(map[string]string), nil
}
return nil, err
}
// Parse JSON map
repos := make(map[string]string)
if err := json.Unmarshal([]byte(value), &repos); err != nil {
return nil, fmt.Errorf("failed to parse repos config: %w", err)
}
return repos, nil
}
func setRepoConfig(ctx context.Context, store storage.Storage, repos map[string]string) error {
data, err := json.Marshal(repos)
if err != nil {
return fmt.Errorf("failed to serialize repos: %w", err)
}
return store.SetConfig(ctx, "repos.additional", string(data))
}
func init() {
repoCmd.AddCommand(repoAddCmd)
repoCmd.AddCommand(repoRemoveCmd)
repoCmd.AddCommand(repoListCmd)
repoCmd.AddCommand(repoSyncCmd)
repoAddCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON")
repoRemoveCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON")
repoListCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON")
repoSyncCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON")
rootCmd.AddCommand(repoCmd)
}