Skip to main content

torii_lib/vcs/
snapshot.rs

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
8/// Recursive directory copy used by the legacy-snapshot migration when
9/// a cross-filesystem `fs::rename` fails. Best-effort — propagates
10/// errors so callers can decide whether to abort or skip the entry.
11fn 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        // 0.7.7: snapshots live INSIDE the gitdir (.git/torii/snapshots/)
46        // rather than in the working tree (.torii/snapshots/). The old
47        // location was traversed by `torii save -a` because nothing put
48        // `.torii/` in .gitignore, so a 681 MB working-tree snapshot got
49        // committed and pushed in the wild. Putting snapshots under the
50        // gitdir mirrors how git itself stores private state (hooks,
51        // refs, objects) where `git add` never reaches. See
52        // docs/fixed/BUG_SNAPSHOT_LEAKS_INTO_COMMITS.md.
53        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        // One-shot migration: pull any pre-0.7.7 snapshots out of the
61        // working tree into the new gitdir location. Idempotent — runs
62        // only if the old dir has entries.
63        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    /// Move every `<old>/<id>/` directory into `<new>/<id>/`. Skips
75    /// destinations that already exist. Removes the old parent if it
76    /// ends up empty. Best-effort: copies on cross-FS rename failure,
77    /// never aborts the caller.
78    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                // Cross-FS or busy: fall back to copy + remove, never abort.
94                if copy_dir_all(&src, &dst).is_ok() {
95                    let _ = fs::remove_dir_all(&src);
96                }
97            }
98        }
99        // Best-effort cleanup of the now-empty .torii/snapshots/ and
100        // (if empty) .torii/ parent. Failure is fine — config.json or
101        // mirrors.json may still live there legitimately.
102        let _ = fs::remove_dir(old);
103        if let Some(parent) = old.parent() {
104            let _ = fs::remove_dir(parent);
105        }
106        Ok(())
107    }
108
109    /// Create a new snapshot
110    pub fn create_snapshot(&self, name: Option<&str>) -> Result<String> {
111        let repo = GitRepo::open(&self.repo_path)?;
112        let timestamp = Utc::now();
113        // Include millis so back-to-back snapshots in the same second don't
114        // collide and silently overwrite each other (the original `_HMS`
115        // format made `stash` lose data when invoked twice quickly).
116        let mut id = timestamp.format("%Y%m%d_%H%M%S_%3f").to_string();
117
118        // Defensive: if even the millis collide (highly unlikely), append
119        // an integer suffix until the dir is fresh. Use atomic create_dir
120        // so two parallel `torii save` invocations can't both decide the
121        // same dir is free and silently overwrite each other's bundle.
122        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    /// Create a git bundle for the snapshot
157    fn create_bundle(&self, snapshot_dir: &Path, repo: &GitRepo) -> Result<()> {
158        // Create bundle with all refs
159        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        // .git is normally a directory (regular checkout). In linked
166        // worktrees and submodules it's a regular file whose first line is
167        // "gitdir: <path-to-real-gitdir>" pointing at the metadata that
168        // actually lives elsewhere — shared with the main repo. In that
169        // case copying the file alone preserves the link; the worktree's
170        // working-tree content gets copied below alongside it. We do NOT
171        // duplicate the linked gitdir because (a) it's shared and (b) the
172        // worktree's unique state lives in the working tree.
173        // Exclude our own state directory (<gitdir>/torii/) from the
174        // .git copy. Since 0.7.7 snapshots live INSIDE the gitdir, so
175        // a naive recursive copy of `.git/` into `.git/torii/snapshots/<id>/git_backup`
176        // would walk into its own destination forever. The torii/
177        // subdir of the gitdir is tool-private state — never useful to
178        // include inside a snapshot of itself.
179        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                // .git is a file (worktree / submodule gitlink). Copy the
186                // single file so we know which gitdir this was tied to,
187                // then leave the rest alone.
188                fs::create_dir_all(&snapshot_git)?;
189                fs::copy(&git_path, snapshot_git.join("gitdir-link"))?;
190                // Also dump the resolved gitdir path so restoration knows
191                // where the real metadata lived.
192                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    /// Recursively copy `src` into `dst`. Skips any path equal to
206    /// `exclude` (matched by canonical-path comparison when both
207    /// resolve), so a `.git` copy can be written into a destination
208    /// inside `.git/` itself without recursing forever. Used by
209    /// `create_bundle` since 0.7.7 — snapshots now live at
210    /// `<gitdir>/torii/snapshots/`, which is inside the source of the
211    /// bundle's git-dir copy.
212    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        // Canonicalise the exclude path once. If canonicalisation fails
220        // (path may not exist yet, e.g. dst itself), fall back to the
221        // raw form.
222        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            // Compare canonical paths so the exclusion catches both
230            // "src/torii" entered via `.git/torii` and via a symlink.
231            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    /// List all snapshots
250    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    /// Restore from a snapshot
283    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        // Reset working directory to match restored git state via git2
297        {
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    /// Delete a snapshot
315    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    /// Delete every snapshot in this repo. Returns the count deleted so
327    /// the CLI can report it. Idempotent — empty dir returns 0.
328    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    /// Print everything we have on one snapshot: metadata + bundle layout.
344    /// Doesn't try to list every file under git_backup (could be huge);
345    /// shows the top-level entries so the user knows it's a real backup.
346    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        // Show what's captured inside.
368        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    /// Configure auto-snapshot settings
379    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    /// Save work temporarily (like git stash).
400    ///
401    /// Uses libgit2's native stash API rather than the snapshot bundle path.
402    /// The previous implementation copied `.git/` and reset HEAD, which
403    /// silently dropped working-tree changes — `git_backup` only contains
404    /// committed history, so any uncommitted edits were unrecoverable.
405    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        // Detect whether there is anything to stash; libgit2 errors with
411        // "no changes selected" otherwise and the message is unhelpful.
412        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        // Use the unified resolver: torii config > git config > error.
426        // The previous "torii"/"torii@local" placeholder fallback (so
427        // stash "never fails") matched the same anti-pattern that
428        // BUG_COMMIT_AUTHOR_FALLBACK.md describes for `save`. Silent
429        // bogus authorship is worse than failing fast and prompting the
430        // user to set their identity — even for stashes.
431        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        // Defensive: libgit2's `stash_save2` has been observed to
441        // return OK without actually saving anything in some edge
442        // cases (changes only in the index with DEFAULT flags). Verify
443        // the working tree is clean post-stash; if it isn't, libgit2
444        // lied to us and we don't want the user to think their
445        // changes are safe when they aren't.
446        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    /// Restore stashed work via libgit2's native stash API.
473    /// `id` selects which stash entry: `"0"` (default) is the most recent,
474    /// `"1"` the one before, etc. `keep` retains the stash entry after apply.
475    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        // Confirm the entry exists for a friendlier error than libgit2's.
489        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    /// Undo last operation
517    pub fn undo(&self) -> Result<()> {
518        // Find most recent auto snapshot
519        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        // SnapshotManager needs HEAD to resolve a branch — make an
553        // empty commit so get_current_branch() doesn't blow up.
554        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        // Must exist inside .git/torii/snapshots/, NOT .torii/snapshots/
571        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        // Seed the legacy location with a fake snapshot dir.
584        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        // Constructing the manager triggers migration.
589        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        // Same id pre-exists in both locations: migration should not
604        // overwrite the new one (preserves whatever 0.7.7+ wrote).
605        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        // New location's content is preserved (not clobbered by legacy).
616        let content = fs::read_to_string(new_loc.join("source.json")).unwrap();
617        assert_eq!(content, "new");
618    }
619}