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 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#[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 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 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 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 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 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 pub fn checkpoint_dir(&self, slug: &str) -> Utf8PathBuf {
277 self.paths.checkpoints.join(slug)
278 }
279
280 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 pub fn state_dirs(&self) -> Result<Vec<Utf8PathBuf>> {
292 read_subdirs_sorted(&self.paths.state)
293 }
294
295 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 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 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 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 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
457pub(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}