1use std::path::{Path, PathBuf};
14
15use anyhow::{anyhow, bail, Context, Result};
16
17pub fn head_sha(root: &Path) -> Result<String> {
25 match gix_head_sha(root) {
26 Ok(sha) => Ok(sha),
27 Err(_) => git_head_sha(root)
28 .with_context(|| format!("read HEAD of {} (gix + git both failed)", root.display())),
29 }
30}
31
32fn gix_head_sha(root: &Path) -> Result<String> {
33 let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
34 let id = repo
35 .head()
36 .context("read HEAD")?
37 .id()
38 .ok_or_else(|| anyhow!("HEAD has no commit (unborn?) in {}", root.display()))?;
39 Ok(id.to_string())
40}
41
42fn git_head_sha(root: &Path) -> Result<String> {
44 let out = std::process::Command::new("git")
45 .arg("-C")
46 .arg(root)
47 .args(["rev-parse", "HEAD"])
48 .output()
49 .with_context(|| format!("spawn `git rev-parse HEAD` in {}", root.display()))?;
50 if !out.status.success() {
51 return Err(anyhow!(
52 "git rev-parse HEAD failed in {}: {}",
53 root.display(),
54 String::from_utf8_lossy(&out.stderr).trim()
55 ));
56 }
57 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
58}
59
60pub fn head_branch(root: &Path) -> Result<String> {
63 if let Ok(b) = gix_head_branch(root) {
64 return Ok(b);
65 }
66 git_head_branch(root)
67}
68
69fn gix_head_branch(root: &Path) -> Result<String> {
70 let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
71 Ok(match repo.head_name().context("read HEAD name")? {
72 Some(name) => name.shorten().to_string(),
73 None => "(detached)".to_string(),
74 })
75}
76
77fn git_head_branch(root: &Path) -> Result<String> {
79 let out = std::process::Command::new("git")
80 .arg("-C")
81 .arg(root)
82 .args(["symbolic-ref", "--short", "HEAD"])
83 .output()
84 .with_context(|| format!("spawn `git symbolic-ref HEAD` in {}", root.display()))?;
85 if !out.status.success() {
86 return Ok("(detached)".to_string());
87 }
88 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
89}
90
91pub fn head_sha_and_branch(root: &Path) -> Result<(String, String)> {
94 Ok((head_sha(root)?, head_branch(root)?))
95}
96
97pub const CLEAN_WORKTREE_DIGEST: &str = "clean";
101
102#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct WorktreeFreshness {
111 pub dirty: bool,
113 pub digest: String,
116}
117
118pub fn worktree_freshness(root: &Path) -> Result<WorktreeFreshness> {
129 use gix::bstr::ByteSlice;
130 use sha2::{Digest, Sha256};
131
132 let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
133 let workdir = repo
134 .workdir()
135 .ok_or_else(|| anyhow!("bare repo has no working tree: {}", root.display()))?
136 .to_path_buf();
137
138 let platform = repo
142 .status(gix::progress::Discard)
143 .context("open status platform")?
144 .untracked_files(gix::status::UntrackedFiles::Files);
145 let iter = platform
146 .into_iter(None)
147 .context("start working-tree status iteration")?;
148
149 let mut lines: Vec<String> = Vec::new();
151 for item in iter {
152 let item = item.context("read status item")?;
153 let rela = item.location().to_str_lossy().into_owned();
154 let tag = match &item {
155 gix::status::Item::TreeIndex(_) => 'S', gix::status::Item::IndexWorktree(iw) => match iw {
157 gix::status::index_worktree::Item::Modification { .. } => 'M',
158 gix::status::index_worktree::Item::DirectoryContents { .. } => 'U', gix::status::index_worktree::Item::Rewrite { .. } => 'R',
160 },
161 };
162 let stat = match std::fs::metadata(workdir.join(&rela)) {
165 Ok(md) => {
166 let mtime = md
167 .modified()
168 .ok()
169 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
170 .map(|d| d.as_nanos())
171 .unwrap_or(0);
172 format!("{}:{mtime}", md.len())
173 }
174 Err(_) => "-".to_string(),
175 };
176 lines.push(format!("{tag} {rela} {stat}"));
177 }
178
179 if lines.is_empty() {
180 return Ok(WorktreeFreshness { dirty: false, digest: CLEAN_WORKTREE_DIGEST.to_string() });
181 }
182 lines.sort();
183 let mut hasher = Sha256::new();
184 for l in &lines {
185 hasher.update(l.as_bytes());
186 hasher.update(b"\n");
187 }
188 use std::fmt::Write as _;
189 let mut digest = String::with_capacity(64);
190 for b in hasher.finalize() {
191 let _ = write!(digest, "{b:02x}");
192 }
193 Ok(WorktreeFreshness { dirty: true, digest })
194}
195
196pub fn tag_commit_sha(root: &Path, tag: &str) -> Result<Option<String>> {
199 let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
200 let full = format!("refs/tags/{tag}");
201 let Some(reference) = repo
202 .try_find_reference(full.as_str())
203 .with_context(|| format!("look up {full}"))?
204 else {
205 return Ok(None);
206 };
207 let id = reference
208 .into_fully_peeled_id()
209 .with_context(|| format!("peel {full}"))?;
210 Ok(Some(id.to_string()))
211}
212
213pub fn tag_points_at_head(root: &Path, tag: &str) -> Result<bool> {
216 match tag_commit_sha(root, tag)? {
217 Some(tag_sha) => Ok(tag_sha == head_sha(root)?),
218 None => Ok(false),
219 }
220}
221
222const FIXTURE_NAME: &str = "Nornir Fixture";
225const FIXTURE_EMAIL: &str = "fixtures@nornir.invalid";
226const FIXTURE_TIME: &str = "1700000000 +0000";
227
228pub fn init(root: &Path) -> Result<()> {
233 std::fs::create_dir_all(root).with_context(|| format!("mkdir {}", root.display()))?;
234 gix::init(root).with_context(|| format!("gix init {}", root.display()))?;
235 Ok(())
236}
237
238pub fn commit_all(root: &Path, message: &str) -> Result<String> {
247 let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
248 let empty = gix::ObjectId::empty_tree(repo.object_hash());
249 let mut editor = repo.edit_tree(empty).context("seed empty tree editor")?;
250
251 add_dir_recursive(&repo, &mut editor, root, root)?;
252 let tree = editor.write().context("write fixture tree")?.detach();
253
254 let parents: Vec<gix::ObjectId> = repo.head_commit().ok().map(|c| c.id).into_iter().collect();
255 let sig = gix::actor::SignatureRef {
256 name: gix::bstr::BStr::new(FIXTURE_NAME),
257 email: gix::bstr::BStr::new(FIXTURE_EMAIL),
258 time: FIXTURE_TIME,
259 };
260 let id = repo
261 .commit_as(sig, sig, "HEAD", message, tree, parents)
262 .context("create fixture commit")?;
263
264 let mut index = repo
268 .index_from_tree(&tree)
269 .context("rebuild index from fixture tree")?;
270 index
271 .write(gix::index::write::Options::default())
272 .context("persist fixture index")?;
273
274 Ok(id.to_string())
275}
276
277pub fn tag_lightweight(root: &Path, name: &str, target_sha: &str) -> Result<()> {
280 let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
281 let target = gix::ObjectId::from_hex(target_sha.as_bytes())
282 .with_context(|| format!("parse target sha `{target_sha}`"))?;
283 repo.tag_reference(name, target, gix::refs::transaction::PreviousValue::MustNotExist)
284 .with_context(|| format!("create tag `{name}` -> {target_sha}"))?;
285 Ok(())
286}
287
288pub fn is_ssh_url(url: &str) -> bool {
298 url.starts_with("ssh://") || (url.contains('@') && !url.contains("://"))
299}
300
301fn clone_repo(url: &str, dest: &Path) -> Result<()> {
303 std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
304 let mut prepare = gix::prepare_clone(url, dest)
305 .with_context(|| format!("prepare clone {url} → {}", dest.display()))?
306 .configure_remote(|remote| {
309 Ok::<_, Box<dyn std::error::Error + Send + Sync>>(
310 remote.with_fetch_tags(gix::remote::fetch::Tags::All),
311 )
312 });
313 let (mut checkout, _) = prepare
314 .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
315 .with_context(|| format!("clone-fetch {url}"))?;
316 checkout
317 .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
318 .with_context(|| format!("checkout worktree for {url}"))?;
319 Ok(())
320}
321
322fn fetch_repo(dest: &Path) -> Result<()> {
324 let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
325 let remote = repo
326 .find_default_remote(gix::remote::Direction::Fetch)
327 .ok_or_else(|| anyhow!("{} has no fetch remote", dest.display()))?
328 .context("resolve default remote")?
329 .with_fetch_tags(gix::remote::fetch::Tags::All);
331 remote
332 .connect(gix::remote::Direction::Fetch)
333 .context("connect to remote")?
334 .prepare_fetch(gix::progress::Discard, Default::default())
335 .context("prepare fetch")?
336 .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
337 .context("fetch")?;
338 Ok(())
339}
340
341pub fn nornir_ssh_key_path() -> Option<PathBuf> {
347 let dir = if let Some(d) = std::env::var_os("NORNIR_SSH_DIR") {
348 PathBuf::from(d)
349 } else {
350 let sys = Path::new("/home/nornir/.ssh");
351 if sys.exists() {
352 sys.to_path_buf()
353 } else if let Some(home) = std::env::var_os("HOME") {
354 Path::new(&home).join(".nornir/ssh")
355 } else {
356 return None;
357 }
358 };
359 let key = dir.join("id_ed25519");
360 key.exists().then_some(key)
361}
362
363fn ssh_sync(url: &str, dest: &Path, key_path: &Path) -> Result<String> {
371 use gix::protocol::transport::client::git::blocking_io::Connection as GitConnection;
372 use gix::protocol::transport::client::git::ConnectMode;
373 use gix::protocol::transport::Protocol;
374
375 let loc = crate::ssh::parse_ssh_url(url)?;
376
377 let refs = crate::ssh::ls_remote_blocking(url, key_path)
379 .with_context(|| format!("ssh ls-remote {url}"))?;
380 let head_sha = refs
381 .iter()
382 .find(|(_, name)| name == "HEAD")
383 .map(|(sha, _)| sha.clone())
384 .ok_or_else(|| anyhow!("remote {url} advertised no HEAD"))?;
385 let branch = refs
387 .iter()
388 .find(|(sha, name)| sha == &head_sha && name.starts_with("refs/heads/"))
389 .map(|(_, name)| name.clone())
390 .unwrap_or_else(|| "refs/heads/main".to_string());
391
392 if dest.join(".git").exists()
395 && crate::gitio::head_sha(dest).ok().as_deref() == Some(head_sha.as_str())
396 {
397 return Ok(head_sha);
398 }
399 eprintln!("nornir-ssh: {url} HEAD={head_sha} changed; fetching pack…");
400
401 let mut repo = if dest.join(".git").exists() {
403 gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?
404 } else {
405 std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
406 gix::init(dest).with_context(|| format!("gix::init {}", dest.display()))?
407 };
408 let _ = repo.committer_or_set_generic_fallback();
412
413 let mut up = crate::ssh::connect_upload_pack(&loc, key_path)
416 .with_context(|| format!("ssh upload-pack {url}"))?;
417 let transport = GitConnection::new(
418 &mut up.reader,
419 &mut up.writer,
420 Protocol::V2,
421 loc.path.clone(),
422 Option::<(String, Option<u16>)>::None,
423 ConnectMode::Process,
424 false,
425 );
426 let remote = repo
427 .remote_at(url)
428 .context("build in-memory remote")?
429 .with_refspecs(
430 [
431 "+refs/heads/*:refs/remotes/origin/*",
432 "+refs/tags/*:refs/tags/*",
434 ],
435 gix::remote::Direction::Fetch,
436 )
437 .context("set fetch refspecs")?;
438 remote
439 .to_connection_with_transport(transport)
440 .prepare_fetch(gix::progress::Discard, Default::default())
441 .context("prepare ssh fetch")?
442 .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
443 .context("ssh fetch (pack transfer)")?;
444 drop(up); set_head(&repo, &branch, &head_sha)?;
448 materialize_worktree(&repo, &head_sha, dest)
449 .with_context(|| format!("checkout {head_sha} into {}", dest.display()))?;
450 eprintln!("nornir-ssh: {url} synced {head_sha} → {}", dest.display());
451 Ok(head_sha)
452}
453
454pub fn set_head_and_checkout(dest: &Path, branch: &str, sha: &str) -> Result<()> {
460 let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
461 let full = format!("refs/heads/{branch}");
462 set_head(&repo, &full, sha)?;
463 materialize_worktree(&repo, sha, dest)
464 .with_context(|| format!("checkout {sha} into {}", dest.display()))
465}
466
467fn set_head(repo: &gix::Repository, branch: &str, sha: &str) -> Result<()> {
469 use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
470 use gix::refs::{FullName, Target};
471
472 let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
473 let branch_name: FullName = branch
474 .try_into()
475 .map_err(|e| anyhow!("invalid branch ref `{branch}`: {e}"))?;
476 let log = LogChange {
477 mode: RefLog::AndReference,
478 force_create_reflog: false,
479 message: "nornir: ssh fetch".into(),
480 };
481 let edits = vec![
482 RefEdit {
483 change: Change::Update {
484 log: log.clone(),
485 expected: PreviousValue::Any,
486 new: Target::Object(oid),
487 },
488 name: branch_name.clone(),
489 deref: false,
490 },
491 RefEdit {
492 change: Change::Update {
493 log,
494 expected: PreviousValue::Any,
495 new: Target::Symbolic(branch_name),
496 },
497 name: "HEAD".try_into().expect("HEAD is a valid ref name"),
498 deref: false,
499 },
500 ];
501 repo.edit_references(edits).context("set HEAD + branch")?;
502 Ok(())
503}
504
505fn materialize_worktree(repo: &gix::Repository, sha: &str, dest: &Path) -> Result<()> {
510 use gix::index::entry::Mode;
511
512 let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
513 let tree_id = repo
514 .find_commit(oid)
515 .with_context(|| format!("find commit {sha}"))?
516 .tree_id()
517 .context("commit tree")?
518 .detach();
519 let index = repo
520 .index_from_tree(&tree_id)
521 .with_context(|| format!("index from tree {tree_id}"))?;
522
523 for entry in std::fs::read_dir(dest).with_context(|| format!("read {}", dest.display()))? {
525 let path = entry?.path();
526 if path.file_name().map(|n| n == ".git").unwrap_or(false) {
527 continue;
528 }
529 if path.is_dir() {
530 std::fs::remove_dir_all(&path).ok();
531 } else {
532 std::fs::remove_file(&path).ok();
533 }
534 }
535
536 for entry in index.entries() {
537 if entry.mode == Mode::COMMIT {
538 continue; }
540 let rel = gix::path::from_bstr(entry.path(&index));
541 let full = dest.join(rel.as_ref());
542 if let Some(parent) = full.parent() {
543 std::fs::create_dir_all(parent)
544 .with_context(|| format!("mkdir {}", parent.display()))?;
545 }
546 let blob = repo
547 .find_object(entry.id)
548 .with_context(|| format!("blob {} for {}", entry.id, full.display()))?;
549
550 if entry.mode == Mode::SYMLINK {
551 #[cfg(unix)]
552 {
553 use std::os::unix::ffi::OsStrExt;
554 let target = std::ffi::OsStr::from_bytes(&blob.data);
555 std::fs::remove_file(&full).ok();
556 std::os::unix::fs::symlink(target, &full)
557 .with_context(|| format!("symlink {}", full.display()))?;
558 }
559 #[cfg(not(unix))]
560 std::fs::write(&full, &blob.data).with_context(|| format!("write {}", full.display()))?;
561 } else {
562 std::fs::write(&full, &blob.data).with_context(|| format!("write {}", full.display()))?;
563 #[cfg(unix)]
564 if entry.mode == Mode::FILE_EXECUTABLE {
565 use std::os::unix::fs::PermissionsExt;
566 std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).ok();
567 }
568 }
569 }
570 Ok(())
571}
572
573pub fn clone_or_fetch(url: &str, dest: &Path, ssh_key: Option<&Path>) -> Result<String> {
577 let coerced = https_to_ssh(url);
584 let url: &str = coerced.as_deref().unwrap_or(url);
585 if is_ssh_url(url) {
586 let resolved;
587 let key = match ssh_key {
588 Some(k) => k,
589 None => {
590 resolved = nornir_ssh_key_path().ok_or_else(|| {
591 anyhow!(
592 "SSH remote `{url}` needs a deploy key, but none was found \
593 (set NORNIR_SSH_DIR or install the service so the key lives \
594 at /home/nornir/.ssh/id_ed25519 — see `nornir key show`)"
595 )
596 })?;
597 resolved.as_path()
598 }
599 };
600 return ssh_sync(url, dest, key);
601 }
602 if !dest.join(".git").exists() {
609 let key = ssh_key.map(|k| k.to_path_buf()).or_else(nornir_ssh_key_path);
610 if let (Some(ssh_url), Some(key)) = (https_to_ssh(url), key.as_ref()) {
611 match ssh_sync(&ssh_url, dest, key) {
612 Ok(sha) => return Ok(sha),
613 Err(e) => {
614 eprintln!(
615 "nornir-gitio: SSH-first clone of `{ssh_url}` failed ({e:#}); \
616 falling back to `{url}`"
617 );
618 if !dest.join(".git").exists() {
621 let _ = std::fs::remove_dir_all(dest);
622 }
623 }
624 }
625 }
626 }
627 if dest.join(".git").exists() {
628 fetch_repo(dest)?;
629 } else {
630 clone_repo(url, dest)?;
631 }
632 head_sha(dest)
633}
634
635#[derive(Debug, Clone, PartialEq, Eq)]
637pub struct LocalPushReport {
638 pub sha: String,
640 pub branch: String,
642 pub created: bool,
644}
645
646pub fn push_to_local_remote(src: &Path, remote: &str) -> Result<LocalPushReport> {
663 let remote = remote.strip_prefix("file://").unwrap_or(remote);
665 if is_ssh_url(remote) || remote.starts_with("http://") || remote.starts_with("https://") {
666 bail!(
667 "in-process push is only supported for LOCAL/file git remotes — gix (0.84) has no \
668 send-pack for `{remote}`. Push the warehouse mirror out-of-band, or use a local remote."
669 );
670 }
671 let remote_path = Path::new(remote);
672 if !remote_path.exists() {
673 bail!("remote warehouse path `{remote}` does not exist (open the remote warehouse first)");
674 }
675
676 let src_repo = gix::open(src).with_context(|| format!("gix::open src {}", src.display()))?;
677 let mut remote_repo =
678 gix::open(remote_path).with_context(|| format!("gix::open remote {remote}"))?;
679 let _ = remote_repo.committer_or_set_generic_fallback();
684
685 let branch = head_branch(src)?;
686 let new_sha = head_sha(src)?;
687 let new_oid =
688 gix::ObjectId::from_hex(new_sha.as_bytes()).with_context(|| format!("parse {new_sha}"))?;
689 let full = format!("refs/heads/{branch}");
690
691 let old_oid: Option<gix::ObjectId> = remote_repo
693 .try_find_reference(full.as_str())
694 .with_context(|| format!("look up {full} on remote"))?
695 .map(|r| r.into_fully_peeled_id().map(|id| id.detach()))
696 .transpose()
697 .with_context(|| format!("peel {full} on remote"))?;
698
699 if let Some(old) = old_oid {
701 if old != new_oid && !is_ancestor(&src_repo, old, new_oid) {
702 bail!(
703 "non-fast-forward push to `{remote}`: remote `{branch}` is at {old} which is not an \
704 ancestor of {new_sha} — the remote advanced. Re-open (fetch) the remote warehouse \
705 to reconcile before pushing; refusing to clobber."
706 );
707 }
708 }
709
710 copy_objects_union(&src.join(".git/objects"), &remote_path.join(".git/objects"))
712 .context("transfer git objects to remote")?;
713
714 update_remote_branch(&remote_repo, &full, new_oid, old_oid)
717 .with_context(|| format!("fast-forward {full} on remote"))?;
718
719 Ok(LocalPushReport { sha: new_sha, branch, created: old_oid.is_none() })
720}
721
722fn is_ancestor(repo: &gix::Repository, ancestor: gix::ObjectId, tip: gix::ObjectId) -> bool {
727 if ancestor == tip {
728 return true;
729 }
730 let Ok(walk) = repo.rev_walk(Some(tip)).all() else {
731 return false;
732 };
733 for info in walk {
734 match info {
735 Ok(info) if info.id == ancestor => return true,
736 Ok(_) => {}
737 Err(_) => return false,
738 }
739 }
740 false
741}
742
743fn update_remote_branch(
747 repo: &gix::Repository,
748 full: &str,
749 new: gix::ObjectId,
750 prev: Option<gix::ObjectId>,
751) -> Result<()> {
752 use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
753 use gix::refs::{FullName, Target};
754
755 let name: FullName = full.try_into().map_err(|e| anyhow!("invalid ref `{full}`: {e}"))?;
756 let expected = match prev {
757 Some(old) => PreviousValue::MustExistAndMatch(Target::Object(old)),
758 None => PreviousValue::MustNotExist,
759 };
760 let edit = RefEdit {
761 change: Change::Update {
762 log: LogChange {
763 mode: RefLog::AndReference,
764 force_create_reflog: false,
765 message: "nornir: warehouse republish (push)".into(),
766 },
767 expected,
768 new: Target::Object(new),
769 },
770 name,
771 deref: false,
772 };
773 repo.edit_references(vec![edit]).context("edit remote branch ref")?;
774 Ok(())
775}
776
777fn copy_objects_union(src_objects: &Path, dst_objects: &Path) -> Result<()> {
782 let mut stack = vec![src_objects.to_path_buf()];
783 while let Some(dir) = stack.pop() {
784 let Ok(entries) = std::fs::read_dir(&dir) else { continue };
785 for e in entries.flatten() {
786 let p = e.path();
787 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
788 let rel = p.strip_prefix(src_objects).unwrap_or(&p);
789 if is_dir {
790 if rel == Path::new("info") {
792 continue;
793 }
794 stack.push(p);
795 continue;
796 }
797 let dst = dst_objects.join(rel);
798 if dst.exists() {
799 continue; }
801 if let Some(parent) = dst.parent() {
802 std::fs::create_dir_all(parent)
803 .with_context(|| format!("mkdir {}", parent.display()))?;
804 }
805 std::fs::copy(&p, &dst)
806 .with_context(|| format!("copy {} → {}", p.display(), dst.display()))?;
807 }
808 }
809 Ok(())
810}
811
812pub fn https_to_ssh(url: &str) -> Option<String> {
817 let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?;
818 let (host, path) = rest.split_once('/')?;
819 let host = host.trim();
820 let path = path.trim().trim_matches('/');
821 if host.is_empty() || path.is_empty() {
822 return None;
823 }
824 let path = path.strip_suffix(".git").unwrap_or(path);
825 Some(format!("git@{host}:{path}.git"))
826}
827
828fn add_dir_recursive(
831 repo: &gix::Repository,
832 editor: &mut gix::object::tree::Editor<'_>,
833 repo_root: &Path,
834 dir: &Path,
835) -> Result<()> {
836 use gix::object::tree::EntryKind;
837
838 let mut entries: Vec<_> = std::fs::read_dir(dir)
839 .with_context(|| format!("read_dir {}", dir.display()))?
840 .collect::<std::io::Result<Vec<_>>>()
841 .with_context(|| format!("iterate {}", dir.display()))?;
842 entries.sort_by_key(|e| e.file_name());
844
845 for entry in entries {
846 let path = entry.path();
847 let name = entry.file_name();
848 if name == ".git" {
849 continue;
850 }
851 let meta = std::fs::symlink_metadata(&path)
852 .with_context(|| format!("stat {}", path.display()))?;
853 let ft = meta.file_type();
854 if ft.is_dir() {
855 add_dir_recursive(repo, editor, repo_root, &path)?;
856 continue;
857 }
858 let rela = path
859 .strip_prefix(repo_root)
860 .expect("path is under repo_root");
861 let rela = gix::path::into_bstr(rela).into_owned();
862
863 let (bytes, kind): (Vec<u8>, EntryKind) = if ft.is_symlink() {
864 let target = std::fs::read_link(&path)
865 .with_context(|| format!("readlink {}", path.display()))?;
866 (gix::path::into_bstr(target).into_owned().into(), EntryKind::Link)
867 } else {
868 let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
869 #[cfg(unix)]
870 let kind = {
871 use std::os::unix::fs::PermissionsExt;
872 if meta.permissions().mode() & 0o111 != 0 {
873 EntryKind::BlobExecutable
874 } else {
875 EntryKind::Blob
876 }
877 };
878 #[cfg(not(unix))]
879 let kind = EntryKind::Blob;
880 (bytes, kind)
881 };
882
883 let blob = repo.write_blob(&bytes).context("write blob")?;
884 editor
885 .upsert(rela.as_ref() as &gix::bstr::BStr, kind, blob.detach())
886 .with_context(|| format!("tree upsert {rela}"))?;
887 }
888 Ok(())
889}
890
891#[cfg(test)]
892mod ssh_first_tests {
893 use super::*;
894
895 #[test]
899 fn https_rewrites_to_scp_like_ssh_and_is_recognised() {
900 let cases = [
901 ("https://codeberg.org/nordisk/korp", "git@codeberg.org:nordisk/korp.git"),
902 ("https://codeberg.org/nordisk/ordning.git", "git@codeberg.org:nordisk/ordning.git"),
903 ("https://codeberg.org/nordisk/knut/", "git@codeberg.org:nordisk/knut.git"),
904 ("http://github.com/rustsec/advisory-db", "git@github.com:rustsec/advisory-db.git"),
905 ];
906 for (https, want) in cases {
907 let got = https_to_ssh(https).unwrap_or_else(|| panic!("no rewrite for {https}"));
908 assert_eq!(got, want, "rewrite of {https}");
909 assert!(is_ssh_url(&got), "rewritten `{got}` must be SSH-shaped");
910 }
911 }
912
913 #[test]
916 fn non_https_or_pathless_urls_do_not_rewrite() {
917 for url in [
918 "git@codeberg.org:nordisk/korp.git", "ssh://git@host/owner/repo.git", "https://codeberg.org", "https://codeberg.org/", "/local/path/repo", ] {
924 assert!(https_to_ssh(url).is_none(), "{url} must not rewrite");
925 }
926 }
927
928 #[test]
931 fn dep_clone_base_default_is_ssh() {
932 if std::env::var("NORNIR_DEP_CLONE_BASE").is_err() {
934 let base = "git@codeberg.org:nordisk";
938 assert!(is_ssh_url(&format!("{base}/nornir-orch")), "`{base}/repo` must be SSH");
939 }
940 }
941}
942
943#[cfg(test)]
944mod push_tests {
945 use super::*;
946
947 fn seed(root: &Path, body: &str) -> String {
948 init(root).unwrap();
949 std::fs::write(root.join("data.txt"), body).unwrap();
950 commit_all(root, "seed").unwrap()
951 }
952
953 #[test]
957 fn push_local_fast_forwards_and_a_fresh_clone_sees_it() {
958 let td = tempfile::tempdir().unwrap();
959 let remote = td.path().join("remote");
960 seed(&remote, "v1\n");
961
962 let src = td.path().join("clone");
964 clone_or_fetch(remote.to_str().unwrap(), &src, None).unwrap();
965 std::fs::write(src.join("data.txt"), "v2\n").unwrap();
966 let new_sha = commit_all(&src, "v2").unwrap();
967
968 let report = push_to_local_remote(&src, remote.to_str().unwrap()).unwrap();
970 assert_eq!(report.sha, new_sha, "remote branch now at the pushed commit");
971 assert!(!report.created, "the branch already existed → fast-forward update");
972
973 assert_eq!(head_sha(&remote).unwrap(), new_sha, "remote HEAD ref fast-forwarded");
975 let fresh = td.path().join("fresh");
976 clone_or_fetch(remote.to_str().unwrap(), &fresh, None).unwrap();
977 assert_eq!(std::fs::read_to_string(fresh.join("data.txt")).unwrap(), "v2\n");
978 }
979
980 #[test]
983 fn push_local_refuses_non_fast_forward() {
984 let td = tempfile::tempdir().unwrap();
985 let remote = td.path().join("remote");
986 seed(&remote, "v1\n");
987
988 let src = td.path().join("clone");
989 clone_or_fetch(remote.to_str().unwrap(), &src, None).unwrap();
990
991 std::fs::write(src.join("data.txt"), "clone-side\n").unwrap();
994 commit_all(&src, "clone edit").unwrap();
995 std::fs::write(remote.join("data.txt"), "remote-side\n").unwrap();
996 let remote_sha = commit_all(&remote, "remote edit").unwrap();
997
998 let err = push_to_local_remote(&src, remote.to_str().unwrap()).unwrap_err().to_string();
999 assert!(err.contains("non-fast-forward"), "clear non-ff error: {err}");
1000 assert_eq!(head_sha(&remote).unwrap(), remote_sha, "remote ref untouched on non-ff");
1002 }
1003
1004 #[test]
1006 fn push_local_rejects_network_remotes() {
1007 let td = tempfile::tempdir().unwrap();
1008 let src = td.path().join("clone");
1009 seed(&src, "v1\n");
1010 for url in ["https://codeberg.org/nordisk/warehouse.git", "git@codeberg.org:nordisk/warehouse.git"] {
1011 let err = push_to_local_remote(&src, url).unwrap_err().to_string();
1012 assert!(err.contains("LOCAL/file"), "network remote rejected with a clear reason: {err}");
1013 }
1014 }
1015}
1016
1017#[cfg(test)]
1018mod freshness_tests {
1019 use super::*;
1020
1021 fn fixture() -> (tempfile::TempDir, PathBuf) {
1024 let td = tempfile::tempdir().expect("tempdir");
1025 let root = td.path().to_path_buf();
1026 init(&root).expect("git init");
1027 std::fs::write(root.join("README.md"), b"hello\n").expect("write README");
1028 commit_all(&root, "initial").expect("commit");
1029 (td, root)
1030 }
1031
1032 #[test]
1037 fn worktree_edit_changes_digest_and_dirty_but_not_head_sha() {
1038 let (_td, root) = fixture();
1039
1040 let clean = worktree_freshness(&root).expect("freshness clean");
1042 assert!(!clean.dirty, "freshly committed tree must read clean");
1043 assert_eq!(clean.digest, CLEAN_WORKTREE_DIGEST, "clean ⇒ sentinel digest");
1044 let sha_before = head_sha(&root).expect("head sha before");
1045
1046 std::fs::write(root.join("README.md"), b"hello, dirty world\n").expect("modify");
1048 let modified = worktree_freshness(&root).expect("freshness after modify");
1049 assert!(modified.dirty, "an uncommitted edit must read dirty");
1050 assert_ne!(modified.digest, clean.digest, "edit must CHANGE the digest");
1051 assert_ne!(modified.digest, CLEAN_WORKTREE_DIGEST, "dirty ⇒ not the sentinel");
1052
1053 let sha_after = head_sha(&root).expect("head sha after");
1055 assert_eq!(
1056 sha_before, sha_after,
1057 "editing the working tree must NOT change HEAD — so the SHA cannot \
1058 detect staleness; only the digest can"
1059 );
1060
1061 std::fs::write(root.join("NEW.txt"), b"brand new\n").expect("add untracked");
1063 let added = worktree_freshness(&root).expect("freshness after add");
1064 assert!(added.dirty, "an untracked file must read dirty");
1065 assert_ne!(added.digest, modified.digest, "adding a file must CHANGE the digest again");
1066 assert_eq!(head_sha(&root).expect("head sha"), sha_before, "still no HEAD movement");
1067 }
1068
1069 #[test]
1073 fn clean_digest_is_stable_and_matches_sentinel() {
1074 let (_td, root) = fixture();
1075 let a = worktree_freshness(&root).expect("a");
1076 let b = worktree_freshness(&root).expect("b");
1077 assert_eq!(a, b, "two reads of an untouched tree must agree");
1078 assert_eq!(a.digest, CLEAN_WORKTREE_DIGEST);
1079 assert!(!a.dirty);
1080 }
1081}