use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context as _, Result, bail};
use super::Record;
pub const REF: &str = "refs/moat/anchors";
const FILE: &str = "anchors.jsonl";
pub struct Repo {
program: PathBuf,
dir: PathBuf,
}
impl Repo {
pub fn new(program: &Path, dir: &Path) -> Result<Self> {
let repo = Self {
program: program.to_path_buf(),
dir: dir.to_path_buf(),
};
if repo.run(&["rev-parse", "--git-dir"], None).is_err() {
bail!("{} is not a git repository", dir.display());
}
Ok(repo)
}
fn run(&self, args: &[&str], stdin: Option<&[u8]>) -> Result<String> {
let mut child = Command::new(&self.program)
.arg("-C")
.arg(&self.dir)
.args(["--no-pager", "-c", "core.fsmonitor=false"])
.args(args)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_OBJECT_DIRECTORY")
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("running {}", self.program.display()))?;
if let (Some(bytes), Some(mut pipe)) = (stdin, child.stdin.take()) {
pipe.write_all(bytes).context("writing to git")?;
}
let out = child.wait_with_output().context("waiting for git")?;
if !out.status.success() {
bail!(
"git {} failed: {}",
args.first().copied().unwrap_or_default(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_owned())
}
fn tip(&self) -> Option<String> {
let target = format!("{REF}^{{commit}}");
self.run(&["rev-parse", "--verify", "--quiet", &target], None)
.ok()
.filter(|tip| !tip.is_empty())
}
fn file_at(&self, tip: Option<&str>) -> Result<String> {
match tip {
None => Ok(String::new()),
Some(tip) => self
.run(&["cat-file", "blob", &format!("{tip}:{FILE}")], None)
.map(|text| text + "\n"),
}
}
pub fn read(&self) -> Result<String> {
let tip = self.tip().with_context(|| {
format!("no anchors in {}: {REF} does not exist", self.dir.display())
})?;
self.file_at(Some(&tip))
}
pub fn append(&self, record: &Record) -> Result<String> {
let tip = self.tip();
let mut text = self.file_at(tip.as_deref())?;
text.push_str(&record.line()?);
text.push('\n');
let blob = self.run(&["hash-object", "-w", "--stdin"], Some(text.as_bytes()))?;
let entry = format!("100644 blob {blob}\t{FILE}\n");
let tree = self.run(&["mktree"], Some(entry.as_bytes()))?;
let message = self.message(record)?;
let mut args = vec!["commit-tree", tree.as_str()];
if let Some(parent) = &tip {
args.extend(["-p", parent.as_str()]);
}
let commit = self.run(&args, Some(message.as_bytes()))?;
let zero = "0".repeat(blob.len());
let old = tip.as_deref().unwrap_or(&zero);
self.run(
&["update-ref", "-m", "moat audit anchor", REF, &commit, old],
None,
)?;
Ok(commit)
}
fn message(&self, record: &Record) -> Result<String> {
let ident = self
.run(&["var", "GIT_COMMITTER_IDENT"], None)
.context("git has no committer identity; set user.name and user.email")?;
let signer = ident
.rfind('>')
.map_or(ident.as_str(), |end| &ident[..=end])
.to_owned();
Ok(format!(
"moat audit anchor: event {}\n\nevent {}\nhash {}\nevent time {}\nanchored {}\n\n\
Signed-off-by: {signer}\n",
record.event,
record.event,
record.hash,
crate::time::timestamp(record.event_ms),
crate::time::timestamp(record.anchored_ms),
))
}
}