use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
pub struct GitRepo {
root: PathBuf,
}
#[allow(dead_code)]
impl GitRepo {
pub fn discover() -> Result<Self> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("failed to run git")?;
if !output.status.success() {
bail!(crate::error::SrAiError::NotAGitRepo);
}
let root = String::from_utf8(output.stdout)
.context("invalid utf-8 from git")?
.trim()
.into();
Ok(Self { root })
}
pub fn root(&self) -> &PathBuf {
&self.root
}
fn git(&self, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(["-C", self.root.to_str().unwrap()])
.args(args)
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(crate::error::SrAiError::GitCommand(format!(
"git {} failed: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn git_allow_failure(&self, args: &[&str]) -> Result<(bool, String)> {
let output = Command::new("git")
.args(["-C", self.root.to_str().unwrap()])
.args(args)
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
Ok((
output.status.success(),
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
pub fn has_staged_changes(&self) -> Result<bool> {
let out = self.git(&["diff", "--cached", "--name-only"])?;
Ok(!out.trim().is_empty())
}
pub fn has_any_changes(&self) -> Result<bool> {
let out = self.git(&["status", "--porcelain"])?;
Ok(!out.trim().is_empty())
}
pub fn has_head(&self) -> Result<bool> {
let (ok, _) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
Ok(ok)
}
pub fn reset_head(&self) -> Result<()> {
if self.has_head()? {
self.git(&["reset", "HEAD", "--quiet"])?;
} else {
let _ = self.git_allow_failure(&["rm", "--cached", "-r", ".", "--quiet"]);
}
Ok(())
}
pub fn stage_file(&self, file: &str) -> Result<bool> {
let full_path = self.root.join(file);
let exists = full_path.exists();
if !exists {
let out = self.git(&["ls-files", "--deleted"])?;
let is_deleted = out.lines().any(|l| l.trim() == file);
if !is_deleted {
return Ok(false);
}
}
let (ok, _) = self.git_allow_failure(&["add", "--", file])?;
Ok(ok)
}
pub fn has_staged_after_add(&self) -> Result<bool> {
self.has_staged_changes()
}
pub fn commit(&self, message: &str) -> Result<()> {
let output = Command::new("git")
.args(["-C", self.root.to_str().unwrap()])
.args(["commit", "-F", "-"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn git commit")?;
use std::io::Write;
let mut child = output;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(message.as_bytes())?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!(crate::error::SrAiError::GitCommand(format!(
"git commit failed: {}",
stderr.trim()
)));
}
Ok(())
}
pub fn recent_commits(&self, count: usize) -> Result<String> {
self.git(&["--no-pager", "log", "--oneline", &format!("-{count}")])
}
pub fn diff_cached(&self) -> Result<String> {
self.git(&["diff", "--cached"])
}
pub fn diff_cached_stat(&self) -> Result<String> {
self.git(&["diff", "--cached", "--stat"])
}
pub fn diff_head(&self) -> Result<String> {
let (ok, out) = self.git_allow_failure(&["diff", "HEAD"])?;
if ok { Ok(out) } else { self.git(&["diff"]) }
}
pub fn status_porcelain(&self) -> Result<String> {
self.git(&["status", "--porcelain"])
}
pub fn untracked_files(&self) -> Result<String> {
self.git(&["ls-files", "--others", "--exclude-standard"])
}
pub fn show(&self, rev: &str) -> Result<String> {
self.git(&["show", rev])
}
pub fn log_range(&self, base: &str, count: Option<usize>) -> Result<String> {
let mut args = vec!["--no-pager", "log", "--oneline"];
let count_str;
if let Some(n) = count {
count_str = format!("-{n}");
args.push(&count_str);
}
args.push(base);
self.git(&args)
}
pub fn diff_range(&self, base: &str) -> Result<String> {
self.git(&["diff", base])
}
pub fn current_branch(&self) -> Result<String> {
let out = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
Ok(out.trim().to_string())
}
pub fn head_short(&self) -> Result<String> {
let out = self.git(&["rev-parse", "--short", "HEAD"])?;
Ok(out.trim().to_string())
}
pub fn commits_since_last_tag(&self) -> Result<usize> {
let (ok, tag) = self.git_allow_failure(&["describe", "--tags", "--abbrev=0"])?;
let tag = tag.trim();
let out = if ok && !tag.is_empty() {
self.git(&["rev-list", &format!("{tag}..HEAD"), "--count"])?
} else {
self.git(&["rev-list", "HEAD", "--count"])?
};
out.trim()
.parse::<usize>()
.context("failed to parse commit count")
}
pub fn log_detailed(&self, count: usize) -> Result<String> {
let out = self.git(&[
"--no-pager",
"log",
"--reverse",
&format!("-{count}"),
"--format=%h %s%n%b%n---",
])?;
Ok(out)
}
pub fn file_statuses(&self) -> Result<HashMap<String, char>> {
let out = self.git(&["status", "--porcelain"])?;
let mut map = HashMap::new();
for line in out.lines() {
if line.len() < 3 {
continue;
}
let xy = &line.as_bytes()[..2];
let path = line[3..].to_string();
let (x, y) = (xy[0], xy[1]);
let is_rename = matches!((x, y), (b'R', _) | (_, b'R'));
if is_rename {
if let Some(pos) = path.find(" -> ") {
let old_path = path[..pos].to_string();
let new_path = path[pos + 4..].to_string();
map.insert(old_path, 'D');
map.insert(new_path, 'R');
} else {
map.insert(path, 'R');
}
} else {
let status = match (x, y) {
(b'?', b'?') => 'A',
(b'A', _) | (_, b'A') => 'A',
(b'D', _) | (_, b'D') => 'D',
(b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => 'M',
_ => '~',
};
map.insert(path, status);
}
}
Ok(map)
}
pub fn snapshot_working_tree(&self) -> Result<PathBuf> {
let snapshot_dir = snapshot_dir_for(&self.root)
.context("failed to resolve snapshot directory (no data directory available)")?;
if snapshot_dir.exists() {
std::fs::remove_dir_all(&snapshot_dir).ok();
}
std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot directory")?;
let files_dir = snapshot_dir.join("files");
std::fs::create_dir_all(&files_dir)?;
std::fs::write(
snapshot_dir.join("repo_root"),
self.root.to_string_lossy().as_bytes(),
)
.context("failed to write repo_root")?;
let (has_head, head_ref) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
if has_head {
std::fs::write(snapshot_dir.join("head_ref"), head_ref.trim())
.context("failed to write head_ref")?;
}
let porcelain = self.git(&["status", "--porcelain"])?;
let staged_names = self.git(&["diff", "--cached", "--name-only"])?;
let staged_set: std::collections::HashSet<&str> = staged_names
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
#[derive(serde::Serialize, serde::Deserialize)]
struct ManifestEntry {
path: String,
index_status: char,
worktree_status: char,
staged: bool,
has_content: bool,
}
let mut manifest: Vec<ManifestEntry> = Vec::new();
for line in porcelain.lines() {
if line.len() < 3 {
continue;
}
let bytes = line.as_bytes();
let x = bytes[0] as char;
let y = bytes[1] as char;
let mut path = line[3..].to_string();
if let Some(pos) = path.find(" -> ") {
path = path[pos + 4..].to_string();
}
let src = self.root.join(&path);
let has_content = src.exists() && src.is_file();
if has_content {
let dest = files_dir.join(&path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Err(e) = std::fs::copy(&src, &dest) {
eprintln!("warning: failed to snapshot {path}: {e}");
}
}
manifest.push(ManifestEntry {
staged: staged_set.contains(path.as_str()),
path,
index_status: x,
worktree_status: y,
has_content,
});
}
let manifest_json =
serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")?;
std::fs::write(snapshot_dir.join("manifest.json"), manifest_json)
.context("failed to write manifest.json")?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
std::fs::write(snapshot_dir.join("timestamp"), now.to_string())
.context("failed to write timestamp")?;
Ok(snapshot_dir)
}
pub fn restore_snapshot(&self) -> Result<()> {
let snapshot_dir = self.snapshot_dir()?;
if !snapshot_dir.join("timestamp").exists() {
bail!("no valid snapshot found");
}
let files_dir = snapshot_dir.join("files");
let head_ref_path = snapshot_dir.join("head_ref");
if head_ref_path.exists() {
let original_head = std::fs::read_to_string(&head_ref_path)?;
let original_head = original_head.trim();
if !original_head.is_empty() {
let _ = self.git_allow_failure(&["reset", "--soft", original_head]);
}
}
self.reset_head()?;
let manifest_path = snapshot_dir.join("manifest.json");
if !manifest_path.exists() {
bail!("snapshot manifest.json missing — cannot restore");
}
#[derive(serde::Deserialize)]
struct ManifestEntry {
path: String,
index_status: char,
worktree_status: char,
staged: bool,
has_content: bool,
}
let manifest_data = std::fs::read_to_string(&manifest_path)?;
let manifest: Vec<ManifestEntry> =
serde_json::from_str(&manifest_data).context("failed to parse snapshot manifest")?;
let mut restored = 0usize;
let mut failed = 0usize;
for entry in &manifest {
let dest = self.root.join(&entry.path);
if entry.has_content {
let src = files_dir.join(&entry.path);
if src.exists() {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).ok();
}
match std::fs::copy(&src, &dest) {
Ok(_) => restored += 1,
Err(e) => {
eprintln!("warning: failed to restore {}: {e}", entry.path);
failed += 1;
}
}
} else {
eprintln!("warning: snapshot missing content for {}", entry.path);
failed += 1;
}
} else if entry.index_status == 'D' || entry.worktree_status == 'D' {
if dest.exists() {
std::fs::remove_file(&dest).ok();
}
}
if entry.staged {
let _ = self.git_allow_failure(&["add", "--", &entry.path]);
}
}
if failed > 0 {
eprintln!("sr: restored {restored} files, {failed} failed");
}
Ok(())
}
pub fn clear_snapshot(&self) {
if let Ok(dir) = self.snapshot_dir() {
let _ = std::fs::remove_dir_all(&dir);
}
}
pub fn snapshot_dir(&self) -> Result<PathBuf> {
snapshot_dir_for(&self.root)
.context("failed to resolve snapshot directory (no data directory available)")
}
pub fn has_snapshot(&self) -> bool {
self.snapshot_dir()
.map(|d| d.join("timestamp").exists())
.unwrap_or(false)
}
}
fn snapshot_dir_for(repo_root: &std::path::Path) -> Option<PathBuf> {
let base = dirs::data_local_dir()?;
let repo_id =
&crate::cache::fingerprint::sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
Some(base.join("sr").join("snapshots").join(repo_id))
}
pub struct SnapshotGuard<'a> {
repo: &'a GitRepo,
succeeded: bool,
}
impl<'a> SnapshotGuard<'a> {
pub fn new(repo: &'a GitRepo) -> Result<Self> {
repo.snapshot_working_tree()?;
Ok(Self {
repo,
succeeded: false,
})
}
pub fn success(mut self) {
self.succeeded = true;
self.repo.clear_snapshot();
}
}
impl Drop for SnapshotGuard<'_> {
fn drop(&mut self) {
if !self.succeeded && self.repo.has_snapshot() {
eprintln!("sr: operation failed, restoring working tree from snapshot...");
if let Err(e) = self.repo.restore_snapshot() {
eprintln!("sr: warning: snapshot restore failed: {e}");
if let Ok(dir) = self.repo.snapshot_dir() {
eprintln!(
"sr: snapshot preserved at {} for manual recovery",
dir.display()
);
}
} else {
self.repo.clear_snapshot();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_repo() -> (tempfile::TempDir, GitRepo) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let git = |args: &[&str]| {
Command::new("git")
.args(["-C", root.to_str().unwrap()])
.args(args)
.output()
.unwrap()
};
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
fs::write(root.join("init.txt"), "init").unwrap();
git(&["add", "init.txt"]);
git(&["commit", "-m", "initial"]);
let repo = GitRepo { root };
(dir, repo)
}
#[test]
fn snapshot_creates_manifest_with_staged_files() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("new.go"), "package main").unwrap();
repo.git(&["add", "new.go"]).unwrap();
let snap_dir = repo.snapshot_working_tree().unwrap();
let manifest_path = snap_dir.join("manifest.json");
assert!(manifest_path.exists(), "manifest.json should exist");
let data = fs::read_to_string(&manifest_path).unwrap();
assert!(data.contains("new.go"), "manifest should list new.go");
assert!(
data.contains("\"staged\": true"),
"new.go should be marked staged"
);
assert!(
snap_dir.join("files/new.go").exists(),
"file content should be copied"
);
assert_eq!(
fs::read_to_string(snap_dir.join("files/new.go")).unwrap(),
"package main"
);
assert!(snap_dir.join("head_ref").exists());
repo.clear_snapshot();
}
#[test]
fn snapshot_restore_recovers_staged_new_files() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("a.go"), "package a").unwrap();
fs::write(repo.root.join("b.go"), "package b").unwrap();
repo.git(&["add", "a.go", "b.go"]).unwrap();
repo.snapshot_working_tree().unwrap();
repo.reset_head().unwrap();
repo.git(&["add", "a.go"]).unwrap();
repo.git(&["commit", "-m", "partial"]).unwrap();
repo.restore_snapshot().unwrap();
assert!(repo.root.join("a.go").exists());
assert!(repo.root.join("b.go").exists());
assert_eq!(
fs::read_to_string(repo.root.join("a.go")).unwrap(),
"package a"
);
assert_eq!(
fs::read_to_string(repo.root.join("b.go")).unwrap(),
"package b"
);
let staged = repo.git(&["diff", "--cached", "--name-only"]).unwrap();
assert!(staged.contains("a.go"), "a.go should be re-staged");
assert!(staged.contains("b.go"), "b.go should be re-staged");
let log = repo.git(&["log", "--oneline"]).unwrap();
assert!(
!log.contains("partial"),
"partial commit should be undone by HEAD reset"
);
repo.clear_snapshot();
}
#[test]
fn snapshot_restore_with_dirty_index_does_not_conflict() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("file.rs"), "fn main() {}").unwrap();
repo.git(&["add", "file.rs"]).unwrap();
repo.snapshot_working_tree().unwrap();
repo.reset_head().unwrap();
repo.git(&["add", "file.rs"]).unwrap();
let result = repo.restore_snapshot();
assert!(
result.is_ok(),
"restore should succeed with dirty index: {result:?}"
);
assert_eq!(
fs::read_to_string(repo.root.join("file.rs")).unwrap(),
"fn main() {}"
);
repo.clear_snapshot();
}
#[test]
fn snapshot_handles_modified_files() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("init.txt"), "modified content").unwrap();
repo.git(&["add", "init.txt"]).unwrap();
repo.snapshot_working_tree().unwrap();
repo.reset_head().unwrap();
fs::write(repo.root.join("init.txt"), "wrong content").unwrap();
repo.restore_snapshot().unwrap();
assert_eq!(
fs::read_to_string(repo.root.join("init.txt")).unwrap(),
"modified content"
);
repo.clear_snapshot();
}
#[test]
fn snapshot_guard_restores_on_drop() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("guarded.txt"), "important").unwrap();
repo.git(&["add", "guarded.txt"]).unwrap();
{
let _guard = SnapshotGuard::new(&repo).unwrap();
repo.reset_head().unwrap();
fs::remove_file(repo.root.join("guarded.txt")).ok();
}
assert!(repo.root.join("guarded.txt").exists());
assert_eq!(
fs::read_to_string(repo.root.join("guarded.txt")).unwrap(),
"important"
);
}
#[test]
fn snapshot_guard_clears_on_success() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("ok.txt"), "data").unwrap();
repo.git(&["add", "ok.txt"]).unwrap();
let guard = SnapshotGuard::new(&repo).unwrap();
assert!(repo.has_snapshot());
guard.success();
assert!(!repo.has_snapshot());
}
#[test]
fn file_statuses_includes_both_sides_of_rename() {
let (_dir, repo) = temp_repo();
fs::write(repo.root.join("old_name.txt"), "content").unwrap();
repo.git(&["add", "old_name.txt"]).unwrap();
repo.git(&["commit", "-m", "add old_name"]).unwrap();
repo.git(&["mv", "old_name.txt", "new_name.txt"]).unwrap();
let statuses = repo.file_statuses().unwrap();
assert_eq!(
statuses.get("old_name.txt").copied(),
Some('D'),
"old path should appear as deleted"
);
assert_eq!(
statuses.get("new_name.txt").copied(),
Some('R'),
"new path should appear as renamed"
);
}
}