Skip to main content

sui_eval/
git.rs

1//! Pure-Rust git helpers using `gix` (gitoxide).
2//!
3//! Replaces all `Command::new("git")` process spawning with in-process
4//! library calls. Every public function in this module corresponds to a
5//! git CLI operation that was previously shelled out to.
6
7use std::path::Path;
8
9/// Clone a remote repository into `dest`.
10///
11/// Parameters:
12/// - `url`: remote URL (HTTPS or file://)
13/// - `dest`: target directory (must not exist or be empty)
14/// - `branch`: optional branch/ref name to checkout after clone
15/// - `shallow`: when true, fetches only the latest commit (like `--depth 1`)
16/// - `submodules`: when true, recursively initializes submodules
17pub fn clone(
18    url: &str,
19    dest: &Path,
20    branch: Option<&str>,
21    shallow: bool,
22    submodules: bool,
23) -> Result<gix::Repository, String> {
24    // Use git CLI for clone operations. gix's edition-2024 fork panics
25    // on background threads during fetch for certain refspec patterns.
26    // git CLI is reliable and clone only happens on cache miss.
27    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            // Retry without shallow (some transports don't support it)
48            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
78/// Recursively initialize and update all submodules.
79fn 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
107/// Checkout a specific revision (commit SHA) in an already-cloned repo.
108///
109/// This detaches HEAD at the given commit and resets the working tree.
110pub 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    // Detach HEAD at the commit by writing HEAD directly
127    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    // Remove existing working tree files (except .git)
136    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 contents to the working directory
150    write_tree_to_workdir(&repo, &tree, workdir)?;
151
152    Ok(())
153}
154
155/// Recursively write a tree's contents to a directory.
156fn 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                // Symlink (git mode 120000): the blob's bytes ARE the link
191                // target. CppNix's fetchGit materialises these as real
192                // symlinks and NARs them as `type=symlink` nodes; skipping
193                // them (the prior behaviour) dropped every symlinked file
194                // from the tree, so the copy-to-store NAR — and every
195                // dependent output path — diverged from nix.
196                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                    // Non-unix: fall back to writing the target text (best
212                    // effort; the fleet's fetch parity target is unix).
213                    std::fs::write(&path, &obj.data)
214                        .map_err(|e| format!("write symlink-as-file {}: {e}", path.display()))?;
215                }
216            }
217            _ => {
218                // Skip gitlinks (submodule commits); submodule content is
219                // materialised separately via init_submodules_recursive.
220            }
221        }
222    }
223    Ok(())
224}
225
226/// Get the full commit hash of HEAD (equivalent to `git rev-parse HEAD`).
227pub 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/// Fingerprint a local flake directory's git state for eval-cache keying.
237///
238/// Returns `Some(head_rev)` iff `dir` is a git worktree with NO uncommitted
239/// changes to TRACKED files (clean) — the committed rev then fully identifies
240/// everything a git flake's `self.rev`/`self.lastModified` exposes AND the
241/// tree's tracked content (a git flake's source excludes untracked files, so
242/// they are correctly ignored here, matching nix's flake-source semantics).
243///
244/// Returns `None` when the tree is DIRTY (its `self.dirtyShortRev` hashes the
245/// whole working tree — content the eval-cache key cannot cheaply capture) or
246/// is not a git repo. The caller treats `None` as "do not cache", so a stale
247/// self-derived byte (e.g. `darwin-system-…dirty` served after a commit) can
248/// never be returned from the cache across a git-state change.
249///
250/// Uses `git diff --quiet HEAD` (exit 0 = clean, 1 = dirty, other = not-a-repo
251/// / no-HEAD) — the cheapest reliable tracked-changes check.
252#[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; // dirty tracked changes, not a repo, or no HEAD → don't cache
262    }
263    head_rev(dir).ok()
264}
265
266/// Count the number of commits reachable from HEAD
267/// (equivalent to `git rev-list --count HEAD`).
268pub 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
289/// Get the committer timestamp of HEAD in seconds since epoch
290/// (equivalent to `git log -1 --format=%ct`).
291pub 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
306/// List remote refs and find the commit SHA for a given ref name
307/// (equivalent to `git ls-remote <url> <ref>`).
308///
309/// Searches for the ref in `refs/heads/<ref_name>`, `refs/tags/<ref_name>`,
310/// and as a direct match.
311pub fn ls_remote(url: &str, ref_name: &str) -> Result<String, String> {
312    // For file:// URLs, open the repo directly and read refs.
313    // This avoids the transport layer complexity.
314    if let Some(path) = url.strip_prefix("file://") {
315        return ls_remote_local(Path::new(path), ref_name);
316    }
317
318    // For network URLs, use git CLI (gix's remote connect panics on
319    // background threads in our edition-2024 fork).
320    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    // Parse git ls-remote output: "<sha>\t<refname>\n"
335    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
355/// Extract name and oid from a handshake Ref.
356fn 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
371/// List refs from a local repository by opening it directly.
372fn 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    // Search patterns in priority order
377    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    // Also check HEAD
393    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
403/// Initialize a new bare/non-bare git repository (for test helpers).
404/// Equivalent to `git init -b <branch>`.
405pub 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    // gix::init creates HEAD -> refs/heads/main by default.
410    // If a different branch is requested, update HEAD.
411    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
423/// Create an initial commit in the given repo.
424/// Adds all files in the working directory and commits them.
425pub 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    // Build a tree from the working directory files
436    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    // Check if there is a parent commit
448    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
467/// Build a tree object from all files in the working directory.
468fn 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
486/// Recursively add files from a directory to a tree editor.
487fn 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        // Skip .git directory
505        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            // Convert path to forward-slash string for gix's ToComponents
533            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
545/// Set a config key in the repo's local config.
546///
547/// Appends to the git config file directly. This is a simple append-based
548/// writer that works correctly because git reads the last value for duplicate keys.
549pub fn set_config(repo: &gix::Repository, key: &str, value: &str) -> Result<(), String> {
550    let config_path = repo.git_dir().join("config");
551
552    // Parse key as section.name or section.subsection.name
553    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    // Build the INI section header
567    let header = if let Some(sub) = subsection {
568        format!("[{section_name} \"{sub}\"]")
569    } else {
570        format!("[{section_name}]")
571    };
572
573    // Read existing config or start fresh
574    let mut content = std::fs::read_to_string(&config_path).unwrap_or_default();
575
576    // Append section and key
577    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        // Build the repo with the `git` CLI — the faithful real-world scenario
626        // (operator flake dirs are git-CLI-managed with a consistent index).
627        // `clean_worktree_rev` uses `git diff --quiet HEAD`, which reads that
628        // index; a gix-created repo can leave a stale index the CLI reads as
629        // dirty (harmless — it would just return None / not-cache — but not the
630        // case under test here).
631        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        // A CI/sandbox may have no `git` on PATH — skip rather than false-fail.
639        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        // `--no-verify`: the pleme-io fleet installs a GLOBAL commit-msg hook
649        // (core.hooksPath) that refuses ~75 placeholder subjects, "initial"
650        // among them. Without this the fixture commit never lands, HEAD has no
651        // commits, and the test dies as `Branch 'refs/heads/main' does not have
652        // any commits` — a failure with nothing to do with the code under test,
653        // reproducible only on a machine carrying the fleet's git config. This
654        // is the escape git provides for exactly this case, and the fleet's own
655        // docs prescribe it for throwaway fixture repos.
656        git(&["commit", "-q", "--no-verify", "-m", "initial"]);
657
658        // Clean worktree → Some(head_rev).
659        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        // Untracked file → still clean (a git flake's source excludes untracked
664        // files, so they do not change self.rev / drvPaths — matching nix).
665        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        // Modify a TRACKED file → dirty → None (do not cache).
670        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        // A non-git directory → None (do not cache).
675        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 first commit
730        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}