use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result, anyhow, bail};
#[derive(Debug, Clone)]
pub struct Git {
dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub name: String,
pub oid: String,
pub kind: EntryKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
Blob,
Tree,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Stamp {
pub commit: String,
pub branch: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Distance {
Behind(u64),
Elsewhere,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Merged {
pub tree: String,
pub conflicts: Vec<Conflict>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Conflict {
pub path: String,
pub base: Option<String>,
pub ours: Option<String>,
pub theirs: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Push {
Done,
Rejected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Swap {
Done,
Lost,
}
impl Git {
pub fn open(dir: &Path) -> Result<Git> {
let git = Git {
dir: dir.to_path_buf(),
};
git.run(&["rev-parse", "--git-dir"])
.with_context(|| format!("{} is not inside a git repository", dir.display()))?;
Ok(git)
}
fn command(&self, args: &[&str]) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(&self.dir).args(args);
cmd
}
fn run(&self, args: &[&str]) -> Result<Vec<u8>> {
self.run_with_input(args, &[])
}
fn run_with_input(&self, args: &[&str], input: &[u8]) -> Result<Vec<u8>> {
let mut child = self
.command(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to run git")?;
let mut stdin = child.stdin.take().expect("stdin was piped");
let input = input.to_vec();
let feeder = std::thread::spawn(move || stdin.write_all(&input));
let out = child.wait_with_output()?;
feeder.join().expect("feeder thread panicked")?;
if !out.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(out.stdout)
}
pub fn rev_parse(&self, rev: &str) -> Result<Option<String>> {
let out = self
.command(&["rev-parse", "--verify", "-q", rev])
.stderr(Stdio::null())
.output()?;
if !out.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8(out.stdout)?.trim_end().to_string()))
}
pub fn ls_tree(&self, commit: &str) -> Result<Vec<(String, String)>> {
let out = self.run(&["ls-tree", "-r", "-z", "--full-tree", commit])?;
let mut entries = Vec::new();
for record in out.split(|b| *b == 0).filter(|r| !r.is_empty()) {
let record = std::str::from_utf8(record)?;
let (meta, path) = record
.split_once('\t')
.ok_or_else(|| anyhow!("malformed ls-tree record: {record}"))?;
let oid = meta
.rsplit(' ')
.next()
.ok_or_else(|| anyhow!("malformed ls-tree record: {record}"))?;
entries.push((path.to_string(), oid.to_string()));
}
Ok(entries)
}
pub fn cat_file_batch(&self, oids: &[&str]) -> Result<Vec<Vec<u8>>> {
if oids.is_empty() {
return Ok(Vec::new());
}
let mut input = oids.join("\n");
input.push('\n');
let out = self.run_with_input(&["cat-file", "--batch"], input.as_bytes())?;
let mut blobs = Vec::with_capacity(oids.len());
let mut rest = &out[..];
for oid in oids {
let nl = rest
.iter()
.position(|b| *b == b'\n')
.ok_or_else(|| anyhow!("truncated cat-file output at {oid}"))?;
let header = std::str::from_utf8(&rest[..nl])?;
let size: usize = header
.rsplit(' ')
.next()
.and_then(|s| s.parse().ok())
.ok_or_else(|| anyhow!("object {oid} is missing: {header}"))?;
let start = nl + 1;
blobs.push(rest[start..start + size].to_vec());
rest = &rest[start + size + 1..];
}
Ok(blobs)
}
pub fn hash_object(&self, content: &[u8]) -> Result<String> {
let out = self.run_with_input(&["hash-object", "-w", "--stdin"], content)?;
Ok(String::from_utf8(out)?.trim_end().to_string())
}
pub fn mktree(&self, entries: &[TreeEntry]) -> Result<String> {
let mut input = String::new();
for e in entries {
let (mode, kind) = match e.kind {
EntryKind::Blob => ("100644", "blob"),
EntryKind::Tree => ("040000", "tree"),
};
input.push_str(&format!("{mode} {kind} {}\t{}\n", e.oid, e.name));
}
let out = self.run_with_input(&["mktree"], input.as_bytes())?;
Ok(String::from_utf8(out)?.trim_end().to_string())
}
pub fn commit_tree(&self, tree: &str, parents: &[&str], message: &str) -> Result<String> {
let mut args = vec!["commit-tree", tree];
for p in parents {
args.push("-p");
args.push(p);
}
args.push("-m");
args.push(message);
let out = self.run(&args)?;
Ok(String::from_utf8(out)?.trim_end().to_string())
}
pub fn update_ref(&self, name: &str, new: &str, old: Option<&str>) -> Result<Swap> {
let out = self
.command(&["update-ref", name, new, old.unwrap_or("")])
.stderr(Stdio::piped())
.output()?;
if out.status.success() {
return Ok(Swap::Done);
}
let err = String::from_utf8_lossy(&out.stderr);
if err.contains("cannot lock ref") || err.contains("but expected") {
return Ok(Swap::Lost);
}
bail!("git update-ref {name} failed: {}", err.trim());
}
pub fn head_stamp(&self) -> Result<Stamp> {
let commit = self
.rev_parse("HEAD")?
.map(|oid| oid[..7].to_string())
.unwrap_or_else(|| "none".to_string());
let branch = self
.command(&["symbolic-ref", "--short", "-q", "HEAD"])
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim_end().to_string())
.unwrap_or_else(|| "detached".to_string());
Ok(Stamp { commit, branch })
}
pub fn config(&self, key: &str) -> Option<String> {
let out = self
.command(&["config", "--get", key])
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())?;
let value = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
(!value.is_empty()).then_some(value)
}
pub fn hooks_redirected(&self) -> bool {
self.config("core.hooksPath").is_some()
}
pub fn distance(&self, commit: &str) -> Distance {
if self
.rev_parse(&format!("{commit}^{{commit}}"))
.ok()
.flatten()
.is_none()
{
return Distance::Unknown;
}
let ancestor = self
.command(&["merge-base", "--is-ancestor", commit, "HEAD"])
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ancestor {
return Distance::Elsewhere;
}
let range = format!("{commit}..HEAD");
self.command(&["rev-list", "--count", &range])
.stderr(Stdio::null())
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
.map(Distance::Behind)
.unwrap_or(Distance::Unknown)
}
pub fn toplevel(&self) -> Result<PathBuf> {
let out = self.run(&["rev-parse", "--show-toplevel"])?;
Ok(PathBuf::from(String::from_utf8(out)?.trim_end()))
}
pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
let status = self
.command(&["merge-base", "--is-ancestor", ancestor, descendant])
.stderr(Stdio::null())
.status()?;
Ok(status.success())
}
pub fn merge_tree(&self, ours: &str, theirs: &str) -> Result<Merged> {
let out = self
.command(&[
"merge-tree",
"--write-tree",
"--allow-unrelated-histories",
"-z",
ours,
theirs,
])
.stderr(Stdio::piped())
.output()?;
if !matches!(out.status.code(), Some(0 | 1)) {
bail!(
"git merge-tree failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let mut records = out.stdout.split(|b| *b == 0);
let tree = std::str::from_utf8(records.next().unwrap_or_default())?.to_string();
let mut conflicts: Vec<Conflict> = Vec::new();
for record in records {
if record.is_empty() {
break;
}
let record = std::str::from_utf8(record)?;
let (meta, path) = record
.split_once('\t')
.ok_or_else(|| anyhow!("malformed merge-tree record: {record}"))?;
let mut fields = meta.split(' ');
let (Some(_mode), Some(oid), Some(stage)) =
(fields.next(), fields.next(), fields.next())
else {
bail!("malformed merge-tree record: {record}");
};
let entry = match conflicts.iter_mut().find(|c| c.path == path) {
Some(c) => c,
None => {
conflicts.push(Conflict {
path: path.to_string(),
..Default::default()
});
conflicts.last_mut().unwrap()
}
};
let slot = match stage {
"1" => &mut entry.base,
"2" => &mut entry.ours,
"3" => &mut entry.theirs,
_ => bail!("unexpected merge stage in: {record}"),
};
*slot = Some(oid.to_string());
}
Ok(Merged { tree, conflicts })
}
pub fn ls_remote(&self, remote: &str, name: &str) -> Result<bool> {
let out = self
.command(&["ls-remote", "--exit-code", remote, name])
.stderr(Stdio::piped())
.output()?;
match out.status.code() {
Some(0) => Ok(true),
Some(2) => Ok(false),
_ => bail!(
"git ls-remote {remote} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
),
}
}
pub fn fetch(&self, remote: &str, refspec: &str) -> Result<()> {
self.run(&["fetch", "--quiet", remote, refspec])?;
Ok(())
}
pub fn push(&self, remote: &str, refspec: &str) -> Result<Push> {
let out = self
.command(&["push", "--quiet", remote, refspec])
.env("FOAM_IN_HOOK", "1")
.stderr(Stdio::piped())
.output()?;
if out.status.success() {
return Ok(Push::Done);
}
let err = String::from_utf8_lossy(&out.stderr);
if err.contains("[rejected]")
|| err.contains("non-fast-forward")
|| err.contains("fetch first")
{
return Ok(Push::Rejected);
}
bail!("git push failed: {}", err.trim());
}
pub fn remote_url(&self, remote: &str) -> Option<String> {
self.config(&format!("remote.{remote}.url"))
}
pub fn config_all(&self, key: &str) -> Vec<String> {
self.command(&["config", "--get-all", key])
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
pub fn config_add(&self, key: &str, value: &str) -> Result<()> {
self.run(&["config", "--add", key, value])?;
Ok(())
}
pub fn hooks_dir(&self) -> Result<PathBuf> {
let out = self.run(&["rev-parse", "--git-path", "hooks"])?;
let path = PathBuf::from(String::from_utf8(out)?.trim_end());
Ok(if path.is_absolute() {
path
} else {
self.dir.join(path)
})
}
pub fn log(
&self,
name: &str,
limit: usize,
word: Option<&str>,
) -> Result<Vec<(String, String, String)>> {
let limit = limit.to_string();
let mut args = vec!["log", "--format=%H%x00%cI%x00%s", "-n", &limit];
let grep;
if let Some(w) = word {
grep = format!("--grep={}", regex_escape(w));
args.push(&grep);
}
args.push(name);
let out = self.run(&args)?;
let text = String::from_utf8(out)?;
Ok(text
.lines()
.filter_map(|l| {
let mut f = l.splitn(3, '\0');
Some((
f.next()?.to_string(),
f.next()?.to_string(),
f.next()?.to_string(),
))
})
.collect())
}
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if "\\.[]*^$".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}