Skip to content
Open
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
33 changes: 33 additions & 0 deletions cmd/bd/doctor/fix/dolt_format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package fix

import (
"fmt"
"os"
"path/filepath"

"github.com/steveyegge/beads/internal/doltserver"
)

// DoltFormat fixes the "Dolt Format" warning by seeding the .bd-dolt-ok marker
// for pre-0.56 dolt databases that are otherwise functional.
//
// In server mode, the .beads/dolt/.dolt/ directory is vestigial from an older
// embedded Dolt setup. The data lives on the Dolt server. Seeding the marker
// tells future doctor checks that this database has been acknowledged.
func DoltFormat(path string) error {
// resolveBeadsDir follows .beads/redirect to find the actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
doltDir := filepath.Join(beadsDir, "dolt")

if !doltserver.IsPreV56DoltDir(doltDir) {
return nil // Already OK or no .dolt/ directory
}

markerPath := filepath.Join(doltDir, ".bd-dolt-ok")
if err := os.WriteFile(markerPath, []byte("ok\n"), 0600); err != nil {
return fmt.Errorf("creating .bd-dolt-ok marker: %w", err)
}

fmt.Printf(" Seeded .bd-dolt-ok marker in %s\n", doltDir)
return nil
}
2 changes: 2 additions & 0 deletions cmd/bd/doctor_fix.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ func applyFixList(path string, fixes []doctorCheck) {
// GH#2160: Pre-#2142 migrations may have wrong database configured.
// Probe the server and backfill dolt_database in metadata.json.
err = fix.FixMissingDoltDatabase(path)
case "Dolt Format":
err = fix.DoltFormat(path)
default:
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
fmt.Printf(" Manual fix: %s\n", check.Fix)
Expand Down
182 changes: 178 additions & 4 deletions cmd/bd/routed.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package main

import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
Expand Down Expand Up @@ -41,7 +47,8 @@ func (r *RoutedResult) Close() {
}

// resolveAndGetIssueWithRouting resolves a partial ID and gets the issue.
// Tries the local store first, then falls back to contributor auto-routing.
// Tries the local store first, then prefix-based routing via routes.jsonl,
// then falls back to contributor auto-routing.
//
// Returns a RoutedResult containing the issue, resolved ID, and the store to use.
// The caller MUST call result.Close() when done to release any routed storage.
Expand All @@ -52,7 +59,16 @@ func resolveAndGetIssueWithRouting(ctx context.Context, localStore storage.DoltS
return result, nil
}

// If not found locally, try contributor auto-routing as fallback (GH#2345).
// If not found locally, try prefix-based routing via routes.jsonl.
// This handles cross-rig lookups where the ID's prefix maps to a different
// database (e.g., hr-8wn.1 routes to the herald rig's database).
if isNotFoundErr(err) {
if prefixResult, prefixErr := resolveViaPrefixRouting(ctx, id); prefixErr == nil {
return prefixResult, nil
}
}

// If not found via prefix routing, try contributor auto-routing as fallback (GH#2345).
if isNotFoundErr(err) {
if autoResult, autoErr := resolveViaAutoRouting(ctx, localStore, id); autoErr == nil {
return autoResult, nil
Expand Down Expand Up @@ -102,8 +118,159 @@ func resolveViaAutoRouting(ctx context.Context, localStore storage.DoltStorage,
return result, nil
}

// prefixRoute represents a prefix-to-path routing rule from routes.jsonl.
type prefixRoute struct {
Prefix string `json:"prefix"` // Issue ID prefix (e.g., "hr-")
Path string `json:"path"` // Relative path to rig directory from town root
}

// resolveViaPrefixRouting attempts to find an issue by looking up its prefix
// in routes.jsonl and opening the target rig's database.
//
// This enables cross-rig lookups: when running from a redirected .beads directory
// (e.g., crew/beercan → town/.beads with database "hq"), a bead ID like "hr-8wn.1"
// can be resolved by following the "hr-" route to the herald rig's .beads directory,
// which declares dolt_database="herald".
func resolveViaPrefixRouting(ctx context.Context, id string) (*RoutedResult, error) {
// Extract prefix from the bead ID (e.g., "hr-" from "hr-8wn.1")
prefix := extractBeadPrefix(id)
if prefix == "" {
return nil, fmt.Errorf("no prefix in ID %q", id)
}

// Find the resolved beads directory (where routes.jsonl lives)
currentBeadsDir := resolveCommandBeadsDir(dbPath)
if currentBeadsDir == "" {
return nil, fmt.Errorf("no beads directory available")
}

// Load routes from routes.jsonl
routes, err := loadPrefixRoutes(currentBeadsDir)
if err != nil || len(routes) == 0 {
return nil, fmt.Errorf("no routes available")
}

// Find matching route for this prefix
var matchedRoute *prefixRoute
for i, r := range routes {
if r.Prefix == prefix {
matchedRoute = &routes[i]
break
}
}
if matchedRoute == nil {
return nil, fmt.Errorf("no route for prefix %q", prefix)
}

// Skip if the route points to current directory (town-level, already checked)
if matchedRoute.Path == "." {
return nil, fmt.Errorf("route points to current database")
}

// Derive the town root from the current beads dir.
// currentBeadsDir is typically <town_root>/.beads
townRoot := filepath.Dir(currentBeadsDir)

// Resolve the target rig's .beads directory
rigDir := filepath.Join(townRoot, matchedRoute.Path)
targetBeadsDir := beads.FollowRedirect(filepath.Join(rigDir, ".beads"))

// Check that the target has a different dolt_database
targetDB := readDoltDatabase(targetBeadsDir)
if targetDB == "" {
return nil, fmt.Errorf("target rig has no dolt_database configured")
}

debug.Logf("[routing] Prefix %q matched route to %s (database: %s)\n", prefix, matchedRoute.Path, targetDB)

// Open a read-only store for the target database.
// We need to temporarily override BEADS_DOLT_SERVER_DATABASE so the store
// connects to the correct database on the shared Dolt server.
origDB := os.Getenv("BEADS_DOLT_SERVER_DATABASE")
_ = os.Setenv("BEADS_DOLT_SERVER_DATABASE", targetDB)
targetStore, err := newReadOnlyStoreFromConfig(ctx, targetBeadsDir)
// Restore the original env var
if origDB != "" {
_ = os.Setenv("BEADS_DOLT_SERVER_DATABASE", origDB)
} else {
_ = os.Unsetenv("BEADS_DOLT_SERVER_DATABASE")
}
if err != nil {
return nil, fmt.Errorf("opening routed store for %s: %w", matchedRoute.Path, err)
}

result, err := resolveAndGetFromStore(ctx, targetStore, id, true)
if err != nil {
_ = targetStore.Close()
return nil, err
}
result.closeFn = func() { _ = targetStore.Close() }

if os.Getenv("BD_DEBUG_ROUTING") != "" {
fmt.Fprintf(os.Stderr, "[routing] Resolved %s via prefix route to %s (database: %s)\n", id, matchedRoute.Path, targetDB)
}

return result, nil
}

// extractBeadPrefix extracts the prefix from a bead ID.
// For example, "hr-8wn.1" returns "hr-", "hq-cv-abc" returns "hq-".
func extractBeadPrefix(beadID string) string {
if beadID == "" {
return ""
}
idx := strings.Index(beadID, "-")
if idx <= 0 {
return ""
}
return beadID[:idx+1]
}

// loadPrefixRoutes loads prefix-to-path routes from routes.jsonl in the beads directory.
func loadPrefixRoutes(beadsDir string) ([]prefixRoute, error) {
routesPath := filepath.Join(beadsDir, "routes.jsonl")
file, err := os.Open(routesPath) //nolint:gosec // G304: path is constructed from trusted beads directory
if err != nil {
return nil, err
}
defer file.Close()

var routes []prefixRoute
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
var route prefixRoute
if err := json.Unmarshal([]byte(line), &route); err != nil {
continue
}
if route.Prefix != "" && route.Path != "" {
routes = append(routes, route)
}
}
return routes, scanner.Err()
}

// readDoltDatabase reads the dolt_database field from a .beads/metadata.json file.
func readDoltDatabase(beadsDir string) string {
metadataPath := filepath.Join(beadsDir, "metadata.json")
data, err := os.ReadFile(metadataPath) //nolint:gosec // G304: path is constructed from trusted beads directory
if err != nil {
return ""
}
var meta struct {
DoltDatabase string `json:"dolt_database"`
}
if json.Unmarshal(data, &meta) != nil {
return ""
}
return meta.DoltDatabase
}

// getIssueWithRouting gets an issue by exact ID.
// Tries the local store first, then falls back to contributor auto-routing.
// Tries the local store first, then prefix-based routing, then contributor auto-routing.
//
// Returns a RoutedResult containing the issue and the store to use for related queries.
// The caller MUST call result.Close() when done to release any routed storage.
Expand All @@ -119,7 +286,14 @@ func getIssueWithRouting(ctx context.Context, localStore storage.DoltStorage, id
}, nil
}

// If not found locally, try contributor auto-routing as fallback (GH#2345).
// If not found locally, try prefix-based routing via routes.jsonl.
if isNotFoundErr(err) {
if prefixResult, prefixErr := resolveViaPrefixRouting(ctx, id); prefixErr == nil {
return prefixResult, nil
}
}

// If not found via prefix routing, try contributor auto-routing as fallback (GH#2345).
if isNotFoundErr(err) {
if autoResult, autoErr := resolveViaAutoRouting(ctx, localStore, id); autoErr == nil {
return autoResult, nil
Expand Down
19 changes: 18 additions & 1 deletion cmd/bd/vc.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,27 @@ Examples:
}

// We are explicitly creating a Dolt commit; avoid redundant auto-commit in PersistentPostRun.
// Use CommitPending which calls CommitWithConfig internally — this stages ALL
// tables including config. The interface method Commit() intentionally skips
// config to prevent sweeping stale changes during auto-commits (GH#2455), but
// an explicit `bd vc commit` is a user action that should commit everything
// the user sees in `bd vc status`.
//
// Note: CommitPending generates its own descriptive commit message rather than
// using vcCommitMessage. The user's message is displayed in the output.
commandDidExplicitDoltCommit = true
if err := store.Commit(ctx, vcCommitMessage); err != nil {
committed, err := store.CommitPending(ctx, getActorWithGit())
if err != nil {
FatalErrorRespectJSON("failed to commit: %v", err)
}
if !committed {
if jsonOutput {
outputJSON(map[string]interface{}{"committed": false, "message": "nothing to commit"})
} else {
fmt.Println("Nothing to commit")
}
return
}

// Get the new commit hash
hash, err := store.GetCurrentCommit(ctx)
Expand Down
20 changes: 10 additions & 10 deletions cmd/bd/vc_embedded_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,24 +90,24 @@ func TestEmbeddedVC(t *testing.T) {

t.Run("commit_json", func(t *testing.T) {
dir, _, _ := bdInit(t, bd, "--prefix", "vccj")
// bd create auto-commits, so vc commit may see "nothing to commit".
// Both committed=true and committed=false are valid outcomes.
bdCreateSilent(t, bd, dir, "commit json issue")

cmd := exec.Command(bd, "vc", "commit", "-m", "json commit", "--json")
cmd.Dir = dir
cmd.Env = bdEnv(dir)
out, err := cmd.CombinedOutput()
if err != nil && !strings.Contains(string(out), "nothing to commit") {
if err != nil {
t.Fatalf("bd vc commit --json failed unexpectedly: %v\n%s", err, out)
}
// If commit succeeded, verify JSON
if err == nil {
var result map[string]interface{}
if jsonErr := json.Unmarshal(out, &result); jsonErr != nil {
t.Fatalf("failed to parse JSON: %v\n%s", jsonErr, out)
}
if committed, _ := result["committed"].(bool); !committed {
t.Error("expected committed=true")
}
// Verify valid JSON with committed field (true or false are both valid)
var result map[string]interface{}
if jsonErr := json.Unmarshal(out, &result); jsonErr != nil {
t.Fatalf("failed to parse JSON: %v\n%s", jsonErr, out)
}
if _, ok := result["committed"]; !ok {
t.Error("expected 'committed' field in JSON output")
}
})

Expand Down
Loading