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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ internal/
enable.go IsEnabled (*bool pattern: nil/true=enabled)
host.go Host filtering (ShouldApplyForHost)
recipe.go Recipe loading, discovery, and merging
sources.go Remote recipe sources ([[recipe_sources]]): cache under ~/.config/ralph/sources/
overrides.go Set/remove recipe overrides in config.toml (text-level, backup+validate)
migrate.go MigrateFromLegacy (dotter → ralph)
dotfile/
Expand Down Expand Up @@ -196,6 +197,7 @@ ralph reads config from `RALPH_CONFIG` env var or `~/.config/ralph/config.toml`.
| `~/.config/ralph/.recipe_state` | Recipe artifact manifest — what each recipe owns (JSON); drives cleanup |
| `~/.config/ralph/generated/` | Generated shell files: `generated_aliases.sh`, `generated_functions.sh`, `generated_env.sh` |
| `~/.config/ralph/pkg/` | Default clone dir for remote/make packages (`packages_dir`) |
| `~/.config/ralph/sources/` | Cached checkouts of remote recipe sources (`[[recipe_sources]]`) |

`ralph state show` prints `.recipe_state` in a readable form.

Expand Down
2 changes: 1 addition & 1 deletion cmd/ralph/commands/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func buildIntendedManifest(
if err != nil {
continue
}
absoluteSource := filepath.Join(expandedRepo, dm.Source)
absoluteSource := config.JoinSourcePath(expandedRepo, dm.Source)
entries, err := os.ReadDir(absoluteSource)
if err != nil {
return nil, fmt.Errorf(
Expand Down
8 changes: 4 additions & 4 deletions cmd/ralph/commands/cmd_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ func applyDirsMirror(ctx *applyContext, symlinkAction dotfile.SymlinkAction) {
fmt.Fprintf(ctx.w, " %s\n", bold(name))

// Resolve source directory
absoluteSource, err := config.ExpandPath(filepath.Join(ctx.cfg.DotfilesRepoPath, dm.Source))
absoluteSource, err := config.ExpandPath(config.JoinSourcePath(ctx.cfg.DotfilesRepoPath, dm.Source))
if err != nil {
fmt.Fprintln(
os.Stderr,
Expand Down Expand Up @@ -500,7 +500,7 @@ func applyDotfiles(ctx *applyContext, symlinkAction dotfile.SymlinkAction) {
if preHooks, exists := ctx.cfg.Hooks.PreLink[name]; exists && len(preHooks) > 0 {
linkContext := &hooks.HookContext{
DotfileName: name,
SourcePath: filepath.Join(ctx.cfg.DotfilesRepoPath, df.Source),
SourcePath: config.JoinSourcePath(ctx.cfg.DotfilesRepoPath, df.Source),
TargetPath: df.Target,
DryRun: ctx.dryRun,
}
Expand All @@ -518,7 +518,7 @@ func applyDotfiles(ctx *applyContext, symlinkAction dotfile.SymlinkAction) {
templateData := make(map[string]any)

var symlinkErr error
currentSourcePath := filepath.Join(ctx.cfg.DotfilesRepoPath, df.Source)
currentSourcePath := config.JoinSourcePath(ctx.cfg.DotfilesRepoPath, df.Source)
dotfileToSymlink := df
repoPathForSymlink := ctx.cfg.DotfilesRepoPath

Expand Down Expand Up @@ -614,7 +614,7 @@ func applyDotfiles(ctx *applyContext, symlinkAction dotfile.SymlinkAction) {
if postHooks, exists := ctx.cfg.Hooks.PostLink[name]; exists && len(postHooks) > 0 {
linkContext := &hooks.HookContext{
DotfileName: name,
SourcePath: filepath.Join(ctx.cfg.DotfilesRepoPath, df.Source),
SourcePath: config.JoinSourcePath(ctx.cfg.DotfilesRepoPath, df.Source),
TargetPath: df.Target,
DryRun: ctx.dryRun,
}
Expand Down
39 changes: 38 additions & 1 deletion cmd/ralph/commands/cmd_enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/mad01/ralph/internal/config"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -78,8 +79,13 @@ func loadConfigAndPath() (string, *config.Config, error) {
}

// verifyRecipeExists checks that a recipe directory with recipe.toml exists
// in the dotfiles repo.
// in the dotfiles repo, or — for a namespaced "<source>/<recipe>" name — in
// the recipe source's cached checkout.
func verifyRecipeExists(cfg *config.Config, recipeName string) error {
if source, recipe, ok := strings.Cut(recipeName, "/"); ok {
return verifySourceRecipeExists(cfg, source, recipe)
}

repoPath, err := config.ExpandPath(cfg.DotfilesRepoPath)
if err != nil {
return fmt.Errorf("error expanding dotfiles repo path: %w", err)
Expand All @@ -101,6 +107,37 @@ func verifyRecipeExists(cfg *config.Config, recipeName string) error {
return nil
}

// verifySourceRecipeExists checks that a recipe exists in the named recipe
// source's cached checkout.
func verifySourceRecipeExists(cfg *config.Config, sourceName, recipeName string) error {
for _, src := range cfg.RecipeSources {
if src.Name != sourceName {
continue
}
sourcesDir, err := config.SourcesDir()
if err != nil {
return fmt.Errorf("error expanding sources dir: %w", err)
}
checkout := config.SourceCheckoutPath(sourcesDir, src)
recipeFile := filepath.Join(
checkout,
config.SourceRecipesDir(src),
recipeName,
"recipe.toml",
)
if _, err := os.Stat(recipeFile); os.IsNotExist(err) {
return fmt.Errorf(
"recipe '%s' not found in recipe source '%s' (%s)",
recipeName,
sourceName,
checkout,
)
}
return nil
}
return fmt.Errorf("recipe source '%s' is not declared in [[recipe_sources]]", sourceName)
}

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

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

"github.com/mad01/ralph/internal/config"
)

func TestVerifyRecipeExists_SourceRecipe(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

// Fake a cached source checkout with one recipe.
recipeDir := filepath.Join(home, ".config", "ralph", "sources", "moon", "recipes", "app")
if err := os.MkdirAll(recipeDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(recipeDir, "recipe.toml"), []byte("[recipe]\n"), 0o644); err != nil {
t.Fatal(err)
}

cfg := &config.Config{
DotfilesRepoPath: t.TempDir(),
RecipeSources: []config.RecipeSource{{Name: "moon", URL: "git@github.com:mad01/x.git"}},
}

if err := verifyRecipeExists(cfg, "moon/app"); err != nil {
t.Errorf("existing source recipe rejected: %v", err)
}
if err := verifyRecipeExists(cfg, "moon/missing"); err == nil {
t.Error("missing source recipe accepted")
}
if err := verifyRecipeExists(cfg, "nosuch/app"); err == nil {
t.Error("undeclared source accepted")
}
}
2 changes: 1 addition & 1 deletion cmd/ralph/commands/cmd_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ var listCmd = &cobra.Command{
statusMsg = "Symlink (error reading destination)"
statusColor = color.New(color.FgRed)
} else {
absoluteSource, _ := config.ExpandPath(filepath.Join(cfg.DotfilesRepoPath, df.Source))
absoluteSource, _ := config.ExpandPath(config.JoinSourcePath(cfg.DotfilesRepoPath, df.Source))
var expectedLinkDest string
if df.IsTemplate {
// For templates, the symlink points to a processed file which is absolute.
Expand Down
24 changes: 16 additions & 8 deletions cmd/ralph/commands/cmd_up.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,24 +81,24 @@ Use --no-sync to skip the sync step and only apply.`,

// --- Sync phase ---
if !upNoSync {
headBefore := dotfilesRepoHead(cfg)
headBefore := syncFingerprint(cfg)
runSyncPhase(w, cfg, currentHost, rpt)
// If the pull advanced the dotfiles repo, the recipes/config on disk
// changed under us — reload so this same run applies the just-pulled
// state instead of the pre-pull snapshot (otherwise cross-machine
// edits always land one `ralph up` late).
if headAfter := dotfilesRepoHead(cfg); headAfter != "" && headAfter != headBefore {
// If the pull advanced the dotfiles repo or a recipe source, the
// recipes/config on disk changed under us — reload so this same run
// applies the just-pulled state instead of the pre-pull snapshot
// (otherwise cross-machine edits always land one `ralph up` late).
if headAfter := syncFingerprint(cfg); headAfter != headBefore {
if reloaded, err := config.LoadConfig(); err != nil {
fmt.Fprintln(
os.Stderr,
color.YellowString(
"Warning: dotfiles repo updated but config reload failed; applying pre-pull config: %v",
"Warning: dotfiles repo or a recipe source updated but config reload failed; applying pre-pull config: %v",
err,
),
)
} else {
cfg = reloaded
fmt.Fprintln(w, color.CyanString("Dotfiles repo advanced during sync; reloaded config."))
fmt.Fprintln(w, color.CyanString("Dotfiles repo or recipe source advanced during sync; reloaded config."))
}
}
}
Expand Down Expand Up @@ -221,6 +221,14 @@ func runSyncPhase(w io.Writer, cfg *config.Config, currentHost string, rpt *repo
progress.StatusLine("Dotfiles repo", pullOK)
}

if len(cfg.RecipeSources) > 0 {
srcPhase := rpt.AddPhase("Recipe sources")
srcOK := syncRecipeSources(w, cfg, srcPhase, dryRun)
if !verbose && !dryRun {
progress.StatusLine("Recipe sources", srcOK)
}
}

if len(cfg.Packages) > 0 {
remotePhase := rpt.AddPhase("Packages (sync)")
opts := packages.SyncOptions{
Expand Down
7 changes: 3 additions & 4 deletions cmd/ralph/commands/doctor_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package commands
import (
"fmt"
"os"
"path/filepath"

"github.com/mad01/ralph/internal/config"
"github.com/mad01/ralph/internal/report"
Expand Down Expand Up @@ -50,7 +49,7 @@ func validateCopyTarget(

// Size comparison for drift detection (skip for templates — source is ephemeral)
if !df.IsTemplate && repoPath != "" {
sourcePath := filepath.Join(repoPath, df.Source)
sourcePath := config.JoinSourcePath(repoPath, df.Source)
sourceInfo, err := os.Stat(sourcePath)
if err == nil && sourceInfo.Size() != targetInfo.Size() {
return report.StatusWarn, fmt.Sprintf(
Expand Down Expand Up @@ -79,7 +78,7 @@ func validateDirSymlinkTarget(
return report.StatusFail, fmt.Sprintf("error reading symlink destination: %v", err), err
}

expectedSource := filepath.Join(repoPath, df.Source)
expectedSource := config.JoinSourcePath(repoPath, df.Source)
if linkDest != expectedSource {
return report.StatusWarn, fmt.Sprintf(
"directory symlink points to '%s', expected '%s'",
Expand Down Expand Up @@ -113,7 +112,7 @@ func validateSymlinkTarget(
if df.IsTemplate {
actualSourcePath = linkDest
} else {
expectedSource := filepath.Join(repoPath, df.Source)
expectedSource := config.JoinSourcePath(repoPath, df.Source)
actualSourcePath = expectedSource
if linkDest != actualSourcePath {
actualSourcePath = linkDest
Expand Down
75 changes: 75 additions & 0 deletions cmd/ralph/commands/sources_sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package commands

import (
"fmt"
"io"
"strings"

"github.com/mad01/ralph/internal/config"
"github.com/mad01/ralph/internal/gitutil"
"github.com/mad01/ralph/internal/report"
)

// syncRecipeSources pulls the latest state of update=true recipe sources.
// Sources pinned to a tag or commit (detached HEAD) are skipped: their state
// is fully described by the ref, and EnsureSourceCheckout moves them when the
// pin changes. Returns true when every attempted pull succeeded.
func syncRecipeSources(
w io.Writer,
cfg *config.Config,
phase *report.Phase,
isDryRun bool,
) bool {
sourcesDir, err := config.SourcesDir()
if err != nil {
phase.AddFail("sources", "failed to expand sources dir", err)
return false
}

ok := true
for _, src := range cfg.RecipeSources {
if !config.IsEnabled(src.Enable) {
phase.AddSkip(src.Name, "disabled")
continue
}
if !src.Update {
phase.AddSkip(src.Name, "update disabled")
continue
}
dir := config.SourceCheckoutPath(sourcesDir, src)
if gitutil.CurrentBranch(dir) == "" {
phase.AddSkip(src.Name, fmt.Sprintf("pinned to %s", src.Ref))
continue
}
if isDryRun {
fmt.Fprintf(w, " [DRY RUN] Would pull recipe source '%s' in '%s'\n", src.Name, dir)
phase.AddSkip(src.Name, "dry run")
continue
}
fmt.Fprintf(w, " Pulling recipe source '%s'...\n", src.Name)
if err := gitutil.Pull(w, dir); err != nil {
phase.AddFail(src.Name, "pull failed", err)
ok = false
continue
}
phase.AddOK(src.Name, "pulled")
}
return ok
}

// syncFingerprint captures the dotfiles repo HEAD plus every enabled recipe
// source checkout HEAD. A change between two fingerprints means the recipes
// or config on disk moved under the running process, so the merged config
// must be reloaded before applying.
func syncFingerprint(cfg *config.Config) string {
parts := []string{dotfilesRepoHead(cfg)}
if sourcesDir, err := config.SourcesDir(); err == nil {
for _, src := range cfg.RecipeSources {
if !config.IsEnabled(src.Enable) {
continue
}
parts = append(parts, gitutil.GetGitHash(config.SourceCheckoutPath(sourcesDir, src)))
}
}
return strings.Join(parts, ",")
}
Loading
Loading