From 7e6a8bce0cce0562719134803c71cd28807196cb Mon Sep 17 00:00:00 2001 From: Rick Liu Date: Sat, 25 Jul 2026 23:35:01 +0100 Subject: [PATCH 1/2] feat(module): pin module version via version field and ?ref= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A git module previously always cloned its source's default branch, so a run tracked whatever was on main. Add version pinning to a branch, tag, or commit: - spec.modules[].version — a visible, templatable field on child module references (the preferred form). - ?ref= — a Terraform-style suffix on the source string, for the CLI/root source (which has no modules[] entry to hold a field). Also accepted in a child source; the version field takes precedence. ResolveSource takes an explicit version and combines it with any ?ref= suffix, then clones in full and checks out the ref via a new git.Checkout (go-git ResolveRevision with git-CLI fallback). Pinning a local source is an error. Documented as spec rule M5 with referencing tests. Co-Authored-By: Claude Opus 4.8 --- README.md | 20 +++++++- cmd/diff.go | 2 +- cmd/run.go | 4 +- docs/guide/module-composition.md | 41 ++++++++++++++++ docs/reference/cli-run.md | 11 +++++ docs/reference/loom-yaml.md | 6 ++- pkg/bulk/bulk.go | 2 +- pkg/config/types.go | 5 ++ pkg/config/validate.go | 1 + pkg/git/cli.go | 10 ++++ pkg/git/cli_test.go | 16 +++++++ pkg/git/repo.go | 40 ++++++++++++++++ pkg/git/repo_test.go | 80 ++++++++++++++++++++++++++++++++ pkg/module/executor.go | 10 +++- pkg/module/executor_test.go | 77 ++++++++++++++++++++++++++++-- pkg/module/resolver.go | 54 +++++++++++++++++++-- pkg/module/resolver_test.go | 50 ++++++++++++++++++-- specs/module.md | 68 +++++++++++++++++++++++++-- 18 files changed, 474 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index fef70d0..1366b35 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,8 @@ Child modules to execute before this module's operations. This is how you compos | Field | Description | |-------|-------------| | `name` | Identifier for the child module | -| `source` | Path to the child module — local (`./sub-module`) or a Git URL | +| `source` | Path to the child module — local (`./sub-module`) or a Git URL, optionally with a `//subdir` | +| `version` | Optional. Pins a git `source` to a branch, tag, or commit (equivalent to a `?ref=` suffix, which it overrides). Templatable | | `params` | Parameters to pass down, rendered through the parent's context | Child modules execute first, in order. Then the parent's operations run. This lets you build layered workflows: a base module that creates the namespace, a service module that creates the ArgoCD app, a policy module that adds the Gatekeeper constraint — all composed from a single root module. @@ -703,9 +704,26 @@ Execution order: Sources can be: - **Local paths** (`./relative/path` or `/absolute/path`) — resolved relative to the parent module - **Git URLs** — cloned to a temporary directory automatically +- **Git URLs with `//subdir`** — one repo hosting many modules (Terraform convention) This means you can publish reusable modules as Git repositories. A platform team maintains the standard modules; product teams compose them. +**Pinning a version.** By default a git module tracks its default branch. Pin a child module to a released version with the `version` field so runs are reproducible: + +```yaml +modules: + - name: onboard-service + source: https://github.com/myorg/loom-modules.git//onboard-service + version: v1.4.0 # branch, tag, or commit +``` + +The root module is named on the command line, so version it with a `?ref=` suffix on the source instead (the same suffix also works in a child `source`; the `version` field wins if you set both): + +```bash +loom run 'https://github.com/myorg/loom-modules.git//onboard-service?ref=v1.4.0' \ + -p serviceName=payments +``` + ## CLI ### `loom run` diff --git a/cmd/diff.go b/cmd/diff.go index 8a5f04a..3e76dcf 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -335,7 +335,7 @@ func resolveModuleAndTarget(ctx context.Context, source string, paramMap map[str } } - moduleDir, srcCleanup, err := module.ResolveSource(source, ".", logger) + moduleDir, srcCleanup, err := module.ResolveSource(source, "", ".", logger) if err != nil { return nil, "", nil, err } diff --git a/cmd/run.go b/cmd/run.go index 00b2c59..188a240 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -51,8 +51,8 @@ func runModule(cmd *cobra.Command, args []string) error { source = args[0] } - // Resolve source — handles git URLs, //subdir, and local paths. - moduleDir, cleanup, err := module.ResolveSource(source, ".", logger) + // Resolve source — handles git URLs, //subdir, ?ref= version, and local paths. + moduleDir, cleanup, err := module.ResolveSource(source, "", ".", logger) if err != nil { return err } diff --git a/docs/guide/module-composition.md b/docs/guide/module-composition.md index f82bde1..a1dffff 100644 --- a/docs/guide/module-composition.md +++ b/docs/guide/module-composition.md @@ -49,6 +49,7 @@ Sources can be: - **Local paths** (`./relative/path` or `/absolute/path`) — resolved relative to the parent module - **Git URLs** — cloned to a temporary directory automatically - **Git URLs with `//` separator** — `repo.git//subdir` clones the repo and uses `subdir/` as the module root +- **Git URLs with `?ref=`** — pins the clone to a branch, tag, or commit (see [Pinning a module version](#pinning-a-module-version)) The `//` convention (borrowed from Terraform) lets a single Git repository host multiple modules in different directories: @@ -88,6 +89,46 @@ modules: This means you can publish reusable modules as Git repositories. A platform team maintains the standard modules; product teams compose them. +### Pinning a module version + +Without a version, loom clones a git module's default branch — so a run picks up +whatever is on `main` at the time. Pin a module to an exact **branch, tag, or +commit SHA** to make runs reproducible. + +For a child module, add a `version` field next to `source`: + +```yaml +modules: + - name: networking + source: https://github.com/myorg/loom-modules.git//networking + version: v1.4.0 # branch, tag, or commit +``` + +Because `version` is templatable, it can be a parameter: + +```yaml +params: + - name: modVersion + default: "v1.4.0" +modules: + - name: networking + source: "https://github.com/myorg/loom-modules.git//networking" + version: "{{ .modVersion }}" +``` + +The **root module** is passed on the command line, so it has no `version` field +to set. Version it with a `?ref=` suffix on the source instead (the +Terraform convention — the `?ref=` comes last, after any `//subdir`): + +```bash +loom run 'https://github.com/myorg/loom-modules.git//onboard?ref=v2.0.0' -p serviceName=payments +``` + +The same `?ref=` suffix also works in a child `source`; the `version` field +takes precedence if you set both. Version pinning applies to git sources only — +combining it with a local path is an error, since a local path is already fixed +on disk. + ## Parameter Flow Parameters are passed from parent to child through the `params` field. Child params are rendered through the parent's template context, so you can reference any parent parameter: diff --git a/docs/reference/cli-run.md b/docs/reference/cli-run.md index 2631e50..06f5431 100644 --- a/docs/reference/cli-run.md +++ b/docs/reference/cli-run.md @@ -31,6 +31,7 @@ loom run [path] [flags] | `./relative` or `/absolute` | Local directory | | `https://github.com/org/repo.git` | Git URL — clones to temp dir, `loom.yaml` at repo root | | `https://github.com/org/repo.git//subdir` | Git URL with `//` separator — `loom.yaml` at `subdir/` within the clone | +| `https://github.com/org/repo.git?ref=` | Git URL pinned to a branch, tag, or commit — the `?ref=` selector comes last, after any `//subdir` | ## Examples @@ -47,6 +48,16 @@ loom run https://github.com/myorg/loom-modules.git//onboard-service \ -p serviceName=payments ``` +### Full run — pinned module version + +Append `?ref=` to pin the module to a branch, tag, or commit so the +run is reproducible instead of tracking the default branch: + +```bash +loom run 'https://github.com/myorg/loom-modules.git//onboard-service?ref=v1.4.0' \ + -p serviceName=payments +``` + ### Dry run Modules with a `target` spec clone the target repo into a temporary diff --git a/docs/reference/loom-yaml.md b/docs/reference/loom-yaml.md index 556dbbc..d0a23f9 100644 --- a/docs/reference/loom-yaml.md +++ b/docs/reference/loom-yaml.md @@ -78,7 +78,7 @@ Parameters are the inputs to your module. They're injected into every template - `spec.excludes[]`, `spec.includes[]` - `spec.dynamicParams[].command`, `spec.dynamicParams[].default` - `spec.target.url`, `spec.target.branch`, `spec.target.featureBranch` -- `spec.modules[].name`, `spec.modules[].source`, `spec.modules[].params` values +- `spec.modules[].name`, `spec.modules[].source`, `spec.modules[].version`, `spec.modules[].params` values - `newFiles.source`, `newFiles.dest` - `patch.engine`, `patch.path`, `patch.target` - `shell.command`, `shell.timeout` @@ -202,7 +202,8 @@ Child modules to execute before this module's operations. See [Module Compositio | Field | Description | |-------|-------------| | `name` | Identifier for the child module. Templatable. | -| `source` | Path to the child module. Accepts local paths, git URLs, or git URLs with `//subdir` separator. Templatable — rendered before source resolution. | +| `source` | Path to the child module. Accepts local paths, git URLs, and git URLs with a `//subdir` separator. Templatable — rendered before source resolution. | +| `version` | Optional. Pins a git `source` to a branch, tag, or commit. Templatable. Ignored for local sources (an error to combine). Equivalent to a `?ref=` suffix on the source, which it overrides. | | `params` | Parameters to pass down, rendered through the parent's context | | `if` | Optional shell predicate gating the child. Templatable (parent's params). Runs via `sh -c` in the child's resolved target dir — exit `0` runs the child, non-zero skips it. See [`if`](#conditional-execution-if). | @@ -213,6 +214,7 @@ Source formats: | `./relative` or `/absolute` | Local path, resolved relative to parent module directory | | `https://github.com/org/repo.git` | Git URL — `loom.yaml` expected at repo root | | `https://github.com/org/repo.git//path` | Git URL — `loom.yaml` expected at `path/` within the clone | +| `https://github.com/org/repo.git//path?ref=v1.4.0` | Git URL pinned to a branch, tag, or commit — `?ref=` comes last, after any `//path`. For child modules, prefer the `version` field above. | ## `spec.operations` diff --git a/pkg/bulk/bulk.go b/pkg/bulk/bulk.go index 8be3fd1..702e1f5 100644 --- a/pkg/bulk/bulk.go +++ b/pkg/bulk/bulk.go @@ -46,7 +46,7 @@ func Run(opts Options, logger *slog.Logger) error { } // B1: load and validate the child module. - moduleDir, cleanup, err := module.ResolveSource(opts.ModuleRef, ".", logger) + moduleDir, cleanup, err := module.ResolveSource(opts.ModuleRef, "", ".", logger) if err != nil { return err } diff --git a/pkg/config/types.go b/pkg/config/types.go index 5a08558..86582fe 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -47,6 +47,11 @@ type ModuleRef struct { Name string `yaml:"name"` Source string `yaml:"source"` Params map[string]string `yaml:"params,omitempty"` + // Version optionally pins a git module source to a specific branch, tag, or + // commit. Empty means the source's default branch. Templatable. Ignored for + // local sources (an error to combine). Equivalent to a "?ref=" suffix on the + // source, which the field takes precedence over. + Version string `yaml:"version,omitempty"` // If is an optional shell predicate gating child execution. Empty means // always run. Templated with the parent's params, then run via sh -c in // the child's resolved target dir: exit 0 runs the child, non-zero skips it. diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 7ccfaac..bf0a8ad 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -140,6 +140,7 @@ func validate(lf *LoomFile, moduleDir string) error { } checkTmpl(fmt.Sprintf("module %q name", m.Name), m.Name) checkTmpl(fmt.Sprintf("module %q source", m.Name), m.Source) + checkTmpl(fmt.Sprintf("module %q version", m.Name), m.Version) checkTmpl(fmt.Sprintf("module %q if", m.Name), m.If) paramKeys := make([]string, 0, len(m.Params)) for k := range m.Params { diff --git a/pkg/git/cli.go b/pkg/git/cli.go index 11d9aba..30ff8c7 100644 --- a/pkg/git/cli.go +++ b/pkg/git/cli.go @@ -26,6 +26,16 @@ func cliClone(ctx context.Context, url, dir, branch string) error { return nil } +func cliCheckout(ctx context.Context, dir, ref string) error { + cmd := exec.CommandContext(ctx, "git", "checkout", ref) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git checkout %s: %w\n%s", ref, err, output) + } + return nil +} + func cliCreateBranch(dir, name string) error { cmd := exec.Command("git", "checkout", "-b", name) cmd.Dir = dir diff --git a/pkg/git/cli_test.go b/pkg/git/cli_test.go index e322452..c2c8a6d 100644 --- a/pkg/git/cli_test.go +++ b/pkg/git/cli_test.go @@ -700,3 +700,19 @@ func runGitCmd(name string, args ...string) (string, error) { out, err := cmd.CombinedOutput() return string(out), err } + +func TestCliCheckout(t *testing.T) { + bare := initTaggedBareRepo(t) + ctx := context.Background() + + dir := t.TempDir() + if err := cliClone(ctx, bare, dir, ""); err != nil { + t.Fatal(err) + } + if err := cliCheckout(ctx, dir, "v1"); err != nil { + t.Fatalf("cliCheckout v1: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "extra.txt")); !os.IsNotExist(err) { + t.Errorf("expected extra.txt absent at v1, stat err = %v", err) + } +} diff --git a/pkg/git/repo.go b/pkg/git/repo.go index 4463341..01a2bf0 100644 --- a/pkg/git/repo.go +++ b/pkg/git/repo.go @@ -76,6 +76,46 @@ func Open(dir string, logger *slog.Logger) (Repository, error) { return &Repo{gg: r, dir: dir, logger: logger}, nil } +// Checkout switches the working tree in dir to an arbitrary git ref — a branch, +// tag, or commit SHA. It is used to pin a module source to a specific version. +// A full clone (as produced by Clone with an empty branch) has every branch, +// tag, and reachable commit on disk, so any of these refs resolves. +// +// Like the rest of this package it tries go-git first and falls back to the git +// CLI, whose `git checkout` resolves refs (notably remote-tracking branches via +// DWIM) that go-git's ResolveRevision does not. +func Checkout(ctx context.Context, dir, ref string, logger *slog.Logger) error { + logger.Info("checking out module version", "ref", ref) + + if err := checkoutLib(dir, ref); err == nil { + return nil + } else if !hasBinary("git") { + return err + } else { + logger.Debug("go-git checkout failed, falling back to git CLI", "ref", ref, "error", err) + } + return cliCheckout(ctx, dir, ref) +} + +// checkoutLib resolves ref to a commit and checks it out via go-git. Tags and +// commits produce a detached HEAD, which is exactly what a pinned, read-only +// module source needs. +func checkoutLib(dir, ref string) error { + r, err := gogit.PlainOpen(dir) + if err != nil { + return err + } + hash, err := r.ResolveRevision(plumbing.Revision(ref)) + if err != nil { + return fmt.Errorf("resolving ref %q: %w", ref, err) + } + wt, err := r.Worktree() + if err != nil { + return err + } + return wt.Checkout(&gogit.CheckoutOptions{Hash: *hash}) +} + func (r *Repo) Dir() string { return r.dir } // --------------------------------------------------------------------------- diff --git a/pkg/git/repo_test.go b/pkg/git/repo_test.go index e0c9f13..60130a8 100644 --- a/pkg/git/repo_test.go +++ b/pkg/git/repo_test.go @@ -922,3 +922,83 @@ func TestCrossPath_FullLoomFlow_CLIPush(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// Checkout — pin a module clone to a ref (branch, tag, or commit) +// --------------------------------------------------------------------------- + +// initTaggedBareRepo builds a bare repo whose default branch adds extra.txt on +// top of a base commit tagged "v1" (base.txt only). Returns the bare repo path. +func initTaggedBareRepo(t *testing.T) string { + t.Helper() + + work := t.TempDir() + for _, args := range [][]string{ + {"git", "init", work}, + {"git", "-C", work, "config", "user.email", "test@test.com"}, + {"git", "-C", work, "config", "user.name", "Test"}, + } { + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + + if err := os.WriteFile(filepath.Join(work, "base.txt"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + gitCmd(t, work, "add", ".") + gitCmd(t, work, "commit", "-m", "base") + gitCmd(t, work, "tag", "v1") + + if err := os.WriteFile(filepath.Join(work, "extra.txt"), []byte("extra\n"), 0o644); err != nil { + t.Fatal(err) + } + gitCmd(t, work, "add", ".") + gitCmd(t, work, "commit", "-m", "extra") + + bare := t.TempDir() + if out, err := exec.Command("git", "clone", "--bare", work, bare).CombinedOutput(); err != nil { + t.Fatalf("bare clone failed: %v\n%s", err, out) + } + return bare +} + +func TestCheckout_Tag(t *testing.T) { + bare := initTaggedBareRepo(t) + ctx := context.Background() + logger := testLogger() + + cloneDir := t.TempDir() + if _, err := Clone(ctx, bare, cloneDir, "", logger); err != nil { + t.Fatal(err) + } + // The default branch carries the newer commit. + if _, err := os.Stat(filepath.Join(cloneDir, "extra.txt")); err != nil { + t.Fatalf("expected extra.txt on default branch: %v", err) + } + + if err := Checkout(ctx, cloneDir, "v1", logger); err != nil { + t.Fatalf("checkout v1: %v", err) + } + // v1 predates extra.txt. + if _, err := os.Stat(filepath.Join(cloneDir, "extra.txt")); !os.IsNotExist(err) { + t.Errorf("expected extra.txt absent at v1, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(cloneDir, "base.txt")); err != nil { + t.Errorf("expected base.txt at v1: %v", err) + } +} + +func TestCheckout_UnknownRef(t *testing.T) { + bare := initTaggedBareRepo(t) + ctx := context.Background() + logger := testLogger() + + cloneDir := t.TempDir() + if _, err := Clone(ctx, bare, cloneDir, "", logger); err != nil { + t.Fatal(err) + } + if err := Checkout(ctx, cloneDir, "does-not-exist", logger); err == nil { + t.Fatal("expected error checking out a nonexistent ref") + } +} diff --git a/pkg/module/executor.go b/pkg/module/executor.go index 9480b7c..29e8bb9 100644 --- a/pkg/module/executor.go +++ b/pkg/module/executor.go @@ -141,7 +141,15 @@ func Execute(ctx context.Context, mod *Module, targetDir string, opts RunOptions return fmt.Errorf("rendering source for child %q: %w", childName, err) } - childDir, sourceCleanup, err := ResolveSource(renderedSource, mod.Dir, childLogger) + // The version field pins the child to a branch, tag, or commit. Like + // name/source/params it is authored in the parent and renders with the + // parent's params (M4/IF2). + renderedVersion, err := tmpl.RenderString(childRef.Version, mod.Params) + if err != nil { + return fmt.Errorf("rendering version for child %q: %w", childName, err) + } + + childDir, sourceCleanup, err := ResolveSource(renderedSource, renderedVersion, mod.Dir, childLogger) if err != nil { return fmt.Errorf("resolving child module %q: %w", childName, err) } diff --git a/pkg/module/executor_test.go b/pkg/module/executor_test.go index 7713257..cb5b182 100644 --- a/pkg/module/executor_test.go +++ b/pkg/module/executor_test.go @@ -52,6 +52,77 @@ func initBareRepo(t *testing.T) string { return bare } +// initTaggedBareRepo builds a bare repo whose default branch's greeting.txt +// reads "main" atop a base commit tagged "v1" whose greeting.txt reads "v1". +// This lets a test tell which version ResolveSource checked out. Returns the +// bare repo path. +func initTaggedBareRepo(t *testing.T) string { + t.Helper() + + work := t.TempDir() + if out, err := exec.Command("git", "init", work).CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + run := func(args ...string) { + full := append([]string{"-C", work}, args...) + if out, err := exec.Command("git", full...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + os.WriteFile(filepath.Join(work, "greeting.txt"), []byte("v1\n"), 0o644) + run("add", "-A") + run("commit", "-m", "v1") + run("tag", "v1") + + os.WriteFile(filepath.Join(work, "greeting.txt"), []byte("main\n"), 0o644) + run("add", "-A") + run("commit", "-m", "main") + + bare := t.TempDir() + if out, err := exec.Command("git", "clone", "--bare", work, bare).CombinedOutput(); err != nil { + t.Fatalf("bare clone: %v\n%s", err, out) + } + return bare +} + +func TestResolveSource_M5_VersionField(t *testing.T) { + bare := initTaggedBareRepo(t) + + dir, cleanup, err := ResolveSource("file://"+bare, "v1", "", testLogger()) + if err != nil { + t.Fatal(err) + } + if cleanup != nil { + defer cleanup() + } + got, _ := os.ReadFile(filepath.Join(dir, "greeting.txt")) + if strings.TrimSpace(string(got)) != "v1" { + t.Errorf("expected greeting.txt from v1 tag, got %q", got) + } +} + +func TestResolveSource_M5_FieldOverridesRefSuffix(t *testing.T) { + bare := initTaggedBareRepo(t) + + // The version field says v1; the ?ref= suffix names a ref that does not + // exist. If the suffix were used the checkout would fail — success with v1 + // content proves the explicit field wins and the suffix is ignored. + dir, cleanup, err := ResolveSource("file://"+bare+"?ref=nonexistent", "v1", "", testLogger()) + if err != nil { + t.Fatal(err) + } + if cleanup != nil { + defer cleanup() + } + got, _ := os.ReadFile(filepath.Join(dir, "greeting.txt")) + if strings.TrimSpace(string(got)) != "v1" { + t.Errorf("expected version field to override ?ref= suffix, got %q", got) + } +} + // writeLoomYAML writes a loom.yaml into dir with the given content. func writeLoomYAML(t *testing.T, dir, content string) { t.Helper() @@ -339,7 +410,7 @@ func TestResolveChildTarget_Cleanup_RemovesDir(t *testing.T) { func TestResolveSource_LocalPath_NilCleanup(t *testing.T) { dir := t.TempDir() - resolved, cleanup, err := ResolveSource(".", dir, testLogger()) + resolved, cleanup, err := ResolveSource(".", "", dir, testLogger()) if err != nil { t.Fatal(err) } @@ -354,7 +425,7 @@ func TestResolveSource_LocalPath_NilCleanup(t *testing.T) { func TestResolveSource_GitURL_ReturnsCleanup(t *testing.T) { bare := initBareRepo(t) - dir, cleanup, err := ResolveSource("file://"+bare, "", testLogger()) + dir, cleanup, err := ResolveSource("file://"+bare, "", "", testLogger()) if err != nil { t.Fatal(err) } @@ -1143,7 +1214,7 @@ spec: } // Resolve source with //networking subdir. - moduleDir, cleanup, err := ResolveSource("file://"+bare+"//networking", "", testLogger()) + moduleDir, cleanup, err := ResolveSource("file://"+bare+"//networking", "", "", testLogger()) if err != nil { t.Fatal(err) } diff --git a/pkg/module/resolver.go b/pkg/module/resolver.go index 1bf0ff9..bc6394f 100644 --- a/pkg/module/resolver.go +++ b/pkg/module/resolver.go @@ -11,6 +11,20 @@ import ( "github.com/rickliujh/loom/pkg/git" ) +// splitSourceRef splits an optional "?ref=" version selector off the +// end of a module source. Returns (base, ref); ref is empty when no selector is +// present. Following the Terraform convention, the ref query is the last +// component of the source — after any "//subdir" — so it is stripped before URL +// and local-path parsing. +func splitSourceRef(source string) (base, ref string) { + const marker = "?ref=" + idx := strings.LastIndex(source, marker) + if idx < 0 { + return source, "" + } + return source[:idx], source[idx+len(marker):] +} + // splitSourceURL splits a source URL at the first "//" that is not part of a // scheme (i.e. not preceded by ":"). Returns (repoURL, subDir). If no "//" // separator is found, subDir is empty. @@ -35,9 +49,28 @@ func splitSourceURL(source string) (repoURL, subDir string) { // Sources starting with "." or "/" are treated as local paths (cleanup is nil). // Other sources are treated as git URLs and cloned to a temp directory. // The "//" separator in git URLs denotes a subdirectory within the repo. +// +// version optionally pins a git source to a branch, tag, or commit. It may be +// given as the explicit `version` argument (spec.modules[].version, used by +// child modules) or as a "?ref=" suffix on the source (used on the +// CLI, where the source is the whole argument); the explicit argument wins when +// both are present. +// // Callers must call cleanup (if non-nil) when the directory is no longer needed. -func ResolveSource(source, parentDir string, logger *slog.Logger) (dir string, cleanup func(), err error) { +func ResolveSource(source, version, parentDir string, logger *slog.Logger) (dir string, cleanup func(), err error) { + // Strip any "?ref=" suffix before path/URL parsing so the + // local-path and "//subdir" checks operate on the bare source. The explicit + // version argument takes precedence over the suffix. + source, srcRef := splitSourceRef(source) + ref := version + if ref == "" { + ref = srcRef + } + if strings.HasPrefix(source, ".") || strings.HasPrefix(source, "/") { + if ref != "" { + return "", nil, fmt.Errorf("module source %q: version pinning is only supported for git sources, not local paths", source) + } path := source if strings.HasPrefix(source, ".") { path = filepath.Join(parentDir, source) @@ -55,8 +88,8 @@ func ResolveSource(source, parentDir string, logger *slog.Logger) (dir string, c // Split repo URL from optional subdir. repoURL, subDir := splitSourceURL(source) - // Git URL — clone to temp directory. - cloneDir, err := cloneToTemp(repoURL, logger) + // Git URL — clone to temp directory, pinned to ref when given. + cloneDir, err := cloneToTemp(repoURL, ref, logger) if err != nil { return "", nil, err } @@ -78,18 +111,29 @@ func ResolveSource(source, parentDir string, logger *slog.Logger) (dir string, c return resolved, func() { os.RemoveAll(cloneDir) }, nil } -// cloneToTemp clones a git URL to a temporary directory. -func cloneToTemp(url string, logger *slog.Logger) (string, error) { +// cloneToTemp clones a git URL to a temporary directory. When ref is non-empty +// the clone is checked out at that branch, tag, or commit, pinning the module +// to a specific version. +func cloneToTemp(url, ref string, logger *slog.Logger) (string, error) { dir, err := os.MkdirTemp("", "loom-module-*") if err != nil { return "", err } + // Clone the default branch in full so any ref (tag/commit/other branch) is + // present for the checkout below. _, err = git.Clone(context.Background(), url, dir, "", logger) if err != nil { os.RemoveAll(dir) return "", fmt.Errorf("cloning module %q: %w", url, err) } + if ref != "" { + if err := git.Checkout(context.Background(), dir, ref, logger); err != nil { + os.RemoveAll(dir) + return "", fmt.Errorf("pinning module %q to version %q: %w", url, ref, err) + } + } + return dir, nil } diff --git a/pkg/module/resolver_test.go b/pkg/module/resolver_test.go index c4a52d5..d183d44 100644 --- a/pkg/module/resolver_test.go +++ b/pkg/module/resolver_test.go @@ -42,12 +42,56 @@ func TestSplitSourceURL_SchemeNotMatched(t *testing.T) { } } +func TestSplitSourceRef_M5_NoRef(t *testing.T) { + base, ref := splitSourceRef("https://github.com/org/repo.git//mod") + if base != "https://github.com/org/repo.git//mod" || ref != "" { + t.Errorf("got base=%q ref=%q", base, ref) + } +} + +func TestSplitSourceRef_M5_TagAfterSubdir(t *testing.T) { + // The ref query is the last component, following any //subdir. + base, ref := splitSourceRef("https://github.com/org/repo.git//mod?ref=v1.2.0") + if base != "https://github.com/org/repo.git//mod" || ref != "v1.2.0" { + t.Errorf("got base=%q ref=%q", base, ref) + } +} + +func TestSplitSourceRef_M5_CommitSSH(t *testing.T) { + base, ref := splitSourceRef("git@github.com:org/repo.git?ref=abc1234") + if base != "git@github.com:org/repo.git" || ref != "abc1234" { + t.Errorf("got base=%q ref=%q", base, ref) + } +} + +func TestResolveSource_M5_LocalRefSuffixRejected(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "child"), 0o755) + + // ?ref= suffix on a local source. + _, _, err := ResolveSource("./child?ref=v1", "", dir, testLogger()) + if err == nil { + t.Fatal("expected error: version pinning is not supported for local sources") + } +} + +func TestResolveSource_M5_LocalVersionFieldRejected(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "child"), 0o755) + + // Explicit version argument (spec.modules[].version) on a local source. + _, _, err := ResolveSource("./child", "v1", dir, testLogger()) + if err == nil { + t.Fatal("expected error: version pinning is not supported for local sources") + } +} + func TestResolveSource_LocalRelative(t *testing.T) { dir := t.TempDir() sub := filepath.Join(dir, "child") os.MkdirAll(sub, 0o755) - resolved, cleanup, err := ResolveSource("./child", dir, testLogger()) + resolved, cleanup, err := ResolveSource("./child", "", dir, testLogger()) if err != nil { t.Fatal(err) } @@ -62,7 +106,7 @@ func TestResolveSource_LocalRelative(t *testing.T) { func TestResolveSource_LocalAbsolute(t *testing.T) { dir := t.TempDir() - resolved, cleanup, err := ResolveSource(dir, "/ignored", testLogger()) + resolved, cleanup, err := ResolveSource(dir, "", "/ignored", testLogger()) if err != nil { t.Fatal(err) } @@ -79,7 +123,7 @@ func TestResolveSource_LocalNotDir(t *testing.T) { f := filepath.Join(dir, "file.txt") os.WriteFile(f, []byte("hi"), 0o644) - _, _, err := ResolveSource(f, "/", testLogger()) + _, _, err := ResolveSource(f, "", "/", testLogger()) if err == nil { t.Fatal("expected error for non-directory source") } diff --git a/specs/module.md b/specs/module.md index cabf7ae..6d11a7f 100644 --- a/specs/module.md +++ b/specs/module.md @@ -230,7 +230,7 @@ Exhaustive list of templatable fields: - `spec.excludes[]`, `spec.includes[]` - `spec.dynamicParams[].command`, `spec.dynamicParams[].default` - `spec.target.url`, `spec.target.branch`, `spec.target.featureBranch` -- `spec.modules[].name`, `spec.modules[].source`, `spec.modules[].params` values +- `spec.modules[].name`, `spec.modules[].source`, `spec.modules[].version`, `spec.modules[].params` values - `spec.modules[].if`, `operations[].if` - `newFiles.source`, `newFiles.dest` - `patch.engine`, `patch.path`, `patch.target` @@ -333,6 +333,7 @@ In `--local-run` mode, the clone target is `/NN-/`. It |-------|-------------| | `modules[].name` | Required. Unique identifier for the child. | | `modules[].source` | Required. Local path or git URL to the child module directory. | +| `modules[].version` | Optional. Pins a git source to a branch, tag, or commit. Templatable. Ignored for local sources (an error to combine). See [M5](#m5-version-pinning). | | `modules[].params` | Optional. `map[string]string` of params to pass to the child. Values are templatable with the parent's params. | | `modules[].if` | Optional. Shell predicate gating the child. Templatable. See [Conditional Execution](#conditional-execution-if). | @@ -346,8 +347,9 @@ In `--local-run` mode, the clone target is `/NN-/`. It | Starts with `/` | Absolute path | | Git URL without `//` | Cloned to temp directory, `loom.yaml` expected at repo root | | Git URL with `//path` | Cloned to temp directory, `loom.yaml` expected at `path` within the clone | +| Git URL with `?ref=` | Cloned, then pinned to the branch, tag, or commit `` (see [M5](#m5-version-pinning); child modules prefer the `version` field) | -The `source` field is templatable (see T4). Templates are rendered **before** source resolution, so `//` parsing operates on the rendered string. +The `source` field is templatable (see T4). Templates are rendered **before** source resolution, so `//` and `?ref=` parsing operate on the rendered string. The `//` separator (Terraform convention) splits a git URL into the repository URL and a subdirectory path within the cloned repo. This allows a single git repository to host multiple loom modules in different directories. @@ -424,6 +426,61 @@ modules: region: us-east-1 # literal value ``` +#### M5: Version pinning + +A git module can be pinned to a specific **branch, tag, or commit SHA** so a run +uses an exact module version instead of tracking the source's default branch. +There are two spellings, one for each place a module is named: + +- **Child modules** declare `spec.modules[].version` — a dedicated, visible + field alongside `name`, `source`, and `params`. This is the preferred form. +- **The CLI / root source** — passed as the whole `loom run ` argument — + carries a trailing `?ref=` suffix, since it has no surrounding + `modules[]` entry to hold a field. + +```yaml +modules: + - name: networking + source: https://github.com/org/modules.git//networking + version: v1.4.0 # branch, tag, or commit +``` + +```bash +loom run 'https://github.com/org/modules.git//networking?ref=v1.4.0' -p serviceName=payments +``` + +Both `version` and the `?ref=` suffix are templatable (T4) and render with the +same params as the rest of the child reference (M4), so a version can be +parameterized: + +```yaml +params: + - name: modVersion + default: "v1.4.0" +modules: + - name: networking + source: "https://github.com/org/modules.git//networking" + version: "{{ .modVersion }}" +``` + +**Resolution.** A `?ref=` suffix, when present, is split off the source before +`//subdir` parsing (Terraform convention: the ref is the last component). The +`version` field takes precedence over a `?ref=` suffix when both are given. The +repository is cloned in full — so any branch, tag, or commit is on disk — then +checked out at the selected version. A tag or commit yields a detached HEAD, +which is correct for a pinned, read-only module. + +Version pinning applies to git sources only. Combining it with a **local** +source (one starting with `.` or `/`) is an error — a local path is already at a +fixed version on disk. + +### Error Conditions + +| Condition | Error | +|-----------|-------| +| `version` or `?ref=` on a local source | `module source "": version pinning is only supported for git sources, not local paths` | +| Selected version does not exist in the repo | `pinning module "" to version "": ...` | + --- ## Operations (`spec.operations`) @@ -999,7 +1056,9 @@ paths are skipped. loom run [flags] 1. Parse CLI flags and parameters. - 1a. If moduleSource contains "//", split into git URL and subdirectory. + 1a. Split off a trailing "?ref=" selector (M5); if the source + contains "//", split the remainder into git URL and subdirectory. Clone + the repo and, when a ref was given, check it out. 2. Load loom.yaml from moduleDir (or cloneDir/subdir if git source with //). 3. Validate config structure. 4. Resolve static params (CLI > params-file > default > required error). @@ -1011,7 +1070,8 @@ loom run [flags] c. No target spec, --target-path given → use target-path directly. d. No target spec, no --target-path → use moduleDir. 7. For each child module (in declaration order): - a. Resolve child source (local path, git clone, or git clone with // subdir). + a. Resolve child source (local path, git clone, or git clone with // subdir); + when `modules[].version` is set, check the clone out at that ref (M5). b. Render child params through parent template context. c. Load child module (validate, resolve params). d. Resolve child target (same logic as step 6). From fc266160306a3a4bfbb544cc6dd5ca2f02eb0e8f Mon Sep 17 00:00:00 2001 From: Rick Liu Date: Sat, 25 Jul 2026 23:47:04 +0100 Subject: [PATCH 2/2] feat(cli): add --ref flag to pin the root module version The version field covers child modules, but the root module is named on the command line and had no field. Add a short --ref flag to `loom run` and `loom diff` that pins the root source to a git branch, tag, or commit. It threads through the same ResolveSource path and takes precedence over a ?ref= suffix on the source. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 ++-- cmd/diff.go | 4 ++- cmd/run.go | 5 ++- cmd/run_test.go | 61 ++++++++++++++++++++++++++++++++ docs/guide/module-composition.md | 10 +++--- docs/reference/cli-diff.md | 1 + docs/reference/cli-run.md | 10 ++++-- specs/module.md | 8 +++-- 8 files changed, 93 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1366b35..b74e110 100644 --- a/README.md +++ b/README.md @@ -717,11 +717,11 @@ modules: version: v1.4.0 # branch, tag, or commit ``` -The root module is named on the command line, so version it with a `?ref=` suffix on the source instead (the same suffix also works in a child `source`; the `version` field wins if you set both): +The root module is named on the command line, so version it with the `--ref` flag or a `?ref=` suffix on the source (`--ref` wins if both are given). The `?ref=` suffix also works in a child `source`, though child modules should prefer the `version` field: ```bash -loom run 'https://github.com/myorg/loom-modules.git//onboard-service?ref=v1.4.0' \ - -p serviceName=payments +loom run https://github.com/myorg/loom-modules.git//onboard-service \ + --ref v1.4.0 -p serviceName=payments ``` ## CLI @@ -739,6 +739,7 @@ loom run [path] [flags] | `-p, --param key=value` | Set a parameter (repeatable) | | `--params-file file.yaml` | Load parameters from a YAML file | | `--target-path /path` | Use a local directory as the target (skip git clone) | +| `--ref version` | Pin the module source to a git branch, tag, or commit (overrides a `?ref=` in the source; git sources only) | | `--author name` | Default git author name for `commitPush` (used when not set in `loom.yaml`) | | `--email email` | Default git author email for `commitPush` (used when not set in `loom.yaml`) | | `--dry-run` | Show what would happen without writing anything | diff --git a/cmd/diff.go b/cmd/diff.go index 3e76dcf..4d0e765 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -21,6 +21,7 @@ var ( diffParams []string diffParamsFile string diffTargetPath string + diffRef string diffAuthor string diffEmail string diffQuick bool @@ -48,6 +49,7 @@ func init() { diffCmd.Flags().StringArrayVarP(&diffParams, "param", "p", nil, "Parameter in key=value format (can be repeated)") diffCmd.Flags().StringVar(&diffParamsFile, "params-file", "", "YAML file with parameters") diffCmd.Flags().StringVar(&diffTargetPath, "target-path", "", "Directory for target clones. When set, it is kept for inspection instead of a cleaned-up temp dir") + diffCmd.Flags().StringVar(&diffRef, "ref", "", "Pin the module source to a git branch, tag, or commit (overrides a ?ref= in the source; git sources only)") diffCmd.Flags().StringVar(&diffAuthor, "author", "", "Default git author name for commitPush operations") diffCmd.Flags().StringVar(&diffEmail, "email", "", "Default git author email for commitPush operations") diffCmd.Flags().BoolVar(&diffQuick, "quick", false, "Simulate the run (dry-run) and show newFiles/patch diffs only, without executing anything") @@ -335,7 +337,7 @@ func resolveModuleAndTarget(ctx context.Context, source string, paramMap map[str } } - moduleDir, srcCleanup, err := module.ResolveSource(source, "", ".", logger) + moduleDir, srcCleanup, err := module.ResolveSource(source, diffRef, ".", logger) if err != nil { return nil, "", nil, err } diff --git a/cmd/run.go b/cmd/run.go index 188a240..8396547 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -20,6 +20,7 @@ var ( params []string paramsFile string targetPath string + moduleRef string gitAuthor string gitEmail string showSummary bool @@ -37,6 +38,7 @@ func init() { runCmd.Flags().StringArrayVarP(¶ms, "param", "p", nil, "Parameter in key=value format (can be repeated)") runCmd.Flags().StringVar(¶msFile, "params-file", "", "YAML file with parameters") runCmd.Flags().StringVar(&targetPath, "target-path", "", "Directory for target files: with --local-run, target repos are cloned into numbered subdirectories here; modules without a target spec use it directly") + runCmd.Flags().StringVar(&moduleRef, "ref", "", "Pin the module source to a git branch, tag, or commit (overrides a ?ref= in the source; git sources only)") runCmd.Flags().StringVar(&gitAuthor, "author", "", "Default git author name for commitPush operations") runCmd.Flags().StringVar(&gitEmail, "email", "", "Default git author email for commitPush operations") runCmd.Flags().BoolVar(&showSummary, "summary", false, "Print a list of PRs/MRs created during the run at the end") @@ -52,7 +54,8 @@ func runModule(cmd *cobra.Command, args []string) error { } // Resolve source — handles git URLs, //subdir, ?ref= version, and local paths. - moduleDir, cleanup, err := module.ResolveSource(source, "", ".", logger) + // The --ref flag pins the root module and takes precedence over any ?ref=. + moduleDir, cleanup, err := module.ResolveSource(source, moduleRef, ".", logger) if err != nil { return err } diff --git a/cmd/run_test.go b/cmd/run_test.go index 5d101cf..1ea83cd 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -47,6 +47,7 @@ func resetFlags() { dryRun = false localRun = false targetPath = "" + moduleRef = "" params = nil paramsFile = "" verbose = false @@ -56,6 +57,7 @@ func resetFlags() { diffQuick = false diffPartial = false diffTargetPath = "" + diffRef = "" diffParams = nil diffParamsFile = "" diffAuthor = "" @@ -243,6 +245,65 @@ spec: } } +// M5: --ref pins the root git source to a tag; the older tagged content wins +// over the newer default branch. +func TestRun_RefFlag_PinsVersion(t *testing.T) { + resetFlags() + + // Work repo: tag v1 has greeting "from v1"; default branch adds "from main". + work := t.TempDir() + initGitRepo(t, work) + tpl := filepath.Join(work, "templates") + os.MkdirAll(tpl, 0o755) + writeLoomYAML(t, work, ` +apiVersion: loom.rickliujh.github.io/v1beta1 +kind: Loom +metadata: + name: greeter +spec: + operations: + - name: write + newFiles: + source: "templates" + dest: "" +`) + os.WriteFile(filepath.Join(tpl, "greeting.txt"), []byte("from v1"), 0o644) + gitRun(t, work, "add", ".") + gitRun(t, work, "commit", "-m", "v1") + gitRun(t, work, "tag", "v1") + os.WriteFile(filepath.Join(tpl, "greeting.txt"), []byte("from main"), 0o644) + gitRun(t, work, "add", ".") + gitRun(t, work, "commit", "-m", "main") + + bare := t.TempDir() + if out, err := exec.Command("git", "clone", "--bare", work, bare).CombinedOutput(); err != nil { + t.Fatalf("bare clone failed: %v\n%s", err, out) + } + + targetDir := t.TempDir() + rootCmd.SetArgs([]string{"run", "file://" + bare, "--ref", "v1", "--target-path", targetDir}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(targetDir, "greeting.txt")) + if err != nil { + t.Fatal("expected greeting.txt in target dir") + } + if string(content) != "from v1" { + t.Errorf("expected --ref v1 content 'from v1', got %q", string(content)) + } +} + +// gitRun runs a git command in dir, failing the test on error. +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + full := append([]string{"-C", dir}, args...) + if out, err := exec.Command("git", full...).CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } +} + // Run with git URL + //subdir resolves to subdirectory. func TestRun_GitSourceWithSubdir(t *testing.T) { resetFlags() diff --git a/docs/guide/module-composition.md b/docs/guide/module-composition.md index a1dffff..8fed857 100644 --- a/docs/guide/module-composition.md +++ b/docs/guide/module-composition.md @@ -117,15 +117,17 @@ modules: ``` The **root module** is passed on the command line, so it has no `version` field -to set. Version it with a `?ref=` suffix on the source instead (the -Terraform convention — the `?ref=` comes last, after any `//subdir`): +to set. Version it with the `--ref` flag, or with a `?ref=` suffix on +the source (the Terraform convention — `?ref=` comes last, after any +`//subdir`). `--ref` takes precedence over a `?ref=` suffix: ```bash +loom run https://github.com/myorg/loom-modules.git//onboard --ref v2.0.0 -p serviceName=payments loom run 'https://github.com/myorg/loom-modules.git//onboard?ref=v2.0.0' -p serviceName=payments ``` -The same `?ref=` suffix also works in a child `source`; the `version` field -takes precedence if you set both. Version pinning applies to git sources only — +The `?ref=` suffix also works in a child `source`; the `version` field takes +precedence if you set both. Version pinning applies to git sources only — combining it with a local path is an error, since a local path is already fixed on disk. diff --git a/docs/reference/cli-diff.md b/docs/reference/cli-diff.md index 1b428f8..33cf959 100644 --- a/docs/reference/cli-diff.md +++ b/docs/reference/cli-diff.md @@ -20,6 +20,7 @@ loom diff [path] [flags] | `-p, --param key=value` | Set a parameter (repeatable) | | `--params-file file.yaml` | Load parameters from a YAML file | | `--target-path /path` | Keep target clones here for inspection instead of a cleaned-up temp dir | +| `--ref version` | Pin the module source to a git branch, tag, or commit. Overrides a `?ref=` in the source; git sources only | | `--author name` | Default git author name for `commitPush` | | `--email email` | Default git author email for `commitPush` | | `-v, --verbose` | Enable debug logging | diff --git a/docs/reference/cli-run.md b/docs/reference/cli-run.md index 06f5431..61f678d 100644 --- a/docs/reference/cli-run.md +++ b/docs/reference/cli-run.md @@ -13,6 +13,7 @@ loom run [path] [flags] | `-p, --param key=value` | Set a parameter (repeatable) | | `--params-file file.yaml` | Load parameters from a YAML file | | `--target-path /path` | Directory for target files. With `--local-run`, each target repo is cloned into a numbered subdirectory (`00-/`, `01-/`, …). Modules without a `target` spec use it directly as the target directory. Ignored otherwise. | +| `--ref version` | Pin the module source to a git branch, tag, or commit. Overrides a `?ref=` in the source; git sources only | | `--author name` | Default git author name for `commitPush` (used when not set in `loom.yaml`) | | `--email email` | Default git author email for `commitPush` (used when not set in `loom.yaml`) | | `--summary` | Print a list of PRs/MRs created during the run at the end | @@ -50,10 +51,15 @@ loom run https://github.com/myorg/loom-modules.git//onboard-service \ ### Full run — pinned module version -Append `?ref=` to pin the module to a branch, tag, or commit so the -run is reproducible instead of tracking the default branch: +Pin the module to a branch, tag, or commit so the run is reproducible instead of +tracking the default branch. Use `--ref`, or append `?ref=` to the +source (`--ref` wins if both are given): ```bash +loom run https://github.com/myorg/loom-modules.git//onboard-service \ + --ref v1.4.0 -p serviceName=payments + +# equivalently, in the source: loom run 'https://github.com/myorg/loom-modules.git//onboard-service?ref=v1.4.0' \ -p serviceName=payments ``` diff --git a/specs/module.md b/specs/module.md index 6d11a7f..957cdc8 100644 --- a/specs/module.md +++ b/specs/module.md @@ -435,8 +435,9 @@ There are two spellings, one for each place a module is named: - **Child modules** declare `spec.modules[].version` — a dedicated, visible field alongside `name`, `source`, and `params`. This is the preferred form. - **The CLI / root source** — passed as the whole `loom run ` argument — - carries a trailing `?ref=` suffix, since it has no surrounding - `modules[]` entry to hold a field. + has no surrounding `modules[]` entry to hold a field, so it is pinned either + with a trailing `?ref=` suffix on the source or with the `--ref` flag. + `--ref` takes precedence over a `?ref=` suffix. ```yaml modules: @@ -447,6 +448,7 @@ modules: ```bash loom run 'https://github.com/org/modules.git//networking?ref=v1.4.0' -p serviceName=payments +loom run https://github.com/org/modules.git//networking --ref v1.4.0 -p serviceName=payments ``` Both `version` and the `?ref=` suffix are templatable (T4) and render with the @@ -852,6 +854,7 @@ is logged. | `--param`, `-p` | string[] | nil | Parameter in `key=value` format (repeatable) | | `--params-file` | string | `""` | YAML file with parameters | | `--target-path` | string | `""` | Base directory for local mode output | +| `--ref` | string | `""` | Pin the root module source to a git branch, tag, or commit. Overrides a `?ref=` in the source; git sources only (M5) | | `--author` | string | `""` | Default git author name for `commitPush` operations | | `--email` | string | `""` | Default git author email for `commitPush` operations | | `--summary` | bool | `false` | Print a list of PRs/MRs created during the run at the end (see PR4) | @@ -951,6 +954,7 @@ fidelity levels. | `--param`, `-p` | string[] | nil | Parameter in `key=value` format (repeatable) | | `--params-file` | string | `""` | YAML file with parameters | | `--target-path` | string | `""` | Directory for target clones; when set it is kept for inspection instead of a cleaned-up temp dir | +| `--ref` | string | `""` | Pin the root module source to a git branch, tag, or commit. Overrides a `?ref=` in the source; git sources only (M5) | | `--author` | string | `""` | Default git author name for `commitPush` operations | | `--email` | string | `""` | Default git author email for `commitPush` operations |