1use std::path::Path;
8
9pub fn clone(
18 url: &str,
19 dest: &Path,
20 branch: Option<&str>,
21 shallow: bool,
22 submodules: bool,
23) -> Result<gix::Repository, String> {
24 let mut args = vec!["clone".to_string()];
28 if shallow {
29 args.extend(["--depth".into(), "1".into()]);
30 }
31 if let Some(br) = branch {
32 args.extend(["--branch".into(), br.to_string()]);
33 }
34 args.push(url.to_string());
35 args.push(dest.to_string_lossy().into_owned());
36
37 let status = std::process::Command::new("git")
38 .args(&args)
39 .stdout(std::process::Stdio::null())
40 .stderr(std::process::Stdio::null())
41 .status()
42 .map_err(|e| format!("git clone {url}: {e}"))?;
43
44 if !status.success() {
45 let _ = std::fs::remove_dir_all(dest);
46 if shallow {
47 let mut retry_args = vec!["clone".to_string()];
49 if let Some(br) = branch {
50 retry_args.extend(["--branch".into(), br.to_string()]);
51 }
52 retry_args.push(url.to_string());
53 retry_args.push(dest.to_string_lossy().into_owned());
54 let retry = std::process::Command::new("git")
55 .args(&retry_args)
56 .stdout(std::process::Stdio::null())
57 .stderr(std::process::Stdio::null())
58 .status()
59 .map_err(|e| format!("git clone retry: {e}"))?;
60 if !retry.success() {
61 return Err(format!("git clone {url} failed"));
62 }
63 } else {
64 return Err(format!("git clone {url} failed"));
65 }
66 }
67
68 let repo = gix::open(dest)
69 .map_err(|e| format!("open cloned repo: {e}"))?;
70
71 if submodules {
72 init_submodules_recursive(&repo)?;
73 }
74
75 Ok(repo)
76}
77
78fn init_submodules_recursive(repo: &gix::Repository) -> Result<(), String> {
80 let modules = match repo.submodules() {
81 Ok(Some(mods)) => mods,
82 Ok(None) => return Ok(()),
83 Err(e) => return Err(format!("list submodules: {e}")),
84 };
85
86 let workdir = repo
87 .workdir()
88 .ok_or("repo has no worktree")?;
89
90 for sub in modules {
91 let name = sub.name().to_string();
92 let sub_url = match sub.url() {
93 Ok(url) => url.to_bstring().to_string(),
94 Err(e) => return Err(format!("submodule {name} url: {e}")),
95 };
96 let sub_path = sub.path().map_err(|e| format!("submodule {name} path: {e}"))?;
97 let dest = workdir.join(sub_path.to_string());
98
99 if !dest.exists() {
100 clone(&sub_url, &dest, None, false, true)
101 .map_err(|e| format!("clone submodule {name}: {e}"))?;
102 }
103 }
104 Ok(())
105}
106
107pub fn checkout_rev(repo_path: &Path, rev: &str) -> Result<(), String> {
111 let repo = gix::open(repo_path)
112 .map_err(|e| format!("open {}: {e}", repo_path.display()))?;
113
114 let oid = gix::ObjectId::from_hex(rev.as_bytes())
115 .map_err(|e| format!("invalid rev {rev}: {e}"))?;
116
117 let commit = repo
118 .find_object(oid)
119 .map_err(|e| format!("rev {rev} not found: {e}"))?
120 .into_commit();
121
122 let tree = commit
123 .tree()
124 .map_err(|e| format!("tree for {rev}: {e}"))?;
125
126 let head_path = repo.git_dir().join("HEAD");
128 std::fs::write(&head_path, format!("{oid}\n"))
129 .map_err(|e| format!("write HEAD: {e}"))?;
130
131 let workdir = repo
132 .workdir()
133 .ok_or("repo has no worktree")?;
134
135 for entry in std::fs::read_dir(workdir).map_err(|e| format!("read workdir: {e}"))? {
137 let entry = entry.map_err(|e| format!("dir entry: {e}"))?;
138 if entry.file_name() == ".git" {
139 continue;
140 }
141 let path = entry.path();
142 if path.is_dir() {
143 let _ = std::fs::remove_dir_all(&path);
144 } else {
145 let _ = std::fs::remove_file(&path);
146 }
147 }
148
149 write_tree_to_workdir(&repo, &tree, workdir)?;
151
152 Ok(())
153}
154
155fn write_tree_to_workdir(
157 repo: &gix::Repository,
158 tree: &gix::Tree<'_>,
159 dest: &Path,
160) -> Result<(), String> {
161 for entry in tree.iter() {
162 let entry = entry.map_err(|e| format!("tree entry: {e}"))?;
163 let name = entry.filename().to_string();
164 let path = dest.join(&name);
165
166 match entry.mode().kind() {
167 gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => {
168 let obj = repo
169 .find_object(entry.oid())
170 .map_err(|e| format!("find blob {}: {e}", entry.oid()))?;
171 std::fs::write(&path, &obj.data)
172 .map_err(|e| format!("write {}: {e}", path.display()))?;
173
174 #[cfg(unix)]
175 if entry.mode().kind() == gix::objs::tree::EntryKind::BlobExecutable {
176 use std::os::unix::fs::PermissionsExt;
177 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
178 }
179 }
180 gix::objs::tree::EntryKind::Tree => {
181 std::fs::create_dir_all(&path)
182 .map_err(|e| format!("mkdir {}: {e}", path.display()))?;
183 let subtree = repo
184 .find_object(entry.oid())
185 .map_err(|e| format!("find tree {}: {e}", entry.oid()))?
186 .into_tree();
187 write_tree_to_workdir(repo, &subtree, &path)?;
188 }
189 gix::objs::tree::EntryKind::Link => {
190 let obj = repo
197 .find_object(entry.oid())
198 .map_err(|e| format!("find symlink blob {}: {e}", entry.oid()))?;
199 let target = std::path::PathBuf::from(
200 std::str::from_utf8(&obj.data)
201 .map_err(|e| format!("symlink target not utf-8 for {}: {e}", path.display()))?,
202 );
203 #[cfg(unix)]
204 {
205 let _ = std::fs::remove_file(&path);
206 std::os::unix::fs::symlink(&target, &path)
207 .map_err(|e| format!("symlink {} -> {}: {e}", path.display(), target.display()))?;
208 }
209 #[cfg(not(unix))]
210 {
211 std::fs::write(&path, &obj.data)
214 .map_err(|e| format!("write symlink-as-file {}: {e}", path.display()))?;
215 }
216 }
217 _ => {
218 }
221 }
222 }
223 Ok(())
224}
225
226pub fn head_rev(repo_path: &Path) -> Result<String, String> {
228 let repo = gix::open(repo_path)
229 .map_err(|e| format!("open {}: {e}", repo_path.display()))?;
230 let head = repo
231 .head_commit()
232 .map_err(|e| format!("head commit: {e}"))?;
233 Ok(head.id.to_string())
234}
235
236#[must_use]
253pub fn clean_worktree_rev(dir: &Path) -> Option<String> {
254 let status = std::process::Command::new("git")
255 .arg("-C")
256 .arg(dir)
257 .args(["diff", "--quiet", "HEAD"])
258 .status()
259 .ok()?;
260 if !status.success() {
261 return None; }
263 head_rev(dir).ok()
264}
265
266pub fn rev_count(repo_path: &Path) -> Result<i64, String> {
269 let repo = gix::open(repo_path)
270 .map_err(|e| format!("open {}: {e}", repo_path.display()))?;
271 let head = repo
272 .head_commit()
273 .map_err(|e| format!("head commit: {e}"))?;
274
275 let mut count: i64 = 0;
276 let walk = repo
277 .rev_walk([head.id])
278 .all()
279 .map_err(|e| format!("rev walk: {e}"))?;
280
281 for info in walk {
282 let _info = info.map_err(|e| format!("rev walk step: {e}"))?;
283 count += 1;
284 }
285
286 Ok(count)
287}
288
289pub fn head_timestamp(repo_path: &Path) -> Result<i64, String> {
292 let repo = gix::open(repo_path)
293 .map_err(|e| format!("open {}: {e}", repo_path.display()))?;
294 let head = repo
295 .head_commit()
296 .map_err(|e| format!("head commit: {e}"))?;
297 let commit = head
298 .decode()
299 .map_err(|e| format!("decode commit: {e}"))?;
300 let committer = commit
301 .committer()
302 .map_err(|e| format!("parse committer: {e}"))?;
303 Ok(committer.seconds())
304}
305
306pub fn ls_remote(url: &str, ref_name: &str) -> Result<String, String> {
312 if let Some(path) = url.strip_prefix("file://") {
315 return ls_remote_local(Path::new(path), ref_name);
316 }
317
318 let output = std::process::Command::new("git")
321 .args(["ls-remote", url])
322 .output()
323 .map_err(|e| format!("git ls-remote {url}: {e}"))?;
324
325 if !output.status.success() {
326 return Err(format!(
327 "git ls-remote failed for {url}: {}",
328 String::from_utf8_lossy(&output.stderr)
329 ));
330 }
331
332 let stdout = String::from_utf8_lossy(&output.stdout);
333
334 let candidates = [
336 format!("refs/heads/{ref_name}"),
337 format!("refs/tags/{ref_name}"),
338 ref_name.to_string(),
339 ];
340
341 for line in stdout.lines() {
342 let mut parts = line.split('\t');
343 let Some(sha) = parts.next() else { continue };
344 let Some(name) = parts.next() else { continue };
345 for candidate in &candidates {
346 if name == candidate {
347 return Ok(sha.to_string());
348 }
349 }
350 }
351
352 Err(format!("ref {ref_name} not found in remote {url}"))
353}
354
355fn ref_to_name_oid(r: &gix::protocol::handshake::Ref) -> (String, Option<String>) {
357 match r {
358 gix::protocol::handshake::Ref::Direct { full_ref_name, object } => {
359 (full_ref_name.to_string(), Some(object.to_string()))
360 }
361 gix::protocol::handshake::Ref::Symbolic { full_ref_name, object, .. } => {
362 (full_ref_name.to_string(), Some(object.to_string()))
363 }
364 gix::protocol::handshake::Ref::Peeled { full_ref_name, object, .. } => {
365 (full_ref_name.to_string(), Some(object.to_string()))
366 }
367 gix::protocol::handshake::Ref::Unborn { .. } => (String::new(), None),
368 }
369}
370
371fn ls_remote_local(repo_path: &Path, ref_name: &str) -> Result<String, String> {
373 let repo = gix::open(repo_path)
374 .map_err(|e| format!("open {}: {e}", repo_path.display()))?;
375
376 let candidates = [
378 format!("refs/heads/{ref_name}"),
379 format!("refs/tags/{ref_name}"),
380 ref_name.to_string(),
381 ];
382
383 for pattern in &candidates {
384 if let Ok(reference) = repo.find_reference(pattern.as_str()) {
385 let id = reference
386 .into_fully_peeled_id()
387 .map_err(|e| format!("peel ref {pattern}: {e}"))?;
388 return Ok(id.to_string());
389 }
390 }
391
392 if ref_name == "HEAD" {
394 let head = repo
395 .head_id()
396 .map_err(|e| format!("head id: {e}"))?;
397 return Ok(head.to_string());
398 }
399
400 Err(format!("ref {ref_name} not found in remote file://{}", repo_path.display()))
401}
402
403pub fn init_repo(path: &Path, initial_branch: &str) -> Result<gix::Repository, String> {
406 let repo = gix::init(path)
407 .map_err(|e| format!("init {}: {e}", path.display()))?;
408
409 if initial_branch != "main" {
412 let head_path = repo.git_dir().join("HEAD");
413 std::fs::write(
414 &head_path,
415 format!("ref: refs/heads/{initial_branch}\n"),
416 )
417 .map_err(|e| format!("set HEAD to {initial_branch}: {e}"))?;
418 }
419
420 Ok(repo)
421}
422
423pub fn commit_all(
426 repo: &gix::Repository,
427 message: &str,
428 name: &str,
429 email: &str,
430) -> Result<gix::ObjectId, String> {
431 let workdir = repo
432 .workdir()
433 .ok_or("repo has no worktree")?;
434
435 let tree_id = build_tree_from_workdir(repo, workdir)?;
437
438 let time = gix::date::Time::now_local_or_utc();
439 let mut time_buf = gix::date::parse::TimeBuf::default();
440 let sig = gix::actor::Signature {
441 name: name.into(),
442 email: email.into(),
443 time,
444 };
445 let sig_ref = sig.to_ref(&mut time_buf);
446
447 let parent_ids: Vec<gix::ObjectId> = match repo.head_commit() {
449 Ok(c) => vec![c.id],
450 Err(_) => vec![],
451 };
452
453 let commit_id = repo
454 .commit_as(
455 sig_ref,
456 sig_ref,
457 "HEAD",
458 message,
459 tree_id,
460 parent_ids.iter().copied(),
461 )
462 .map_err(|e| format!("commit: {e}"))?;
463
464 Ok(commit_id.detach())
465}
466
467fn build_tree_from_workdir(
469 repo: &gix::Repository,
470 workdir: &Path,
471) -> Result<gix::ObjectId, String> {
472 let empty_tree = repo.empty_tree();
473 let mut editor = repo
474 .edit_tree(empty_tree.id)
475 .map_err(|e| format!("create tree editor: {e}"))?;
476
477 add_files_to_tree(&mut editor, repo, workdir, workdir)?;
478
479 let tree_id = editor
480 .write()
481 .map_err(|e| format!("write tree: {e}"))?;
482
483 Ok(tree_id.detach())
484}
485
486fn add_files_to_tree(
488 editor: &mut gix::object::tree::Editor<'_>,
489 repo: &gix::Repository,
490 base: &Path,
491 dir: &Path,
492) -> Result<(), String> {
493 let entries = std::fs::read_dir(dir)
494 .map_err(|e| format!("read dir {}: {e}", dir.display()))?;
495
496 for entry in entries {
497 let entry = entry.map_err(|e| format!("dir entry: {e}"))?;
498 let path = entry.path();
499 let file_name = entry.file_name();
500 let relative = path
501 .strip_prefix(base)
502 .map_err(|e| format!("strip prefix: {e}"))?;
503
504 if file_name == ".git" {
506 continue;
507 }
508
509 let metadata = entry
510 .metadata()
511 .map_err(|e| format!("metadata {}: {e}", path.display()))?;
512
513 if metadata.is_file() {
514 let data = std::fs::read(&path)
515 .map_err(|e| format!("read {}: {e}", path.display()))?;
516 let blob_id = repo
517 .write_blob(&data)
518 .map_err(|e| format!("write blob {}: {e}", path.display()))?;
519
520 #[cfg(unix)]
521 let mode = {
522 use std::os::unix::fs::PermissionsExt;
523 if metadata.permissions().mode() & 0o111 != 0 {
524 gix::objs::tree::EntryKind::BlobExecutable
525 } else {
526 gix::objs::tree::EntryKind::Blob
527 }
528 };
529 #[cfg(not(unix))]
530 let mode = gix::objs::tree::EntryKind::Blob;
531
532 let relative_str = relative.to_string_lossy().replace('\\', "/");
534 editor
535 .upsert(relative_str.as_str(), mode, blob_id.detach())
536 .map_err(|e| format!("upsert {}: {e}", relative.display()))?;
537 } else if metadata.is_dir() {
538 add_files_to_tree(editor, repo, base, &path)?;
539 }
540 }
541
542 Ok(())
543}
544
545pub fn set_config(repo: &gix::Repository, key: &str, value: &str) -> Result<(), String> {
550 let config_path = repo.git_dir().join("config");
551
552 let parts: Vec<&str> = key.splitn(2, '.').collect();
554 if parts.len() != 2 {
555 return Err(format!("invalid config key: {key}"));
556 }
557
558 let section_name = parts[0];
559 let remaining = parts[1];
560 let (subsection, key_name) = if let Some(dot_pos) = remaining.rfind('.') {
561 (Some(&remaining[..dot_pos]), &remaining[dot_pos + 1..])
562 } else {
563 (None, remaining)
564 };
565
566 let header = if let Some(sub) = subsection {
568 format!("[{section_name} \"{sub}\"]")
569 } else {
570 format!("[{section_name}]")
571 };
572
573 let mut content = std::fs::read_to_string(&config_path).unwrap_or_default();
575
576 if !content.ends_with('\n') && !content.is_empty() {
578 content.push('\n');
579 }
580 content.push_str(&format!("{header}\n\t{key_name} = {value}\n"));
581
582 std::fs::write(&config_path, content)
583 .map_err(|e| format!("write config: {e}"))?;
584
585 Ok(())
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use std::fs;
592
593 fn temp_dir(suffix: &str) -> std::path::PathBuf {
594 std::env::temp_dir().join(format!(
595 "sui_git_test_{suffix}_{}",
596 std::time::SystemTime::now()
597 .duration_since(std::time::UNIX_EPOCH)
598 .unwrap()
599 .as_nanos()
600 ))
601 }
602
603 #[test]
604 fn init_and_commit() {
605 let dir = temp_dir("init_commit");
606 fs::create_dir_all(&dir).unwrap();
607
608 let repo = init_repo(&dir, "main").unwrap();
609 set_config(&repo, "user.email", "test@sui.local").unwrap();
610 set_config(&repo, "user.name", "sui-test").unwrap();
611
612 fs::write(dir.join("README"), "hello").unwrap();
613 let oid = commit_all(&repo, "initial", "sui-test", "test@sui.local").unwrap();
614
615 assert!(!oid.is_null());
616 assert_eq!(head_rev(&dir).unwrap().len(), 40);
617 assert_eq!(rev_count(&dir).unwrap(), 1);
618 assert!(head_timestamp(&dir).unwrap() > 0);
619
620 let _ = fs::remove_dir_all(&dir);
621 }
622
623 #[test]
624 fn clean_worktree_rev_clean_vs_dirty() {
625 let dir = temp_dir("clean_rev");
632 fs::create_dir_all(&dir).unwrap();
633 let git = |args: &[&str]| {
634 std::process::Command::new("git")
635 .arg("-C").arg(&dir).args(args)
636 .output().unwrap()
637 };
638 if git(&["init", "-q"]).status.success() == false {
640 eprintln!("skip clean_worktree_rev test: git init unavailable");
641 let _ = fs::remove_dir_all(&dir);
642 return;
643 }
644 git(&["config", "user.email", "test@sui.local"]);
645 git(&["config", "user.name", "sui-test"]);
646 fs::write(dir.join("flake.nix"), "{ outputs = _: {}; }").unwrap();
647 git(&["add", "-A"]);
648 git(&["commit", "-q", "--no-verify", "-m", "initial"]);
657
658 let rev = clean_worktree_rev(&dir);
660 assert_eq!(rev.as_deref(), Some(head_rev(&dir).unwrap().as_str()),
661 "a clean git worktree must fingerprint to its HEAD rev");
662
663 fs::write(dir.join("result"), "untracked-artifact").unwrap();
666 assert!(clean_worktree_rev(&dir).is_some(),
667 "untracked files must NOT mark the tree dirty (nix excludes them)");
668
669 fs::write(dir.join("flake.nix"), "{ outputs = _: { x = 1; }; }").unwrap();
671 assert!(clean_worktree_rev(&dir).is_none(),
672 "an uncommitted change to a tracked file must return None (dirty → don't cache)");
673
674 let plain = temp_dir("clean_rev_plain");
676 fs::create_dir_all(&plain).unwrap();
677 assert!(clean_worktree_rev(&plain).is_none(),
678 "a non-git directory must return None");
679
680 let _ = fs::remove_dir_all(&dir);
681 let _ = fs::remove_dir_all(&plain);
682 }
683
684 #[test]
685 fn clone_local_repo() {
686 let src = temp_dir("clone_src");
687 fs::create_dir_all(&src).unwrap();
688
689 let repo = init_repo(&src, "main").unwrap();
690 set_config(&repo, "user.email", "test@sui.local").unwrap();
691 set_config(&repo, "user.name", "sui-test").unwrap();
692 fs::write(src.join("file.txt"), "content").unwrap();
693 commit_all(&repo, "first", "sui-test", "test@sui.local").unwrap();
694
695 let dest = temp_dir("clone_dest");
696 let cloned = clone(
697 &format!("file://{}", src.display()),
698 &dest,
699 None,
700 false,
701 false,
702 )
703 .unwrap();
704
705 assert!(dest.join("file.txt").exists());
706 assert_eq!(
707 head_rev(&dest).unwrap(),
708 head_rev(&src).unwrap()
709 );
710
711 drop(cloned);
712 let _ = fs::remove_dir_all(&src);
713 let _ = fs::remove_dir_all(&dest);
714 }
715
716 #[test]
717 fn checkout_rev_works() {
718 let dir = temp_dir("checkout");
719 fs::create_dir_all(&dir).unwrap();
720
721 let repo = init_repo(&dir, "main").unwrap();
722 set_config(&repo, "user.email", "test@sui.local").unwrap();
723 set_config(&repo, "user.name", "sui-test").unwrap();
724 fs::write(dir.join("a.txt"), "first").unwrap();
725 let first_oid = commit_all(&repo, "first", "sui-test", "test@sui.local").unwrap();
726 fs::write(dir.join("b.txt"), "second").unwrap();
727 commit_all(&repo, "second", "sui-test", "test@sui.local").unwrap();
728
729 checkout_rev(&dir, &first_oid.to_string()).unwrap();
731 assert!(!dir.join("b.txt").exists());
732
733 let _ = fs::remove_dir_all(&dir);
734 }
735
736 #[test]
737 fn rev_count_multiple_commits() {
738 let dir = temp_dir("revcount");
739 fs::create_dir_all(&dir).unwrap();
740
741 let repo = init_repo(&dir, "main").unwrap();
742 set_config(&repo, "user.email", "test@sui.local").unwrap();
743 set_config(&repo, "user.name", "sui-test").unwrap();
744
745 fs::write(dir.join("a"), "1").unwrap();
746 commit_all(&repo, "one", "sui-test", "test@sui.local").unwrap();
747 fs::write(dir.join("b"), "2").unwrap();
748 commit_all(&repo, "two", "sui-test", "test@sui.local").unwrap();
749 fs::write(dir.join("c"), "3").unwrap();
750 commit_all(&repo, "three", "sui-test", "test@sui.local").unwrap();
751
752 assert_eq!(rev_count(&dir).unwrap(), 3);
753
754 let _ = fs::remove_dir_all(&dir);
755 }
756
757 #[test]
758 fn ls_remote_local() {
759 let src = temp_dir("lsremote");
760 fs::create_dir_all(&src).unwrap();
761
762 let repo = init_repo(&src, "main").unwrap();
763 set_config(&repo, "user.email", "test@sui.local").unwrap();
764 set_config(&repo, "user.name", "sui-test").unwrap();
765 fs::write(src.join("f"), "data").unwrap();
766 commit_all(&repo, "init", "sui-test", "test@sui.local").unwrap();
767
768 let sha = ls_remote(&format!("file://{}", src.display()), "main").unwrap();
769 assert_eq!(sha.len(), 40);
770 assert_eq!(sha, head_rev(&src).unwrap());
771
772 let _ = fs::remove_dir_all(&src);
773 }
774}