git-ents.gitmain
⌘K
foforge
commit c540209
feat: record and expose recipe provenance for toolchain imports

Each import’s commit message and the Toolchain document itself now name the recipe (and selector) that produced it, so `refs/meta/toolchains/*’s own commit log doubles as an audit trail instead of needing a separate one.

feat: add recipe field to Toolchain, threaded through import/import_downloaded feat: add git_toolchain::history, wrapping Store::history over a toolchain’s ref feat: add git ents toolchain recipes to list --from-able recipes feat: add git ents toolchain log <name> to show a toolchain’s import history refactor: list recipes via registry::RECIPES instead of a hardcoded match arm Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents/src/main.rs @@ -271,6 +271,16 @@ }, /// List the toolchains configured on a remote. List, + /// List the recipes `--from` accepts. + Recipes, + /// Show a remote's toolchain `name`'s past imports, newest first: when, + /// what recipe (if any) produced it, and its version — the ref's own + /// commit log, not a separate audit trail. + Log { + /// Name (`toolchains/<name>`) to show import history for. + #[facet(args::positional)] + name: String, + }, /// Export a remote's toolchain `name` to a local directory. Read-only: /// fetches the toolchain's tree but never pushes. Export { @@ -491,6 +501,8 @@ name, bin, src, license, version, platform, from, spec, embed, remote, ), ToolchainAction::List => toolchain_list(remote), + ToolchainAction::Recipes => toolchain_recipes(), + ToolchainAction::Log { name } => toolchain_log(&name, remote), ToolchainAction::Export { name, dest } => toolchain_export(&name, &dest, remote), ToolchainAction::Remove { name } => toolchain_remove(&name, remote), } @@ -516,6 +528,9 @@ ) -> Result<(), String> { let name = interactive::text_or(name, "Toolchain name")?; + let recipe_desc = from + .as_deref() + .map(|from| registry::describe(from, spec.as_deref().unwrap_or("stable"))); let recipe = from .map(|recipe| registry::resolve(&recipe, spec.as_deref().unwrap_or("stable"), embed)) .transpose()?; @@ -565,6 +580,7 @@ &license, &version, &platform, + recipe_desc.as_deref(), ), registry::Bin::Components(components) => git_toolchain::import_downloaded( &repo, @@ -574,6 +590,7 @@ &license, &version, &platform, + recipe_desc.as_deref(), ), } .map_err(|error| error.to_string())?; @@ -599,14 +616,40 @@ format!("{} components", components.len()) } }; + let recipe = toolchain.recipe.as_deref().unwrap_or("hand-supplied"); println!( - "{name} {bin} {} {} {}", + "{name} {bin} {} {} {} {recipe}", toolchain.version, toolchain.platform, toolchain.license ); } Ok(()) } +/// Print every recipe `git ents toolchain import --from` accepts. +fn toolchain_recipes() -> Result<(), String> { + for recipe in registry::RECIPES { + println!("{recipe}"); + } + Ok(()) +} + +/// Print `name`'s import history on `remote`, newest first: when, its +/// version, and the recipe (if any) that produced it — read from +/// `refs/meta/toolchains/<name>`'s own commit log via +/// `git_toolchain::history`, not a separate audit trail. +fn toolchain_log(name: &str, remote: &str) -> Result<(), String> { + let refname = format!("{TOOLCHAINS_NS}/{name}"); + sync(remote, &refname)?.ok_or_else(|| format!("no toolchain {name} on {remote}"))?; + let repo = repo()?; + let history = git_toolchain::history(&repo, name).map_err(|error| error.to_string())?; + for (seconds, toolchain) in history { + let when = ago(seconds); + let recipe = toolchain.recipe.as_deref().unwrap_or("hand-supplied"); + println!("{when} {} {recipe}", toolchain.version); + } + Ok(()) +} + /// Export `remote`'s toolchain `name` to `dest`. Read-only per /// `cli.remote-admin`: fetches the toolchain's tree but never pushes. fn toolchain_export(name: &str, dest: &str, remote: &str) -> Result<(), String> {
crates/git-ents/src/registry.rs @@ -40,8 +40,14 @@ _staging: Option<TempDir>, } +/// Every recipe `resolve` knows, for `git ents toolchain recipes` and +/// `resolve`'s own error message — a plain list rather than a trait registry, +/// since each recipe is one function with its own selector semantics, not a +/// uniform interface worth abstracting over for a list of one. +pub const RECIPES: &[&str] = &["rustup"]; + /// Resolve `recipe` against `spec` (a recipe-specific selector, e.g. a -/// rustup toolchain name). The only recipe today is `rustup`. +/// rustup toolchain name). See [`RECIPES`] for what's known. /// /// `embed` forces the old behavior of staging and importing `bin`'s actual /// bytes; by default the recipe instead points at its distributor's own @@ -51,11 +57,20 @@ match recipe { "rustup" => rustup(spec, embed), other => Err(format!( - "unknown toolchain recipe {other:?} (known: rustup)" + "unknown toolchain recipe {other:?} (known: {})", + RECIPES.join(", ") )), } } +/// `<recipe> <spec>`, recorded as [`git_toolchain::Toolchain::recipe`] — the +/// provenance a `--from` import leaves behind, distinct from `Resolved` +/// itself since only the recipe name and selector (not the resolved bytes) +/// are worth keeping once the import is written. +pub fn describe(recipe: &str, spec: &str) -> String { + format!("{recipe} {spec}") +} + /// Resolve a rustup-managed toolchain named `spec` (e.g. `stable`, /// `1.75.0`, `nightly`) via `rustc +<spec> -vV`, which reports the /// toolchain's own `release` (its version) and `host` (its target platform)
crates/git-toolchain/src/lib.rs @@ -79,6 +79,12 @@ /// standard platform identifier; there is no SPDX-equivalent registry /// for platforms. pub platform: String, + /// The recipe (and its selector) this import was derived from, e.g. + /// `"rustup stable"` — `None` when `bin`/`src`/metadata were supplied by + /// hand instead. Recorded here so the toolchain's current state names its + /// own origin; [`history`] additionally surfaces every past import's + /// recipe from the ref's commit log, not just the tip's. + pub recipe: Option<String>, } /// How a toolchain's `bin` is provisioned. @@ -172,7 +178,10 @@ /// to a commit over it. Returns the document's root tree object id. /// /// `license` MUST be a valid SPDX license expression, `version` a valid -/// semver version, and `platform` a valid target triple. +/// semver version, and `platform` a valid target triple. `recipe`, if given, +/// is recorded on the [`Toolchain`] document and in the import's commit +/// message as this import's provenance (see [`Toolchain::recipe`]). +#[expect(clippy::too_many_arguments, reason = "one flag per import field")] pub fn import( repo: &Path, name: &str, @@ -181,6 +190,7 @@ license: &str, version: &str, platform: &str, + recipe: Option<&str>, ) -> Result<ObjectId, Error> { if !git_store::ref_segment_ok(name) { return Err(Error::InvalidName(name.to_owned())); @@ -203,6 +213,7 @@ license: license.to_owned(), version: version.to_owned(), platform: platform.to_owned(), + recipe: recipe.map(str::to_owned), }; store_toolchain(repo, name, toolchain, &odb) } @@ -213,6 +224,7 @@ /// document. `src_dir`, if given, is still captured as a `RawTree` the usual /// way — provenance-only content with no natural external origin to point at /// instead. +#[expect(clippy::too_many_arguments, reason = "one flag per import field")] pub fn import_downloaded( repo: &Path, name: &str, @@ -221,6 +233,7 @@ license: &str, version: &str, platform: &str, + recipe: Option<&str>, ) -> Result<ObjectId, Error> { if !git_store::ref_segment_ok(name) { return Err(Error::InvalidName(name.to_owned())); @@ -240,6 +253,7 @@ license: license.to_owned(), version: version.to_owned(), platform: platform.to_owned(), + recipe: recipe.map(str::to_owned), }; store_toolchain(repo, name, toolchain, &odb) } @@ -280,11 +294,11 @@ ) -> Result<ObjectId, Error> { let oid = facet_git_tree::serialize_into(&toolchain, odb)?; let store = Store::open(repo)?; - store.store_tree( - &toolchain_ref(name), - oid, - &format!("git-toolchain: import {name}"), - )?; + let message = match &toolchain.recipe { + Some(recipe) => format!("git-toolchain: import {name} via {recipe}"), + None => format!("git-toolchain: import {name}"), + }; + store.store_tree(&toolchain_ref(name), oid, &message)?; Ok(oid) } @@ -314,6 +328,17 @@ Ok(out) } +/// Toolchain `name`'s past imports, newest first, as `(committer unix +/// seconds, document)` pairs — one entry per commit on +/// `refs/meta/toolchains/<name>`, each document's own [`Toolchain::recipe`] +/// naming what produced it. The commit *is* the audit trail: no separate +/// provenance log is kept, since every [`import`]/[`import_downloaded`] call +/// already lands as a new commit on this ref. +pub fn history(repo: &Path, name: &str) -> Result<Vec<(u64, Toolchain)>, Error> { + let store = Store::open(repo)?; + Ok(store.history(&toolchain_ref(name))?) +} + /// Recreate the toolchain `name`'s `bin` (and `src`, if present) directory /// under `dest`, restoring the executable bit and symlinks. Refuses to write /// into a `dest` that already has contents. Returns the resolved document, @@ -776,6 +801,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); let second = import( @@ -786,6 +812,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); assert_eq!(first, second); @@ -805,6 +832,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); let toolchain = resolve(repo_dir.path(), "gcc").unwrap(); @@ -831,6 +859,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); let toolchain = resolve(repo_dir.path(), "gcc").unwrap(); @@ -857,6 +886,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); @@ -891,6 +921,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); @@ -948,6 +979,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); @@ -972,6 +1004,7 @@ "MIT", VERSION, PLATFORM, + None, ); assert!(matches!(result, Err(Error::NoComponents))); } @@ -991,6 +1024,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); @@ -1013,6 +1047,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); @@ -1035,6 +1070,7 @@ "not a license", VERSION, PLATFORM, + None, ); assert!(matches!(result, Err(Error::InvalidLicense(_, _)))); } @@ -1052,6 +1088,7 @@ "MIT", "not-semver", PLATFORM, + None, ); assert!(matches!(result, Err(Error::InvalidVersion(_, _)))); } @@ -1069,6 +1106,7 @@ "MIT", VERSION, "not a platform!!", + None, ); assert!(matches!(result, Err(Error::InvalidPlatform(_)))); } @@ -1088,6 +1126,7 @@ "MIT", VERSION, PLATFORM, + None, ); assert!(matches!(result, Err(Error::EmptyBin(_)))); } @@ -1108,6 +1147,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); import( @@ -1118,6 +1158,7 @@ "Apache-2.0", VERSION, PLATFORM, + None, ) .unwrap(); @@ -1142,6 +1183,7 @@ "MIT", VERSION, PLATFORM, + None, ) .unwrap(); @@ -1162,6 +1204,7 @@ "MIT", VERSION, PLATFORM, + None, ); assert!(matches!(result, Err(Error::InvalidName(_)))); }