use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
pub struct GitRepo {
pub root: PathBuf,
}
#[derive(Debug, Clone)]
pub struct CheckpointInfo {
pub hash: String,
pub short_hash: String,
pub message: String,
pub timestamp: i64,
}
impl GitRepo {
pub fn open(path: &Path) -> Option<Self> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8(output.stdout).ok()?;
Some(Self {
root: PathBuf::from(root.trim()),
})
}
}
pub fn is_git_repo(path: &Path) -> bool {
GitRepo::open(path).is_some()
}
impl GitRepo {
pub fn checkpoint(&self, task_summary: &str) -> Result<String> {
let porcelain = self.run_git(&["status", "--porcelain"])?;
let dirty = !porcelain.trim().is_empty();
if !dirty {
return self
.run_git(&["rev-parse", "HEAD"])
.map(|s| s.trim().to_string());
}
let summary: String = task_summary.chars().take(60).collect();
let msg = format!("parecode: checkpoint before \"{}\"", summary);
self.run_git(&["add", "-A"])?;
self.run_git(&["commit", "--no-verify", "-m", &msg])?;
self.run_git(&["rev-parse", "HEAD"])
.map(|s| s.trim().to_string())
}
pub fn undo(&self, n: usize) -> Result<()> {
let checkpoints = self.list_checkpoints()?;
if checkpoints.is_empty() {
return Err(anyhow!("no parecode checkpoints found"));
}
let idx = n.saturating_sub(1).min(checkpoints.len() - 1);
let target = &checkpoints[idx];
self.run_git(&["reset", "--hard", &target.hash])?;
Ok(())
}
pub fn diff_stat_from(&self, ref_hash: &str) -> Result<String> {
self.run_git(&["diff", ref_hash, "--stat"])
}
pub fn diff_full_from(&self, ref_hash: &str) -> Result<String> {
self.run_git(&["diff", ref_hash])
}
pub fn _diff_stat(&self) -> Result<String> {
self.run_git(&["diff", "HEAD", "--stat"])
}
pub fn _diff_full(&self) -> Result<String> {
self.run_git(&["diff", "HEAD"])
}
pub fn auto_commit(&self, message: &str) -> Result<()> {
self.run_git(&["add", "-A"])?;
self.run_git(&["commit", "--no-verify", "-m", message])?;
Ok(())
}
pub fn status_short(&self) -> Result<String> {
let out = self.run_git(&["status", "--short"])?;
let lines: Vec<&str> = out.lines().collect();
if lines.len() <= 10 {
Ok(out)
} else {
let truncated = lines[..10].join("\n");
Ok(format!(
"{}\n... ({} more files)",
truncated,
lines.len() - 10
))
}
}
pub fn list_checkpoints(&self) -> Result<Vec<CheckpointInfo>> {
let out = self.run_git(&[
"log",
"--format=%H|%h|%s|%ct",
"--grep=parecode: checkpoint",
"-20",
])?;
let checkpoints = out
.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let mut parts = line.splitn(4, '|');
let hash = parts.next()?.to_string();
let short_hash = parts.next()?.to_string();
let message = parts.next()?.to_string();
let timestamp = parts.next()?.trim().parse::<i64>().unwrap_or(0);
Some(CheckpointInfo {
hash,
short_hash,
message,
timestamp,
})
})
.collect();
Ok(checkpoints)
}
fn run_git(&self, args: &[&str]) -> Result<String> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&self.root)
.output()
.map_err(|e| anyhow!("failed to run git: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow!("git {}: {}", args.join(" "), stderr.trim()))
}
}
}