use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use crate::workspace::descriptor::{GitRef, RepoSource, WorkspaceDescriptor};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathPlan {
Use,
Materialize { url: String, branch: Option<String> },
Missing,
}
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
}
}
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)
}
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))
}
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 } => {
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)?;
if !cache.join(".git").exists() {
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.\n\
Bootstrap once with:\n\
mkdir -p {parent}\n\
git clone{br} {url} {cache}\n\
(network clone via gix is not yet wired; \
once it is, `nornir workspace fetch {name}` will do this for you.)",
parent = cache.parent().unwrap().display(),
cache = cache.display(),
));
}
cache
}
};
out.insert(name, path);
}
Ok(out)
}
fn materialize_member(
name: &str,
url: &str,
branch: Option<&str>,
dest: &std::path::Path,
) -> Result<()> {
crate::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(())
}
fn checkout_branch(dest: &std::path::Path, branch: &str) -> Result<()> {
let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
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();
crate::gitio::set_head_and_checkout(dest, branch, &id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_dir_respects_explicit_env() {
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) }
}
#[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);
}
#[test]
fn plan_missing_with_fallback_materializes_with_branch() {
let plan = plan_path(false, Some(&git("git@codeberg.org:nordisk/korp.git", Some("dev"))));
assert_eq!(
plan,
PathPlan::Materialize {
url: "git@codeberg.org:nordisk/korp.git".into(),
branch: Some("dev".into()),
},
);
}
#[test]
fn plan_missing_without_fallback_is_missing() {
assert_eq!(plan_path(false, None), PathPlan::Missing);
}
#[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");
}
#[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}");
}
#[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");
assert_eq!(
resolved.get("foo").unwrap().canonicalize().unwrap(),
checkout.canonicalize().unwrap(),
);
}
}