Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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(),
        );
    }
}