use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::error::{MindError, Result};
use crate::source::Pin;
static NONINTERACTIVE: AtomicBool = AtomicBool::new(false);
pub fn set_noninteractive(on: bool) {
NONINTERACTIVE.store(on, Ordering::Relaxed);
}
fn noninteractive_env_pairs(base_ssh: &str) -> [(&'static str, String); 2] {
[
("GIT_TERMINAL_PROMPT", "0".to_string()),
(
"GIT_SSH_COMMAND",
format!("{base_ssh} -o BatchMode=yes -o ConnectTimeout=10"),
),
]
}
fn apply_noninteractive_env(cmd: &mut Command) {
if !NONINTERACTIVE.load(Ordering::Relaxed) {
return;
}
let base = std::env::var("GIT_SSH_COMMAND").unwrap_or_else(|_| "ssh".to_string());
for (k, v) in noninteractive_env_pairs(&base) {
cmd.env(k, v);
}
}
fn run(url: &str, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
let mut cmd = Command::new("git");
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd.args(args);
apply_noninteractive_env(&mut cmd);
let output = match cmd.output() {
Ok(o) => o,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(MindError::GitNotFound),
Err(e) => {
return Err(MindError::Git {
url: url.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
status: None,
stderr: e.to_string(),
});
}
};
if !output.status.success() {
return Err(MindError::Git {
url: url.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
status: Some(output.status),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn clone_at(url: &str, dest: &Path, pin: &Pin) -> Result<()> {
let dest_str = dest.to_string_lossy().into_owned();
match pin {
Pin::DefaultBranch => {
run(url, None, &["clone", "--depth", "1", url, &dest_str])?;
}
Pin::FollowBranch(branch) => {
run(
url,
None,
&["clone", "--depth", "1", "--branch", branch, url, &dest_str],
)?;
}
Pin::Tag(tag) => {
run(
url,
None,
&["clone", "--depth", "1", "--branch", tag, url, &dest_str],
)?;
}
Pin::Ref(sha) => {
run(url, None, &["clone", url, &dest_str])?;
run(url, Some(dest), &["checkout", sha])?;
}
}
Ok(())
}
pub fn clone(url: &str, dest: &Path) -> Result<()> {
clone_at(url, dest, &Pin::DefaultBranch)
}
pub fn sync_to_pin(url: &str, dir: &Path, pin: &Pin) -> Result<()> {
match pin {
Pin::DefaultBranch => {
run(url, Some(dir), &["fetch", "--depth", "1", "origin"])?;
let head = run(url, Some(dir), &["rev-parse", "origin/HEAD"]).or_else(|_| {
run(url, Some(dir), &["rev-parse", "FETCH_HEAD"])
})?;
run(url, Some(dir), &["reset", "--hard", &head])?;
}
Pin::FollowBranch(branch) => {
run(url, Some(dir), &["fetch", "--depth", "1", "origin", branch])?;
run(url, Some(dir), &["reset", "--hard", "FETCH_HEAD"])?;
}
Pin::Tag(tag) => {
run(
url,
Some(dir),
&[
"fetch",
"--force",
"origin",
&format!("+refs/tags/{tag}:refs/tags/{tag}"),
],
)?;
run(url, Some(dir), &["reset", "--hard", tag])?;
}
Pin::Ref(sha) => {
run(url, Some(dir), &["fetch", "origin"])?;
run(url, Some(dir), &["reset", "--hard", sha])?;
}
}
Ok(())
}
pub fn head_commit(url: &str, dir: &Path) -> Result<String> {
run(url, Some(dir), &["rev-parse", "HEAD"])
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[test]
fn noninteractive_env_disables_git_and_ssh_prompts() {
let pairs = noninteractive_env_pairs("ssh");
let map: std::collections::HashMap<_, _> = pairs.iter().cloned().collect();
assert_eq!(
map.get("GIT_TERMINAL_PROMPT").map(String::as_str),
Some("0")
);
let ssh = map.get("GIT_SSH_COMMAND").expect("GIT_SSH_COMMAND set");
assert!(
ssh.contains("BatchMode=yes"),
"ssh must be BatchMode: {ssh}"
);
assert!(ssh.starts_with("ssh "), "base ssh command preserved: {ssh}");
let custom = noninteractive_env_pairs("ssh -i /my/key");
let ssh2 = &custom[1].1;
assert!(
ssh2.starts_with("ssh -i /my/key ") && ssh2.contains("BatchMode=yes"),
"custom base ssh command must be preserved and wrapped: {ssh2}"
);
}
#[test]
fn set_noninteractive_toggles_the_flag() {
set_noninteractive(true);
assert!(NONINTERACTIVE.load(Ordering::Relaxed));
set_noninteractive(false);
assert!(!NONINTERACTIVE.load(Ordering::Relaxed));
}
fn tmpdir(tag: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-git-test-{}-{}-{n}", std::process::id(), tag));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed in {dir:?}");
}
fn make_remote(base: &Path) -> (PathBuf, String, String, String) {
let remote = base.join("remote");
fs::create_dir_all(&remote).unwrap();
git(&remote, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&remote, &["config", "user.email", "t@t"]);
git(&remote, &["config", "user.name", "t"]);
fs::write(remote.join("file.txt"), "version A").unwrap();
git(&remote, &["add", "file.txt"]);
git(&remote, &["commit", "-qm", "commit A"]);
let sha_a = read_head(&remote);
git(&remote, &["tag", "v1.0"]);
fs::write(remote.join("file.txt"), "version B").unwrap();
git(&remote, &["commit", "-aqm", "commit B"]);
let sha_b = read_head(&remote);
git(&remote, &["branch", "stable", &sha_a]);
fs::write(remote.join("file.txt"), "version C").unwrap();
git(&remote, &["commit", "-aqm", "commit C"]);
let sha_c = read_head(&remote);
(remote, sha_a, sha_b, sha_c)
}
fn read_head(dir: &Path) -> String {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn read_file(dir: &Path, name: &str) -> String {
fs::read_to_string(dir.join(name)).unwrap()
}
fn cleanup(dir: &Path) {
let _ = fs::remove_dir_all(dir);
}
#[test]
fn clone_at_default_branch_checks_out_tip() {
let base = tmpdir("default");
let (remote, _a, _b, sha_c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::DefaultBranch).expect("clone_at default");
let got = read_head(&dest);
assert_eq!(got, sha_c, "default branch clone should be at tip (C)");
assert_eq!(read_file(&dest, "file.txt"), "version C");
cleanup(&base);
}
#[test]
fn clone_at_follow_branch_checks_out_that_branch() {
let base = tmpdir("follow");
let (remote, sha_a, _b, _c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::FollowBranch("stable".into())).expect("clone_at stable");
let got = read_head(&dest);
assert_eq!(got, sha_a, "follow-branch=stable should be at commit A");
assert_eq!(read_file(&dest, "file.txt"), "version A");
cleanup(&base);
}
#[test]
fn clone_at_tag_checks_out_tag() {
let base = tmpdir("tag");
let (remote, sha_a, _b, _c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::Tag("v1.0".into())).expect("clone_at tag");
let got = read_head(&dest);
assert_eq!(got, sha_a, "pin-tag=v1.0 should be at commit A (tagged)");
assert_eq!(read_file(&dest, "file.txt"), "version A");
cleanup(&base);
}
#[test]
fn clone_at_ref_checks_out_specific_commit() {
let base = tmpdir("ref");
let (remote, _a, sha_b, _c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::Ref(sha_b.clone())).expect("clone_at ref");
let got = read_head(&dest);
assert_eq!(got, sha_b, "pin-ref should land on commit B");
assert_eq!(read_file(&dest, "file.txt"), "version B");
cleanup(&base);
}
#[test]
fn sync_follow_branch_moves_to_branch_tip() {
let base = tmpdir("sync-follow");
let (remote, sha_a, _b, sha_c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::FollowBranch("stable".into())).unwrap();
assert_eq!(read_head(&dest), sha_a);
git(&remote, &["branch", "-f", "stable", &sha_c]);
sync_to_pin(&url, &dest, &Pin::FollowBranch("stable".into())).unwrap();
assert_eq!(read_head(&dest), sha_c, "stable after advance should be C");
cleanup(&base);
}
#[test]
fn sync_pin_ref_stays_fixed() {
let base = tmpdir("sync-ref");
let (remote, _a, sha_b, sha_c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::Ref(sha_b.clone())).unwrap();
assert_eq!(read_head(&dest), sha_b);
fs::write(remote.join("file.txt"), "version D").unwrap();
git(&remote, &["commit", "-aqm", "commit D"]);
let _ = sha_c;
sync_to_pin(&url, &dest, &Pin::Ref(sha_b.clone())).unwrap();
assert_eq!(read_head(&dest), sha_b, "pin-ref must stay fixed on sync");
cleanup(&base);
}
#[test]
fn sync_pin_tag_moves_when_tag_is_moved() {
let base = tmpdir("sync-tag");
let (remote, sha_a, _b, sha_c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::Tag("v1.0".into())).unwrap();
assert_eq!(read_head(&dest), sha_a);
git(&remote, &["tag", "-f", "v1.0", &sha_c]);
sync_to_pin(&url, &dest, &Pin::Tag("v1.0".into())).unwrap();
assert_eq!(
read_head(&dest),
sha_c,
"pin-tag with moved tag should advance to C"
);
cleanup(&base);
}
#[test]
fn sync_pin_tag_stays_when_tag_is_not_moved() {
let base = tmpdir("sync-tag-fixed");
let (remote, sha_a, _b, _c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::Tag("v1.0".into())).unwrap();
assert_eq!(read_head(&dest), sha_a);
fs::write(remote.join("file.txt"), "version D").unwrap();
git(&remote, &["commit", "-aqm", "commit D"]);
sync_to_pin(&url, &dest, &Pin::Tag("v1.0".into())).unwrap();
assert_eq!(
read_head(&dest),
sha_a,
"pin-tag with unmoved tag should stay at A"
);
cleanup(&base);
}
}