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, resolve_full_path_at_gitdir,
48};
49use crate::mem_repo_schemas::{LoadOutcome, MemRepoSchemasError};
50use crate::vcs::{CommitContext, author_identity, format_commit_message};
51
52const COMMITTER_NAME: &str = "engine";
53const COMMITTER_EMAIL: &str = "noreply@memstead.io";
54
55/// Errors raised while migrating to or reading from the unified
56/// `__MEMSTEAD` ref.
57#[derive(Debug, thiserror::Error)]
58pub enum MemsteadRefError {
59    #[error("could not open mem-repo gitdir: {0}")]
60    GixOpen(String),
61    #[error("git tree read error: {0}")]
62    GitTree(String),
63    #[error("git commit error: {0}")]
64    GitCommit(String),
65    #[error("schema blob {0} is not valid UTF-8: {1}")]
66    NotUtf8(String, String),
67    #[error("schema '{name}': {source}")]
68    Schema {
69        name: String,
70        #[source]
71        source: SchemaLoadError,
72    },
73    #[error("config json error at {path}: {message}")]
74    Config { path: String, message: String },
75}
76
77/// Outcome of [`migrate_to_memstead_ref`].
78#[derive(Debug, Clone)]
79pub struct MemsteadMigrationOutcome {
80    /// Hex commit id of the new `__MEMSTEAD` ref tip after migration.
81    /// Equal to the prior tip when the migration was a no-op (the
82    /// projection matched what `__MEMSTEAD` already carried).
83    pub commit_sha: String,
84    /// `true` when `__MEMSTEAD` already carried the projection — no new
85    /// commit was written.
86    pub already_current: bool,
87    /// Number of schema entries projected from `__SCHEMAS`.
88    pub schemas_migrated: usize,
89    /// Number of per-mem entries projected from `__SYSTEM` (one
90    /// per top-level entry under `__SYSTEM` minus `repo.json`).
91    pub mems_migrated: usize,
92}
93
94/// Read `refs/heads/__SCHEMAS` and `refs/heads/__SYSTEM` from
95/// `gitdir`, build a unified tree under the layout described in this
96/// module's header docs, and write it as `refs/heads/__MEMSTEAD`.
97///
98/// Idempotent: re-running against a workspace whose `__MEMSTEAD` tree
99/// already matches the projection produces no new commit (the
100/// outcome carries `already_current: true` and the existing tip's
101/// SHA).
102///
103/// Empty workspaces (no `__SYSTEM`, no `__SCHEMAS`) write an empty
104/// tree under `__MEMSTEAD`. The runtime treats an empty `__MEMSTEAD` the
105/// same way it treats absent `__SCHEMAS` / `__SYSTEM` — fall through
106/// to legacy fallbacks while the cutover lands.
107pub fn migrate_to_memstead_ref(
108    gitdir: &Path,
109) -> Result<MemsteadMigrationOutcome, MemsteadRefError> {
110    let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
111    // 1. Read __SCHEMAS — for each schema directory, parse the
112    //    manifest YAML to extract the version, then plan a write at
113    //    `schemas/<name>@<version>/...` using the original subtree's
114    //    object id (the entire schema directory copies as a tree
115    //    object, byte-identical).
116    let mut schema_entries: Vec<(String, gix::ObjectId)> = Vec::new();
117    if let Some(reference) = repo
118        .try_find_reference("refs/heads/__SCHEMAS")
119        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
120    {
121        let id = reference
122            .into_fully_peeled_id()
123            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
124        let commit = id
125            .object()
126            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
127            .try_into_commit()
128            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
129        let tree = commit
130            .tree()
131            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
132        for entry_res in tree.iter() {
133            let entry = entry_res.map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
134            if !matches!(entry.mode().kind(), gix::object::tree::EntryKind::Tree) {
135                continue;
136            }
137            let dir_name = match std::str::from_utf8(entry.filename()) {
138                Ok(s) => s.to_string(),
139                Err(_) => continue,
140            };
141            // Parse `<dir_name>/schema.yaml` to extract the version.
142            let subtree = repo
143                .find_object(entry.oid().to_owned())
144                .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
145                .try_into_tree()
146                .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
147            let manifest_entry = subtree
148                .lookup_entry_by_path("schema.yaml")
149                .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
150            let version = match manifest_entry {
151                Some(me) => {
152                    let manifest_obj = me
153                        .object()
154                        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
155                    let yaml = std::str::from_utf8(&manifest_obj.data)
156                        .map_err(|e| {
157                            MemsteadRefError::NotUtf8(
158                                format!("{dir_name}/schema.yaml"),
159                                e.to_string(),
160                            )
161                        })?
162                        .to_string();
163                    extract_manifest_version(&yaml).unwrap_or_else(|| "0.0.0".to_string())
164                }
165                None => "0.0.0".to_string(),
166            };
167            schema_entries.push((format!("{dir_name}@{version}"), entry.oid().to_owned()));
168        }
169    }
170
171    // 2. Read __SYSTEM — copy every top-level entry except
172    //    `repo.json` into `mems/<entry-name>/...`. Hierarchical
173    //    structures (`<seg>/<seg>/<mem>/config.json`) are
174    //    preserved by copying the subtree's object id verbatim.
175    let mut mem_entries: Vec<(String, gix::ObjectId, gix::object::tree::EntryKind)> = Vec::new();
176    if let Some(reference) = repo
177        .try_find_reference("refs/heads/__SYSTEM")
178        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
179    {
180        let id = reference
181            .into_fully_peeled_id()
182            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
183        let commit = id
184            .object()
185            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
186            .try_into_commit()
187            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
188        let tree = commit
189            .tree()
190            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
191        for entry_res in tree.iter() {
192            let entry = entry_res.map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
193            let name = match std::str::from_utf8(entry.filename()) {
194                Ok(s) => s.to_string(),
195                Err(_) => continue,
196            };
197            if name == "repo.json" {
198                // Workspace-level metadata — projects into mounts.json,
199                // not into __MEMSTEAD.
200                continue;
201            }
202            mem_entries.push((name, entry.oid().to_owned(), entry.mode().kind()));
203        }
204    }
205
206    // 3. Build the unified tree. Use the empty tree as the base and
207    //    upsert each entry at its target path.
208    let mut editor = repo
209        .empty_tree()
210        .edit()
211        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
212    for (path_segment, oid) in &schema_entries {
213        editor
214            .upsert(
215                format!("schemas/{path_segment}").as_str(),
216                gix::object::tree::EntryKind::Tree,
217                *oid,
218            )
219            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
220    }
221    for (name, oid, kind) in &mem_entries {
222        editor
223            .upsert(format!("mems/{name}").as_str(), *kind, *oid)
224            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
225    }
226    let new_tree_id = editor
227        .write()
228        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
229        .detach();
230
231    // 4. Idempotency check — if __MEMSTEAD already exists with the same
232    //    tree id, no commit needed.
233    let existing_tip: Option<(gix::ObjectId, gix::ObjectId)> = repo
234        .try_find_reference("refs/heads/__MEMSTEAD")
235        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
236        .and_then(|r| {
237            let id = r.into_fully_peeled_id().ok()?;
238            let commit = id.object().ok()?.try_into_commit().ok()?;
239            let tree_id = commit.tree().ok()?.id;
240            Some((id.detach(), tree_id))
241        });
242    if let Some((tip_id, tree_id)) = existing_tip
243        && tree_id == new_tree_id
244    {
245        return Ok(MemsteadMigrationOutcome {
246            commit_sha: tip_id.to_hex().to_string(),
247            already_current: true,
248            schemas_migrated: schema_entries.len(),
249            mems_migrated: mem_entries.len(),
250        });
251    }
252
253    // 5. Commit the new tree. Reuse the engine's deterministic
254    //    committer identity (matches every other engine-produced
255    //    commit; humans recognise the "engine" author).
256    let time = gix::date::Time::now_local_or_utc();
257    let signature = gix::actor::Signature {
258        name: "engine".into(),
259        email: "noreply@memstead.io".into(),
260        time,
261    };
262    let mut buf = gix::date::parse::TimeBuf::default();
263    let sig_ref = signature.to_ref(&mut buf);
264    let parents: Vec<gix::ObjectId> = match existing_tip {
265        Some((tip, _)) => vec![tip],
266        None => Vec::new(),
267    };
268    let commit_id = repo
269        .commit_as(
270            sig_ref,
271            sig_ref,
272            "refs/heads/__MEMSTEAD",
273            "memstead: storage migration to unified __MEMSTEAD ref",
274            new_tree_id,
275            parents,
276        )
277        .map_err(|e| MemsteadRefError::GitCommit(e.to_string()))?;
278
279    Ok(MemsteadMigrationOutcome {
280        commit_sha: commit_id.to_hex().to_string(),
281        already_current: false,
282        schemas_migrated: schema_entries.len(),
283        mems_migrated: mem_entries.len(),
284    })
285}
286
287/// Outcome of [`write_schema_to_memstead_ref`].
288#[derive(Debug, Clone)]
289pub struct SchemaWriteOutcome {
290    /// Hex sha of the resulting `__MEMSTEAD` tip commit.
291    pub commit_sha: String,
292    /// `true` when the package was already present byte-for-byte, so no
293    /// new commit was produced (the existing tip is returned).
294    pub already_current: bool,
295}
296
297/// Write a schema package onto the unified `__MEMSTEAD` ref under
298/// `schemas/<name>@<version>/`, committing on top of the current ref tip
299/// (or an empty tree when the ref is absent). `files` are
300/// `(relative-path, bytes)` pairs — e.g. `("schema.yaml", …)`,
301/// `("types/decision.yaml", …)`, `("mem-template.json", …)`. Existing
302/// `schemas/` and `mems/` entries on the ref are preserved (the new
303/// package is upserted into the current tree). Idempotent: re-writing
304/// identical bytes yields the same tree and produces no commit.
305///
306/// This is the git-branch backend's authoring write path — the engine
307/// owns mem-repo state, so this lib function is invoked through the
308/// engine, never by an external consumer directly. Mirrors the
309/// committer identity and idempotency check of [`migrate_to_memstead_ref`].
310pub fn write_schema_to_memstead_ref(
311    gitdir: &Path,
312    name: &str,
313    version: &str,
314    files: &[(String, Vec<u8>)],
315) -> Result<SchemaWriteOutcome, MemsteadRefError> {
316    let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
317
318    // Current `__MEMSTEAD` tip (commit id + tree id), if the ref exists.
319    let existing_tip: Option<(gix::ObjectId, gix::ObjectId)> = repo
320        .try_find_reference("refs/heads/__MEMSTEAD")
321        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
322        .and_then(|r| {
323            let id = r.into_fully_peeled_id().ok()?;
324            let commit = id.object().ok()?.try_into_commit().ok()?;
325            let tree_id = commit.tree().ok()?.id;
326            Some((id.detach(), tree_id))
327        });
328
329    // Edit from the current tree so existing schemas/mems survive; an
330    // empty tree when the ref does not exist yet.
331    let base_tree = match existing_tip {
332        Some((_, tree_id)) => repo
333            .find_object(tree_id)
334            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
335            .try_into_tree()
336            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?,
337        None => repo.empty_tree(),
338    };
339    let mut editor = base_tree
340        .edit()
341        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
342    let prefix = format!("schemas/{name}@{version}");
343    for (rel, bytes) in files {
344        let blob_id = repo
345            .write_blob(bytes)
346            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
347            .detach();
348        editor
349            .upsert(
350                format!("{prefix}/{rel}").as_str(),
351                gix::object::tree::EntryKind::Blob,
352                blob_id,
353            )
354            .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
355    }
356    let new_tree_id = editor
357        .write()
358        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
359        .detach();
360
361    // Idempotency — identical resulting tree, no commit.
362    if let Some((tip_id, tree_id)) = existing_tip
363        && tree_id == new_tree_id
364    {
365        return Ok(SchemaWriteOutcome {
366            commit_sha: tip_id.to_hex().to_string(),
367            already_current: true,
368        });
369    }
370
371    let time = gix::date::Time::now_local_or_utc();
372    let signature = gix::actor::Signature {
373        name: "engine".into(),
374        email: "noreply@memstead.io".into(),
375        time,
376    };
377    let mut buf = gix::date::parse::TimeBuf::default();
378    let sig_ref = signature.to_ref(&mut buf);
379    let parents: Vec<gix::ObjectId> = match existing_tip {
380        Some((tip, _)) => vec![tip],
381        None => Vec::new(),
382    };
383    let commit_id = repo
384        .commit_as(
385            sig_ref,
386            sig_ref,
387            "refs/heads/__MEMSTEAD",
388            format!("memstead: install schema {name}@{version}"),
389            new_tree_id,
390            parents,
391        )
392        .map_err(|e| MemsteadRefError::GitCommit(e.to_string()))?;
393
394    Ok(SchemaWriteOutcome {
395        commit_sha: commit_id.to_hex().to_string(),
396        already_current: false,
397    })
398}
399
400/// Cheap hand-roll YAML scan: find the top-level `version: <value>`
401/// line and return the value (stripped of quotes / whitespace). Used
402/// only by the migration to label `schemas/<name>@<version>/`; the
403/// canonical schema parse pipeline runs against the original blob
404/// bytes after migration.
405///
406/// Returns `None` when no `version:` field is found at the top level.
407/// Schemas without a manifest version are migrated under `name@0.0.0`
408/// — operators see the placeholder and can re-publish with an
409/// explicit version.
410fn extract_manifest_version(yaml: &str) -> Option<String> {
411    for line in yaml.lines() {
412        let trimmed = line.trim_start();
413        if let Some(rest) = trimmed.strip_prefix("version:") {
414            let value = rest.trim().trim_matches(|c: char| c == '"' || c == '\'');
415            if value.is_empty() {
416                return None;
417            }
418            return Some(value.to_string());
419        }
420    }
421    None
422}
423
424/// Read schemas from the unified `__MEMSTEAD:schemas/` tree.
425///
426/// Mirrors [`crate::mem_repo_schemas::load_schemas_from_ref`]'s
427/// `LoadOutcome` shape so a future cutover session can drop in the
428/// new function without touching call sites. The shape is the same;
429/// the read source is `refs/heads/__MEMSTEAD` instead of
430/// `refs/heads/__SCHEMAS`.
431pub fn load_schemas_from_memstead_ref(
432    workspace_root: &Path,
433) -> Result<LoadOutcome, MemRepoSchemasError> {
434    let gitdir = workspace_root.join("mem-repo").join(".git");
435    if !gitdir.is_dir() {
436        return Ok(LoadOutcome::NoMemRepo);
437    }
438    load_schemas_from_memstead_ref_at_gitdir(&gitdir)
439}
440
441/// Gitdir-rooted variant of [`load_schemas_from_memstead_ref`].
442pub fn load_schemas_from_memstead_ref_at_gitdir(
443    gitdir: &Path,
444) -> Result<LoadOutcome, MemRepoSchemasError> {
445    let repo = gix::open(gitdir).map_err(|e| MemRepoSchemasError::GixOpen(e.to_string()))?;
446    let memstead_ref = match repo
447        .try_find_reference("refs/heads/__MEMSTEAD")
448        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
449    {
450        Some(r) => r,
451        None => return Ok(LoadOutcome::NoMemRepo),
452    };
453    let id = memstead_ref
454        .into_fully_peeled_id()
455        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
456    let commit = id
457        .object()
458        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
459        .try_into_commit()
460        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
461    let tree = commit
462        .tree()
463        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
464
465    let schemas_entry = match tree
466        .lookup_entry_by_path("schemas")
467        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
468    {
469        Some(e) if e.mode().is_tree() => e,
470        _ => return Ok(LoadOutcome::NoSchemas),
471    };
472    let schemas_tree = schemas_entry
473        .object()
474        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
475        .try_into_tree()
476        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
477
478    let mut entries: Vec<(String, gix::ObjectId, gix::object::tree::EntryKind)> = Vec::new();
479    for entry_res in schemas_tree.iter() {
480        let entry = entry_res.map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
481        let name = match std::str::from_utf8(entry.filename()) {
482            Ok(s) => s.to_string(),
483            Err(_) => continue,
484        };
485        entries.push((name, entry.oid().to_owned(), entry.mode().kind()));
486    }
487    entries.sort_by(|a, b| a.0.cmp(&b.0));
488
489    let mut out: Vec<Arc<Schema>> = Vec::new();
490    for (versioned_name, oid, kind) in entries {
491        if !matches!(kind, gix::object::tree::EntryKind::Tree) {
492            continue;
493        }
494        let schema_obj = repo
495            .find_object(oid)
496            .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
497        let schema_tree = schema_obj
498            .try_into_tree()
499            .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
500
501        let manifest_yaml =
502            read_blob_string_for_schemas(&repo, &schema_tree, "schema.yaml", &versioned_name)?;
503        let mut types_yamls: Vec<(String, String)> = Vec::new();
504        if let Some(types_entry) = schema_tree
505            .lookup_entry_by_path("types")
506            .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
507            && types_entry.mode().is_tree()
508        {
509            let types_obj = types_entry
510                .object()
511                .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
512            let types_tree = types_obj
513                .try_into_tree()
514                .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
515            for entry_res in types_tree.iter() {
516                let entry = entry_res.map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
517                if !entry.mode().is_blob() {
518                    continue;
519                }
520                let filename = match std::str::from_utf8(entry.filename()) {
521                    Ok(s) => s,
522                    Err(_) => continue,
523                };
524                let stem = match filename.strip_suffix(".yaml") {
525                    Some(s) => s.to_string(),
526                    None => continue,
527                };
528                let blob = repo
529                    .find_object(entry.oid().to_owned())
530                    .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
531                let bytes = blob.data.clone();
532                let contents = String::from_utf8(bytes).map_err(|e| {
533                    MemRepoSchemasError::NotUtf8(
534                        format!("{versioned_name}/types/{filename}"),
535                        e.to_string(),
536                    )
537                })?;
538                types_yamls.push((stem, contents));
539            }
540            types_yamls.sort_by(|a, b| a.0.cmp(&b.0));
541        }
542
543        let schema = memstead_schema::loader::load_schema_from_memory(&manifest_yaml, &types_yamls)
544            .map_err(|source| MemRepoSchemasError::Schema {
545                name: versioned_name.clone(),
546                source,
547            })?;
548        out.push(Arc::new(schema));
549    }
550
551    if out.is_empty() {
552        Ok(LoadOutcome::NoSchemas)
553    } else {
554        Ok(LoadOutcome::Schemas(out))
555    }
556}
557
558fn read_blob_string_for_schemas(
559    _repo: &gix::Repository,
560    schema_tree: &gix::Tree<'_>,
561    filename: &str,
562    schema_name: &str,
563) -> Result<String, MemRepoSchemasError> {
564    let entry = schema_tree
565        .lookup_entry_by_path(filename)
566        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?
567        .ok_or_else(|| {
568            MemRepoSchemasError::GitTree(format!(
569                "schema '{schema_name}': missing {filename} in __MEMSTEAD"
570            ))
571        })?;
572    let object = entry
573        .object()
574        .map_err(|e| MemRepoSchemasError::GitTree(e.to_string()))?;
575    let bytes = object.data.clone();
576    String::from_utf8(bytes).map_err(|e| {
577        MemRepoSchemasError::NotUtf8(format!("{schema_name}/{filename}"), e.to_string())
578    })
579}
580
581/// Read a per-mem config from `__MEMSTEAD:mems/<mem>/config.json`.
582///
583/// Mirrors [`crate::mem_repo_config::read_config_at_gitdir`]'s
584/// return shape so callers can swap signatures without touching the
585/// consumer code. Hierarchical mem paths
586/// (`mems/<seg>/<seg>/<mem>/config.json`) resolve via the same
587/// `resolve_full_path_at_gitdir` walker the legacy reader uses — but
588/// rooted under `mems/` rather than the legacy `__SYSTEM:` tree.
589/// When the leaf has no matching content branch yet (the
590/// `mem_management::create_mem` flow before the per-mem commit
591/// lands), the resolver returns `None` and the read falls back to the
592/// flat `mems/<leaf>/config.json` layout — same fallback the write
593/// side uses, so reads observe the just-written blob in either layout.
594pub fn read_mem_config_from_memstead_ref(
595    gitdir: &Path,
596    mem_name: &str,
597) -> Result<MemConfig, MemsteadRefError> {
598    // Resolve the leaf to its full hierarchical tree path. Mirrors
599    // commit_config_to_memstead_at_gitdir on the write side — the write
600    // and read paths use identical resolution so a hierarchical-leaf
601    // read of just-written content lands at the same tree position.
602    let full_tree_path = match resolve_full_path_at_gitdir(gitdir, mem_name) {
603        Ok(Some(p)) => p,
604        Ok(None) => mem_name.to_string(),
605        Err(e) => {
606            return Err(MemsteadRefError::GitTree(e.to_string()));
607        }
608    };
609
610    let repo = gix::open(gitdir).map_err(|e| MemsteadRefError::GixOpen(e.to_string()))?;
611    let memstead_ref = repo
612        .try_find_reference("refs/heads/__MEMSTEAD")
613        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
614        .ok_or_else(|| MemsteadRefError::Config {
615            path: "refs/heads/__MEMSTEAD".to_string(),
616            message: "ref not found".to_string(),
617        })?;
618    let id = memstead_ref
619        .into_fully_peeled_id()
620        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
621    let commit = id
622        .object()
623        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
624        .try_into_commit()
625        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
626    let tree = commit
627        .tree()
628        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
629
630    let path = format!("mems/{full_tree_path}/config.json");
631    let entry = tree
632        .lookup_entry_by_path(&path)
633        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?
634        .ok_or_else(|| MemsteadRefError::Config {
635            path: path.clone(),
636            message: "config not found in __MEMSTEAD tree".to_string(),
637        })?;
638    let object = entry
639        .object()
640        .map_err(|e| MemsteadRefError::GitTree(e.to_string()))?;
641    let bytes = object.data.clone();
642    let raw = String::from_utf8(bytes).map_err(|e| MemsteadRefError::Config {
643        path: path.clone(),
644        message: format!("not utf-8: {e}"),
645    })?;
646    let value: serde_json::Value =
647        serde_json::from_str(&raw).map_err(|e| MemsteadRefError::Config {
648            path: path.clone(),
649            message: format!("invalid json: {e}"),
650        })?;
651    memstead_schema::parse_mem_config(&value).map_err(|e| MemsteadRefError::Config {
652        path,
653        message: e.to_string(),
654    })
655}
656
657/// Commit `mems/<mem_name>/config.json` to
658/// `mem-repo-git:refs/heads/__MEMSTEAD`.
659///
660/// Mirrors [`crate::mem_repo_config::commit_config_at_gitdir`]'s
661/// shape (read-modify-write against the snapshot, atomic ref
662/// advance via [`commit_refs_at_gitdir`]) but targets the unified
663/// `__MEMSTEAD` ref under `mems/<full_tree_path>/config.json` rather
664/// than the legacy `__SYSTEM:<full_tree_path>/config.json`.
665///
666/// Hierarchical paths are resolved with the same
667/// `resolve_full_path_at_gitdir` walker the legacy helper uses; for
668/// callers writing the very first config blob before any per-mem
669/// branch exists (the unified `mem_management::create_mem`
670/// flow), the resolver returns `None` and the function falls back
671/// to the flat `<mem_name>/config.json` shape.
672///
673/// When `__MEMSTEAD` does not yet exist (workspace was never migrated),
674/// the function creates the ref atomically with a `MustNotExist`
675/// precondition — this is race-safe against a sibling writer that
676/// might be performing the migration concurrently.
677pub fn commit_config_to_memstead_at_gitdir(
678    gitdir: &Path,
679    mem_name: &str,
680    config_bytes: &[u8],
681    ctx: &CommitContext<'_>,
682    message: &str,
683) -> Result<(), MemRepoWriteError> {
684    let full_tree_path = match resolve_full_path_at_gitdir(gitdir, mem_name) {
685        Ok(Some(p)) => p,
686        Ok(None) => mem_name.to_string(),
687        Err(crate::mem_repo_config::MemRepoConfigError::GitdirNotFound(p)) => {
688            return Err(MemRepoWriteError::GixOpen {
689                path: p,
690                message: "mem-repo gitdir not found".to_string(),
691            });
692        }
693        Err(e) => {
694            return Err(MemRepoWriteError::GitTree(e.to_string()));
695        }
696    };
697
698    let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
699        path: gitdir.display().to_string(),
700        message: e.to_string(),
701    })?;
702    // Snapshot the existing __MEMSTEAD tip (or absence thereof) so the
703    // ref-edit batch can use the matching precondition.
704    let existing_tip: Option<gix::ObjectId> = repo
705        .try_find_reference("refs/heads/__MEMSTEAD")
706        .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
707        .map(|r| {
708            r.into_fully_peeled_id()
709                .map(|id| id.detach())
710                .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
711        })
712        .transpose()?;
713
714    // Base tree: existing __MEMSTEAD tree, or the empty tree when the ref
715    // does not yet exist. Either way, upsert the per-mem config blob
716    // and write the resulting tree.
717    let mut editor = match existing_tip {
718        Some(tip) => {
719            let commit = repo
720                .find_object(tip)
721                .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
722                .into_commit();
723            let tree = commit
724                .tree()
725                .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
726            tree.edit().map_err(|e| {
727                MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
728            })?
729        }
730        None => repo.empty_tree().edit().map_err(|e| {
731            MemRepoWriteError::GitTree(format!("editor init (empty) for __MEMSTEAD: {e}"))
732        })?,
733    };
734    let blob_id = repo
735        .write_blob(config_bytes)
736        .map_err(|e| MemRepoWriteError::GitTree(format!("write config blob: {e}")))?
737        .detach();
738    let tree_path = format!("mems/{full_tree_path}/config.json");
739    editor
740        .upsert(
741            tree_path.as_str(),
742            gix::objs::tree::EntryKind::Blob,
743            blob_id,
744        )
745        .map_err(|e| MemRepoWriteError::GitTree(format!("tree upsert {tree_path}: {e}")))?;
746    let new_tree_id = editor
747        .write()
748        .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
749        .detach();
750
751    let time = gix::date::Time::now_local_or_utc();
752    let committer_sig = gix::actor::Signature {
753        name: COMMITTER_NAME.into(),
754        email: COMMITTER_EMAIL.into(),
755        time,
756    };
757    let author_sig = match author_identity(ctx) {
758        Some((name, email)) => gix::actor::Signature {
759            name: name.into(),
760            email: email.into(),
761            time,
762        },
763        None => committer_sig.clone(),
764    };
765
766    let full_message = format_commit_message(message, ctx);
767
768    let parents: Vec<gix::ObjectId> = match existing_tip {
769        Some(tip) => vec![tip],
770        None => Vec::new(),
771    };
772    let commit = gix::objs::Commit {
773        message: full_message.into(),
774        tree: new_tree_id,
775        author: author_sig,
776        committer: committer_sig,
777        encoding: None,
778        parents: parents.into_iter().collect(),
779        extra_headers: Default::default(),
780    };
781    let new_commit_id = repo
782        .write_object(&commit)
783        .map_err(|e| MemRepoWriteError::GitTree(format!("write {tree_path} commit: {e}")))?
784        .detach();
785
786    let expected = match existing_tip {
787        Some(tip) => {
788            gix::refs::transaction::PreviousValue::MustExistAndMatch(gix::refs::Target::Object(tip))
789        }
790        None => gix::refs::transaction::PreviousValue::MustNotExist,
791    };
792    commit_refs_at_gitdir(
793        gitdir,
794        &[RefSpec {
795            ref_name: "refs/heads/__MEMSTEAD".to_string(),
796            new_oid: new_commit_id,
797            expected,
798            log_message: format!("memstead: commit __MEMSTEAD:{tree_path}"),
799        }],
800    )?;
801
802    Ok(())
803}
804
805/// Drop every git-branch artifact the engine wrote for one mem:
806/// the per-mem content branch (`refs/heads/<branch_leaf>`) and the
807/// `__MEMSTEAD:mems/<branch_leaf>/config.json` blob (collapsing any
808/// empty `mems/…` ancestor directories along the way). Symmetric
809/// counterpart to [`commit_config_to_memstead_at_gitdir`] +
810/// [`crate::storage::git_tree::GitTreeMemWriter::commit`]'s seed
811/// commit pair — `memstead_mem_create` writes those two, this helper
812/// undoes them when `memstead_mem_delete delete_files=true`.
813///
814/// `branch_leaf` is the hierarchical branch name (e.g.
815/// `planning/plan-q4-revamp` or the bare leaf for flat layouts).
816/// The function does NOT walk the repo to resolve it — the caller
817/// (the git-branch backend's [`memstead_base::backend::MemBackend::delete_artifacts`]
818/// impl) has it directly in `self.ref_name` minus the `refs/heads/`
819/// prefix.
820///
821/// Idempotent in both halves:
822/// - Branch ref delete uses `PreviousValue::Any` so a missing branch
823///   (sibling engine pruned it, manual surgery) is not an error.
824/// - `__MEMSTEAD` rewrite is skipped entirely when the ref doesn't exist;
825///   when it exists but doesn't carry the per-mem entry, the
826///   helper still writes a no-op commit only if the tree changed
827///   (the editor's `remove` on a missing path is a no-op there).
828///
829/// Both edits land in a single `edit_references` transaction — either
830/// the branch + `__MEMSTEAD` advance both succeed, or the whole call is
831/// rejected and no state changed.
832pub fn delete_mem_artifacts_at_gitdir(
833    gitdir: &Path,
834    branch_leaf: &str,
835    ctx: &CommitContext<'_>,
836) -> Result<(), MemRepoWriteError> {
837    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
838    use gix::refs::{FullName, Target};
839
840    let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
841        path: gitdir.display().to_string(),
842        message: e.to_string(),
843    })?;
844    // ---- Step 1: drop the per-mem entry from __MEMSTEAD (if present) ----
845    let existing_memstead_tip: Option<gix::ObjectId> = repo
846        .try_find_reference("refs/heads/__MEMSTEAD")
847        .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
848        .map(|r| {
849            r.into_fully_peeled_id()
850                .map(|id| id.detach())
851                .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
852        })
853        .transpose()?;
854
855    let mut new_memstead_commit: Option<gix::ObjectId> = None;
856    if let Some(tip) = existing_memstead_tip {
857        let commit = repo
858            .find_object(tip)
859            .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
860            .into_commit();
861        let tree = commit
862            .tree()
863            .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
864
865        // Check whether the entry actually exists — skip the commit
866        // entirely when it doesn't (e.g. a parallel run already
867        // pruned it). Saves an empty no-op commit.
868        let tree_path = format!("mems/{branch_leaf}/config.json");
869        let entry_present = tree
870            .lookup_entry_by_path(&tree_path)
871            .map_err(|e| MemRepoWriteError::GitTree(format!("lookup {tree_path}: {e}")))?
872            .is_some();
873
874        if entry_present {
875            let mut editor = tree.edit().map_err(|e| {
876                MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
877            })?;
878            editor
879                .remove(tree_path.as_str())
880                .map_err(|e| MemRepoWriteError::GitTree(format!("tree remove {tree_path}: {e}")))?;
881            // gix's tree writer prunes empty subtrees on write, so
882            // removing `mems/<path>/<name>/config.json` collapses
883            // the now-empty `<path>/<name>/` and any `<path>/`
884            // ancestors automatically. No manual walk needed.
885            let new_tree_id = editor
886                .write()
887                .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
888                .detach();
889
890            let time = gix::date::Time::now_local_or_utc();
891            let committer_sig = gix::actor::Signature {
892                name: COMMITTER_NAME.into(),
893                email: COMMITTER_EMAIL.into(),
894                time,
895            };
896            let author_sig = match author_identity(ctx) {
897                Some((name, email)) => gix::actor::Signature {
898                    name: name.into(),
899                    email: email.into(),
900                    time,
901                },
902                None => committer_sig.clone(),
903            };
904            let full_message = format_commit_message(
905                &format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json"),
906                ctx,
907            );
908            let commit_obj = gix::objs::Commit {
909                message: full_message.into(),
910                tree: new_tree_id,
911                author: author_sig,
912                committer: committer_sig,
913                encoding: None,
914                parents: vec![tip].into_iter().collect(),
915                extra_headers: Default::default(),
916            };
917            let new_commit_id = repo
918                .write_object(&commit_obj)
919                .map_err(|e| {
920                    MemRepoWriteError::GitTree(format!("write __MEMSTEAD prune commit: {e}"))
921                })?
922                .detach();
923            new_memstead_commit = Some(new_commit_id);
924        }
925    }
926
927    // ---- Step 2: ref-edit batch (branch delete + __MEMSTEAD advance) ----
928    let mut edits: Vec<RefEdit> = Vec::with_capacity(2);
929
930    let branch_ref = format!("refs/heads/{branch_leaf}");
931    let branch_full: FullName = branch_ref.as_str().try_into().map_err(|e| {
932        MemRepoWriteError::RefTransaction(format!("invalid branch ref {branch_ref:?}: {e}"))
933    })?;
934    edits.push(RefEdit {
935        change: Change::Delete {
936            // `Any` keeps the delete idempotent — a missing branch
937            // (sibling pruned it, manual surgery) is not an error.
938            expected: PreviousValue::Any,
939            log: RefLog::AndReference,
940        },
941        name: branch_full,
942        deref: false,
943    });
944
945    if let (Some(prior_tip), Some(new_commit)) = (existing_memstead_tip, new_memstead_commit) {
946        let memstead_full: FullName = "refs/heads/__MEMSTEAD".try_into().map_err(|e| {
947            MemRepoWriteError::RefTransaction(format!("invalid __MEMSTEAD ref: {e}"))
948        })?;
949        edits.push(RefEdit {
950            change: Change::Update {
951                log: LogChange {
952                    mode: RefLog::AndReference,
953                    force_create_reflog: false,
954                    message: format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json")
955                        .as_str()
956                        .into(),
957                },
958                expected: PreviousValue::MustExistAndMatch(Target::Object(prior_tip)),
959                new: Target::Object(new_commit),
960            },
961            name: memstead_full,
962            deref: false,
963        });
964    }
965
966    repo.edit_references(edits)
967        .map_err(|e| MemRepoWriteError::RefTransaction(e.to_string()))?;
968
969    Ok(())
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975    use std::path::PathBuf;
976    use tempfile::TempDir;
977
978    fn fresh_repo_dir(tmp: &Path) -> PathBuf {
979        let git_dir = tmp.join("mem-repo.git");
980        gix::init_bare(&git_dir).unwrap();
981        std::fs::canonicalize(&git_dir).unwrap()
982    }
983
984    fn actor_for_test() -> gix::actor::Signature {
985        gix::actor::Signature {
986            name: "test".into(),
987            email: "test@example.com".into(),
988            time: gix::date::Time {
989                seconds: 0,
990                offset: 0,
991            },
992        }
993    }
994
995    /// `(name, version, [(filename, body)])` schema seed tuple.
996    type SchemaSeed<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]);
997
998    /// Build a minimal __SCHEMAS commit with one schema directory
999    /// `<name>` containing `schema.yaml` (with a `version: <v>` field)
1000    /// and an optional types subtree of `(filename, body)` pairs.
1001    fn seed_schemas(gitdir: &Path, schemas: &[SchemaSeed<'_>]) {
1002        let repo = gix::open(gitdir).unwrap();
1003        let actor = actor_for_test();
1004        let mut buf = gix::date::parse::TimeBuf::default();
1005        let sig_ref = actor.to_ref(&mut buf);
1006        let mut editor = repo.empty_tree().edit().unwrap();
1007        for (name, version, types) in schemas {
1008            let manifest = format!("name: {name}\nversion: \"{version}\"\n");
1009            let manifest_blob = repo.write_blob(manifest.as_bytes()).unwrap().detach();
1010            editor
1011                .upsert(
1012                    format!("{name}/schema.yaml"),
1013                    gix::object::tree::EntryKind::Blob,
1014                    manifest_blob,
1015                )
1016                .unwrap();
1017            for (type_name, type_body) in *types {
1018                let blob = repo.write_blob(type_body.as_bytes()).unwrap().detach();
1019                editor
1020                    .upsert(
1021                        format!("{name}/types/{type_name}.yaml"),
1022                        gix::object::tree::EntryKind::Blob,
1023                        blob,
1024                    )
1025                    .unwrap();
1026            }
1027        }
1028        let tree_id = editor.write().unwrap().detach();
1029        repo.commit_as(
1030            sig_ref,
1031            sig_ref,
1032            "refs/heads/__SCHEMAS",
1033            "seed __SCHEMAS",
1034            tree_id,
1035            Vec::<gix::ObjectId>::new(),
1036        )
1037        .unwrap();
1038    }
1039
1040    /// Build a minimal __SYSTEM commit. `mems` is `(mem_name,
1041    /// config_json)` pairs; `repo_json` is the repo.json blob (or
1042    /// empty to skip).
1043    fn seed_system(gitdir: &Path, repo_json: &str, mems: &[(&str, &str)]) {
1044        let repo = gix::open(gitdir).unwrap();
1045        let actor = actor_for_test();
1046        let mut buf = gix::date::parse::TimeBuf::default();
1047        let sig_ref = actor.to_ref(&mut buf);
1048        let mut editor = repo.empty_tree().edit().unwrap();
1049        if !repo_json.is_empty() {
1050            let blob = repo.write_blob(repo_json.as_bytes()).unwrap().detach();
1051            editor
1052                .upsert("repo.json", gix::object::tree::EntryKind::Blob, blob)
1053                .unwrap();
1054        }
1055        for (mem, config) in mems {
1056            let blob = repo.write_blob(config.as_bytes()).unwrap().detach();
1057            editor
1058                .upsert(
1059                    format!("{mem}/config.json"),
1060                    gix::object::tree::EntryKind::Blob,
1061                    blob,
1062                )
1063                .unwrap();
1064        }
1065        let tree_id = editor.write().unwrap().detach();
1066        repo.commit_as(
1067            sig_ref,
1068            sig_ref,
1069            "refs/heads/__SYSTEM",
1070            "seed __SYSTEM",
1071            tree_id,
1072            Vec::<gix::ObjectId>::new(),
1073        )
1074        .unwrap();
1075    }
1076
1077    /// Walk the `__MEMSTEAD` tree at `gitdir` and return every
1078    /// (path, blob_oid) entry — used to assert tree shape.
1079    fn list_memstead_entries(gitdir: &Path) -> Vec<String> {
1080        let repo = gix::open(gitdir).unwrap();
1081        let reference = repo
1082            .try_find_reference("refs/heads/__MEMSTEAD")
1083            .unwrap()
1084            .unwrap();
1085        let id = reference.into_fully_peeled_id().unwrap();
1086        let commit = id.object().unwrap().try_into_commit().unwrap();
1087        let tree = commit.tree().unwrap();
1088        let mut out: Vec<String> = Vec::new();
1089        walk(&repo, &tree, "", &mut out);
1090        out.sort();
1091        out
1092    }
1093
1094    fn walk(repo: &gix::Repository, tree: &gix::Tree<'_>, prefix: &str, out: &mut Vec<String>) {
1095        for entry in tree.iter().flatten() {
1096            let name = std::str::from_utf8(entry.filename())
1097                .unwrap_or("")
1098                .to_string();
1099            let path = if prefix.is_empty() {
1100                name.clone()
1101            } else {
1102                format!("{prefix}/{name}")
1103            };
1104            match entry.mode().kind() {
1105                gix::object::tree::EntryKind::Tree => {
1106                    let subtree = repo
1107                        .find_object(entry.oid().to_owned())
1108                        .unwrap()
1109                        .into_tree();
1110                    walk(repo, &subtree, &path, out);
1111                }
1112                gix::object::tree::EntryKind::Blob
1113                | gix::object::tree::EntryKind::BlobExecutable => {
1114                    out.push(path);
1115                }
1116                _ => {}
1117            }
1118        }
1119    }
1120
1121    #[test]
1122    fn migrate_writes_unified_tree_from_schemas_and_system() {
1123        let tmp = TempDir::new().unwrap();
1124        let gitdir = fresh_repo_dir(tmp.path());
1125        seed_schemas(
1126            &gitdir,
1127            &[
1128                ("default", "1.0.0", &[("spec", "name: spec\n")]),
1129                ("custom", "0.5.0", &[]),
1130            ],
1131        );
1132        seed_system(
1133            &gitdir,
1134            r#"{"name":"main"}"#,
1135            &[
1136                ("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#),
1137                ("beta", r#"{"format": 1, "schema": "custom@0.5.0"}"#),
1138            ],
1139        );
1140
1141        let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1142        assert_eq!(outcome.schemas_migrated, 2);
1143        assert_eq!(outcome.mems_migrated, 2);
1144        assert!(!outcome.already_current);
1145
1146        let entries = list_memstead_entries(&gitdir);
1147        // Schemas live under versioned paths.
1148        assert!(entries.contains(&"schemas/default@1.0.0/schema.yaml".to_string()));
1149        assert!(entries.contains(&"schemas/default@1.0.0/types/spec.yaml".to_string()));
1150        assert!(entries.contains(&"schemas/custom@0.5.0/schema.yaml".to_string()));
1151        // Mem configs live under mems/.
1152        assert!(entries.contains(&"mems/alpha/config.json".to_string()));
1153        assert!(entries.contains(&"mems/beta/config.json".to_string()));
1154        // repo.json explicitly NOT migrated.
1155        assert!(
1156            !entries.iter().any(|p| p.contains("repo.json")),
1157            "repo.json must not appear under __MEMSTEAD: {entries:?}"
1158        );
1159    }
1160
1161    #[test]
1162    fn write_schema_to_memstead_ref_adds_package_idempotent_and_preserves_others() {
1163        let tmp = TempDir::new().unwrap();
1164        let gitdir = fresh_repo_dir(tmp.path());
1165
1166        // Write a package onto an absent ref — the ref is created.
1167        let tiny = vec![
1168            (
1169                "schema.yaml".to_string(),
1170                b"name: tiny\nversion: 0.1.0\n".to_vec(),
1171            ),
1172            ("types/doc.yaml".to_string(), b"name: doc\n".to_vec()),
1173            ("mem-template.json".to_string(), b"{}\n".to_vec()),
1174        ];
1175        let out = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1176        assert!(!out.already_current);
1177        let entries = list_memstead_entries(&gitdir);
1178        for p in [
1179            "schemas/tiny@0.1.0/schema.yaml",
1180            "schemas/tiny@0.1.0/types/doc.yaml",
1181            "schemas/tiny@0.1.0/mem-template.json",
1182        ] {
1183            assert!(entries.iter().any(|e| e == p), "missing {p}: {entries:?}");
1184        }
1185
1186        // Re-writing identical bytes is a no-op — same tip, no new commit.
1187        let again = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1188        assert!(again.already_current);
1189        assert_eq!(out.commit_sha, again.commit_sha);
1190
1191        // A second package upserts into the existing tree, preserving the first.
1192        let other = vec![(
1193            "schema.yaml".to_string(),
1194            b"name: other\nversion: 2.0.0\n".to_vec(),
1195        )];
1196        let out2 = write_schema_to_memstead_ref(&gitdir, "other", "2.0.0", &other).unwrap();
1197        assert!(!out2.already_current);
1198        assert_ne!(out2.commit_sha, out.commit_sha);
1199        let entries2 = list_memstead_entries(&gitdir);
1200        assert!(
1201            entries2
1202                .iter()
1203                .any(|e| e == "schemas/tiny@0.1.0/schema.yaml")
1204        );
1205        assert!(
1206            entries2
1207                .iter()
1208                .any(|e| e == "schemas/other@2.0.0/schema.yaml")
1209        );
1210    }
1211
1212    #[test]
1213    fn pro_git_branch_ops_write_schema_hook_writes_to_ref() {
1214        // The engine reaches the ref-write through the
1215        // `GitBranchOps.write_schema` dispatcher; this pins that the const
1216        // is wired to `write_schema_to_memstead_ref` and returns a sha.
1217        let tmp = TempDir::new().unwrap();
1218        let gitdir = fresh_repo_dir(tmp.path());
1219        let files = vec![(
1220            "schema.yaml".to_string(),
1221            b"name: h\nversion: 1.0.0\n".to_vec(),
1222        )];
1223        let commit =
1224            (crate::storage::FULL_GIT_BRANCH_OPS.write_schema)(&gitdir, "h", "1.0.0", &files)
1225                .expect("hook writes the package");
1226        assert!(!commit.is_empty());
1227        let entries = list_memstead_entries(&gitdir);
1228        assert!(
1229            entries.iter().any(|e| e == "schemas/h@1.0.0/schema.yaml"),
1230            "package must land on the ref: {entries:?}"
1231        );
1232    }
1233
1234    #[test]
1235    fn migrate_is_idempotent() {
1236        let tmp = TempDir::new().unwrap();
1237        let gitdir = fresh_repo_dir(tmp.path());
1238        seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1239        seed_system(
1240            &gitdir,
1241            r#"{"name":"main"}"#,
1242            &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1243        );
1244
1245        let first = migrate_to_memstead_ref(&gitdir).unwrap();
1246        assert!(!first.already_current);
1247        let second = migrate_to_memstead_ref(&gitdir).unwrap();
1248        assert!(second.already_current);
1249        // Same tip; no new commit was written.
1250        assert_eq!(first.commit_sha, second.commit_sha);
1251    }
1252
1253    #[test]
1254    fn migrate_with_empty_workspace_writes_empty_tree() {
1255        let tmp = TempDir::new().unwrap();
1256        let gitdir = fresh_repo_dir(tmp.path());
1257        // No __SCHEMAS, no __SYSTEM — the migration writes an
1258        // empty __MEMSTEAD tree (the cutover session decides what to
1259        // do with that case).
1260        let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1261        assert_eq!(outcome.schemas_migrated, 0);
1262        assert_eq!(outcome.mems_migrated, 0);
1263        let entries = list_memstead_entries(&gitdir);
1264        assert!(entries.is_empty());
1265    }
1266
1267    #[test]
1268    fn migrate_handles_missing_version_with_placeholder() {
1269        let tmp = TempDir::new().unwrap();
1270        let gitdir = fresh_repo_dir(tmp.path());
1271        // Schema YAML with no `version:` field.
1272        let repo = gix::open(&gitdir).unwrap();
1273        let actor = actor_for_test();
1274        let mut buf = gix::date::parse::TimeBuf::default();
1275        let sig_ref = actor.to_ref(&mut buf);
1276        let mut editor = repo.empty_tree().edit().unwrap();
1277        let blob = repo.write_blob(b"name: anonymous\n").unwrap().detach();
1278        editor
1279            .upsert(
1280                "anonymous/schema.yaml",
1281                gix::object::tree::EntryKind::Blob,
1282                blob,
1283            )
1284            .unwrap();
1285        let tree_id = editor.write().unwrap().detach();
1286        repo.commit_as(
1287            sig_ref,
1288            sig_ref,
1289            "refs/heads/__SCHEMAS",
1290            "seed",
1291            tree_id,
1292            Vec::<gix::ObjectId>::new(),
1293        )
1294        .unwrap();
1295
1296        let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1297        assert_eq!(outcome.schemas_migrated, 1);
1298        let entries = list_memstead_entries(&gitdir);
1299        assert!(
1300            entries.contains(&"schemas/anonymous@0.0.0/schema.yaml".to_string()),
1301            "missing-version schemas land under @0.0.0; got {entries:?}"
1302        );
1303    }
1304
1305    #[test]
1306    fn read_mem_config_from_memstead_round_trips_after_migration() {
1307        let tmp = TempDir::new().unwrap();
1308        let gitdir = fresh_repo_dir(tmp.path());
1309        seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1310        seed_system(
1311            &gitdir,
1312            r#"{"name":"main"}"#,
1313            &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1314        );
1315        let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1316
1317        let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1318        assert!(config.schema.is_some());
1319        assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1320    }
1321
1322    #[test]
1323    fn read_mem_config_from_memstead_returns_typed_error_for_missing_mem() {
1324        let tmp = TempDir::new().unwrap();
1325        let gitdir = fresh_repo_dir(tmp.path());
1326        seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1327        seed_system(
1328            &gitdir,
1329            r#"{"name":"main"}"#,
1330            &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1331        );
1332        let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1333        match read_mem_config_from_memstead_ref(&gitdir, "nonexistent") {
1334            Err(MemsteadRefError::Config { path, .. }) => {
1335                assert!(path.contains("nonexistent"));
1336            }
1337            other => panic!("expected Config error for missing mem, got {other:?}"),
1338        }
1339    }
1340
1341    #[test]
1342    fn commit_config_to_memstead_creates_ref_when_absent() {
1343        let tmp = TempDir::new().unwrap();
1344        let gitdir = fresh_repo_dir(tmp.path());
1345        // No __MEMSTEAD ref, no __SYSTEM ref — fresh repo. The helper
1346        // must create __MEMSTEAD from scratch via the MustNotExist
1347        // precondition.
1348        let ctx = CommitContext::internal();
1349        commit_config_to_memstead_at_gitdir(
1350            &gitdir,
1351            "alpha",
1352            br#"{"format": 1, "schema": "default@1.0.0"}"#,
1353            &ctx,
1354            "test commit",
1355        )
1356        .unwrap();
1357
1358        let entries = list_memstead_entries(&gitdir);
1359        assert_eq!(entries, vec!["mems/alpha/config.json".to_string()]);
1360
1361        let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1362        assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1363    }
1364
1365    #[test]
1366    fn commit_config_to_memstead_overwrites_existing_blob() {
1367        let tmp = TempDir::new().unwrap();
1368        let gitdir = fresh_repo_dir(tmp.path());
1369        let ctx = CommitContext::internal();
1370
1371        commit_config_to_memstead_at_gitdir(
1372            &gitdir,
1373            "alpha",
1374            br#"{"format": 1, "schema": "default@1.0.0"}"#,
1375            &ctx,
1376            "first",
1377        )
1378        .unwrap();
1379        commit_config_to_memstead_at_gitdir(
1380            &gitdir,
1381            "alpha",
1382            br#"{"format": 1, "schema": "default@2.0.0"}"#,
1383            &ctx,
1384            "second",
1385        )
1386        .unwrap();
1387
1388        let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1389        assert_eq!(config.schema.unwrap().to_string(), "default@2.0.0");
1390    }
1391
1392    #[test]
1393    fn commit_config_to_memstead_preserves_sibling_mem_entries() {
1394        let tmp = TempDir::new().unwrap();
1395        let gitdir = fresh_repo_dir(tmp.path());
1396        let ctx = CommitContext::internal();
1397
1398        commit_config_to_memstead_at_gitdir(
1399            &gitdir,
1400            "alpha",
1401            br#"{"format": 1, "schema": "default@1.0.0"}"#,
1402            &ctx,
1403            "alpha",
1404        )
1405        .unwrap();
1406        commit_config_to_memstead_at_gitdir(
1407            &gitdir,
1408            "beta",
1409            br#"{"format": 1, "schema": "default@1.0.0"}"#,
1410            &ctx,
1411            "beta",
1412        )
1413        .unwrap();
1414
1415        let entries = list_memstead_entries(&gitdir);
1416        assert_eq!(
1417            entries,
1418            vec![
1419                "mems/alpha/config.json".to_string(),
1420                "mems/beta/config.json".to_string(),
1421            ]
1422        );
1423    }
1424
1425    #[test]
1426    fn extract_manifest_version_handles_quoted_and_unquoted() {
1427        assert_eq!(
1428            extract_manifest_version("name: foo\nversion: \"1.0.0\"\n"),
1429            Some("1.0.0".to_string())
1430        );
1431        assert_eq!(
1432            extract_manifest_version("name: foo\nversion: 1.0.0\n"),
1433            Some("1.0.0".to_string())
1434        );
1435        assert_eq!(
1436            extract_manifest_version("name: foo\nversion: '0.5.0'\n"),
1437            Some("0.5.0".to_string())
1438        );
1439        assert_eq!(extract_manifest_version("name: foo\n"), None);
1440        assert_eq!(extract_manifest_version(""), None);
1441    }
1442}