diff --git a/cmd/bd/doctor/fix/dolt_format.go b/cmd/bd/doctor/fix/dolt_format.go new file mode 100644 index 0000000000..ae152167da --- /dev/null +++ b/cmd/bd/doctor/fix/dolt_format.go @@ -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 +} diff --git a/cmd/bd/doctor_fix.go b/cmd/bd/doctor_fix.go index e7cd338aa7..6b99352a70 100644 --- a/cmd/bd/doctor_fix.go +++ b/cmd/bd/doctor_fix.go @@ -350,6 +350,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) diff --git a/cmd/bd/routed.go b/cmd/bd/routed.go index 31b5bceb3e..0670c80bde 100644 --- a/cmd/bd/routed.go +++ b/cmd/bd/routed.go @@ -41,7 +41,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. @@ -52,7 +53,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 @@ -102,8 +112,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 /.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. @@ -119,7 +280,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 diff --git a/cmd/bd/vc.go b/cmd/bd/vc.go index 66a9b2485a..f2353a8d63 100644 --- a/cmd/bd/vc.go +++ b/cmd/bd/vc.go @@ -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) diff --git a/cmd/bd/vc_embedded_test.go b/cmd/bd/vc_embedded_test.go index ce51cc021e..2cb978d5a9 100644 --- a/cmd/bd/vc_embedded_test.go +++ b/cmd/bd/vc_embedded_test.go @@ -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") } })