feat: record and expose recipe provenance for toolchain imports
commit
c540209feat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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(_))));
}