use std::borrow::Cow;
use std::ffi::OsStr;
use std::fmt::{self, Display};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crates::thiserror::Error;
use prepare::{GitWorkArea, WorkAreaResult};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CommitId(String);
#[derive(Debug, Error)]
pub enum GitError {
#[error("failed to construct 'git {}' command", subcommand)]
Subcommand {
subcommand: &'static str,
#[source]
source: io::Error,
},
#[error("git error: '{}'", msg)]
Git {
msg: Cow<'static, str>,
#[source]
source: Option<io::Error>,
},
#[error("invalid git ref: '{}'", ref_)]
InvalidRef {
ref_: Cow<'static, str>,
},
#[doc(hidden)]
#[error("unreachable...")]
_NonExhaustive,
}
impl GitError {
pub fn subcommand(subcommand: &'static str, source: io::Error) -> Self {
GitError::Subcommand {
subcommand,
source,
}
}
pub(crate) fn git<M>(msg: M) -> Self
where
M: Into<Cow<'static, str>>,
{
GitError::Git {
msg: msg.into(),
source: None,
}
}
pub(crate) fn git_with_source<M>(msg: M, source: io::Error) -> Self
where
M: Into<Cow<'static, str>>,
{
GitError::Git {
msg: msg.into(),
source: Some(source),
}
}
pub(crate) fn invalid_ref<R>(ref_: R) -> Self
where
R: Into<Cow<'static, str>>,
{
GitError::InvalidRef {
ref_: ref_.into(),
}
}
}
pub(crate) type GitResult<T> = Result<T, GitError>;
impl CommitId {
pub fn new<I: Into<String>>(id: I) -> Self {
CommitId(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Display for CommitId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct GitContext {
gitdir: PathBuf,
config: Option<PathBuf>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Identity {
pub name: String,
pub email: String,
}
impl Identity {
pub fn new<N, E>(name: N, email: E) -> Self
where
N: Into<String>,
E: Into<String>,
{
Self {
name: name.into(),
email: email.into(),
}
}
}
impl Clone for Identity {
fn clone(&self) -> Self {
Self::new(self.name.clone(), self.email.clone())
}
}
impl Display for Identity {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} <{}>", self.name, self.email)
}
}
#[derive(Debug)]
pub enum MergeStatus {
NoCommonHistory,
AlreadyMerged,
Mergeable(Vec<CommitId>),
}
impl Display for MergeStatus {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}",
match *self {
MergeStatus::NoCommonHistory => "no common history",
MergeStatus::AlreadyMerged => "already merged",
MergeStatus::Mergeable(_) => "mergeable",
},
)
}
}
impl GitContext {
pub fn new<P>(gitdir: P) -> Self
where
P: AsRef<Path>,
{
Self {
gitdir: gitdir.as_ref().to_path_buf(),
config: None,
}
}
pub fn new_with_config<P, C>(gitdir: P, config: C) -> Self
where
P: AsRef<Path>,
C: AsRef<Path>,
{
Self {
gitdir: gitdir.as_ref().to_path_buf(),
config: Some(config.as_ref().to_path_buf()),
}
}
pub fn git(&self) -> Command {
let mut git = Command::new("git");
git.env("GIT_DIR", &self.gitdir);
self.config
.as_ref()
.map(|config| git.env("GIT_CONFIG", config));
git
}
pub fn fetch<R, I, N>(&self, remote: R, refnames: I) -> GitResult<()>
where
R: AsRef<str>,
I: IntoIterator<Item = N>,
N: AsRef<OsStr>,
{
let fetch = self
.git()
.arg("fetch")
.arg(remote.as_ref())
.args(refnames.into_iter())
.output()
.map_err(|err| GitError::subcommand("fetch", err))?;
if !fetch.status.success() {
return Err(GitError::git(format!(
"fetch from {} failed: {}",
remote.as_ref(),
String::from_utf8_lossy(&fetch.stderr)
)));
}
Ok(())
}
pub fn fetch_into<R, N, T>(&self, remote: R, refname: N, target: T) -> GitResult<()>
where
R: AsRef<str>,
N: AsRef<str>,
T: AsRef<str>,
{
self.fetch(
remote,
&[&format!("{}:{}", refname.as_ref(), target.as_ref())],
)
}
pub fn force_fetch_into<R, N, T>(&self, remote: R, refname: N, target: T) -> GitResult<()>
where
R: AsRef<str>,
N: AsRef<str>,
T: AsRef<str>,
{
self.fetch_into(
remote.as_ref(),
format!("+{}", refname.as_ref()),
target.as_ref(),
)
}
pub fn prepare(&self, rev: &CommitId) -> WorkAreaResult<GitWorkArea> {
GitWorkArea::new(self.clone(), rev)
}
pub fn reserve_ref<N>(&self, name: N, commit: &CommitId) -> GitResult<(String, usize)>
where
N: AsRef<str>,
{
let ref_prefix = format!("refs/{}/heads", name.as_ref());
debug!(target: "git", "reserving ref under {}", ref_prefix);
loop {
let for_each_ref = self
.git()
.arg("for-each-ref")
.arg("--format=%(refname)")
.arg("--")
.arg(&ref_prefix)
.output()
.map_err(|err| GitError::subcommand("for-each-ref", err))?;
if !for_each_ref.status.success() {
return Err(GitError::git(format!(
"listing all {} refs: {}",
ref_prefix,
String::from_utf8_lossy(&for_each_ref.stderr)
)));
}
let refs = String::from_utf8_lossy(&for_each_ref.stdout);
let nrefs = refs.lines().count();
let new_ref = format!("{}/{}", ref_prefix, nrefs);
debug!(target: "git", "trying to reserve ref {}", new_ref);
let lock_ref = self
.git()
.arg("update-ref")
.arg(&new_ref)
.arg(commit.as_str())
.arg("0000000000000000000000000000000000000000")
.stdout(Stdio::null())
.output()
.map_err(|err| GitError::git_with_source("update-ref", err))?;
if lock_ref.status.success() {
debug!(target: "git", "successfully reserved {}", new_ref);
return Ok((new_ref, nrefs));
}
let err = String::from_utf8_lossy(&lock_ref.stderr);
if err.contains("with nonexistent object") {
return Err(GitError::invalid_ref("no such commit"));
} else if err.contains("not a valid SHA1") {
return Err(GitError::invalid_ref("invalid SHA"));
}
}
}
pub fn reserve_refs<N>(&self, name: N, commit: &CommitId) -> GitResult<(String, String)>
where
N: AsRef<str>,
{
let (new_ref, id) = self.reserve_ref(name.as_ref(), commit)?;
let new_base = format!("refs/{}/bases/{}", name.as_ref(), id);
debug!(target: "git", "successfully reserved {} and {}", new_ref, new_base);
Ok((new_ref, new_base))
}
pub fn mergeable(&self, base: &CommitId, topic: &CommitId) -> GitResult<MergeStatus> {
let merge_base = self
.git()
.arg("merge-base")
.arg("--all")
.arg(base.as_str())
.arg(topic.as_str())
.output()
.map_err(|err| GitError::subcommand("merge-base", err))?;
if !merge_base.status.success() {
return Ok(MergeStatus::NoCommonHistory);
}
let bases = String::from_utf8_lossy(&merge_base.stdout);
let bases = bases
.split_whitespace()
.map(CommitId::new)
.collect::<Vec<_>>();
Ok(if Some(topic) == bases.first() {
MergeStatus::AlreadyMerged
} else {
MergeStatus::Mergeable(bases)
})
}
pub fn gitdir(&self) -> &Path {
&self.gitdir
}
}