Skip to main content

newgit_core/
store.rs

1use camino::{Utf8Path, Utf8PathBuf};
2use chrono::Utc;
3use serde::Serialize;
4use serde::de::DeserializeOwned;
5
6use crate::branch::BranchInstance;
7use crate::config::{ProjectConfig, SourceSubstrate};
8use crate::error::{NewgitError, Result};
9use crate::materializer::{WorkspaceMarker, create_dir_all};
10use crate::resource::ResourceDefinition;
11use crate::tracker::{TrackerDefinition, validate_disjoint};
12
13const LOCAL_GITIGNORE: &str = "\
14# newgit local state — never committed
15/local/
16/branches/
17/snapshots/
18/logs/
19/state/
20/checkpoints/
21";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct MetadataStore {
25    paths: NewgitPaths,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct NewgitPaths {
30    pub project_root: Utf8PathBuf,
31    pub metadata_root: Utf8PathBuf,
32    pub config: Utf8PathBuf,
33    pub branches: Utf8PathBuf,
34    pub archived_branches: Utf8PathBuf,
35    pub trackers: Utf8PathBuf,
36    pub resources: Utf8PathBuf,
37    pub local: Utf8PathBuf,
38    pub snapshots: Utf8PathBuf,
39    pub logs: Utf8PathBuf,
40    pub state: Utf8PathBuf,
41    pub checkpoints: Utf8PathBuf,
42}
43
44/// Where a newgit command is standing: which store owns the metadata, and —
45/// when inside a workspace — which branch instance the cwd belongs to.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Context {
48    pub store: MetadataStore,
49    pub current_branch: Option<String>,
50}
51
52impl MetadataStore {
53    pub fn at(project_root: impl Into<Utf8PathBuf>) -> Self {
54        Self {
55            paths: NewgitPaths::new(project_root.into()),
56        }
57    }
58
59    pub fn init(
60        project_root: impl Into<Utf8PathBuf>,
61        project_name: &str,
62        source: SourceSubstrate,
63    ) -> Result<Self> {
64        let store = Self::at(project_root);
65        if store.paths.config.exists() {
66            return Err(NewgitError::AlreadyExists(store.paths.config.clone()));
67        }
68        store.create_layout()?;
69        let config = ProjectConfig::new(project_name, source);
70        store.write_toml(&store.paths.config, "project config", &config)?;
71        Ok(store)
72    }
73
74    /// Walk upward from `cwd` to find the governing metadata. A workspace is
75    /// recognized by its gitignored marker and resolves to the store it was
76    /// cloned from; a directory with a committed `.newgit/config.toml` and no
77    /// marker is the store itself.
78    pub fn discover(cwd: &Utf8Path) -> Result<Context> {
79        let mut dir = Some(cwd);
80        while let Some(current) = dir {
81            let metadata_root = current.join(".newgit");
82            if metadata_root.is_dir() {
83                let marker_path = metadata_root.join("local/instance.toml");
84                if marker_path.is_file() {
85                    let marker: WorkspaceMarker = read_toml_at(&marker_path)?;
86                    let store = Self::at(marker.store_root);
87                    store.ensure_initialized()?;
88                    return Ok(Context {
89                        store,
90                        current_branch: Some(marker.branch),
91                    });
92                }
93                if metadata_root.join("config.toml").is_file() {
94                    return Ok(Context {
95                        store: Self::at(current),
96                        current_branch: None,
97                    });
98                }
99            }
100            dir = current.parent();
101        }
102        Err(NewgitError::MissingMetadata(cwd.to_path_buf()))
103    }
104
105    pub fn paths(&self) -> &NewgitPaths {
106        &self.paths
107    }
108
109    pub fn ensure_initialized(&self) -> Result<()> {
110        if self.paths.metadata_root.is_dir() && self.paths.config.is_file() {
111            Ok(())
112        } else {
113            Err(NewgitError::MissingMetadata(
114                self.paths.metadata_root.clone(),
115            ))
116        }
117    }
118
119    pub fn load_config(&self) -> Result<ProjectConfig> {
120        read_toml_at(&self.paths.config)
121    }
122
123    pub fn write_config(&self, config: &ProjectConfig) -> Result<()> {
124        self.write_toml(&self.paths.config, "project config", config)
125    }
126
127    pub fn branch_record_path(&self, slug: &str) -> Utf8PathBuf {
128        self.paths.branches.join(format!("{slug}.toml"))
129    }
130
131    /// Write a brand-new binding record; fails on slug collision.
132    pub fn create_branch_record(&self, branch: &BranchInstance) -> Result<Utf8PathBuf> {
133        create_dir_all(&self.paths.branches)?;
134        let path = self.branch_record_path(&branch.slug);
135        if path.exists() {
136            return Err(NewgitError::BranchInstanceExists {
137                name: branch.name.clone(),
138                path,
139            });
140        }
141        self.write_toml(&path, &format!("branch `{}`", branch.name), branch)?;
142        Ok(path)
143    }
144
145    /// Overwrite an existing binding record (e.g. after a tracker capture).
146    pub fn save_branch_record(&self, branch: &BranchInstance) -> Result<Utf8PathBuf> {
147        create_dir_all(&self.paths.branches)?;
148        let path = self.branch_record_path(&branch.slug);
149        self.write_toml(&path, &format!("branch `{}`", branch.name), branch)?;
150        Ok(path)
151    }
152
153    /// Tracker definitions, one per file in `.newgit/trackers/`; the name
154    /// comes from the filename. Lanes are validated as disjoint.
155    pub fn load_tracker_definitions(&self) -> Result<Vec<TrackerDefinition>> {
156        self.ensure_initialized()?;
157        let mut definitions = Vec::new();
158        for entry in read_dir_sorted(&self.paths.trackers)? {
159            if entry.extension() != Some("toml") {
160                continue;
161            }
162            let Some(name) = entry.file_stem() else {
163                continue;
164            };
165            definitions.push(TrackerDefinition::from_file(name, &entry)?);
166        }
167        validate_disjoint(&definitions)?;
168        Ok(definitions)
169    }
170
171    pub fn create_tracker_definition(&self, definition: &TrackerDefinition) -> Result<Utf8PathBuf> {
172        create_dir_all(&self.paths.trackers)?;
173        let path = self
174            .paths
175            .trackers
176            .join(format!("{}.toml", definition.name));
177        if path.exists() {
178            return Err(NewgitError::AlreadyExists(path));
179        }
180        self.write_toml(
181            &path,
182            &format!("tracker `{}`", definition.name),
183            &definition.to_file(),
184        )?;
185        Ok(path)
186    }
187
188    pub fn save_tracker_definition(&self, definition: &TrackerDefinition) -> Result<Utf8PathBuf> {
189        create_dir_all(&self.paths.trackers)?;
190        let path = self
191            .paths
192            .trackers
193            .join(format!("{}.toml", definition.name));
194        self.write_toml(
195            &path,
196            &format!("tracker `{}`", definition.name),
197            &definition.to_file(),
198        )?;
199        Ok(path)
200    }
201
202    /// Resource definitions, one per file in `.newgit/resources/`.
203    pub fn load_resource_definitions(&self) -> Result<Vec<ResourceDefinition>> {
204        self.ensure_initialized()?;
205        let mut definitions = Vec::new();
206        for entry in read_dir_sorted(&self.paths.resources)? {
207            if entry.extension() != Some("toml") {
208                continue;
209            }
210            let Some(name) = entry.file_stem() else {
211                continue;
212            };
213            definitions.push(ResourceDefinition::from_file(name, &entry)?);
214        }
215        Ok(definitions)
216    }
217
218    pub fn write_resource_file(&self, name: &str, contents: &str) -> Result<Utf8PathBuf> {
219        create_dir_all(&self.paths.resources)?;
220        let path = self.paths.resources.join(format!("{name}.toml"));
221        if path.exists() {
222            return Err(NewgitError::AlreadyExists(path));
223        }
224        std::fs::write(&path, contents).map_err(|source| NewgitError::io(&path, source))?;
225        Ok(path)
226    }
227
228    pub fn instance_state_dir(&self, slug: &str) -> Utf8PathBuf {
229        self.paths.state.join(slug)
230    }
231
232    /// Per-instance checkpoint records: `.newgit/checkpoints/<slug>/`.
233    pub fn checkpoint_dir(&self, slug: &str) -> Utf8PathBuf {
234        self.paths.checkpoints.join(slug)
235    }
236
237    /// Every slug with a checkpoint directory, including instances whose
238    /// binding record has been archived. Checkpoints outlive removal, so
239    /// snapshot pruning has to consult all of them, not just live records.
240    pub fn checkpointed_slugs(&self) -> Result<Vec<String>> {
241        Ok(read_subdirs_sorted(&self.paths.checkpoints)?
242            .iter()
243            .filter_map(|dir| dir.file_name().map(ToOwned::to_owned))
244            .collect())
245    }
246
247    /// Per-instance runtime state directories that exist on disk.
248    pub fn state_dirs(&self) -> Result<Vec<Utf8PathBuf>> {
249        read_subdirs_sorted(&self.paths.state)
250    }
251
252    /// Timestamped log path for one action run.
253    pub fn action_log_path(&self, slug: &str, label: &str) -> Utf8PathBuf {
254        let now = Utc::now();
255        self.paths.logs.join(slug).join(format!(
256            "{label}-{}-{:09}Z.log",
257            now.format("%Y%m%dT%H%M%S"),
258            now.timestamp_subsec_nanos()
259        ))
260    }
261
262    /// Append patterns to the store repo's .gitignore under a labeled block.
263    pub fn append_gitignore(&self, label: &str, patterns: &[String]) -> Result<()> {
264        if patterns.is_empty() {
265            return Ok(());
266        }
267        let path = self.paths.project_root.join(".gitignore");
268        let existing = if path.exists() {
269            std::fs::read_to_string(&path).map_err(|source| NewgitError::io(&path, source))?
270        } else {
271            String::new()
272        };
273        let mut updated = existing.clone();
274        if !updated.is_empty() && !updated.ends_with('\n') {
275            updated.push('\n');
276        }
277        updated.push_str(&format!("\n# newgit tracker: {label}\n"));
278        for pattern in patterns {
279            updated.push_str(pattern);
280            updated.push('\n');
281        }
282        std::fs::write(&path, updated).map_err(|source| NewgitError::io(&path, source))
283    }
284
285    pub fn load_branches(&self) -> Result<Vec<BranchInstance>> {
286        self.ensure_initialized()?;
287        let mut branches: Vec<BranchInstance> = Vec::new();
288
289        for entry in read_dir_sorted(&self.paths.branches)? {
290            if entry.extension() == Some("toml") {
291                branches.push(read_toml_at(&entry)?);
292            }
293        }
294
295        branches.sort_by(|left, right| left.name.cmp(&right.name));
296        Ok(branches)
297    }
298
299    /// Look an instance up by name or slug.
300    pub fn find_branch(&self, name: &str) -> Result<BranchInstance> {
301        self.load_branches()?
302            .into_iter()
303            .find(|branch| branch.name == name || branch.slug == name)
304            .ok_or_else(|| NewgitError::UnknownBranchInstance(name.to_owned()))
305    }
306
307    /// The binding record outlives the workspace: removal archives it rather
308    /// than deleting it.
309    pub fn archive_branch_record(&self, branch: &BranchInstance) -> Result<Utf8PathBuf> {
310        create_dir_all(&self.paths.archived_branches)?;
311        let record = self.branch_record_path(&branch.slug);
312        let archived = self.paths.archived_branches.join(format!(
313            "{}-{}.toml",
314            branch.slug,
315            Utc::now().format("%Y%m%dT%H%M%SZ")
316        ));
317        std::fs::rename(&record, &archived).map_err(|source| NewgitError::io(record, source))?;
318        Ok(archived)
319    }
320
321    fn create_layout(&self) -> Result<()> {
322        for path in [
323            &self.paths.metadata_root,
324            &self.paths.branches,
325            &self.paths.trackers,
326            &self.paths.resources,
327            &self.paths.local,
328            &self.paths.snapshots,
329            &self.paths.logs,
330            &self.paths.state,
331            &self.paths.checkpoints,
332        ] {
333            create_dir_all(path)?;
334        }
335
336        let gitignore = self.paths.metadata_root.join(".gitignore");
337        if !gitignore.exists() {
338            std::fs::write(&gitignore, LOCAL_GITIGNORE)
339                .map_err(|source| NewgitError::io(gitignore, source))?;
340        }
341        Ok(())
342    }
343
344    fn write_toml<T>(&self, path: &Utf8Path, label: &str, value: &T) -> Result<()>
345    where
346        T: Serialize,
347    {
348        write_toml_at(path, label, value)
349    }
350}
351
352impl NewgitPaths {
353    pub fn new(project_root: Utf8PathBuf) -> Self {
354        let metadata_root = project_root.join(".newgit");
355        Self {
356            project_root,
357            config: metadata_root.join("config.toml"),
358            branches: metadata_root.join("branches"),
359            archived_branches: metadata_root.join("branches/archived"),
360            trackers: metadata_root.join("trackers"),
361            resources: metadata_root.join("resources"),
362            local: metadata_root.join("local"),
363            snapshots: metadata_root.join("snapshots"),
364            logs: metadata_root.join("logs"),
365            state: metadata_root.join("state"),
366            checkpoints: metadata_root.join("checkpoints"),
367            metadata_root,
368        }
369    }
370}
371
372pub fn expand_home(path: &Utf8Path) -> Utf8PathBuf {
373    let Some(stripped) = path.as_str().strip_prefix("~/") else {
374        return path.to_path_buf();
375    };
376
377    std::env::var("HOME")
378        .map(|home| Utf8PathBuf::from(home).join(stripped))
379        .unwrap_or_else(|_| path.to_path_buf())
380}
381
382pub(crate) fn read_toml_at<T>(path: &Utf8Path) -> Result<T>
383where
384    T: DeserializeOwned,
385{
386    let contents = std::fs::read_to_string(path).map_err(|source| NewgitError::io(path, source))?;
387    toml::from_str(&contents).map_err(|source| NewgitError::TomlRead {
388        path: path.to_path_buf(),
389        source,
390    })
391}
392
393pub(crate) fn write_toml_at<T>(path: &Utf8Path, label: &str, value: &T) -> Result<()>
394where
395    T: Serialize,
396{
397    let contents = toml::to_string_pretty(value).map_err(|source| NewgitError::TomlWrite {
398        label: label.to_owned(),
399        source,
400    })?;
401    std::fs::write(path, contents).map_err(|source| NewgitError::io(path, source))
402}
403
404/// Immediate subdirectories, sorted. The dir-shaped counterpart to
405/// [`read_dir_sorted`], for walking per-instance checkpoint dirs and lane
406/// rev dirs.
407pub(crate) fn read_subdirs_sorted(path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
408    if !path.exists() {
409        return Ok(Vec::new());
410    }
411
412    let mut entries = Vec::new();
413    for entry in std::fs::read_dir(path).map_err(|source| NewgitError::io(path, source))? {
414        let entry = entry.map_err(|source| NewgitError::io(path, source))?;
415        let path = Utf8PathBuf::from_path_buf(entry.path())
416            .map_err(|path| NewgitError::NonUtf8Path(path.display().to_string()))?;
417        if path.is_dir() {
418            entries.push(path);
419        }
420    }
421    entries.sort();
422    Ok(entries)
423}
424
425pub(crate) fn read_dir_sorted(path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
426    if !path.exists() {
427        return Ok(Vec::new());
428    }
429
430    let mut entries = Vec::new();
431    for entry in std::fs::read_dir(path).map_err(|source| NewgitError::io(path, source))? {
432        let entry = entry.map_err(|source| NewgitError::io(path, source))?;
433        let path = Utf8PathBuf::from_path_buf(entry.path())
434            .map_err(|path| NewgitError::NonUtf8Path(path.display().to_string()))?;
435        if path.is_file() && path.file_name() != Some(".DS_Store") {
436            entries.push(path);
437        }
438    }
439    entries.sort();
440    Ok(entries)
441}