use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::error::{MindError, Result};
use crate::source::Pin;
pub fn validate_ref_value(value: &str) -> Result<()> {
if value.is_empty() {
return Err(MindError::InvalidRef {
value: value.to_string(),
reason: "ref value must not be empty".to_string(),
});
}
if value.starts_with('-') {
return Err(MindError::InvalidRef {
value: value.to_string(),
reason: "ref value must not begin with '-' (looks like a git option)".to_string(),
});
}
if value.chars().any(|c| c.is_ascii_control()) {
return Err(MindError::InvalidRef {
value: value.to_string(),
reason: "ref value must not contain control characters".to_string(),
});
}
if value.chars().any(|c| c.is_ascii_whitespace()) {
return Err(MindError::InvalidRef {
value: value.to_string(),
reason: "ref value must not contain whitespace".to_string(),
});
}
if value.contains("..") {
return Err(MindError::InvalidRef {
value: value.to_string(),
reason: "ref value must not contain '..' (ambiguous git range syntax)".to_string(),
});
}
Ok(())
}
pub fn is_auth_failure(err: &MindError) -> bool {
let stderr = match err {
MindError::Git { stderr, .. } => stderr.to_ascii_lowercase(),
_ => return false,
};
const PATTERNS: &[&str] = &[
"authentication failed",
"permission denied (publickey)",
"could not read username",
"could not read password",
"the requested url returned error: 401",
"the requested url returned error: 403",
"invalid username or password",
"invalid credentials",
"http basic: access denied",
"fatal: unable to authenticate",
];
PATTERNS.iter().any(|p| stderr.contains(p))
}
pub fn is_proxy_failure(err: &MindError) -> bool {
let stderr = match err {
MindError::Git { stderr, .. } => stderr.to_ascii_lowercase(),
_ => return false,
};
const PATTERNS: &[&str] = &[
"received http code 407",
"the requested url returned error: 407",
"could not resolve proxy",
];
PATTERNS.iter().any(|p| stderr.contains(p))
}
pub fn auth_hint_lines(ssh_url: &str) -> Vec<String> {
vec![
"note: this can also mean the repo does not exist, is private, or the name is \
misspelled -- double check the identity first"
.to_string(),
"hint: authentication failed; to use SSH:".to_string(),
format!(" mind meld {ssh_url}"),
" or set `ssh = true` in ~/.mind/config.toml to always prefer SSH for HTTPS remotes"
.to_string(),
" or configure a git credential helper: https://git-scm.com/doc/credential-helpers"
.to_string(),
]
}
pub fn proxy_hint_lines() -> Vec<String> {
vec![
"hint: a proxy may be blocking the connection; \
set HTTPS_PROXY (e.g. export HTTPS_PROXY=http://proxy.example.com:8080) \
or configure git's http.proxy setting"
.to_string(),
]
}
static NONINTERACTIVE: AtomicBool = AtomicBool::new(false);
pub fn set_noninteractive(on: bool) {
NONINTERACTIVE.store(on, Ordering::Relaxed);
}
pub fn is_noninteractive() -> bool {
NONINTERACTIVE.load(Ordering::Relaxed)
}
thread_local! {
static THREAD_NONINTERACTIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub fn with_noninteractive<R>(f: impl FnOnce() -> R) -> R {
let prev = THREAD_NONINTERACTIVE.with(|c| c.replace(true));
let out = f();
THREAD_NONINTERACTIVE.with(|c| c.set(prev));
out
}
pub fn is_suppressed_prompt_failure(err: &MindError) -> bool {
is_auth_failure(err)
|| matches!(err, MindError::Git { stderr, .. }
if stderr.to_ascii_lowercase().contains("host key verification failed"))
}
fn noninteractive_env_pairs(ssh: &SshSetup) -> Vec<(&'static str, String)> {
prompt_free_env(ssh, "-o BatchMode=yes -o ConnectTimeout=10")
}
fn prompt_free_env(ssh: &SshSetup, openssh_opts: &str) -> Vec<(&'static str, String)> {
let mut pairs = vec![("GIT_TERMINAL_PROMPT", "0".to_string())];
if let Some(command) = prompt_free_ssh(ssh, openssh_opts) {
pairs.push(("GIT_SSH_COMMAND", command));
}
pairs
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SshSetup {
command: String,
variant: Option<String>,
}
fn prompt_free_ssh(ssh: &SshSetup, openssh_opts: &str) -> Option<String> {
let variant = match ssh.variant.as_deref().map(str::trim) {
Some(v) if !v.is_empty() && !v.eq_ignore_ascii_case("auto") => v.to_ascii_lowercase(),
_ => program_name(&ssh.command),
};
let extra = match variant.as_str() {
"ssh" => openssh_opts,
"plink" | "putty" => "-batch",
_ => return None,
};
Some(format!("{} {extra}", ssh.command))
}
fn program_name(command: &str) -> String {
let command = command.trim_start();
let word = match command.chars().next() {
Some(q @ ('\'' | '"')) => command[1..].split(q).next().unwrap_or(""),
_ => command.split_whitespace().next().unwrap_or(""),
};
let base = word
.rsplit(['/', '\\'])
.next()
.unwrap_or("")
.to_ascii_lowercase();
base.strip_suffix(".exe")
.map(str::to_string)
.unwrap_or(base)
}
fn noninteractive_here() -> bool {
NONINTERACTIVE.load(Ordering::Relaxed) || THREAD_NONINTERACTIVE.with(|c| c.get())
}
fn ssh_setup(cwd: Option<&Path>) -> SshSetup {
let var = |k| {
std::env::var(k)
.ok()
.filter(|v: &String| !v.trim().is_empty())
};
let config = config_ssh(cwd);
SshSetup {
command: resolve_ssh_base(
var("GIT_SSH_COMMAND"),
|| config.command.clone(),
var("GIT_SSH"),
),
variant: var("GIT_SSH_VARIANT").or(config.variant),
}
}
fn resolve_ssh_base(
env_command: Option<String>,
config_command: impl FnOnce() -> Option<String>,
git_ssh: Option<String>,
) -> String {
let set = |v: Option<String>| v.filter(|s| !s.trim().is_empty());
set(env_command)
.or_else(|| set(config_command()))
.or_else(|| set(git_ssh).map(|p| format!("'{}'", p.replace('\'', r"'\''"))))
.unwrap_or_else(|| "ssh".to_string())
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ConfigSsh {
command: Option<String>,
variant: Option<String>,
}
fn config_ssh(cwd: Option<&Path>) -> ConfigSsh {
let dir = cwd.map_or_else(std::env::temp_dir, Path::to_path_buf);
let Ok(out) = Command::new("git")
.args([
"config",
"--get-regexp",
r"^(core\.sshcommand|ssh\.variant)$",
])
.current_dir(dir)
.output()
else {
return ConfigSsh::default();
};
parse_config_ssh(&String::from_utf8_lossy(&out.stdout))
}
fn parse_config_ssh(text: &str) -> ConfigSsh {
let mut found = ConfigSsh::default();
for line in text.lines() {
let (key, value) = line.split_once(' ').unwrap_or((line, ""));
let value = Some(value.trim().to_string()).filter(|v| !v.is_empty());
match key {
"core.sshcommand" => found.command = value,
"ssh.variant" => found.variant = value,
_ => {}
}
}
found
}
fn apply_noninteractive_env(cmd: &mut Command, cwd: Option<&Path>) {
if !noninteractive_here() {
return;
}
let ssh = ssh_setup(cwd);
for (k, v) in noninteractive_env_pairs(&ssh) {
cmd.env(k, v);
}
if must_detach(&ssh, is_noninteractive()) {
detach_from_terminal(cmd);
}
}
fn must_detach(ssh: &SshSetup, process_wide: bool) -> bool {
process_wide && prompt_free_ssh(ssh, "").is_none()
}
fn detach_from_terminal(cmd: &mut Command) {
cmd.stdin(std::process::Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
}
fn batch_env_pairs(ssh: &SshSetup) -> Vec<(&'static str, String)> {
prompt_free_env(ssh, "-o BatchMode=yes")
}
fn with_prompt_retry<T>(batch: bool, mut attempt: impl FnMut(bool) -> Result<T>) -> Result<T> {
match attempt(batch) {
Err(e) if batch && is_suppressed_prompt_failure(&e) => {
crate::progress::suspend(|| attempt(false))
}
r => r,
}
}
fn network_label(url: &str, args: &[&str]) -> Option<String> {
let verb = match args.first().copied()? {
"clone" => "cloning",
"fetch" => "fetching",
"ls-remote" => "listing tags of",
_ => return None,
};
Some(format!("{verb} {url}"))
}
fn run(url: &str, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
let label = network_label(url, args);
let remote = label.is_some();
let batch = remote && crate::progress::enabled() && !noninteractive_here();
let _step = label.map(|label| crate::progress::step_if_idle(|| label));
with_prompt_retry(batch, |batch| exec(url, cwd, args, remote, batch))
}
fn exec(url: &str, cwd: Option<&Path>, args: &[&str], remote: bool, batch: bool) -> Result<String> {
let mut cmd = Command::new("git");
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd.args(args);
if remote {
apply_noninteractive_env(&mut cmd, cwd);
}
if batch {
for (k, v) in batch_env_pairs(&ssh_setup(cwd)) {
cmd.env(k, v);
}
}
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<()> {
clone_shallow_at(url, dest, pin)?;
deepen_if_declared(url, dest)
}
pub fn clone_shallow_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", "--depth", "1", "--", url, &dest_str])?;
let target = ensure_commit(url, dest, sha)?;
run(url, Some(dest), &["checkout", &target])?;
}
Pin::Version(v) => {
let tag = resolved_tag(url, v)?;
run(
url,
None,
&[
"clone", "--depth", "1", "--branch", tag, "--", url, &dest_str,
],
)?;
}
}
Ok(())
}
fn resolved_tag<'a>(url: &str, v: &'a crate::source::VersionPin) -> Result<&'a str> {
v.tag
.as_deref()
.ok_or_else(|| MindError::NoMatchingRelease {
source_name: url.to_string(),
range: v.range.clone(),
highest: v.highest.clone(),
})
}
pub fn clone(url: &str, dest: &Path) -> Result<()> {
clone_at(url, dest, &Pin::DefaultBranch)
}
fn is_shallow(url: &str, dir: &Path) -> bool {
run(url, Some(dir), &["rev-parse", "--is-shallow-repository"]).map_or(true, |s| s != "false")
}
fn fetch_depth(url: &str, dir: &Path) -> &'static [&'static str] {
if is_shallow(url, dir) {
&["--depth", "1"]
} else {
&[]
}
}
fn fetch(url: &str, dir: &Path, rest: &[&str]) -> Result<String> {
let mut args: Vec<&str> = vec!["fetch"];
args.extend_from_slice(fetch_depth(url, dir));
args.extend_from_slice(rest);
run(url, Some(dir), &args)
}
fn ensure_commit(url: &str, dir: &Path, rev: &str) -> Result<String> {
let object = format!("{rev}^{{commit}}");
if run(url, Some(dir), &["cat-file", "-e", &object]).is_ok() {
return Ok(rev.to_string());
}
if fetch(url, dir, &["origin", "--", rev]).is_ok() {
return run(url, Some(dir), &["rev-parse", "FETCH_HEAD^{commit}"]);
}
let mut args: Vec<&str> = vec!["fetch"];
if is_shallow(url, dir) {
args.push("--unshallow");
}
args.extend_from_slice(&[
"origin",
"--",
"+refs/heads/*:refs/remotes/origin/*",
"+refs/tags/*:refs/tags/*",
]);
run(url, Some(dir), &args)?;
Ok(rev.to_string())
}
fn declares_full_history(dir: &Path) -> bool {
matches!(
crate::mindfile::MindToml::load(dir),
Ok(Some(t)) if t.source.history == Some(crate::mindfile::History::Full)
)
}
fn deepen_if_declared(url: &str, dir: &Path) -> Result<()> {
if declares_full_history(dir) {
deepen(url, dir)?;
}
Ok(())
}
pub fn deepen(url: &str, dir: &Path) -> Result<()> {
if is_shallow(url, dir) {
run(url, Some(dir), &["fetch", "--unshallow", "origin"])?;
}
Ok(())
}
pub fn sync_to_pin(url: &str, dir: &Path, pin: &Pin) -> Result<()> {
sync_to_pin_shallow(url, dir, pin)?;
deepen_if_declared(url, dir)
}
fn sync_to_pin_shallow(url: &str, dir: &Path, pin: &Pin) -> Result<()> {
match pin {
Pin::DefaultBranch => {
fetch(url, dir, &["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) => {
fetch(url, dir, &["origin", "--", branch])?;
run(url, Some(dir), &["reset", "--hard", "FETCH_HEAD"])?;
}
Pin::Tag(tag) => {
fetch(
url,
dir,
&[
"--force",
"origin",
"--",
&format!("+refs/tags/{tag}:refs/tags/{tag}"),
],
)?;
run(url, Some(dir), &["reset", "--hard", tag])?;
}
Pin::Ref(sha) => {
let target = ensure_commit(url, dir, sha)?;
run(url, Some(dir), &["reset", "--hard", &target])?;
}
Pin::Version(v) => {
let tag = resolved_tag(url, v)?;
fetch(
url,
dir,
&[
"--force",
"origin",
"--",
&format!("+refs/tags/{tag}:refs/tags/{tag}"),
],
)?;
run(url, Some(dir), &["reset", "--hard", tag])?;
}
}
Ok(())
}
pub fn ls_remote_tags(url: &str) -> Result<Vec<(String, String)>> {
let out = run(url, None, &["ls-remote", "--tags", "--", url])?;
let mut tags: Vec<(String, String)> = Vec::new();
for line in out.lines() {
let Some((sha, name)) = line.split_once('\t') else {
continue;
};
let Some(name) = name.strip_prefix("refs/tags/") else {
continue;
};
match name.strip_suffix("^{}") {
Some(tag) => match tags.iter_mut().find(|(t, _)| t == tag) {
Some(entry) => entry.1 = sha.to_string(),
None => tags.push((tag.to_string(), sha.to_string())),
},
None => tags.push((name.to_string(), sha.to_string())),
}
}
Ok(tags)
}
pub fn default_branch_mind_toml(url: &str, dir: &Path) -> Result<Option<String>> {
fetch(url, dir, &["origin", "HEAD"])?;
let Ok(size) = run(url, Some(dir), &["cat-file", "-s", "FETCH_HEAD:mind.toml"]) else {
return Ok(None);
};
let limit = crate::error::metadata_size_limit();
if size.parse::<u64>().map_or(true, |n| n > limit) {
return Err(MindError::MetadataTooLarge {
path: Path::new(url).join("mind.toml"),
limit,
});
}
run(
url,
Some(dir),
&["cat-file", "blob", "FETCH_HEAD:mind.toml"],
)
.map(Some)
}
pub fn tag_annotation(url: &str, dir: &Path, tag: &str) -> Result<Option<String>> {
validate_ref_value(tag)?;
let tag_ref = format!("refs/tags/{tag}");
fetch(
url,
dir,
&["--force", "origin", "--", &format!("+{tag_ref}:{tag_ref}")],
)?;
if run(url, Some(dir), &["cat-file", "-t", &tag_ref])? != "tag" {
return Ok(None);
}
let limit = crate::error::metadata_size_limit();
let size = run(url, Some(dir), &["cat-file", "-s", &tag_ref])?;
if size.parse::<u64>().map_or(true, |n| n > limit) {
return Err(MindError::MetadataTooLarge {
path: Path::new(url).join(&tag_ref),
limit,
});
}
let text = run(
url,
Some(dir),
&[
"for-each-ref",
"--format=%(contents:subject)%0a%0a%(contents:body)",
&tag_ref,
],
)?;
Ok((!text.is_empty()).then_some(text))
}
pub fn tag_mind_toml(url: &str, dir: &Path, tag: &str, fetch: bool) -> Result<Option<String>> {
validate_ref_value(tag)?;
let tag_ref = format!("refs/tags/{tag}");
if fetch {
self::fetch(
url,
dir,
&["--force", "origin", "--", &format!("+{tag_ref}:{tag_ref}")],
)?;
}
let spec = format!("{tag_ref}:mind.toml");
let Ok(size) = run(url, Some(dir), &["cat-file", "-s", &spec]) else {
return Ok(None);
};
let limit = crate::error::metadata_size_limit();
if size.parse::<u64>().map_or(true, |n| n > limit) {
return Err(MindError::MetadataTooLarge {
path: Path::new(url).join(&spec),
limit,
});
}
run(url, Some(dir), &["cat-file", "blob", &spec]).map(Some)
}
pub fn head_commit(url: &str, dir: &Path) -> Result<String> {
run(url, Some(dir), &["rev-parse", "HEAD"])
}
pub fn reset_to_commit(url: &str, dir: &Path, sha: &str) -> Result<()> {
validate_ref_value(sha)?;
run(url, Some(dir), &["reset", "--hard", sha]).map(|_| ())
}
pub fn git_init(dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|e| crate::error::MindError::io(dir, e))?;
run(&dir.to_string_lossy(), Some(dir), &["init", "-q"])?;
run(
&dir.to_string_lossy(),
Some(dir),
&["config", "user.email", "mind@local"],
)?;
run(
&dir.to_string_lossy(),
Some(dir),
&["config", "user.name", "mind"],
)?;
Ok(())
}
pub fn is_repo(dir: &Path) -> bool {
run(
&dir.to_string_lossy(),
Some(dir),
&["rev-parse", "--git-dir"],
)
.is_ok()
}
pub fn is_tracked(repo: &Path, file: &Path) -> bool {
if !is_repo(repo) {
return true;
}
let (Ok(repo_abs), Ok(file_abs)) = (repo.canonicalize(), file.canonicalize()) else {
return true;
};
let Ok(rel) = file_abs.strip_prefix(&repo_abs) else {
return true;
};
run(
&repo_abs.to_string_lossy(),
Some(&repo_abs),
&["ls-files", "--error-unmatch", "--", &rel.to_string_lossy()],
)
.is_ok()
}
pub fn add_all(dir: &Path) -> Result<()> {
run(&dir.to_string_lossy(), Some(dir), &["add", "-A"])?;
Ok(())
}
pub fn commit(dir: &Path, message: &str) -> Result<()> {
run(
&dir.to_string_lossy(),
Some(dir),
&["commit", "-m", message],
)?;
Ok(())
}
#[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 validate_ref_value_accepts_normal_refs() {
for good in [
"main",
"develop",
"release/2.0",
"v1.0",
"v1.0.0-rc1",
"feature/abc-123",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"abc1234",
] {
assert!(validate_ref_value(good).is_ok(), "expected ok for {good:?}");
}
}
#[test]
fn validate_ref_value_boundary_dash_and_colon_and_refspec() {
let err = validate_ref_value("-").unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"a bare '-' must be rejected, got: {err}"
);
for good in ["x-y", "abc-", "feature-1", "a-b-c"] {
assert!(
validate_ref_value(good).is_ok(),
"non-leading dash must be allowed: {good:?}"
);
}
for good in [
"refs/tags/v1",
"refs/heads/main",
"refs/remotes/origin/main",
] {
assert!(
validate_ref_value(good).is_ok(),
"refspec-looking ref must be allowed: {good:?}"
);
}
}
#[test]
fn validate_ref_value_accepts_colon_documenting_current_contract() {
for accepted in ["a:b", "refs/tags/v1:refs/tags/v1", "feature/x:y"] {
assert!(
validate_ref_value(accepted).is_ok(),
"':' is not in the DSC-66 rejected set; {accepted:?} must pass"
);
}
}
#[test]
fn validate_ref_value_rejects_empty() {
let err = validate_ref_value("").unwrap_err();
assert!(
err.to_string().contains("empty"),
"error should mention 'empty': {err}"
);
}
#[test]
fn validate_ref_value_rejects_leading_dash() {
for bad in [
"--upload-pack=touch /tmp/pwned",
"-x",
"--no-tags",
"--depth=1",
] {
let err = validate_ref_value(bad).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef for {bad:?}, got: {err}"
);
assert!(
err.to_string().contains("'-'"),
"error should mention '-': {err}"
);
}
}
#[test]
fn validate_ref_value_rejects_whitespace() {
for bad in ["main branch", "v1.0 stable", "a b"] {
let err = validate_ref_value(bad).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef for {bad:?}"
);
assert!(
err.to_string().contains("whitespace"),
"error should mention 'whitespace': {err}"
);
}
let err = validate_ref_value("ref\twith\ttabs").unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef for tab-containing value"
);
}
#[test]
fn validate_ref_value_rejects_dotdot() {
for bad in ["main..HEAD", "v1.0..v2.0", "a..b", "..HEAD"] {
let err = validate_ref_value(bad).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef for {bad:?}"
);
assert!(
err.to_string().contains(".."),
"error should mention '..': {err}"
);
}
}
#[test]
fn validate_ref_value_rejects_control_chars() {
let nul = "\x00ref";
let err = validate_ref_value(nul).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef for NUL-containing value"
);
}
#[test]
fn clone_at_ref_pin_succeeds_with_validated_sha() {
let base = tmpdir("dsc66-checkout");
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 Pin::Ref must succeed for a valid sha");
let got = read_head(&dest);
assert_eq!(got, sha_b, "clone_at must land on the pinned sha");
cleanup(&base);
}
#[test]
fn sync_to_pin_follow_branch_with_fetch_double_dash_succeeds() {
let base = tmpdir("dsc66-fetch-branch");
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()))
.expect("fetch with -- terminator must succeed");
assert_eq!(
read_head(&dest),
sha_c,
"-- before the branch name must not disturb the fetch"
);
cleanup(&base);
}
#[test]
fn sync_to_pin_ref_succeeds_with_validated_sha() {
let base = tmpdir("dsc66-reset-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())).unwrap();
assert_eq!(read_head(&dest), sha_b);
fs::write(remote.join("file.txt"), "version D").unwrap();
git(&remote, &["commit", "-aqm", "commit D"]);
sync_to_pin(&url, &dest, &Pin::Ref(sha_b.clone()))
.expect("sync_to_pin Pin::Ref must succeed for a valid sha");
assert_eq!(
read_head(&dest),
sha_b,
"sync_to_pin must stay on the pinned sha"
);
cleanup(&base);
}
#[test]
fn sync_to_pin_tag_with_fetch_double_dash_succeeds() {
let base = tmpdir("dsc66-reset-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())).unwrap();
assert_eq!(read_head(&dest), sha_a);
sync_to_pin(&url, &dest, &Pin::Tag("v1.0".into()))
.expect("tag sync with -- in fetch must succeed");
assert_eq!(
read_head(&dest),
sha_a,
"sync_to_pin must stay on the pinned tag"
);
cleanup(&base);
}
#[test]
fn noninteractive_env_disables_git_and_ssh_prompts() {
let pairs = noninteractive_env_pairs(&openssh("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(&openssh("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 the_ssh_base_follows_gits_own_precedence() {
let s = |v: &str| Some(v.to_string());
assert_eq!(
resolve_ssh_base(s("ssh -v"), || s("ssh -i cfg"), s("/bin/plink")),
"ssh -v",
"GIT_SSH_COMMAND wins"
);
assert_eq!(
resolve_ssh_base(None, || s("ssh -i cfg"), s("/bin/plink")),
"ssh -i cfg",
"core.sshCommand beats GIT_SSH"
);
assert_eq!(
resolve_ssh_base(s(" "), || None, s("/opt/my ssh/it's")),
r"'/opt/my ssh/it'\''s'",
"an empty command is unset; GIT_SSH is a program, quoted"
);
assert_eq!(resolve_ssh_base(None, || s(""), None), "ssh");
}
fn openssh(command: &str) -> SshSetup {
SshSetup {
command: command.to_string(),
variant: None,
}
}
#[test]
fn prompts_go_off_only_in_a_form_the_ssh_program_accepts() {
let opts = "-o BatchMode=yes";
let with = |command: &str, variant: Option<&str>| {
prompt_free_ssh(
&SshSetup {
command: command.to_string(),
variant: variant.map(str::to_string),
},
opts,
)
};
assert_eq!(
with("/usr/bin/ssh -i k", None).as_deref(),
Some("/usr/bin/ssh -i k -o BatchMode=yes")
);
assert_eq!(
with(r#""C:\Program Files\PuTTY\PLINK.EXE""#, None).as_deref(),
Some(r#""C:\Program Files\PuTTY\PLINK.EXE" -batch"#),
"plink takes -batch, not -o"
);
assert_eq!(
with("'/opt/putty' -v", None).as_deref(),
Some("'/opt/putty' -v -batch")
);
assert_eq!(
with("tortoiseplink", None),
None,
"git already passes -batch"
);
assert_eq!(
with("~/bin/my-ssh-wrapper", None),
None,
"an unknown program is left as configured"
);
assert_eq!(
with("~/bin/my-ssh-wrapper", Some("ssh")).as_deref(),
Some("~/bin/my-ssh-wrapper -o BatchMode=yes"),
"an explicit variant wins"
);
assert_eq!(
with("ssh", Some("simple")),
None,
"ssh.variant=simple takes no options"
);
assert_eq!(
with("ssh", Some("auto")).as_deref(),
Some("ssh -o BatchMode=yes")
);
let pairs = batch_env_pairs(&openssh("~/bin/my-ssh-wrapper"));
assert_eq!(pairs, [("GIT_TERMINAL_PROMPT", "0".to_string())]);
}
#[test]
fn config_ssh_parses_the_last_value_of_each_key() {
assert_eq!(
parse_config_ssh(
"core.sshcommand ssh -i /a\nssh.variant plink\ncore.sshcommand ssh -i /b\n"
),
ConfigSsh {
command: Some("ssh -i /b".to_string()),
variant: Some("plink".to_string()),
}
);
assert_eq!(parse_config_ssh(""), ConfigSsh::default());
}
#[test]
fn config_ssh_reads_the_repos_core_ssh_command() {
let base = std::env::temp_dir().join(format!(
"mind-git-sshcmd-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
std::fs::create_dir_all(&base).unwrap();
let git = |args: &[&str]| {
assert!(
Command::new("git")
.args(args)
.current_dir(&base)
.status()
.unwrap()
.success()
);
};
git(&["init", "-q"]);
git(&["config", "core.sshCommand", "ssh -i /work/key"]);
git(&["config", "ssh.variant", "ssh"]);
assert_eq!(
config_ssh(Some(&base)),
ConfigSsh {
command: Some("ssh -i /work/key".to_string()),
variant: Some("ssh".to_string()),
}
);
cleanup(&base);
}
#[test]
fn with_noninteractive_applies_the_env_on_this_thread_only() {
let prompt_off = |cmd: &Command| {
cmd.get_envs()
.any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v == Some("0".as_ref()))
};
let mut inside = Command::new("git");
with_noninteractive(|| apply_noninteractive_env(&mut inside, None));
assert!(prompt_off(&inside), "worker git must not prompt");
let thread_flag = || THREAD_NONINTERACTIVE.with(|c| c.get());
assert!(!thread_flag(), "override must end with the closure");
let other = with_noninteractive(|| std::thread::spawn(thread_flag).join().unwrap());
assert!(!other, "override must not leak to another thread");
}
#[test]
fn batch_env_disables_prompts_without_a_connect_timeout() {
let pairs = batch_env_pairs(&openssh("ssh -i /my/key"));
assert_eq!(
pairs,
[
("GIT_TERMINAL_PROMPT", "0".to_string()),
(
"GIT_SSH_COMMAND",
"ssh -i /my/key -o BatchMode=yes".to_string()
),
]
);
}
#[test]
fn prompt_retry_reruns_a_suppressed_prompt_with_prompts_on() {
let mut calls = Vec::new();
let r = with_prompt_retry(true, |batch| {
calls.push(batch);
if batch {
Err(git_err(
"fatal: could not read Username for 'https://x': terminal prompts disabled",
))
} else {
Ok("done")
}
});
assert_eq!(r.unwrap(), "done");
assert_eq!(calls, [true, false]);
}
#[test]
fn prompt_retry_leaves_other_failures_and_non_batch_runs_alone() {
let mut calls = Vec::new();
let r: Result<()> = with_prompt_retry(true, |batch| {
calls.push(batch);
Err(git_err("fatal: repository 'x' not found"))
});
assert!(r.is_err());
assert_eq!(
calls,
[true],
"a failure that is not a prompt is not retried"
);
let mut calls = Vec::new();
let r: Result<()> = with_prompt_retry(false, |batch| {
calls.push(batch);
Err(git_err("fatal: Authentication failed"))
});
assert!(r.is_err());
assert_eq!(calls, [false], "a run that already prompted is not retried");
}
#[test]
fn network_subcommands_get_a_label_local_ones_do_not() {
let url = "https://example.com/o/r";
assert_eq!(
network_label(url, &["clone", "--", url, "/d"]).as_deref(),
Some("cloning https://example.com/o/r")
);
assert_eq!(
network_label(url, &["fetch", "origin"]).as_deref(),
Some("fetching https://example.com/o/r")
);
assert_eq!(
network_label(url, &["ls-remote", "--tags", "--", url]).as_deref(),
Some("listing tags of https://example.com/o/r")
);
assert_eq!(network_label(url, &["rev-parse", "HEAD"]), None);
assert_eq!(network_label(url, &[]), None);
}
#[test]
fn suppressed_prompt_failures_cover_credentials_and_host_keys() {
let git_err = |stderr: &str| MindError::Git {
url: "u".into(),
args: vec!["fetch".into()],
status: None,
stderr: stderr.into(),
};
assert!(is_suppressed_prompt_failure(&git_err(
"fatal: could not read Username for 'https://x': terminal prompts disabled"
)));
assert!(is_suppressed_prompt_failure(&git_err(
"Host key verification failed.\nfatal: Could not read from remote repository."
)));
assert!(is_suppressed_prompt_failure(&git_err(
"git@x: Permission denied (publickey)."
)));
assert!(!is_suppressed_prompt_failure(&git_err(
"fatal: unable to access 'https://x/': Could not resolve host: x"
)));
assert!(!is_suppressed_prompt_failure(&MindError::GitNotFound));
}
#[test]
fn only_the_tui_detaches_and_only_an_ssh_it_cannot_quiet() {
let wrapper = openssh("~/bin/my-ssh-wrapper");
assert!(must_detach(&wrapper, true));
assert!(!must_detach(&wrapper, false), "a fetch worker keeps Ctrl-C");
assert!(
!must_detach(&openssh("ssh -i k"), true),
"BatchMode is enough"
);
assert!(!must_detach(&openssh("plink"), true), "-batch is enough");
}
#[cfg(unix)]
#[test]
fn a_detached_git_child_has_no_controlling_terminal() {
let mut cmd = Command::new("sleep");
cmd.arg("5");
detach_from_terminal(&mut cmd);
let mut child = cmd.spawn().unwrap();
let pid = child.id() as libc::pid_t;
let (group, ours) = unsafe { (libc::getpgid(pid), libc::getpgid(0)) };
let _ = child.kill();
let _ = child.wait();
assert_eq!(group, pid, "the child leads its own process group");
assert_ne!(group, ours);
}
#[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 ls_remote_tags_peels_annotated_tags() {
let base = tmpdir("ls-remote-tags");
let (remote, sha_a, sha_b, _c) = make_remote(&base);
git(&remote, &["tag", "-a", "v2.0.0", "-m", "notes", &sha_b]);
let url = format!("file://{}", remote.display());
let mut tags = ls_remote_tags(&url).unwrap();
tags.sort();
assert_eq!(
tags,
[
("v1.0".to_string(), sha_a.clone()),
("v2.0.0".to_string(), sha_b.clone())
]
);
cleanup(&base);
}
#[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);
}
fn has_commit(dir: &Path, sha: &str) -> bool {
Command::new("git")
.args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
.current_dir(dir)
.stderr(std::process::Stdio::null())
.status()
.unwrap()
.success()
}
fn declare_full_history(remote: &Path) -> String {
fs::write(remote.join("mind.toml"), "[source]\nhistory = \"full\"\n").unwrap();
git(remote, &["add", "mind.toml"]);
git(remote, &["commit", "-qm", "declare full history"]);
read_head(remote)
}
#[test]
fn clone_at_every_pin_is_shallow() {
let base = tmpdir("depth-all");
let (remote, sha_a, sha_b, _c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let pins = [
("default", Pin::DefaultBranch),
("follow", Pin::FollowBranch("stable".into())),
("tag", Pin::Tag("v1.0".into())),
("ref", Pin::Ref(sha_b.clone())),
];
for (name, pin) in pins {
let dest = base.join(name);
clone_at(&url, &dest, &pin).unwrap();
assert!(is_shallow(&url, &dest), "{name} clone must be shallow");
}
assert!(has_commit(&base.join("ref"), &sha_b));
assert!(
!has_commit(&base.join("ref"), &sha_a),
"a ref pin must fetch only the pinned commit, not its history"
);
cleanup(&base);
}
#[test]
fn ref_pin_falls_back_to_full_fetch_when_sha_fetch_is_refused() {
let base = tmpdir("depth-fallback");
let (remote, _a, _b, _c) = make_remote(&base);
git(&remote, &["checkout", "-qb", "side"]);
fs::write(remote.join("side.txt"), "side 1").unwrap();
git(&remote, &["add", "side.txt"]);
git(&remote, &["commit", "-qm", "side 1"]);
let side_1 = read_head(&remote);
fs::write(remote.join("side.txt"), "side 2").unwrap();
git(&remote, &["commit", "-aqm", "side 2"]);
git(&remote, &["checkout", "-q", "main"]);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_shallow_at(&url, &dest, &Pin::DefaultBranch).unwrap();
git(&dest, &["config", "protocol.version", "0"]);
assert!(!has_commit(&dest, &side_1));
ensure_commit(&url, &dest, &side_1).expect("fallback fetch finds the commit");
assert!(has_commit(&dest, &side_1));
assert!(!is_shallow(&url, &dest), "the fallback deepens the clone");
cleanup(&base);
}
#[test]
fn sync_ref_pin_holding_the_commit_does_not_fetch() {
let base = tmpdir("depth-ref-nofetch");
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())).unwrap();
let gone = format!("file://{}", base.join("gone").display());
git(&dest, &["remote", "set-url", "origin", &gone]);
sync_to_pin(&gone, &dest, &Pin::Ref(sha_b.clone())).expect("no fetch needed");
assert_eq!(read_head(&dest), sha_b);
cleanup(&base);
}
#[test]
fn complete_clone_stays_complete_across_syncs() {
let base = tmpdir("depth-stays-full");
let (remote, sha_a, _b, _c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_shallow_at(&url, &dest, &Pin::DefaultBranch).unwrap();
git(&dest, &["fetch", "-q", "--unshallow", "origin"]);
assert!(!is_shallow(&url, &dest));
fs::write(remote.join("file.txt"), "version D").unwrap();
git(&remote, &["commit", "-aqm", "commit D"]);
let sha_d = read_head(&remote);
sync_to_pin(&url, &dest, &Pin::DefaultBranch).unwrap();
default_branch_mind_toml(&url, &dest).unwrap();
assert_eq!(read_head(&dest), sha_d);
assert!(!is_shallow(&url, &dest), "a sync must not re-shallow");
assert!(has_commit(&dest, &sha_a));
cleanup(&base);
}
#[test]
fn history_full_deepens_the_clone() {
let base = tmpdir("depth-history-full");
let (remote, sha_a, _b, _c) = make_remote(&base);
declare_full_history(&remote);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_at(&url, &dest, &Pin::DefaultBranch).unwrap();
assert!(!is_shallow(&url, &dest), "history = full must deepen");
assert!(has_commit(&dest, &sha_a));
let throwaway = base.join("throwaway");
clone_shallow_at(&url, &throwaway, &Pin::DefaultBranch).unwrap();
assert!(is_shallow(&url, &throwaway));
cleanup(&base);
}
#[test]
fn reset_to_commit_restores_a_shallow_clone_after_moving_forward() {
let base = tmpdir("depth-restore");
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).unwrap();
assert!(is_shallow(&url, &dest));
assert_eq!(read_head(&dest), sha_c);
fs::write(remote.join("file.txt"), "version D").unwrap();
git(&remote, &["commit", "-aqm", "commit D"]);
let sha_d = read_head(&remote);
sync_to_pin(&url, &dest, &Pin::DefaultBranch).unwrap();
assert_eq!(read_head(&dest), sha_d);
assert!(is_shallow(&url, &dest));
let gone = format!("file://{}", base.join("gone").display());
git(&dest, &["remote", "set-url", "origin", &gone]);
reset_to_commit(&gone, &dest, &sha_c).expect("old commit is still local");
assert_eq!(read_head(&dest), sha_c);
assert_eq!(read_file(&dest, "file.txt"), "version C");
cleanup(&base);
}
#[test]
fn deepen_is_a_no_op_on_a_complete_clone() {
let base = tmpdir("depth-deepen-twice");
let (remote, sha_a, _b, _c) = make_remote(&base);
let url = format!("file://{}", remote.display());
let dest = base.join("clone");
clone_shallow_at(&url, &dest, &Pin::DefaultBranch).unwrap();
deepen(&url, &dest).unwrap();
assert!(!is_shallow(&url, &dest));
assert!(has_commit(&dest, &sha_a));
deepen(&url, &dest).expect("a second deepen is a no-op");
cleanup(&base);
}
#[test]
fn history_full_declared_later_deepens_on_sync() {
let base = tmpdir("depth-history-sync");
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::DefaultBranch).unwrap();
assert!(is_shallow(&url, &dest));
declare_full_history(&remote);
sync_to_pin(&url, &dest, &Pin::DefaultBranch).unwrap();
assert!(!is_shallow(&url, &dest));
assert!(has_commit(&dest, &sha_a));
cleanup(&base);
}
#[test]
fn git_init_creates_repo_with_identity() {
let base = tmpdir("ginit");
let repo = base.join("nested").join("repo");
assert!(!repo.exists(), "sanity: repo dir must not pre-exist");
git_init(&repo).expect("git_init");
assert!(repo.exists(), "git_init must create the directory");
assert!(is_repo(&repo), "git_init must produce a git repo");
cleanup(&base);
}
#[test]
fn is_repo_false_for_plain_dir_true_after_init() {
let base = tmpdir("isrepo");
let plain = base.join("plain");
fs::create_dir_all(&plain).unwrap();
assert!(!is_repo(&plain), "a plain dir is not a git repo");
git_init(&plain).unwrap();
assert!(is_repo(&plain), "after git_init the dir is a git repo");
cleanup(&base);
}
#[test]
fn is_repo_false_for_missing_path() {
let base = tmpdir("isrepo-missing");
let missing = base.join("does-not-exist");
assert!(
!is_repo(&missing),
"a non-existent path must not report as a git repo"
);
cleanup(&base);
}
#[test]
fn add_all_and_commit_records_message() {
let base = tmpdir("commit");
let repo = base.join("repo");
git_init(&repo).unwrap();
fs::write(repo.join("skill.md"), "# absorbed\n").unwrap();
add_all(&repo).expect("add_all");
commit(&repo, "absorb skill:review").expect("commit");
let msg = read_head_subject(&repo);
assert_eq!(
msg, "absorb skill:review",
"commit message must be the absorb default message"
);
let tracked = Command::new("git")
.args(["ls-files"])
.current_dir(&repo)
.output()
.unwrap();
let tracked = String::from_utf8(tracked.stdout).unwrap();
assert!(
tracked.contains("skill.md"),
"the committed file must be tracked: {tracked}"
);
cleanup(&base);
}
#[test]
fn commit_with_nothing_staged_errors() {
let base = tmpdir("commit-empty");
let repo = base.join("repo");
git_init(&repo).unwrap();
let result = commit(&repo, "absorb skill:nothing");
assert!(
result.is_err(),
"commit with an empty index must error, not produce an empty commit"
);
cleanup(&base);
}
fn read_head_subject(dir: &Path) -> String {
let out = Command::new("git")
.args(["log", "-1", "--pretty=format:%s"])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn git_err(stderr: &str) -> MindError {
MindError::Git {
url: "https://example.com/repo.git".into(),
args: vec!["clone".into()],
status: None,
stderr: stderr.into(),
}
}
#[test]
fn is_auth_failure_matches_authentication_failed() {
let err = git_err("fatal: Authentication failed for 'https://github.com/owner/private/'");
assert!(is_auth_failure(&err), "authentication failed must match");
}
#[test]
fn is_auth_failure_matches_permission_denied_publickey() {
let err = git_err("git@github.com: Permission denied (publickey).");
assert!(
is_auth_failure(&err),
"Permission denied (publickey) must match"
);
}
#[test]
fn is_auth_failure_matches_http_401() {
let err = git_err(
"fatal: unable to access 'https://example.com/private.git/': The requested URL returned error: 401",
);
assert!(is_auth_failure(&err), "401 error must match");
}
#[test]
fn is_auth_failure_matches_http_403() {
let err = git_err(
"fatal: unable to access 'https://example.com/private.git/': The requested URL returned error: 403",
);
assert!(is_auth_failure(&err), "403 error must match");
}
#[test]
fn is_auth_failure_does_not_match_repository_not_found() {
let err = git_err("ERROR: Repository not found.");
assert!(
!is_auth_failure(&err),
"Repository not found must not match"
);
}
#[test]
fn is_auth_failure_matches_invalid_username_or_password() {
let err = git_err("remote: Invalid username or password.");
assert!(
is_auth_failure(&err),
"Invalid username or password must match"
);
}
#[test]
fn is_auth_failure_matches_could_not_read_username() {
let err = git_err(
"fatal: could not read Username for 'https://github.com': No such device or address",
);
assert!(is_auth_failure(&err), "could not read Username must match");
}
#[test]
fn is_auth_failure_matches_could_not_read_password() {
let err = git_err(
"fatal: could not read Password for 'https://user@github.com': terminal prompts disabled",
);
assert!(is_auth_failure(&err), "could not read Password must match");
}
#[test]
fn is_auth_failure_matches_http_basic_access_denied() {
let err = git_err(
"remote: HTTP Basic: Access denied. The provided password or token is incorrect.",
);
assert!(
is_auth_failure(&err),
"HTTP Basic: Access denied must match"
);
}
#[test]
fn is_auth_failure_matches_invalid_credentials() {
let err = git_err("Invalid credentials.");
assert!(is_auth_failure(&err), "invalid credentials must match");
}
#[test]
fn is_auth_failure_matches_unable_to_authenticate() {
let err = git_err("fatal: unable to authenticate");
assert!(is_auth_failure(&err), "unable to authenticate must match");
}
#[test]
fn is_auth_failure_does_not_match_network_error() {
let err = git_err("fatal: unable to connect to github.com: Connection refused");
assert!(
!is_auth_failure(&err),
"a network error must not match auth failure"
);
}
#[test]
fn is_auth_failure_does_not_match_non_git_error() {
let err = MindError::GitNotFound;
assert!(
!is_auth_failure(&err),
"GitNotFound must not be an auth failure"
);
}
#[test]
fn is_auth_failure_is_case_insensitive() {
let err = git_err("AUTHENTICATION FAILED for something");
assert!(
is_auth_failure(&err),
"auth failure detection must be case-insensitive"
);
}
#[test]
fn is_proxy_failure_matches_received_http_code_407() {
let err = git_err(
"fatal: unable to access 'https://github.com/owner/repo.git/': \
Received HTTP code 407 from proxy after CONNECT",
);
assert!(
is_proxy_failure(&err),
"Received HTTP code 407 from proxy must match"
);
}
#[test]
fn is_proxy_failure_matches_url_returned_407() {
let err = git_err(
"fatal: unable to access 'https://github.com/owner/repo.git/': \
The requested URL returned error: 407",
);
assert!(
is_proxy_failure(&err),
"The requested URL returned error: 407 must match"
);
}
#[test]
fn is_proxy_failure_matches_could_not_resolve_proxy() {
let err = git_err(
"fatal: unable to access 'https://github.com/owner/repo.git/': \
Could not resolve proxy: proxy.corp.example.com",
);
assert!(is_proxy_failure(&err), "Could not resolve proxy must match");
}
#[test]
fn is_proxy_failure_does_not_match_auth_failure() {
let err = git_err("fatal: Authentication failed for 'https://github.com/owner/repo/'");
assert!(
!is_proxy_failure(&err),
"auth failure must not match proxy failure"
);
}
#[test]
fn is_proxy_failure_does_not_match_network_error() {
let err = git_err("fatal: unable to connect to github.com: Connection refused");
assert!(
!is_proxy_failure(&err),
"plain network error must not match proxy failure"
);
}
#[test]
fn is_proxy_failure_does_not_match_non_git_error() {
let err = MindError::GitNotFound;
assert!(
!is_proxy_failure(&err),
"GitNotFound must not be a proxy failure"
);
}
#[test]
fn is_proxy_failure_is_case_insensitive() {
let err = git_err("RECEIVED HTTP CODE 407 FROM PROXY AFTER CONNECT");
assert!(
is_proxy_failure(&err),
"proxy failure detection must be case-insensitive"
);
}
#[test]
fn auth_hint_lines_include_ssh_url_and_config_and_helper() {
let lines = auth_hint_lines("git@github.com:owner/private");
let combined = lines.join("\n");
assert!(
combined.contains("git@github.com:owner/private"),
"SSH URL must appear in auth hint: {combined}"
);
assert!(
combined.contains("ssh = true"),
"config option must appear in auth hint: {combined}"
);
assert!(
combined.contains("credential helper"),
"credential helper mention must appear in auth hint: {combined}"
);
}
#[test]
fn auth_hint_lines_lead_with_not_found_or_misspelled_note_before_remedies() {
let lines = auth_hint_lines("git@github.com:owner/private");
let combined = lines.join("\n");
assert!(
combined.contains("does not exist") && combined.contains("misspelled"),
"must note the repo may not exist / be misspelled: {combined}"
);
assert!(
combined.contains("private"),
"must note the repo may be private: {combined}"
);
let note_pos = combined
.find("does not exist")
.expect("note must be present");
let ssh_pos = combined
.find("git@github.com:owner/private")
.expect("SSH remedy must be present");
assert!(
note_pos < ssh_pos,
"the not-found/misspelled note must precede the SSH remedy: {combined}"
);
assert!(combined.contains("to use SSH"), "{combined}");
assert!(combined.contains("ssh = true"), "{combined}");
assert!(combined.contains("credential helper"), "{combined}");
}
#[test]
fn proxy_hint_lines_mention_https_proxy_and_http_proxy_setting() {
let lines = proxy_hint_lines();
let combined = lines.join("\n");
assert!(
combined.contains("HTTPS_PROXY"),
"HTTPS_PROXY must appear in proxy hint: {combined}"
);
assert!(
combined.contains("http.proxy"),
"http.proxy must appear in proxy hint: {combined}"
);
}
}