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 commit_paths_to_memstead_at_gitdir(
820 gitdir: &Path,
821 edits: &[(String, Option<Vec<u8>>)],
822 ctx: &CommitContext<'_>,
823 message: &str,
824) -> Result<(), MemRepoWriteError> {
825 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
826 path: gitdir.display().to_string(),
827 message: e.to_string(),
828 })?;
829 let existing_tip: Option<gix::ObjectId> = repo
830 .try_find_reference("refs/heads/__MEMSTEAD")
831 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
832 .map(|r| {
833 r.into_fully_peeled_id()
834 .map(|id| id.detach())
835 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
836 })
837 .transpose()?;
838
839 let mut editor = match existing_tip {
840 Some(tip) => {
841 let commit = repo
842 .find_object(tip)
843 .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
844 .into_commit();
845 let tree = commit
846 .tree()
847 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
848 tree.edit().map_err(|e| {
849 MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
850 })?
851 }
852 None => repo.empty_tree().edit().map_err(|e| {
853 MemRepoWriteError::GitTree(format!("editor init (empty) for __MEMSTEAD: {e}"))
854 })?,
855 };
856 for (tree_path, bytes) in edits {
857 match bytes {
858 Some(bytes) => {
859 let blob_id = repo
860 .write_blob(bytes.as_slice())
861 .map_err(|e| MemRepoWriteError::GitTree(format!("write blob: {e}")))?
862 .detach();
863 editor
864 .upsert(
865 tree_path.as_str(),
866 gix::objs::tree::EntryKind::Blob,
867 blob_id,
868 )
869 .map_err(|e| {
870 MemRepoWriteError::GitTree(format!("tree upsert {tree_path}: {e}"))
871 })?;
872 }
873 None => {
874 editor.remove(tree_path.as_str()).map_err(|e| {
875 MemRepoWriteError::GitTree(format!("tree remove {tree_path}: {e}"))
876 })?;
877 }
878 }
879 }
880 let new_tree_id = editor
881 .write()
882 .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
883 .detach();
884
885 let time = gix::date::Time::now_local_or_utc();
886 let committer_sig = gix::actor::Signature {
887 name: COMMITTER_NAME.into(),
888 email: COMMITTER_EMAIL.into(),
889 time,
890 };
891 let author_sig = match author_identity(ctx) {
892 Some((name, email)) => gix::actor::Signature {
893 name: name.into(),
894 email: email.into(),
895 time,
896 },
897 None => committer_sig.clone(),
898 };
899 let full_message = format_commit_message(message, ctx);
900 let parents: Vec<gix::ObjectId> = match existing_tip {
901 Some(tip) => vec![tip],
902 None => Vec::new(),
903 };
904 let commit = gix::objs::Commit {
905 message: full_message.into(),
906 tree: new_tree_id,
907 author: author_sig,
908 committer: committer_sig,
909 encoding: None,
910 parents: parents.into_iter().collect(),
911 extra_headers: Default::default(),
912 };
913 let new_commit_id = repo
914 .write_object(&commit)
915 .map_err(|e| MemRepoWriteError::GitTree(format!("write __MEMSTEAD commit: {e}")))?
916 .detach();
917
918 let expected = match existing_tip {
919 Some(tip) => {
920 gix::refs::transaction::PreviousValue::MustExistAndMatch(gix::refs::Target::Object(tip))
921 }
922 None => gix::refs::transaction::PreviousValue::MustNotExist,
923 };
924 commit_refs_at_gitdir(
925 gitdir,
926 &[RefSpec {
927 ref_name: "refs/heads/__MEMSTEAD".to_string(),
928 new_oid: new_commit_id,
929 expected,
930 log_message: message.to_string(),
931 }],
932 )?;
933 Ok(())
934}
935
936pub fn delete_mem_artifacts_at_gitdir(
964 gitdir: &Path,
965 branch_leaf: &str,
966 ctx: &CommitContext<'_>,
967) -> Result<(), MemRepoWriteError> {
968 use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
969 use gix::refs::{FullName, Target};
970
971 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
972 path: gitdir.display().to_string(),
973 message: e.to_string(),
974 })?;
975 let existing_memstead_tip: Option<gix::ObjectId> = repo
977 .try_find_reference("refs/heads/__MEMSTEAD")
978 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
979 .map(|r| {
980 r.into_fully_peeled_id()
981 .map(|id| id.detach())
982 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
983 })
984 .transpose()?;
985
986 let mut new_memstead_commit: Option<gix::ObjectId> = None;
987 if let Some(tip) = existing_memstead_tip {
988 let commit = repo
989 .find_object(tip)
990 .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
991 .into_commit();
992 let tree = commit
993 .tree()
994 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
995
996 let tree_path = format!("mems/{branch_leaf}/config.json");
1000 let entry_present = tree
1001 .lookup_entry_by_path(&tree_path)
1002 .map_err(|e| MemRepoWriteError::GitTree(format!("lookup {tree_path}: {e}")))?
1003 .is_some();
1004
1005 if entry_present {
1006 let mut editor = tree.edit().map_err(|e| {
1007 MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
1008 })?;
1009 editor
1010 .remove(tree_path.as_str())
1011 .map_err(|e| MemRepoWriteError::GitTree(format!("tree remove {tree_path}: {e}")))?;
1012 let new_tree_id = editor
1017 .write()
1018 .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
1019 .detach();
1020
1021 let time = gix::date::Time::now_local_or_utc();
1022 let committer_sig = gix::actor::Signature {
1023 name: COMMITTER_NAME.into(),
1024 email: COMMITTER_EMAIL.into(),
1025 time,
1026 };
1027 let author_sig = match author_identity(ctx) {
1028 Some((name, email)) => gix::actor::Signature {
1029 name: name.into(),
1030 email: email.into(),
1031 time,
1032 },
1033 None => committer_sig.clone(),
1034 };
1035 let full_message = format_commit_message(
1036 &format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json"),
1037 ctx,
1038 );
1039 let commit_obj = gix::objs::Commit {
1040 message: full_message.into(),
1041 tree: new_tree_id,
1042 author: author_sig,
1043 committer: committer_sig,
1044 encoding: None,
1045 parents: vec![tip].into_iter().collect(),
1046 extra_headers: Default::default(),
1047 };
1048 let new_commit_id = repo
1049 .write_object(&commit_obj)
1050 .map_err(|e| {
1051 MemRepoWriteError::GitTree(format!("write __MEMSTEAD prune commit: {e}"))
1052 })?
1053 .detach();
1054 new_memstead_commit = Some(new_commit_id);
1055 }
1056 }
1057
1058 let mut edits: Vec<RefEdit> = Vec::with_capacity(2);
1060
1061 let branch_ref = format!("refs/heads/{branch_leaf}");
1062 let branch_full: FullName = branch_ref.as_str().try_into().map_err(|e| {
1063 MemRepoWriteError::RefTransaction(format!("invalid branch ref {branch_ref:?}: {e}"))
1064 })?;
1065 edits.push(RefEdit {
1066 change: Change::Delete {
1067 expected: PreviousValue::Any,
1070 log: RefLog::AndReference,
1071 },
1072 name: branch_full,
1073 deref: false,
1074 });
1075
1076 if let (Some(prior_tip), Some(new_commit)) = (existing_memstead_tip, new_memstead_commit) {
1077 let memstead_full: FullName = "refs/heads/__MEMSTEAD".try_into().map_err(|e| {
1078 MemRepoWriteError::RefTransaction(format!("invalid __MEMSTEAD ref: {e}"))
1079 })?;
1080 edits.push(RefEdit {
1081 change: Change::Update {
1082 log: LogChange {
1083 mode: RefLog::AndReference,
1084 force_create_reflog: false,
1085 message: format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json")
1086 .as_str()
1087 .into(),
1088 },
1089 expected: PreviousValue::MustExistAndMatch(Target::Object(prior_tip)),
1090 new: Target::Object(new_commit),
1091 },
1092 name: memstead_full,
1093 deref: false,
1094 });
1095 }
1096
1097 repo.edit_references(edits)
1098 .map_err(|e| MemRepoWriteError::RefTransaction(e.to_string()))?;
1099
1100 Ok(())
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 use super::*;
1106 use std::path::PathBuf;
1107 use tempfile::TempDir;
1108
1109 fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1110 let git_dir = tmp.join("mem-repo.git");
1111 gix::init_bare(&git_dir).unwrap();
1112 std::fs::canonicalize(&git_dir).unwrap()
1113 }
1114
1115 fn actor_for_test() -> gix::actor::Signature {
1116 gix::actor::Signature {
1117 name: "test".into(),
1118 email: "test@example.com".into(),
1119 time: gix::date::Time {
1120 seconds: 0,
1121 offset: 0,
1122 },
1123 }
1124 }
1125
1126 type SchemaSeed<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]);
1128
1129 fn seed_schemas(gitdir: &Path, schemas: &[SchemaSeed<'_>]) {
1133 let repo = gix::open(gitdir).unwrap();
1134 let actor = actor_for_test();
1135 let mut buf = gix::date::parse::TimeBuf::default();
1136 let sig_ref = actor.to_ref(&mut buf);
1137 let mut editor = repo.empty_tree().edit().unwrap();
1138 for (name, version, types) in schemas {
1139 let manifest = format!("name: {name}\nversion: \"{version}\"\n");
1140 let manifest_blob = repo.write_blob(manifest.as_bytes()).unwrap().detach();
1141 editor
1142 .upsert(
1143 format!("{name}/schema.yaml"),
1144 gix::object::tree::EntryKind::Blob,
1145 manifest_blob,
1146 )
1147 .unwrap();
1148 for (type_name, type_body) in *types {
1149 let blob = repo.write_blob(type_body.as_bytes()).unwrap().detach();
1150 editor
1151 .upsert(
1152 format!("{name}/types/{type_name}.yaml"),
1153 gix::object::tree::EntryKind::Blob,
1154 blob,
1155 )
1156 .unwrap();
1157 }
1158 }
1159 let tree_id = editor.write().unwrap().detach();
1160 repo.commit_as(
1161 sig_ref,
1162 sig_ref,
1163 "refs/heads/__SCHEMAS",
1164 "seed __SCHEMAS",
1165 tree_id,
1166 Vec::<gix::ObjectId>::new(),
1167 )
1168 .unwrap();
1169 }
1170
1171 fn seed_system(gitdir: &Path, repo_json: &str, mems: &[(&str, &str)]) {
1175 let repo = gix::open(gitdir).unwrap();
1176 let actor = actor_for_test();
1177 let mut buf = gix::date::parse::TimeBuf::default();
1178 let sig_ref = actor.to_ref(&mut buf);
1179 let mut editor = repo.empty_tree().edit().unwrap();
1180 if !repo_json.is_empty() {
1181 let blob = repo.write_blob(repo_json.as_bytes()).unwrap().detach();
1182 editor
1183 .upsert("repo.json", gix::object::tree::EntryKind::Blob, blob)
1184 .unwrap();
1185 }
1186 for (mem, config) in mems {
1187 let blob = repo.write_blob(config.as_bytes()).unwrap().detach();
1188 editor
1189 .upsert(
1190 format!("{mem}/config.json"),
1191 gix::object::tree::EntryKind::Blob,
1192 blob,
1193 )
1194 .unwrap();
1195 }
1196 let tree_id = editor.write().unwrap().detach();
1197 repo.commit_as(
1198 sig_ref,
1199 sig_ref,
1200 "refs/heads/__SYSTEM",
1201 "seed __SYSTEM",
1202 tree_id,
1203 Vec::<gix::ObjectId>::new(),
1204 )
1205 .unwrap();
1206 }
1207
1208 fn list_memstead_entries(gitdir: &Path) -> Vec<String> {
1211 let repo = gix::open(gitdir).unwrap();
1212 let reference = repo
1213 .try_find_reference("refs/heads/__MEMSTEAD")
1214 .unwrap()
1215 .unwrap();
1216 let id = reference.into_fully_peeled_id().unwrap();
1217 let commit = id.object().unwrap().try_into_commit().unwrap();
1218 let tree = commit.tree().unwrap();
1219 let mut out: Vec<String> = Vec::new();
1220 walk(&repo, &tree, "", &mut out);
1221 out.sort();
1222 out
1223 }
1224
1225 fn walk(repo: &gix::Repository, tree: &gix::Tree<'_>, prefix: &str, out: &mut Vec<String>) {
1226 for entry in tree.iter().flatten() {
1227 let name = std::str::from_utf8(entry.filename())
1228 .unwrap_or("")
1229 .to_string();
1230 let path = if prefix.is_empty() {
1231 name.clone()
1232 } else {
1233 format!("{prefix}/{name}")
1234 };
1235 match entry.mode().kind() {
1236 gix::object::tree::EntryKind::Tree => {
1237 let subtree = repo
1238 .find_object(entry.oid().to_owned())
1239 .unwrap()
1240 .into_tree();
1241 walk(repo, &subtree, &path, out);
1242 }
1243 gix::object::tree::EntryKind::Blob
1244 | gix::object::tree::EntryKind::BlobExecutable => {
1245 out.push(path);
1246 }
1247 _ => {}
1248 }
1249 }
1250 }
1251
1252 #[test]
1253 fn migrate_writes_unified_tree_from_schemas_and_system() {
1254 let tmp = TempDir::new().unwrap();
1255 let gitdir = fresh_repo_dir(tmp.path());
1256 seed_schemas(
1257 &gitdir,
1258 &[
1259 ("default", "1.0.0", &[("spec", "name: spec\n")]),
1260 ("custom", "0.5.0", &[]),
1261 ],
1262 );
1263 seed_system(
1264 &gitdir,
1265 r#"{"name":"main"}"#,
1266 &[
1267 ("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#),
1268 ("beta", r#"{"format": 1, "schema": "custom@0.5.0"}"#),
1269 ],
1270 );
1271
1272 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1273 assert_eq!(outcome.schemas_migrated, 2);
1274 assert_eq!(outcome.mems_migrated, 2);
1275 assert!(!outcome.already_current);
1276
1277 let entries = list_memstead_entries(&gitdir);
1278 assert!(entries.contains(&"schemas/default@1.0.0/schema.yaml".to_string()));
1280 assert!(entries.contains(&"schemas/default@1.0.0/types/spec.yaml".to_string()));
1281 assert!(entries.contains(&"schemas/custom@0.5.0/schema.yaml".to_string()));
1282 assert!(entries.contains(&"mems/alpha/config.json".to_string()));
1284 assert!(entries.contains(&"mems/beta/config.json".to_string()));
1285 assert!(
1287 !entries.iter().any(|p| p.contains("repo.json")),
1288 "repo.json must not appear under __MEMSTEAD: {entries:?}"
1289 );
1290 }
1291
1292 #[test]
1293 fn write_schema_to_memstead_ref_adds_package_idempotent_and_preserves_others() {
1294 let tmp = TempDir::new().unwrap();
1295 let gitdir = fresh_repo_dir(tmp.path());
1296
1297 let tiny = vec![
1299 (
1300 "schema.yaml".to_string(),
1301 b"name: tiny\nversion: 0.1.0\n".to_vec(),
1302 ),
1303 ("types/doc.yaml".to_string(), b"name: doc\n".to_vec()),
1304 ("mem-template.json".to_string(), b"{}\n".to_vec()),
1305 ];
1306 let out = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1307 assert!(!out.already_current);
1308 let entries = list_memstead_entries(&gitdir);
1309 for p in [
1310 "schemas/tiny@0.1.0/schema.yaml",
1311 "schemas/tiny@0.1.0/types/doc.yaml",
1312 "schemas/tiny@0.1.0/mem-template.json",
1313 ] {
1314 assert!(entries.iter().any(|e| e == p), "missing {p}: {entries:?}");
1315 }
1316
1317 let again = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1319 assert!(again.already_current);
1320 assert_eq!(out.commit_sha, again.commit_sha);
1321
1322 let other = vec![(
1324 "schema.yaml".to_string(),
1325 b"name: other\nversion: 2.0.0\n".to_vec(),
1326 )];
1327 let out2 = write_schema_to_memstead_ref(&gitdir, "other", "2.0.0", &other).unwrap();
1328 assert!(!out2.already_current);
1329 assert_ne!(out2.commit_sha, out.commit_sha);
1330 let entries2 = list_memstead_entries(&gitdir);
1331 assert!(
1332 entries2
1333 .iter()
1334 .any(|e| e == "schemas/tiny@0.1.0/schema.yaml")
1335 );
1336 assert!(
1337 entries2
1338 .iter()
1339 .any(|e| e == "schemas/other@2.0.0/schema.yaml")
1340 );
1341 }
1342
1343 #[test]
1344 fn pro_git_branch_ops_write_schema_hook_writes_to_ref() {
1345 let tmp = TempDir::new().unwrap();
1349 let gitdir = fresh_repo_dir(tmp.path());
1350 let files = vec![(
1351 "schema.yaml".to_string(),
1352 b"name: h\nversion: 1.0.0\n".to_vec(),
1353 )];
1354 let commit =
1355 (crate::storage::FULL_GIT_BRANCH_OPS.write_schema)(&gitdir, "h", "1.0.0", &files)
1356 .expect("hook writes the package");
1357 assert!(!commit.is_empty());
1358 let entries = list_memstead_entries(&gitdir);
1359 assert!(
1360 entries.iter().any(|e| e == "schemas/h@1.0.0/schema.yaml"),
1361 "package must land on the ref: {entries:?}"
1362 );
1363 }
1364
1365 #[test]
1366 fn migrate_is_idempotent() {
1367 let tmp = TempDir::new().unwrap();
1368 let gitdir = fresh_repo_dir(tmp.path());
1369 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1370 seed_system(
1371 &gitdir,
1372 r#"{"name":"main"}"#,
1373 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1374 );
1375
1376 let first = migrate_to_memstead_ref(&gitdir).unwrap();
1377 assert!(!first.already_current);
1378 let second = migrate_to_memstead_ref(&gitdir).unwrap();
1379 assert!(second.already_current);
1380 assert_eq!(first.commit_sha, second.commit_sha);
1382 }
1383
1384 #[test]
1385 fn migrate_with_empty_workspace_writes_empty_tree() {
1386 let tmp = TempDir::new().unwrap();
1387 let gitdir = fresh_repo_dir(tmp.path());
1388 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1392 assert_eq!(outcome.schemas_migrated, 0);
1393 assert_eq!(outcome.mems_migrated, 0);
1394 let entries = list_memstead_entries(&gitdir);
1395 assert!(entries.is_empty());
1396 }
1397
1398 #[test]
1399 fn migrate_handles_missing_version_with_placeholder() {
1400 let tmp = TempDir::new().unwrap();
1401 let gitdir = fresh_repo_dir(tmp.path());
1402 let repo = gix::open(&gitdir).unwrap();
1404 let actor = actor_for_test();
1405 let mut buf = gix::date::parse::TimeBuf::default();
1406 let sig_ref = actor.to_ref(&mut buf);
1407 let mut editor = repo.empty_tree().edit().unwrap();
1408 let blob = repo.write_blob(b"name: anonymous\n").unwrap().detach();
1409 editor
1410 .upsert(
1411 "anonymous/schema.yaml",
1412 gix::object::tree::EntryKind::Blob,
1413 blob,
1414 )
1415 .unwrap();
1416 let tree_id = editor.write().unwrap().detach();
1417 repo.commit_as(
1418 sig_ref,
1419 sig_ref,
1420 "refs/heads/__SCHEMAS",
1421 "seed",
1422 tree_id,
1423 Vec::<gix::ObjectId>::new(),
1424 )
1425 .unwrap();
1426
1427 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1428 assert_eq!(outcome.schemas_migrated, 1);
1429 let entries = list_memstead_entries(&gitdir);
1430 assert!(
1431 entries.contains(&"schemas/anonymous@0.0.0/schema.yaml".to_string()),
1432 "missing-version schemas land under @0.0.0; got {entries:?}"
1433 );
1434 }
1435
1436 #[test]
1437 fn read_mem_config_from_memstead_round_trips_after_migration() {
1438 let tmp = TempDir::new().unwrap();
1439 let gitdir = fresh_repo_dir(tmp.path());
1440 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1441 seed_system(
1442 &gitdir,
1443 r#"{"name":"main"}"#,
1444 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1445 );
1446 let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1447
1448 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1449 assert!(config.schema.is_some());
1450 assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1451 }
1452
1453 #[test]
1454 fn read_mem_config_from_memstead_returns_typed_error_for_missing_mem() {
1455 let tmp = TempDir::new().unwrap();
1456 let gitdir = fresh_repo_dir(tmp.path());
1457 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1458 seed_system(
1459 &gitdir,
1460 r#"{"name":"main"}"#,
1461 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1462 );
1463 let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1464 match read_mem_config_from_memstead_ref(&gitdir, "nonexistent") {
1465 Err(MemsteadRefError::Config { path, .. }) => {
1466 assert!(path.contains("nonexistent"));
1467 }
1468 other => panic!("expected Config error for missing mem, got {other:?}"),
1469 }
1470 }
1471
1472 #[test]
1473 fn commit_config_to_memstead_creates_ref_when_absent() {
1474 let tmp = TempDir::new().unwrap();
1475 let gitdir = fresh_repo_dir(tmp.path());
1476 let ctx = CommitContext::internal();
1480 commit_config_to_memstead_at_gitdir(
1481 &gitdir,
1482 "alpha",
1483 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1484 &ctx,
1485 "test commit",
1486 )
1487 .unwrap();
1488
1489 let entries = list_memstead_entries(&gitdir);
1490 assert_eq!(entries, vec!["mems/alpha/config.json".to_string()]);
1491
1492 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1493 assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1494 }
1495
1496 #[test]
1497 fn commit_config_to_memstead_overwrites_existing_blob() {
1498 let tmp = TempDir::new().unwrap();
1499 let gitdir = fresh_repo_dir(tmp.path());
1500 let ctx = CommitContext::internal();
1501
1502 commit_config_to_memstead_at_gitdir(
1503 &gitdir,
1504 "alpha",
1505 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1506 &ctx,
1507 "first",
1508 )
1509 .unwrap();
1510 commit_config_to_memstead_at_gitdir(
1511 &gitdir,
1512 "alpha",
1513 br#"{"format": 1, "schema": "default@2.0.0"}"#,
1514 &ctx,
1515 "second",
1516 )
1517 .unwrap();
1518
1519 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1520 assert_eq!(config.schema.unwrap().to_string(), "default@2.0.0");
1521 }
1522
1523 #[test]
1524 fn commit_config_to_memstead_preserves_sibling_mem_entries() {
1525 let tmp = TempDir::new().unwrap();
1526 let gitdir = fresh_repo_dir(tmp.path());
1527 let ctx = CommitContext::internal();
1528
1529 commit_config_to_memstead_at_gitdir(
1530 &gitdir,
1531 "alpha",
1532 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1533 &ctx,
1534 "alpha",
1535 )
1536 .unwrap();
1537 commit_config_to_memstead_at_gitdir(
1538 &gitdir,
1539 "beta",
1540 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1541 &ctx,
1542 "beta",
1543 )
1544 .unwrap();
1545
1546 let entries = list_memstead_entries(&gitdir);
1547 assert_eq!(
1548 entries,
1549 vec![
1550 "mems/alpha/config.json".to_string(),
1551 "mems/beta/config.json".to_string(),
1552 ]
1553 );
1554 }
1555
1556 #[test]
1557 fn extract_manifest_version_handles_quoted_and_unquoted() {
1558 assert_eq!(
1559 extract_manifest_version("name: foo\nversion: \"1.0.0\"\n"),
1560 Some("1.0.0".to_string())
1561 );
1562 assert_eq!(
1563 extract_manifest_version("name: foo\nversion: 1.0.0\n"),
1564 Some("1.0.0".to_string())
1565 );
1566 assert_eq!(
1567 extract_manifest_version("name: foo\nversion: '0.5.0'\n"),
1568 Some("0.5.0".to_string())
1569 );
1570 assert_eq!(extract_manifest_version("name: foo\n"), None);
1571 assert_eq!(extract_manifest_version(""), None);
1572 }
1573}