use std::path::PathBuf;
use std::str::FromStr;
use git2::{Cred, FetchOptions, PushOptions, Remote, RemoteCallbacks, Repository};
use miette::{Context, IntoDiagnostic};
use url::Url;
#[derive(Debug, Clone)]
pub struct OwnerRepo {
pub owner: String,
pub repo: String,
}
impl FromStr for OwnerRepo {
type Err = miette::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (owner, repo) = s.split_once('/').ok_or_else(|| {
miette::miette!("Please provide the repository in the format OWNER/REPO.")
})?;
Ok(OwnerRepo {
owner: owner.to_owned(),
repo: repo.to_owned(),
})
}
}
#[derive(Debug, Clone)]
pub enum MaybeOwnerRepo {
ImplicitOwner(String),
ExplicitOwner(OwnerRepo),
}
impl FromStr for MaybeOwnerRepo {
type Err = miette::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(s.split_once("/")
.map(|(owner, repo)| OwnerRepo {
owner: owner.to_string(),
repo: repo.to_string(),
})
.map(Self::ExplicitOwner)
.unwrap_or(Self::ImplicitOwner(s.to_string())))
}
}
pub struct Git {
pub repo: Option<Repository>,
}
impl std::fmt::Display for OwnerRepo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{owner}/{repo}", owner = self.owner, repo = self.repo)
}
}
impl Default for Git {
fn default() -> Self {
Self::new(".")
}
}
fn basic_auth_callbacks() -> RemoteCallbacks<'static> {
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, allowed| {
if allowed.is_ssh_key() {
Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
} else if allowed.is_user_pass_plaintext() {
Cred::default()
} else {
Err(git2::Error::from_str(
miette::miette!(
help = "Try SSH or User/Pass authentication",
"No supported authentication method found, but one was required!"
)
.to_string()
.as_str(),
))
}
});
callbacks
}
impl Git {
pub fn clone(url: Url, destination: impl AsRef<str>) -> miette::Result<()> {
let destination = std::path::Path::new(destination.as_ref());
println!(
"Cloning from '{url}' into '{destination}'",
destination = destination.display()
);
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(basic_auth_callbacks());
git2::build::RepoBuilder::new()
.fetch_options(fetch_opts)
.clone(url.as_str(), destination)
.map_err(|e| {
miette::miette!(
help = format!(
"Does the directory {dir} exist locally?\nDoes the repository exist remotely?",
dir = destination.display()
),
"{e}"
)
.context("Cloning repository failed!")
})?;
Ok(())
}
pub fn new(path: impl AsRef<std::path::Path>) -> Self {
Self {
repo: Repository::discover(path).ok(),
}
}
pub fn origin(&self) -> Option<Remote<'_>> {
self.repo.as_ref().and_then(|repo| {
let remotes_list = repo.remotes().ok()?;
remotes_list
.into_iter()
.flatten()
.find_map(|remote| repo.find_remote(remote).ok())
})
}
pub fn remotes(&self) -> miette::Result<Vec<Remote<'_>>> {
let repo = self
.repo
.as_ref()
.context("No repository found in the current path even though one is needed!")?;
let remotes = repo
.remotes()
.into_diagnostic()?
.into_iter()
.filter_map(|remote| {
let remote = remote?;
let remote = repo.find_remote(remote).ok()?;
Some(remote)
})
.collect::<Vec<_>>();
Ok(remotes)
}
pub fn owner_repo(&self) -> miette::Result<OwnerRepo> {
let remotes = self
.remotes()?
.into_iter()
.filter_map(|remote| {
let mut git_url = remote.url().map(PathBuf::from)?;
let repo = git_url
.file_name()?
.to_str()?
.trim_end_matches(".git")
.to_owned();
git_url.pop();
let https_owner = git_url
.file_name()?
.to_str()
.filter(|owner| !owner.contains(":"))
.map(|owner| owner.to_owned());
let ssh_owner = git_url
.to_str()?
.split_once(":")
.map(|(_junk, owner)| owner.to_owned());
let remote_name = remote.name()?.to_owned();
Some((remote_name, https_owner.or(ssh_owner)?, repo))
})
.inspect(|x| tracing::debug!("{x:?}"))
.collect::<Vec<_>>();
remotes
.iter()
.find_map(|(name, owner, repo)| (*name == "origin").then_some((owner, repo)))
.or_else(|| remotes.first().map(|(_, owner, repo)| (owner, repo)))
.map(|(owner, repo)| OwnerRepo {
owner: owner.clone(),
repo: repo.clone(),
})
.context("Couldn't find owner and repo")
}
pub fn push(&self, remote_name: impl Into<Option<String>>, url: &Url) -> miette::Result<()> {
const FALLBACK_REMOTE_NAMES: [&str; 2] = ["origin", "origin-new"];
let repo = self.repo.as_ref().context("No repository detected in ")?;
let mut remote = remote_name
.into()
.as_deref()
.into_iter()
.chain(FALLBACK_REMOTE_NAMES)
.find_map(|name| {
let remote = repo.remote(name, url.as_str()).ok()?;
println!("Set remote {name} to {url}", url = url.as_str());
Some(remote)
})
.wrap_err(miette::miette!(
help =
"Try to pick a remote name interactively or rename on of the existing remotes",
"Remote names 'origin' and 'new_origin' already exist!"
))?;
let mut push_opts = PushOptions::default();
push_opts.remote_callbacks(basic_auth_callbacks());
let head = repo.head().into_diagnostic()?;
let branch = head
.shorthand()
.context("No branch name found for current git HEAD")?;
let refspec = format!("refs/heads/{0}:refs/heads/{0}", branch);
remote
.push(&[refspec], Some(&mut push_opts))
.into_diagnostic()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_owner_repo_valid_parsing() {
let result = OwnerRepo::from_str("owner/repo").unwrap();
assert_eq!(result.owner, "owner");
assert_eq!(result.repo, "repo");
}
#[test]
fn test_owner_repo_with_hyphens() {
let result = OwnerRepo::from_str("my-owner/my-repo").unwrap();
assert_eq!(result.owner, "my-owner");
assert_eq!(result.repo, "my-repo");
}
#[test]
fn test_owner_repo_with_underscores() {
let result = OwnerRepo::from_str("my_owner/my_repo").unwrap();
assert_eq!(result.owner, "my_owner");
assert_eq!(result.repo, "my_repo");
}
#[test]
fn test_owner_repo_invalid_no_slash() {
let result = OwnerRepo::from_str("invalid");
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Please provide the repository in the format OWNER/REPO."
);
}
#[test]
fn test_owner_repo_invalid_multiple_slashes() {
let result = OwnerRepo::from_str("owner/repo/extra").unwrap();
assert_eq!(result.owner, "owner");
assert_eq!(result.repo, "repo/extra");
}
#[test]
fn test_owner_repo_empty_owner() {
let result = OwnerRepo::from_str("/repo").unwrap();
assert_eq!(result.owner, "");
assert_eq!(result.repo, "repo");
}
#[test]
fn test_owner_repo_empty_repo() {
let result = OwnerRepo::from_str("owner/").unwrap();
assert_eq!(result.owner, "owner");
assert_eq!(result.repo, "");
}
}