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))
}
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"])
}
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 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("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);
}
#[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"
);
}
}