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/// Commit a batch of tree edits (upsert or remove) to arbitrary paths
806/// on the unified `__MEMSTEAD` ref in one commit. The pipeline-edit
807/// provenance path: `.memstead/` pipeline configs are plain disk JSON
808/// with no commit of their own, so each edit mirrors its config bytes
809/// under `__MEMSTEAD:pipeline/<kind>/<mem>/<name>.json` — the commit
810/// (subject + `Note:` trailer from `ctx`) IS the provenance record;
811/// the disk file stays the read path. Same read-modify-write shape and
812/// atomic ref advance as [`commit_config_to_memstead_at_gitdir`],
813/// generalized to N paths so a rename (remove old + upsert new) lands
814/// as one commit.
815///
816/// `edits`: `(tree_path, Some(bytes))` upserts, `(tree_path, None)`
817/// removes. Missing-`__MEMSTEAD` bootstrap follows the config helper's
818/// race-safe `MustNotExist` creation.
819pub fn commit_paths_to_memstead_at_gitdir(
820    gitdir: &Path,
821    edits: &[(String, Option<Vec<u8>>)],
822    ctx: &CommitContext<'_>,
823    message: &str,
824) -> Result<(), MemRepoWriteError> {
825    let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
826        path: gitdir.display().to_string(),
827        message: e.to_string(),
828    })?;
829    let existing_tip: Option<gix::ObjectId> = repo
830        .try_find_reference("refs/heads/__MEMSTEAD")
831        .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
832        .map(|r| {
833            r.into_fully_peeled_id()
834                .map(|id| id.detach())
835                .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
836        })
837        .transpose()?;
838
839    let mut editor = match existing_tip {
840        Some(tip) => {
841            let commit = repo
842                .find_object(tip)
843                .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
844                .into_commit();
845            let tree = commit
846                .tree()
847                .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
848            tree.edit().map_err(|e| {
849                MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
850            })?
851        }
852        None => repo.empty_tree().edit().map_err(|e| {
853            MemRepoWriteError::GitTree(format!("editor init (empty) for __MEMSTEAD: {e}"))
854        })?,
855    };
856    for (tree_path, bytes) in edits {
857        match bytes {
858            Some(bytes) => {
859                let blob_id = repo
860                    .write_blob(bytes.as_slice())
861                    .map_err(|e| MemRepoWriteError::GitTree(format!("write blob: {e}")))?
862                    .detach();
863                editor
864                    .upsert(
865                        tree_path.as_str(),
866                        gix::objs::tree::EntryKind::Blob,
867                        blob_id,
868                    )
869                    .map_err(|e| {
870                        MemRepoWriteError::GitTree(format!("tree upsert {tree_path}: {e}"))
871                    })?;
872            }
873            None => {
874                editor.remove(tree_path.as_str()).map_err(|e| {
875                    MemRepoWriteError::GitTree(format!("tree remove {tree_path}: {e}"))
876                })?;
877            }
878        }
879    }
880    let new_tree_id = editor
881        .write()
882        .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
883        .detach();
884
885    let time = gix::date::Time::now_local_or_utc();
886    let committer_sig = gix::actor::Signature {
887        name: COMMITTER_NAME.into(),
888        email: COMMITTER_EMAIL.into(),
889        time,
890    };
891    let author_sig = match author_identity(ctx) {
892        Some((name, email)) => gix::actor::Signature {
893            name: name.into(),
894            email: email.into(),
895            time,
896        },
897        None => committer_sig.clone(),
898    };
899    let full_message = format_commit_message(message, ctx);
900    let parents: Vec<gix::ObjectId> = match existing_tip {
901        Some(tip) => vec![tip],
902        None => Vec::new(),
903    };
904    let commit = gix::objs::Commit {
905        message: full_message.into(),
906        tree: new_tree_id,
907        author: author_sig,
908        committer: committer_sig,
909        encoding: None,
910        parents: parents.into_iter().collect(),
911        extra_headers: Default::default(),
912    };
913    let new_commit_id = repo
914        .write_object(&commit)
915        .map_err(|e| MemRepoWriteError::GitTree(format!("write __MEMSTEAD commit: {e}")))?
916        .detach();
917
918    let expected = match existing_tip {
919        Some(tip) => {
920            gix::refs::transaction::PreviousValue::MustExistAndMatch(gix::refs::Target::Object(tip))
921        }
922        None => gix::refs::transaction::PreviousValue::MustNotExist,
923    };
924    commit_refs_at_gitdir(
925        gitdir,
926        &[RefSpec {
927            ref_name: "refs/heads/__MEMSTEAD".to_string(),
928            new_oid: new_commit_id,
929            expected,
930            log_message: message.to_string(),
931        }],
932    )?;
933    Ok(())
934}
935
936/// Drop every git-branch artifact the engine wrote for one mem:
937/// the per-mem content branch (`refs/heads/<branch_leaf>`) and the
938/// `__MEMSTEAD:mems/<branch_leaf>/config.json` blob (collapsing any
939/// empty `mems/…` ancestor directories along the way). Symmetric
940/// counterpart to [`commit_config_to_memstead_at_gitdir`] +
941/// [`crate::storage::git_tree::GitTreeMemWriter::commit`]'s seed
942/// commit pair — `memstead_mem_create` writes those two, this helper
943/// undoes them when `memstead_mem_delete delete_files=true`.
944///
945/// `branch_leaf` is the hierarchical branch name (e.g.
946/// `planning/plan-q4-revamp` or the bare leaf for flat layouts).
947/// The function does NOT walk the repo to resolve it — the caller
948/// (the git-branch backend's [`memstead_base::backend::MemBackend::delete_artifacts`]
949/// impl) has it directly in `self.ref_name` minus the `refs/heads/`
950/// prefix.
951///
952/// Idempotent in both halves:
953/// - Branch ref delete uses `PreviousValue::Any` so a missing branch
954///   (sibling engine pruned it, manual surgery) is not an error.
955/// - `__MEMSTEAD` rewrite is skipped entirely when the ref doesn't exist;
956///   when it exists but doesn't carry the per-mem entry, the
957///   helper still writes a no-op commit only if the tree changed
958///   (the editor's `remove` on a missing path is a no-op there).
959///
960/// Both edits land in a single `edit_references` transaction — either
961/// the branch + `__MEMSTEAD` advance both succeed, or the whole call is
962/// rejected and no state changed.
963pub fn delete_mem_artifacts_at_gitdir(
964    gitdir: &Path,
965    branch_leaf: &str,
966    ctx: &CommitContext<'_>,
967) -> Result<(), MemRepoWriteError> {
968    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
969    use gix::refs::{FullName, Target};
970
971    let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
972        path: gitdir.display().to_string(),
973        message: e.to_string(),
974    })?;
975    // ---- Step 1: drop the per-mem entry from __MEMSTEAD (if present) ----
976    let existing_memstead_tip: Option<gix::ObjectId> = repo
977        .try_find_reference("refs/heads/__MEMSTEAD")
978        .map_err(|e| MemRepoWriteError::GitTree(e.to_string()))?
979        .map(|r| {
980            r.into_fully_peeled_id()
981                .map(|id| id.detach())
982                .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD: {e}")))
983        })
984        .transpose()?;
985
986    let mut new_memstead_commit: Option<gix::ObjectId> = None;
987    if let Some(tip) = existing_memstead_tip {
988        let commit = repo
989            .find_object(tip)
990            .map_err(|e| MemRepoWriteError::GitTree(format!("read __MEMSTEAD commit: {e}")))?
991            .into_commit();
992        let tree = commit
993            .tree()
994            .map_err(|e| MemRepoWriteError::GitTree(format!("peel __MEMSTEAD tree: {e}")))?;
995
996        // Check whether the entry actually exists — skip the commit
997        // entirely when it doesn't (e.g. a parallel run already
998        // pruned it). Saves an empty no-op commit.
999        let tree_path = format!("mems/{branch_leaf}/config.json");
1000        let entry_present = tree
1001            .lookup_entry_by_path(&tree_path)
1002            .map_err(|e| MemRepoWriteError::GitTree(format!("lookup {tree_path}: {e}")))?
1003            .is_some();
1004
1005        if entry_present {
1006            let mut editor = tree.edit().map_err(|e| {
1007                MemRepoWriteError::GitTree(format!("editor init for __MEMSTEAD: {e}"))
1008            })?;
1009            editor
1010                .remove(tree_path.as_str())
1011                .map_err(|e| MemRepoWriteError::GitTree(format!("tree remove {tree_path}: {e}")))?;
1012            // gix's tree writer prunes empty subtrees on write, so
1013            // removing `mems/<path>/<name>/config.json` collapses
1014            // the now-empty `<path>/<name>/` and any `<path>/`
1015            // ancestors automatically. No manual walk needed.
1016            let new_tree_id = editor
1017                .write()
1018                .map_err(|e| MemRepoWriteError::GitTree(format!("tree write for __MEMSTEAD: {e}")))?
1019                .detach();
1020
1021            let time = gix::date::Time::now_local_or_utc();
1022            let committer_sig = gix::actor::Signature {
1023                name: COMMITTER_NAME.into(),
1024                email: COMMITTER_EMAIL.into(),
1025                time,
1026            };
1027            let author_sig = match author_identity(ctx) {
1028                Some((name, email)) => gix::actor::Signature {
1029                    name: name.into(),
1030                    email: email.into(),
1031                    time,
1032                },
1033                None => committer_sig.clone(),
1034            };
1035            let full_message = format_commit_message(
1036                &format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json"),
1037                ctx,
1038            );
1039            let commit_obj = gix::objs::Commit {
1040                message: full_message.into(),
1041                tree: new_tree_id,
1042                author: author_sig,
1043                committer: committer_sig,
1044                encoding: None,
1045                parents: vec![tip].into_iter().collect(),
1046                extra_headers: Default::default(),
1047            };
1048            let new_commit_id = repo
1049                .write_object(&commit_obj)
1050                .map_err(|e| {
1051                    MemRepoWriteError::GitTree(format!("write __MEMSTEAD prune commit: {e}"))
1052                })?
1053                .detach();
1054            new_memstead_commit = Some(new_commit_id);
1055        }
1056    }
1057
1058    // ---- Step 2: ref-edit batch (branch delete + __MEMSTEAD advance) ----
1059    let mut edits: Vec<RefEdit> = Vec::with_capacity(2);
1060
1061    let branch_ref = format!("refs/heads/{branch_leaf}");
1062    let branch_full: FullName = branch_ref.as_str().try_into().map_err(|e| {
1063        MemRepoWriteError::RefTransaction(format!("invalid branch ref {branch_ref:?}: {e}"))
1064    })?;
1065    edits.push(RefEdit {
1066        change: Change::Delete {
1067            // `Any` keeps the delete idempotent — a missing branch
1068            // (sibling pruned it, manual surgery) is not an error.
1069            expected: PreviousValue::Any,
1070            log: RefLog::AndReference,
1071        },
1072        name: branch_full,
1073        deref: false,
1074    });
1075
1076    if let (Some(prior_tip), Some(new_commit)) = (existing_memstead_tip, new_memstead_commit) {
1077        let memstead_full: FullName = "refs/heads/__MEMSTEAD".try_into().map_err(|e| {
1078            MemRepoWriteError::RefTransaction(format!("invalid __MEMSTEAD ref: {e}"))
1079        })?;
1080        edits.push(RefEdit {
1081            change: Change::Update {
1082                log: LogChange {
1083                    mode: RefLog::AndReference,
1084                    force_create_reflog: false,
1085                    message: format!("memstead: prune __MEMSTEAD:mems/{branch_leaf}/config.json")
1086                        .as_str()
1087                        .into(),
1088                },
1089                expected: PreviousValue::MustExistAndMatch(Target::Object(prior_tip)),
1090                new: Target::Object(new_commit),
1091            },
1092            name: memstead_full,
1093            deref: false,
1094        });
1095    }
1096
1097    repo.edit_references(edits)
1098        .map_err(|e| MemRepoWriteError::RefTransaction(e.to_string()))?;
1099
1100    Ok(())
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106    use std::path::PathBuf;
1107    use tempfile::TempDir;
1108
1109    fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1110        let git_dir = tmp.join("mem-repo.git");
1111        gix::init_bare(&git_dir).unwrap();
1112        std::fs::canonicalize(&git_dir).unwrap()
1113    }
1114
1115    fn actor_for_test() -> gix::actor::Signature {
1116        gix::actor::Signature {
1117            name: "test".into(),
1118            email: "test@example.com".into(),
1119            time: gix::date::Time {
1120                seconds: 0,
1121                offset: 0,
1122            },
1123        }
1124    }
1125
1126    /// `(name, version, [(filename, body)])` schema seed tuple.
1127    type SchemaSeed<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]);
1128
1129    /// Build a minimal __SCHEMAS commit with one schema directory
1130    /// `<name>` containing `schema.yaml` (with a `version: <v>` field)
1131    /// and an optional types subtree of `(filename, body)` pairs.
1132    fn seed_schemas(gitdir: &Path, schemas: &[SchemaSeed<'_>]) {
1133        let repo = gix::open(gitdir).unwrap();
1134        let actor = actor_for_test();
1135        let mut buf = gix::date::parse::TimeBuf::default();
1136        let sig_ref = actor.to_ref(&mut buf);
1137        let mut editor = repo.empty_tree().edit().unwrap();
1138        for (name, version, types) in schemas {
1139            let manifest = format!("name: {name}\nversion: \"{version}\"\n");
1140            let manifest_blob = repo.write_blob(manifest.as_bytes()).unwrap().detach();
1141            editor
1142                .upsert(
1143                    format!("{name}/schema.yaml"),
1144                    gix::object::tree::EntryKind::Blob,
1145                    manifest_blob,
1146                )
1147                .unwrap();
1148            for (type_name, type_body) in *types {
1149                let blob = repo.write_blob(type_body.as_bytes()).unwrap().detach();
1150                editor
1151                    .upsert(
1152                        format!("{name}/types/{type_name}.yaml"),
1153                        gix::object::tree::EntryKind::Blob,
1154                        blob,
1155                    )
1156                    .unwrap();
1157            }
1158        }
1159        let tree_id = editor.write().unwrap().detach();
1160        repo.commit_as(
1161            sig_ref,
1162            sig_ref,
1163            "refs/heads/__SCHEMAS",
1164            "seed __SCHEMAS",
1165            tree_id,
1166            Vec::<gix::ObjectId>::new(),
1167        )
1168        .unwrap();
1169    }
1170
1171    /// Build a minimal __SYSTEM commit. `mems` is `(mem_name,
1172    /// config_json)` pairs; `repo_json` is the repo.json blob (or
1173    /// empty to skip).
1174    fn seed_system(gitdir: &Path, repo_json: &str, mems: &[(&str, &str)]) {
1175        let repo = gix::open(gitdir).unwrap();
1176        let actor = actor_for_test();
1177        let mut buf = gix::date::parse::TimeBuf::default();
1178        let sig_ref = actor.to_ref(&mut buf);
1179        let mut editor = repo.empty_tree().edit().unwrap();
1180        if !repo_json.is_empty() {
1181            let blob = repo.write_blob(repo_json.as_bytes()).unwrap().detach();
1182            editor
1183                .upsert("repo.json", gix::object::tree::EntryKind::Blob, blob)
1184                .unwrap();
1185        }
1186        for (mem, config) in mems {
1187            let blob = repo.write_blob(config.as_bytes()).unwrap().detach();
1188            editor
1189                .upsert(
1190                    format!("{mem}/config.json"),
1191                    gix::object::tree::EntryKind::Blob,
1192                    blob,
1193                )
1194                .unwrap();
1195        }
1196        let tree_id = editor.write().unwrap().detach();
1197        repo.commit_as(
1198            sig_ref,
1199            sig_ref,
1200            "refs/heads/__SYSTEM",
1201            "seed __SYSTEM",
1202            tree_id,
1203            Vec::<gix::ObjectId>::new(),
1204        )
1205        .unwrap();
1206    }
1207
1208    /// Walk the `__MEMSTEAD` tree at `gitdir` and return every
1209    /// (path, blob_oid) entry — used to assert tree shape.
1210    fn list_memstead_entries(gitdir: &Path) -> Vec<String> {
1211        let repo = gix::open(gitdir).unwrap();
1212        let reference = repo
1213            .try_find_reference("refs/heads/__MEMSTEAD")
1214            .unwrap()
1215            .unwrap();
1216        let id = reference.into_fully_peeled_id().unwrap();
1217        let commit = id.object().unwrap().try_into_commit().unwrap();
1218        let tree = commit.tree().unwrap();
1219        let mut out: Vec<String> = Vec::new();
1220        walk(&repo, &tree, "", &mut out);
1221        out.sort();
1222        out
1223    }
1224
1225    fn walk(repo: &gix::Repository, tree: &gix::Tree<'_>, prefix: &str, out: &mut Vec<String>) {
1226        for entry in tree.iter().flatten() {
1227            let name = std::str::from_utf8(entry.filename())
1228                .unwrap_or("")
1229                .to_string();
1230            let path = if prefix.is_empty() {
1231                name.clone()
1232            } else {
1233                format!("{prefix}/{name}")
1234            };
1235            match entry.mode().kind() {
1236                gix::object::tree::EntryKind::Tree => {
1237                    let subtree = repo
1238                        .find_object(entry.oid().to_owned())
1239                        .unwrap()
1240                        .into_tree();
1241                    walk(repo, &subtree, &path, out);
1242                }
1243                gix::object::tree::EntryKind::Blob
1244                | gix::object::tree::EntryKind::BlobExecutable => {
1245                    out.push(path);
1246                }
1247                _ => {}
1248            }
1249        }
1250    }
1251
1252    #[test]
1253    fn migrate_writes_unified_tree_from_schemas_and_system() {
1254        let tmp = TempDir::new().unwrap();
1255        let gitdir = fresh_repo_dir(tmp.path());
1256        seed_schemas(
1257            &gitdir,
1258            &[
1259                ("default", "1.0.0", &[("spec", "name: spec\n")]),
1260                ("custom", "0.5.0", &[]),
1261            ],
1262        );
1263        seed_system(
1264            &gitdir,
1265            r#"{"name":"main"}"#,
1266            &[
1267                ("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#),
1268                ("beta", r#"{"format": 1, "schema": "custom@0.5.0"}"#),
1269            ],
1270        );
1271
1272        let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1273        assert_eq!(outcome.schemas_migrated, 2);
1274        assert_eq!(outcome.mems_migrated, 2);
1275        assert!(!outcome.already_current);
1276
1277        let entries = list_memstead_entries(&gitdir);
1278        // Schemas live under versioned paths.
1279        assert!(entries.contains(&"schemas/default@1.0.0/schema.yaml".to_string()));
1280        assert!(entries.contains(&"schemas/default@1.0.0/types/spec.yaml".to_string()));
1281        assert!(entries.contains(&"schemas/custom@0.5.0/schema.yaml".to_string()));
1282        // Mem configs live under mems/.
1283        assert!(entries.contains(&"mems/alpha/config.json".to_string()));
1284        assert!(entries.contains(&"mems/beta/config.json".to_string()));
1285        // repo.json explicitly NOT migrated.
1286        assert!(
1287            !entries.iter().any(|p| p.contains("repo.json")),
1288            "repo.json must not appear under __MEMSTEAD: {entries:?}"
1289        );
1290    }
1291
1292    #[test]
1293    fn write_schema_to_memstead_ref_adds_package_idempotent_and_preserves_others() {
1294        let tmp = TempDir::new().unwrap();
1295        let gitdir = fresh_repo_dir(tmp.path());
1296
1297        // Write a package onto an absent ref — the ref is created.
1298        let tiny = vec![
1299            (
1300                "schema.yaml".to_string(),
1301                b"name: tiny\nversion: 0.1.0\n".to_vec(),
1302            ),
1303            ("types/doc.yaml".to_string(), b"name: doc\n".to_vec()),
1304            ("mem-template.json".to_string(), b"{}\n".to_vec()),
1305        ];
1306        let out = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1307        assert!(!out.already_current);
1308        let entries = list_memstead_entries(&gitdir);
1309        for p in [
1310            "schemas/tiny@0.1.0/schema.yaml",
1311            "schemas/tiny@0.1.0/types/doc.yaml",
1312            "schemas/tiny@0.1.0/mem-template.json",
1313        ] {
1314            assert!(entries.iter().any(|e| e == p), "missing {p}: {entries:?}");
1315        }
1316
1317        // Re-writing identical bytes is a no-op — same tip, no new commit.
1318        let again = write_schema_to_memstead_ref(&gitdir, "tiny", "0.1.0", &tiny).unwrap();
1319        assert!(again.already_current);
1320        assert_eq!(out.commit_sha, again.commit_sha);
1321
1322        // A second package upserts into the existing tree, preserving the first.
1323        let other = vec![(
1324            "schema.yaml".to_string(),
1325            b"name: other\nversion: 2.0.0\n".to_vec(),
1326        )];
1327        let out2 = write_schema_to_memstead_ref(&gitdir, "other", "2.0.0", &other).unwrap();
1328        assert!(!out2.already_current);
1329        assert_ne!(out2.commit_sha, out.commit_sha);
1330        let entries2 = list_memstead_entries(&gitdir);
1331        assert!(
1332            entries2
1333                .iter()
1334                .any(|e| e == "schemas/tiny@0.1.0/schema.yaml")
1335        );
1336        assert!(
1337            entries2
1338                .iter()
1339                .any(|e| e == "schemas/other@2.0.0/schema.yaml")
1340        );
1341    }
1342
1343    #[test]
1344    fn pro_git_branch_ops_write_schema_hook_writes_to_ref() {
1345        // The engine reaches the ref-write through the
1346        // `GitBranchOps.write_schema` dispatcher; this pins that the const
1347        // is wired to `write_schema_to_memstead_ref` and returns a sha.
1348        let tmp = TempDir::new().unwrap();
1349        let gitdir = fresh_repo_dir(tmp.path());
1350        let files = vec![(
1351            "schema.yaml".to_string(),
1352            b"name: h\nversion: 1.0.0\n".to_vec(),
1353        )];
1354        let commit =
1355            (crate::storage::FULL_GIT_BRANCH_OPS.write_schema)(&gitdir, "h", "1.0.0", &files)
1356                .expect("hook writes the package");
1357        assert!(!commit.is_empty());
1358        let entries = list_memstead_entries(&gitdir);
1359        assert!(
1360            entries.iter().any(|e| e == "schemas/h@1.0.0/schema.yaml"),
1361            "package must land on the ref: {entries:?}"
1362        );
1363    }
1364
1365    #[test]
1366    fn migrate_is_idempotent() {
1367        let tmp = TempDir::new().unwrap();
1368        let gitdir = fresh_repo_dir(tmp.path());
1369        seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1370        seed_system(
1371            &gitdir,
1372            r#"{"name":"main"}"#,
1373            &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1374        );
1375
1376        let first = migrate_to_memstead_ref(&gitdir).unwrap();
1377        assert!(!first.already_current);
1378        let second = migrate_to_memstead_ref(&gitdir).unwrap();
1379        assert!(second.already_current);
1380        // Same tip; no new commit was written.
1381        assert_eq!(first.commit_sha, second.commit_sha);
1382    }
1383
1384    #[test]
1385    fn migrate_with_empty_workspace_writes_empty_tree() {
1386        let tmp = TempDir::new().unwrap();
1387        let gitdir = fresh_repo_dir(tmp.path());
1388        // No __SCHEMAS, no __SYSTEM — the migration writes an
1389        // empty __MEMSTEAD tree (the cutover session decides what to
1390        // do with that case).
1391        let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1392        assert_eq!(outcome.schemas_migrated, 0);
1393        assert_eq!(outcome.mems_migrated, 0);
1394        let entries = list_memstead_entries(&gitdir);
1395        assert!(entries.is_empty());
1396    }
1397
1398    #[test]
1399    fn migrate_handles_missing_version_with_placeholder() {
1400        let tmp = TempDir::new().unwrap();
1401        let gitdir = fresh_repo_dir(tmp.path());
1402        // Schema YAML with no `version:` field.
1403        let repo = gix::open(&gitdir).unwrap();
1404        let actor = actor_for_test();
1405        let mut buf = gix::date::parse::TimeBuf::default();
1406        let sig_ref = actor.to_ref(&mut buf);
1407        let mut editor = repo.empty_tree().edit().unwrap();
1408        let blob = repo.write_blob(b"name: anonymous\n").unwrap().detach();
1409        editor
1410            .upsert(
1411                "anonymous/schema.yaml",
1412                gix::object::tree::EntryKind::Blob,
1413                blob,
1414            )
1415            .unwrap();
1416        let tree_id = editor.write().unwrap().detach();
1417        repo.commit_as(
1418            sig_ref,
1419            sig_ref,
1420            "refs/heads/__SCHEMAS",
1421            "seed",
1422            tree_id,
1423            Vec::<gix::ObjectId>::new(),
1424        )
1425        .unwrap();
1426
1427        let outcome = migrate_to_memstead_ref(&gitdir).unwrap();
1428        assert_eq!(outcome.schemas_migrated, 1);
1429        let entries = list_memstead_entries(&gitdir);
1430        assert!(
1431            entries.contains(&"schemas/anonymous@0.0.0/schema.yaml".to_string()),
1432            "missing-version schemas land under @0.0.0; got {entries:?}"
1433        );
1434    }
1435
1436    #[test]
1437    fn read_mem_config_from_memstead_round_trips_after_migration() {
1438        let tmp = TempDir::new().unwrap();
1439        let gitdir = fresh_repo_dir(tmp.path());
1440        seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1441        seed_system(
1442            &gitdir,
1443            r#"{"name":"main"}"#,
1444            &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1445        );
1446        let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1447
1448        let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1449        assert!(config.schema.is_some());
1450        assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1451    }
1452
1453    #[test]
1454    fn read_mem_config_from_memstead_returns_typed_error_for_missing_mem() {
1455        let tmp = TempDir::new().unwrap();
1456        let gitdir = fresh_repo_dir(tmp.path());
1457        seed_schemas(&gitdir, &[("default", "1.0.0", &[])]);
1458        seed_system(
1459            &gitdir,
1460            r#"{"name":"main"}"#,
1461            &[("alpha", r#"{"format": 1, "schema": "default@1.0.0"}"#)],
1462        );
1463        let _ = migrate_to_memstead_ref(&gitdir).unwrap();
1464        match read_mem_config_from_memstead_ref(&gitdir, "nonexistent") {
1465            Err(MemsteadRefError::Config { path, .. }) => {
1466                assert!(path.contains("nonexistent"));
1467            }
1468            other => panic!("expected Config error for missing mem, got {other:?}"),
1469        }
1470    }
1471
1472    #[test]
1473    fn commit_config_to_memstead_creates_ref_when_absent() {
1474        let tmp = TempDir::new().unwrap();
1475        let gitdir = fresh_repo_dir(tmp.path());
1476        // No __MEMSTEAD ref, no __SYSTEM ref — fresh repo. The helper
1477        // must create __MEMSTEAD from scratch via the MustNotExist
1478        // precondition.
1479        let ctx = CommitContext::internal();
1480        commit_config_to_memstead_at_gitdir(
1481            &gitdir,
1482            "alpha",
1483            br#"{"format": 1, "schema": "default@1.0.0"}"#,
1484            &ctx,
1485            "test commit",
1486        )
1487        .unwrap();
1488
1489        let entries = list_memstead_entries(&gitdir);
1490        assert_eq!(entries, vec!["mems/alpha/config.json".to_string()]);
1491
1492        let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1493        assert_eq!(config.schema.unwrap().to_string(), "default@1.0.0");
1494    }
1495
1496    #[test]
1497    fn commit_config_to_memstead_overwrites_existing_blob() {
1498        let tmp = TempDir::new().unwrap();
1499        let gitdir = fresh_repo_dir(tmp.path());
1500        let ctx = CommitContext::internal();
1501
1502        commit_config_to_memstead_at_gitdir(
1503            &gitdir,
1504            "alpha",
1505            br#"{"format": 1, "schema": "default@1.0.0"}"#,
1506            &ctx,
1507            "first",
1508        )
1509        .unwrap();
1510        commit_config_to_memstead_at_gitdir(
1511            &gitdir,
1512            "alpha",
1513            br#"{"format": 1, "schema": "default@2.0.0"}"#,
1514            &ctx,
1515            "second",
1516        )
1517        .unwrap();
1518
1519        let config = read_mem_config_from_memstead_ref(&gitdir, "alpha").unwrap();
1520        assert_eq!(config.schema.unwrap().to_string(), "default@2.0.0");
1521    }
1522
1523    #[test]
1524    fn commit_config_to_memstead_preserves_sibling_mem_entries() {
1525        let tmp = TempDir::new().unwrap();
1526        let gitdir = fresh_repo_dir(tmp.path());
1527        let ctx = CommitContext::internal();
1528
1529        commit_config_to_memstead_at_gitdir(
1530            &gitdir,
1531            "alpha",
1532            br#"{"format": 1, "schema": "default@1.0.0"}"#,
1533            &ctx,
1534            "alpha",
1535        )
1536        .unwrap();
1537        commit_config_to_memstead_at_gitdir(
1538            &gitdir,
1539            "beta",
1540            br#"{"format": 1, "schema": "default@1.0.0"}"#,
1541            &ctx,
1542            "beta",
1543        )
1544        .unwrap();
1545
1546        let entries = list_memstead_entries(&gitdir);
1547        assert_eq!(
1548            entries,
1549            vec![
1550                "mems/alpha/config.json".to_string(),
1551                "mems/beta/config.json".to_string(),
1552            ]
1553        );
1554    }
1555
1556    #[test]
1557    fn extract_manifest_version_handles_quoted_and_unquoted() {
1558        assert_eq!(
1559            extract_manifest_version("name: foo\nversion: \"1.0.0\"\n"),
1560            Some("1.0.0".to_string())
1561        );
1562        assert_eq!(
1563            extract_manifest_version("name: foo\nversion: 1.0.0\n"),
1564            Some("1.0.0".to_string())
1565        );
1566        assert_eq!(
1567            extract_manifest_version("name: foo\nversion: '0.5.0'\n"),
1568            Some("0.5.0".to_string())
1569        );
1570        assert_eq!(extract_manifest_version("name: foo\n"), None);
1571        assert_eq!(extract_manifest_version(""), None);
1572    }
1573}