use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{debug, warn};
pub struct SnapshotManager {
project_dir: PathBuf,
snapshot_dir: PathBuf,
initialized: bool,
}
impl SnapshotManager {
pub fn new(project_dir: &Path) -> Self {
let project_dir = project_dir
.canonicalize()
.unwrap_or_else(|_| project_dir.to_path_buf());
let project_id = compute_project_id(&project_dir);
let snapshot_dir = dirs_next::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".opendev")
.join("data")
.join("snapshot")
.join(&project_id);
Self {
project_dir,
snapshot_dir,
initialized: false,
}
}
pub fn snapshot_dir(&self) -> &Path {
&self.snapshot_dir
}
pub fn take_snapshot(&mut self, files: &[&str], label: &str) -> Option<String> {
if !self.ensure_initialized() {
return None;
}
let mut copied = 0usize;
for file_path in files {
let src = Path::new(file_path);
if !src.exists() {
continue;
}
let rel = src
.strip_prefix(&self.project_dir)
.unwrap_or_else(|_| Path::new(src.file_name().unwrap_or_default()));
let dest = self.snapshot_dir.join(rel);
if let Some(parent) = dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::copy(src, &dest) {
Ok(_) => {
self.git(&["add", &rel.to_string_lossy()]);
copied += 1;
}
Err(e) => {
debug!("Failed to copy {}: {}", src.display(), e);
}
}
}
if copied == 0 {
return None;
}
let msg = if label.is_empty() {
"snapshot".to_string()
} else {
format!("snapshot: {label}")
};
self.git(&["commit", "-m", &msg, "--allow-empty"]);
let output = self.git(&["rev-parse", "HEAD"])?;
let snapshot_id = output.trim().to_string();
debug!(
"Snapshot {}: {} files ({})",
&snapshot_id[..8.min(snapshot_id.len())],
copied,
label
);
Some(snapshot_id)
}
pub fn get_diff(&mut self, snapshot_id: &str) -> Option<String> {
if !self.ensure_initialized() {
return None;
}
let output = self.git(&[
"diff-tree",
"--no-commit-id",
"-r",
"--name-only",
snapshot_id,
])?;
let files: Vec<&str> = output.lines().filter(|l| !l.is_empty()).collect();
for rel_path in &files {
let src = self.project_dir.join(rel_path);
let dest = self.snapshot_dir.join(rel_path);
if src.exists() {
if let Some(parent) = dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::copy(&src, &dest);
} else if dest.exists() {
let _ = std::fs::remove_file(&dest);
}
}
self.git(&["diff", snapshot_id, "--"])
}
pub fn revert_to_snapshot(&mut self, snapshot_id: &str) -> Vec<String> {
if !self.ensure_initialized() {
return Vec::new();
}
let output = match self.git(&[
"diff-tree",
"--no-commit-id",
"-r",
"--name-only",
snapshot_id,
]) {
Some(o) => o,
None => return Vec::new(),
};
let mut reverted = Vec::new();
for rel_path in output.lines().filter(|l| !l.is_empty()) {
self.git(&["checkout", snapshot_id, "--", rel_path]);
let src = self.snapshot_dir.join(rel_path);
let dest = self.project_dir.join(rel_path);
if src.exists() {
if let Some(parent) = dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::copy(&src, &dest) {
Ok(_) => reverted.push(dest.to_string_lossy().to_string()),
Err(e) => warn!("Failed to revert {}: {}", rel_path, e),
}
}
}
reverted
}
pub fn cleanup(&mut self, max_age_days: u32) {
if !self.ensure_initialized() {
return;
}
let prune_arg = format!("--prune={max_age_days}.days.ago");
if self.git(&["gc", &prune_arg]).is_none() {
debug!("Snapshot GC failed");
}
}
fn ensure_initialized(&mut self) -> bool {
if self.initialized {
return true;
}
if std::fs::create_dir_all(&self.snapshot_dir).is_err() {
warn!("Failed to create snapshot dir");
return false;
}
let git_dir = self.snapshot_dir.join(".git");
if !git_dir.exists() {
if self.git(&["init"]).is_none() {
return false;
}
self.git(&["config", "user.name", "opendev-snapshot"]);
self.git(&["config", "user.email", "snapshot@opendev.local"]);
self.git(&["config", "gc.auto", "0"]);
}
self.initialized = true;
true
}
fn git(&self, args: &[&str]) -> Option<String> {
let result = Command::new("git")
.args(args)
.current_dir(&self.snapshot_dir)
.output();
match result {
Ok(output) => {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("nothing to commit") {
debug!("git {} failed: {}", args.join(" "), stderr.trim());
return None;
}
}
Some(String::from_utf8_lossy(&output.stdout).to_string())
}
Err(e) => {
debug!("git command failed: {}", e);
None
}
}
}
}
fn compute_project_id(project_dir: &Path) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
project_dir.display().to_string().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
impl std::fmt::Debug for SnapshotManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SnapshotManager")
.field("project_dir", &self.project_dir)
.field("snapshot_dir", &self.snapshot_dir)
.field("initialized", &self.initialized)
.finish()
}
}
#[cfg(test)]
#[path = "snapshot_tests.rs"]
mod tests;