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