1use std::path::{Path, PathBuf};
2use std::fs;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use crate::error::{Result, ToriiError};
6use crate::core::GitRepo;
7
8fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
12 fs::create_dir_all(dst)?;
13 for entry in fs::read_dir(src)? {
14 let entry = entry?;
15 let kind = entry.file_type()?;
16 let src_p = entry.path();
17 let dst_p = dst.join(entry.file_name());
18 if kind.is_dir() {
19 copy_dir_all(&src_p, &dst_p)?;
20 } else {
21 fs::copy(&src_p, &dst_p)?;
22 }
23 }
24 Ok(())
25}
26
27#[derive(Debug, Serialize, Deserialize)]
28pub struct SnapshotMetadata {
29 pub id: String,
30 pub timestamp: DateTime<Utc>,
31 pub name: Option<String>,
32 pub branch: String,
33 pub commit_hash: Option<String>,
34}
35
36pub struct SnapshotManager {
37 repo_path: PathBuf,
38 snapshots_dir: PathBuf,
39}
40
41impl SnapshotManager {
42 pub fn new<P: AsRef<Path>>(repo_path: P) -> Result<Self> {
43 let repo_path = repo_path.as_ref().to_path_buf();
44
45 let gitdir = git2::Repository::discover(&repo_path)
54 .map_err(crate::error::ToriiError::Git)?
55 .path()
56 .to_path_buf();
57 let snapshots_dir = gitdir.join("torii").join("snapshots");
58 fs::create_dir_all(&snapshots_dir)?;
59
60 let old_dir = repo_path.join(".torii").join("snapshots");
64 if old_dir.exists() && old_dir != snapshots_dir {
65 Self::migrate_legacy_snapshots(&old_dir, &snapshots_dir)?;
66 }
67
68 Ok(Self {
69 repo_path,
70 snapshots_dir,
71 })
72 }
73
74 fn migrate_legacy_snapshots(old: &Path, new: &Path) -> Result<()> {
79 let entries: Vec<PathBuf> = match fs::read_dir(old) {
80 Ok(it) => it.flatten().map(|e| e.path()).collect(),
81 Err(_) => return Ok(()),
82 };
83 if entries.is_empty() {
84 return Ok(());
85 }
86 eprintln!("ℹ Migrating {} snapshot(s) from {} → {}",
87 entries.len(), old.display(), new.display());
88 for src in entries {
89 let name = match src.file_name() { Some(n) => n.to_owned(), None => continue };
90 let dst = new.join(&name);
91 if dst.exists() { continue; }
92 if fs::rename(&src, &dst).is_err() {
93 if copy_dir_all(&src, &dst).is_ok() {
95 let _ = fs::remove_dir_all(&src);
96 }
97 }
98 }
99 let _ = fs::remove_dir(old);
103 if let Some(parent) = old.parent() {
104 let _ = fs::remove_dir(parent);
105 }
106 Ok(())
107 }
108
109 pub fn create_snapshot(&self, name: Option<&str>) -> Result<String> {
111 let repo = GitRepo::open(&self.repo_path)?;
112 let timestamp = Utc::now();
113 let mut id = timestamp.format("%Y%m%d_%H%M%S_%3f").to_string();
117
118 fs::create_dir_all(&self.snapshots_dir)?;
123 let mut snapshot_dir = self.snapshots_dir.join(&id);
124 let mut suffix = 0;
125 loop {
126 match fs::create_dir(&snapshot_dir) {
127 Ok(_) => break,
128 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
129 suffix += 1;
130 id = format!("{}_{}", timestamp.format("%Y%m%d_%H%M%S_%3f"), suffix);
131 snapshot_dir = self.snapshots_dir.join(&id);
132 }
133 Err(e) => return Err(e.into()),
134 }
135 }
136
137 let branch = repo.get_current_branch()?;
138
139 let metadata = SnapshotMetadata {
140 id: id.clone(),
141 timestamp,
142 name: name.map(String::from),
143 branch,
144 commit_hash: None,
145 };
146
147 let metadata_path = snapshot_dir.join("metadata.json");
148 let metadata_json = serde_json::to_string_pretty(&metadata)?;
149 fs::write(metadata_path, metadata_json)?;
150
151 self.create_bundle(&snapshot_dir, &repo)?;
152
153 Ok(id)
154 }
155
156 fn create_bundle(&self, snapshot_dir: &Path, repo: &GitRepo) -> Result<()> {
158 let mut revwalk = repo.repository().revwalk()?;
160 revwalk.push_head()?;
161
162 let git_path = self.repo_path.join(".git");
163 let snapshot_git = snapshot_dir.join("git_backup");
164
165 let torii_state = git_path.join("torii");
180 match fs::symlink_metadata(&git_path) {
181 Ok(meta) if meta.is_dir() => {
182 self.copy_dir_recursive_excluding(&git_path, &snapshot_git, Some(&torii_state))?;
183 }
184 Ok(_) => {
185 fs::create_dir_all(&snapshot_git)?;
189 fs::copy(&git_path, snapshot_git.join("gitdir-link"))?;
190 if let Ok(content) = fs::read_to_string(&git_path) {
193 let pointer = content.trim();
194 fs::write(snapshot_git.join("RESOLVED-GITDIR"), pointer)?;
195 }
196 }
197 Err(e) => {
198 return Err(ToriiError::Io(e));
199 }
200 }
201
202 Ok(())
203 }
204
205 fn copy_dir_recursive(&self, src: &Path, dst: &Path) -> Result<()> {
213 self.copy_dir_recursive_excluding(src, dst, None)
214 }
215
216 fn copy_dir_recursive_excluding(&self, src: &Path, dst: &Path, exclude: Option<&Path>) -> Result<()> {
217 fs::create_dir_all(dst)?;
218
219 let excl_canon = exclude.map(|p| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()));
223
224 for entry in fs::read_dir(src)? {
225 let entry = entry?;
226 let file_type = entry.file_type()?;
227 let src_path = entry.path();
228
229 if let Some(ref excl) = excl_canon {
232 let src_canon = src_path.canonicalize().unwrap_or_else(|_| src_path.clone());
233 if &src_canon == excl {
234 continue;
235 }
236 }
237
238 let dst_path = dst.join(entry.file_name());
239 if file_type.is_dir() {
240 self.copy_dir_recursive_excluding(&src_path, &dst_path, exclude)?;
241 } else {
242 fs::copy(&src_path, &dst_path)?;
243 }
244 }
245
246 Ok(())
247 }
248
249 pub fn list_snapshots(&self) -> Result<()> {
251 let entries = fs::read_dir(&self.snapshots_dir)?;
252
253 println!("📸 Snapshots:");
254 println!();
255
256 for entry in entries {
257 let entry = entry?;
258 if entry.file_type()?.is_dir() {
259 let metadata_path = entry.path().join("metadata.json");
260 if metadata_path.exists() {
261 let metadata_json = fs::read_to_string(metadata_path)?;
262 let metadata: SnapshotMetadata = serde_json::from_str(&metadata_json)?;
263
264 let name_str = metadata.name
265 .as_ref()
266 .map(|n| format!(" ({})", n))
267 .unwrap_or_default();
268
269 println!(" {} - {}{}",
270 metadata.id,
271 metadata.timestamp.format("%Y-%m-%d %H:%M:%S"),
272 name_str
273 );
274 println!(" Branch: {}", metadata.branch);
275 }
276 }
277 }
278
279 Ok(())
280 }
281
282 pub fn restore_snapshot(&self, id: &str) -> Result<()> {
284 let snapshot_dir = self.snapshots_dir.join(id);
285
286 if !snapshot_dir.exists() {
287 return Err(ToriiError::Snapshot(format!("Snapshot not found: {}", id)));
288 }
289
290 let snapshot_git = snapshot_dir.join("git_backup");
291 let git_dir = self.repo_path.join(".git");
292
293 fs::remove_dir_all(&git_dir)?;
294 self.copy_dir_recursive(&snapshot_git, &git_dir)?;
295
296 {
298 let repo = git2::Repository::discover(&self.repo_path)
299 .map_err(|e| ToriiError::Git(e))?;
300 let head = repo.head()
301 .map_err(|e| ToriiError::Git(e))?
302 .peel_to_commit()
303 .map_err(|e| ToriiError::Git(e))?;
304 repo.reset(
305 head.as_object(),
306 git2::ResetType::Hard,
307 Some(git2::build::CheckoutBuilder::default().force()),
308 ).map_err(|e| ToriiError::Git(e))?;
309 }
310
311 Ok(())
312 }
313
314 pub fn delete_snapshot(&self, id: &str) -> Result<()> {
316 let snapshot_dir = self.snapshots_dir.join(id);
317
318 if !snapshot_dir.exists() {
319 return Err(ToriiError::Snapshot(format!("Snapshot not found: {}", id)));
320 }
321
322 fs::remove_dir_all(snapshot_dir)?;
323 Ok(())
324 }
325
326 pub fn clear_all(&self) -> Result<usize> {
329 if !self.snapshots_dir.exists() {
330 return Ok(0);
331 }
332 let mut count = 0;
333 for entry in fs::read_dir(&self.snapshots_dir)? {
334 let entry = entry?;
335 if entry.file_type()?.is_dir() {
336 fs::remove_dir_all(entry.path())?;
337 count += 1;
338 }
339 }
340 Ok(count)
341 }
342
343 pub fn show(&self, id: &str) -> Result<()> {
347 let snapshot_dir = self.snapshots_dir.join(id);
348 if !snapshot_dir.exists() {
349 return Err(ToriiError::Snapshot(format!("Snapshot not found: {}", id)));
350 }
351 let metadata_path = snapshot_dir.join("metadata.json");
352 if metadata_path.exists() {
353 let metadata_json = fs::read_to_string(&metadata_path)?;
354 let metadata: SnapshotMetadata = serde_json::from_str(&metadata_json)?;
355 println!("📸 Snapshot {}", metadata.id);
356 println!(" timestamp: {}", metadata.timestamp.format("%Y-%m-%d %H:%M:%S"));
357 if let Some(name) = &metadata.name {
358 println!(" name: {}", name);
359 }
360 println!(" branch: {}", metadata.branch);
361 if let Some(commit) = &metadata.commit_hash {
362 println!(" commit: {}", commit);
363 }
364 } else {
365 println!("📸 Snapshot {} (no metadata.json — likely partial)", id);
366 }
367 println!(" contents:");
369 for entry in fs::read_dir(&snapshot_dir)? {
370 let entry = entry?;
371 let kind = if entry.file_type()?.is_dir() { "dir" } else { "file" };
372 println!(" {kind}: {}", entry.file_name().to_string_lossy());
373 }
374 Ok(())
375 }
376
377
378 pub fn configure_auto_snapshot(&self, enable: bool, interval: Option<u32>) -> Result<()> {
380 let config_path = self.repo_path.join(".torii").join("config.json");
381
382 #[derive(Serialize, Deserialize)]
383 struct Config {
384 auto_snapshot_enabled: bool,
385 auto_snapshot_interval_minutes: u32,
386 }
387
388 let config = Config {
389 auto_snapshot_enabled: enable,
390 auto_snapshot_interval_minutes: interval.unwrap_or(30),
391 };
392
393 let config_json = serde_json::to_string_pretty(&config)?;
394 fs::write(config_path, config_json)?;
395
396 Ok(())
397 }
398
399 pub fn stash(&self, name: Option<&str>, include_untracked: bool) -> Result<()> {
406 let stash_name = name.unwrap_or("WIP");
407 let mut repo = git2::Repository::discover(&self.repo_path)
408 .map_err(ToriiError::Git)?;
409
410 let mut opts = git2::StatusOptions::new();
413 opts.include_untracked(include_untracked)
414 .recurse_untracked_dirs(include_untracked);
415 let is_empty = {
416 let statuses = repo.statuses(Some(&mut opts)).map_err(ToriiError::Git)?;
417 statuses.is_empty()
418 };
419 if is_empty {
420 return Err(ToriiError::Snapshot(
421 "Nothing to stash — working tree is clean.".to_string(),
422 ));
423 }
424
425 let signature = crate::core::resolve_signature(&repo)?;
432
433 let mut flags = git2::StashFlags::DEFAULT;
434 if include_untracked {
435 flags |= git2::StashFlags::INCLUDE_UNTRACKED;
436 }
437 let oid = repo.stash_save2(&signature, Some(stash_name), Some(flags))
438 .map_err(ToriiError::Git)?;
439
440 let mut verify = git2::StatusOptions::new();
447 verify.include_untracked(include_untracked)
448 .recurse_untracked_dirs(include_untracked);
449 let still_dirty = !repo.statuses(Some(&mut verify))
450 .map_err(ToriiError::Git)?
451 .is_empty();
452 if still_dirty {
453 return Err(ToriiError::Snapshot(
454 "stash_save2 returned OK but the working tree is still dirty — \
455 libgit2 didn't actually stash anything. Workaround: \
456 `torii snapshot create -n WIP` (named persistent snapshot).".to_string()
457 ));
458 }
459
460 println!("📦 Stashed changes");
461 println!(" stash@{{0}}: {}", &oid.to_string()[..7]);
462 println!(" Name: {}", stash_name);
463 if include_untracked {
464 println!(" Untracked files included");
465 }
466 println!();
467 println!("💡 To restore: torii snapshot unstash");
468
469 Ok(())
470 }
471
472 pub fn unstash(&self, id: Option<&str>, keep: bool) -> Result<()> {
476 let mut repo = git2::Repository::discover(&self.repo_path)
477 .map_err(ToriiError::Git)?;
478
479 let index: usize = match id {
480 Some(s) => s.trim_start_matches("stash@{").trim_end_matches('}')
481 .parse()
482 .map_err(|_| ToriiError::Snapshot(
483 format!("invalid stash index `{}` (use a number: 0, 1, …)", s)
484 ))?,
485 None => 0,
486 };
487
488 let mut count = 0;
490 repo.stash_foreach(|_, _, _| { count += 1; true }).map_err(ToriiError::Git)?;
491 if count == 0 {
492 return Err(ToriiError::Snapshot("No stash found".to_string()));
493 }
494 if index >= count {
495 return Err(ToriiError::Snapshot(format!(
496 "stash@{{{}}} doesn't exist (have {} stash{})", index, count,
497 if count == 1 { "" } else { "es" }
498 )));
499 }
500
501 println!("🔄 Restoring stash@{{{}}}", index);
502 if keep {
503 let mut opts = git2::StashApplyOptions::new();
504 opts.reinstantiate_index();
505 repo.stash_apply(index, Some(&mut opts)).map_err(ToriiError::Git)?;
506 println!(" Stash kept (use `torii snapshot unstash {} --no-keep` to drop)", index);
507 } else {
508 repo.stash_pop(index, None).map_err(ToriiError::Git)?;
509 println!(" Stash popped");
510 }
511 println!("✅ Stash restored");
512
513 Ok(())
514 }
515
516 pub fn undo(&self) -> Result<()> {
518 let mut snapshots: Vec<_> = fs::read_dir(&self.snapshots_dir)?
520 .filter_map(|e| e.ok())
521 .filter(|e| {
522 let name = e.file_name().to_string_lossy().to_string();
523 name.starts_with("before-") || name.contains("auto-")
524 })
525 .collect();
526
527 snapshots.sort_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
528
529 let latest = snapshots.last()
530 .ok_or_else(|| ToriiError::Snapshot("No operation to undo".to_string()))?;
531
532 let snapshot_id = latest.file_name().to_string_lossy().to_string();
533
534 println!("🔄 Undoing last operation...");
535 println!(" Restoring snapshot: {}", snapshot_id);
536
537 self.restore_snapshot(&snapshot_id)?;
538
539 println!("✅ Operation undone");
540
541 Ok(())
542 }
543}
544
545#[cfg(test)]
546mod snapshot_location_tests {
547 use super::*;
548 use tempfile::TempDir;
549
550 fn init_repo(dir: &Path) {
551 let repo = git2::Repository::init(dir).unwrap();
552 let sig = git2::Signature::now("T", "t@x").unwrap();
555 let mut idx = repo.index().unwrap();
556 let tree_oid = idx.write_tree().unwrap();
557 let tree = repo.find_tree(tree_oid).unwrap();
558 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
559 }
560
561 #[test]
562 fn snapshots_land_under_gitdir_not_working_tree() {
563 let tmp = TempDir::new().unwrap();
564 let repo_path = tmp.path();
565 init_repo(repo_path);
566
567 let mgr = SnapshotManager::new(repo_path).unwrap();
568 let id = mgr.create_snapshot(Some("test")).unwrap();
569
570 let new_loc = repo_path.join(".git/torii/snapshots").join(&id);
572 let old_loc = repo_path.join(".torii/snapshots").join(&id);
573 assert!(new_loc.exists(), "snapshot should be at .git/torii/snapshots/{}", id);
574 assert!(!old_loc.exists(), "snapshot must NOT be in working tree at .torii/snapshots/{}", id);
575 }
576
577 #[test]
578 fn migrates_legacy_snapshots_from_working_tree_to_gitdir() {
579 let tmp = TempDir::new().unwrap();
580 let repo_path = tmp.path();
581 init_repo(repo_path);
582
583 let legacy = repo_path.join(".torii/snapshots/20200101_000000_000");
585 fs::create_dir_all(&legacy).unwrap();
586 fs::write(legacy.join("metadata.json"), "{}").unwrap();
587
588 let _mgr = SnapshotManager::new(repo_path).unwrap();
590
591 let new_loc = repo_path.join(".git/torii/snapshots/20200101_000000_000");
592 assert!(new_loc.exists(), "legacy snapshot should be migrated");
593 assert!(new_loc.join("metadata.json").exists(), "files inside should come along");
594 assert!(!legacy.exists(), "legacy location should be cleaned up");
595 }
596
597 #[test]
598 fn migration_is_idempotent_when_destination_exists() {
599 let tmp = TempDir::new().unwrap();
600 let repo_path = tmp.path();
601 init_repo(repo_path);
602
603 let id = "20200101_000000_000";
606 let legacy = repo_path.join(".torii/snapshots").join(id);
607 let new_loc = repo_path.join(".git/torii/snapshots").join(id);
608 fs::create_dir_all(&legacy).unwrap();
609 fs::create_dir_all(&new_loc).unwrap();
610 fs::write(legacy.join("source.json"), "legacy").unwrap();
611 fs::write(new_loc.join("source.json"), "new").unwrap();
612
613 let _mgr = SnapshotManager::new(repo_path).unwrap();
614
615 let content = fs::read_to_string(new_loc.join("source.json")).unwrap();
617 assert_eq!(content, "new");
618 }
619}