use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
pub fn head_sha(root: &Path) -> Result<String> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let id = repo
.head()
.context("read HEAD")?
.id()
.ok_or_else(|| anyhow!("HEAD has no commit (unborn?) in {}", root.display()))?;
Ok(id.to_string())
}
pub fn head_branch(root: &Path) -> Result<String> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
Ok(match repo.head_name().context("read HEAD name")? {
Some(name) => name.shorten().to_string(),
None => "(detached)".to_string(),
})
}
pub fn head_sha_and_branch(root: &Path) -> Result<(String, String)> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let sha = repo
.head()
.context("read HEAD")?
.id()
.ok_or_else(|| anyhow!("HEAD has no commit (unborn?) in {}", root.display()))?
.to_string();
let branch = match repo.head_name().context("read HEAD name")? {
Some(name) => name.shorten().to_string(),
None => "(detached)".to_string(),
};
Ok((sha, branch))
}
pub fn tag_commit_sha(root: &Path, tag: &str) -> Result<Option<String>> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let full = format!("refs/tags/{tag}");
let Some(reference) = repo
.try_find_reference(full.as_str())
.with_context(|| format!("look up {full}"))?
else {
return Ok(None);
};
let id = reference
.into_fully_peeled_id()
.with_context(|| format!("peel {full}"))?;
Ok(Some(id.to_string()))
}
pub fn tag_points_at_head(root: &Path, tag: &str) -> Result<bool> {
match tag_commit_sha(root, tag)? {
Some(tag_sha) => Ok(tag_sha == head_sha(root)?),
None => Ok(false),
}
}
const FIXTURE_NAME: &str = "Nornir Fixture";
const FIXTURE_EMAIL: &str = "fixtures@nornir.invalid";
const FIXTURE_TIME: &str = "1700000000 +0000";
pub fn init(root: &Path) -> Result<()> {
std::fs::create_dir_all(root).with_context(|| format!("mkdir {}", root.display()))?;
gix::init(root).with_context(|| format!("gix init {}", root.display()))?;
Ok(())
}
pub fn commit_all(root: &Path, message: &str) -> Result<String> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let empty = gix::ObjectId::empty_tree(repo.object_hash());
let mut editor = repo.edit_tree(empty).context("seed empty tree editor")?;
add_dir_recursive(&repo, &mut editor, root, root)?;
let tree = editor.write().context("write fixture tree")?.detach();
let parents: Vec<gix::ObjectId> = repo.head_commit().ok().map(|c| c.id).into_iter().collect();
let sig = gix::actor::SignatureRef {
name: gix::bstr::BStr::new(FIXTURE_NAME),
email: gix::bstr::BStr::new(FIXTURE_EMAIL),
time: FIXTURE_TIME,
};
let id = repo
.commit_as(sig, sig, "HEAD", message, tree, parents)
.context("create fixture commit")?;
let mut index = repo
.index_from_tree(&tree)
.context("rebuild index from fixture tree")?;
index
.write(gix::index::write::Options::default())
.context("persist fixture index")?;
Ok(id.to_string())
}
pub fn tag_lightweight(root: &Path, name: &str, target_sha: &str) -> Result<()> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let target = gix::ObjectId::from_hex(target_sha.as_bytes())
.with_context(|| format!("parse target sha `{target_sha}`"))?;
repo.tag_reference(name, target, gix::refs::transaction::PreviousValue::MustNotExist)
.with_context(|| format!("create tag `{name}` -> {target_sha}"))?;
Ok(())
}
pub fn is_ssh_url(url: &str) -> bool {
url.starts_with("ssh://") || (url.contains('@') && !url.contains("://"))
}
fn clone_repo(url: &str, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
let mut prepare = gix::prepare_clone(url, dest)
.with_context(|| format!("prepare clone {url} → {}", dest.display()))?;
let (mut checkout, _) = prepare
.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.with_context(|| format!("clone-fetch {url}"))?;
checkout
.main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.with_context(|| format!("checkout worktree for {url}"))?;
Ok(())
}
fn fetch_repo(dest: &Path) -> Result<()> {
let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
let remote = repo
.find_default_remote(gix::remote::Direction::Fetch)
.ok_or_else(|| anyhow!("{} has no fetch remote", dest.display()))?
.context("resolve default remote")?;
remote
.connect(gix::remote::Direction::Fetch)
.context("connect to remote")?
.prepare_fetch(gix::progress::Discard, Default::default())
.context("prepare fetch")?
.receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.context("fetch")?;
Ok(())
}
pub fn nornir_ssh_key_path() -> Option<PathBuf> {
let dir = if let Some(d) = std::env::var_os("NORNIR_SSH_DIR") {
PathBuf::from(d)
} else {
let sys = Path::new("/home/nornir/.ssh");
if sys.exists() {
sys.to_path_buf()
} else if let Some(home) = std::env::var_os("HOME") {
Path::new(&home).join(".nornir/ssh")
} else {
return None;
}
};
let key = dir.join("id_ed25519");
key.exists().then_some(key)
}
fn ssh_sync(url: &str, dest: &Path, key_path: &Path) -> Result<String> {
use gix::protocol::transport::client::git::blocking_io::Connection as GitConnection;
use gix::protocol::transport::client::git::ConnectMode;
use gix::protocol::transport::Protocol;
let loc = crate::ssh::parse_ssh_url(url)?;
let refs = crate::ssh::ls_remote_blocking(url, key_path)
.with_context(|| format!("ssh ls-remote {url}"))?;
let head_sha = refs
.iter()
.find(|(_, name)| name == "HEAD")
.map(|(sha, _)| sha.clone())
.ok_or_else(|| anyhow!("remote {url} advertised no HEAD"))?;
let branch = refs
.iter()
.find(|(sha, name)| sha == &head_sha && name.starts_with("refs/heads/"))
.map(|(_, name)| name.clone())
.unwrap_or_else(|| "refs/heads/main".to_string());
if dest.join(".git").exists()
&& crate::gitio::head_sha(dest).ok().as_deref() == Some(head_sha.as_str())
{
return Ok(head_sha);
}
eprintln!("nornir-ssh: {url} HEAD={head_sha} changed; fetching pack…");
let mut repo = if dest.join(".git").exists() {
gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?
} else {
std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
gix::init(dest).with_context(|| format!("gix::init {}", dest.display()))?
};
let _ = repo.committer_or_set_generic_fallback();
let mut up = crate::ssh::connect_upload_pack(&loc, key_path)
.with_context(|| format!("ssh upload-pack {url}"))?;
let transport = GitConnection::new(
&mut up.reader,
&mut up.writer,
Protocol::V2,
loc.path.clone(),
Option::<(String, Option<u16>)>::None,
ConnectMode::Process,
false,
);
let remote = repo
.remote_at(url)
.context("build in-memory remote")?
.with_refspecs(
Some("+refs/heads/*:refs/remotes/origin/*"),
gix::remote::Direction::Fetch,
)
.context("set fetch refspecs")?;
remote
.to_connection_with_transport(transport)
.prepare_fetch(gix::progress::Discard, Default::default())
.context("prepare ssh fetch")?
.receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.context("ssh fetch (pack transfer)")?;
drop(up);
set_head(&repo, &branch, &head_sha)?;
materialize_worktree(&repo, &head_sha, dest)
.with_context(|| format!("checkout {head_sha} into {}", dest.display()))?;
eprintln!("nornir-ssh: {url} synced {head_sha} → {}", dest.display());
Ok(head_sha)
}
fn set_head(repo: &gix::Repository, branch: &str, sha: &str) -> Result<()> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix::refs::{FullName, Target};
let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
let branch_name: FullName = branch
.try_into()
.map_err(|e| anyhow!("invalid branch ref `{branch}`: {e}"))?;
let log = LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "nornir: ssh fetch".into(),
};
let edits = vec![
RefEdit {
change: Change::Update {
log: log.clone(),
expected: PreviousValue::Any,
new: Target::Object(oid),
},
name: branch_name.clone(),
deref: false,
},
RefEdit {
change: Change::Update {
log,
expected: PreviousValue::Any,
new: Target::Symbolic(branch_name),
},
name: "HEAD".try_into().expect("HEAD is a valid ref name"),
deref: false,
},
];
repo.edit_references(edits).context("set HEAD + branch")?;
Ok(())
}
fn materialize_worktree(repo: &gix::Repository, sha: &str, dest: &Path) -> Result<()> {
use gix::index::entry::Mode;
let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
let tree_id = repo
.find_commit(oid)
.with_context(|| format!("find commit {sha}"))?
.tree_id()
.context("commit tree")?
.detach();
let index = repo
.index_from_tree(&tree_id)
.with_context(|| format!("index from tree {tree_id}"))?;
for entry in std::fs::read_dir(dest).with_context(|| format!("read {}", dest.display()))? {
let path = entry?.path();
if path.file_name().map(|n| n == ".git").unwrap_or(false) {
continue;
}
if path.is_dir() {
std::fs::remove_dir_all(&path).ok();
} else {
std::fs::remove_file(&path).ok();
}
}
for entry in index.entries() {
if entry.mode == Mode::COMMIT {
continue; }
let rel = gix::path::from_bstr(entry.path(&index));
let full = dest.join(rel.as_ref());
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir {}", parent.display()))?;
}
let blob = repo
.find_object(entry.id)
.with_context(|| format!("blob {} for {}", entry.id, full.display()))?;
if entry.mode == Mode::SYMLINK {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let target = std::ffi::OsStr::from_bytes(&blob.data);
std::fs::remove_file(&full).ok();
std::os::unix::fs::symlink(target, &full)
.with_context(|| format!("symlink {}", full.display()))?;
}
#[cfg(not(unix))]
std::fs::write(&full, &blob.data).with_context(|| format!("write {}", full.display()))?;
} else {
std::fs::write(&full, &blob.data).with_context(|| format!("write {}", full.display()))?;
#[cfg(unix)]
if entry.mode == Mode::FILE_EXECUTABLE {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).ok();
}
}
}
Ok(())
}
pub fn clone_or_fetch(url: &str, dest: &Path, ssh_key: Option<&Path>) -> Result<String> {
if is_ssh_url(url) {
let resolved;
let key = match ssh_key {
Some(k) => k,
None => {
resolved = nornir_ssh_key_path().ok_or_else(|| {
anyhow!(
"SSH remote `{url}` needs a deploy key, but none was found \
(set NORNIR_SSH_DIR or install the service so the key lives \
at /home/nornir/.ssh/id_ed25519 — see `nornir key show`)"
)
})?;
resolved.as_path()
}
};
return ssh_sync(url, dest, key);
}
if dest.join(".git").exists() {
fetch_repo(dest)?;
} else {
clone_repo(url, dest)?;
}
head_sha(dest)
}
fn add_dir_recursive(
repo: &gix::Repository,
editor: &mut gix::object::tree::Editor<'_>,
repo_root: &Path,
dir: &Path,
) -> Result<()> {
use gix::object::tree::EntryKind;
let mut entries: Vec<_> = std::fs::read_dir(dir)
.with_context(|| format!("read_dir {}", dir.display()))?
.collect::<std::io::Result<Vec<_>>>()
.with_context(|| format!("iterate {}", dir.display()))?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let name = entry.file_name();
if name == ".git" {
continue;
}
let meta = std::fs::symlink_metadata(&path)
.with_context(|| format!("stat {}", path.display()))?;
let ft = meta.file_type();
if ft.is_dir() {
add_dir_recursive(repo, editor, repo_root, &path)?;
continue;
}
let rela = path
.strip_prefix(repo_root)
.expect("path is under repo_root");
let rela = gix::path::into_bstr(rela).into_owned();
let (bytes, kind): (Vec<u8>, EntryKind) = if ft.is_symlink() {
let target = std::fs::read_link(&path)
.with_context(|| format!("readlink {}", path.display()))?;
(gix::path::into_bstr(target).into_owned().into(), EntryKind::Link)
} else {
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
#[cfg(unix)]
let kind = {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 != 0 {
EntryKind::BlobExecutable
} else {
EntryKind::Blob
}
};
#[cfg(not(unix))]
let kind = EntryKind::Blob;
(bytes, kind)
};
let blob = repo.write_blob(&bytes).context("write blob")?;
editor
.upsert(rela.as_ref() as &gix::bstr::BStr, kind, blob.detach())
.with_context(|| format!("tree upsert {rela}"))?;
}
Ok(())
}