aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli')
-rw-r--r--mingling_cli/Cargo.lock72
-rw-r--r--mingling_cli/Cargo.toml3
-rw-r--r--mingling_cli/src/proj_mgr.rs1
-rw-r--r--mingling_cli/src/proj_mgr/cmd_proj_init.rs50
-rw-r--r--mingling_cli/src/proj_mgr/template_source.rs365
5 files changed, 479 insertions, 12 deletions
diff --git a/mingling_cli/Cargo.lock b/mingling_cli/Cargo.lock
index 5cde631..d928706 100644
--- a/mingling_cli/Cargo.lock
+++ b/mingling_cli/Cargo.lock
@@ -43,6 +43,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -98,6 +107,25 @@ dependencies = [
]
[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -164,6 +192,16 @@ dependencies = [
]
[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -207,6 +245,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -344,6 +392,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
+ "sha2",
"syn 3.0.2",
"tokio",
"toml_edit 0.25.13+spec-1.1.0",
@@ -531,6 +580,17 @@ dependencies = [
]
[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -711,6 +771,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -723,6 +789,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/mingling_cli/Cargo.toml b/mingling_cli/Cargo.toml
index 0fb03a3..6e31851 100644
--- a/mingling_cli/Cargo.toml
+++ b/mingling_cli/Cargo.toml
@@ -64,6 +64,9 @@ semver = "1.0.28"
# Code gen
just_template = "0.2.1"
+# Template sources
+sha2 = "0.10"
+
[build-dependencies]
# Configure & Serialization
serde = { version = "1.0.229", features = ["derive"] }
diff --git a/mingling_cli/src/proj_mgr.rs b/mingling_cli/src/proj_mgr.rs
index 509f37d..66130f3 100644
--- a/mingling_cli/src/proj_mgr.rs
+++ b/mingling_cli/src/proj_mgr.rs
@@ -1,2 +1,3 @@
pub mod cmd_proj_init;
pub mod rule_solver;
+pub mod template_source;
diff --git a/mingling_cli/src/proj_mgr/cmd_proj_init.rs b/mingling_cli/src/proj_mgr/cmd_proj_init.rs
index 756965e..82f3bf1 100644
--- a/mingling_cli/src/proj_mgr/cmd_proj_init.rs
+++ b/mingling_cli/src/proj_mgr/cmd_proj_init.rs
@@ -6,18 +6,21 @@ use std::{
use just_template::Template;
use mingling::{
- Grouped, RenderResult, Routable,
+ Grouped, LazyRes, RenderResult, Routable,
macros::{arg, chain, command, metadata, pack, pack_err, r_println, renderer, routeify},
metadata::Description,
- picker::{EntryPicker, value::DirPath},
+ picker::EntryPicker,
res::ResCurrentDir,
};
-use crate::{Entry, Next, eprintln_cargo, hprintln_cargo, println_cargo};
+use crate::{Entry, Next, config::ResMlingConfig, eprintln_cargo, hprintln_cargo, println_cargo};
use super::rule_solver::{eval_rule, parse_checklist, parse_rules, resolve_answers};
+use super::template_source::{
+ DEFAULT_TMPL_SOURCE, TemplateSource, cache_dir, normalize_source, resolve_git,
+};
-/// The name of the checklist file the user edits and we re-read on generate.
+/// The checklist filename that the user edits and is re-read during generation.
const CHECKLIST_FILENAME: &str = "checklist.toml";
/// The name of the rules file declaring default params, display blocks and hides.
const RULE_FILENAME: &str = "rule.toml";
@@ -46,6 +49,7 @@ pub struct ResultProjectGenerate {
pack_err!(ErrorTemplateNotProvided = ());
pack_err!(ErrorTemplateCopyFailed = String);
+pack_err!(ErrorTemplateFetchFailed = String);
pack_err!(ErrorChecklistMissing = String);
pack_err!(ErrorRuleParseFailed = String);
pack_err!(ErrorTemplateExpandFailed = String);
@@ -60,18 +64,33 @@ pub fn proj_init(args: Entry, cwd: &ResCurrentDir) -> Next {
}
}
-/// Phase 1: copy the user-provided template directory into
+/// Phase 1: resolve the user-provided template source into
/// `./.mling/tmpl-cache/` and hand the checklist over for editing.
#[chain(routeify)]
pub fn handle_state_proj_checklist_ready(
args: StateProjectChecklistReady,
cwd: &ResCurrentDir,
+ config: &mut LazyRes<ResMlingConfig>,
) -> Next {
- let tmpl_dir: Option<DirPath> = args.pick(&arg![Option<DirPath>]).to_result()?;
-
- // Validate
- let Some(tmpl_dir) = tmpl_dir else {
- return ErrorTemplateNotProvided::new(()).to_chain();
+ let source: TemplateSource = args
+ .pick_or_route(&arg![TemplateSource], || {
+ ErrorTemplateNotProvided::new(()).to_chain()
+ })
+ .to_result()?;
+
+ // Resolve the template root directory.
+ let template_root: PathBuf = match source {
+ TemplateSource::FsDir(dir) => dir,
+ TemplateSource::Git { reference, variant } => {
+ let configured = config.get_ref().get("tmpl-source");
+ let source_url = normalize_source(if configured.is_empty() {
+ DEFAULT_TMPL_SOURCE
+ } else {
+ configured
+ });
+ resolve_git(&source_url, &reference, &variant, &cache_dir())
+ .map_err(ErrorTemplateFetchFailed::new)?
+ }
};
// Copy the template directory into the .mling directory under the current
@@ -80,7 +99,7 @@ pub fn handle_state_proj_checklist_ready(
fs::create_dir_all(&tmpl_cache).map_err(|e| {
ErrorTemplateCopyFailed::new(format!("failed to create {}: {e}", tmpl_cache.display()))
})?;
- copy_dir_contents(&tmpl_dir, &tmpl_cache)
+ copy_dir_contents(&template_root, &tmpl_cache)
.map_err(|e| ErrorTemplateCopyFailed::new(e.to_string()))?;
// Move the internal checklist.toml to ./ for the user to fill in
@@ -88,7 +107,7 @@ pub fn handle_state_proj_checklist_ready(
if !checklist_src.is_file() {
return ErrorChecklistMissing::new(format!(
"no checklist.toml found inside {}",
- tmpl_dir.display()
+ template_root.display()
))
.to_chain();
}
@@ -323,6 +342,13 @@ pub fn render_error_template_copy_failed(err: ErrorTemplateCopyFailed) -> Render
}
#[renderer]
+pub fn render_error_template_fetch_failed(err: ErrorTemplateFetchFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "failed to fetch template: {}", err.info);
+ r
+}
+
+#[renderer]
pub fn render_error_checklist_missing(err: ErrorChecklistMissing) -> RenderResult {
let mut r = RenderResult::new();
eprintln_cargo!(r, "{}", err.info);
diff --git a/mingling_cli/src/proj_mgr/template_source.rs b/mingling_cli/src/proj_mgr/template_source.rs
new file mode 100644
index 0000000..6364512
--- /dev/null
+++ b/mingling_cli/src/proj_mgr/template_source.rs
@@ -0,0 +1,365 @@
+//! Template source resolution for `proj-init`.
+//!
+//! A template source is either:
+//! - a git remote template addressed as `<ref>@<variant>` (e.g. `0.4@basic`),
+//! where `ref` is a tag, branch or commit hash and `variant` is a
+//! subdirectory of the repository;
+//! - a local template directory given by a plain path.
+
+use std::{
+ fs,
+ path::{Path, PathBuf},
+ process::Command,
+};
+
+use mingling::picker::{PickerArgResult, SinglePickable};
+
+/// Default template source when none is configured: the `mingling-rs/tmpl`
+/// repository on GitHub.
+pub const DEFAULT_TMPL_SOURCE: &str = "mingling-rs/tmpl";
+
+/// The template cache root: `~/.local/share/mingling/cache`.
+pub fn cache_dir() -> PathBuf {
+ dirs::data_local_dir()
+ .unwrap_or_default()
+ .join("mingling")
+ .join("cache")
+}
+
+/// Normalize a template source spec into a full git URL.
+///
+/// - `mingling-rs/tmpl` -> `https://github.com/mingling-rs/tmpl.git`
+/// - `https://github.com/mingling-rs/tmpl` -> `https://github.com/mingling-rs/tmpl.git`
+/// - `https://example.com/tmpl.git` -> unchanged
+pub fn normalize_source(source: &str) -> String {
+ if source.ends_with(".git") {
+ return source.to_string();
+ }
+ if source.contains("://") {
+ // Ensure GitHub URLs share the same cache key as the short form.
+ if let Some(rest) = source.strip_prefix("https://github.com/") {
+ return format!("https://github.com/{rest}.git");
+ }
+ return source.to_string();
+ }
+ if let Some((owner, repo)) = source.split_once('/') {
+ return format!("https://github.com/{owner}/{repo}.git");
+ }
+ source.to_string()
+}
+
+/// A template source provided by the user.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TemplateSource {
+ /// Git remote template: `<ref>@<variant>`.
+ Git { reference: String, variant: String },
+ /// Local template directory.
+ FsDir(PathBuf),
+}
+
+impl TemplateSource {
+ /// Creates a git source from a `ref@variant` spec.
+ pub fn git(reference: impl Into<String>, variant: impl Into<String>) -> Self {
+ Self::Git {
+ reference: reference.into(),
+ variant: variant.into(),
+ }
+ }
+
+ /// Creates a local-directory source.
+ pub fn fs_dir(path: impl Into<PathBuf>) -> Self {
+ Self::FsDir(path.into())
+ }
+}
+
+impl SinglePickable for TemplateSource {
+ fn pick_single(str: Option<&str>) -> PickerArgResult<Self> {
+ let Some(raw) = str else {
+ return PickerArgResult::NotFound;
+ };
+
+ // `<ref>@<variant>` — both sides non-empty.
+ if let Some((reference, variant)) = raw.split_once('@')
+ && !reference.is_empty()
+ && !variant.is_empty()
+ {
+ return PickerArgResult::Parsed(Self::git(reference, variant));
+ }
+
+ // Plain path — reuse the PathBuf parsing (handles `~` expansion etc.).
+ match <PathBuf as SinglePickable>::pick_single(str) {
+ PickerArgResult::Parsed(path) => PickerArgResult::Parsed(Self::FsDir(path)),
+ PickerArgResult::NotFound => PickerArgResult::NotFound,
+ PickerArgResult::Unparsed => PickerArgResult::Unparsed,
+ }
+ }
+}
+
+/// Resolve a git template source to a local template directory.
+///
+/// The repository is shallow-cloned into
+/// `<cache>/<source-hash>/<ref-hash>/` where `<source-hash>` is the first 16
+/// hex chars of the SHA-256 of the source URL and `<ref-hash>` is the first 16
+/// hex chars of the resolved commit. Already-cached references are reused. The
+/// returned path is `<repo>/<variant>`, validated to contain a `checklist.toml`.
+pub fn resolve_git(
+ source_url: &str,
+ reference: &str,
+ variant: &str,
+ cache: &Path,
+) -> Result<PathBuf, String> {
+ let source_hash = sha256_prefix16(source_url);
+ let full_hash = resolve_commit(source_url, reference)?;
+ let ref_hash = &full_hash[..16];
+ let repo_dir = cache.join(source_hash).join(ref_hash);
+
+ if !repo_dir.join(".git").is_dir() {
+ shallow_clone(source_url, reference, &full_hash, &repo_dir)?;
+ }
+
+ let template_dir = repo_dir.join(variant);
+ if !template_dir.join("checklist.toml").is_file() {
+ return Err(format!(
+ "variant `{variant}` has no checklist.toml in repository {source_url}"
+ ));
+ }
+ Ok(template_dir)
+}
+
+/// First 16 hex chars of the SHA-256 digest of `input`.
+fn sha256_prefix16(input: &str) -> String {
+ use sha2::{Digest, Sha256};
+ let mut hasher = Sha256::new();
+ hasher.update(input.as_bytes());
+ let digest = hasher.finalize();
+ digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
+}
+
+/// Resolve `reference` (tag / branch / commit hash) to its full commit hash.
+///
+/// A full 40-hex reference is used as-is; otherwise `git ls-remote` resolves
+/// it — first as a ref name (tag / branch), then by prefix-matching against
+/// every advertised ref to support abbreviated commit hashes.
+fn resolve_commit(source_url: &str, reference: &str) -> Result<String, String> {
+ if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Ok(reference.to_string());
+ }
+
+ // Ref name resolution (tag / branch).
+ let by_ref = git_ls_remote(source_url, Some(reference))?;
+ if let Some(hash) = by_ref
+ .lines()
+ .next()
+ .and_then(|line| line.split('\t').next())
+ {
+ return Ok(hash.to_string());
+ }
+
+ // Abbreviated commit hash: prefix-match against all advertised refs.
+ let all_refs = git_ls_remote(source_url, None)?;
+ for line in all_refs.lines() {
+ let Some(hash) = line.split('\t').next() else {
+ continue;
+ };
+ if hash.starts_with(reference) {
+ return Ok(hash.to_string());
+ }
+ }
+
+ Err(format!("reference `{reference}` not found in {source_url}"))
+}
+
+/// Run `git ls-remote <url> [pattern]` and return its stdout.
+fn git_ls_remote(source_url: &str, pattern: Option<&str>) -> Result<String, String> {
+ let mut cmd = Command::new("git");
+ cmd.args(["ls-remote", source_url]);
+ if let Some(pattern) = pattern {
+ cmd.arg(pattern);
+ }
+ let output = cmd
+ .output()
+ .map_err(|e| format!("failed to run `git ls-remote`: {e}"))?;
+ if !output.status.success() {
+ return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
+ }
+ Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+}
+
+/// Shallow-clone `reference` from `source_url` into `dst`.
+///
+/// Uses `git init` + `git fetch --depth 1 origin <reference>` so that tags and
+/// branches are handled uniformly. When the reference is not a ref name (a
+/// commit hash), it falls back to fetching the resolved full hash — supported
+/// by GitHub, but not by plain local `file://` protocols.
+fn shallow_clone(
+ source_url: &str,
+ reference: &str,
+ full_hash: &str,
+ dst: &Path,
+) -> Result<(), String> {
+ if dst.exists() {
+ fs::remove_dir_all(dst).map_err(|e| e.to_string())?;
+ }
+ fs::create_dir_all(dst).map_err(|e| e.to_string())?;
+
+ run_git(dst, ["init", "-q"])?;
+ run_git(dst, ["remote", "add", "origin", source_url])?;
+
+ let fetched_by_ref = run_git(dst, ["fetch", "-q", "--depth", "1", "origin", reference]);
+ if fetched_by_ref.is_err() {
+ run_git(dst, ["fetch", "-q", "--depth", "1", "origin", full_hash])?;
+ }
+
+ // `git fetch` only writes FETCH_HEAD; the fresh repo has no branch yet, so
+ // create one explicitly to populate the working tree.
+ run_git(dst, ["checkout", "-q", "-B", "cache", "FETCH_HEAD"])?;
+ Ok(())
+}
+
+/// Run a git command in `cwd`, returning an error message on failure.
+fn run_git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<(), String> {
+ let status = Command::new("git")
+ .args(args)
+ .current_dir(cwd)
+ .status()
+ .map_err(|e| format!("failed to run git: {e}"))?;
+ if !status.success() {
+ return Err(format!("git command failed with {status}"));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Create a local git repository at `dir` with one commit tagged `v0.1`
+ /// and a `basic` variant subdirectory containing a checklist.
+ fn make_repo(dir: &Path) {
+ fs::create_dir_all(dir).unwrap();
+ run_git(dir, ["init", "-q", "-b", "main"]).unwrap();
+ fs::create_dir_all(dir.join("basic")).unwrap();
+ fs::write(
+ dir.join("basic").join("checklist.toml"),
+ "program_name = \"x\"\n",
+ )
+ .unwrap();
+ fs::write(dir.join("basic").join("rule.toml"), "").unwrap();
+ run_git(dir, ["add", "."]).unwrap();
+ run_git(
+ dir,
+ [
+ "-c",
+ "user.name=t",
+ "-c",
+ "user.email=t@t",
+ "commit",
+ "-q",
+ "-m",
+ "init",
+ ],
+ )
+ .unwrap();
+ run_git(dir, ["tag", "v0.1"]).unwrap();
+ }
+
+ #[test]
+ fn parses_ref_at_variant_as_git() {
+ let source = TemplateSource::pick_single(Some("0.4@basic")).unwrap();
+ assert_eq!(source, TemplateSource::git("0.4", "basic"));
+ }
+
+ #[test]
+ fn parses_plain_path_as_fs_dir() {
+ let source = TemplateSource::pick_single(Some("/some/dir")).unwrap();
+ assert_eq!(source, TemplateSource::FsDir(PathBuf::from("/some/dir")));
+ }
+
+ #[test]
+ fn missing_input_is_not_found() {
+ assert!(matches!(
+ TemplateSource::pick_single(None),
+ PickerArgResult::NotFound
+ ));
+ }
+
+ #[test]
+ fn empty_variant_falls_back_to_path() {
+ // `ref@` has an empty variant — treat as a path, not a git source.
+ let source = TemplateSource::pick_single(Some("0.4@")).unwrap();
+ assert_eq!(source, TemplateSource::FsDir(PathBuf::from("0.4@")));
+ }
+
+ #[test]
+ fn sha256_prefix_is_stable_and_16_chars() {
+ let a = sha256_prefix16("https://example.com/repo.git");
+ let b = sha256_prefix16("https://example.com/repo.git");
+ let c = sha256_prefix16("https://example.com/other.git");
+ assert_eq!(a, b);
+ assert_eq!(a.len(), 16);
+ assert_ne!(a, c);
+ }
+
+ #[test]
+ fn normalize_source_default_and_configured() {
+ assert_eq!(
+ normalize_source(DEFAULT_TMPL_SOURCE),
+ "https://github.com/mingling-rs/tmpl.git"
+ );
+ assert_eq!(
+ normalize_source("https://example.com/tmpl.git"),
+ "https://example.com/tmpl.git"
+ );
+ assert_eq!(
+ normalize_source("some-one/other-tmpl"),
+ "https://github.com/some-one/other-tmpl.git"
+ );
+ // GitHub URL without `.git` shares the cache key with the short form.
+ assert_eq!(
+ normalize_source("https://github.com/mingling-rs/tmpl"),
+ "https://github.com/mingling-rs/tmpl.git"
+ );
+ }
+
+ #[test]
+ fn resolve_git_clones_tag_and_validates_variant() {
+ let tmp =
+ std::env::temp_dir().join(format!("mling-tmpl-src-test-{}-clone", std::process::id()));
+ let repo = tmp.join("repo");
+ let cache = tmp.join("cache");
+ let _ = fs::remove_dir_all(&tmp);
+ make_repo(&repo);
+
+ let repo_url = format!("file://{}", repo.display());
+ let template = resolve_git(&repo_url, "v0.1", "basic", &cache).unwrap();
+ assert!(template.join("checklist.toml").is_file());
+
+ // Cached under <source-hash>/<ref-hash> with a working tree.
+ let source_hash = sha256_prefix16(&repo_url);
+ let full_hash = resolve_commit(&repo_url, "v0.1").unwrap();
+ let cached = cache.join(source_hash).join(&full_hash[..16]);
+ assert!(cached.join(".git").is_dir());
+
+ // Second resolution reuses the cache.
+ let template_again = resolve_git(&repo_url, "v0.1", "basic", &cache).unwrap();
+ assert_eq!(template, template_again);
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+
+ #[test]
+ fn resolve_git_rejects_unknown_variant() {
+ let tmp =
+ std::env::temp_dir().join(format!("mling-tmpl-src-test-{}-reject", std::process::id()));
+ let repo = tmp.join("repo");
+ let cache = tmp.join("cache");
+ let _ = fs::remove_dir_all(&tmp);
+ make_repo(&repo);
+
+ let repo_url = format!("file://{}", repo.display());
+ let err = resolve_git(&repo_url, "v0.1", "nope", &cache).unwrap_err();
+ assert!(err.contains("nope"), "unexpected error: {err}");
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+}