use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone)]
pub struct GitRepo {
root: PathBuf,
}
impl GitRepo {
pub fn discover(start: impl AsRef<Path>) -> Result<Self> {
let out = Command::new("git")
.arg("-C")
.arg(start.as_ref())
.args(["rev-parse", "--show-toplevel"])
.output()
.context("failed to invoke `git rev-parse`")?;
if !out.status.success() {
return Err(anyhow!(
"not inside a git repository (run `git init` first)"
));
}
let root = String::from_utf8(out.stdout)
.context("git emitted non-utf8 path")?
.trim()
.to_string();
Ok(Self {
root: PathBuf::from(root),
})
}
#[allow(dead_code)]
pub fn root(&self) -> &Path {
&self.root
}
pub fn git_dir(&self) -> Result<PathBuf> {
let out = self
.git()
.args(["rev-parse", "--git-dir"])
.output()
.context("failed to invoke `git rev-parse --git-dir`")?;
if !out.status.success() {
return Err(anyhow!("git rev-parse --git-dir failed"));
}
let raw = String::from_utf8(out.stdout)
.context("non-utf8 .git path")?
.trim()
.to_string();
let p = PathBuf::from(&raw);
Ok(if p.is_absolute() {
p
} else {
self.root.join(p)
})
}
pub fn git(&self) -> Command {
let mut c = Command::new("git");
c.arg("-C").arg(&self.root);
c
}
pub fn has_head(&self) -> bool {
self.git()
.args(["rev-parse", "--verify", "--quiet", "HEAD"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn head_sha(&self) -> Result<Option<String>> {
if !self.has_head() {
return Ok(None);
}
let out = self
.git()
.args(["rev-parse", "HEAD"])
.output()
.context("rev-parse HEAD failed")?;
if !out.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8(out.stdout)?.trim().to_string()))
}
pub fn capture_tree(&self) -> Result<String> {
let tmp_dir = self.git_dir()?.join("claude-oops");
std::fs::create_dir_all(&tmp_dir)
.with_context(|| format!("failed to create {}", tmp_dir.display()))?;
let tmp_index = tmp_dir.join("tmp-index");
let _ = std::fs::remove_file(&tmp_index);
let mut read = self.git();
read.env("GIT_INDEX_FILE", &tmp_index)
.args(["read-tree", "HEAD"]);
let status = read.status().context("read-tree failed to run")?;
if !status.success() {
return Err(anyhow!("git read-tree HEAD failed"));
}
let mut add = self.git();
add.env("GIT_INDEX_FILE", &tmp_index).args(["add", "-A"]);
let status = add.status().context("git add -A failed to run")?;
if !status.success() {
return Err(anyhow!("git add -A failed"));
}
let mut write = self.git();
write.env("GIT_INDEX_FILE", &tmp_index).args(["write-tree"]);
let out = write.output().context("git write-tree failed to run")?;
let _ = std::fs::remove_file(&tmp_index);
if !out.status.success() {
return Err(anyhow!(
"git write-tree failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
pub fn commit_tree(&self, tree: &str, parent: &str, message: &str) -> Result<String> {
let out = self
.git()
.args(["commit-tree", tree, "-p", parent, "-m", message])
.output()
.context("git commit-tree failed to run")?;
if !out.status.success() {
return Err(anyhow!(
"git commit-tree failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
pub fn tree_of(&self, commit: &str) -> Result<String> {
let out = self
.git()
.args(["rev-parse", &format!("{}^{{tree}}", commit)])
.output()
.context("rev-parse tree failed")?;
if !out.status.success() {
return Err(anyhow!(
"could not resolve tree of {}: {}",
commit,
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
pub fn update_ref(&self, id: &str, target: &str) -> Result<()> {
let refname = format!("refs/claude-oops/{}", id);
let status = self
.git()
.args(["update-ref", &refname, target])
.status()
.context("update-ref failed to run")?;
if !status.success() {
return Err(anyhow!("git update-ref {} failed", refname));
}
Ok(())
}
pub fn delete_ref(&self, id: &str) -> Result<()> {
let refname = format!("refs/claude-oops/{}", id);
let status = self
.git()
.args(["update-ref", "-d", &refname])
.status()
.context("update-ref -d failed to run")?;
if !status.success() {
return Err(anyhow!("git update-ref -d {} failed", refname));
}
Ok(())
}
pub fn ref_exists(&self, id: &str) -> bool {
let refname = format!("refs/claude-oops/{}", id);
self.git()
.args(["rev-parse", "--verify", "--quiet", &refname])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn show_prefix_from(cwd: &Path) -> Result<String> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["rev-parse", "--show-prefix"])
.output()
.context("git rev-parse --show-prefix failed")?;
if !out.status.success() {
return Err(anyhow!(
"git rev-parse --show-prefix failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
pub fn list_tree_paths(&self, tree: &str, paths: &[String]) -> Result<Vec<String>> {
let mut cmd = self.git();
cmd.args(["ls-tree", "-r", "--name-only", tree]);
if !paths.is_empty() {
cmd.arg("--");
for p in paths {
cmd.arg(p);
}
}
let out = cmd.output().context("git ls-tree failed")?;
if !out.status.success() {
return Err(anyhow!(
"git ls-tree failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect())
}
pub fn list_working_paths(&self, paths: &[String]) -> Result<Vec<String>> {
let mut cmd = self.git();
cmd.args(["ls-files", "-c", "-o", "--exclude-standard"]);
if !paths.is_empty() {
cmd.arg("--");
for p in paths {
cmd.arg(p);
}
}
let out = cmd.output().context("git ls-files failed")?;
if !out.status.success() {
return Err(anyhow!(
"git ls-files failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect())
}
pub fn name_status(&self, from: &str, to: &str) -> Result<Vec<(char, String)>> {
let out = self
.git()
.args([
"diff-tree",
"-r",
"--no-commit-id",
"--name-status",
from,
to,
])
.output()
.context("git diff-tree failed")?;
if !out.status.success() {
return Err(anyhow!(
"git diff-tree failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let mut rows = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut parts = line.splitn(2, '\t');
let status = parts.next().unwrap_or("");
let path = parts.next().unwrap_or("");
if path.is_empty() {
continue;
}
let letter = status.chars().next().unwrap_or('?');
rows.push((letter, path.to_string()));
}
Ok(rows)
}
pub fn diff_stats(&self, commit: &str) -> Result<(u32, u32)> {
let out = self
.git()
.args(["show", "--numstat", "--format=", "--no-renames", commit])
.output()
.context("git show --numstat failed")?;
if !out.status.success() {
return Ok((0, 0));
}
let mut added = 0u32;
let mut deleted = 0u32;
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut parts = line.split('\t');
let a = parts.next().unwrap_or("0");
let d = parts.next().unwrap_or("0");
added = added.saturating_add(a.parse::<u32>().unwrap_or(0));
deleted = deleted.saturating_add(d.parse::<u32>().unwrap_or(0));
}
Ok((added, deleted))
}
}