use std::path::PathBuf;
use git2::{Cred, CredentialType, FetchOptions, RemoteCallbacks, Repository};
use tracing::{info, warn};
use crate::collect::collector::{FetchOutcome, PerRepoFetch};
use crate::collect::errors::Result;
pub fn fetch_remote(repo: &Repository, remote_name: &str) -> Result<()> {
fetch_remote_with_outcome(repo, remote_name).map(|_| ())
}
pub fn fetch_remote_with_outcome(repo: &Repository, remote_name: &str) -> Result<FetchOutcome> {
let mut remote = match repo.find_remote(remote_name) {
Ok(r) => r,
Err(e) => {
info!(
remote = remote_name,
error = %e,
"no matching remote; skipping fetch (local-only repo)"
);
return Ok(FetchOutcome::Skipped {
reason: "no remote configured".to_string(),
});
}
};
info!("Fetching from remote '{}'", remote_name);
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|url, username_from_url, allowed_types| {
non_interactive_credentials(url, username_from_url, allowed_types)
});
let mut fetch_options = FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) {
Ok(()) => {
info!(remote = remote_name, "fetch succeeded");
Ok(FetchOutcome::Success {
remote: remote_name.to_string(),
})
}
Err(e) => {
let kind = if is_auth_or_transport_error(&e) {
"auth/transport"
} else {
"git"
};
warn!(
remote = remote_name,
error = %e,
kind,
"fetch failed — continuing with local refs"
);
Ok(FetchOutcome::Failed {
remote: remote_name.to_string(),
error: e.to_string(),
})
}
}
}
pub fn fetch_and_record(repo: &Repository, repo_name: &str, remote_name: &str) -> PerRepoFetch {
match fetch_remote_with_outcome(repo, remote_name) {
Ok(outcome) => PerRepoFetch {
repo: repo_name.to_string(),
outcome,
},
Err(e) => PerRepoFetch {
repo: repo_name.to_string(),
outcome: FetchOutcome::Failed {
remote: remote_name.to_string(),
error: e.to_string(),
},
},
}
}
fn non_interactive_credentials(
_url: &str,
username_from_url: Option<&str>,
allowed_types: CredentialType,
) -> std::result::Result<Cred, git2::Error> {
let username = username_from_url.unwrap_or("git");
if allowed_types.contains(CredentialType::SSH_KEY) {
if let Ok(cred) = Cred::ssh_key_from_agent(username) {
return Ok(cred);
}
if let Some(home) = home_dir() {
for key_name in &["id_ed25519", "id_rsa"] {
let private_key = home.join(".ssh").join(key_name);
if private_key.exists() {
if let Ok(cred) = Cred::ssh_key(username, None, private_key.as_path(), None) {
return Ok(cred);
}
}
}
}
}
if allowed_types.contains(CredentialType::USER_PASS_PLAINTEXT) {
let token = std::env::var("GITHUB_TOKEN")
.or_else(|_| std::env::var("GH_TOKEN"))
.ok();
if let Some(tok) = token {
if let Ok(cred) = Cred::userpass_plaintext("x-access-token", &tok) {
return Ok(cred);
}
}
}
if allowed_types.contains(CredentialType::DEFAULT) {
if let Ok(cred) = Cred::default() {
return Ok(cred);
}
}
Err(git2::Error::from_str(
"no non-interactive credentials available \
(tried SSH agent, ~/.ssh/id_ed25519, ~/.ssh/id_rsa, \
GITHUB_TOKEN/GH_TOKEN env, platform credential helper)",
))
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn is_auth_or_transport_error(e: &git2::Error) -> bool {
matches!(
e.class(),
git2::ErrorClass::Ssh
| git2::ErrorClass::Http
| git2::ErrorClass::Net
| git2::ErrorClass::Callback
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_outcome_skipped_for_local_repo() {
let td = tempfile::tempdir().expect("tempdir");
let repo = git2::Repository::init(td.path()).expect("init");
let outcome =
fetch_remote_with_outcome(&repo, "origin").expect("no error expected from soft-fail");
assert!(
matches!(outcome, FetchOutcome::Skipped { .. }),
"expected Skipped, got {outcome:?}"
);
}
#[test]
fn fetch_and_record_sets_repo_name() {
let td = tempfile::tempdir().expect("tempdir");
let repo = git2::Repository::init(td.path()).expect("init");
let prf = fetch_and_record(&repo, "my-repo", "origin");
assert_eq!(prf.repo, "my-repo");
assert!(
matches!(prf.outcome, FetchOutcome::Skipped { .. }),
"expected Skipped, got {:?}",
prf.outcome
);
}
#[test]
fn fetch_remote_with_outcome_returns_failed_for_dead_remote() {
let td_remote = tempfile::tempdir().expect("tempdir for remote");
let _remote = git2::Repository::init_bare(td_remote.path()).expect("init bare");
let td_local = tempfile::tempdir().expect("tempdir for local");
let local = git2::Repository::init(td_local.path()).expect("init local");
let remote_url = td_remote.path().to_str().expect("valid utf8").to_string();
local
.remote("origin", &remote_url)
.expect("add remote origin");
drop(td_remote);
let local2 = git2::Repository::open(td_local.path()).expect("reopen local");
let outcome = fetch_remote_with_outcome(&local2, "origin")
.expect("no Err — soft-fail policy means we return Ok(Failed)");
assert!(
matches!(outcome, FetchOutcome::Failed { .. }),
"expected Failed for dead remote path, got {outcome:?}"
);
let local3 = git2::Repository::open(td_local.path()).expect("reopen local 3");
let prf = fetch_and_record(&local3, "dead-remote-repo", "origin");
assert_eq!(prf.repo, "dead-remote-repo");
assert!(
matches!(prf.outcome, FetchOutcome::Failed { .. }),
"expected Failed in PerRepoFetch, got {:?}",
prf.outcome
);
}
}