use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use std::path::{Path, PathBuf};
use crate::git::GitRepo;
use crate::storage::{self, SnapshotRecord};
#[allow(dead_code)]
pub mod trigger {
pub const MANUAL: &str = "manual";
pub const SESSION_START: &str = "session-start";
pub const PRE_EDIT: &str = "pre-edit";
pub const PRE_BASH: &str = "pre-bash";
}
pub struct SnapOpts<'a> {
pub trigger: &'a str,
pub message: Option<String>,
pub force: bool,
}
pub enum SnapOutcome {
Created(SnapshotRecord),
Skipped(SnapshotRecord),
NoCommits,
}
pub fn snap(repo: &GitRepo, opts: SnapOpts) -> Result<SnapOutcome> {
if !repo.has_head() {
return Ok(SnapOutcome::NoCommits);
}
let head_sha = repo
.head_sha()?
.ok_or_else(|| anyhow!("HEAD missing despite has_head()"))?;
let head_tree = repo
.tree_of(&head_sha)
.context("could not resolve HEAD tree")?;
let tree_sha = repo
.capture_tree()
.context("failed to capture working tree")?;
let clean = tree_sha == head_tree;
let sha = if clean {
head_sha.clone()
} else {
let msg = opts
.message
.as_deref()
.map(|m| format!("claude-oops snapshot ({}): {}", opts.trigger, m))
.unwrap_or_else(|| format!("claude-oops snapshot ({})", opts.trigger));
repo.commit_tree(&tree_sha, &head_sha, &msg)?
};
let mut existing = storage::read_all(repo)?;
if !opts.force {
if let Some(last) = existing.last() {
if last.tree_sha == tree_sha {
return Ok(SnapOutcome::Skipped(last.clone()));
}
}
}
let id = storage::pick_id(&sha, &existing);
let (files_added, files_deleted) = if clean {
(0, 0)
} else {
repo.diff_stats(&sha).unwrap_or((0, 0))
};
repo.update_ref(&id, &sha)?;
let rec = SnapshotRecord {
id: id.clone(),
stash_sha: sha,
tree_sha,
trigger: opts.trigger.to_string(),
message: opts.message,
timestamp: Utc::now().timestamp(),
files_added,
files_deleted,
clean,
};
storage::append(repo, &rec)?;
existing.push(rec.clone());
Ok(SnapOutcome::Created(rec))
}
pub fn resolve_path(repo: &GitRepo, cwd: &Path, path: &str) -> Result<String> {
let _ = repo; if PathBuf::from(path).is_absolute() {
return Err(anyhow!(
"absolute paths aren't supported — pass a path relative to the repo"
));
}
let prefix = GitRepo::show_prefix_from(cwd)?;
let user = path.replace('\\', "/");
let combined = format!("{}{}", prefix, user);
let cleaned = clean_slash_path(&combined);
if cleaned.is_empty() {
return Ok(String::new());
}
if cleaned == ".." || cleaned.starts_with("../") {
return Err(anyhow!(
"{} resolves to {}, which is outside the repo",
path,
cleaned
));
}
Ok(cleaned)
}
fn clean_slash_path(p: &str) -> String {
let mut out: Vec<&str> = Vec::new();
for comp in p.split('/') {
match comp {
"" | "." => {}
".." => match out.last() {
Some(prev) if *prev != ".." => {
out.pop();
}
_ => out.push(".."),
},
other => out.push(other),
}
}
out.join("/")
}
pub fn restore_paths(
repo: &GitRepo,
rec: &SnapshotRecord,
paths: &[String],
) -> Result<RestorePathReport> {
if paths.is_empty() {
return Err(anyhow!("restore_paths called with empty paths"));
}
let tmp_dir = repo.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("restore-index");
let _ = std::fs::remove_file(&tmp_index);
let status = repo
.git()
.env("GIT_INDEX_FILE", &tmp_index)
.args(["read-tree", &rec.tree_sha])
.status()
.context("read-tree (restore) failed to run")?;
if !status.success() {
return Err(anyhow!("git read-tree {} failed", rec.tree_sha));
}
let snap_paths = repo.list_tree_paths(&rec.tree_sha, paths)?;
let working_paths = repo.list_working_paths(paths)?;
if !snap_paths.is_empty() {
let mut cmd = repo.git();
cmd.env("GIT_INDEX_FILE", &tmp_index)
.args(["checkout-index", "-f", "--"]);
for p in &snap_paths {
cmd.arg(p);
}
let status = cmd
.status()
.context("checkout-index (restore) failed to run")?;
if !status.success() {
return Err(anyhow!("git checkout-index failed during restore"));
}
}
use std::collections::HashSet;
let snap_set: HashSet<&String> = snap_paths.iter().collect();
let mut deleted = Vec::new();
for w in &working_paths {
if !snap_set.contains(w) {
let abs = repo.root().join(w);
if std::fs::remove_file(&abs).is_ok() {
deleted.push(w.clone());
}
}
}
let _ = std::fs::remove_file(&tmp_index);
if snap_paths.is_empty() && deleted.is_empty() {
return Err(anyhow!(
"no matching files in snapshot or working tree for the given paths"
));
}
Ok(RestorePathReport {
restored: snap_paths,
deleted,
})
}
pub struct RestorePathReport {
pub restored: Vec<String>,
pub deleted: Vec<String>,
}
pub fn restore(repo: &GitRepo, rec: &SnapshotRecord) -> Result<()> {
let tmp_dir = repo.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("restore-index");
let _ = std::fs::remove_file(&tmp_index);
let status = repo
.git()
.env("GIT_INDEX_FILE", &tmp_index)
.args(["read-tree", &rec.tree_sha])
.status()
.context("read-tree (restore) failed to run")?;
if !status.success() {
return Err(anyhow!("git read-tree {} failed", rec.tree_sha));
}
let status = repo
.git()
.env("GIT_INDEX_FILE", &tmp_index)
.args(["checkout-index", "-a", "-f"])
.status()
.context("checkout-index (restore) failed to run")?;
if !status.success() {
return Err(anyhow!("git checkout-index failed"));
}
use std::collections::HashSet;
let snap_set: HashSet<String> = repo
.list_tree_paths(&rec.tree_sha, &[])?
.into_iter()
.collect();
let working = repo.list_working_paths(&[])?;
for w in working {
if !snap_set.contains(&w) {
let abs = repo.root().join(&w);
let _ = std::fs::remove_file(&abs);
}
}
let _ = std::fs::remove_file(&tmp_index);
Ok(())
}
pub fn diff(repo: &GitRepo, rec: &SnapshotRecord) -> Result<()> {
let current_tree = repo.capture_tree()?;
let status = repo
.git()
.args(["diff", &rec.tree_sha, ¤t_tree])
.status()
.context("git diff failed to run")?;
if !status.success() {
return Err(anyhow!("git diff failed"));
}
Ok(())
}
pub fn show_files(repo: &GitRepo, rec: &SnapshotRecord) -> Result<Vec<(char, String)>> {
let current_tree = repo.capture_tree()?;
repo.name_status(¤t_tree, &rec.tree_sha)
}
pub fn drop(repo: &GitRepo, id: &str) -> Result<SnapshotRecord> {
let mut all = storage::read_all(repo)?;
let rec = storage::find_by_id(&all, id)?.clone();
if repo.ref_exists(&rec.id) {
repo.delete_ref(&rec.id)?;
}
all.retain(|r| r.id != rec.id);
storage::rewrite(repo, &all)?;
Ok(rec)
}