use crate::errors::*;
use determinator::Utf8Paths0;
use guppy::{graph::PackageGraph, MetadataCommand};
use indoc::formatdoc;
use log::{debug, info};
use once_cell::sync::OnceCell;
use std::{
borrow::Cow,
ffi::{OsStr, OsString},
fmt,
path::{Path, PathBuf},
process::{Command, Stdio},
};
#[derive(Clone, Debug)]
pub struct GitCli {
root: &'static Path,
tracked_files: OnceCell<Utf8Paths0>,
}
impl GitCli {
pub fn new(root: &'static Path) -> Result<Self> {
let git_cli = Self {
root,
tracked_files: OnceCell::new(),
};
git_cli.validate()?;
Ok(git_cli)
}
pub fn tracked_files(&self) -> Result<&Utf8Paths0> {
self.tracked_files.get_or_try_init(|| {
let output = self
.git_command()
.args(&["ls-files", "-z"])
.output()
.map_err(|err| SystemError::io("running git ls-files", err))?;
if !output.status.success() {
return Err(SystemError::Exec {
cmd: "git ls-files",
status: output.status,
});
}
Utf8Paths0::from_bytes(output.stdout)
.map_err(|(path, err)| SystemError::NonUtf8Path { path, err })
})
}
pub fn merge_base(&self, commit_ref: &str) -> Result<GitHash> {
let output = self
.git_command()
.args(&["merge-base", "HEAD", commit_ref])
.output()
.map_err(|err| {
SystemError::io(format!("running git merge-base HEAD {}", commit_ref), err)
})?;
if !output.status.success() {
return Err(SystemError::Exec {
cmd: "git merge-base",
status: output.status,
});
}
let stdout = &output.stdout[..(output.stdout.len() - 1)];
GitHash::from_hex(stdout)
}
pub fn files_changed_between<'a>(
&self,
old: impl Into<Cow<'a, OsStr>>,
new: impl Into<Option<Cow<'a, OsStr>>>,
diff_filter: Option<&str>,
) -> Result<Utf8Paths0> {
let mut command = self.git_command();
command.args(&["diff", "-z", "--name-only"]);
if let Some(diff_filter) = diff_filter {
command.arg(format!("--diff-filter={}", diff_filter));
}
command.arg(old.into());
if let Some(new) = new.into() {
command.arg(new);
}
let output = command
.output()
.map_err(|err| SystemError::io("running git diff", err))?;
if !output.status.success() {
return Err(SystemError::Exec {
cmd: "git diff",
status: output.status,
});
}
Utf8Paths0::from_bytes(output.stdout)
.map_err(|(path, err)| SystemError::NonUtf8Path { path, err })
}
pub fn package_graph_at(&self, commit_ref: &GitHash) -> Result<PackageGraph> {
let scratch = self.get_or_init_scratch(commit_ref)?;
MetadataCommand::new()
.current_dir(scratch)
.build_graph()
.map_err(|err| SystemError::guppy("building package graph", err))
}
fn validate(&self) -> Result<()> {
let output = self
.git_command()
.args(&["rev-parse", "--show-toplevel"])
.stderr(Stdio::inherit())
.output()
.map_err(|err| SystemError::io("running git rev-parse --show-toplevel", err))?;
if !output.status.success() {
let msg = formatdoc!(
"unable to find a git repo at {}
(hint: did you download an archive from GitHub? x requires a git clone)",
self.root.display()
);
return Err(SystemError::git_root(msg));
}
let mut git_root_bytes = output.stdout;
git_root_bytes.pop();
let git_root = match String::from_utf8(git_root_bytes) {
Ok(git_root) => git_root,
Err(_) => {
return Err(SystemError::git_root(
"git rev-parse --show-toplevel returned a non-Unicode path",
));
}
};
if self.root != Path::new(&git_root) {
let msg = formatdoc!(
"git root expected to be at {}, but actually found at {}
(hint: did you download an archive from GitHub? x requires a git clone)",
self.root.display(),
git_root,
);
return Err(SystemError::git_root(msg));
}
Ok(())
}
fn git_command(&self) -> Command {
let mut command = Command::new("git");
command.current_dir(self.root).stderr(Stdio::inherit());
command
}
fn get_or_init_scratch(&self, hash: &GitHash) -> Result<PathBuf> {
let mut scratch_dir = self.root.join("target");
scratch_dir.extend(&["x-scratch", "tree"]);
if scratch_dir.is_dir() {
debug!(
"Using existing scratch worktree at {}",
scratch_dir.display()
);
let output = self
.git_command()
.current_dir(&scratch_dir)
.args(&["reset", &format!("{:x}", hash), "--hard"])
.output()
.map_err(|err| SystemError::io("running git checkout in scratch tree", err))?;
if !output.status.success() {
return Err(SystemError::Exec {
cmd: "git checkout",
status: output.status,
});
}
} else {
info!("Setting up scratch worktree in {}", scratch_dir.display());
let output = self
.git_command()
.args(&["worktree", "add"])
.arg(&scratch_dir)
.args(&[&format!("{:x}", hash), "--detach"])
.output()
.map_err(|err| SystemError::io("running git worktree add", err))?;
if !output.status.success() {
return Err(SystemError::Exec {
cmd: "git worktree add",
status: output.status,
});
}
}
Ok(scratch_dir)
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct GitHash([u8; 20]);
impl GitHash {
pub fn from_hex(hex: impl AsRef<[u8]>) -> Result<Self> {
let hex = hex.as_ref();
Ok(GitHash(hex::FromHex::from_hex(hex).map_err(|err| {
SystemError::from_hex(format!("parsing a Git hash: {:?}", hex), err)
})?))
}
}
impl<'a, 'b> From<&'a GitHash> for Cow<'b, OsStr> {
fn from(git_hash: &'a GitHash) -> Cow<'b, OsStr> {
OsString::from(format!("{:x}", git_hash)).into()
}
}
impl fmt::LowerHex for GitHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", hex::encode(&self.0))
}
}