Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 4 additions & 24 deletions cmd/bd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,17 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/steveyegge/beads/cmd/bd/doctor"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/remotecache"
"github.com/steveyegge/beads/internal/types"
)

// gitSSHRemotePattern matches standard git SSH remote URLs (user@host:path)
var gitSSHRemotePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+@[a-zA-Z0-9][a-zA-Z0-9._-]*:.+$`)

var configCmd = &cobra.Command{
Use: "config",
GroupID: "setup",
Expand Down Expand Up @@ -503,27 +500,10 @@ func validateSyncConfig(repoPath string) []string {
return issues
}

// isValidRemoteURL validates remote URL formats for sync configuration
// isValidRemoteURL validates remote URL formats for sync configuration.
// Delegates to remotecache.IsRemoteURL for consistent URL classification.
func isValidRemoteURL(url string) bool {
// Valid URL schemes for beads remotes
validSchemes := []string{
"dolthub://",
"gs://",
"s3://",
"file://",
"https://",
"http://",
"ssh://",
}

for _, scheme := range validSchemes {
if strings.HasPrefix(url, scheme) {
return true
}
}

// Also allow standard git remote patterns (user@host:path)
return gitSSHRemotePattern.MatchString(url)
return remotecache.IsRemoteURL(url)
}

// findBeadsRepoRoot walks up from the given path to find the repo root (containing .beads)
Expand Down
51 changes: 39 additions & 12 deletions cmd/bd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/hooks"
"github.com/steveyegge/beads/internal/remotecache"
"github.com/steveyegge/beads/internal/routing"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/dolt"
Expand Down Expand Up @@ -423,21 +424,38 @@ var createCmd = &cobra.Command{
// Switch to target repo for multi-repo support (bd-6x6g)
// When routing to a different repo, we use direct storage access
var targetStore storage.DoltStorage
var remoteCache *remotecache.Cache // non-nil when routing to a remote URL
if repoPath != "." {
targetBeadsDir := routing.ExpandPath(repoPath)
debug.Logf("DEBUG: Routing to target repo: %s\n", targetBeadsDir)
if remotecache.IsRemoteURL(repoPath) {
// Remote URL: pull into cache, open store, push explicitly after create
var err error
remoteCache, err = remotecache.DefaultCache()
if err != nil {
FatalError("failed to initialize remote cache: %v", err)
}
if _, err := remoteCache.Ensure(rootCtx, repoPath); err != nil {
FatalError("failed to sync remote %s: %v", repoPath, err)
}
targetStore, err = remoteCache.OpenStore(rootCtx, repoPath, newDoltStoreFromConfig)
if err != nil {
FatalError("failed to open remote store: %v", err)
}
} else {
targetBeadsDir := routing.ExpandPath(repoPath)
debug.Logf("DEBUG: Routing to target repo: %s\n", targetBeadsDir)

// Ensure target beads directory exists with prefix inheritance
if err := ensureBeadsDirForPath(rootCtx, targetBeadsDir, store); err != nil {
FatalError("failed to initialize target repo: %v", err)
}
// Ensure target beads directory exists with prefix inheritance
if err := ensureBeadsDirForPath(rootCtx, targetBeadsDir, store); err != nil {
FatalError("failed to initialize target repo: %v", err)
}

// Open new store for target repo using factory to respect backend config
targetBeadsDirPath := filepath.Join(targetBeadsDir, ".beads")
var err error
targetStore, err = newDoltStoreFromConfig(rootCtx, targetBeadsDirPath)
if err != nil {
FatalError("failed to open target store: %v", err)
// Open new store for target repo using factory to respect backend config
targetBeadsDirPath := filepath.Join(targetBeadsDir, ".beads")
var err error
targetStore, err = newDoltStoreFromConfig(rootCtx, targetBeadsDirPath)
if err != nil {
FatalError("failed to open target store: %v", err)
}
}

// Close the original store before replacing it (it won't be used anymore)
Expand Down Expand Up @@ -742,6 +760,15 @@ var createCmd = &cobra.Command{
}
}

// Push to remote if this was a remote-routed create.
// Done explicitly (not via defer) because FatalError calls os.Exit,
// which skips deferred functions.
if remoteCache != nil {
if pushErr := remoteCache.Push(rootCtx, repoPath); pushErr != nil {
FatalError("failed to push to %s: %v\nThe issue was created locally but not synced to the remote.", repoPath, pushErr)
}
}

// Run create hook
if hookRunner != nil {
hookRunner.Run(hooks.EventCreate, issue)
Expand Down
79 changes: 67 additions & 12 deletions cmd/bd/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/remotecache"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
)
Expand Down Expand Up @@ -52,19 +53,23 @@ shared across all clones of this repository.`,
RunE: func(cmd *cobra.Command, args []string) error {
repoPath := args[0]

// Expand ~ to home directory for validation and display
expandedPath := repoPath
if len(repoPath) > 0 && repoPath[0] == '~' {
home, err := os.UserHomeDir()
if err == nil {
expandedPath = filepath.Join(home, repoPath[1:])
if remotecache.IsRemoteURL(repoPath) {
// Remote URL: skip local .beads directory validation
fmt.Fprintf(os.Stderr, "Adding remote repository: %s\n", repoPath)
} else {
// Local path: validate .beads directory exists
expandedPath := repoPath
if len(repoPath) > 0 && repoPath[0] == '~' {
home, err := os.UserHomeDir()
if err == nil {
expandedPath = filepath.Join(home, repoPath[1:])
}
}
}

// Validate the repo path exists and has .beads
beadsDir := filepath.Join(expandedPath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return fmt.Errorf("no .beads directory found at %s - is this a beads repository?", expandedPath)
beadsDir := filepath.Join(expandedPath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return fmt.Errorf("no .beads directory found at %s - is this a beads repository?", expandedPath)
}
}

// Find config.yaml
Expand Down Expand Up @@ -137,6 +142,13 @@ that came from the removed repository.`,
return fmt.Errorf("failed to remove repository: %w", err)
}

// Evict remote cache if applicable
if remotecache.IsRemoteURL(repoPath) {
if cache, err := remotecache.DefaultCache(); err == nil {
_ = cache.Evict(repoPath)
}
}

// Embedded mode: flush Dolt commit before output.
if isEmbeddedDolt && store != nil {
if _, err := store.CommitPending(ctx, actor); err != nil {
Expand Down Expand Up @@ -244,7 +256,50 @@ Also triggers Dolt push/pull if a remote is configured.`,

// Hydrate issues from each additional repository
for _, repoPath := range repos.Additional {
// Expand tilde
// Remote URL: pull into cache, read issues from SQL store
if remotecache.IsRemoteURL(repoPath) {
cache, err := remotecache.DefaultCache()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to init cache for %s: %v\n", repoPath, err)
continue
}
if _, err = cache.Ensure(ctx, repoPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to sync remote %s: %v\n", repoPath, err)
continue
}
remoteStore, err := cache.OpenStore(ctx, repoPath, newDoltStoreFromConfig)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to open remote store %s: %v\n", repoPath, err)
continue
}

issues, err := remoteStore.SearchIssues(ctx, "", types.IssueFilter{})
_ = remoteStore.Close() // close eagerly — defer in a loop would leak connections
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to read issues from %s: %v\n", repoPath, err)
continue
}

for _, issue := range issues {
issue.SourceRepo = repoPath
}
if len(issues) > 0 {
if importErr := store.CreateIssuesWithFullOptions(ctx, issues, "repo-sync", storage.BatchCreateOptions{
OrphanHandling: storage.OrphanAllow,
SkipPrefixValidation: true,
}); importErr != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to import from %s: %v\n", repoPath, importErr)
continue
}
totalImported += len(issues)
if verbose {
fmt.Fprintf(os.Stderr, "Imported %d issue(s) from remote %s\n", len(issues), repoPath)
}
}
continue
}

// Local path: expand tilde
expandedPath := repoPath
if len(repoPath) > 0 && repoPath[0] == '~' {
home, err := os.UserHomeDir()
Expand Down
Loading
Loading