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
100 changes: 93 additions & 7 deletions cmd/bd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ var configSetCmd = &cobra.Command{
setErr = config.SetUserYamlConfig(key, value)
location = config.UserConfigYamlDisplayPath()
} else {
if config.IsMachineLocalKey(key) {
location = config.LocalConfigFileName
}
setErr = config.SetYamlConfig(key, value)
}
if setErr != nil {
Expand Down Expand Up @@ -327,15 +330,24 @@ var configGetCmd = &cobra.Command{

value := config.GetYamlConfig(key)

// Attribute the file the value actually came from. Hard-coding
// "config.yaml" sends an operator to edit a file that does not
// contain the value — the misattribution the sidecar split exists
// to prevent, reintroduced at the reporting layer.
location := "config.yaml"
if _, ok := config.MachineLocalYamlValue(key); ok {
location = config.LocalConfigFileName
}

if jsonOutput {
return outputJSON(map[string]interface{}{
"key": key,
"value": value,
"location": "config.yaml",
"location": location,
})
}
if value == "" {
fmt.Printf("%s (not set in config.yaml)\n", key)
fmt.Printf("%s (not set in %s)\n", key, location)
} else {
fmt.Printf("%s\n", value)
}
Expand Down Expand Up @@ -406,6 +418,9 @@ func runConfigGetBackupEnabled() error {
sourceDesc = "env var"
case config.SourceConfigFile:
sourceDesc = "config.yaml"
if _, ok := config.MachineLocalYamlValue(key); ok {
sourceDesc = config.LocalConfigFileName
}
default: // SourceDefault — value came from auto-detection
switch {
case usesSQLServer():
Expand Down Expand Up @@ -517,7 +532,15 @@ func showConfigYAMLOverrides(dbConfig map[string]string) {
}
val := config.GetString(key)
if val != "" {
yamlOverrides = append(yamlOverrides, fmt.Sprintf(" %s = %s", key, val))
// Name the file per line. GetValueSource reports SourceConfigFile
// for both workspace files, so without this a sidecar value is
// listed under a heading that points at the tracked file it is not
// in — and an operator edits the wrong one.
if _, local := config.MachineLocalYamlValue(key); local {
yamlOverrides = append(yamlOverrides, fmt.Sprintf(" %s = %s (%s)", key, val, config.LocalConfigFileName))
} else {
yamlOverrides = append(yamlOverrides, fmt.Sprintf(" %s = %s (config.yaml)", key, val))
}
}
}

Expand All @@ -535,7 +558,10 @@ func showConfigYAMLOverrides(dbConfig map[string]string) {
}

if len(yamlOverrides) > 0 {
fmt.Println("\nAlso set in config.yaml (not shown above):")
// GetValueSource cannot distinguish the two workspace files — both
// report SourceConfigFile — so this heading names the pair rather than
// asserting the tracked one and being wrong for every sidecar value.
fmt.Println("\nAlso set in the workspace config files (not shown above):")
for _, line := range yamlOverrides {
fmt.Println(line)
}
Expand Down Expand Up @@ -571,25 +597,73 @@ var configUnsetCmd = &cobra.Command{
if config.IsYamlOnlyKey(key) {
location := "config.yaml"
var unsetErr error
var trackedValue string
var clearedTracked, clearedLocal bool
if config.IsUserGlobalKey(key) {
unsetErr = config.UnsetUserYamlConfig(key)
location = config.UserConfigYamlDisplayPath()
} else {
unsetErr = config.UnsetYamlConfig(key)
if config.IsMachineLocalKey(key) {
location = config.LocalConfigFileName
}
trackedValue, clearedTracked, clearedLocal, unsetErr = config.UnsetYamlConfigReporting(key)
}
if unsetErr != nil {
return HandleError("unsetting config: %v", unsetErr)
}

// Name only files actually edited, and do not claim a removal that
// did not happen: a key inside a YAML flow mapping is out of the
// line-based remover's reach, and a key that was never set has
// nothing to remove. Both used to print success plus a
// config_side_effects consequence.
if config.IsMachineLocalKey(key) {
switch {
case clearedLocal && clearedTracked:
location = config.LocalConfigFileName + " and config.yaml"
case clearedLocal:
location = config.LocalConfigFileName
case clearedTracked:
location = "config.yaml"
}
}
removedSomething := clearedLocal || clearedTracked

if !removedSomething {
if jsonOutput {
return outputJSON(map[string]interface{}{
"key": key,
"removed": false,
"reason": "not set in config.local.yaml or config.yaml, or defined inside a YAML flow mapping that must be edited by hand",
})
}
fmt.Printf("%s was not removed: it is not set in %s or config.yaml.\n", key, config.LocalConfigFileName)
fmt.Printf(" (a key written inside a flow mapping, e.g. `dolt: {mode: server}`, must be edited by hand)\n")
return nil
}

if jsonOutput {
if err := outputJSON(map[string]interface{}{
payload := map[string]interface{}{
"key": key,
"location": location,
}); err != nil {
"removed": true,
}
// A machine-local unset can touch BOTH files. Reporting only
// the sidecar told a scripted caller — this repo's own tooling
// among them — that the tracked file was untouched when it was
// not.
if clearedTracked {
payload["also_cleared"] = "config.yaml"
payload["tracked_value_removed"] = trackedValue
}
if err := outputJSON(payload); err != nil {
return err
}
} else {
fmt.Printf("Unset %s (in %s)\n", key, location)
if clearedTracked {
fmt.Printf(" also removed %s = %s from config.yaml (tracked in git — commit the change)\n", key, trackedValue)
}
}
printConfigSideEffects(checkConfigUnsetSideEffects(key))
return nil
Expand Down Expand Up @@ -931,7 +1005,16 @@ Examples:
if config.IsUserGlobalKey(p.key) {
location = config.UserConfigYamlDisplayPath()
} else if config.IsYamlOnlyKey(p.key) {
// set-many goes through the same SetYamlConfig, so a
// machine-local key lands in the sidecar here too. Naming
// config.yaml sent the operator to a file that is
// byte-identical — the misattribution the rest of this
// change removes, surviving in the one writer that was
// missed.
location = "config.yaml"
if config.IsMachineLocalKey(p.key) {
location = config.LocalConfigFileName
}
} else if p.key == "beads.role" {
location = "git config"
}
Expand All @@ -951,6 +1034,9 @@ Examples:
location = fmt.Sprintf(" (in %s)", config.UserConfigYamlDisplayPath())
} else if config.IsYamlOnlyKey(p.key) {
location = " (in config.yaml)"
if config.IsMachineLocalKey(p.key) {
location = " (in " + config.LocalConfigFileName + ")"
}
} else if p.key == "beads.role" {
location = " (in git config)"
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/bd/config_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ func viperSourceLabel(key string, source config.ConfigSource) string {
}
return "env"
case config.SourceConfigFile:
if _, ok := config.MachineLocalYamlValue(key); ok {
return config.LocalConfigFileName
}
return "config.yaml"
default:
return "default"
Expand Down
11 changes: 11 additions & 0 deletions cmd/bd/doctor/gitignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ backup/
*.db-shm
db.sqlite
bd.db
# Machine-local config sidecar: overrides that describe THIS host (which Dolt
# to talk to, whether this host takes backups) rather than the project. bd
# routes those keys here precisely so the tracked config.yaml stops being
# rewritten underneath clean-tree guards.
config.local.yaml

# NOTE: Do NOT add negation patterns here.
# They would override fork protection in .git/info/exclude.
# Config files (metadata.json, config.yaml) are tracked by git by default
Expand All @@ -112,6 +118,11 @@ const ProjectGitignoreHeader = "# Beads / Dolt files (added by bd init)"
var requiredPatterns = []string{
"*.db?*",
".env",
// Machine-local config sidecar. Required rather than merely templated so
// that repositories initialized before it existed pick it up from
// `bd doctor --fix`; without that, an existing checkout gets the sidecar
// written but not ignored, and trades one self-dirtying file for another.
"config.local.yaml",
"redirect",
"last-touched",
"bd.sock.startlock",
Expand Down
53 changes: 53 additions & 0 deletions cmd/bd/doctor/gitignore_machine_local_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package doctor

import (
"os"
"path/filepath"
"strings"
"testing"
)

// TestSidecarIsIgnoredForNewAndExistingWorkspaces closes the loop on the
// machine-local sidecar: bd writes .beads/config.local.yaml, so bd must also
// ignore it. Both halves matter — the template covers repositories initialized
// from now on, and requiredPatterns is what retrofits the ones that already
// exist. Without the second, an existing checkout trades one self-dirtying
// file for another.
func TestSidecarIsIgnoredForNewAndExistingWorkspaces(t *testing.T) {
const sidecar = "config.local.yaml"

t.Run("new workspace gets it from the template", func(t *testing.T) {
if !strings.Contains(GitignoreTemplate, sidecar) {
t.Errorf("%q missing from the .beads/.gitignore template", sidecar)
}
})

t.Run("existing workspace gets it from doctor --fix", func(t *testing.T) {
beadsDir := filepath.Join(t.TempDir(), ".beads")
if err := os.MkdirAll(beadsDir, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
gitignorePath := filepath.Join(beadsDir, ".gitignore")
// An older .beads/.gitignore, plus a local rule that must survive.
existing := "dolt/\n.env\nredirect\n# local rule\nmy-scratch/\n"
if err := os.WriteFile(gitignorePath, []byte(existing), 0o600); err != nil {
t.Fatalf("write .gitignore: %v", err)
}

if err := EnsureGitignoreForBeadsDir(beadsDir); err != nil {
t.Fatalf("EnsureGitignoreForBeadsDir: %v", err)
}

b, err := os.ReadFile(gitignorePath)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(b)
if !strings.Contains(got, sidecar) {
t.Errorf("%q not appended to an existing .beads/.gitignore:\n%s", sidecar, got)
}
if !strings.Contains(got, "my-scratch/") {
t.Errorf("append clobbered a local rule:\n%s", got)
}
})
}
8 changes: 6 additions & 2 deletions cmd/bd/dolt.go
Original file line number Diff line number Diff line change
Expand Up @@ -2184,10 +2184,14 @@ func setDoltConfig(key, value string, updateConfig bool) error {

// Also update config.yaml if requested
if updateConfig && yamlKey != "" {
yamlLocation := "config.yaml"
if config.IsMachineLocalKey(yamlKey) {
yamlLocation = config.LocalConfigFileName
}
if err := config.SetYamlConfig(yamlKey, value); err != nil {
fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: failed to update config.yaml: %v", err)))
fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: failed to update %s: %v", yamlLocation, err)))
} else {
fmt.Printf("Set %s = %s (in config.yaml)\n", yamlKey, value)
fmt.Printf("Set %s = %s (in %s)\n", yamlKey, value, yamlLocation)
}
}
return nil
Expand Down
16 changes: 14 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,19 @@ Dolt is the only storage backend. Embedded mode (the default) stores data at `.b
3. `<repo>/.beads/config.yaml` (project-level, walked up from the current directory)
4. `$BEADS_DIR/config.yaml` (highest priority, when `BEADS_DIR` points at a different workspace)

A `config.local.yaml` next to the project `config.yaml` is also merged in last for machine-specific overrides that should not be committed.
A `config.local.yaml` next to the project `config.yaml` is merged in last, so anything in it wins.

Some settings describe *this machine*, not the project — where the Dolt server listens, whether backups run here. Committing those to `config.yaml` makes every clone inherit one machine's answer. So `bd config set` routes them to `config.local.yaml` automatically; you do not have to remember which is which:

```bash
bd config set dolt.mode server
# Set dolt.mode = server (in config.local.yaml)

bd config set project.name my-app
# Set project.name = my-app (in config.yaml)
```

The routed keys are `dolt.mode`, `dolt.host`, `dolt.port`, `dolt.socket`, `dolt.user`, `dolt.data-dir`, `dolt.debug`, `backup.enabled`, and `backup.interval`. `bd config get` and `bd config list` name the file each value came from, and `bd init` adds `config.local.yaml` to `.beads/.gitignore`.

## Precedence

Expand Down Expand Up @@ -535,7 +547,7 @@ output:
title-length: 255
```

For machine-specific overrides that should not be committed, drop them in `.beads/config.local.yaml`; it is merged in last.
For machine-specific overrides that should not be committed, drop them in `.beads/config.local.yaml`; it is merged in last. The Dolt connection and backup keys go there on their own — see [Config Files](#config-files).

## Per-Command Override

Expand Down
18 changes: 14 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -682,15 +682,25 @@ func GetString(key string) string {
return v.GetString(key)
}

// GetStringFromDir reads a single string configuration value directly from
// <beadsDir>/config.yaml without using or modifying global viper state.
// This is intended for library consumers that call NewFromConfigWithOptions
// without first invoking config.Initialize().
// GetStringFromDir reads a single string configuration value for the workspace
// at beadsDir without using or modifying global viper state. This is intended
// for library consumers that call NewFromConfigWithOptions without first
// invoking config.Initialize().
//
// It mirrors Initialize's precedence for the workspace's two files: a value in
// the untracked config.local.yaml sidecar wins over the tracked config.yaml.
// Without that, machine-local keys routed to the sidecar would be invisible
// here — `bd bootstrap` resolves dolt.port through this path — and the caller
// would silently fall back to a default while bd's merged config said
// otherwise.
//
// The key uses dotted notation (e.g. "dolt.auto-start"). YAML booleans and
// numbers are coerced to their string representations ("true", "false", etc.).
// Returns "" if the file is absent, the key is not found, or any error occurs.
func GetStringFromDir(beadsDir, key string) string {
if v, ok := readYamlValueAtPath(filepath.Join(beadsDir, LocalConfigFileName), key); ok {
return v
}
configPath := filepath.Join(beadsDir, "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
Expand Down
Loading
Loading