use std::sync::mpsc;
use std::time::Duration;
const REMOTE_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum RemoteCheckResult {
Ok,
NotFound,
AuthFailure,
NetworkError(String),
}
#[derive(Debug)]
pub enum RemoteError {
OriginNotFound { url: String },
OriginAuthFailure { url: String },
MirrorNotFound { url: String },
MirrorAuthFailure { url: String },
OfflineAborted,
}
impl std::fmt::Display for RemoteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RemoteError::OriginNotFound { url } => write!(
f,
"origin repository '{url}' does not exist. \
Create it on the forge, then re-run `entangle init`."
),
RemoteError::OriginAuthFailure { url } => write!(
f,
"SSH authentication failed for origin '{url}'. \
Add your SSH public key to the forge, then re-run `entangle init`."
),
RemoteError::MirrorNotFound { url } => write!(
f,
"mirror repository '{url}' does not exist. \
Create it on the forge, then re-run `entangle init`."
),
RemoteError::MirrorAuthFailure { url } => write!(
f,
"SSH authentication failed for mirror '{url}'. \
Add your SSH public key to the forge, then re-run `entangle init`."
),
RemoteError::OfflineAborted => write!(
f,
"Setup cancelled: could not verify remote accessibility. \
Check your network connection and re-run `entangle init`, \
or re-run and accept the offline-override prompt."
),
}
}
}
impl std::error::Error for RemoteError {}
pub fn check_remote(url: &str) -> RemoteCheckResult {
let url_owned = url.to_string();
let (tx, rx) = mpsc::channel::<RemoteCheckResult>();
std::thread::spawn(move || {
let result = do_ls_refs_blocking(&url_owned);
let _ = tx.send(result); });
match rx.recv_timeout(REMOTE_CHECK_TIMEOUT) {
Ok(result) => result,
Err(_) => RemoteCheckResult::NetworkError(format!(
"connection timed out after {} seconds",
REMOTE_CHECK_TIMEOUT.as_secs()
)),
}
}
#[cfg_attr(test, mutants::skip)]
pub fn validate_remotes(origin_url: &str, mirror_url: &str) -> Result<(), RemoteError> {
validate_remotes_with_checker(origin_url, mirror_url, check_remote, |url| {
use dialoguer::{Confirm, theme::ColorfulTheme};
eprintln!("Warning: couldn't reach '{url}'.");
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Accept anyway and proceed with setup?")
.default(false) .interact()
.unwrap_or(false)
})
}
pub fn validate_remotes_with_checker(
origin_url: &str,
mirror_url: &str,
checker: impl Fn(&str) -> RemoteCheckResult,
offline_override: impl Fn(&str) -> bool,
) -> Result<(), RemoteError> {
match checker(origin_url) {
RemoteCheckResult::Ok => {}
RemoteCheckResult::NotFound => {
return Err(RemoteError::OriginNotFound {
url: origin_url.to_string(),
});
}
RemoteCheckResult::AuthFailure => {
return Err(RemoteError::OriginAuthFailure {
url: origin_url.to_string(),
});
}
RemoteCheckResult::NetworkError(_) => {
if !offline_override(origin_url) {
return Err(RemoteError::OfflineAborted);
}
}
}
match checker(mirror_url) {
RemoteCheckResult::Ok => {}
RemoteCheckResult::NotFound => {
return Err(RemoteError::MirrorNotFound {
url: mirror_url.to_string(),
});
}
RemoteCheckResult::AuthFailure => {
return Err(RemoteError::MirrorAuthFailure {
url: mirror_url.to_string(),
});
}
RemoteCheckResult::NetworkError(_) => {
if !offline_override(mirror_url) {
return Err(RemoteError::OfflineAborted);
}
}
}
Ok(())
}
fn do_ls_refs_blocking(url_str: &str) -> RemoteCheckResult {
let repo = match gix::discover(".") {
Ok(r) => r,
Err(e) => {
return RemoteCheckResult::NetworkError(format!(
"not in a git repository (required for remote checks): {e}"
));
}
};
let url = match gix::url::parse(url_str.as_bytes().into()) {
Ok(u) => u,
Err(e) => {
return RemoteCheckResult::NetworkError(format!("invalid remote URL '{url_str}': {e}"));
}
};
let remote = match repo.remote_at(url) {
Ok(r) => r,
Err(e) => return classify_error_chain(&e),
};
let connection = match remote.connect(gix::remote::Direction::Fetch) {
Ok(c) => c,
Err(e) => return classify_error_chain(&e),
};
match connection.ref_map(gix::progress::Discard, Default::default()) {
Ok(_) => RemoteCheckResult::Ok,
Err(e) => classify_error_chain(&e),
}
}
fn classify_error_chain(e: &dyn std::error::Error) -> RemoteCheckResult {
let full_chain = full_error_chain(e);
classify_error_str(&full_chain)
}
#[cfg_attr(test, mutants::skip)]
fn full_error_chain(e: &dyn std::error::Error) -> String {
let mut parts = vec![e.to_string()];
let mut current = e.source();
while let Some(src) = current {
parts.push(src.to_string());
current = src.source();
}
parts.join(": ").to_lowercase()
}
fn classify_error_str(lower: &str) -> RemoteCheckResult {
if lower.contains("permission denied")
|| lower.contains("publickey")
|| lower.contains("authentication failed")
|| lower.contains("access denied")
{
RemoteCheckResult::AuthFailure
} else if lower.contains("repository not found")
|| lower.contains("repository does not exist")
|| lower.contains("no such repository")
{
RemoteCheckResult::NotFound
} else {
RemoteCheckResult::NetworkError(lower.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remote_check_result_ok_is_not_error() {
assert_eq!(RemoteCheckResult::Ok, RemoteCheckResult::Ok);
}
#[test]
fn remote_check_result_variants_are_distinct() {
assert_ne!(RemoteCheckResult::Ok, RemoteCheckResult::NotFound);
assert_ne!(RemoteCheckResult::Ok, RemoteCheckResult::AuthFailure);
assert_ne!(
RemoteCheckResult::Ok,
RemoteCheckResult::NetworkError("x".to_string())
);
assert_ne!(RemoteCheckResult::NotFound, RemoteCheckResult::AuthFailure);
}
#[test]
fn classify_permission_denied_is_auth_failure() {
assert_eq!(
classify_error_str("permission denied (publickey)"),
RemoteCheckResult::AuthFailure
);
}
#[test]
fn classify_publickey_alone_is_auth_failure() {
assert_eq!(
classify_error_str("publickey"),
RemoteCheckResult::AuthFailure
);
}
#[test]
fn classify_authentication_failed_is_auth_failure() {
assert_eq!(
classify_error_str("authentication failed"),
RemoteCheckResult::AuthFailure
);
}
#[test]
fn classify_access_denied_is_auth_failure() {
assert_eq!(
classify_error_str("access denied"),
RemoteCheckResult::AuthFailure
);
}
#[test]
fn classify_repository_not_found_is_not_found() {
assert_eq!(
classify_error_str("error: repository not found"),
RemoteCheckResult::NotFound
);
}
#[test]
fn classify_repository_does_not_exist_is_not_found() {
assert_eq!(
classify_error_str("repository does not exist"),
RemoteCheckResult::NotFound
);
}
#[test]
fn classify_no_such_repository_is_not_found() {
assert_eq!(
classify_error_str("no such repository"),
RemoteCheckResult::NotFound
);
}
#[test]
fn classify_connection_refused_is_network_error() {
let r = classify_error_str("connection refused");
assert!(matches!(r, RemoteCheckResult::NetworkError(_)));
}
#[test]
fn classify_no_route_to_host_is_network_error() {
let r = classify_error_str("no route to host");
assert!(matches!(r, RemoteCheckResult::NetworkError(_)));
}
#[test]
fn classify_unknown_error_is_network_error() {
let r = classify_error_str("something completely unexpected happened");
assert!(matches!(r, RemoteCheckResult::NetworkError(_)));
}
#[test]
fn remote_error_origin_not_found_mentions_url_and_action() {
let e = RemoteError::OriginNotFound {
url: "git@github.com:user/repo.git".to_string(),
};
let s = e.to_string();
assert!(
s.contains("git@github.com:user/repo.git"),
"must mention URL: {s}"
);
assert!(s.contains("does not exist"), "must describe problem: {s}");
}
#[test]
fn remote_error_origin_auth_failure_mentions_url_and_action() {
let e = RemoteError::OriginAuthFailure {
url: "git@github.com:user/repo.git".to_string(),
};
let s = e.to_string();
assert!(
s.contains("git@github.com:user/repo.git"),
"must mention URL: {s}"
);
assert!(
s.contains("SSH authentication"),
"must describe problem: {s}"
);
}
#[test]
fn remote_error_mirror_not_found_mentions_url_and_action() {
let e = RemoteError::MirrorNotFound {
url: "git@tangled.org:user/repo".to_string(),
};
let s = e.to_string();
assert!(
s.contains("git@tangled.org:user/repo"),
"must mention URL: {s}"
);
assert!(s.contains("does not exist"), "must describe problem: {s}");
}
#[test]
fn remote_error_offline_aborted_mentions_network_and_retry() {
let s = RemoteError::OfflineAborted.to_string();
assert!(
s.contains("cancelled") || s.contains("aborted"),
"must say it was cancelled: {s}"
);
assert!(s.contains("entangle init"), "must suggest retry: {s}");
}
#[test]
fn origin_not_found_fails_before_mirror_is_checked() {
let mirror_checked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let mirror_flag = mirror_checked.clone();
let result = validate_remotes_with_checker(
"git@github.com:user/origin.git",
"git@tangled.org:user/mirror",
move |url| {
if url.contains("tangled") {
mirror_flag.store(true, std::sync::atomic::Ordering::SeqCst);
RemoteCheckResult::Ok
} else {
RemoteCheckResult::NotFound
}
},
|_| false, );
assert!(result.is_err(), "NotFound on origin must be an error");
assert!(
!mirror_checked.load(std::sync::atomic::Ordering::SeqCst),
"mirror must not be checked when origin fails with NotFound"
);
assert!(
matches!(result.unwrap_err(), RemoteError::OriginNotFound { .. }),
"error variant must be OriginNotFound"
);
}
#[test]
fn origin_auth_failure_fails_before_mirror_is_checked() {
let mirror_checked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let mirror_flag = mirror_checked.clone();
let result = validate_remotes_with_checker(
"git@github.com:user/origin.git",
"git@tangled.org:user/mirror",
move |url| {
if url.contains("tangled") {
mirror_flag.store(true, std::sync::atomic::Ordering::SeqCst);
RemoteCheckResult::Ok
} else {
RemoteCheckResult::AuthFailure
}
},
|_| false,
);
assert!(result.is_err());
assert!(!mirror_checked.load(std::sync::atomic::Ordering::SeqCst));
assert!(matches!(
result.unwrap_err(),
RemoteError::OriginAuthFailure { .. }
));
}
#[test]
fn both_ok_returns_ok() {
let result = validate_remotes_with_checker(
"git@github.com:user/repo.git",
"git@tangled.org:user/repo",
|_| RemoteCheckResult::Ok,
|_| false,
);
assert!(result.is_ok());
}
#[test]
fn mirror_not_found_returns_mirror_error() {
let result = validate_remotes_with_checker(
"git@github.com:user/repo.git",
"git@tangled.org:user/repo",
|url| {
if url.contains("github") {
RemoteCheckResult::Ok
} else {
RemoteCheckResult::NotFound
}
},
|_| false,
);
assert!(matches!(
result.unwrap_err(),
RemoteError::MirrorNotFound { .. }
));
}
#[test]
fn mirror_auth_failure_returns_mirror_auth_error() {
let result = validate_remotes_with_checker(
"git@github.com:user/repo.git",
"git@tangled.org:user/repo",
|url| {
if url.contains("github") {
RemoteCheckResult::Ok
} else {
RemoteCheckResult::AuthFailure
}
},
|_| false,
);
assert!(matches!(
result.unwrap_err(),
RemoteError::MirrorAuthFailure { .. }
));
}
#[test]
fn origin_network_error_calls_offline_override() {
let override_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = override_called.clone();
let _ = validate_remotes_with_checker(
"git@github.com:user/repo.git",
"git@tangled.org:user/repo",
|_| RemoteCheckResult::NetworkError("no route to host".to_string()),
move |_url| {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
false },
);
assert!(
override_called.load(std::sync::atomic::Ordering::SeqCst),
"offline override must be called for NetworkError on origin"
);
}
#[test]
fn origin_network_error_accepted_continues_to_mirror() {
let mirror_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = mirror_called.clone();
let result = validate_remotes_with_checker(
"git@github.com:user/repo.git",
"git@tangled.org:user/repo",
move |url| {
if url.contains("tangled") {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
RemoteCheckResult::Ok
} else {
RemoteCheckResult::NetworkError("offline".to_string())
}
},
|_| true, );
assert!(result.is_ok());
assert!(
mirror_called.load(std::sync::atomic::Ordering::SeqCst),
"mirror must still be checked when origin NetworkError is accepted"
);
}
#[test]
fn origin_network_error_declined_returns_offline_aborted() {
let result = validate_remotes_with_checker(
"git@github.com:user/repo.git",
"git@tangled.org:user/repo",
|_| RemoteCheckResult::NetworkError("no route to host".to_string()),
|_| false, );
assert!(matches!(result.unwrap_err(), RemoteError::OfflineAborted));
}
#[test]
fn mirror_network_error_accepted_returns_ok() {
let result = validate_remotes_with_checker(
"git@github.com:user/repo.git",
"git@tangled.org:user/repo",
|url| {
if url.contains("github") {
RemoteCheckResult::Ok
} else {
RemoteCheckResult::NetworkError("offline".to_string())
}
},
|_| true, );
assert!(result.is_ok());
}
#[test]
fn mirror_network_error_declined_returns_offline_aborted() {
let result = validate_remotes_with_checker(
"git@github.com:user/repo.git",
"git@tangled.org:user/repo",
|url| {
if url.contains("github") {
RemoteCheckResult::Ok
} else {
RemoteCheckResult::NetworkError("offline".to_string())
}
},
|_| false, );
assert!(matches!(result.unwrap_err(), RemoteError::OfflineAborted));
}
#[test]
#[ignore]
fn check_remote_real_github_ok() {
let result = check_remote("git@github.com:cyrusae/entangle.git");
assert_eq!(
result,
RemoteCheckResult::Ok,
"expected Ok for a real GitHub repo"
);
}
#[test]
#[ignore]
fn check_remote_non_routable_is_network_error() {
let result = check_remote("git@192.0.2.1:user/repo.git");
assert!(
matches!(result, RemoteCheckResult::NetworkError(_)),
"expected NetworkError for non-routable address, got: {result:?}"
);
}
}