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
23 changes: 23 additions & 0 deletions cmd/ralph/commands/cmd_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ var doctorCmd = &cobra.Command{
return &ExitError{Code: 1}
}

checkConfig(rpt)
checkDotfiles(rpt, cfg)
checkDirectories(rpt, cfg)
checkRepositories(rpt, cfg)
Expand All @@ -61,6 +62,28 @@ var doctorCmd = &cobra.Command{
},
}

// checkConfig reports on config file resolution: the active config path and
// whether the optional config.local.toml overlay was found.
func checkConfig(rpt *report.Report) {
phase := rpt.AddPhase("Configuration")

configPath, err := config.GetDefaultConfigPath()
if err != nil {
phase.AddResult("config path", "", report.StatusFail, fmt.Sprintf("%v", err), err)
return
}
phase.AddResult("config.toml", "", report.StatusOK, configPath, nil)

localPath := config.LocalConfigPath(configPath)
if _, err := os.Stat(localPath); err == nil {
phase.AddResult("config.local.toml", "", report.StatusOK, "loaded: "+localPath, nil)
} else if os.IsNotExist(err) {
phase.AddResult("config.local.toml", "", report.StatusSkip, "not found (optional)", nil)
} else {
phase.AddResult("config.local.toml", "", report.StatusWarn, fmt.Sprintf("%v", err), err)
}
}

func checkDotfiles(rpt *report.Report, cfg *config.Config) {
if len(cfg.Dotfiles) == 0 {
return
Expand Down
45 changes: 45 additions & 0 deletions cmd/ralph/commands/cmd_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

// "io/fs" // Unused import

Expand Down Expand Up @@ -123,6 +124,14 @@ var initCmd = &cobra.Command{
}
color.Green("Default configuration file created at %s", defaultConfigPath)

// Best-effort: keep the optional machine-local overlay out of version
// control by gitignoring it in the dotfiles repo. Never fail init on this.
if added, err := ensureGitignored(expandedRepoPath, config.LocalConfigFileName); err != nil {
color.Yellow("Could not update .gitignore in %s: %v", expandedRepoPath, err)
} else if added {
color.Green("Added '%s' to %s", config.LocalConfigFileName, filepath.Join(expandedRepoPath, ".gitignore"))
}

fmt.Println("\n" + color.New(color.FgCyan, color.Bold).Sprint("🎉 Next Steps:"))
fmt.Printf(
"1. %s your dotfiles repository at '%s'.\n",
Expand Down Expand Up @@ -156,6 +165,42 @@ var initCmd = &cobra.Command{
},
}

// ensureGitignored adds entry to repoPath/.gitignore unless it is already
// ignored. It is a no-op (added=false, err=nil) when repoPath is not an
// existing directory, so it is safe to call before the repo is populated. The
// .gitignore is created if missing. A trailing newline is preserved.
func ensureGitignored(repoPath, entry string) (added bool, err error) {
info, statErr := os.Stat(repoPath)
if statErr != nil || !info.IsDir() {
return false, nil //nolint:nilerr // missing repo dir is intentionally not an error here
}

gitignorePath := filepath.Join(repoPath, ".gitignore")
content, readErr := os.ReadFile(gitignorePath)
if readErr != nil && !os.IsNotExist(readErr) {
return false, fmt.Errorf("reading %s: %w", gitignorePath, readErr)
}

for line := range strings.SplitSeq(string(content), "\n") {
if strings.TrimSpace(line) == entry {
return false, nil
}
}

var b strings.Builder
b.Write(content)
if len(content) > 0 && !bytes.HasSuffix(content, []byte("\n")) {
b.WriteString("\n")
}
b.WriteString(entry)
b.WriteString("\n")

if err := os.WriteFile(gitignorePath, []byte(b.String()), 0o644); err != nil {
return false, fmt.Errorf("writing %s: %w", gitignorePath, err)
}
return true, nil
}

func init() {
rootCmd.AddCommand(initCmd)
}
Expand Down
80 changes: 80 additions & 0 deletions cmd/ralph/commands/cmd_init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package commands

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

func TestEnsureGitignored(t *testing.T) {
t.Run("creates .gitignore when missing", func(t *testing.T) {
dir := t.TempDir()
added, err := ensureGitignored(dir, "config.local.toml")
if err != nil {
t.Fatalf("ensureGitignored() error = %v", err)
}
if !added {
t.Fatal("expected added=true")
}
got := readFile(t, filepath.Join(dir, ".gitignore"))
if got != "config.local.toml\n" {
t.Errorf(".gitignore = %q, want %q", got, "config.local.toml\n")
}
})

t.Run("idempotent when entry already present", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, ".gitignore"), "node_modules\nconfig.local.toml\n")

added, err := ensureGitignored(dir, "config.local.toml")
if err != nil {
t.Fatalf("ensureGitignored() error = %v", err)
}
if added {
t.Error("expected added=false when entry already present")
}
got := readFile(t, filepath.Join(dir, ".gitignore"))
if got != "node_modules\nconfig.local.toml\n" {
t.Errorf(".gitignore changed: %q", got)
}
})

