use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use crate::git::GitRepo;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotRecord {
pub id: String,
pub stash_sha: String,
pub tree_sha: String,
pub trigger: String,
pub message: Option<String>,
pub timestamp: i64,
pub files_added: u32,
pub files_deleted: u32,
pub clean: bool,
}
pub fn index_path(repo: &GitRepo) -> Result<PathBuf> {
Ok(repo.git_dir()?.join("claude-oops").join("index.jsonl"))
}
fn ensure_parent(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
Ok(())
}
pub fn read_all(repo: &GitRepo) -> Result<Vec<SnapshotRecord>> {
let path = index_path(repo)?;
if !path.exists() {
return Ok(Vec::new());
}
let f = File::open(&path).with_context(|| format!("failed to open {}", path.display()))?;
let reader = BufReader::new(f);
let mut out = Vec::new();
for (lineno, line) in reader.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SnapshotRecord>(&line) {
Ok(rec) => out.push(rec),
Err(e) => eprintln!(
"claude-oops: skipping malformed index line {}: {}",
lineno + 1,
e
),
}
}
Ok(out)
}
pub fn append(repo: &GitRepo, rec: &SnapshotRecord) -> Result<()> {
let path = index_path(repo)?;
ensure_parent(&path)?;
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to open {} for append", path.display()))?;
let line = serde_json::to_string(rec)?;
writeln!(f, "{}", line)?;
Ok(())
}
pub fn rewrite(repo: &GitRepo, recs: &[SnapshotRecord]) -> Result<()> {
let path = index_path(repo)?;
ensure_parent(&path)?;
let tmp = path.with_extension("jsonl.tmp");
{
let mut f =
File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?;
for rec in recs {
let line = serde_json::to_string(rec)?;
writeln!(f, "{}", line)?;
}
f.sync_all().ok();
}
std::fs::rename(&tmp, &path)
.with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
pub fn pick_id(sha: &str, existing: &[SnapshotRecord]) -> String {
for len in 7..=sha.len() {
let candidate = &sha[..len];
if !existing.iter().any(|r| r.id == candidate) {
return candidate.to_string();
}
}
sha.to_string()
}
pub fn find_by_id<'a>(recs: &'a [SnapshotRecord], needle: &str) -> Result<&'a SnapshotRecord> {
let exact: Vec<&SnapshotRecord> = recs.iter().filter(|r| r.id == needle).collect();
if exact.len() == 1 {
return Ok(exact[0]);
}
let prefix: Vec<&SnapshotRecord> = recs.iter().filter(|r| r.id.starts_with(needle)).collect();
match prefix.len() {
0 => Err(anyhow::anyhow!("no snapshot matches id `{}`", needle)),
1 => Ok(prefix[0]),
n => Err(anyhow::anyhow!(
"ambiguous id `{}` matches {} snapshots",
needle,
n
)),
}
}