From 4bf6300f6f4797d4288660148a8447eded26e804 Mon Sep 17 00:00:00 2001 From: Alexander Brandstedt Date: Sun, 5 Jul 2026 17:41:50 +0200 Subject: [PATCH 1/6] feat(config): RecipeSource type + validation for [[recipe_sources]] Refs: MAD-185 --- internal/config/sources_test.go | 166 ++++++++++++++++++++++++++++++++ internal/config/types.go | 16 +++ internal/config/validate.go | 73 ++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 internal/config/sources_test.go diff --git a/internal/config/sources_test.go b/internal/config/sources_test.go new file mode 100644 index 0000000..014971e --- /dev/null +++ b/internal/config/sources_test.go @@ -0,0 +1,166 @@ +package config + +import ( + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +func TestDecodeRecipeSources(t *testing.T) { + raw := ` +dotfiles_repo_path = "~/dots" + +[[recipe_sources]] +name = "thismoon" +url = "git@github.com:mad01/thismoon.git" +ref = "main" +update = true +recipes_dir = "recipes" +` + var cfg Config + if _, err := toml.Decode(raw, &cfg); err != nil { + t.Fatalf("decode failed: %v", err) + } + if len(cfg.RecipeSources) != 1 { + t.Fatalf("expected 1 recipe source, got %d", len(cfg.RecipeSources)) + } + src := cfg.RecipeSources[0] + if src.Name != "thismoon" { + t.Errorf("name = %q, want thismoon", src.Name) + } + if src.URL != "git@github.com:mad01/thismoon.git" { + t.Errorf("url = %q", src.URL) + } + if src.Ref != "main" { + t.Errorf("ref = %q, want main", src.Ref) + } + if !src.Update { + t.Error("update = false, want true") + } + if src.RecipesDir != "recipes" { + t.Errorf("recipes_dir = %q, want recipes", src.RecipesDir) + } +} + +func TestValidateRecipeSources(t *testing.T) { + valid := RecipeSource{ + Name: "thismoon", + URL: "git@github.com:mad01/thismoon.git", + Ref: "main", + } + + tests := []struct { + name string + sources []RecipeSource + wantErr string + }{ + { + name: "valid single source", + sources: []RecipeSource{valid}, + }, + { + name: "valid minimal source", + sources: []RecipeSource{ + {Name: "x", URL: "https://github.com/mad01/x.git"}, + }, + }, + { + name: "empty name", + sources: []RecipeSource{ + {URL: "https://github.com/mad01/x.git"}, + }, + wantErr: "name cannot be empty", + }, + { + name: "name with slash", + sources: []RecipeSource{ + {Name: "a/b", URL: "https://github.com/mad01/x.git"}, + }, + wantErr: "invalid characters", + }, + { + name: "name is dot-dot", + sources: []RecipeSource{ + {Name: "..", URL: "https://github.com/mad01/x.git"}, + }, + wantErr: "invalid", + }, + { + name: "name with leading dash", + sources: []RecipeSource{ + {Name: "-x", URL: "https://github.com/mad01/x.git"}, + }, + wantErr: "invalid", + }, + { + name: "duplicate names", + sources: []RecipeSource{ + valid, + {Name: "thismoon", URL: "https://github.com/mad01/other.git"}, + }, + wantErr: "duplicate", + }, + { + name: "empty url", + sources: []RecipeSource{ + {Name: "x"}, + }, + wantErr: "url cannot be empty", + }, + { + name: "unsafe url", + sources: []RecipeSource{ + {Name: "x", URL: "ext::sh -c whoami"}, + }, + wantErr: "unsafe url", + }, + { + name: "unsafe ref", + sources: []RecipeSource{ + {Name: "x", URL: "https://github.com/mad01/x.git", Ref: "--upload-pack=evil"}, + }, + wantErr: "unsafe ref", + }, + { + name: "recipes_dir with parent traversal", + sources: []RecipeSource{ + { + Name: "x", + URL: "https://github.com/mad01/x.git", + RecipesDir: "../outside", + }, + }, + wantErr: "must not contain '..'", + }, + { + name: "absolute recipes_dir", + sources: []RecipeSource{ + {Name: "x", URL: "https://github.com/mad01/x.git", RecipesDir: "/etc"}, + }, + wantErr: "must be relative", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + DotfilesRepoPath: "~/dots", + RecipeSources: tt.sources, + } + err := ValidateConfig(cfg) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr) + } + }) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index e07b422..89ffcb7 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -16,6 +16,7 @@ type Config struct { Packages map[string]Package `toml:"packages"` Recipes []RecipeRef `toml:"recipes"` // Explicit recipe references (Mode A) RecipesConfig RecipesConfig `toml:"recipes_config"` // Auto-discovery configuration (Mode B) + RecipeSources []RecipeSource `toml:"recipe_sources"` // Remote recipe repos, cached under sources_dir // Profiles are the machine's semantic profile labels (e.g. "personal", // "work"). A recipe applies when its profiles intersect these. Normally set @@ -167,6 +168,21 @@ type RecipeRef struct { Hosts []string `toml:"hosts,omitempty"` // List of hostnames this recipe should apply to (empty = all hosts) } +// RecipeSource declares a remote git repository holding recipes. The repo is +// cached under / and its recipes are discovered in +// RecipesDir within the checkout, merged with identity "/". +type RecipeSource struct { + Name string `toml:"name"` // Cache dir name and recipe namespace prefix + URL string `toml:"url"` // Git URL; auth via the user's existing git/SSH setup + Ref string `toml:"ref,omitempty"` // Branch, tag, or commit to pin (empty = default branch) + Update bool `toml:"update,omitempty"` // Pull latest on each `ralph up` sync (branch refs only) + RecipesDir string `toml:"recipes_dir,omitempty"` // Recipe dir within the checkout (default "recipes") + Enable *bool `toml:"enable,omitempty"` // nil/true = enabled, false = disabled +} + +// DefaultSourcesDir is the default cache directory for remote recipe sources. +const DefaultSourcesDir = "~/.config/ralph/sources" + // RecipeOverride provides enable/hosts overrides for auto-discovered recipes. type RecipeOverride struct { Enable *bool `toml:"enable,omitempty"` // nil/true = enabled, false = disabled diff --git a/internal/config/validate.go b/internal/config/validate.go index 2ed8d17..ed66d80 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -382,6 +382,76 @@ func validatePackages(pkgs map[string]Package) error { return nil } +// isValidSourceName checks that a recipe source name is safe to use as a +// cache directory name and namespace prefix: letters, digits, dot, dash, +// underscore; not "." or ".."; no leading dash (option injection). +func isValidSourceName(name string) bool { + if name == "" || name == "." || name == ".." || strings.HasPrefix(name, "-") { + return false + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case r == '.', r == '-', r == '_': + default: + return false + } + } + return true +} + +// validateRecipeSources validates the [[recipe_sources]] entries. +func validateRecipeSources(sources []RecipeSource) error { + seen := make(map[string]bool, len(sources)) + for i, src := range sources { + if src.Name == "" { + return fmt.Errorf("recipe_source at index %d: name cannot be empty", i) + } + if !isValidSourceName(src.Name) { + return fmt.Errorf( + "recipe_source '%s': name contains invalid characters (allowed: letters, digits, '.', '-', '_'; no leading '-')", + src.Name, + ) + } + if seen[src.Name] { + return fmt.Errorf("recipe_source '%s': duplicate name", src.Name) + } + seen[src.Name] = true + if src.URL == "" { + return fmt.Errorf("recipe_source '%s': url cannot be empty", src.Name) + } + if !gitutil.IsSafeRemoteURL(src.URL) { + return fmt.Errorf( + "recipe_source '%s': unsafe url '%s' (leading '-' or ext::/fd:: transport not allowed)", + src.Name, + src.URL, + ) + } + if !gitutil.IsSafeGitRef(src.Ref) { + return fmt.Errorf( + "recipe_source '%s': unsafe ref '%s' (must not look like an option)", + src.Name, + src.Ref, + ) + } + if filepath.IsAbs(src.RecipesDir) { + return fmt.Errorf( + "recipe_source '%s': recipes_dir '%s' must be relative to the checkout", + src.Name, + src.RecipesDir, + ) + } + if hasParentTraversal(src.RecipesDir) { + return fmt.Errorf( + "recipe_source '%s': recipes_dir '%s' must not contain '..' segments", + src.Name, + src.RecipesDir, + ) + } + } + return nil +} + // ValidateConfig performs basic validation on the loaded configuration. func ValidateConfig(cfg *Config) error { if cfg.DotfilesRepoPath == "" { @@ -422,6 +492,9 @@ func ValidateConfig(cfg *Config) error { if err := validatePackages(cfg.Packages); err != nil { return err } + if err := validateRecipeSources(cfg.RecipeSources); err != nil { + return err + } // Validate recipe references for i, ref := range cfg.Recipes { From df6fcc2d0362df495a55546b83c4bf6abf449e2e Mon Sep 17 00:00:00 2001 From: Alexander Brandstedt Date: Sun, 5 Jul 2026 17:44:34 +0200 Subject: [PATCH 2/6] refactor(gitutil): extract git clone/fetch/checkout/pull primitives internal/repo delegates to gitutil so internal/config can reuse the same git operations for recipe sources without an import cycle. Refs: MAD-185 --- internal/{repo => gitutil}/clone_args_test.go | 15 +- internal/gitutil/sync.go | 83 ++++++++++ internal/gitutil/sync_test.go | 153 ++++++++++++++++++ internal/repo/clone.go | 73 +++------ 4 files changed, 267 insertions(+), 57 deletions(-) rename internal/{repo => gitutil}/clone_args_test.go (55%) create mode 100644 internal/gitutil/sync.go create mode 100644 internal/gitutil/sync_test.go diff --git a/internal/repo/clone_args_test.go b/internal/gitutil/clone_args_test.go similarity index 55% rename from internal/repo/clone_args_test.go rename to internal/gitutil/clone_args_test.go index dc5ddec..881d5dc 100644 --- a/internal/repo/clone_args_test.go +++ b/internal/gitutil/clone_args_test.go @@ -1,14 +1,12 @@ -package repo +package gitutil import ( "slices" "testing" - - "github.com/mad01/ralph/internal/config" ) -func TestBuildCloneArgs_InsertsSeparatorBeforePositionals(t *testing.T) { - args := buildCloneArgs(config.Repo{URL: "-oProxyCommand=evil", Branch: "main"}, "/tmp/t") +func TestCloneArgs_InsertsSeparatorBeforePositionals(t *testing.T) { + args := cloneArgs("-oProxyCommand=evil", "/tmp/t", "main") // "--" must appear before the URL so it cannot be parsed as an option. sep := slices.Index(args, "--") url := slices.Index(args, "-oProxyCommand=evil") @@ -20,3 +18,10 @@ func TestBuildCloneArgs_InsertsSeparatorBeforePositionals(t *testing.T) { t.Errorf("branch flag should precede --, got %v", args) } } + +func TestCloneArgs_NoBranchOmitsFlag(t *testing.T) { + args := cloneArgs("https://example.com/x.git", "/tmp/t", "") + if slices.Contains(args, "-b") { + t.Errorf("expected no -b flag, got %v", args) + } +} diff --git a/internal/gitutil/sync.go b/internal/gitutil/sync.go new file mode 100644 index 0000000..021e2c4 --- /dev/null +++ b/internal/gitutil/sync.go @@ -0,0 +1,83 @@ +package gitutil + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// Clone clones url into target. If branch is non-empty it is passed to +// git clone -b (works for branches and tags). The "--" separator before the +// positional URL and target prevents a "-"-prefixed URL from being parsed as +// a git option (argument injection). +func Clone(w io.Writer, url, target, branch string) error { + if err := runGitStreaming(w, "", cloneArgs(url, target, branch)...); err != nil { + return fmt.Errorf("failed to clone repository: %w", err) + } + return nil +} + +// cloneArgs assembles the argv for `git clone`. +func cloneArgs(url, target, branch string) []string { + args := []string{"clone"} + if branch != "" { + args = append(args, "-b", branch) + } + return append(args, "--", url, target) +} + +// Fetch fetches all remotes and tags in dir. +func Fetch(w io.Writer, dir string) error { + if err := runGitStreaming(w, dir, "fetch", "--all", "--tags"); err != nil { + return fmt.Errorf("failed to fetch: %w", err) + } + return nil +} + +// Checkout checks out a branch, tag, or commit in dir. +func Checkout(w io.Writer, dir, ref string) error { + if err := runGitStreaming(w, dir, "checkout", ref); err != nil { + return fmt.Errorf("failed to checkout %s: %w", ref, err) + } + return nil +} + +// Pull pulls the latest changes in dir. +func Pull(w io.Writer, dir string) error { + if err := runGitStreaming(w, dir, "pull"); err != nil { + return fmt.Errorf("failed to pull: %w", err) + } + return nil +} + +// CurrentBranch returns the checked-out branch name, or "" when HEAD is +// detached (tag or commit checkout) or dir is not a git repository. +func CurrentBranch(dir string) string { + out, err := runGit(dir, "symbolic-ref", "--short", "-q", "HEAD") + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + +// ResolveRef resolves a ref (branch, tag, or commit) to its commit hash in +// dir. Returns "" if the ref is unknown or dir is not a git repository. +func ResolveRef(dir, ref string) string { + out, err := runGit(dir, "rev-parse", "--verify", ref+"^{commit}") + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + +// runGitStreaming runs a git subcommand with stdout streamed to w and stderr +// to the process stderr. dir may be empty for commands like clone. +func runGitStreaming(w io.Writer, dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Stdout = w + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/gitutil/sync_test.go b/internal/gitutil/sync_test.go new file mode 100644 index 0000000..c500e8f --- /dev/null +++ b/internal/gitutil/sync_test.go @@ -0,0 +1,153 @@ +package gitutil + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// makeFixtureRepo creates a local git repo with two commits, a tag "v1.0.0" +// on the first commit, and a branch "feature" on the second. Returns the repo +// path and the two commit hashes. +func makeFixtureRepo(t *testing.T) (repoPath, first, second string) { + t.Helper() + repoPath = t.TempDir() + + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoPath + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + + run("init", "-b", "main") + if err := os.WriteFile(filepath.Join(repoPath, "a.txt"), []byte("one"), 0o644); err != nil { + t.Fatal(err) + } + run("add", ".") + run("commit", "-m", "first") + first = GetGitHash(repoPath) + run("tag", "v1.0.0") + + if err := os.WriteFile(filepath.Join(repoPath, "b.txt"), []byte("two"), 0o644); err != nil { + t.Fatal(err) + } + run("add", ".") + run("commit", "-m", "second") + second = GetGitHash(repoPath) + run("branch", "feature") + + return repoPath, first, second +} + +func TestCloneDefaultBranch(t *testing.T) { + src, _, second := makeFixtureRepo(t) + target := filepath.Join(t.TempDir(), "clone") + + if err := Clone(io.Discard, src, target, ""); err != nil { + t.Fatalf("clone failed: %v", err) + } + if got := GetGitHash(target); got != second { + t.Errorf("HEAD = %s, want %s", got, second) + } + if got := CurrentBranch(target); got != "main" { + t.Errorf("branch = %q, want main", got) + } +} + +func TestCloneTag(t *testing.T) { + src, first, _ := makeFixtureRepo(t) + target := filepath.Join(t.TempDir(), "clone") + + if err := Clone(io.Discard, src, target, "v1.0.0"); err != nil { + t.Fatalf("clone failed: %v", err) + } + if got := GetGitHash(target); got != first { + t.Errorf("HEAD = %s, want tag commit %s", got, first) + } + if got := CurrentBranch(target); got != "" { + t.Errorf("branch = %q, want detached HEAD", got) + } +} + +func TestCheckoutCommit(t *testing.T) { + src, first, second := makeFixtureRepo(t) + target := filepath.Join(t.TempDir(), "clone") + + if err := Clone(io.Discard, src, target, ""); err != nil { + t.Fatalf("clone failed: %v", err) + } + if err := Checkout(io.Discard, target, first); err != nil { + t.Fatalf("checkout failed: %v", err) + } + if got := GetGitHash(target); got != first { + t.Errorf("HEAD = %s, want %s", got, first) + } + if got := CurrentBranch(target); got != "" { + t.Errorf("branch = %q, want detached HEAD", got) + } + if got := GetGitHash(target); got == second { + t.Error("HEAD still at second commit after checkout") + } +} + +func TestFetchAndPullAdvances(t *testing.T) { + src, _, _ := makeFixtureRepo(t) + target := filepath.Join(t.TempDir(), "clone") + + if err := Clone(io.Discard, src, target, ""); err != nil { + t.Fatalf("clone failed: %v", err) + } + + // Advance the source repo. + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = src + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + if err := os.WriteFile(filepath.Join(src, "c.txt"), []byte("three"), 0o644); err != nil { + t.Fatal(err) + } + run("add", ".") + run("commit", "-m", "third") + third := GetGitHash(src) + + if err := Fetch(io.Discard, target); err != nil { + t.Fatalf("fetch failed: %v", err) + } + if err := Pull(io.Discard, target); err != nil { + t.Fatalf("pull failed: %v", err) + } + if got := GetGitHash(target); got != third { + t.Errorf("HEAD = %s, want %s after pull", got, third) + } +} + +func TestResolveRef(t *testing.T) { + src, first, second := makeFixtureRepo(t) + + if got := ResolveRef(src, "v1.0.0"); got != first { + t.Errorf("ResolveRef(v1.0.0) = %s, want %s", got, first) + } + if got := ResolveRef(src, "main"); got != second { + t.Errorf("ResolveRef(main) = %s, want %s", got, second) + } + if got := ResolveRef(src, "nope"); got != "" { + t.Errorf("ResolveRef(nope) = %q, want empty", got) + } +} diff --git a/internal/repo/clone.go b/internal/repo/clone.go index c0cc75a..d553835 100644 --- a/internal/repo/clone.go +++ b/internal/repo/clone.go @@ -4,10 +4,10 @@ import ( "fmt" "io" "os" - "os/exec" "sort" "github.com/mad01/ralph/internal/config" + "github.com/mad01/ralph/internal/gitutil" ) // CloneOrUpdateRepo clones a git repository or updates it based on configuration. @@ -48,47 +48,36 @@ func CloneOrUpdateRepo(w io.Writer, name string, repo config.Repo, dryRun bool) return nil } -// buildCloneArgs assembles the argv for `git clone`. The "--" separator before -// the positional URL and target prevents a "-"-prefixed URL from being parsed -// as a git option (argument injection). -func buildCloneArgs(repo config.Repo, absoluteTarget string) []string { - args := []string{"clone"} - if repo.Branch != "" { - args = append(args, "-b", repo.Branch) - } - args = append(args, "--", repo.URL, absoluteTarget) - return args -} - // cloneRepo clones a git repository to the target path. func cloneRepo(w io.Writer, repo config.Repo, absoluteTarget string, dryRun bool) error { - args := buildCloneArgs(repo, absoluteTarget) - if dryRun { - fmt.Fprintf(w, "[DRY RUN] Would clone: git %v\n", args) + branchInfo := "" + if repo.Branch != "" { + branchInfo = fmt.Sprintf(" (branch %s)", repo.Branch) + } + fmt.Fprintf( + w, + "[DRY RUN] Would clone '%s' to '%s'%s\n", + repo.URL, + absoluteTarget, + branchInfo, + ) if repo.Commit != "" { fmt.Fprintf(w, "[DRY RUN] Would checkout commit: %s\n", repo.Commit) } return nil } - fmt.Fprintf(w, "Cloning: git %v\n", args) - cmd := exec.Command("git", args...) - cmd.Stdout = w - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to clone repository: %w", err) + fmt.Fprintf(w, "Cloning '%s' to '%s'...\n", repo.URL, absoluteTarget) + if err := gitutil.Clone(w, repo.URL, absoluteTarget, repo.Branch); err != nil { + return err } // If commit is specified, checkout that commit after cloning if repo.Commit != "" { fmt.Fprintf(w, "Checking out commit: %s\n", repo.Commit) - checkoutCmd := exec.Command("git", "checkout", repo.Commit) - checkoutCmd.Dir = absoluteTarget - checkoutCmd.Stdout = w - checkoutCmd.Stderr = os.Stderr - if err := checkoutCmd.Run(); err != nil { - return fmt.Errorf("failed to checkout commit %s: %w", repo.Commit, err) + if err := gitutil.Checkout(w, absoluteTarget, repo.Commit); err != nil { + return err } } @@ -108,24 +97,12 @@ func checkoutCommit(w io.Writer, repo config.Repo, absoluteTarget string, dryRun } fmt.Fprintf(w, "Fetching in '%s'...\n", absoluteTarget) - fetchCmd := exec.Command("git", "fetch", "--all") - fetchCmd.Dir = absoluteTarget - fetchCmd.Stdout = w - fetchCmd.Stderr = os.Stderr - if err := fetchCmd.Run(); err != nil { - return fmt.Errorf("failed to fetch: %w", err) + if err := gitutil.Fetch(w, absoluteTarget); err != nil { + return err } fmt.Fprintf(w, "Checking out commit: %s\n", repo.Commit) - checkoutCmd := exec.Command("git", "checkout", repo.Commit) - checkoutCmd.Dir = absoluteTarget - checkoutCmd.Stdout = w - checkoutCmd.Stderr = os.Stderr - if err := checkoutCmd.Run(); err != nil { - return fmt.Errorf("failed to checkout commit %s: %w", repo.Commit, err) - } - - return nil + return gitutil.Checkout(w, absoluteTarget, repo.Commit) } // pullRepo pulls the latest changes in the repository. @@ -136,15 +113,7 @@ func pullRepo(w io.Writer, name string, absoluteTarget string, dryRun bool) erro } fmt.Fprintf(w, "Pulling latest for '%s' in '%s'...\n", name, absoluteTarget) - pullCmd := exec.Command("git", "pull") - pullCmd.Dir = absoluteTarget - pullCmd.Stdout = w - pullCmd.Stderr = os.Stderr - if err := pullCmd.Run(); err != nil { - return fmt.Errorf("failed to pull: %w", err) - } - - return nil + return gitutil.Pull(w, absoluteTarget) } // ProcessRepos processes all configured repositories. From 3e8e6cfc34f92922f312f06685cbee83ac4d29f4 Mon Sep 17 00:00:00 2001 From: Alexander Brandstedt Date: Sun, 5 Jul 2026 17:50:15 +0200 Subject: [PATCH 3/6] feat(config): load recipes from remote recipe sources Enabled [[recipe_sources]] entries are cloned/pinned under ~/.config/ralph/sources/ at load time, their recipes discovered and merged with identity /. Source recipe paths resolve absolute against the checkout; JoinSourcePath passes them through at every dotfiles-repo join site. Enable/hosts overrides use the namespaced key (overrides."/"). Refs: MAD-185 --- cmd/ralph/commands/cleanup.go | 2 +- cmd/ralph/commands/cmd_apply.go | 8 +- cmd/ralph/commands/cmd_list.go | 2 +- internal/config/recipe.go | 211 ++++++++++++++++++++---------- internal/config/sources.go | 92 +++++++++++++ internal/config/sources_test.go | 223 ++++++++++++++++++++++++++++++++ internal/dotfile/copy.go | 2 +- internal/dotfile/symlink.go | 4 +- internal/migrate/migrate.go | 2 +- 9 files changed, 468 insertions(+), 78 deletions(-) create mode 100644 internal/config/sources.go diff --git a/cmd/ralph/commands/cleanup.go b/cmd/ralph/commands/cleanup.go index a7143aa..d5829c7 100644 --- a/cmd/ralph/commands/cleanup.go +++ b/cmd/ralph/commands/cleanup.go @@ -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( diff --git a/cmd/ralph/commands/cmd_apply.go b/cmd/ralph/commands/cmd_apply.go index 707d561..9f54104 100644 --- a/cmd/ralph/commands/cmd_apply.go +++ b/cmd/ralph/commands/cmd_apply.go @@ -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, @@ -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, } @@ -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 @@ -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, } diff --git a/cmd/ralph/commands/cmd_list.go b/cmd/ralph/commands/cmd_list.go index 300fe75..39ab8dd 100644 --- a/cmd/ralph/commands/cmd_list.go +++ b/cmd/ralph/commands/cmd_list.go @@ -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. diff --git a/internal/config/recipe.go b/internal/config/recipe.go index 14c4e0a..307f286 100644 --- a/internal/config/recipe.go +++ b/internal/config/recipe.go @@ -401,90 +401,165 @@ func ProcessRecipes(cfg *Config, currentHost string) error { recipeRefs = append(recipeRefs, resolvedRef) } - } else { - // No recipes configured - return nil } + // No local recipes configured is fine — remote recipe sources below may + // still contribute recipes. // Process each recipe for _, ref := range recipeRefs { - // Disabled recipes are intentionally cleaned up — skip entirely. - if !IsEnabled(ref.Enable) { - continue + // Local recipes resolve paths relative to the dotfiles repo. + if err := processRecipeRef( + cfg, + ref, + expandedRepoPath, + filepath.Dir(ref.Path), + "", + currentHost, + ); err != nil { + return err } + } - // Load the recipe. - recipePath := filepath.Join(expandedRepoPath, ref.Path) - recipe, err := LoadRecipe(recipePath) + return processRecipeSources(cfg, currentHost) +} - // Host-filtered recipes belong to other hosts: don't apply them here, - // but record their name so cleanup freezes (rather than deletes) any - // artifacts a previous apply on a matching host recorded. A malformed - // off-host recipe must not break apply, so fall back to a ref-derived - // name if it failed to load. - if !ShouldApplyForHost(ref.Hosts, currentHost) { - cfg.HostFilteredRecipes = append( - cfg.HostFilteredRecipes, - resolveRecipeName(recipe, ref), - ) - continue - } +// processRecipeRef loads one recipe ref rooted at loadRoot and merges it into +// cfg. resolveDir is prefixed onto recipe-relative paths: the recipe dir +// relative to the dotfiles repo for local recipes, or the absolute recipe dir +// inside a source checkout (making merged paths absolute, which the apply +// layer passes through via JoinSourcePath). namePrefix namespaces the recipe +// identity ("/") for remote sources. +func processRecipeRef( + cfg *Config, + ref RecipeRef, + loadRoot, resolveDir, namePrefix string, + currentHost string, +) error { + // Disabled recipes are intentionally cleaned up — skip entirely. + if !IsEnabled(ref.Enable) { + return nil + } - if err != nil { - return fmt.Errorf("failed to load recipe '%s': %w", ref.Path, err) - } - - // Profile-filtered recipes belong to other machine profiles: freeze rather - // than apply, same as host-filtered recipes. Machine profiles come from the - // config.local.toml overlay (cfg.Profiles). Unlike the host filter, this - // must run after the load-error check above: a recipe's profiles live in - // its recipe.toml, so the recipe has to parse before we can read them — an - // unparseable on-host recipe is a hard error regardless of profile. - if !ShouldApplyForProfiles(recipe.Recipe.Profiles, cfg.Profiles) { - cfg.HostFilteredRecipes = append( - cfg.HostFilteredRecipes, - resolveRecipeName(recipe, ref), - ) - continue - } + // Load the recipe. + recipePath := filepath.Join(loadRoot, ref.Path) + recipe, err := LoadRecipe(recipePath) + + // Host-filtered recipes belong to other hosts: don't apply them here, + // but record their name so cleanup freezes (rather than deletes) any + // artifacts a previous apply on a matching host recorded. A malformed + // off-host recipe must not break apply, so fall back to a ref-derived + // name if it failed to load. + if !ShouldApplyForHost(ref.Hosts, currentHost) { + cfg.HostFilteredRecipes = append( + cfg.HostFilteredRecipes, + namePrefix+resolveRecipeName(recipe, ref), + ) + return nil + } + + if err != nil { + return fmt.Errorf("failed to load recipe '%s': %w", ref.Path, err) + } + + // Profile-filtered recipes belong to other machine profiles: freeze rather + // than apply, same as host-filtered recipes. Machine profiles come from the + // config.local.toml overlay (cfg.Profiles). Unlike the host filter, this + // must run after the load-error check above: a recipe's profiles live in + // its recipe.toml, so the recipe has to parse before we can read them — an + // unparseable on-host recipe is a hard error regardless of profile. + if !ShouldApplyForProfiles(recipe.Recipe.Profiles, cfg.Profiles) { + cfg.HostFilteredRecipes = append( + cfg.HostFilteredRecipes, + namePrefix+resolveRecipeName(recipe, ref), + ) + return nil + } + + // Resolve relative paths + ResolveRecipePaths(recipe, resolveDir) - // Get the directory containing the recipe - recipeDir := filepath.Dir(ref.Path) + // Apply recipe-level host filter to items that don't have their own + applyRecipeHostFilter(recipe, ref.Hosts) - // Resolve relative paths - ResolveRecipePaths(recipe, recipeDir) + // Get recipe name for error messages + recipeName := namePrefix + resolveRecipeName(recipe, ref) - // Apply recipe-level host filter to items that don't have their own - applyRecipeHostFilter(recipe, ref.Hosts) + // Merge into config + if err := MergeRecipeIntoConfig(cfg, recipe, recipeName); err != nil { + return err + } + + // Store loaded recipe info for migration + cleanup support + deleteBehavior := recipe.Recipe.DeleteBehavior + if deleteBehavior == "" { + deleteBehavior = DeleteBehaviorDelete + } + loadedWave := 1 + if recipe.Recipe.Wave != nil { + loadedWave = *recipe.Recipe.Wave + } + cfg.LoadedRecipes = append(cfg.LoadedRecipes, LoadedRecipeInfo{ + Path: ref.Path, + Dir: resolveDir, + Name: recipeName, + LegacyPaths: recipe.Recipe.LegacyPaths, + DeleteBehavior: deleteBehavior, + Wave: loadedWave, + Caveats: recipe.Recipe.Caveats, + PreUninstall: recipe.Hooks.PreUninstall, + PostUninstall: recipe.Hooks.PostUninstall, + }) + return nil +} + +// processRecipeSources ensures each enabled remote recipe source has a cached +// checkout, discovers its recipes, and merges them with identity +// "/". Enable/hosts overrides apply via the namespaced key +// (e.g. [recipes_config.overrides."thismoon/reminder"]). +func processRecipeSources(cfg *Config, currentHost string) error { + if len(cfg.RecipeSources) == 0 { + return nil + } + + sourcesDir, err := SourcesDir() + if err != nil { + return fmt.Errorf("failed to expand sources dir: %w", err) + } - // Get recipe name for error messages - recipeName := resolveRecipeName(recipe, ref) + for _, src := range cfg.RecipeSources { + if !IsEnabled(src.Enable) { + continue + } - // Merge into config - if err := MergeRecipeIntoConfig(cfg, recipe, recipeName); err != nil { + // Clone progress goes to stderr: config loading has no writer, and a + // first-run bootstrap clone should not be silent. + checkout, err := EnsureSourceCheckout(os.Stderr, src, sourcesDir) + if err != nil { return err } - // Store loaded recipe info for migration + cleanup support - deleteBehavior := recipe.Recipe.DeleteBehavior - if deleteBehavior == "" { - deleteBehavior = DeleteBehaviorDelete - } - loadedWave := 1 - if recipe.Recipe.Wave != nil { - loadedWave = *recipe.Recipe.Wave - } - cfg.LoadedRecipes = append(cfg.LoadedRecipes, LoadedRecipeInfo{ - Path: ref.Path, - Dir: recipeDir, - Name: recipeName, - LegacyPaths: recipe.Recipe.LegacyPaths, - DeleteBehavior: deleteBehavior, - Wave: loadedWave, - Caveats: recipe.Recipe.Caveats, - PreUninstall: recipe.Hooks.PreUninstall, - PostUninstall: recipe.Hooks.PostUninstall, - }) + refs, err := DiscoverRecipes(checkout, RecipesConfig{Dir: SourceRecipesDir(src)}) + if err != nil { + return fmt.Errorf("recipe_source '%s': discovery failed: %w", src.Name, err) + } + + for _, ref := range refs { + if override, ok := cfg.RecipesConfig.Overrides[src.Name+"/"+ref.Name]; ok { + ref.Enable = override.Enable + ref.Hosts = override.Hosts + } + // Absolute resolveDir roots the recipe's paths in the checkout. + if err := processRecipeRef( + cfg, + ref, + checkout, + filepath.Join(checkout, filepath.Dir(ref.Path)), + src.Name+"/", + currentHost, + ); err != nil { + return err + } + } } return nil diff --git a/internal/config/sources.go b/internal/config/sources.go new file mode 100644 index 0000000..a5050a3 --- /dev/null +++ b/internal/config/sources.go @@ -0,0 +1,92 @@ +package config + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/mad01/ralph/internal/gitutil" +) + +// SourcesDir returns the expanded cache directory for remote recipe sources. +func SourcesDir() (string, error) { + return ExpandPath(DefaultSourcesDir) +} + +// SourceCheckoutPath returns the cached checkout path for a recipe source. +func SourceCheckoutPath(sourcesDir string, src RecipeSource) string { + return filepath.Join(sourcesDir, src.Name) +} + +// SourceRecipesDir returns the source's recipe directory name within its +// checkout, defaulting to DefaultRecipesDir. +func SourceRecipesDir(src RecipeSource) string { + if src.RecipesDir == "" { + return DefaultRecipesDir + } + return src.RecipesDir +} + +// EnsureSourceCheckout makes sure the source's cached checkout exists and sits +// at the pinned ref. A missing checkout is cloned; an existing one is moved to +// Ref when HEAD differs (fetching first if the ref is unknown locally). +// Branch refs with update=true are pulled by the `ralph up` sync phase, not +// here — config loading must not hit the network for an already-pinned cache. +func EnsureSourceCheckout(w io.Writer, src RecipeSource, sourcesDir string) (string, error) { + target := SourceCheckoutPath(sourcesDir, src) + + info, err := os.Stat(target) + if err == nil && info.IsDir() { + if src.Ref == "" { + return target, nil + } + want := gitutil.ResolveRef(target, src.Ref) + if want == "" { + // Ref unknown locally (e.g. pin moved to a new tag): fetch first. + if err := gitutil.Fetch(w, target); err != nil { + return "", fmt.Errorf("recipe_source '%s': %w", src.Name, err) + } + want = gitutil.ResolveRef(target, src.Ref) + if want == "" { + return "", fmt.Errorf( + "recipe_source '%s': ref '%s' not found in %s", + src.Name, + src.Ref, + src.URL, + ) + } + } + if gitutil.GetGitHash(target) == want { + return target, nil + } + if err := gitutil.Checkout(w, target, src.Ref); err != nil { + return "", fmt.Errorf("recipe_source '%s': %w", src.Name, err) + } + return target, nil + } + + if err := os.MkdirAll(sourcesDir, 0o755); err != nil { + return "", fmt.Errorf("recipe_source '%s': creating sources dir: %w", src.Name, err) + } + fmt.Fprintf(w, "Cloning recipe source '%s' from %s...\n", src.Name, src.URL) + if err := gitutil.Clone(w, src.URL, target, ""); err != nil { + return "", fmt.Errorf("recipe_source '%s': %w", src.Name, err) + } + if src.Ref != "" { + if err := gitutil.Checkout(w, target, src.Ref); err != nil { + return "", fmt.Errorf("recipe_source '%s': %w", src.Name, err) + } + } + return target, nil +} + +// JoinSourcePath resolves a recipe item's source against the dotfiles repo +// path. Items merged from remote recipe sources carry absolute sources +// (rooted in the source's cached checkout) and pass through unchanged. +func JoinSourcePath(repoPath, source string) string { + if filepath.IsAbs(source) { + return source + } + return filepath.Join(repoPath, source) +} diff --git a/internal/config/sources_test.go b/internal/config/sources_test.go index 014971e..425bffb 100644 --- a/internal/config/sources_test.go +++ b/internal/config/sources_test.go @@ -1,12 +1,235 @@ package config import ( + "io" + "os" + "os/exec" + "path/filepath" "strings" "testing" "github.com/BurntSushi/toml" + + "github.com/mad01/ralph/internal/gitutil" ) +// makeSourceRepo creates a local git repo shaped like a recipe source: a +// recipes//recipe.toml declaring one dotfile whose source lives +// next to it. Returns the repo path and the two commit hashes (first has the +// tag "v1.0.0"; second is the branch tip of main). +func makeSourceRepo(t *testing.T, recipeName string) (repoPath, first, second string) { + t.Helper() + repoPath = t.TempDir() + + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoPath + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + + recipeDir := filepath.Join(repoPath, "recipes", recipeName) + if err := os.MkdirAll(recipeDir, 0o755); err != nil { + t.Fatal(err) + } + recipe := ` +[recipe] +name = "` + recipeName + `" + +[dotfiles.` + recipeName + `_conf] +source = "files/app.conf" +target = "~/.config/` + recipeName + `/app.conf" +` + if err := os.WriteFile(filepath.Join(recipeDir, "recipe.toml"), []byte(recipe), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(recipeDir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(recipeDir, "files", "app.conf"), []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + + run("init", "-b", "main") + run("add", ".") + run("commit", "-m", "first") + first = gitutil.GetGitHash(repoPath) + run("tag", "v1.0.0") + + if err := os.WriteFile(filepath.Join(recipeDir, "files", "app.conf"), []byte("v2"), 0o644); err != nil { + t.Fatal(err) + } + run("add", ".") + run("commit", "-m", "second") + second = gitutil.GetGitHash(repoPath) + + return repoPath, first, second +} + +func TestEnsureSourceCheckout_PinTypes(t *testing.T) { + src, first, second := makeSourceRepo(t, "app") + + tests := []struct { + name string + ref string + want string + }{ + {"branch", "main", second}, + {"tag", "v1.0.0", first}, + {"commit", first, first}, + {"default branch", "", second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sourcesDir := t.TempDir() + source := RecipeSource{Name: "app", URL: src, Ref: tt.ref} + + checkout, err := EnsureSourceCheckout(io.Discard, source, sourcesDir) + if err != nil { + t.Fatalf("ensure failed: %v", err) + } + if checkout != filepath.Join(sourcesDir, "app") { + t.Errorf("checkout = %q, want %q", checkout, filepath.Join(sourcesDir, "app")) + } + if got := gitutil.GetGitHash(checkout); got != tt.want { + t.Errorf("HEAD = %s, want %s", got, tt.want) + } + + // Ensure again: idempotent, no error, same state. + if _, err := EnsureSourceCheckout(io.Discard, source, sourcesDir); err != nil { + t.Fatalf("re-ensure failed: %v", err) + } + if got := gitutil.GetGitHash(checkout); got != tt.want { + t.Errorf("HEAD after re-ensure = %s, want %s", got, tt.want) + } + }) + } +} + +func TestEnsureSourceCheckout_RefChangeMovesCheckout(t *testing.T) { + src, first, second := makeSourceRepo(t, "app") + sourcesDir := t.TempDir() + + if _, err := EnsureSourceCheckout(io.Discard, RecipeSource{Name: "app", URL: src, Ref: "main"}, sourcesDir); err != nil { + t.Fatalf("ensure failed: %v", err) + } + checkout := filepath.Join(sourcesDir, "app") + if got := gitutil.GetGitHash(checkout); got != second { + t.Fatalf("HEAD = %s, want %s", got, second) + } + + // Repin to the tag: the existing checkout must move. + if _, err := EnsureSourceCheckout(io.Discard, RecipeSource{Name: "app", URL: src, Ref: "v1.0.0"}, sourcesDir); err != nil { + t.Fatalf("re-pin failed: %v", err) + } + if got := gitutil.GetGitHash(checkout); got != first { + t.Errorf("HEAD = %s, want tag commit %s", got, first) + } +} + +func TestProcessRecipes_RemoteSource(t *testing.T) { + src, _, _ := makeSourceRepo(t, "app") + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + + cfg := &Config{ + DotfilesRepoPath: t.TempDir(), + RecipeSources: []RecipeSource{{Name: "moon", URL: src, Ref: "main"}}, + } + if err := ProcessRecipes(cfg, "testhost"); err != nil { + t.Fatalf("ProcessRecipes failed: %v", err) + } + + df, ok := cfg.Dotfiles["app_conf"] + if !ok { + t.Fatalf("dotfile app_conf not merged; have %v", cfg.Dotfiles) + } + wantPrefix := filepath.Join(home, ".config", "ralph", "sources", "moon") + if !filepath.IsAbs(df.Source) || !strings.HasPrefix(df.Source, wantPrefix) { + t.Errorf("source = %q, want absolute path under %q", df.Source, wantPrefix) + } + if df.OwnerRecipe != "moon/app" { + t.Errorf("owner = %q, want moon/app", df.OwnerRecipe) + } + + var loaded *LoadedRecipeInfo + for i := range cfg.LoadedRecipes { + if cfg.LoadedRecipes[i].Name == "moon/app" { + loaded = &cfg.LoadedRecipes[i] + } + } + if loaded == nil { + t.Fatalf("no LoadedRecipes entry moon/app; have %+v", cfg.LoadedRecipes) + } + if !filepath.IsAbs(loaded.Dir) { + t.Errorf("loaded dir = %q, want absolute", loaded.Dir) + } + + // The merged source file must actually exist in the checkout. + if _, err := os.Stat(df.Source); err != nil { + t.Errorf("merged source does not exist: %v", err) + } +} + +func TestProcessRecipes_RemoteSource_DisabledByOverride(t *testing.T) { + src, _, _ := makeSourceRepo(t, "app") + + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", "") + + disabled := false + cfg := &Config{ + DotfilesRepoPath: t.TempDir(), + RecipeSources: []RecipeSource{{Name: "moon", URL: src}}, + RecipesConfig: RecipesConfig{ + Overrides: map[string]RecipeOverride{ + "moon/app": {Enable: &disabled}, + }, + }, + } + if err := ProcessRecipes(cfg, "testhost"); err != nil { + t.Fatalf("ProcessRecipes failed: %v", err) + } + if _, ok := cfg.Dotfiles["app_conf"]; ok { + t.Error("dotfile merged despite moon/app disabled override") + } +} + +func TestProcessRecipes_RemoteSource_DisabledSource(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", "") + + disabled := false + cfg := &Config{ + DotfilesRepoPath: t.TempDir(), + // URL points nowhere: a disabled source must not be cloned at all. + RecipeSources: []RecipeSource{ + {Name: "moon", URL: "/nonexistent/repo.git", Enable: &disabled}, + }, + } + if err := ProcessRecipes(cfg, "testhost"); err != nil { + t.Fatalf("ProcessRecipes failed: %v", err) + } +} + +func TestJoinSourcePath(t *testing.T) { + if got := JoinSourcePath("/repo", "recipes/x/file"); got != "/repo/recipes/x/file" { + t.Errorf("relative join = %q", got) + } + if got := JoinSourcePath("/repo", "/abs/checkout/file"); got != "/abs/checkout/file" { + t.Errorf("absolute passthrough = %q", got) + } +} + func TestDecodeRecipeSources(t *testing.T) { raw := ` dotfiles_repo_path = "~/dots" diff --git a/internal/dotfile/copy.go b/internal/dotfile/copy.go index 35dd0eb..df20918 100644 --- a/internal/dotfile/copy.go +++ b/internal/dotfile/copy.go @@ -28,7 +28,7 @@ func CopyFile( if dotfilesRepoPath == "" { // Source is already absolute (e.g., a processed template file) absoluteSource = dotfileCfg.Source } else { - absoluteSource, err = config.ExpandPath(filepath.Join(dotfilesRepoPath, dotfileCfg.Source)) + absoluteSource, err = config.ExpandPath(config.JoinSourcePath(dotfilesRepoPath, dotfileCfg.Source)) if err != nil { return fmt.Errorf("failed to expand source path '%s' relative to '%s': %w", dotfileCfg.Source, dotfilesRepoPath, err) } diff --git a/internal/dotfile/symlink.go b/internal/dotfile/symlink.go index 8efbe3b..f6d6046 100644 --- a/internal/dotfile/symlink.go +++ b/internal/dotfile/symlink.go @@ -111,7 +111,7 @@ func CreateSymlink( if dotfilesRepoPath == "" { // Source is already absolute (e.g., a processed template file) absoluteSource = dotfileCfg.Source } else { - absoluteSource, err = config.ExpandPath(filepath.Join(dotfilesRepoPath, dotfileCfg.Source)) + absoluteSource, err = config.ExpandPath(config.JoinSourcePath(dotfilesRepoPath, dotfileCfg.Source)) if err != nil { return fmt.Errorf("failed to expand source path '%s' relative to '%s': %w", dotfileCfg.Source, dotfilesRepoPath, err) } @@ -234,7 +234,7 @@ func CreateDirSymlink( if dotfilesRepoPath == "" { absoluteSource = dotfileCfg.Source } else { - absoluteSource, err = config.ExpandPath(filepath.Join(dotfilesRepoPath, dotfileCfg.Source)) + absoluteSource, err = config.ExpandPath(config.JoinSourcePath(dotfilesRepoPath, dotfileCfg.Source)) if err != nil { return fmt.Errorf("failed to expand source path '%s' relative to '%s': %w", dotfileCfg.Source, dotfilesRepoPath, err) } diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index c5c8d01..6dc4a1f 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -129,7 +129,7 @@ func checkSymlink( result.Target = expandedTarget // Calculate expected source path - expectedSource := filepath.Join(repoPath, df.Source) + expectedSource := config.JoinSourcePath(repoPath, df.Source) result.NewSource = expectedSource // Check if target exists From 3a8e1cd31550ba062b60556abe6b460b33f21ffa Mon Sep 17 00:00:00 2001 From: Alexander Brandstedt Date: Sun, 5 Jul 2026 17:52:20 +0200 Subject: [PATCH 4/6] feat(up): pull update=true recipe sources during sync phase Pinned (detached HEAD) sources are skipped; the config-reload trigger now fingerprints recipe source checkouts alongside the dotfiles repo. Refs: MAD-185 --- cmd/ralph/commands/cmd_up.go | 20 ++-- cmd/ralph/commands/sources_sync.go | 75 +++++++++++++ cmd/ralph/commands/sources_sync_test.go | 137 ++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 cmd/ralph/commands/sources_sync.go create mode 100644 cmd/ralph/commands/sources_sync_test.go diff --git a/cmd/ralph/commands/cmd_up.go b/cmd/ralph/commands/cmd_up.go index 07cbdbb..673dfcd 100644 --- a/cmd/ralph/commands/cmd_up.go +++ b/cmd/ralph/commands/cmd_up.go @@ -81,13 +81,13 @@ 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, @@ -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{ diff --git a/cmd/ralph/commands/sources_sync.go b/cmd/ralph/commands/sources_sync.go new file mode 100644 index 0000000..3564bbe --- /dev/null +++ b/cmd/ralph/commands/sources_sync.go @@ -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, ",") +} diff --git a/cmd/ralph/commands/sources_sync_test.go b/cmd/ralph/commands/sources_sync_test.go new file mode 100644 index 0000000..9f507a9 --- /dev/null +++ b/cmd/ralph/commands/sources_sync_test.go @@ -0,0 +1,137 @@ +package commands + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/mad01/ralph/internal/config" + "github.com/mad01/ralph/internal/gitutil" + "github.com/mad01/ralph/internal/report" +) + +// makeSourceFixture creates a local origin repo and a source checkout of it +// under a fake HOME's sources dir, returning the origin path and checkout path. +func makeSourceFixture(t *testing.T, name, ref string) (origin, checkout string) { + t.Helper() + origin = t.TempDir() + + gitIn := func(dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + + gitIn(origin, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(origin, "a.txt"), []byte("one"), 0o644); err != nil { + t.Fatal(err) + } + gitIn(origin, "add", ".") + gitIn(origin, "commit", "-m", "first") + gitIn(origin, "tag", "v1.0.0") + + home := t.TempDir() + t.Setenv("HOME", home) + + sourcesDir, err := config.SourcesDir() + if err != nil { + t.Fatal(err) + } + src := config.RecipeSource{Name: name, URL: origin, Ref: ref} + checkout, err = config.EnsureSourceCheckout(io.Discard, src, sourcesDir) + if err != nil { + t.Fatalf("ensure failed: %v", err) + } + return origin, checkout +} + +func advanceOrigin(t *testing.T, origin string) string { + t.Helper() + if err := os.WriteFile(filepath.Join(origin, "b.txt"), []byte("two"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"add", "."}, {"commit", "-m", "second"}} { + cmd := exec.Command("git", args...) + cmd.Dir = origin + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return gitutil.GetGitHash(origin) +} + +func TestSyncRecipeSources_PullsBranchSource(t *testing.T) { + origin, checkout := makeSourceFixture(t, "moon", "main") + second := advanceOrigin(t, origin) + + cfg := &config.Config{ + RecipeSources: []config.RecipeSource{ + {Name: "moon", URL: origin, Ref: "main", Update: true}, + }, + } + rpt := &report.Report{Command: "test"} + phase := rpt.AddPhase("Recipe sources") + + if ok := syncRecipeSources(io.Discard, cfg, phase, false); !ok { + t.Fatal("sync reported failure") + } + if got := gitutil.GetGitHash(checkout); got != second { + t.Errorf("HEAD = %s, want %s after pull", got, second) + } +} + +func TestSyncRecipeSources_SkipsPinnedAndNoUpdate(t *testing.T) { + origin, checkout := makeSourceFixture(t, "moon", "v1.0.0") + before := gitutil.GetGitHash(checkout) + advanceOrigin(t, origin) + + cfg := &config.Config{ + RecipeSources: []config.RecipeSource{ + // Pinned to a tag: detached HEAD, update must be a no-op. + {Name: "moon", URL: origin, Ref: "v1.0.0", Update: true}, + }, + } + rpt := &report.Report{Command: "test"} + phase := rpt.AddPhase("Recipe sources") + + if ok := syncRecipeSources(io.Discard, cfg, phase, false); !ok { + t.Fatal("sync reported failure") + } + if got := gitutil.GetGitHash(checkout); got != before { + t.Errorf("pinned checkout moved: %s -> %s", before, got) + } +} + +func TestSyncFingerprint_ChangesWhenSourceAdvances(t *testing.T) { + origin, checkout := makeSourceFixture(t, "moon", "main") + + cfg := &config.Config{ + DotfilesRepoPath: t.TempDir(), + RecipeSources: []config.RecipeSource{ + {Name: "moon", URL: origin, Ref: "main", Update: true}, + }, + } + + before := syncFingerprint(cfg) + advanceOrigin(t, origin) + if err := gitutil.Pull(io.Discard, checkout); err != nil { + t.Fatalf("pull failed: %v", err) + } + after := syncFingerprint(cfg) + if before == after { + t.Error("fingerprint unchanged after source advanced") + } +} From 05eb7d51ac5374d783380f6bae05dcf547a10b3a Mon Sep 17 00:00:00 2001 From: Alexander Brandstedt Date: Sun, 5 Jul 2026 17:56:38 +0200 Subject: [PATCH 5/6] docs: document [[recipe_sources]] remote recipe repos Refs: MAD-185 --- CLAUDE.md | 2 ++ cmd/ralph/commands/cmd_up.go | 4 ++-- docs/configuration.md | 38 ++++++++++++++++++++++++++++++++++++ docs/recipes.md | 23 ++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4acb124..a9cd943 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/ @@ -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. diff --git a/cmd/ralph/commands/cmd_up.go b/cmd/ralph/commands/cmd_up.go index 673dfcd..c6d19aa 100644 --- a/cmd/ralph/commands/cmd_up.go +++ b/cmd/ralph/commands/cmd_up.go @@ -92,13 +92,13 @@ Use --no-sync to skip the sync step and only apply.`, 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.")) } } } diff --git a/docs/configuration.md b/docs/configuration.md index 96efd03..410a879 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -559,6 +559,44 @@ hosts = ["work-laptop"] enable = false ``` +### `[[recipe_sources]]` + +Remote git repositories that provide recipes, so recipes can live outside your dotfiles checkout. Each source is cached under `~/.config/ralph/sources/` and its recipes are discovered automatically in `recipes_dir` within the checkout. + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `name` | string | yes | -- | Cache directory name and recipe namespace prefix. Letters, digits, `.`, `-`, `_`. | +| `url` | string | yes | -- | Git URL. Authentication uses your existing git/SSH setup; ralph handles no tokens. | +| `ref` | string | no | default branch | Branch, tag, or commit to pin. | +| `update` | bool | no | `false` | Pull the latest state on each `ralph up` sync. Only meaningful for branch refs; pinned tags and commits never move on their own. | +| `recipes_dir` | string | no | `"recipes"` | Recipe directory within the checkout. | +| `enable` | bool (pointer) | no | `nil` | Enable/disable the whole source. | + +```toml +[[recipe_sources]] +name = "thismoon" +url = "git@github.com:mad01/thismoon.git" +ref = "main" +update = true +recipes_dir = "recipes" +``` + +Recipes from a source merge with the identity `/` (e.g. `thismoon/reminder`), which is how they appear in the recipe-state manifest and in conflict errors. Enable/hosts overrides use the same namespaced key: + +```toml +[recipes_config.overrides."thismoon/reminder"] +enable = false +``` + +How the cache behaves: + +- A missing checkout is cloned the first time any ralph command loads the config. +- Changing `ref` moves the existing checkout to the new pin on the next load (fetching if the ref is not known locally). +- `update = true` sources are pulled during the `ralph up` sync phase; if a pull advances a source, ralph reloads the config so the same run applies the just-pulled recipes. +- `ralph up --no-sync` skips source updates, same as it skips the dotfiles repo pull. + +Paths inside a source recipe (dotfile `source`, `dirs_mirror` entries, tool config files) resolve against the source checkout, not your dotfiles repo. Build and package `working_dir` fields are not recipe-relative; use absolute or `~`-prefixed paths, same as in local recipes. + ### Recipe File Format (`recipe.toml`) A recipe file can contain any of the same sections as the main config (except top-level fields and recipe references). It also supports metadata and legacy path mappings for migration. diff --git a/docs/recipes.md b/docs/recipes.md index fae4c86..e464770 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -113,6 +113,29 @@ enable = false | `exclude` | list | Glob patterns to exclude from discovery | | `overrides` | map | Per-recipe overrides keyed by directory name, supporting `enable` and `hosts` | +### Remote recipe sources + +Recipes can also come from other git repositories, declared with `[[recipe_sources]]`. This combines with either loading mode: local recipes load as usual, and each source contributes its own recipes on top. + +```toml +[[recipe_sources]] +name = "thismoon" +url = "git@github.com:mad01/thismoon.git" +ref = "main" # branch, tag, or commit +update = true # pull on each `ralph up` +``` + +ralph caches each source under `~/.config/ralph/sources/`, discovers every `recipe.toml` in the source's `recipes_dir` (default `recipes/`), and merges them with the identity `/`. A `reminder` recipe from the `thismoon` source is tracked as `thismoon/reminder`, so it cannot conflict with a local recipe named `reminder`. + +Host filtering, profiles, waves, and dependency ordering work exactly as for local recipes. To disable or host-scope one recipe from a source, use the namespaced override key: + +```toml +[recipes_config.overrides."thismoon/reminder"] +enable = false +``` + +Pinning: a `ref` of a tag or commit stays put until you change it in the config; a branch ref combined with `update = true` follows the branch on each `ralph up`. See [`[[recipe_sources]]` in the configuration reference](configuration.md#recipe_sources) for the full field list and cache behavior. + ## Host filtering Setting `hosts` on a recipe reference applies that filter to every item in the recipe that does not already define its own `hosts` field. Items with their own `hosts` field keep their own filter. From 1c74fd8d50bb22732d8d73d63fd4a05c4ecbe030 Mon Sep 17 00:00:00 2001 From: Alexander Brandstedt Date: Sun, 5 Jul 2026 18:08:41 +0200 Subject: [PATCH 6/6] fix: address review findings for recipe sources - ralph doctor resolves absolute source paths via JoinSourcePath (three missed join sites in doctor_validate.go) - config.local.toml overlay no longer drops [[recipe_sources]] - ralph enable/disable accept namespaced / names - EnsureSourceCheckout re-pins to remote-only branches via checkout DWIM instead of hard-failing on local ref resolution Refs: MAD-185 --- cmd/ralph/commands/cmd_enable.go | 39 ++++++++++++++++- cmd/ralph/commands/cmd_enable_sources_test.go | 38 +++++++++++++++++ cmd/ralph/commands/doctor_validate.go | 7 ++-- internal/config/local.go | 3 ++ internal/config/sources.go | 23 +++++----- internal/config/sources_test.go | 42 +++++++++++++++++++ 6 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 cmd/ralph/commands/cmd_enable_sources_test.go diff --git a/cmd/ralph/commands/cmd_enable.go b/cmd/ralph/commands/cmd_enable.go index 8854d2a..ea4ad04 100644 --- a/cmd/ralph/commands/cmd_enable.go +++ b/cmd/ralph/commands/cmd_enable.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/mad01/ralph/internal/config" "github.com/spf13/cobra" @@ -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 "/" 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) @@ -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) diff --git a/cmd/ralph/commands/cmd_enable_sources_test.go b/cmd/ralph/commands/cmd_enable_sources_test.go new file mode 100644 index 0000000..c70665d --- /dev/null +++ b/cmd/ralph/commands/cmd_enable_sources_test.go @@ -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") + } +} diff --git a/cmd/ralph/commands/doctor_validate.go b/cmd/ralph/commands/doctor_validate.go index 3247ee7..283f748 100644 --- a/cmd/ralph/commands/doctor_validate.go +++ b/cmd/ralph/commands/doctor_validate.go @@ -3,7 +3,6 @@ package commands import ( "fmt" "os" - "path/filepath" "github.com/mad01/ralph/internal/config" "github.com/mad01/ralph/internal/report" @@ -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( @@ -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'", @@ -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 diff --git a/internal/config/local.go b/internal/config/local.go index 92cbdd4..168faf5 100644 --- a/internal/config/local.go +++ b/internal/config/local.go @@ -104,6 +104,9 @@ func mergeLocalConfig(base, local *Config) { if len(local.Recipes) > 0 { base.Recipes = local.Recipes } + if len(local.RecipeSources) > 0 { + base.RecipeSources = local.RecipeSources + } if len(local.Hooks.PreApply) > 0 { base.Hooks.PreApply = local.Hooks.PreApply } diff --git a/internal/config/sources.go b/internal/config/sources.go index a5050a3..2df5541 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -43,25 +43,26 @@ func EnsureSourceCheckout(w io.Writer, src RecipeSource, sourcesDir string) (str } want := gitutil.ResolveRef(target, src.Ref) if want == "" { - // Ref unknown locally (e.g. pin moved to a new tag): fetch first. + // Ref unknown locally (e.g. pin moved to a new tag, or to a branch + // that was never checked out here): fetch first. ResolveRef only + // sees local refs, so it can still come back empty for a remote + // branch — git checkout's DWIM below resolves origin/ and + // creates the tracking branch, and its error is the real answer. if err := gitutil.Fetch(w, target); err != nil { return "", fmt.Errorf("recipe_source '%s': %w", src.Name, err) } want = gitutil.ResolveRef(target, src.Ref) - if want == "" { - return "", fmt.Errorf( - "recipe_source '%s': ref '%s' not found in %s", - src.Name, - src.Ref, - src.URL, - ) - } } - if gitutil.GetGitHash(target) == want { + if want != "" && gitutil.GetGitHash(target) == want { return target, nil } if err := gitutil.Checkout(w, target, src.Ref); err != nil { - return "", fmt.Errorf("recipe_source '%s': %w", src.Name, err) + return "", fmt.Errorf( + "recipe_source '%s': ref '%s': %w", + src.Name, + src.Ref, + err, + ) } return target, nil } diff --git a/internal/config/sources_test.go b/internal/config/sources_test.go index 425bffb..0abec5e 100644 --- a/internal/config/sources_test.go +++ b/internal/config/sources_test.go @@ -221,6 +221,48 @@ func TestProcessRecipes_RemoteSource_DisabledSource(t *testing.T) { } } +func TestEnsureSourceCheckout_RePinToRemoteOnlyBranch(t *testing.T) { + src, _, second := makeSourceRepo(t, "app") + + // Create a branch in the origin that the checkout has never tracked. + cmd := exec.Command("git", "branch", "feature") + cmd.Dir = src + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git branch: %v\n%s", err, out) + } + + sourcesDir := t.TempDir() + if _, err := EnsureSourceCheckout(io.Discard, RecipeSource{Name: "app", URL: src, Ref: "main"}, sourcesDir); err != nil { + t.Fatalf("initial ensure failed: %v", err) + } + + // Re-pin to the branch that exists only on the remote: checkout DWIM must + // create the local tracking branch instead of failing. + if _, err := EnsureSourceCheckout(io.Discard, RecipeSource{Name: "app", URL: src, Ref: "feature"}, sourcesDir); err != nil { + t.Fatalf("re-pin to remote-only branch failed: %v", err) + } + checkout := filepath.Join(sourcesDir, "app") + if got := gitutil.GetGitHash(checkout); got != second { + t.Errorf("HEAD = %s, want %s", got, second) + } + if got := gitutil.CurrentBranch(checkout); got != "feature" { + t.Errorf("branch = %q, want feature", got) + } +} + +func TestMergeLocalConfig_RecipeSources(t *testing.T) { + base := &Config{DotfilesRepoPath: "~/dots"} + local := &Config{ + RecipeSources: []RecipeSource{ + {Name: "moon", URL: "git@github.com:mad01/thismoon.git"}, + }, + } + mergeLocalConfig(base, local) + if len(base.RecipeSources) != 1 || base.RecipeSources[0].Name != "moon" { + t.Errorf("recipe sources not merged from local overlay: %+v", base.RecipeSources) + } +} + func TestJoinSourcePath(t *testing.T) { if got := JoinSourcePath("/repo", "recipes/x/file"); got != "/repo/recipes/x/file" { t.Errorf("relative join = %q", got)