use std::{fmt, fs, io, num::NonZeroU32};
use camino::{Utf8Path, Utf8PathBuf};
use git_url::Url;
use serde::{
de::{Deserializer, Error as SerdeDeError},
ser::Serializer,
Deserialize, Serialize,
};
use thiserror::Error as ThisError;
use tokio::process;
use super::{command, file, Status};
#[derive(Debug, ThisError)]
pub(crate) enum Error {
#[error(transparent)]
CommandJob {
#[from]
source: command::Error,
},
#[error("{} already exists", dest)]
DestExists { dest: Utf8PathBuf },
#[error("{} not found", dest)]
DestNotFound { dest: Utf8PathBuf },
#[error(transparent)]
FileJob {
#[from]
source: file::Error,
},
#[error(transparent)]
FromUtf8 {
#[from]
source: std::string::FromUtf8Error,
},
#[error("working `git` not found")]
GitNotFound,
#[error(transparent)]
Io {
#[from]
source: io::Error,
},
#[allow(dead_code)]
#[error("never")]
Never,
#[error(transparent)]
UrlParse {
#[from]
source: git_url::parse::Error,
},
}
impl PartialEq for Error {
fn eq(&self, other: &Error) -> bool {
format!("{:?}", self) == format!("{:?}", other)
}
}
#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "lowercase", tag = "type")]
pub(crate) struct Git {
#[serde(deserialize_with = "crate::convert::into_option_bool")]
pub clone: Option<bool>,
#[serde(deserialize_with = "crate::convert::into_option_nonzero_u32")]
pub depth: Option<NonZeroU32>,
pub dest: Utf8PathBuf,
#[serde(deserialize_with = "crate::convert::into_option_bool")]
pub force: Option<bool>,
#[serde(
deserialize_with = "from_toml_git_url",
serialize_with = "to_toml_git_url"
)]
pub repo: Url,
#[serde(deserialize_with = "crate::convert::into_option_bool")]
pub update: Option<bool>,
}
impl Default for Git {
fn default() -> Self {
Self {
clone: Some(true),
depth: None,
dest: Utf8PathBuf::new(),
force: None,
repo: Url::try_from(String::from("https://gitlab.com/"))
.expect("unable to parse default URL"),
update: Some(true),
}
}
}
impl fmt::Display for Git {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?} -> {:?}", &self.repo, &self.dest)
}
}
impl Git {
pub async fn execute(&self) -> Result {
if !has_git().await {
return Err(Error::GitNotFound);
}
if !self.dest.exists() && !self.clone.unwrap_or(true) {
return Err(Error::DestNotFound {
dest: self.dest.clone(),
});
}
let mut before = String::from("absent");
if self.dest.exists() {
if is_git_repository(&self.dest).await
&& is_git_url_eq(&git_origin_remote_url(&self.dest).await?, &self.repo)
{
if !self.update.unwrap_or(true) {
return Ok(Status::no_change(current_commit(&self.dest).await?));
}
return self.git_pull().await;
}
if self.force.unwrap_or(false) {
before = String::from("not repository");
if self.dest.is_dir() {
fs::remove_dir_all(&self.dest)?;
} else {
fs::remove_file(&self.dest)?;
}
} else {
return Err(Error::DestExists {
dest: self.dest.clone(),
});
}
}
self.git_clone().await?;
Ok(Status::changed(before, current_commit(&self.dest).await?))
}
async fn git_clone(&self) -> std::result::Result<(), Error> {
let mut argv: Vec<String> = vec![String::from("clone")];
if let Some(depth) = self.depth {
argv.push(String::from("--depth"));
argv.push(format!("{}", depth));
}
argv.push(format!("{}", self.repo.to_bstring()));
argv.push(String::from(self.dest.as_str()));
let clone = command::Command {
argv,
command: String::from("git"),
..Default::default()
};
clone.execute().await?;
Ok(())
}
async fn git_fetch(&self) -> Result {
let before = current_commit(&self.dest).await?;
let mut argv: Vec<String> = vec![String::from("fetch")];
if let Some(depth) = self.depth {
argv.push(String::from("--depth"));
argv.push(format!("{}", depth));
}
let fetch = command::Command {
argv,
chdir: Some(self.dest.clone()),
command: String::from("git"),
..Default::default()
};
fetch.execute().await?;
let after = current_commit(&self.dest).await?;
Ok(Status::changed(before, after))
}
async fn git_hard_reset(&self) -> Result {
let before = current_commit(&self.dest).await?;
let reset = command::Command {
argv: vec![
String::from("reset"),
String::from("--hard"),
String::from("FETCH_HEAD"),
],
chdir: Some(self.dest.clone()),
command: String::from("git"),
..Default::default()
};
reset.execute().await?;
let after = current_commit(&self.dest).await?;
Ok(Status::changed(before, after))
}
async fn git_pull(&self) -> Result {
let before = current_commit(&self.dest).await?;
let mut argv: Vec<String> = vec![String::from("pull")];
if let Some(depth) = self.depth {
argv.push(String::from("--depth"));
argv.push(format!("{}", depth));
}
let pull = command::Command {
argv,
chdir: Some(self.dest.clone()),
command: String::from("git"),
..Default::default()
};
if pull.execute().await.is_err() && self.force.unwrap_or(false) {
self.git_fetch().await?;
self.git_hard_reset().await?;
}
let after = current_commit(&self.dest).await?;
Ok(Status::changed(before, after))
}
}
pub(crate) type Result = std::result::Result<Status, Error>;
async fn current_commit<P>(path: P) -> std::result::Result<String, Error>
where
P: AsRef<Utf8Path>,
{
let p = path.as_ref();
let o = process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(p)
.output()
.await?;
Ok(String::from_utf8(o.stdout)?)
}
fn from_toml_git_url<'de, D>(deserializer: D) -> std::result::Result<Url, D::Error>
where
D: Deserializer<'de>,
{
let s = toml::value::Value::deserialize(deserializer)?
.try_into::<String>()
.map_err(SerdeDeError::custom)?;
let u = Url::try_from(s).map_err(SerdeDeError::custom)?;
Ok(u)
}
async fn git_origin_remote_url<P>(path: P) -> std::result::Result<Url, Error>
where
P: AsRef<Utf8Path>,
{
let p = path.as_ref();
let o = process::Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(p)
.output()
.await?;
if o.status.success() {
let u = Url::try_from(String::from_utf8(o.stdout)?)?;
Ok(u)
} else {
Err(Error::CommandJob {
source: command::Error::NonZeroExitStatus {
cmd: String::from("git"),
},
})
}
}
async fn has_git() -> bool {
match process::Command::new("git")
.args(["--version"])
.output()
.await
{
Ok(o) => o.status.success(),
Err(_) => false,
}
}
async fn is_git_repository<P>(path: P) -> bool
where
P: AsRef<Utf8Path>,
{
let p = path.as_ref();
if !p.is_dir() {
return false;
}
match process::Command::new("git")
.args(["status"])
.current_dir(p)
.output()
.await
{
Ok(o) => o.status.success(),
Err(_) => false,
}
}
fn is_git_url_eq(a: &Url, b: &Url) -> bool {
if a == b {
return true;
}
normalize_git_url(a) == normalize_git_url(b)
}
fn normalize_git_url(u: &Url) -> Url {
let mut n = u.clone();
if n.scheme == git_url::Scheme::Ssh {
if n.user() == Some("git") {
n.set_user(None);
}
let p = n.path.to_string();
if p.starts_with("//") {
n.path = p.replace("//", "/").into();
}
}
if n.scheme != git_url::Scheme::Https {
n.scheme = git_url::Scheme::Https;
}
n
}
fn to_toml_git_url<S>(input: &Url, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("{}", input.to_bstring()))
}
#[allow(clippy::similar_names)]
#[cfg(test)]
mod tests {
use crate::status::Satisfying;
use super::super::file::tests::{temp_dir, temp_file};
use super::*;
const DOTFILES_REPOSITORY: &str = "https://gitlab.com/jokeyrhyme/dotfiles.git";
#[tokio::test]
async fn error_when_absent_and_no_clone() -> std::result::Result<(), Error> {
let git = Git {
clone: Some(false),
dest: temp_dir()?.join("absent"),
repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
..Default::default()
};
match git.execute().await {
Ok(_) => unreachable!(),
Err(e) => assert_eq!(e, Error::DestNotFound { dest: git.dest }),
}
Ok(())
}
#[tokio::test]
async fn git_pull_when_exists_and_matching_remote() -> std::result::Result<(), Error> {
let git = Git {
dest: temp_dir()?.join("exists"),
repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
..Default::default()
};
let got = git.execute().await?;
match got {
Status::Satisfying(Satisfying::Changed(_, _)) => {}
_ => unreachable!(),
}
Ok(())
}
#[tokio::test]
async fn rm_and_git_clone_when_not_repo_and_force() -> std::result::Result<(), Error> {
let f = file::File {
path: temp_file()?.to_path_buf(),
state: file::FileState::Directory,
..Default::default()
};
f.execute().await?;
let git = Git {
dest: f.path,
force: Some(true),
repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
..Default::default()
};
let got = git.execute().await?;
match got {
Status::Satisfying(Satisfying::Changed(before, _)) => {
assert_eq!(before, String::from("not repository"));
}
_ => unreachable!(),
}
Ok(())
}
#[tokio::test]
async fn rm_and_git_clone_when_no_matching_remote_and_force() -> std::result::Result<(), Error>
{
let mut git = Git {
dest: temp_dir()?.join("mismatch"),
repo: Url::try_from(DOTFILES_REPOSITORY).expect("unable to parse dotfiles URL"),
..Default::default()
};
git.execute().await?;
git = Git {
dest: git.dest,
force: Some(true),
repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
..Default::default()
};
let got = git.execute().await?;
match got {
Status::Satisfying(Satisfying::Changed(before, _)) => {
assert_eq!(before, String::from("not repository"));
}
_ => unreachable!(),
}
Ok(())
}
#[tokio::test]
async fn error_when_not_repo_and_no_force() -> std::result::Result<(), Error> {
let f = file::File {
path: temp_file()?.to_path_buf(),
state: file::FileState::Directory,
..Default::default()
};
f.execute().await?;
let git = Git {
dest: f.path,
repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
..Default::default()
};
match git.execute().await {
Ok(_) => unreachable!(),
Err(e) => assert_eq!(e, Error::DestExists { dest: git.dest }),
}
Ok(())
}
#[tokio::test]
async fn error_when_no_matching_remote_and_no_force() -> std::result::Result<(), Error> {
let mut git = Git {
dest: temp_dir()?.join("mismatch"),
repo: Url::try_from(DOTFILES_REPOSITORY).expect("unable to parse dotfiles URL"),
..Default::default()
};
git.execute().await?;
git = Git {
dest: git.dest,
repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
..Default::default()
};
match git.execute().await {
Ok(_) => unreachable!(),
Err(e) => assert_eq!(e, Error::DestExists { dest: git.dest }),
}
Ok(())
}
#[tokio::test]
async fn git_clone_when_absent() -> std::result::Result<(), Error> {
let git = Git {
dest: temp_dir()?.join("absent"),
repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
..Default::default()
};
let got = git.execute().await?;
match got {
Status::Satisfying(Satisfying::Changed(before, _)) => {
assert_eq!(before, String::from("absent"));
}
_ => unreachable!(),
}
Ok(())
}
#[test]
fn is_git_url_eq_with_same_urls() {
assert!(is_git_url_eq(
&Url::try_from(String::from("https://gitlab.com/jokeyrhyme/tuning.git"))
.expect("unable to parse test URL"),
&Url::try_from(String::from("https://gitlab.com/jokeyrhyme/tuning.git"))
.expect("unable to parse test URL")
));
}
#[test]
fn is_git_url_eq_with_ssh_versus_https_urls() {
assert!(is_git_url_eq(
&Url::try_from(String::from("git@gitlab.com:/jokeyrhyme/tuning.git"))
.expect("unable to parse test URL"),
&Url::try_from(String::from("https://gitlab.com/jokeyrhyme/tuning.git"))
.expect("unable to parse test URL")
));
}
}