Skip to main content

newgit_core/
lane.rs

1use camino::{Utf8Path, Utf8PathBuf};
2
3use crate::error::{NewgitError, Result};
4use crate::materializer::create_dir_all;
5use crate::tracker::{TrackerDefinition, collect_all_files, collect_owned_files, content_rev};
6
7/// A tracker's content lane in the store: content-addressed snapshots under
8/// `.newgit/snapshots/<tracker>/<rev>/`, plus a LATEST pointer that marks the
9/// lane head/default for new instances and explicit pulls.
10///
11/// Checkpoints reference lane revs by name, so pruning (a future `cleanup`
12/// concern) must never delete a rev any checkpoint record still points at.
13#[derive(Debug, Clone)]
14pub struct TrackerLane {
15    root: Utf8PathBuf,
16    tracker: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CaptureOutcome {
21    pub rev: String,
22    pub files: usize,
23}
24
25impl TrackerLane {
26    pub fn new(snapshots_root: &Utf8Path, tracker: &str) -> Self {
27        Self {
28            root: snapshots_root.join(tracker),
29            tracker: tracker.to_owned(),
30        }
31    }
32
33    pub fn latest(&self) -> Option<String> {
34        let contents = std::fs::read_to_string(self.root.join("LATEST")).ok()?;
35        let rev = contents.trim().to_owned();
36        (!rev.is_empty()).then_some(rev)
37    }
38
39    pub fn set_latest(&self, rev: &str) -> Result<()> {
40        create_dir_all(&self.root)?;
41        let path = self.root.join("LATEST");
42        std::fs::write(&path, format!("{rev}\n")).map_err(|source| NewgitError::io(path, source))
43    }
44
45    pub fn has_rev(&self, rev: &str) -> bool {
46        self.rev_dir(rev).is_dir()
47    }
48
49    fn rev_dir(&self, rev: &str) -> Utf8PathBuf {
50        self.root.join(rev)
51    }
52
53    /// Snapshot the tracker's owned paths from `workspace`. Identical
54    /// content dedupes to an existing rev.
55    pub fn capture(
56        &self,
57        workspace: &Utf8Path,
58        definition: &TrackerDefinition,
59    ) -> Result<CaptureOutcome> {
60        if definition.paths.is_empty() {
61            return Err(NewgitError::TrackerHasNoPaths(self.tracker.clone()));
62        }
63        let files = collect_owned_files(workspace, definition)?;
64        self.store_snapshot(&files)
65    }
66
67    /// Snapshot an arbitrary directory's contents into the lane — how a
68    /// resource checkpoint deposits into a deposit-only tracker (one with no
69    /// owned workspace paths).
70    pub fn deposit(&self, dir: &Utf8Path) -> Result<CaptureOutcome> {
71        let files = collect_all_files(dir)?;
72        self.store_snapshot(&files)
73    }
74
75    /// Write files as a content-addressed rev. Identical content dedupes to
76    /// an existing rev.
77    fn store_snapshot(&self, files: &[(Utf8PathBuf, Utf8PathBuf)]) -> Result<CaptureOutcome> {
78        let rev = content_rev(files)?;
79
80        let rev_dir = self.rev_dir(&rev);
81        if !rev_dir.exists() {
82            let staging = self.root.join(format!("{rev}.tmp"));
83            if staging.exists() {
84                std::fs::remove_dir_all(&staging)
85                    .map_err(|source| NewgitError::io(&staging, source))?;
86            }
87            for (relative, absolute) in files {
88                copy_file(absolute, &staging.join(relative))?;
89            }
90            create_dir_all(&staging)?;
91            std::fs::rename(&staging, &rev_dir)
92                .map_err(|source| NewgitError::io(&rev_dir, source))?;
93        }
94
95        Ok(CaptureOutcome {
96            rev,
97            files: files.len(),
98        })
99    }
100
101    /// Absolute path of a captured rev's content directory.
102    pub fn rev_path(&self, rev: &str) -> Utf8PathBuf {
103        self.rev_dir(rev)
104    }
105
106    /// Put a captured rev back into the workspace: owned paths are cleared
107    /// first, so restore reproduces the captured state exactly (including
108    /// file absence).
109    pub fn restore(
110        &self,
111        workspace: &Utf8Path,
112        definition: &TrackerDefinition,
113        rev: &str,
114    ) -> Result<usize> {
115        let rev_dir = self.rev_dir(rev);
116        if !rev_dir.is_dir() {
117            return Err(NewgitError::NoSnapshot {
118                tracker: self.tracker.clone(),
119                rev: rev.to_owned(),
120            });
121        }
122
123        clear_owned_paths(workspace, definition)?;
124
125        // The snapshot mirrors the workspace-relative layout, so the same
126        // walk that captures from a workspace enumerates a snapshot.
127        let files = collect_owned_files(&rev_dir, definition)?;
128        for (relative, absolute) in &files {
129            copy_file(absolute, &workspace.join(relative))?;
130        }
131        Ok(files.len())
132    }
133}
134
135/// Remove a tracker's owned paths from a workspace — restoring "no captured
136/// content" means file absence, not leftovers.
137pub fn clear_owned_paths(workspace: &Utf8Path, definition: &TrackerDefinition) -> Result<()> {
138    for owned in &definition.paths {
139        let target = workspace.join(owned);
140        if target.is_dir() {
141            std::fs::remove_dir_all(&target).map_err(|source| NewgitError::io(&target, source))?;
142        } else if target.is_file() {
143            std::fs::remove_file(&target).map_err(|source| NewgitError::io(&target, source))?;
144        }
145    }
146    Ok(())
147}
148
149pub fn copy_file(from: &Utf8Path, to: &Utf8Path) -> Result<()> {
150    if let Some(parent) = to.parent() {
151        create_dir_all(parent)?;
152    }
153    std::fs::copy(from, to)
154        .map(|_| ())
155        .map_err(|source| NewgitError::io(to.to_path_buf(), source))
156}