t.Run("appends a newline when file lacks trailing newline", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, ".gitignore"), "node_modules")

if _, err := ensureGitignored(dir, "config.local.toml"); err != nil {
t.Fatalf("ensureGitignored() error = %v", err)
}
got := readFile(t, filepath.Join(dir, ".gitignore"))
if got != "node_modules\nconfig.local.toml\n" {
t.Errorf(".gitignore = %q, want %q", got, "node_modules\nconfig.local.toml\n")
}
})

t.Run("no-op when repo dir does not exist", func(t *testing.T) {
added, err := ensureGitignored(filepath.Join(t.TempDir(), "nope"), "config.local.toml")
if err != nil {
t.Fatalf("ensureGitignored() error = %v", err)
}
if added {
t.Error("expected added=false for missing repo dir")
}
})
}

func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}

func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
40 changes: 40 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,48 @@ If `$XDG_CONFIG_HOME` is not set, it defaults to:
~/.config/ralph/config.toml
```

Set `RALPH_CONFIG` to point at a specific config file, which takes precedence over the XDG path. If `RALPH_CONFIG` names a file that doesn't exist, ralph errors instead of falling back to the default.

Run `ralph init` to create a starter config interactively. See [commands](commands.md) for details.

## Local Overlay (`config.local.toml`)

Ralph optionally loads a second file next to the main config and merges it on top. The overlay is named by inserting `.local` before the extension of the resolved config path:

| Main config | Overlay |
|-------------|---------|
| `~/.config/ralph/config.toml` | `~/.config/ralph/config.local.toml` |
| `RALPH_CONFIG=/path/myralph.toml` | `/path/myralph.local.toml` |

The overlay is always optional — a missing file is not an error. Keep it out of version control for machine-specific settings (host toggles, local paths, secrets). `ralph init` adds `config.local.toml` to your dotfiles repo's `.gitignore`, and `ralph doctor` reports whether the overlay was found.

### Merge semantics

The overlay wins over the main config, per field type:

| Field type | Rule | Examples |
|------------|------|----------|
| Scalars | Overlay overrides when set to a non-zero value | `dotfiles_repo_path`, `packages_dir`, `shell.name` |
| Maps | Keys are merged; overlay wins per key | `[dotfiles]`, `[packages]`, `[recipes_config.overrides]`, `[shell.aliases]` |
| Slices | Overlay replaces the whole list when non-empty | `[[tools]]`, `[[recipes]]`, `hooks.pre_apply` |

Boolean scalars can only be turned on from the overlay — a `false` reads as unset and won't override a `true` in the main config.

### Example

Instead of committing host filters to the shared config:

```toml
# config.local.toml on the machine named "yesyes"
[recipes_config.overrides.apple-dev]
enable = true

