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, error_chain, reflog_committer,
48 resolve_full_path_at_gitdir,
49};
50use crate::mem_repo_schemas::{LoadOutcome, MemRepoSchemasError};
51use crate::vcs::{CommitContext, author_identity, format_commit_message};
52
53const COMMITTER_NAME: &str = "engine";
54const COMMITTER_EMAIL: &str = "noreply@memstead.io";
55
56#[derive(Debug, thiserror::Error)]
59pub enum MemsteadRefError {
60 #[error("could not open mem-repo gitdir: {0}")]
61 GixOpen(String),
62 #[error("git tree read error: {0}")]
63 GitTree(String),
64 #[error("git commit error: {0}")]
65 GitCommit(String),
66 #[error("schema blob {0} is not valid UTF-8: {1}")]
67 NotUtf8(String, String),
68 #[error("schema '{name}': {source}")]
69 Schema {
70 name: String,
71 #[source]
72 source: SchemaLoadError,
73 },
74 #[error("config json error at {path}: {message}")]
75 Config { path: String, message: String },
76}
77
78#[derive(Debug, Clone)]
80pub struct MemsteadMigrationOutcome {
81 pub commit_sha: String,
85 pub already_current: bool,
88 pub schemas_migrated: usize,
90 pub mems_migrated: usize,
93}
94
95pub fn migrate_to_memstead_ref(
109 gitdir: &Path,
110) -> Result<MemsteadMigrationOutcome, MemsteadRefError> {
111 let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
112 let mut schema_entries: Vec<(String, gix::ObjectId)> = Vec::new();
118 if let Some(reference) = repo
119 .try_find_reference("refs/heads/__SCHEMAS")
120 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
121 {
122 let id = reference
123 .into_fully_peeled_id()
124 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
125 let commit = id
126 .object()
127 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
128 .try_into_commit()
129 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
130 let tree = commit
131 .tree()
132 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
133 for entry_res in tree.iter() {
134 let entry = entry_res.map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
135 if !matches!(entry.mode().kind(), gix::object::tree::EntryKind::Tree) {
136 continue;
137 }
138 let dir_name = match std::str::from_utf8(entry.filename()) {
139 Ok(s) => s.to_string(),
140 Err(_) => continue,
141 };
142 let subtree = repo
144 .find_object(entry.oid().to_owned())
145 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
146 .try_into_tree()
147 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
148 let manifest_entry = subtree
149 .lookup_entry_by_path("schema.yaml")
150 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
151 let version = match manifest_entry {
152 Some(me) => {
153 let manifest_obj = me
154 .object()
155 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
156 let yaml = std::str::from_utf8(&manifest_obj.data)
157 .map_err(|e| {
158 MemsteadRefError::NotUtf8(
159 format!("{dir_name}/schema.yaml"),
160 e.to_string(),
161 )
162 })?
163 .to_string();
164 extract_manifest_version(&yaml).unwrap_or_else(|| "0.0.0".to_string())
165 }
166 None => "0.0.0".to_string(),
167 };
168 schema_entries.push((format!("{dir_name}@{version}"), entry.oid().to_owned()));
169 }
170 }
171
172 let mut mem_entries: Vec<(String, gix::ObjectId, gix::object::tree::EntryKind)> = Vec::new();
177 if let Some(reference) = repo
178 .try_find_reference("refs/heads/__SYSTEM")
179 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
180 {
181 let id = reference
182 .into_fully_peeled_id()
183 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
184 let commit = id
185 .object()
186 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
187 .try_into_commit()
188 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
189 let tree = commit
190 .tree()
191 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
192 for entry_res in tree.iter() {
193 let entry = entry_res.map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
194 let name = match std::str::from_utf8(entry.filename()) {
195 Ok(s) => s.to_string(),
196 Err(_) => continue,
197 };
198 if name == "repo.json" {
199 continue;
202 }
203 mem_entries.push((name, entry.oid().to_owned(), entry.mode().kind()));
204 }
205 }
206
207 let mut editor = repo
210 .empty_tree()
211 .edit()
212 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
213 for (path_segment, oid) in &schema_entries {
214 editor
215 .upsert(
216 format!("schemas/{path_segment}").as_str(),
217 gix::object::tree::EntryKind::Tree,
218 *oid,
219 )
220 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
221 }
222 for (name, oid, kind) in &mem_entries {
223 editor
224 .upsert(format!("mems/{name}").as_str(), *kind, *oid)
225 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
226 }
227 let new_tree_id = editor
228 .write()
229 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
230 .detach();
231
232 let existing_tip: Option<(gix::ObjectId, gix::ObjectId)> = repo
235 .try_find_reference("refs/heads/__MEMSTEAD")
236 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
237 .and_then(|r| {
238 let id = r.into_fully_peeled_id().ok()?;
239 let commit = id.object().ok()?.try_into_commit().ok()?;
240 let tree_id = commit.tree().ok()?.id;
241 Some((id.detach(), tree_id))
242 });
243 if let Some((tip_id, tree_id)) = existing_tip
244 && tree_id == new_tree_id
245 {
246 return Ok(MemsteadMigrationOutcome {
247 commit_sha: tip_id.to_hex().to_string(),
248 already_current: true,
249 schemas_migrated: schema_entries.len(),
250 mems_migrated: mem_entries.len(),
251 });
252 }
253
254 let time = gix::date::Time::now_local_or_utc();
258 let signature = gix::actor::Signature {
259 name: "engine".into(),
260 email: "noreply@memstead.io".into(),
261 time,
262 };
263 let mut buf = gix::date::parse::TimeBuf::default();
264 let sig_ref = signature.to_ref(&mut buf);
265 let parents: Vec<gix::ObjectId> = match existing_tip {
266 Some((tip, _)) => vec![tip],
267 None => Vec::new(),
268 };
269 let commit_id = repo
270 .commit_as(
271 sig_ref,
272 sig_ref,
273 "refs/heads/__MEMSTEAD",
274 "memstead: storage migration to unified __MEMSTEAD ref",
275 new_tree_id,
276 parents,
277 )
278 .map_err(|e| MemsteadRefError::GitCommit(e.to_string()))?;
279
280 Ok(MemsteadMigrationOutcome {
281 commit_sha: commit_id.to_hex().to_string(),
282 already_current: false,
283 schemas_migrated: schema_entries.len(),
284 mems_migrated: mem_entries.len(),
285 })
286}
287
288#[derive(Debug, Clone)]
290pub struct SchemaWriteOutcome {
291 pub commit_sha: String,
293 pub already_current: bool,
296}
297
298pub fn write_schema_to_memstead_ref(
312 gitdir: &Path,
313 name: &str,
314 version: &str,
315 files: &[(String, Vec<u8>)],
316) -> Result<SchemaWriteOutcome, MemsteadRefError> {
317 let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
318
319 let existing_tip: Option<(gix::ObjectId, gix::ObjectId)> = repo
321 .try_find_reference("refs/heads/__MEMSTEAD")
322 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
323 .and_then(|r| {
324 let id = r.into_fully_peeled_id().ok()?;
325 let commit = id.object().ok()?.try_into_commit().ok()?;
326 let tree_id = commit.tree().ok()?.id;
327 Some((id.detach(), tree_id))
328 });
329
330 let base_tree = match existing_tip {
333 Some((_, tree_id)) => repo
334 .find_object(tree_id)
335 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
336 .try_into_tree()
337 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?,
338 None => repo.empty_tree(),
339 };
340 let mut editor = base_tree
341 .edit()
342 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
343 let prefix = format!("schemas/{name}@{version}");
344 for (rel, bytes) in files {
345 let blob_id = repo
346 .write_blob(bytes)
347 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
348 .detach();
349 editor
350 .upsert(
351 format!("{prefix}/{rel}").as_str(),
352 gix::object::tree::EntryKind::Blob,
353 blob_id,
354 )
355 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
356 }
357 let new_tree_id = editor
358 .write()
359 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
360 .detach();
361
362 if let Some((tip_id, tree_id)) = existing_tip
364 && tree_id == new_tree_id
365 {
366 return Ok(SchemaWriteOutcome {
367 commit_sha: tip_id.to_hex().to_string(),
368 already_current: true,
369 });
370 }
371
372 let time = gix::date::Time::now_local_or_utc();
373 let signature = gix::actor::Signature {
374 name: "engine".into(),
375 email: "noreply@memstead.io".into(),
376 time,
377 };
378 let mut buf = gix::date::parse::TimeBuf::default();
379 let sig_ref = signature.to_ref(&mut buf);
380 let parents: Vec<gix::ObjectId> = match existing_tip {
381 Some((tip, _)) => vec![tip],
382 None => Vec::new(),
383 };
384 let commit_id = repo
385 .commit_as(
386 sig_ref,
387 sig_ref,
388 "refs/heads/__MEMSTEAD",
389 format!("memstead: install schema {name}@{version}"),
390 new_tree_id,
391 parents,
392 )
393 .map_err(|e| MemsteadRefError::GitCommit(e.to_string()))?;
394
395 Ok(SchemaWriteOutcome {
396 commit_sha: commit_id.to_hex().to_string(),
397 already_current: false,
398 })
399}
400
401fn extract_manifest_version(yaml: &str) -> Option<String> {
412 for line in yaml.lines() {
413 let trimmed = line.trim_start();
414 if let Some(rest) = trimmed.strip_prefix("version:") {
415 let value = rest.trim().trim_matches(|c: char| c == '"' || c == '\'');
416 if value.is_empty() {
417 return None;
418 }
419 return Some(value.to_string());
420 }
421 }
422 None
423}
424
425pub fn load_schemas_from_memstead_ref(
433 workspace_root: &Path,
434) -> Result<LoadOutcome, MemRepoSchemasError> {
435 let gitdir = workspace_root.join("mem-repo").join(".git");
436 if !gitdir.is_dir() {
437 return Ok(LoadOutcome::NoMemRepo);
438 }
439 load_schemas_from_memstead_ref_at_gitdir(&gitdir)
440}
441
442pub fn load_schemas_from_memstead_ref_at_gitdir(
444 gitdir: &Path,
445) -> Result<LoadOutcome, MemRepoSchemasError> {
446 let repo = gix::open(gitdir).map_err(|e| MemRepoSchemasError::GixOpen(e.to_string()))?;
447 let memstead_ref = match repo
448 .try_find_reference("refs/heads/__MEMSTEAD")
449 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
450 {
451 Some(r) => r,
452 None => return Ok(LoadOutcome::NoMemRepo),
453 };
454 let id = memstead_ref
455 .into_fully_peeled_id()
456 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
457 let commit = id
458 .object()
459 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
460 .try_into_commit()
461 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
462 let tree = commit
463 .tree()
464 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
465
466 let schemas_entry = match tree
467 .lookup_entry_by_path("schemas")
468 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
469 {
470 Some(e) if e.mode().is_tree() => e,
471 _ => return Ok(LoadOutcome::NoSchemas),
472 };
473 let schemas_tree = schemas_entry
474 .object()
475 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
476 .try_into_tree()
477 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
478
479 let mut entries: Vec<(String, gix::ObjectId, gix::object::tree::EntryKind)> = Vec::new();
480 for entry_res in schemas_tree.iter() {
481 let entry = entry_res.map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
482 let name = match std::str::from_utf8(entry.filename()) {
483 Ok(s) => s.to_string(),
484 Err(_) => continue,
485 };
486 entries.push((name, entry.oid().to_owned(), entry.mode().kind()));
487 }
488 entries.sort_by(|a, b| a.0.cmp(&b.0));
489
490 let mut out: Vec<Arc<Schema>> = Vec::new();
491 for (versioned_name, oid, kind) in entries {
492 if !matches!(kind, gix::object::tree::EntryKind::Tree) {
493 continue;
494 }
495 let schema_obj = repo
496 .find_object(oid)
497 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
498 let schema_tree = schema_obj
499 .try_into_tree()
500 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
501
502 let manifest_yaml =
503 read_blob_string_for_schemas(&repo, &schema_tree, "schema.yaml", &versioned_name)?;
504 let mut types_yamls: Vec<(String, String)> = Vec::new();
505 if let Some(types_entry) = schema_tree
506 .lookup_entry_by_path("types")
507 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
508 && types_entry.mode().is_tree()
509 {
510 let types_obj = types_entry
511 .object()
512 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
513 let types_tree = types_obj
514 .try_into_tree()
515 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
516 for entry_res in types_tree.iter() {
517 let entry = entry_res.map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
518 if !entry.mode().is_blob() {
519 continue;
520 }
521 let filename = match std::str::from_utf8(entry.filename()) {
522 Ok(s) => s,
523 Err(_) => continue,
524 };
525 let stem = match filename.strip_suffix(".yaml") {
526 Some(s) => s.to_string(),
527 None => continue,
528 };
529 let blob = repo
530 .find_object(entry.oid().to_owned())
531 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
532 let bytes = blob.data.clone();
533 let contents = String::from_utf8(bytes).map_err(|e| {
534 MemRepoSchemasError::NotUtf8(
535 format!("{versioned_name}/types/{filename}"),
536 e.to_string(),
537 )
538 })?;
539 types_yamls.push((stem, contents));
540 }
541 types_yamls.sort_by(|a, b| a.0.cmp(&b.0));
542 }
543
544 let format = match schema_tree
549 .lookup_entry_by_path(memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE)
550 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
551 {
552 Some(e) if e.mode().is_blob() => {
553 memstead_schema::loader::MetadataPolarityFormat::RequiredOptIn
554 }
555 _ => memstead_schema::loader::MetadataPolarityFormat::Legacy,
556 };
557 let schema = memstead_schema::loader::load_schema_from_memory_with_format(
558 &manifest_yaml,
559 &types_yamls,
560 format,
561 )
562 .map_err(|source| MemRepoSchemasError::Schema {
563 name: versioned_name.clone(),
564 source,
565 })?;
566 out.push(Arc::new(schema));
567 }
568
569 if out.is_empty() {
570 Ok(LoadOutcome::NoSchemas)
571 } else {
572 Ok(LoadOutcome::Schemas(out))
573 }
574}
575
576fn read_blob_string_for_schemas(
577 _repo: &gix::Repository,
578 schema_tree: &gix::Tree<'_>,
579 filename: &str,
580 schema_name: &str,
581) -> Result<String, MemRepoSchemasError> {
582 let entry = schema_tree
583 .lookup_entry_by_path(filename)
584 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
585 .ok_or_else(|| {
586 MemRepoSchemasError::GitTree(format!(
587 "schema '{schema_name}': missing {filename} in __MEMSTEAD"
588 ))
589 })?;
590 let object = entry
591 .object()
592 .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
593 let bytes = object.data.clone();
594 String::from_utf8(bytes).map_err(|e| {
595 MemRepoSchemasError::NotUtf8(format!("{schema_name}/{filename}"), e.to_string())
596 })
597}
598
599pub fn read_schema_file_from_memstead_ref(
606 gitdir: &Path,
607 name: &str,
608 version: &str,
609 rel: &str,
610) -> Result<Option<Vec<u8>>, MemsteadRefError> {
611 let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
612 let memstead_ref = match repo
613 .try_find_reference("refs/heads/__MEMSTEAD")
614 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
615 {
616 Some(r) => r,
617 None => return Ok(None),
618 };
619 let id = memstead_ref
620 .into_fully_peeled_id()
621 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
622 let commit = id
623 .object()
624 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
625 .try_into_commit()
626 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
627 let tree = commit
628 .tree()
629 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
630 let path = format!("schemas/{name}@{version}/{rel}");
631 let entry = match tree
632 .lookup_entry_by_path(path.as_str())
633 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
634 {
635 Some(e) if e.mode().is_blob() => e,
636 _ => return Ok(None),
637 };
638 let object = entry
639 .object()
640 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
641 Ok(Some(object.data.clone()))
642}
643
644pub fn read_mem_config_from_memstead_ref(
658 gitdir: &Path,
659 mem_name: &str,
660) -> Result<MemConfig, MemsteadRefError> {
661 let full_tree_path = match resolve_full_path_at_gitdir(gitdir, mem_name) {
666 Ok(Some(p)) => p,
667 Ok(None) => mem_name.to_string(),
668 Err(e) => {
669 return Err(MemsteadRefError::GitTree(e.to_string()));
670 }
671 };
672
673 let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
674 let memstead_ref = repo
675 .try_find_reference("refs/heads/__MEMSTEAD")
676 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
677 .ok_or_else(|| MemsteadRefError::Config {
678 path: "refs/heads/__MEMSTEAD".to_string(),
679 message: "ref not found".to_string(),
680 })?;
681 let id = memstead_ref
682 .into_fully_peeled_id()
683 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
684 let commit = id
685 .object()
686 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
687 .try_into_commit()
688 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
689 let tree = commit
690 .tree()
691 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
692
693 let path = format!("mems/{full_tree_path}/config.json");
694 let entry = tree
695 .lookup_entry_by_path(&path)
696 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
697 .ok_or_else(|| MemsteadRefError::Config {
698 path: path.clone(),
699 message: "config not found in __MEMSTEAD tree".to_string(),
700 })?;
701 let object = entry
702 .object()
703 .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
704 let bytes = object.data.clone();
705 let raw = String::from_utf8(bytes).map_err(|e| MemsteadRefError::Config {
706 path: path.clone(),
707 message: format!("not utf-8: {e}"),
708 })?;
709 let value: serde_json::Value =
710 serde_json::from_str(&raw).map_err(|e| MemsteadRefError::Config {
711 path: path.clone(),
712 message: format!("invalid json: {e}"),
713 })?;
714 memstead_schema::parse_mem_config(&value).map_err(|e| MemsteadRefError::Config {
715 path,
716 message: e.to_string(),
717 })
718}
719
720pub fn commit_config_to_memstead_at_gitdir(
741 gitdir: &Path,
742 mem_name: &str,
743 config_bytes: &[u8],
744 ctx: &CommitContext<'_>,
745 message: &str,
746) -> Result<(), MemRepoWriteError> {
747 let full_tree_path = match resolve_full_path_at_gitdir(gitdir, mem_name) {
748 Ok(Some(p)) => p,
749 Ok(None) => mem_name.to_string(),
750 Err(crate::mem_repo_config::MemRepoConfigError::GitdirNotFound(p)) => {
751 return Err(MemRepoWriteError::GixOpen {
752 path: p,
753 message: "mem-repo gitdir not found".to_string(),
754 });
755 }
756 Err(e) => {
757 return Err(MemRepoWriteError::GitTree(e.to_string()));
758 }
759 };
760
761 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
762 path: gitdir.display().to_string(),
763 message: e.to_string(),
764 })?;
765 let existing_tip: Option<gix::ObjectId> = repo
768 .try_find_reference("refs/heads/__MEMSTEAD")
769 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
770 .map(|r| {
771 r.into_fully_peeled_id()
772 .map(|id| id.detach())
773 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
774 })
775 .transpose()?;
776
777 let mut editor = match existing_tip {
781 Some(tip) => {
782 let commit = repo
783 .find_object(tip)
784 .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
785 .into_commit();
786 let tree = commit
787 .tree()
788 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
789 tree.edit().map_err(|e| {
790 MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
791 })?
792 }
793 None => repo.empty_tree().edit().map_err(|e| {
794 MemRepoWriteError::GitTree(format!("editor init (empty) for __MEMSTEAD: {e}"))
795 })?,
796 };
797 let blob_id = repo
798 .write_blob(config_bytes)
799 .map_err(|e| MemRepoWriteError::GitTree(format!("write config blob: {e}")))?
800 .detach();
801 let tree_path = format!("mems/{full_tree_path}/config.json");
802 editor
803 .upsert(
804 tree_path.as_str(),
805 gix::objs::tree::EntryKind::Blob,
806 blob_id,
807 )
808 .map_err(|e| MemRepoWriteError::GitTree(format!("tree upsert {tree_path}: {e}")))?;
809 let new_tree_id = editor
810 .write()
811 .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
812 .detach();
813
814 let time = gix::date::Time::now_local_or_utc();
815 let committer_sig = gix::actor::Signature {
816 name: COMMITTER_NAME.into(),
817 email: COMMITTER_EMAIL.into(),
818 time,
819 };
820 let author_sig = match author_identity(ctx) {
821 Some((name, email)) => gix::actor::Signature {
822 name: name.into(),
823 email: email.into(),
824 time,
825 },
826 None => committer_sig.clone(),
827 };
828
829 let full_message = format_commit_message(message, ctx);
830
831 let parents: Vec<gix::ObjectId> = match existing_tip {
832 Some(tip) => vec![tip],
833 None => Vec::new(),
834 };
835 let commit = gix::objs::Commit {
836 message: full_message.into(),
837 tree: new_tree_id,
838 author: author_sig,
839 committer: committer_sig,
840 encoding: None,
841 parents: parents.into_iter().collect(),
842 extra_headers: Default::default(),
843 };
844 let new_commit_id = repo
845 .write_object(&commit)
846 .map_err(|e| MemRepoWriteError::GitTree(format!("write {tree_path} commit: {e}")))?
847 .detach();
848
849 let expected = match existing_tip {
850 Some(tip) => {
851 gix::refs::transaction::PreviousValue::MustExistAndMatch(gix::refs::Target::Object(tip))
852 }
853 None => gix::refs::transaction::PreviousValue::MustNotExist,
854 };
855 commit_refs_at_gitdir(
856 gitdir,
857 &[RefSpec {
858 ref_name: "refs/heads/__MEMSTEAD".to_string(),
859 new_oid: new_commit_id,
860 expected,
861 log_message: format!("memstead: commit __MEMSTEAD:{tree_path}"),
862 }],
863 )?;
864
865 Ok(())
866}
867
868pub fn commit_paths_to_memstead_at_gitdir(
883 gitdir: &Path,
884 edits: &[(String, Option<Vec<u8>>)],
885 ctx: &CommitContext<'_>,
886 message: &str,
887) -> Result<(), MemRepoWriteError> {
888 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
889 path: gitdir.display().to_string(),
890 message: e.to_string(),
891 })?;
892 let existing_tip: Option<gix::ObjectId> = repo
893 .try_find_reference("refs/heads/__MEMSTEAD")
894 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
895 .map(|r| {
896 r.into_fully_peeled_id()
897 .map(|id| id.detach())
898 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
899 })
900 .transpose()?;
901
902 let mut editor = match existing_tip {
903 Some(tip) => {
904 let commit = repo
905 .find_object(tip)
906 .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
907 .into_commit();
908 let tree = commit
909 .tree()
910 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
911 tree.edit().map_err(|e| {
912 MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
913 })?
914 }
915 None => repo.empty_tree().edit().map_err(|e| {
916 MemRepoWriteError::GitTree(format!("editor init (empty) for __MEMSTEAD: {e}"))
917 })?,
918 };
919 for (tree_path, bytes) in edits {
920 match bytes {
921 Some(bytes) => {
922 let blob_id = repo
923 .write_blob(bytes.as_slice())
924 .map_err(|e| MemRepoWriteError::GitTree(format!("write blob: {e}")))?
925 .detach();
926 editor
927 .upsert(
928 tree_path.as_str(),
929 gix::objs::tree::EntryKind::Blob,
930 blob_id,
931 )
932 .map_err(|e| {
933 MemRepoWriteError::GitTree(format!("tree upsert {tree_path}: {e}"))
934 })?;
935 }
936 None => {
937 editor.remove(tree_path.as_str()).map_err(|e| {
938 MemRepoWriteError::GitTree(format!("tree remove {tree_path}: {e}"))
939 })?;
940 }
941 }
942 }
943 let new_tree_id = editor
944 .write()
945 .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
946 .detach();
947
948 let time = gix::date::Time::now_local_or_utc();
949 let committer_sig = gix::actor::Signature {
950 name: COMMITTER_NAME.into(),
951 email: COMMITTER_EMAIL.into(),
952 time,
953 };
954 let author_sig = match author_identity(ctx) {
955 Some((name, email)) => gix::actor::Signature {
956 name: name.into(),
957 email: email.into(),
958 time,
959 },
960 None => committer_sig.clone(),
961 };
962 let full_message = format_commit_message(message, ctx);
963 let parents: Vec<gix::ObjectId> = match existing_tip {
964 Some(tip) => vec![tip],
965 None => Vec::new(),
966 };
967 let commit = gix::objs::Commit {
968 message: full_message.into(),
969 tree: new_tree_id,
970 author: author_sig,
971 committer: committer_sig,
972 encoding: None,
973 parents: parents.into_iter().collect(),
974 extra_headers: Default::default(),
975 };
976 let new_commit_id = repo
977 .write_object(&commit)
978 .map_err(|e| MemRepoWriteError::GitTree(format!("write __MEMSTEAD commit: {e}")))?
979 .detach();
980
981 let expected = match existing_tip {
982 Some(tip) => {
983 gix::refs::transaction::PreviousValue::MustExistAndMatch(gix::refs::Target::Object(tip))
984 }
985 None => gix::refs::transaction::PreviousValue::MustNotExist,
986 };
987 commit_refs_at_gitdir(
988 gitdir,
989 &[RefSpec {
990 ref_name: "refs/heads/__MEMSTEAD".to_string(),
991 new_oid: new_commit_id,
992 expected,
993 log_message: message.to_string(),
994 }],
995 )?;
996 Ok(())
997}
998
999pub fn delete_mem_artifacts_at_gitdir(
1027 gitdir: &Path,
1028 branch_leaf: &str,
1029 ctx: &CommitContext<'_>,
1030) -> Result<(), MemRepoWriteError> {
1031 use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
1032 use gix::refs::{FullName, Target};
1033
1034 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
1035 path: gitdir.display().to_string(),
1036 message: e.to_string(),
1037 })?;
1038 let existing_memstead_tip: Option<gix::ObjectId> = repo
1040 .try_find_reference("refs/heads/__MEMSTEAD")
1041 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
1042 .map(|r| {
1043 r.into_fully_peeled_id()
1044 .map(|id| id.detach())
1045 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
1046 })
1047 .transpose()?;
1048
1049 let mut new_memstead_commit: Option<gix::ObjectId> = None;
1050 if let Some(tip) = existing_memstead_tip {
1051 let commit = repo
1052 .find_object(tip)
1053 .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
1054 .into_commit();
1055 let tree = commit
1056 .tree()
1057 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
1058
1059 let tree_path = format!("mems/{branch_leaf}/config.json");
1063 let entry_present = tree
1064 .lookup_entry_by_path(&tree_path)
1065 .map_err(|e| MemRepoWriteError::GitTree(format!("lookup {tree_path}: {e}")))?
1066 .is_some();
1067
1068 if entry_present {
1069 let mut editor = tree.edit().map_err(|e| {
1070 MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
1071 })?;
1072 editor
1073 .remove(tree_path.as_str())
1074 .map_err(|e| MemRepoWriteError::GitTree(format!("tree remove {tree_path}: {e}")))?;
1075 let new_tree_id = editor
1080 .write()
1081 .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
1082 .detach();
1083
1084 let time = gix::date::Time::now_local_or_utc();
1085 let committer_sig = gix::actor::Signature {
1086 name: COMMITTER_NAME.into(),
1087 email: COMMITTER_EMAIL.into(),
1088 time,
1089 };
1090 let author_sig = match author_identity(ctx) {
1091 Some((name, email)) => gix::actor::Signature {
1092 name: name.into(),
1093 email: email.into(),
1094 time,
1095 },
1096 None => committer_sig.clone(),
1097 };
1098 let full_message = format_commit_message(
1099 &format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json"),
1100 ctx,
1101 );
1102 let commit_obj = gix::objs::Commit {
1103 message: full_message.into(),
1104 tree: new_tree_id,
1105 author: author_sig,
1106 committer: committer_sig,
1107 encoding: None,
1108 parents: vec![tip].into_iter().collect(),
1109 extra_headers: Default::default(),
1110 };
1111 let new_commit_id = repo
1112 .write_object(&commit_obj)
1113 .map_err(|e| {
1114 MemRepoWriteError::GitTree(format!("write __MEMSTEAD prune commit: {e}"))
1115 })?
1116 .detach();
1117 new_memstead_commit = Some(new_commit_id);
1118 }
1119 }
1120
1121 let mut edits: Vec<RefEdit> = Vec::with_capacity(2);
1123
1124 let branch_ref = format!("refs/heads/{branch_leaf}");
1125 let branch_full: FullName = branch_ref.as_str().try_into().map_err(|e| {
1126 MemRepoWriteError::RefTransaction(format!("invalid branch ref {branch_ref:?}: {e}"))
1127 })?;
1128 edits.push(RefEdit {
1129 change: Change::Delete {
1130 expected: PreviousValue::Any,
1133 log: RefLog::AndReference,
1134 },
1135 name: branch_full,
1136 deref: false,
1137 });
1138
1139 if let (Some(prior_tip), Some(new_commit)) = (existing_memstead_tip, new_memstead_commit) {
1140 let memstead_full: FullName = "refs/heads/__MEMSTEAD".try_into().map_err(|e| {
1141 MemRepoWriteError::RefTransaction(format!("invalid __MEMSTEAD ref: {e}"))
1142 })?;
1143 edits.push(RefEdit {
1144 change: Change::Update {
1145 log: LogChange {
1146 mode: RefLog::AndReference,
1147 force_create_reflog: false,
1148 message: format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json")
1149 .as_str()
1150 .into(),
1151 },
1152 expected: PreviousValue::MustExistAndMatch(Target::Object(prior_tip)),
1153 new: Target::Object(new_commit),
1154 },
1155 name: memstead_full,
1156 deref: false,
1157 });
1158 }
1159
1160 repo.edit_references_as(
1161 edits,
1162 Some(reflog_committer().to_ref(&mut Default::default())),
1163 )
1164 .map_err(|e| MemRepoWriteError::RefTransaction(error_chain(&e)))?;
1165
1166 Ok(())
1167}
1168
1169pub fn rename_mem_artifacts_at_gitdir(
1182 gitdir: &Path,
1183 old_leaf: &str,
1184 new_leaf: &str,
1185 ctx: &CommitContext<'_>,
1186) -> Result<(), MemRepoWriteError> {
1187 use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
1188 use gix::refs::{FullName, Target};
1189
1190 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
1191 path: gitdir.display().to_string(),
1192 message: e.to_string(),
1193 })?;
1194
1195 let old_ref = format!("refs/heads/{old_leaf}");
1197 let new_ref = format!("refs/heads/{new_leaf}");
1198 let old_tip: gix::ObjectId = repo
1199 .try_find_reference(&old_ref)
1200 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
1201 .ok_or_else(|| {
1202 MemRepoWriteError::GitTree(format!("rename source branch {old_ref} not found"))
1203 })?
1204 .into_fully_peeled_id()
1205 .map_err(|e| MemRepoWriteError::GitTree(format!("peel {old_ref}: {e}")))?
1206 .detach();
1207 if repo
1208 .try_find_reference(&new_ref)
1209 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
1210 .is_some()
1211 {
1212 return Err(MemRepoWriteError::GitTree(format!(
1213 "rename target branch {new_ref} already exists"
1214 )));
1215 }
1216
1217 let existing_memstead_tip: Option<gix::ObjectId> = repo
1219 .try_find_reference("refs/heads/__MEMSTEAD")
1220 .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
1221 .map(|r| {
1222 r.into_fully_peeled_id()
1223 .map(|id| id.detach())
1224 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
1225 })
1226 .transpose()?;
1227
1228 let mut new_memstead_commit: Option<gix::ObjectId> = None;
1229 if let Some(tip) = existing_memstead_tip {
1230 let commit = repo
1231 .find_object(tip)
1232 .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
1233 .into_commit();
1234 let tree = commit
1235 .tree()
1236 .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
1237
1238 let old_path = format!("mems/{old_leaf}/config.json");
1239 let new_path = format!("mems/{new_leaf}/config.json");
1240 let config_blob_id = tree
1241 .lookup_entry_by_path(&old_path)
1242 .map_err(|e| MemRepoWriteError::GitTree(format!("lookup {old_path}: {e}")))?
1243 .map(|e| e.object_id());
1244
1245 if let Some(blob_id) = config_blob_id {
1246 let mut editor = tree.edit().map_err(|e| {
1247 MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
1248 })?;
1249 editor
1250 .remove(old_path.as_str())
1251 .map_err(|e| MemRepoWriteError::GitTree(format!("tree remove {old_path}: {e}")))?;
1252 editor
1253 .upsert(new_path.as_str(), gix::objs::tree::EntryKind::Blob, blob_id)
1254 .map_err(|e| MemRepoWriteError::GitTree(format!("tree upsert {new_path}: {e}")))?;
1255 let new_tree_id = editor
1256 .write()
1257 .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
1258 .detach();
1259
1260 let time = gix::date::Time::now_local_or_utc();
1261 let committer_sig = gix::actor::Signature {
1262 name: COMMITTER_NAME.into(),
1263 email: COMMITTER_EMAIL.into(),
1264 time,
1265 };
1266 let author_sig = match author_identity(ctx) {
1267 Some((name, email)) => gix::actor::Signature {
1268 name: name.into(),
1269 email: email.into(),
1270 time,
1271 },
1272 None => committer_sig.clone(),
1273 };
1274 let full_message = format_commit_message(
1275 &format!("memstead: rename mem `{old_leaf}` → `{new_leaf}`"),
1276 ctx,
1277 );
1278 let commit_obj = gix::objs::Commit {
1279 message: full_message.into(),
1280 tree: new_tree_id,
1281 author: author_sig,
1282 committer: committer_sig,
1283 encoding: None,
1284 parents: vec![tip].into_iter().collect(),
1285 extra_headers: Default::default(),
1286 };
1287 new_memstead_commit = Some(
1288 repo.write_object(&commit_obj)
1289 .map_err(|e| {
1290 MemRepoWriteError::GitTree(format!("write __MEMSTEAD rename commit: {e}"))
1291 })?
1292 .detach(),
1293 );
1294 }
1295 }
1296
1297 let mut edits: Vec<RefEdit> = Vec::with_capacity(3);
1299
1300 let new_full: FullName = new_ref.as_str().try_into().map_err(|e| {
1301 MemRepoWriteError::RefTransaction(format!("invalid branch ref {new_ref:?}: {e}"))
1302 })?;
1303 edits.push(RefEdit {
1304 change: Change::Update {
1305 log: LogChange {
1306 mode: RefLog::AndReference,
1307 force_create_reflog: false,
1308 message: format!("memstead: rename mem `{old_leaf}` → `{new_leaf}`")
1309 .as_str()
1310 .into(),
1311 },
1312 expected: PreviousValue::MustNotExist,
1313 new: Target::Object(old_tip),
1314 },
1315 name: new_full,
1316 deref: false,
1317 });
1318
1319 let old_full: FullName = old_ref.as_str().try_into().map_err(|e| {
1320 MemRepoWriteError::RefTransaction(format!("invalid branch ref {old_ref:?}: {e}"))
1321 })?;
1322 edits.push(RefEdit {
1323 change: Change::Delete {
1324 expected: PreviousValue::MustExistAndMatch(Target::Object(old_tip)),
1325 log: RefLog::AndReference,
1326 },
1327 name: old_full,
1328 deref: false,
1329 });
1330
1331 if let (Some(prior_tip), Some(new_commit)) = (existing_memstead_tip, new_memstead_commit) {
1332 let memstead_full: FullName = "refs/heads/__MEMSTEAD".try_into().map_err(|e| {
1333 MemRepoWriteError::RefTransaction(format!("invalid __MEMSTEAD ref: {e}"))
1334 })?;
1335 edits.push(RefEdit {
1336 change: Change::Update {
1337 log: LogChange {
1338 mode: RefLog::AndReference,
1339 force_create_reflog: false,
1340 message: format!("memstead: rename mem `{old_leaf}` → `{new_leaf}`")
1341 .as_str()
1342 .into(),
1343 },
1344 expected: PreviousValue::MustExistAndMatch(Target::Object(prior_tip)),
1345 new: Target::Object(new_commit),
1346 },
1347 name: memstead_full,
1348 deref: false,
1349 });
1350 }
1351
1352 repo.edit_references_as(
1353 edits,
1354 Some(reflog_committer().to_ref(&mut Default::default())),
1355 )
1356 .map_err(|e| MemRepoWriteError::RefTransaction(error_chain(&e)))?;
1357
1358 Ok(())
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363 use super::*;
1364 use std::path::PathBuf;
1365 use tempfile::TempDir;
1366
1367 fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1368 let git_dir = tmp.join("mem-repo.git");
1369 gix::init_bare(&git_dir).unwrap();
1370 std::fs::canonicalize(&git_dir).unwrap()
1371 }
1372
1373 fn actor_for_test() -> gix::actor::Signature {
1374 gix::actor::Signature {
1375 name: "test".into(),
1376 email: "test@example.com".into(),
1377 time: gix::date::Time {
1378 seconds: 0,
1379 offset: 0,
1380 },
1381 }
1382 }
1383
1384 type SchemaSeed<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]);
1386
1387 fn seed_schemas(gitdir: &Path, schemas: &[SchemaSeed<'_>]) {
1391 let repo = gix::open(gitdir).unwrap();
1392 let actor = actor_for_test();
1393 let mut buf = gix::date::parse::TimeBuf::default();
1394 let sig_ref = actor.to_ref(&mut buf);
1395 let mut editor = repo.empty_tree().edit().unwrap();
1396 for (name, version, types) in schemas {
1397 let manifest = format!("name: {name}\nversion: \"{version}\"\n");
1398 let manifest_blob = repo.write_blob(manifest.as_bytes()).unwrap().detach();
1399 editor
1400 .upsert(
1401 format!("{name}/schema.yaml"),
1402 gix::object::tree::EntryKind::Blob,
1403 manifest_blob,
1404 )
1405 .unwrap();
1406 for (type_name, type_body) in *types {
1407 let blob = repo.write_blob(type_body.as_bytes()).unwrap().detach();
1408 editor
1409 .upsert(
1410 format!("{name}/types/{type_name}.yaml"),
1411 gix::object::tree::EntryKind::Blob,
1412 blob,
1413 )
1414 .unwrap();
1415 }
1416 }
1417 let tree_id = editor.write().unwrap().detach();
1418 repo.commit_as(
1419 sig_ref,
1420 sig_ref,
1421 "refs/heads/__SCHEMAS",
1422 "seed __SCHEMAS",
1423 tree_id,
1424 Vec::<gix::ObjectId>::new(),
1425 )
1426 .unwrap();
1427 }
1428
1429 fn seed_system(gitdir: &Path, repo_json: &str, mems: &[(&str, &str)]) {
1433 let repo = gix::open(gitdir).unwrap();
1434 let actor = actor_for_test();
1435 let mut buf = gix::date::parse::TimeBuf::default();
1436 let sig_ref = actor.to_ref(&mut buf);
1437 let mut editor = repo.empty_tree().edit().unwrap();
1438 if !repo_json.is_empty() {
1439 let blob = repo.write_blob(repo_json.as_bytes()).unwrap().detach();
1440 editor
1441 .upsert("repo.json", gix::object::tree::EntryKind::Blob, blob)
1442 .unwrap();
1443 }
1444 for (mem, config) in mems {
1445 let blob = repo.write_blob(config.as_bytes()).unwrap().detach();
1446 editor
1447 .upsert(
1448 format!("{mem}/config.json"),
1449 gix::object::tree::EntryKind::Blob,
1450 blob,
1451 )
1452 .unwrap();
1453 }
1454 let tree_id = editor.write().unwrap().detach();
1455 repo.commit_as(
1456 sig_ref,
1457 sig_ref,
1458 "refs/heads/__SYSTEM",
1459 "seed __SYSTEM",
1460 tree_id,
1461 Vec::<gix::ObjectId>::new(),
1462 )
1463 .unwrap();
1464 }
1465
1466 fn list_memstead_entries(gitdir: &Path) -> Vec<String> {
1469 let repo = gix::open(gitdir).unwrap();
1470 let reference = repo
1471 .try_find_reference("refs/heads/__MEMSTEAD")
1472 .unwrap()
1473 .unwrap();
1474 let id = reference.into_fully_peeled_id().unwrap();
1475 let commit = id.object().unwrap().try_into_commit().unwrap();
1476 let tree = commit.tree().unwrap();
1477 let mut out: Vec<String> = Vec::new();
1478 walk(&repo, &tree, "", &mut out);
1479 out.sort();
1480 out
1481 }
1482
1483 fn walk(repo: &gix::Repository, tree: &gix::Tree<'_>, prefix: &str, out: &mut Vec<String>) {
1484 for entry in tree.iter().flatten() {
1485 let name = std::str::from_utf8(entry.filename())
1486 .unwrap_or("")
1487 .to_string();
1488 let path = if prefix.is_empty() {
1489 name.clone()
1490 } else {
1491 format!("{prefix}/{name}")
1492 };
1493 match entry.mode().kind() {
1494 gix::object::tree::EntryKind::Tree => {
1495 let subtree = repo
1496 .find_object(entry.oid().to_owned())
1497 .unwrap()
1498 .into_tree();
1499 walk(repo, &subtree, &path, out);
1500 }
1501 gix::object::tree::EntryKind::Blob
1502 | gix::object::tree::EntryKind::BlobExecutable => {
1503 out.push(path);
1504 }
1505 _ => {}
1506 }
1507 }
1508 }
1509
1510 #[test]
1511 fn migrate_writes_unified_tree_from_schemas_and_system() {
1512 let tmp = TempDir::new().unwrap();
1513 let gitdir = fresh_repo_dir(tmp.path());
1514 seed_schemas(
1515 &gitdir,
1516 &[
1517 ("default", "1.0.0", &[("spec", "name: spec\n")]),
1518 ("custom", "0.5.0", &[]),
1519 ],
1520 );
1521 seed_system(
1522 &gitdir,
1523 r#"{"name":"main"}"#,
1524 &[
1525 ("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#),
1526 ("beta", r#"{"format": 1, "schema": "custom@0.5.0"}"#),
1527 ],
1528 );
1529
1530 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1531 assert_eq!(outcome.schemas_migrated, 2);
1532 assert_eq!(outcome.mems_migrated, 2);
1533 assert!(!outcome.already_current);
1534
1535 let entries = list_memstead_entries(&gitdir);
1536 assert!(entries.contains(&"schemas/default@1.0.0/schema.yaml".to_string()));
1538 assert!(entries.contains(&"schemas/default@1.0.0/types/spec.yaml".to_string()));
1539 assert!(entries.contains(&"schemas/custom@0.5.0/schema.yaml".to_string()));
1540 assert!(entries.contains(&"mems/alpha/config.json".to_string()));
1542 assert!(entries.contains(&"mems/beta/config.json".to_string()));
1543 assert!(
1545 !entries.iter().any(|p| p.contains("repo.json")),
1546 "repo.json must not appear under __MEMSTEAD: {entries:?}"
1547 );
1548 }
1549
1550 #[test]
1556 fn sealed_format_marker_decides_metadata_polarity() {
1557 let manifest = b"name: tiny\nversion: 0.1.0\ndescription: t\nwhen_to_use: tests\ntypes:\n - doc\nrelationships:\n mode: strict\n definitions:\n - name: PART_OF\n description: h\n default_weight: 3.0\n - name: _default\n description: d\n default_weight: 1.0\ncommunity:\n resolution: 1.0\n seed: 42\n".to_vec();
1558 let doc = b"name: doc\ndescription: t\nwhen_to_use: tests\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields:\n - key: bare\n description: no required key\n field_type: string\ntitle_weight: 100.0\ntext_fields: [body]\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields: [title, body]\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n".to_vec();
1559 let files = vec![
1560 ("schema.yaml".to_string(), manifest),
1561 ("types/doc.yaml".to_string(), doc),
1562 ];
1563
1564 let tmp = TempDir::new().unwrap();
1566 let gitdir = fresh_repo_dir(tmp.path());
1567 write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &files).unwrap();
1568 let LoadOutcome::Schemas(schemas) =
1569 load_schemas_from_memstead_ref_at_gitdir(&gitdir).unwrap()
1570 else {
1571 panic!("schemas expected");
1572 };
1573 assert!(
1574 schemas[0]
1575 .get_type("doc")
1576 .unwrap()
1577 .metadata_field("bare")
1578 .unwrap()
1579 .is_required(),
1580 "unmarked sealed package: absence keeps the legacy meaning (required)"
1581 );
1582
1583 let tmp2 = TempDir::new().unwrap();
1585 let gitdir2 = fresh_repo_dir(tmp2.path());
1586 let marked = memstead_schema::loader::with_format_marker(files);
1587 write_schema_to_memstead_ref(&gitdir2, "tiny", "0.1.0", &marked).unwrap();
1588 let LoadOutcome::Schemas(schemas) =
1589 load_schemas_from_memstead_ref_at_gitdir(&gitdir2).unwrap()
1590 else {
1591 panic!("schemas expected");
1592 };
1593 assert!(
1594 !schemas[0]
1595 .get_type("doc")
1596 .unwrap()
1597 .metadata_field("bare")
1598 .unwrap()
1599 .is_required(),
1600 "marked sealed package: absence means optional"
1601 );
1602 }
1603
1604 #[test]
1605 fn write_schema_to_memstead_ref_adds_package_idempotent_and_preserves_others() {
1606 let tmp = TempDir::new().unwrap();
1607 let gitdir = fresh_repo_dir(tmp.path());
1608
1609 let tiny = vec![
1611 (
1612 "schema.yaml".to_string(),
1613 b"name: tiny\nversion: 0.1.0\n".to_vec(),
1614 ),
1615 ("types/doc.yaml".to_string(), b"name: doc\n".to_vec()),
1616 ("mem-template.json".to_string(), b"{}\n".to_vec()),
1617 ];
1618 let out = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1619 assert!(!out.already_current);
1620 let entries = list_memstead_entries(&gitdir);
1621 for p in [
1622 "schemas/tiny@0.1.0/schema.yaml",
1623 "schemas/tiny@0.1.0/types/doc.yaml",
1624 "schemas/tiny@0.1.0/mem-template.json",
1625 ] {
1626 assert!(entries.iter().any(|e| e == p), "missing {p}: {entries:?}");
1627 }
1628
1629 let again = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1631 assert!(again.already_current);
1632 assert_eq!(out.commit_sha, again.commit_sha);
1633
1634 let other = vec![(
1636 "schema.yaml".to_string(),
1637 b"name: other\nversion: 2.0.0\n".to_vec(),
1638 )];
1639 let out2 = write_schema_to_memstead_ref(&gitdir, "other", "2.0.0", &other).unwrap();
1640 assert!(!out2.already_current);
1641 assert_ne!(out2.commit_sha, out.commit_sha);
1642 let entries2 = list_memstead_entries(&gitdir);
1643 assert!(
1644 entries2
1645 .iter()
1646 .any(|e| e == "schemas/tiny@0.1.0/schema.yaml")
1647 );
1648 assert!(
1649 entries2
1650 .iter()
1651 .any(|e| e == "schemas/other@2.0.0/schema.yaml")
1652 );
1653 }
1654
1655 #[test]
1660 fn read_schema_file_round_trips_and_absence_is_none() {
1661 let tmp = TempDir::new().unwrap();
1662 let gitdir = fresh_repo_dir(tmp.path());
1663
1664 assert!(
1666 read_schema_file_from_memstead_ref(&gitdir, "tiny", "0.1.0", "install-provenance.json")
1667 .unwrap()
1668 .is_none()
1669 );
1670
1671 let stamp = br#"{"authoring_path":"/tmp/somewhere/tiny"}"#.to_vec();
1672 let files = vec![
1673 (
1674 "schema.yaml".to_string(),
1675 b"name: tiny\nversion: 0.1.0\n".to_vec(),
1676 ),
1677 ("install-provenance.json".to_string(), stamp.clone()),
1678 ];
1679 write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &files).unwrap();
1680
1681 assert_eq!(
1683 read_schema_file_from_memstead_ref(&gitdir, "tiny", "0.1.0", "install-provenance.json")
1684 .unwrap()
1685 .as_deref(),
1686 Some(stamp.as_slice())
1687 );
1688 assert_eq!(
1689 (crate::storage::FULL_GIT_BRANCH_OPS.read_schema_file)(
1690 &gitdir,
1691 "tiny",
1692 "0.1.0",
1693 "install-provenance.json"
1694 )
1695 .unwrap()
1696 .as_deref(),
1697 Some(stamp.as_slice())
1698 );
1699
1700 assert!(
1702 read_schema_file_from_memstead_ref(&gitdir, "tiny", "0.1.0", "no-such-file.json")
1703 .unwrap()
1704 .is_none()
1705 );
1706 assert!(
1707 read_schema_file_from_memstead_ref(
1708 &gitdir,
1709 "ghost",
1710 "9.9.9",
1711 "install-provenance.json"
1712 )
1713 .unwrap()
1714 .is_none()
1715 );
1716 }
1717
1718 #[test]
1719 fn full_git_branch_ops_write_schema_hook_writes_to_ref() {
1720 let tmp = TempDir::new().unwrap();
1724 let gitdir = fresh_repo_dir(tmp.path());
1725 let files = vec![(
1726 "schema.yaml".to_string(),
1727 b"name: h\nversion: 1.0.0\n".to_vec(),
1728 )];
1729 let commit =
1730 (crate::storage::FULL_GIT_BRANCH_OPS.write_schema)(&gitdir, "h", "1.0.0", &files)
1731 .expect("hook writes the package");
1732 assert!(!commit.is_empty());
1733 let entries = list_memstead_entries(&gitdir);
1734 assert!(
1735 entries.iter().any(|e| e == "schemas/h@1.0.0/schema.yaml"),
1736 "package must land on the ref: {entries:?}"
1737 );
1738 }
1739
1740 #[test]
1741 fn migrate_is_idempotent() {
1742 let tmp = TempDir::new().unwrap();
1743 let gitdir = fresh_repo_dir(tmp.path());
1744 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1745 seed_system(
1746 &gitdir,
1747 r#"{"name":"main"}"#,
1748 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1749 );
1750
1751 let first = migrate_to_memstead_ref(&gitdir).unwrap();
1752 assert!(!first.already_current);
1753 let second = migrate_to_memstead_ref(&gitdir).unwrap();
1754 assert!(second.already_current);
1755 assert_eq!(first.commit_sha, second.commit_sha);
1757 }
1758
1759 #[test]
1760 fn migrate_with_empty_workspace_writes_empty_tree() {
1761 let tmp = TempDir::new().unwrap();
1762 let gitdir = fresh_repo_dir(tmp.path());
1763 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1767 assert_eq!(outcome.schemas_migrated, 0);
1768 assert_eq!(outcome.mems_migrated, 0);
1769 let entries = list_memstead_entries(&gitdir);
1770 assert!(entries.is_empty());
1771 }
1772
1773 #[test]
1774 fn migrate_handles_missing_version_with_placeholder() {
1775 let tmp = TempDir::new().unwrap();
1776 let gitdir = fresh_repo_dir(tmp.path());
1777 let repo = gix::open(&gitdir).unwrap();
1779 let actor = actor_for_test();
1780 let mut buf = gix::date::parse::TimeBuf::default();
1781 let sig_ref = actor.to_ref(&mut buf);
1782 let mut editor = repo.empty_tree().edit().unwrap();
1783 let blob = repo.write_blob(b"name: anonymous\n").unwrap().detach();
1784 editor
1785 .upsert(
1786 "anonymous/schema.yaml",
1787 gix::object::tree::EntryKind::Blob,
1788 blob,
1789 )
1790 .unwrap();
1791 let tree_id = editor.write().unwrap().detach();
1792 repo.commit_as(
1793 sig_ref,
1794 sig_ref,
1795 "refs/heads/__SCHEMAS",
1796 "seed",
1797 tree_id,
1798 Vec::<gix::ObjectId>::new(),
1799 )
1800 .unwrap();
1801
1802 let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1803 assert_eq!(outcome.schemas_migrated, 1);
1804 let entries = list_memstead_entries(&gitdir);
1805 assert!(
1806 entries.contains(&"schemas/anonymous@0.0.0/schema.yaml".to_string()),
1807 "missing-version schemas land under @0.0.0; got {entries:?}"
1808 );
1809 }
1810
1811 #[test]
1812 fn read_mem_config_from_memstead_round_trips_after_migration() {
1813 let tmp = TempDir::new().unwrap();
1814 let gitdir = fresh_repo_dir(tmp.path());
1815 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1816 seed_system(
1817 &gitdir,
1818 r#"{"name":"main"}"#,
1819 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1820 );
1821 let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1822
1823 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1824 assert!(config.schema.is_some());
1825 assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1826 }
1827
1828 #[test]
1829 fn read_mem_config_from_memstead_returns_typed_error_for_missing_mem() {
1830 let tmp = TempDir::new().unwrap();
1831 let gitdir = fresh_repo_dir(tmp.path());
1832 seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1833 seed_system(
1834 &gitdir,
1835 r#"{"name":"main"}"#,
1836 &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1837 );
1838 let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1839 match read_mem_config_from_memstead_ref(&gitdir, "nonexistent") {
1840 Err(MemsteadRefError::Config { path, .. }) => {
1841 assert!(path.contains("nonexistent"));
1842 }
1843 other => panic!("expected Config error for missing mem, got {other:?}"),
1844 }
1845 }
1846
1847 #[test]
1848 fn commit_config_to_memstead_creates_ref_when_absent() {
1849 let tmp = TempDir::new().unwrap();
1850 let gitdir = fresh_repo_dir(tmp.path());
1851 let ctx = CommitContext::internal();
1855 commit_config_to_memstead_at_gitdir(
1856 &gitdir,
1857 "alpha",
1858 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1859 &ctx,
1860 "test commit",
1861 )
1862 .unwrap();
1863
1864 let entries = list_memstead_entries(&gitdir);
1865 assert_eq!(entries, vec!["mems/alpha/config.json".to_string()]);
1866
1867 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1868 assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1869 }
1870
1871 #[test]
1872 fn commit_config_to_memstead_overwrites_existing_blob() {
1873 let tmp = TempDir::new().unwrap();
1874 let gitdir = fresh_repo_dir(tmp.path());
1875 let ctx = CommitContext::internal();
1876
1877 commit_config_to_memstead_at_gitdir(
1878 &gitdir,
1879 "alpha",
1880 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1881 &ctx,
1882 "first",
1883 )
1884 .unwrap();
1885 commit_config_to_memstead_at_gitdir(
1886 &gitdir,
1887 "alpha",
1888 br#"{"format": 1, "schema": "default@2.0.0"}"#,
1889 &ctx,
1890 "second",
1891 )
1892 .unwrap();
1893
1894 let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1895 assert_eq!(config.schema.unwrap().to_string(), "default@2.0.0");
1896 }
1897
1898 #[test]
1899 fn commit_config_to_memstead_preserves_sibling_mem_entries() {
1900 let tmp = TempDir::new().unwrap();
1901 let gitdir = fresh_repo_dir(tmp.path());
1902 let ctx = CommitContext::internal();
1903
1904 commit_config_to_memstead_at_gitdir(
1905 &gitdir,
1906 "alpha",
1907 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1908 &ctx,
1909 "alpha",
1910 )
1911 .unwrap();
1912 commit_config_to_memstead_at_gitdir(
1913 &gitdir,
1914 "beta",
1915 br#"{"format": 1, "schema": "default@1.0.0"}"#,
1916 &ctx,
1917 "beta",
1918 )
1919 .unwrap();
1920
1921 let entries = list_memstead_entries(&gitdir);
1922 assert_eq!(
1923 entries,
1924 vec![
1925 "mems/alpha/config.json".to_string(),
1926 "mems/beta/config.json".to_string(),
1927 ]
1928 );
1929 }
1930
1931 #[test]
1932 fn extract_manifest_version_handles_quoted_and_unquoted() {
1933 assert_eq!(
1934 extract_manifest_version("name: foo\nversion: \"1.0.0\"\n"),
1935 Some("1.0.0".to_string())
1936 );
1937 assert_eq!(
1938 extract_manifest_version("name: foo\nversion: 1.0.0\n"),
1939 Some("1.0.0".to_string())
1940 );
1941 assert_eq!(
1942 extract_manifest_version("name: foo\nversion: '0.5.0'\n"),
1943 Some("0.5.0".to_string())
1944 );
1945 assert_eq!(extract_manifest_version("name: foo\n"), None);
1946 assert_eq!(extract_manifest_version(""), None);
1947 }
1948}