Skip to main content

opendev_runtime/
snapshot.rs

1//! Git-based snapshot system for reliable file change tracking and revert.
2//!
3//! Uses a shadow git repository to create atomic snapshots of modified files
4//! before and after tool execution. Enables reliable revert of any change set.
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use tracing::{debug, warn};
10
11/// Manages file snapshots using a shadow git repository.
12pub struct SnapshotManager {
13    project_dir: PathBuf,
14    snapshot_dir: PathBuf,
15    initialized: bool,
16}
17
18impl SnapshotManager {
19    /// Create a new snapshot manager for the given project directory.
20    pub fn new(project_dir: &Path) -> Self {
21        let project_dir = project_dir
22            .canonicalize()
23            .unwrap_or_else(|_| project_dir.to_path_buf());
24        let project_id = compute_project_id(&project_dir);
25
26        let snapshot_dir = dirs_next::home_dir()
27            .unwrap_or_else(|| PathBuf::from("/tmp"))
28            .join(".opendev")
29            .join("data")
30            .join("snapshot")
31            .join(&project_id);
32
33        Self {
34            project_dir,
35            snapshot_dir,
36            initialized: false,
37        }
38    }
39
40    /// Path to the snapshot directory.
41    pub fn snapshot_dir(&self) -> &Path {
42        &self.snapshot_dir
43    }
44
45    /// Take a snapshot of the given files before modification.
46    ///
47    /// Returns the snapshot ID (git commit hash) or `None` on failure.
48    pub fn take_snapshot(&mut self, files: &[&str], label: &str) -> Option<String> {
49        if !self.ensure_initialized() {
50            return None;
51        }
52
53        let mut copied = 0usize;
54
55        for file_path in files {
56            let src = Path::new(file_path);
57            if !src.exists() {
58                continue;
59            }
60
61            let rel = src
62                .strip_prefix(&self.project_dir)
63                .unwrap_or_else(|_| Path::new(src.file_name().unwrap_or_default()));
64
65            let dest = self.snapshot_dir.join(rel);
66            if let Some(parent) = dest.parent() {
67                let _ = std::fs::create_dir_all(parent);
68            }
69
70            match std::fs::copy(src, &dest) {
71                Ok(_) => {
72                    self.git(&["add", &rel.to_string_lossy()]);
73                    copied += 1;
74                }
75                Err(e) => {
76                    debug!("Failed to copy {}: {}", src.display(), e);
77                }
78            }
79        }
80
81        if copied == 0 {
82            return None;
83        }
84
85        let msg = if label.is_empty() {
86            "snapshot".to_string()
87        } else {
88            format!("snapshot: {label}")
89        };
90
91        self.git(&["commit", "-m", &msg, "--allow-empty"]);
92
93        let output = self.git(&["rev-parse", "HEAD"])?;
94        let snapshot_id = output.trim().to_string();
95
96        debug!(
97            "Snapshot {}: {} files ({})",
98            &snapshot_id[..8.min(snapshot_id.len())],
99            copied,
100            label
101        );
102
103        Some(snapshot_id)
104    }
105
106    /// Get diff between a snapshot and the current project state.
107    pub fn get_diff(&mut self, snapshot_id: &str) -> Option<String> {
108        if !self.ensure_initialized() {
109            return None;
110        }
111
112        let output = self.git(&[
113            "diff-tree",
114            "--no-commit-id",
115            "-r",
116            "--name-only",
117            snapshot_id,
118        ])?;
119        let files: Vec<&str> = output.lines().filter(|l| !l.is_empty()).collect();
120
121        for rel_path in &files {
122            let src = self.project_dir.join(rel_path);
123            let dest = self.snapshot_dir.join(rel_path);
124
125            if src.exists() {
126                if let Some(parent) = dest.parent() {
127                    let _ = std::fs::create_dir_all(parent);
128                }
129                let _ = std::fs::copy(&src, &dest);
130            } else if dest.exists() {
131                let _ = std::fs::remove_file(&dest);
132            }
133        }
134
135        self.git(&["diff", snapshot_id, "--"])
136    }
137
138    /// Revert project files to a snapshot state.
139    ///
140    /// Returns list of reverted file paths.
141    pub fn revert_to_snapshot(&mut self, snapshot_id: &str) -> Vec<String> {
142        if !self.ensure_initialized() {
143            return Vec::new();
144        }
145
146        let output = match self.git(&[
147            "diff-tree",
148            "--no-commit-id",
149            "-r",
150            "--name-only",
151            snapshot_id,
152        ]) {
153            Some(o) => o,
154            None => return Vec::new(),
155        };
156
157        let mut reverted = Vec::new();
158
159        for rel_path in output.lines().filter(|l| !l.is_empty()) {
160            self.git(&["checkout", snapshot_id, "--", rel_path]);
161
162            let src = self.snapshot_dir.join(rel_path);
163            let dest = self.project_dir.join(rel_path);
164
165            if src.exists() {
166                if let Some(parent) = dest.parent() {
167                    let _ = std::fs::create_dir_all(parent);
168                }
169                match std::fs::copy(&src, &dest) {
170                    Ok(_) => reverted.push(dest.to_string_lossy().to_string()),
171                    Err(e) => warn!("Failed to revert {}: {}", rel_path, e),
172                }
173            }
174        }
175
176        reverted
177    }
178
179    /// Run garbage collection on the snapshot repo.
180    pub fn cleanup(&mut self, max_age_days: u32) {
181        if !self.ensure_initialized() {
182            return;
183        }
184        let prune_arg = format!("--prune={max_age_days}.days.ago");
185        if self.git(&["gc", &prune_arg]).is_none() {
186            debug!("Snapshot GC failed");
187        }
188    }
189
190    fn ensure_initialized(&mut self) -> bool {
191        if self.initialized {
192            return true;
193        }
194
195        if std::fs::create_dir_all(&self.snapshot_dir).is_err() {
196            warn!("Failed to create snapshot dir");
197            return false;
198        }
199
200        let git_dir = self.snapshot_dir.join(".git");
201        if !git_dir.exists() {
202            if self.git(&["init"]).is_none() {
203                return false;
204            }
205            self.git(&["config", "user.name", "opendev-snapshot"]);
206            self.git(&["config", "user.email", "snapshot@opendev.local"]);
207            self.git(&["config", "gc.auto", "0"]);
208        }
209
210        self.initialized = true;
211        true
212    }
213
214    fn git(&self, args: &[&str]) -> Option<String> {
215        let result = Command::new("git")
216            .args(args)
217            .current_dir(&self.snapshot_dir)
218            .output();
219
220        match result {
221            Ok(output) => {
222                if !output.status.success() {
223                    let stderr = String::from_utf8_lossy(&output.stderr);
224                    if !stderr.contains("nothing to commit") {
225                        debug!("git {} failed: {}", args.join(" "), stderr.trim());
226                        return None;
227                    }
228                }
229                Some(String::from_utf8_lossy(&output.stdout).to_string())
230            }
231            Err(e) => {
232                debug!("git command failed: {}", e);
233                None
234            }
235        }
236    }
237}
238
239fn compute_project_id(project_dir: &Path) -> String {
240    use std::collections::hash_map::DefaultHasher;
241    use std::hash::{Hash, Hasher};
242    let mut hasher = DefaultHasher::new();
243    project_dir.display().to_string().hash(&mut hasher);
244    format!("{:016x}", hasher.finish())
245}
246
247impl std::fmt::Debug for SnapshotManager {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.debug_struct("SnapshotManager")
250            .field("project_dir", &self.project_dir)
251            .field("snapshot_dir", &self.snapshot_dir)
252            .field("initialized", &self.initialized)
253            .finish()
254    }
255}
256
257#[cfg(test)]
258#[path = "snapshot_tests.rs"]
259mod tests;