1use std::path::Path;
42use std::sync::Arc;
43
44use memstead_schema::{MemConfig, Schema, loader::SchemaLoadError};
45
46use crate::mem_repo_config::{
47 MemRepoWriteError, RefSpec, commit_refs_at_gitdir, resolve_full_path_at_gitdir,
48};
49use crate::mem_repo_schemas::{LoadOutcome, MemRepoSchemasError};
50use crate::vcs::{CommitContext, author_identity, format_commit_message};
51
52const COMMITTER_NAME: &str = "engine";
53const COMMITTER_EMAIL: &str = "noreply@memstead.io";
54
55#[derive(Debug, thiserror::Error)]
58pub enum MemsteadRefError {
59 #[error("could not open mem-repo gitdir: {0}")]
60 GixOpen(String),
61 #[error("git tree read error: {0}")]
62 GitTree(String),
63 #[error("git commit error: {0}")]
64 GitCommit(String),
65 #[error("schema blob {0} is not valid UTF-8: {1}")]
66 NotUtf8(String, String),
67 #[error("schema '{name}': {source}")]
68 Schema {
69 name: String,
70 #[source]
71 source: SchemaLoadError,
72 },
73 #[error("config json error at {path}: {message}")]
74 Config { path: String, message: String },
75}
76
77#[derive(Debug, Clone)]
79pub struct MemsteadMigrationOutcome {
80 pub commit_sha: String,
84 pub already_current: bool,
87 pub schemas_migrated: usize,
89 pub mems_migrated: usize,
92}
93
94pub fn migrate_to_memstead_ref(
108 gitdir: &Path,
109) -> Result<MemsteadMigrationOutcome, MemsteadRefError> {
110 let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
111 let mut schema_entries: Vec<(String, gix::ObjectId)> = Vec::new();
117 if let Some(reference) = repo
118 .try_find_reference("refs/heads/__SCHEMAS")
119 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
120 {
121 let id = reference
122 .into_fully_peeled_id()
123 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
124 let commit = id
125 .object()
126 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
127 .try_into_commit()
128 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
129 let tree = commit
130 .tree()
131 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
132 for entry_res in tree.iter() {
133 let entry = entry_res.map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
134 if !matches!(entry.mode().kind(), gix::object::tree::EntryKind::Tree) {
135 continue;
136 }
137 let dir_name = match std::str::from_utf8(entry.filename()) {
138 Ok(s) => s.to_string(),
139 Err(_) => continue,
140 };
141 let subtree = repo
143 .find_object(entry.oid().to_owned())
144 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
145 .try_into_tree()
146 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
147 let manifest_entry = subtree
148 .lookup_entry_by_path("schema.yaml")
149 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
150 let version = match manifest_entry {
151 Some(me) => {
152 let manifest_obj = me
153 .object()
154 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
155 let yaml = std::str::from_utf8(&manifest_obj.data)
156 .map_err(|e| {
157 MemsteadRefError::NotUtf8(
158 format!("{dir_name}/schema.yaml"),
159 e.to_string(),
160 )
161 })?
162 .to_string();
163 extract_manifest_version(&yaml).unwrap_or_else(|| "0.0.0".to_string())
164 }
165 None => "0.0.0".to_string(),
166 };
167 schema_entries.push((format!("{dir_name}@{version}"), entry.oid().to_owned()));
168 }
169 }
170
171 let mut mem_entries: Vec<(String, gix::ObjectId, gix::object::tree::EntryKind)> = Vec::new();
176 if let Some(reference) = repo
177 .try_find_reference("refs/heads/__SYSTEM")
178 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
179 {
180 let id = reference
181 .into_fully_peeled_id()
182 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
183 let commit = id
184 .object()
185 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
186 .try_into_commit()
187 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
188 let tree = commit
189 .tree()
190 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
191 for entry_res in tree.iter() {
192 let entry = entry_res.map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
193 let name = match std::str::from_utf8(entry.filename()) {
194 Ok(s) => s.to_string(),
195 Err(_) => continue,
196 };
197 if name == "repo.json" {
198 continue;
201 }
202 mem_entries.push((name, entry.oid().to_owned(), entry.mode().kind()));
203 }
204 }
205
206 let mut editor = repo
209 .empty_tree()
210 .edit()
211 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
212 for (path_segment, oid) in &schema_entries {
213 editor
214 .upsert(
215 format!("schemas/{path_segment}").as_str(),
216 gix::object::tree::EntryKind::Tree,
217 *oid,
218 )
219 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
220 }
221 for (name, oid, kind) in &mem_entries {
222 editor
223 .upsert(format!("mems/{name}").as_str(), *kind, *oid)
224 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
225 }
226 let new_tree_id = editor
227 .write()
228 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
229 .detach();
230
231 let existing_tip: Option<(gix::ObjectId, gix::ObjectId)> = repo
234 .try_find_reference("refs/heads/__MEMSTEAD")
235 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
236 .and_then(|r| {
237 let id = r.into_fully_peeled_id().ok()?;
238 let commit = id.object().ok()?.try_into_commit().ok()?;
239 let tree_id = commit.tree().ok()?.id;
240 Some((id.detach(), tree_id))
241 });
242 if let Some((tip_id, tree_id)) = existing_tip
243 && tree_id == new_tree_id
244 {
245 return Ok(MemsteadMigrationOutcome {
246 commit_sha: tip_id.to_hex().to_string(),
247 already_current: true,
248 schemas_migrated: schema_entries.len(),
249 mems_migrated: mem_entries.len(),
250 });
251 }
252
253 let time = gix::date::Time::now_local_or_utc();
257 let signature = gix::actor::Signature {
258 name: "engine".into(),
259 email: "noreply@memstead.io".into(),
260 time,
261 };
262 let mut buf = gix::date::parse::TimeBuf::default();
263 let sig_ref = signature.to_ref(&mut buf);
264 let parents: Vec<gix::ObjectId> = match existing_tip {
265 Some((tip, _)) => vec![tip],
266 None => Vec::new(),
267 };
268 let commit_id = repo
269 .commit_as(
270 sig_ref,
271 sig_ref,
272 "refs/heads/__MEMSTEAD",
273 "memstead: storage migration to unified __MEMSTEAD ref",
274 new_tree_id,
275 parents,
276 )
277 .map_err(|e| MemsteadRefError::GitCommit(e.to_string()))?;
278
279 Ok(MemsteadMigrationOutcome {
280 commit_sha: commit_id.to_hex().to_string(),
281 already_current: false,
282 schemas_migrated: schema_entries.len(),
283 mems_migrated: mem_entries.len(),
284 })
285}
286
287#[derive(Debug, Clone)]
289pub struct SchemaWriteOutcome {
290 pub commit_sha: String,
292 pub already_current: bool,
295}
296
297pub fn write_schema_to_memstead_ref(
311 gitdir: &Path,
312 name: &str,
313 version: &str,
314 files: &[(String, Vec<u8>)],
315) -> Result<SchemaWriteOutcome, MemsteadRefError> {
316 let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
317
318 let existing_tip: Option<(gix::ObjectId, gix::ObjectId)> = repo
320 .try_find_reference("refs/heads/__MEMSTEAD")
321 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
322 .and_then(|r| {
323 let id = r.into_fully_peeled_id().ok()?;
324 let commit = id.object().ok()?.try_into_commit().ok()?;
325 let tree_id = commit.tree().ok()?.id;
326 Some((id.detach(), tree_id))
327 });
328
329 let base_tree = match existing_tip {
332 Some((_, tree_id)) => repo
333 .find_object(tree_id)
334 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
335 .try_into_tree()
336 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?,
337 None => repo.empty_tree(),
338 };
339 let mut editor = base_tree
340 .edit()
341 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
342 let prefix = format!("schemas/{name}@{version}");
343 for (rel, bytes) in files {
344 let blob_id = repo
345 .write_blob(bytes)
346 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
347 .detach();
348 editor
349 .upsert(
350 format!("{prefix}/{rel}").as_str(),
351 gix::object::tree::EntryKind::Blob,
352 blob_id,
353 )
354 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
355 }
356 let new_tree_id = editor
357 .write()
358 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
359 .detach();
360
361 if let Some((tip_id, tree_id)) = existing_tip
363 && tree_id == new_tree_id
364 {
365 return Ok(SchemaWriteOutcome {
366 commit_sha: tip_id.to_hex().to_string(),
367 already_current: true,
368 });
369 }
370
371 let time = gix::date::Time::now_local_or_utc();
372 let signature = gix::actor::Signature {
373 name: "engine".into(),
374 email: "noreply@memstead.io".into(),
375 time,
376 };
377 let mut buf = gix::date::parse::TimeBuf::default();
378 let sig_ref = signature.to_ref(&mut buf);
379 let parents: Vec<gix::ObjectId> = match existing_tip {
380 Some((tip, _)) => vec![tip],
381 None => Vec::new(),
382 };
383 let commit_id = repo
384 .commit_as(
385 sig_ref,
386 sig_ref,
387 "refs/heads/__MEMSTEAD",
388 format!("memstead: install schema {name}@{version}"),
389 new_tree_id,
390 parents,
391 )
392 .map_err(|e| MemsteadRefError::GitCommit(e.to_string()))?;
393
394 Ok(SchemaWriteOutcome {
395 commit_sha: commit_id.to_hex().to_string(),
396 already_current: false,
397 })
398}
399
400fn extract_manifest_version(yaml: &str) -> Option<String> {
411 for line in yaml.lines() {
412 let trimmed = line.trim_start();
413 if let Some(rest) = trimmed.strip_prefix("version:") {
414 let value = rest.trim().trim_matches(|c: char| c == '"' || c == '\'');
415 if value.is_empty() {
416 return None;
417 }
418 return Some(value.to_string());
419 }
420 }
421 None
422}
423
424pub fn load_schemas_from_memstead_ref(
432 workspace_root: &Path,
433) -> Result<LoadOutcome, MemRepoSchemasError> {
434 let gitdir = workspace_root.join("mem-repo").join(".git");
435 if !gitdir.is_dir() {
436 return Ok(LoadOutcome::NoMemRepo);
437 }
438 load_schemas_from_memstead_ref_at_gitdir(&gitdir)
439}
440
441pub fn load_schemas_from_memstead_ref_at_gitdir(
443 gitdir: &Path,
444) -> Result<LoadOutcome, MemRepoSchemasError> {
445 let repo = gix::open(gitdir).map_err(|e| MemRepoSchemasError::GixOpen(e.to_string()))?;
446 let memstead_ref = match repo
447 .try_find_reference("refs/heads/__MEMSTEAD")
448 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
449 {
450 Some(r) => r,
451 None => return Ok(LoadOutcome::NoMemRepo),
452 };
453 let id = memstead_ref
454 .into_fully_peeled_id()
455 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
456 let commit = id
457 .object()
458 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
459 .try_into_commit()
460 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
461 let tree = commit
462 .tree()
463 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
464
465 let schemas_entry = match tree
466 .lookup_entry_by_path("schemas")
467 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
468 {
469 Some(e) if e.mode().is_tree() => e,
470 _ => return Ok(LoadOutcome::NoSchemas),
471 };
472 let schemas_tree = schemas_entry
473 .object()
474 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
475 .try_into_tree()
476 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
477
478 let mut entries: Vec<(String, gix::ObjectId, gix::object::tree::EntryKind)> = Vec::new();
479 for entry_res in schemas_tree.iter() {
480 let entry = entry_res.map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
481 let name = match std::str::from_utf8(entry.filename()) {
482 Ok(s) => s.to_string(),
483 Err(_) => continue,
484 };
485 entries.push((name, entry.oid().to_owned(), entry.mode().kind()));
486 }
487 entries.sort_by(|a, b| a.0.cmp(&b.0));
488
489 let mut out: Vec<Arc<Schema>> = Vec::new();
490 for (versioned_name, oid, kind) in entries {
491 if !matches!(kind, gix::object::tree::EntryKind::Tree) {
492 continue;
493 }
494 let schema_obj = repo
495 .find_object(oid)
496 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
497 let schema_tree = schema_obj
498 .try_into_tree()
499 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
500
501 let manifest_yaml =
502 read_blob_string_for_schemas(&repo, &schema_tree, "schema.yaml", &versioned_name)?;
503 let mut types_yamls: Vec<(String, String)> = Vec::new();
504 if let Some(types_entry) = schema_tree
505 .lookup_entry_by_path("types")
506 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
507 && types_entry.mode().is_tree()
508 {
509 let types_obj = types_entry
510 .object()
511 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
512 let types_tree = types_obj
513 .try_into_tree()
514 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
515 for entry_res in types_tree.iter() {
516 let entry = entry_res.map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
517 if !entry.mode().is_blob() {
518 continue;
519 }
520 let filename = match std::str::from_utf8(entry.filename()) {
521 Ok(s) => s,
522 Err(_) => continue,
523 };
524 let stem = match filename.strip_suffix(".yaml") {
525 Some(s) => s.to_string(),
526 None => continue,
527 };
528 let blob = repo
529 .find_object(entry.oid().to_owned())
530 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
531 let bytes = blob.data.clone();
532 let contents = String::from_utf8(bytes).map_err(|e| {
533 MemRepoSchemasError::NotUtf8(
534 format!("{versioned_name}/types/{filename}"),
535 e.to_string(),
536 )
537 })?;
538 types_yamls.push((stem, contents));
539 }
540 types_yamls.sort_by(|a, b| a.0.cmp(&b.0));
541 }
542
543 let schema = memstead_schema::loader::load_schema_from_memory(&manifest_yaml, &types_yamls)
544 .map_err(|source| MemRepoSchemasError::Schema {
545 name: versioned_name.clone(),
546 source,
547 })?;
548 out.push(Arc::new(schema));
549 }
550
551 if out.is_empty() {
552 Ok(LoadOutcome::NoSchemas)
553 } else {
554 Ok(LoadOutcome::Schemas(out))
555 }
556}
557
558fn read_blob_string_for_schemas(
559 _repo: &gix::Repository,
560 schema_tree: &gix::Tree<'_>,
561 filename: &str,
562 schema_name: &str,
563) -> Result<String, MemRepoSchemasError> {
564 let entry = schema_tree
565 .lookup_entry_by_path(filename)
566 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
567 .ok_or_else(|| {
568 MemRepoSchemasError::GitTree(format!(
569 "schema '{schema_name}': missing {filename} in __MEMSTEAD"
570 ))
571 })?;
572 let object = entry
573 .object()
574 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
575 let bytes = object.data.clone();
576 String::from_utf8(bytes).map_err(|e| {
577 MemRepoSchemasError::NotUtf8(format!("{schema_name}/{filename}"), e.to_string())
578 })
579}
580
581pub fn read_mem_config_from_memstead_ref(
595 gitdir: &Path,
596 mem_name: &str,
597) -> Result<MemConfig, MemsteadRefError> {
598 let full_tree_path = match resolve_full_path_at_gitdir(gitdir, mem_name) {
603 Ok(Some(p)) => p,
604 Ok(None) => mem_name.to_string(),
605 Err(e) => {
606 return Err(MemsteadRefError::GitTree(e.to_string()));
607 }
608 };
609
610 let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
611 let memstead_ref = repo
612 .try_find_reference("refs/heads/__MEMSTEAD")
613 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
614 .ok_or_else(|| MemsteadRefError::Config {
615 path: "refs/heads/__MEMSTEAD".to_string(),
616 message: "ref not found".to_string(),
617 })?;
618 let id = memstead_ref
619 .into_fully_peeled_id()
620 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
621 let commit = id
622 .object()
623 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
624 .try_into_commit()
625 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
626 let tree = commit
627 .tree()
628 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
629
630 let path = format!("mems/{full_tree_path}/config.json");
631 let entry = tree
632 .lookup_entry_by_path(&path)
633 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
634 .ok_or_else(|| MemsteadRefError::Config {
635 path: path.clone(),
636 message: "config not found in __MEMSTEAD tree".to_string(),
637 })?;
638 let object = entry
639 .object()
640 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
641 let bytes = object.data.clone();
642 let raw = String::from_utf8(bytes).map_err(|e| MemsteadRefError::Config {
643 path: path.clone(),
644 message: format!("not utf-8: {e}"),
645 })?;
646 let value: serde_json::Value =
647 serde_json::from_str(&raw).map_err(|e| MemsteadRefError::Config {
648 path: path.clone(),
649 message: format!("invalid json: {e}"),
650 })?;
651 memstead_schema::parse_mem_config(&value).map_err(|e| MemsteadRefError::Config {
652 path,
653 message: e.to_string(),
654 })
655}
656
657pub fn commit_config_to_memstead_at_gitdir(
678 gitdir: &Path,
679 mem_name: &str,
680 config_bytes: &[u8],
681 ctx: &CommitContext<'_>,
682 message: &str,
683) -> Result<(), MemRepoWriteError> {
684 let full_tree_path = match resolve_full_path_at_gitdir(gitdir, mem_name) {
685 Ok(Some(p)) => p,
686 Ok(None) => mem_name.to_string(),
687 Err(crate::mem_repo_config::MemRepoConfigError::GitdirNotFound(p)) => {
688 return Err(MemRepoWriteError::GixOpen {
689 path: p,
690 message: "mem-repo gitdir not found".to_string(),
691 });
692 }
693 Err(e) => {
694 return Err(MemRepoWriteError::GitTree(e.to_string()));
695 }
696 };
697
698 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
699 path: gitdir.display().to_string(),
700 message: e.to_string(),
701 })?;
702 let existing_tip: Option<gix::ObjectId> = repo
705 .try_find_reference("refs/heads/__MEMSTEAD")
706 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
707 .map(|r| {
708 r.into_fully_peeled_id()
709 .map(|id| id.detach())
710 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
711 })
712 .transpose()?;
713
714 let mut editor = match existing_tip {
718 Some(tip) => {
719 let commit = repo
720 .find_object(tip)
721 .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
722 .into_commit();
723 let tree = commit
724 .tree()
725 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
726 tree.edit().map_err(|e| {
727 MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
728 })?
729 }
730 None => repo.empty_tree().edit().map_err(|e| {
731 MemRepoWriteError::GitTree(format!("editor init (empty) for __MEMSTEAD: {e}"))
732 })?,
733 };
734 let blob_id = repo
735 .write_blob(config_bytes)
736 .map_err(|e| MemRepoWriteError::GitTree(format!("write config blob: {e}")))?
737 .detach();
738 let tree_path = format!("mems/{full_tree_path}/config.json");
739 editor
740 .upsert(
741 tree_path.as_str(),
742 gix::objs::tree::EntryKind::Blob,
743 blob_id,
744 )
745 .map_err(|e| MemRepoWriteError::GitTree(format!("tree upsert {tree_path}: {e}")))?;
746 let new_tree_id = editor
747 .write()
748 .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
749 .detach();
750
751 let time = gix::date::Time::now_local_or_utc();
752 let committer_sig = gix::actor::Signature {
753 name: COMMITTER_NAME.into(),
754 email: COMMITTER_EMAIL.into(),
755 time,
756 };
757 let author_sig = match author_identity(ctx) {
758 Some((name, email)) => gix::actor::Signature {
759 name: name.into(),
760 email: email.into(),
761 time,
762 },
763 None => committer_sig.clone(),
764 };
765
766 let full_message = format_commit_message(message, ctx);
767
768 let parents: Vec<gix::ObjectId> = match existing_tip {
769 Some(tip) => vec![tip],
770 None => Vec::new(),
771 };
772 let commit = gix::objs::Commit {
773 message: full_message.into(),
774 tree: new_tree_id,
775 author: author_sig,
776 committer: committer_sig,
777 encoding: None,
778 parents: parents.into_iter().collect(),
779 extra_headers: Default::default(),
780 };
781 let new_commit_id = repo
782 .write_object(&commit)
783 .map_err(|e| MemRepoWriteError::GitTree(format!("write {tree_path} commit: {e}")))?
784 .detach();
785
786 let expected = match existing_tip {
787 Some(tip) => {
788 gix::refs::transaction::PreviousValue::MustExistAndMatch(gix::refs::Target::Object(tip))
789 }
790 None => gix::refs::transaction::PreviousValue::MustNotExist,
791 };
792 commit_refs_at_gitdir(
793 gitdir,
794 &[RefSpec {
795 ref_name: "refs/heads/__MEMSTEAD".to_string(),
796 new_oid: new_commit_id,
797 expected,
798 log_message: format!("memstead: commit __MEMSTEAD:{tree_path}"),
799 }],
800 )?;
801
802 Ok(())
803}
804
805pub fn delete_mem_artifacts_at_gitdir(
833 gitdir: &Path,
834 branch_leaf: &str,
835 ctx: &CommitContext<'_>,
836) -> Result<(), MemRepoWriteError> {
837 use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
838 use gix::refs::{FullName, Target};
839
840 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
841 path: gitdir.display().to_string(),
842 message: e.to_string(),
843 })?;
844 let existing_memstead_tip: Option<gix::ObjectId> = repo
846 .try_find_reference("refs/heads/__MEMSTEAD")
847 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
848 .map(|r| {
849 r.into_fully_peeled_id()
850 .map(|id| id.detach())
851 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
852 })
853 .transpose()?;
854
855 let mut new_memstead_commit: Option<gix::ObjectId> = None;
856 if let Some(tip) = existing_memstead_tip {
857 let commit = repo
858 .find_object(tip)
859 .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
860 .into_commit();
861 let tree = commit
862 .tree()
863 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
864
865 let tree_path = format!("mems/{branch_leaf}/config.json");
869 let entry_present = tree
870 .lookup_entry_by_path(&tree_path)
871 .map_err(|e| MemRepoWriteError::GitTree(format!("lookup {tree_path}: {e}")))?
872 .is_some();
873
874 if entry_present {
875 let mut editor = tree.edit().map_err(|e| {
876 MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
877 })?;
878 editor
879 .remove(tree_path.as_str())
880 .map_err(|e| MemRepoWriteError::GitTree(format!("tree remove {tree_path}: {e}")))?;
881 let new_tree_id = editor
886 .write()
887 .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
888 .detach();
889
890 let time = gix::date::Time::now_local_or_utc();
891 let committer_sig = gix::actor::Signature {
892 name: COMMITTER_NAME.into(),
893 email: COMMITTER_EMAIL.into(),
894 time,
895 };
896 let author_sig = match author_identity(ctx) {
897 Some((name, email)) => gix::actor::Signature {
898 name: name.into(),
899 email: email.into(),
900 time,
901 },
902 None => committer_sig.clone(),
903 };
904 let full_message = format_commit_message(
905 &format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json"),
906 ctx,
907 );
908 let commit_obj = gix::objs::Commit {
909 message: full_message.into(),
910 tree: new_tree_id,
911 author: author_sig,
912 committer: committer_sig,
913 encoding: None,
914 parents: vec![tip].into_iter().collect(),
915 extra_headers: Default::default(),
916 };
917 let new_commit_id = repo
918 .write_object(&commit_obj)
919 .map_err(|e| {
920 MemRepoWriteError::GitTree(format!("write __MEMSTEAD prune commit: {e}"))
921 })?
922 .detach();
923 new_memstead_commit = Some(new_commit_id);
924 }
925 }
926
927 let mut edits: Vec<RefEdit> = Vec::with_capacity(2);
929
930 let branch_ref = format!("refs/heads/{branch_leaf}");
931 let branch_full: FullName = branch_ref.as_str().try_into().map_err(|e| {
932 MemRepoWriteError::RefTransaction(format!("invalid branch ref {branch_ref:?}: {e}"))
933 })?;
934 edits.push(RefEdit {
935 change: Change::Delete {
936 expected: PreviousValue::Any,
939 log: RefLog::AndReference,
940 },
941 name: branch_full,
942 deref: false,
943 });
944
945 if let (Some(prior_tip), Some(new_commit)) = (existing_memstead_tip, new_memstead_commit) {
946 let memstead_full: FullName = "refs/heads/__MEMSTEAD".try_into().map_err(|e| {
947 MemRepoWriteError::RefTransaction(format!("invalid __MEMSTEAD ref: {e}"))
948 })?;
949 edits.push(RefEdit {
950 change: Change::Update {
951 log: LogChange {
952 mode: RefLog::AndReference,
953 force_create_reflog: false,
954 message: format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json")
955 .as_str()
956 .into(),
957 },
958 expected: PreviousValue::MustExistAndMatch(Target::Object(prior_tip)),
959 new: Target::Object(new_commit),
960 },
961 name: memstead_full,
962 deref: false,
963 });
964 }
965
966 repo.edit_references(edits)
967 .map_err(|e| MemRepoWriteError::RefTransaction(e.to_string()))?;
968
969 Ok(())
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975 use std::path::PathBuf;
976 use tempfile::TempDir;
977
978 fn fresh_repo_dir(tmp: &Path) -> PathBuf {
979 let git_dir = tmp.join("mem-repo.git");
980 gix::init_bare(&git_dir).unwrap();
981 std::fs::canonicalize(&git_dir).unwrap()
982 }
983
984 fn actor_for_test() -> gix::actor::Signature {
985 gix::actor::Signature {
986 name: "test".into(),
987 email: "test@example.com".into(),
988 time: gix::date::Time {
989 seconds: 0,
990 offset: 0,
991 },
992 }
993 }
994
995 type SchemaSeed<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]);
997
998 fn seed_schemas(gitdir: &Path, schemas: &[SchemaSeed<'_>]) {
1002 let repo = gix::open(gitdir).unwrap();
1003 let actor = actor_for_test();
1004 let mut buf = gix::date::parse::TimeBuf::default();
1005 let sig_ref = actor.to_ref(&mut buf);
1006 let mut editor = repo.empty_tree().edit().unwrap();
1007 for (name, version, types) in schemas {
1008 let manifest = format!("name: {name}\nversion: \"{version}\"\n");
1009 let manifest_blob = repo.write_blob(manifest.as_bytes()).unwrap().detach();
1010 editor
1011 .upsert(
1012 format!("{name}/schema.yaml"),
1013 gix::object::tree::EntryKind::Blob,
1014 manifest_blob,
1015 )
1016 .unwrap();
1017 for (type_name, type_body) in *types {
1018 let blob = repo.write_blob(type_body.as_bytes()).unwrap().detach();
1019 editor
1020 .upsert(
1021 format!("{name}/types/{type_name}.yaml"),
1022 gix::object::tree::EntryKind::Blob,
1023 blob,
1024 )
1025 .unwrap();
1026 }
1027 }
1028 let tree_id = editor.write().unwrap().detach();
1029 repo.commit_as(
1030 sig_ref,
1031 sig_ref,
1032 "refs/heads/__SCHEMAS",
1033 "seed __SCHEMAS",
1034 tree_id,
1035 Vec::<gix::ObjectId>::new(),
1036 )
1037 .unwrap();
1038 }
1039
1040 fn seed_system(gitdir: &Path, repo_json: &str, mems: &[(&str, &str)]) {
1044 let repo = gix::open(gitdir).unwrap();
1045 let actor = actor_for_test();
1046 let mut buf = gix::date::parse::TimeBuf::default();
1047 let sig_ref = actor.to_ref(&mut buf);
1048 let mut editor = repo.empty_tree().edit().unwrap();
1049 if !repo_json.is_empty() {
1050 let blob = repo.write_blob(repo_json.as_bytes()).unwrap().detach();
1051 editor
1052 .upsert("repo.json", gix::object::tree::EntryKind::Blob, blob)
1053 .unwrap();
1054 }
1055 for (mem, config) in mems {
1056 let blob = repo.write_blob(config.as_bytes()).unwrap().detach();
1057 editor
1058 .upsert(
1059 format!("{mem}/config.json"),
1060 gix::object::tree::EntryKind::Blob,
1061 blob,
1062 )
1063 .unwrap();
1064 }
1065 let tree_id = editor.write().unwrap().detach();
1066 repo.commit_as(
1067 sig_ref,
1068 sig_ref,
1069 "refs/heads/__SYSTEM",
1070 "seed __SYSTEM",
1071 tree_id,
1072 Vec::<gix::ObjectId>::new(),
1073 )
1074 .unwrap();
1075 }
1076
1077 fn list_memstead_entries(gitdir: &Path) -> Vec<String> {
1080 let repo = gix::open(gitdir).unwrap();
1081 let reference = repo
1082 .try_find_reference("refs/heads/__MEMSTEAD")
1083 .unwrap()
1084 .unwrap();
1085 let id = reference.into_fully_peeled_id().unwrap();
1086 let commit = id.object().unwrap().try_into_commit().unwrap();
1087 let tree = commit.tree().unwrap();
1088 let mut out: Vec<String> = Vec::new();
1089 walk(&repo, &tree, "", &mut out);
1090 out.sort();
1091 out
1092 }
1093
1094 fn walk(repo: &gix::Repository, tree: &gix::Tree<'_>, prefix: &str, out: &mut Vec<String>) {
1095 for entry in tree.iter().flatten() {
1096 let name = std::str::from_utf8(entry.filename())
1097 .unwrap_or("")
1098 .to_string();
1099 let path = if prefix.is_empty() {
1100 name.clone()
1101 } else {
1102 format!("{prefix}/{name}")
1103 };
1104 match entry.mode().kind() {
1105 gix::object::tree::EntryKind::Tree => {
1106 let subtree = repo
1107 .find_object(entry.oid().to_owned())
1108 .unwrap()
1109 .into_tree();
1110 walk(repo, &subtree, &path, out);
1111 }
1112 gix::object::tree::EntryKind::Blob
1113 | gix::object::tree::EntryKind::BlobExecutable => {
1114 out.push(path);
1115 }
1116 _ => {}
1117 }
1118 }
1119 }
1120
1121 #[test]
1122 fn migrate_writes_unified_tree_from_schemas_and_system() {
1123 let tmp = TempDir::new().unwrap();
1124 let gitdir = fresh_repo_dir(tmp.path());
1125 seed_schemas(
1126 &gitdir,
1127 &[
1128 ("default", "1.0.0", &[("spec", "name: spec\n")]),
1129 ("custom", "0.5.0", &[]),
1130 ],
1131 );
1132 seed_system(
1133 &gitdir,
1134 r#"{"name":"main"}"#,
1135 &[
1136 ("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#),
1137 ("beta", r#"{"format": 1, "schema": "custom@0.5.0"}"#),
1138 ],
1139 );
1140
1141 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1142 assert_eq!(outcome.schemas_migrated, 2);
1143 assert_eq!(outcome.mems_migrated, 2);
1144 assert!(!outcome.already_current);
1145
1146 let entries = list_memstead_entries(&gitdir);
1147 assert!(entries.contains(&"schemas/default@1.0.0/schema.yaml".to_string()));
1149 assert!(entries.contains(&"schemas/default@1.0.0/types/spec.yaml".to_string()));
1150 assert!(entries.contains(&"schemas/custom@0.5.0/schema.yaml".to_string()));
1151 assert!(entries.contains(&"mems/alpha/config.json".to_string()));
1153 assert!(entries.contains(&"mems/beta/config.json".to_string()));
1154 assert!(
1156 !entries.iter().any(|p| p.contains("repo.json")),
1157 "repo.json must not appear under __MEMSTEAD: {entries:?}"
1158 );
1159 }
1160
1161 #[test]
1162 fn write_schema_to_memstead_ref_adds_package_idempotent_and_preserves_others() {
1163 let tmp = TempDir::new().unwrap();
1164 let gitdir = fresh_repo_dir(tmp.path());
1165
1166 let tiny = vec![
1168 (
1169 "schema.yaml".to_string(),
1170 b"name: tiny\nversion: 0.1.0\n".to_vec(),
1171 ),
1172 ("types/doc.yaml".to_string(), b"name: doc\n".to_vec()),
1173 ("mem-template.json".to_string(), b"{}\n".to_vec()),
1174 ];
1175 let out = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1176 assert!(!out.already_current);
1177 let entries = list_memstead_entries(&gitdir);
1178 for p in [
1179 "schemas/tiny@0.1.0/schema.yaml",
1180 "schemas/tiny@0.1.0/types/doc.yaml",
1181 "schemas/tiny@0.1.0/mem-template.json",
1182 ] {
1183 assert!(entries.iter().any(|e| e == p), "missing {p}: {entries:?}");
1184 }
1185
1186 let again = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1188 assert!(again.already_current);
1189 assert_eq!(out.commit_sha, again.commit_sha);
1190
1191 let other = vec![(
1193 "schema.yaml".to_string(),
1194 b"name: other\nversion: 2.0.0\n".to_vec(),
1195 )];
1196 let out2 = write_schema_to_memstead_ref(&gitdir, "other", "2.0.0", &other).unwrap();
1197 assert!(!out2.already_current);
1198 assert_ne!(out2.commit_sha, out.commit_sha);
1199 let entries2 = list_memstead_entries(&gitdir);
1200 assert!(
1201 entries2
1202 .iter()
1203 .any(|e| e == "schemas/tiny@0.1.0/schema.yaml")
1204 );
1205 assert!(
1206 entries2
1207 .iter()
1208 .any(|e| e == "schemas/other@2.0.0/schema.yaml")
1209 );
1210 }
1211
1212 #[test]
1213 fn pro_git_branch_ops_write_schema_hook_writes_to_ref() {
1214 let tmp = TempDir::new().unwrap();
1218 let gitdir = fresh_repo_dir(tmp.path());
1219 let files = vec![(
1220 "schema.yaml".to_string(),
1221 b"name: h\nversion: 1.0.0\n".to_vec(),
1222 )];
1223 let commit =
1224 (crate::storage::FULL_GIT_BRANCH_OPS.write_schema)(&gitdir, "h", "1.0.0", &files)
1225 .expect("hook writes the package");
1226 assert!(!commit.is_empty());
1227 let entries = list_memstead_entries(&gitdir);
1228 assert!(
1229 entries.iter().any(|e| e == "schemas/h@1.0.0/schema.yaml"),
1230 "package must land on the ref: {entries:?}"
1231 );
1232 }
1233
1234 #[test]
1235 fn migrate_is_idempotent() {
1236 let tmp = TempDir::new().unwrap();
1237 let gitdir = fresh_repo_dir(tmp.path());
1238 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1239 seed_system(
1240 &gitdir,
1241 r#"{"name":"main"}"#,
1242 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1243 );
1244
1245 let first = migrate_to_memstead_ref(&gitdir).unwrap();
1246 assert!(!first.already_current);
1247 let second = migrate_to_memstead_ref(&gitdir).unwrap();
1248 assert!(second.already_current);
1249 assert_eq!(first.commit_sha, second.commit_sha);
1251 }
1252
1253 #[test]
1254 fn migrate_with_empty_workspace_writes_empty_tree() {
1255 let tmp = TempDir::new().unwrap();
1256 let gitdir = fresh_repo_dir(tmp.path());
1257 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1261 assert_eq!(outcome.schemas_migrated, 0);
1262 assert_eq!(outcome.mems_migrated, 0);
1263 let entries = list_memstead_entries(&gitdir);
1264 assert!(entries.is_empty());
1265 }
1266
1267 #[test]
1268 fn migrate_handles_missing_version_with_placeholder() {
1269 let tmp = TempDir::new().unwrap();
1270 let gitdir = fresh_repo_dir(tmp.path());
1271 let repo = gix::open(&gitdir).unwrap();
1273 let actor = actor_for_test();
1274 let mut buf = gix::date::parse::TimeBuf::default();
1275 let sig_ref = actor.to_ref(&mut buf);
1276 let mut editor = repo.empty_tree().edit().unwrap();
1277 let blob = repo.write_blob(b"name: anonymous\n").unwrap().detach();
1278 editor
1279 .upsert(
1280 "anonymous/schema.yaml",
1281 gix::object::tree::EntryKind::Blob,
1282 blob,
1283 )
1284 .unwrap();
1285 let tree_id = editor.write().unwrap().detach();
1286 repo.commit_as(
1287 sig_ref,
1288 sig_ref,
1289 "refs/heads/__SCHEMAS",
1290 "seed",
1291 tree_id,
1292 Vec::<gix::ObjectId>::new(),
1293 )
1294 .unwrap();
1295
1296 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1297 assert_eq!(outcome.schemas_migrated, 1);
1298 let entries = list_memstead_entries(&gitdir);
1299 assert!(
1300 entries.contains(&"schemas/anonymous@0.0.0/schema.yaml".to_string()),
1301 "missing-version schemas land under @0.0.0; got {entries:?}"
1302 );
1303 }
1304
1305 #[test]
1306 fn read_mem_config_from_memstead_round_trips_after_migration() {
1307 let tmp = TempDir::new().unwrap();
1308 let gitdir = fresh_repo_dir(tmp.path());
1309 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1310 seed_system(
1311 &gitdir,
1312 r#"{"name":"main"}"#,
1313 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1314 );
1315 let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1316
1317 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1318 assert!(config.schema.is_some());
1319 assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1320 }
1321
1322 #[test]
1323 fn read_mem_config_from_memstead_returns_typed_error_for_missing_mem() {
1324 let tmp = TempDir::new().unwrap();
1325 let gitdir = fresh_repo_dir(tmp.path());
1326 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1327 seed_system(
1328 &gitdir,
1329 r#"{"name":"main"}"#,
1330 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1331 );
1332 let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1333 match read_mem_config_from_memstead_ref(&gitdir, "nonexistent") {
1334 Err(MemsteadRefError::Config { path, .. }) => {
1335 assert!(path.contains("nonexistent"));
1336 }
1337 other => panic!("expected Config error for missing mem, got {other:?}"),
1338 }
1339 }
1340
1341 #[test]
1342 fn commit_config_to_memstead_creates_ref_when_absent() {
1343 let tmp = TempDir::new().unwrap();
1344 let gitdir = fresh_repo_dir(tmp.path());
1345 let ctx = CommitContext::internal();
1349 commit_config_to_memstead_at_gitdir(
1350 &gitdir,
1351 "alpha",
1352 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1353 &ctx,
1354 "test commit",
1355 )
1356 .unwrap();
1357
1358 let entries = list_memstead_entries(&gitdir);
1359 assert_eq!(entries, vec!["mems/alpha/config.json".to_string()]);
1360
1361 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1362 assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1363 }
1364
1365 #[test]
1366 fn commit_config_to_memstead_overwrites_existing_blob() {
1367 let tmp = TempDir::new().unwrap();
1368 let gitdir = fresh_repo_dir(tmp.path());
1369 let ctx = CommitContext::internal();
1370
1371 commit_config_to_memstead_at_gitdir(
1372 &gitdir,
1373 "alpha",
1374 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1375 &ctx,
1376 "first",
1377 )
1378 .unwrap();
1379 commit_config_to_memstead_at_gitdir(
1380 &gitdir,
1381 "alpha",
1382 br#"{"format": 1, "schema": "default@2.0.0"}"#,
1383 &ctx,
1384 "second",
1385 )
1386 .unwrap();
1387
1388 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1389 assert_eq!(config.schema.unwrap().to_string(), "default@2.0.0");
1390 }
1391
1392 #[test]
1393 fn commit_config_to_memstead_preserves_sibling_mem_entries() {
1394 let tmp = TempDir::new().unwrap();
1395 let gitdir = fresh_repo_dir(tmp.path());
1396 let ctx = CommitContext::internal();
1397
1398 commit_config_to_memstead_at_gitdir(
1399 &gitdir,
1400 "alpha",
1401 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1402 &ctx,
1403 "alpha",
1404 )
1405 .unwrap();
1406 commit_config_to_memstead_at_gitdir(
1407 &gitdir,
1408 "beta",
1409 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1410 &ctx,
1411 "beta",
1412 )
1413 .unwrap();
1414
1415 let entries = list_memstead_entries(&gitdir);
1416 assert_eq!(
1417 entries,
1418 vec![
1419 "mems/alpha/config.json".to_string(),
1420 "mems/beta/config.json".to_string(),
1421 ]
1422 );
1423 }
1424
1425 #[test]
1426 fn extract_manifest_version_handles_quoted_and_unquoted() {
1427 assert_eq!(
1428 extract_manifest_version("name: foo\nversion: \"1.0.0\"\n"),
1429 Some("1.0.0".to_string())
1430 );
1431 assert_eq!(
1432 extract_manifest_version("name: foo\nversion: 1.0.0\n"),
1433 Some("1.0.0".to_string())
1434 );
1435 assert_eq!(
1436 extract_manifest_version("name: foo\nversion: '0.5.0'\n"),
1437 Some("0.5.0".to_string())
1438 );
1439 assert_eq!(extract_manifest_version("name: foo\n"), None);
1440 assert_eq!(extract_manifest_version(""), None);
1441 }
1442}