crates/nornir-workspace/src/resolve.rs
//! Resolve every repo in a [`WorkspaceDescriptor`] to a concrete local
//! filesystem path that the dep-graph builder and release pipeline can
//! consume uniformly.
//!
//! - `path = "…"` → resolved relative to the descriptor's parent dir. A fat
//! member may ALSO carry a `git` fallback: when the local `path` checkout is
//! missing, nornir auto-materializes it by cloning the fallback remote into
//! that path (honoring the member's `branch`), then proceeds as if it had
//! been there all along. A path-only member with no fallback still errors
//! when its checkout is absent (the original behavior).
//! - `git = "…"` → mapped to a deterministic cache path under
//! `$NORNIR_CACHE_DIR` (default `~/.cache/nornir/workspaces/<ws>/<repo>`).
//! When the cache checkout is missing, nornir **auto-materializes** it by
//! cloning the remote into the cache via the pure-Rust `clone_or_fetch`
//! primitive (SSH via russh, HTTPS coerced to SSH — no shell-out), honoring
//! the member's `branch`. This is what makes `funnel plan-from` (and every
//! dep-graph query) work in an all-`git` workspace like `nordisk` with no
//! manual bootstrap. Pin `NORNIR_OFFLINE` to disable the network and get the
//! airgap-safe error-with-`git clone`-hint instead.
//!
//! Pure Rust, sync.
use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use crate::descriptor::{GitRef, RepoSource, WorkspaceDescriptor};
/// What to do with a fat (`path = …`) member when resolving its checkout —
/// the pure decision the materialize hook acts on. Factored out so the logic
/// is unit-testable without touching the filesystem or the network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathPlan {
/// The checkout exists on disk (a non-empty dir) → use it as-is.
Use,
/// The checkout is missing but a fallback git remote is declared → clone it
/// into `path` from this remote/branch, then use it.
Materialize { url: String, branch: Option<String> },
/// The checkout is missing and there is NO fallback remote → error.
Missing,
}
/// Decide what to do with a fat member's local checkout, given whether the
/// `path` already holds a non-empty checkout and the optional git fallback.
/// Pure: no IO. `present` should be `true` iff the path exists as a non-empty
/// directory (see [`path_is_present`]).
pub fn plan_path(present: bool, fallback: Option<&GitRef>) -> PathPlan {
if present {
PathPlan::Use
} else if let Some(gr) = fallback {
PathPlan::Materialize { url: gr.url.clone(), branch: gr.branch.clone() }
} else {
PathPlan::Missing
}
}
/// What to do with a `git = …` member's deterministic cache checkout — the pure
/// decision the git-materialize hook acts on. Unit-testable without IO/network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitPlan {
/// The cache checkout already exists (`<cache>/.git`) → use it as-is.
Use,
/// The cache is missing and network fetch is allowed → clone the remote into
/// the cache, then use it. This is what makes `funnel plan-from` (and any
/// dep-graph query) work in an all-`git` workspace like `nordisk` without a
/// manual bootstrap clone.
Clone,
/// The cache is missing and we're offline (`NORNIR_OFFLINE`) → error with a
/// manual `git clone` hint (the original airgap-safe behavior).
Offline,
}
/// Decide what to do with a git-sourced member's cache checkout. Pure: no IO.
/// `present` is `true` iff `<cache>/.git` exists; `offline` is `true` iff the
/// operator pinned `NORNIR_OFFLINE` (no network — keep the error-with-hint path).
pub fn plan_git(present: bool, offline: bool) -> GitPlan {
if present {
GitPlan::Use
} else if offline {
GitPlan::Offline
} else {
GitPlan::Clone
}
}
/// Whether network fetches are disabled via `NORNIR_OFFLINE` (any non-empty
/// value). When set, a missing git-sourced member errors with a manual clone
/// hint instead of auto-cloning — the airgap-safe path.
fn offline_pinned() -> bool {
std::env::var_os("NORNIR_OFFLINE")
.map(|v| !v.is_empty())
.unwrap_or(false)
}
/// A checkout is "present" iff `path` is an existing directory with at least
/// one entry (an empty dir left behind by a failed clone is NOT present, so we
/// can re-materialize into it).
fn path_is_present(path: &std::path::Path) -> bool {
path.is_dir()
&& std::fs::read_dir(path)
.map(|mut it| it.next().is_some())
.unwrap_or(false)
}
/// Deterministic cache dir for one (workspace, repo) git source.
///
/// Respects `$NORNIR_CACHE_DIR`; falls back to `$XDG_CACHE_HOME` then
/// `$HOME/.cache`. Final layout: `…/nornir/workspaces/<ws>/<repo>`.
pub fn git_cache_dir(workspace_name: &str, repo_name: &str) -> Result<PathBuf> {
let base = if let Some(d) = std::env::var_os("NORNIR_CACHE_DIR") {
PathBuf::from(d)
} else if let Some(d) = std::env::var_os("XDG_CACHE_HOME") {
PathBuf::from(d).join("nornir")
} else if let Some(home) = std::env::var_os("HOME") {
PathBuf::from(home).join(".cache").join("nornir")
} else {
return Err(anyhow!("no $HOME and no $NORNIR_CACHE_DIR — can't pick cache dir"));
};
Ok(base.join("workspaces").join(workspace_name).join(repo_name))
}
/// Resolve every repo to a local path. For `git:` sources, errors out
/// (with a clone hint) if the cache path is missing.
pub fn resolve_sources(desc: &WorkspaceDescriptor) -> Result<BTreeMap<String, PathBuf>> {
let mut out = BTreeMap::new();
for (name, src) in desc.sources()? {
let path = match src {
RepoSource::Path { resolved, fallback } => {
match plan_path(path_is_present(&resolved), fallback.as_ref()) {
PathPlan::Use => resolved,
PathPlan::Materialize { url, branch } => {
// Auto-materialize: the fat member's local checkout is
// absent, so clone it from the declared fallback remote
// into `resolved` and proceed as if it had been there.
// Reuses the pure-Rust `gitio::clone_or_fetch` primitive
// (HTTPS via gix, SSH via russh) — no shell-out.
eprintln!(
"nornir-workspace: materializing fat member `{name}` \
from {url} into {}",
resolved.display()
);
materialize_member(&name, &url, branch.as_deref(), &resolved)?;
resolved
}
PathPlan::Missing => {
return Err(anyhow!(
"fat member `{name}` has no local checkout at {} and no \
`git` fallback remote in the descriptor — add a `git = …` \
to auto-materialize it, or check it out manually.",
resolved.display()
));
}
}
}
RepoSource::Git(GitRef { url, branch }) => {
let cache = git_cache_dir(&desc.workspace.name, &name)?;
match plan_git(cache.join(".git").exists(), offline_pinned()) {
GitPlan::Use => cache,
GitPlan::Clone => {
// Auto-materialize the git-sourced member into its cache
// checkout, exactly like the fat-member `Materialize` arm
// above — reuse the same pure-Rust `clone_or_fetch`
// primitive the server monitor uses (SSH via russh, HTTPS
// coerced to SSH), no shell-out. This is what makes
// `funnel plan-from` work end-to-end in the all-`git`
// `nordisk` workspace (the members are not checked out
// anywhere else on the box).
if let Some(parent) = cache.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("mkdir cache parent {}", parent.display())
})?;
}
eprintln!(
"nornir-workspace: fetching git member `{name}` from {url} into {}",
cache.display()
);
materialize_member(&name, &url, branch.as_deref(), &cache)?;
cache
}
GitPlan::Offline => {
let br = branch
.as_deref()
.map(|b| format!(" --branch {b}"))
.unwrap_or_default();
return Err(anyhow!(
"repo `{name}` is git-sourced and not yet in cache, and \
NORNIR_OFFLINE is set.\n\
Bootstrap once with:\n\
mkdir -p {parent}\n\
git clone{br} {url} {cache}\n\
or unset NORNIR_OFFLINE to let `nornir workspace fetch {name}` \
(and `funnel plan-from`) clone it for you.",
parent = cache.parent().unwrap().display(),
cache = cache.display(),
));
}
}
}
};
out.insert(name, path);
}
Ok(out)
}
/// Clone a fat member's fallback remote into its `path` location (the
/// auto-materialize action). Delegates the transport to the pure-Rust
/// `nornir_git::gitio::clone_or_fetch` (HTTPS via gix, SSH via russh) — no
/// shell-out. After the clone, if a specific `branch` is requested, best-effort
/// check it out so the materialized tree matches the descriptor's pinned ref;
/// a checkout failure is non-fatal (we keep the default branch and warn) so a
/// stale/renamed branch never blocks the member entirely.
fn materialize_member(
name: &str,
url: &str,
branch: Option<&str>,
dest: &std::path::Path,
) -> Result<()> {
nornir_git::gitio::clone_or_fetch(url, dest, None)
.with_context(|| format!("clone fat member `{name}` from {url} into {}", dest.display()))?;
if let Some(branch) = branch {
if let Err(e) = checkout_branch(dest, branch) {
eprintln!(
"nornir-workspace: member `{name}` materialized, but checking out \
branch `{branch}` failed ({e:#}); staying on the default branch"
);
}
}
Ok(())
}
/// Best-effort: point `HEAD` at `branch` in the freshly cloned repo at `dest`
/// and materialize its worktree, using only public gix APIs (no shell-out).
/// Resolves the branch from the local clone (`refs/heads/<b>`) or the fetched
/// remote-tracking ref (`refs/remotes/origin/<b>`).
fn checkout_branch(dest: &std::path::Path, branch: &str) -> Result<()> {
let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
// Try the local branch first, then the remote-tracking ref.
let local = format!("refs/heads/{branch}");
let remote = format!("refs/remotes/origin/{branch}");
let target = repo
.try_find_reference(local.as_str())
.ok()
.flatten()
.or_else(|| repo.try_find_reference(remote.as_str()).ok().flatten())
.ok_or_else(|| anyhow!("branch `{branch}` not found in {}", dest.display()))?;
let id = target
.into_fully_peeled_id()
.with_context(|| format!("peel branch `{branch}`"))?
.to_string();
nornir_git::gitio::set_head_and_checkout(dest, branch, &id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_dir_respects_explicit_env() {
// SAFETY: single-threaded test; we restore env at end.
let prev = std::env::var_os("NORNIR_CACHE_DIR");
unsafe { std::env::set_var("NORNIR_CACHE_DIR", "/tmp/nornir-cache-test"); }
let d = git_cache_dir("ws1", "repo1").unwrap();
assert_eq!(d, PathBuf::from("/tmp/nornir-cache-test/workspaces/ws1/repo1"));
unsafe {
match prev {
Some(v) => std::env::set_var("NORNIR_CACHE_DIR", v),
None => std::env::remove_var("NORNIR_CACHE_DIR"),
}
}
}
fn git(url: &str, branch: Option<&str>) -> GitRef {
GitRef { url: url.into(), branch: branch.map(str::to_string) }
}
/// A present checkout is used as-is regardless of any fallback (no clone).
#[test]
fn plan_present_uses_path() {
assert_eq!(plan_path(true, None), PathPlan::Use);
assert_eq!(plan_path(true, Some(&git("git@h:o/r.git", Some("main")))), PathPlan::Use);
}
/// A MISSING checkout WITH a fallback → materialize from that remote/branch.
#[test]
fn plan_missing_with_fallback_materializes_with_branch() {
let plan = plan_path(false, Some(&git("ssh://git@git.gunnar.rs:2222/nordisk/korp.git", Some("dev"))));
assert_eq!(
plan,
PathPlan::Materialize {
url: "ssh://git@git.gunnar.rs:2222/nordisk/korp.git".into(),
branch: Some("dev".into()),
},
);
}
/// A MISSING checkout with NO fallback → the original missing/error case.
#[test]
fn plan_missing_without_fallback_is_missing() {
assert_eq!(plan_path(false, None), PathPlan::Missing);
}
/// A present git cache checkout is used as-is (no clone), online or off.
#[test]
fn git_present_uses_cache() {
assert_eq!(plan_git(true, false), GitPlan::Use);
assert_eq!(plan_git(true, true), GitPlan::Use);
}
/// A missing git cache checkout auto-clones when online, but errors with a
/// manual hint under `NORNIR_OFFLINE` (airgap-safe) — this is the toggle that
/// let `funnel plan-from` start working in the all-git `nordisk` workspace.
#[test]
fn git_missing_clones_online_but_offline_errors() {
assert_eq!(plan_git(false, false), GitPlan::Clone);
assert_eq!(plan_git(false, true), GitPlan::Offline);
}
/// End-to-end (no network): a git-sourced member whose cache is absent errors
/// with the manual `git clone` hint when `NORNIR_OFFLINE` is pinned, naming
/// the member and remote. Guards the airgap path without touching the wire.
#[test]
fn resolve_git_offline_errors_with_hint() {
// Isolate the cache + offline pin for this single-threaded test.
let prev_cache = std::env::var_os("NORNIR_CACHE_DIR");
let prev_offline = std::env::var_os("NORNIR_OFFLINE");
let td = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("NORNIR_CACHE_DIR", td.path().join("cache"));
std::env::set_var("NORNIR_OFFLINE", "1");
}
let toml = "[workspace]\nname = \"demo\"\n\n[repos.foo]\n\
git = \"ssh://git@git.gunnar.rs:2222/nordisk/foo.git\"\n";
let toml_path = td.path().join("nornir-workspace.toml");
std::fs::write(&toml_path, toml).unwrap();
let desc = WorkspaceDescriptor::load(&toml_path).unwrap();
let err = resolve_sources(&desc).expect_err("offline git member must error");
let msg = format!("{err:#}");
assert!(msg.contains("foo"), "error names the member: {msg}");
assert!(msg.contains("NORNIR_OFFLINE"), "error explains the offline pin: {msg}");
unsafe {
match prev_cache {
Some(v) => std::env::set_var("NORNIR_CACHE_DIR", v),
None => std::env::remove_var("NORNIR_CACHE_DIR"),
}
match prev_offline {
Some(v) => std::env::set_var("NORNIR_OFFLINE", v),
None => std::env::remove_var("NORNIR_OFFLINE"),
}
}
}
/// `path_is_present` treats an empty dir as ABSENT (so a partial clone can be
/// re-materialized) and a non-empty dir as present.
#[test]
fn empty_dir_is_not_present() {
let td = tempfile::tempdir().unwrap();
let empty = td.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(!path_is_present(&empty), "empty dir must not count as a checkout");
std::fs::write(empty.join("file"), b"x").unwrap();
assert!(path_is_present(&empty), "non-empty dir is a present checkout");
assert!(!path_is_present(&td.path().join("nope")), "missing path is absent");
}
/// End-to-end resolver behavior WITHOUT a network: a path-only fat member
/// whose checkout is absent and which carries NO `git` fallback must still
/// error (backward-compatible), and the error names the member + path.
#[test]
fn resolve_missing_path_no_fallback_errors() {
let td = tempfile::tempdir().unwrap();
let toml = "[workspace]\nname = \"demo\"\n\n[repos.foo]\npath = \"foo-checkout\"\n";
let toml_path = td.path().join("nornir-workspace.toml");
std::fs::write(&toml_path, toml).unwrap();
let desc = WorkspaceDescriptor::load(&toml_path).unwrap();
let err = resolve_sources(&desc).expect_err("missing path with no fallback must error");
let msg = format!("{err:#}");
assert!(msg.contains("foo"), "error names the member: {msg}");
assert!(msg.contains("no `git` fallback"), "error explains the fix: {msg}");
}
/// A present path-only fat member resolves to its checkout with no error and
/// no network — the steady-state fat case.
#[test]
fn resolve_present_path_uses_checkout() {
let td = tempfile::tempdir().unwrap();
let checkout = td.path().join("foo-checkout");
std::fs::create_dir_all(&checkout).unwrap();
std::fs::write(checkout.join("Cargo.toml"), b"# present\n").unwrap();
let toml = "[workspace]\nname = \"demo\"\n\n[repos.foo]\npath = \"foo-checkout\"\n";
let toml_path = td.path().join("nornir-workspace.toml");
std::fs::write(&toml_path, toml).unwrap();
let desc = WorkspaceDescriptor::load(&toml_path).unwrap();
let resolved = resolve_sources(&desc).expect("present checkout resolves");
// The descriptor_dir is canonicalized on load, so compare canonical paths.
assert_eq!(
resolved.get("foo").unwrap().canonicalize().unwrap(),
checkout.canonicalize().unwrap(),
);
}
}