opendev_runtime/
snapshot.rs1use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use tracing::{debug, warn};
10
11pub struct SnapshotManager {
13 project_dir: PathBuf,
14 snapshot_dir: PathBuf,
15 initialized: bool,
16}
17
18impl SnapshotManager {
19 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 pub fn snapshot_dir(&self) -> &Path {
42 &self.snapshot_dir
43 }
44
45 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 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 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 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;