Skip to main content

memstead_git_branch/
storage_memstead.rs

1//! Unified `__MEMSTEAD` ref — schemas + per-mem configs in one tree.
2//!
3//! The post-rebuild target collapses today's two registry-class refs
4//! (`__SCHEMAS` for YAMLs, `__SYSTEM` for per-mem configs) onto a
5//! single `__MEMSTEAD` ref with the layout:
6//!
7//! - `__MEMSTEAD:schemas/<name>@<version>/schema.yaml`
8//! - `__MEMSTEAD:schemas/<name>@<version>/types/<type>.yaml`
9//! - `__MEMSTEAD:mems/<mem>/config.json` (and any other per-mem
10//!   blobs that today live under `__SYSTEM:<mem>/`)
11//!
12//! The `repo.json` blob from `__SYSTEM:` does NOT migrate — its
13//! `canonical_name` field projects into the mount record in
14//! `state/mounts.json` (the workspace store carries that today). Old
15//! `__SCHEMAS` and `__SYSTEM` refs are left in place for one release
16//! cycle so operators with an older binary can still read the
17//! workspace.
18//!
19//! ## What this module ships
20//!
21//! - [`migrate_to_memstead_ref`] — read `__SCHEMAS` + `__SYSTEM`, write
22//!   the unified `__MEMSTEAD` ref. Idempotent: re-running against a
23//!   workspace whose `__MEMSTEAD` tree already matches the projection
24//!   produces no new commit.
25//! - [`load_schemas_from_memstead_ref`] — additive reader for the new
26//!   layout. Returns the same [`crate::mem_repo_schemas::LoadOutcome`]
27//!   shape so a future cutover session can drop in the new function
28//!   without touching call sites.
29//! - [`read_mem_config_from_memstead_ref`] — additive reader for the
30//!   per-mem config under the new layout. Returns the same
31//!   [`memstead_schema::MemConfig`] shape the legacy `read_config_at_gitdir`
32//!   produces.
33//!
34//! Existing readers (`mem_repo_schemas::load_schemas_from_ref`,
35//! `mem_repo_config::read_config_at_gitdir`) are NOT touched by this
36//! module. The cutover (replace legacy reads with `__MEMSTEAD` reads + drop
37//! the old refs) is deliberately a separate session — landing the
38//! migration helpers first lets operators upgrade their workspaces
39//! without coupling to the runtime read-path retire.
40
41use 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/// Errors raised while migrating to or reading from the unified
57/// `__MEMSTEAD` ref.
58#[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/// Outcome of [`migrate_to_memstead_ref`].
79#[derive(Debug, Clone)]
80pub struct MemsteadMigrationOutcome {
81    /// Hex commit id of the new `__MEMSTEAD` ref tip after migration.
82    /// Equal to the prior tip when the migration was a no-op (the
83    /// projection matched what `__MEMSTEAD` already carried).
84    pub commit_sha: String,
85    /// `true` when `__MEMSTEAD` already carried the projection — no new
86    /// commit was written.
87    pub already_current: bool,
88    /// Number of schema entries projected from `__SCHEMAS`.
89    pub schemas_migrated: usize,
90    /// Number of per-mem entries projected from `__SYSTEM` (one
91    /// per top-level entry under `__SYSTEM` minus `repo.json`).
92    pub mems_migrated: usize,
93}
94
95/// Read `refs/heads/__SCHEMAS` and `refs/heads/__SYSTEM` from
96/// `gitdir`, build a unified tree under the layout described in this
97/// module's header docs, and write it as `refs/heads/__MEMSTEAD`.
98///
99/// Idempotent: re-running against a workspace whose `__MEMSTEAD` tree
100/// already matches the projection produces no new commit (the
101/// outcome carries `already_current: true` and the existing tip's
102/// SHA).
103///
104/// Empty workspaces (no `__SYSTEM`, no `__SCHEMAS`) write an empty
105/// tree under `__MEMSTEAD`. The runtime treats an empty `__MEMSTEAD` the
106/// same way it treats absent `__SCHEMAS` / `__SYSTEM` — fall through
107/// to legacy fallbacks while the cutover lands.
108pub 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    // 1. Read __SCHEMAS — for each schema directory, parse the
113    //    manifest YAML to extract the version, then plan a write at
114    //    `schemas/<name>@<version>/...` using the original subtree's
115    //    object id (the entire schema directory copies as a tree
116    //    object, byte-identical).
117    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            // Parse `<dir_name>/schema.yaml` to extract the version.
143            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    // 2. Read __SYSTEM — copy every top-level entry except
173    //    `repo.json` into `mems/<entry-name>/...`. Hierarchical
174    //    structures (`<seg>/<seg>/<mem>/config.json`) are
175    //    preserved by copying the subtree's object id verbatim.
176    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                // Workspace-level metadata — projects into mounts.json,
200                // not into __MEMSTEAD.
201                continue;
202            }
203            mem_entries.push((name, entry.oid().to_owned(), entry.mode().kind()));
204        }
205    }
206
207    // 3. Build the unified tree. Use the empty tree as the base and
208    //    upsert each entry at its target path.
209    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    // 4. Idempotency check — if __MEMSTEAD already exists with the same
233    //    tree id, no commit needed.
234    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    // 5. Commit the new tree. Reuse the engine's deterministic
255    //    committer identity (matches every other engine-produced
256    //    commit; humans recognise the "engine" author).
257    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/// Outcome of [`write_schema_to_memstead_ref`].
289#[derive(Debug, Clone)]
290pub struct SchemaWriteOutcome {
291    /// Hex sha of the resulting `__MEMSTEAD` tip commit.
292    pub commit_sha: String,
293    /// `true` when the package was already present byte-for-byte, so no
294    /// new commit was produced (the existing tip is returned).
295    pub already_current: bool,
296}
297
298/// Write a schema package onto the unified `__MEMSTEAD` ref under
299/// `schemas/<name>@<version>/`, committing on top of the current ref tip
300/// (or an empty tree when the ref is absent). `files` are
301/// `(relative-path, bytes)` pairs — e.g. `("schema.yaml", …)`,
302/// `("types/decision.yaml", …)`, `("mem-template.json", …)`. Existing
303/// `schemas/` and `mems/` entries on the ref are preserved (the new
304/// package is upserted into the current tree). Idempotent: re-writing
305/// identical bytes yields the same tree and produces no commit.
306///
307/// This is the git-branch backend's authoring write path — the engine
308/// owns mem-repo state, so this lib function is invoked through the
309/// engine, never by an external consumer directly. Mirrors the
310/// committer identity and idempotency check of [`migrate_to_memstead_ref`].
311pub 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    // Current `__MEMSTEAD` tip (commit id + tree id), if the ref exists.
320    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    // Edit from the current tree so existing schemas/mems survive; an
331    // empty tree when the ref does not exist yet.
332    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    // Idempotency — identical resulting tree, no commit.
363    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
401/// Cheap hand-roll YAML scan: find the top-level `version: <value>`
402/// line and return the value (stripped of quotes / whitespace). Used
403/// only by the migration to label `schemas/<name>@<version>/`; the
404/// canonical schema parse pipeline runs against the original blob
405/// bytes after migration.
406///
407/// Returns `None` when no `version:` field is found at the top level.
408/// Schemas without a manifest version are migrated under `name@0.0.0`
409/// — operators see the placeholder and can re-publish with an
410/// explicit version.
411fn 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
425/// Read schemas from the unified `__MEMSTEAD:schemas/` tree.
426///
427/// Mirrors [`crate::mem_repo_schemas::load_schemas_from_ref`]'s
428/// `LoadOutcome` shape so a future cutover session can drop in the
429/// new function without touching call sites. The shape is the same;
430/// the read source is `refs/heads/__MEMSTEAD` instead of
431/// `refs/heads/__SCHEMAS`.
432pub 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
442/// Gitdir-rooted variant of [`load_schemas_from_memstead_ref`].
443pub 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        // Format generation from the sealed marker's presence —
545        // unmarked packages read with legacy semantics (absent
546        // required/optional key = required), marked ones with the
547        // current rule (absence = optional).
548        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
599/// Read one file from a sealed schema package on the `__MEMSTEAD`
600/// ref: the blob at `schemas/<name>@<version>/<rel>`. Returns
601/// `Ok(None)` when the ref, the package subtree, or the file is
602/// absent — absence is a normal state (e.g. the install-provenance
603/// stamp only exists for path-sourced installs), never an error.
604/// Read-only.
605pub 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
644/// Read a per-mem config from `__MEMSTEAD:mems/<mem>/config.json`.
645///
646/// Mirrors [`crate::mem_repo_config::read_config_at_gitdir`]'s
647/// return shape so callers can swap signatures without touching the
648/// consumer code. Hierarchical mem paths
649/// (`mems/<seg>/<seg>/<mem>/config.json`) resolve via the same
650/// `resolve_full_path_at_gitdir` walker the legacy reader uses — but
651/// rooted under `mems/` rather than the legacy `__SYSTEM:` tree.
652/// When the leaf has no matching content branch yet (the
653/// `mem_management::create_mem` flow before the per-mem commit
654/// lands), the resolver returns `None` and the read falls back to the
655/// flat `mems/<leaf>/config.json` layout — same fallback the write
656/// side uses, so reads observe the just-written blob in either layout.
657pub fn read_mem_config_from_memstead_ref(
658    gitdir: &Path,
659    mem_name: &str,
660) -> Result<MemConfig, MemsteadRefError> {
661    // Resolve the leaf to its full hierarchical tree path. Mirrors
662    // commit_config_to_memstead_at_gitdir on the write side — the write
663    // and read paths use identical resolution so a hierarchical-leaf
664    // read of just-written content lands at the same tree position.
665    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
720/// Commit `mems/<mem_name>/config.json` to
721/// `mem-repo-git:refs/heads/__MEMSTEAD`.
722///
723/// Mirrors [`crate::mem_repo_config::commit_config_at_gitdir`]'s
724/// shape (read-modify-write against the snapshot, atomic ref
725/// advance via [`commit_refs_at_gitdir`]) but targets the unified
726/// `__MEMSTEAD` ref under `mems/<full_tree_path>/config.json` rather
727/// than the legacy `__SYSTEM:<full_tree_path>/config.json`.
728///
729/// Hierarchical paths are resolved with the same
730/// `resolve_full_path_at_gitdir` walker the legacy helper uses; for
731/// callers writing the very first config blob before any per-mem
732/// branch exists (the unified `mem_management::create_mem`
733/// flow), the resolver returns `None` and the function falls back
734/// to the flat `<mem_name>/config.json` shape.
735///
736/// When `__MEMSTEAD` does not yet exist (workspace was never migrated),
737/// the function creates the ref atomically with a `MustNotExist`
738/// precondition — this is race-safe against a sibling writer that
739/// might be performing the migration concurrently.
740pub 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    // Snapshot the existing __MEMSTEAD tip (or absence thereof) so the
766    // ref-edit batch can use the matching precondition.
767    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    // Base tree: existing __MEMSTEAD tree, or the empty tree when the ref
778    // does not yet exist. Either way, upsert the per-mem config blob
779    // and write the resulting tree.
780    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
868/// Commit a batch of tree edits (upsert or remove) to arbitrary paths
869/// on the unified `__MEMSTEAD` ref in one commit. The pipeline-edit
870/// provenance path: `.memstead/` pipeline configs are plain disk JSON
871/// with no commit of their own, so each edit mirrors its config bytes
872/// under `__MEMSTEAD:pipeline/<kind>/<mem>/<name>.json` — the commit
873/// (subject + `Note:` trailer from `ctx`) IS the provenance record;
874/// the disk file stays the read path. Same read-modify-write shape and
875/// atomic ref advance as [`commit_config_to_memstead_at_gitdir`],
876/// generalized to N paths so a rename (remove old + upsert new) lands
877/// as one commit.
878///
879/// `edits`: `(tree_path, Some(bytes))` upserts, `(tree_path, None)`
880/// removes. Missing-`__MEMSTEAD` bootstrap follows the config helper's
881/// race-safe `MustNotExist` creation.
882pub 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
999/// Drop every git-branch artifact the engine wrote for one mem:
1000/// the per-mem content branch (`refs/heads/<branch_leaf>`) and the
1001/// `__MEMSTEAD:mems/<branch_leaf>/config.json` blob (collapsing any
1002/// empty `mems/…` ancestor directories along the way). Symmetric
1003/// counterpart to [`commit_config_to_memstead_at_gitdir`] +
1004/// [`crate::storage::git_tree::GitTreeMemWriter::commit`]'s seed
1005/// commit pair — `memstead_mem_create` writes those two, this helper
1006/// undoes them when `memstead_mem_delete delete_files=true`.
1007///
1008/// `branch_leaf` is the hierarchical branch name (e.g.
1009/// `planning/plan-q4-revamp` or the bare leaf for flat layouts).
1010/// The function does NOT walk the repo to resolve it — the caller
1011/// (the git-branch backend's [`memstead_base::backend::MemBackend::delete_artifacts`]
1012/// impl) has it directly in `self.ref_name` minus the `refs/heads/`
1013/// prefix.
1014///
1015/// Idempotent in both halves:
1016/// - Branch ref delete uses `PreviousValue::Any` so a missing branch
1017///   (sibling engine pruned it, manual surgery) is not an error.
1018/// - `__MEMSTEAD` rewrite is skipped entirely when the ref doesn't exist;
1019///   when it exists but doesn't carry the per-mem entry, the
1020///   helper still writes a no-op commit only if the tree changed
1021///   (the editor's `remove` on a missing path is a no-op there).
1022///
1023/// Both edits land in a single `edit_references` transaction — either
1024/// the branch + `__MEMSTEAD` advance both succeed, or the whole call is
1025/// rejected and no state changed.
1026pub 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    // ---- Step 1: drop the per-mem entry from __MEMSTEAD (if present) ----
1039    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        // Check whether the entry actually exists — skip the commit
1060        // entirely when it doesn't (e.g. a parallel run already
1061        // pruned it). Saves an empty no-op commit.
1062        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            // gix's tree writer prunes empty subtrees on write, so
1076            // removing `mems/<path>/<name>/config.json` collapses
1077            // the now-empty `<path>/<name>/` and any `<path>/`
1078            // ancestors automatically. No manual walk needed.
1079            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    // ---- Step 2: ref-edit batch (branch delete + __MEMSTEAD advance) ----
1122    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            // `Any` keeps the delete idempotent — a missing branch
1131            // (sibling pruned it, manual surgery) is not an error.
1132            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
1169/// Rename a mem's storage identity in one logical operation: the
1170/// content branch `refs/heads/<old_leaf>` moves to
1171/// `refs/heads/<new_leaf>` **at the same tip** (history preserved — a
1172/// ref move, never a fresh seed), and the per-mem config blob moves
1173/// from `__MEMSTEAD:mems/<old_leaf>/config.json` to
1174/// `mems/<new_leaf>/config.json` in a single `__MEMSTEAD` commit. All
1175/// three ref changes (create new branch, delete old branch, advance
1176/// `__MEMSTEAD`) land in one ref-edit transaction, so a crash cannot
1177/// leave the branch half-moved.
1178///
1179/// Refusals (no mutation on any): `refs/heads/<old_leaf>` missing,
1180/// `refs/heads/<new_leaf>` already present.
1181pub 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    // ---- Step 0: resolve both branch refs; refuse before any write ----
1196    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    // ---- Step 1: move the config blob on __MEMSTEAD (if present) ----
1218    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    // ---- Step 2: one ref-edit transaction for all three changes ----
1298    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    /// `(name, version, [(filename, body)])` schema seed tuple.
1385    type SchemaSeed<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]);
1386
1387    /// Build a minimal __SCHEMAS commit with one schema directory
1388    /// `<name>` containing `schema.yaml` (with a `version: <v>` field)
1389    /// and an optional types subtree of `(filename, body)` pairs.
1390    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    /// Build a minimal __SYSTEM commit. `mems` is `(mem_name,
1430    /// config_json)` pairs; `repo_json` is the repo.json blob (or
1431    /// empty to skip).
1432    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    /// Walk the `__MEMSTEAD` tree at `gitdir` and return every
1467    /// (path, blob_oid) entry — used to assert tree shape.
1468    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        // Schemas live under versioned paths.
1537        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        // Mem configs live under mems/.
1541        assert!(entries.contains(&"mems/alpha/config.json".to_string()));
1542        assert!(entries.contains(&"mems/beta/config.json".to_string()));
1543        // repo.json explicitly NOT migrated.
1544        assert!(
1545            !entries.iter().any(|p| p.contains("repo.json")),
1546            "repo.json must not appear under __MEMSTEAD: {entries:?}"
1547        );
1548    }
1549
1550    /// The format marker decides the sealed reader's metadata-polarity
1551    /// generation: the same package sealed WITHOUT the marker reads an
1552    /// absent required/optional key as required (legacy written
1553    /// meaning), and sealed WITH the marker (the install helper) as
1554    /// optional.
1555    #[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        // Unmarked seal (a package sealed before the polarity flip).
1565        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        // Marked seal — the install helper appends the marker.
1584        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        // Write a package onto an absent ref — the ref is created.
1610        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        // Re-writing identical bytes is a no-op — same tip, no new commit.
1630        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        // A second package upserts into the existing tree, preserving the first.
1635        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    /// `read_schema_file_from_memstead_ref` round-trips a file written
1656    /// by `write_schema_to_memstead_ref` (the install-provenance stamp
1657    /// being the consumer), and absence — of the file, the package, or
1658    /// the ref — is `Ok(None)`, never an error.
1659    #[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        // Absent ref → None.
1665        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        // Present file round-trips byte-exact (via the ops hook too).
1682        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        // Absent file in a present package → None; absent package → None.
1701        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        // The engine reaches the ref-write through the
1721        // `GitBranchOps.write_schema` dispatcher; this pins that the const
1722        // is wired to `write_schema_to_memstead_ref` and returns a sha.
1723        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        // Same tip; no new commit was written.
1756        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        // No __SCHEMAS, no __SYSTEM — the migration writes an
1764        // empty __MEMSTEAD tree (the cutover session decides what to
1765        // do with that case).
1766        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        // Schema YAML with no `version:` field.
1778        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        // No __MEMSTEAD ref, no __SYSTEM ref — fresh repo. The helper
1852        // must create __MEMSTEAD from scratch via the MustNotExist
1853        // precondition.
1854        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}