[recipes_config.overrides.pi]
enable = false
```

This enables `apple-dev` and disables `pi` on this machine only, without editing the committed `config.toml`.

## Top-Level Fields

| Field | Type | Required | Default | Description |
Expand Down
7 changes: 7 additions & 0 deletions internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ func LoadConfigWithHost(host string) (*Config, error) {
return nil, fmt.Errorf("failed to decode config file %s: %w", configPath, err)
}

// Overlay the optional, git-ignored config.local.toml (machine-local
// overrides) before any validation or recipe processing, so the merged
// result is validated as one config and recipe overrides set locally apply.
if _, err := loadLocalOverlay(&cfg, configPath); err != nil {
return nil, fmt.Errorf("failed to load local config overlay: %w", err)
}

// Validate the base config first
if err := ValidateConfig(&cfg); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
Expand Down
134 changes: 134 additions & 0 deletions internal/config/local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package config

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

"github.com/BurntSushi/toml"
)

// LocalConfigSuffix is inserted before the extension of the main config file to
// derive the optional, git-ignored overlay path: config.toml -> config.local.toml.
const LocalConfigSuffix = ".local"

// LocalConfigFileName is the overlay file name derived from the default
// config.toml. Used by `ralph init` for the .gitignore entry.
const LocalConfigFileName = "config" + LocalConfigSuffix + ".toml"

// LocalConfigPath derives the overlay path from the resolved main config path.
// It keeps the directory and base name and inserts ".local" before the
// extension, mirroring mise's config.toml -> config.local.toml convention.
// A main path with no extension gets ".local.toml" appended.
func LocalConfigPath(mainConfigPath string) string {
dir := filepath.Dir(mainConfigPath)
base := filepath.Base(mainConfigPath)
ext := filepath.Ext(base)
if ext == "" {
return filepath.Join(dir, base+LocalConfigSuffix+".toml")
}
stem := strings.TrimSuffix(base, ext)
return filepath.Join(dir, stem+LocalConfigSuffix+ext)
}

// loadLocalOverlay decodes the optional config.local.toml sitting next to the
// main config and merges it onto cfg. The overlay is always optional: a missing
// file is not an error. It returns whether an overlay was found and merged.
//
// Merge semantics (local wins, never conflict-detected like recipes):
// - scalar fields: local overrides when set to a non-zero value
// - map fields: keys are merged, local wins per key
// - slice fields: local replaces the whole slice when non-empty
func loadLocalOverlay(cfg *Config, mainConfigPath string) (bool, error) {
localPath := LocalConfigPath(mainConfigPath)

if _, err := os.Stat(localPath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("failed to stat local config %s: %w", localPath, err)
}

var local Config
if _, err := toml.DecodeFile(localPath, &local); err != nil {
return false, fmt.Errorf("failed to decode local config %s: %w", localPath, err)
}

mergeLocalConfig(cfg, &local)
return true, nil
}

// mergeLocalConfig overlays local onto base in place with local-wins semantics.
func mergeLocalConfig(base, local *Config) {
// Scalars: local overrides when set.
if local.DotfilesRepoPath != "" {
base.DotfilesRepoPath = local.DotfilesRepoPath
}
if local.PackagesDir != "" {
base.PackagesDir = local.PackagesDir
}
if local.Shell.Name != "" {
base.Shell.Name = local.Shell.Name
}
if local.RecipesConfig.AutoDiscover {
base.RecipesConfig.AutoDiscover = true
}
if local.RecipesConfig.AutoCleanup {
base.RecipesConfig.AutoCleanup = true
}
if local.RecipesConfig.Dir != "" {
base.RecipesConfig.Dir = local.RecipesConfig.Dir
}

// Maps: merge keys, local wins per key.
mergeLocalMap(&base.Dotfiles, local.Dotfiles)
mergeLocalMap(&base.DirsMirror, local.DirsMirror)
mergeLocalMap(&base.Directories, local.Directories)
mergeLocalMap(&base.Repos, local.Repos)
mergeLocalMap(&base.Packages, local.Packages)
mergeLocalMap(&base.TemplateVariables, local.TemplateVariables)
mergeLocalMap(&base.Shell.Aliases, local.Shell.Aliases)
mergeLocalMap(&base.Shell.Functions, local.Shell.Functions)
mergeLocalMap(&base.Shell.Env, local.Shell.Env)
mergeLocalMap(&base.Hooks.PreLink, local.Hooks.PreLink)
mergeLocalMap(&base.Hooks.PostLink, local.Hooks.PostLink)
mergeLocalMap(&base.Hooks.Builds, local.Hooks.Builds)
mergeLocalMap(&base.RecipesConfig.Overrides, local.RecipesConfig.Overrides)

// Slices: local replaces the whole slice when set.
if len(local.Tools) > 0 {
base.Tools = local.Tools
}
if len(local.Recipes) > 0 {
base.Recipes = local.Recipes
}
if len(local.Hooks.PreApply) > 0 {
base.Hooks.PreApply = local.Hooks.PreApply
}
if len(local.Hooks.PostApply) > 0 {
base.Hooks.PostApply = local.Hooks.PostApply
}
if len(local.Hooks.PreUninstall) > 0 {
base.Hooks.PreUninstall = local.Hooks.PreUninstall
}
if len(local.Hooks.PostUninstall) > 0 {
base.Hooks.PostUninstall = local.Hooks.PostUninstall
}
if len(local.RecipesConfig.Exclude) > 0 {
base.RecipesConfig.Exclude = local.RecipesConfig.Exclude
}
}

// mergeLocalMap copies every key from local into *base, allocating the base map
// if needed. Local values overwrite existing keys.
func mergeLocalMap[T any](base *map[string]T, local map[string]T) {
if len(local) == 0 {
return
}
if *base == nil {
*base = make(map[string]T, len(local))
}
maps.Copy(*base, local)
}
Loading
Loading