diff --git a/cmd/bd/daemon_sync.go b/cmd/bd/daemon_sync.go index ee0318b9..df2f8e31 100644 --- a/cmd/bd/daemon_sync.go +++ b/cmd/bd/daemon_sync.go @@ -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 { diff --git a/cmd/bd/repo.go b/cmd/bd/repo.go new file mode 100644 index 00000000..3236cea9 --- /dev/null +++ b/cmd/bd/repo.go @@ -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 [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 ", + 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) +}