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#[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 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 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 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 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 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 pub fn checkpoint_dir(&self, slug: &str) -> Utf8PathBuf {
234 self.paths.checkpoints.join(slug)
235 }
236
237 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 pub fn state_dirs(&self) -> Result<Vec<Utf8PathBuf>> {
249 read_subdirs_sorted(&self.paths.state)
250 }
251
252 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 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 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 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
404pub(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}