Skip to main content

memstead_cli/commands/
mem_repo.rs

1//! `memstead mem-repo ...` — mem-repo-git lifecycle commands.
2//!
3//! Today this module hosts `init [<path>]` — bootstrap a fresh
4//! mem-repo-git workspace at `<path>/mem-repo/.git/` with a working
5//! tree on `main`, an initial commit carrying the README template, and
6//! (optionally) an outer-repo `.gitignore` append so a surrounding git
7//! repo does not see the new `mem-repo/` as a gitlink.
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use clap::{Args, Subcommand};
13use gix::object::tree::EntryKind;
14
15use crate::CliError;
16use crate::outer_gitignore::{OuterRepoOutcome, apply_outer_gitignore};
17use crate::output::ExitKind;
18use crate::setup::CliContext;
19
20const README_TEMPLATE: &str = include_str!("../../templates/mem-repo-readme.md");
21
22/// Engine-required minimum for `.memstead/workspace.toml`. The two-layer
23/// file adapter loader treats `format` + `[persistence_adapter]` as
24/// the only mandatory keys; everything else (mem_management,
25/// cross_mem_links, mutations, plugin.*) is operator-opt-in and
26/// defaults to deny/empty. Matches the same baseline `memstead init`
27/// writes for filesystem-mem — keeping the two flavours symmetric.
28const DEFAULT_WORKSPACE_TOML: &str = "\
29format = \"memstead-git-branch-2\"\n\
30\n\
31[persistence_adapter]\n\
32name = \"file-two-layer\"\n";
33
34/// Subcommands under `memstead mem-repo`.
35#[derive(Subcommand, Debug)]
36pub enum MemRepoAction {
37    /// Bootstrap a fresh mem-repo-git workspace.
38    Init(InitArgs),
39
40    /// Configure (or re-point) a named git remote on the mem-repo, so
41    /// `memstead fetch` / `pull` / `push` have somewhere to go. Upsert:
42    /// re-running with a new URL re-points the remote.
43    #[command(name = "remote-add")]
44    RemoteAdd(RemoteAddArgs),
45}
46
47/// `memstead mem-repo remote-add <name> <url>` arguments.
48#[derive(Args, Debug)]
49pub struct RemoteAddArgs {
50    /// Remote name (e.g. `origin`).
51    pub name: String,
52    /// Remote URL (e.g. `git@github.com:you/mem-backup.git` or a local
53    /// bare-repo path).
54    pub url: String,
55}
56
57/// `memstead mem-repo init [<path>]` arguments.
58#[derive(Args, Debug)]
59pub struct InitArgs {
60    /// Workspace directory to bootstrap. Created if missing. Defaults to
61    /// the current directory.
62    #[arg(default_value = ".")]
63    pub path: PathBuf,
64
65    /// Skip outer-repo `.gitignore` auto-append. Useful when the user
66    /// intends to track `mem-repo/` as a git submodule, or when the
67    /// detection heuristic would pick the wrong outer repo.
68    #[arg(long)]
69    pub no_gitignore: bool,
70}
71
72pub fn run(ctx: &CliContext, action: MemRepoAction) -> anyhow::Result<()> {
73    match action {
74        MemRepoAction::Init(args) => init(ctx, args),
75        MemRepoAction::RemoteAdd(args) => remote_add(ctx, args),
76    }
77}
78
79fn remote_add(ctx: &CliContext, args: RemoteAddArgs) -> anyhow::Result<()> {
80    let outcome = match ctx.cli_engine()? {
81        crate::setup::CliEngine::MemRepo(engine) => engine
82            .remote_add(&args.name, &args.url)
83            .map_err(CliError::from_engine_op)?,
84        crate::setup::CliEngine::Filesystem(_) => {
85            return Err(CliError {
86                code: "INVALID_INPUT",
87                kind: ExitKind::Validation,
88                message: "this workspace is not git-backed — `memstead mem-repo remote-add` \
89                          requires a mem-repo workspace"
90                    .to_string(),
91                details: None,
92            }
93            .into());
94        }
95    };
96    if ctx.json {
97        crate::output::print_json(&outcome)?;
98    } else {
99        let verb = if outcome.updated {
100            "Re-pointed"
101        } else {
102            "Added"
103        };
104        crate::output::print_markdown(&format!(
105            "# {verb} remote `{}`\n\n- URL: `{}`",
106            outcome.remote, outcome.url,
107        ));
108    }
109    Ok(())
110}
111
112fn init(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
113    let outcome = run_init(&args.path, args.no_gitignore)?;
114
115    // `--json` stdout is machine-only: exactly one JSON document, the
116    // contract `--help` advertises and steers callers to pipe through
117    // `jq`. The primary result becomes a structured envelope; the
118    // human progress block is suppressed.
119    if ctx.json {
120        crate::output::print_json(&serde_json::json!({
121            "mem_repo_dir": outcome.mem_repo_dir.display().to_string(),
122            "workspace_toml": outcome.workspace_toml.display().to_string(),
123        }))?;
124    } else {
125        println!(
126            "Initialised mem-repo-git at {}",
127            outcome.mem_repo_dir.display(),
128        );
129        println!("  main: README.md (initial commit)");
130        println!(
131            "  __MEMSTEAD: empty (unified registry ref for workspace schemas + per-mem configs)"
132        );
133        println!("  config: {}", outcome.workspace_toml.display());
134    }
135
136    // Outer-repo provenance is human-facing context, not part of the
137    // structured result — it always goes to stderr (never stdout) so a
138    // `--json` caller's stdout stays exactly one JSON document, and is
139    // suppressed under `--quiet`. A human still sees it on the terminal.
140    match outcome.gitignore {
141        OuterRepoOutcome::Appended { outer_root, rel } => {
142            if !ctx.quiet {
143                eprintln!(
144                    "  outer:    {} — added `{}` to .gitignore",
145                    outer_root.display(),
146                    rel,
147                );
148            }
149        }
150        OuterRepoOutcome::AlreadyIgnored { outer_root, rel } => {
151            if !ctx.quiet {
152                eprintln!(
153                    "  outer:    {} — `{}` already in .gitignore, no change",
154                    outer_root.display(),
155                    rel,
156                );
157            }
158        }
159        OuterRepoOutcome::NoOuter | OuterRepoOutcome::Skipped => {}
160    }
161    Ok(())
162}
163
164/// Library-form entry point for `memstead mem-repo init`. Creates
165/// `<workspace>/mem-repo/.git/` with a working tree on `main`, makes
166/// the initial commit, seeds the unified `__MEMSTEAD` registry ref with an
167/// empty tree, and (unless suppressed) appends `mem-repo/` to an
168/// enclosing outer-repo's `.gitignore`. Idempotent on a fresh target;
169/// refuses to overwrite an existing `mem-repo/` directory.
170pub(crate) fn run_init(
171    workspace_path: &Path,
172    skip_outer_gitignore: bool,
173) -> anyhow::Result<InitOutcome> {
174    fs::create_dir_all(workspace_path)
175        .map_err(|e| generic_error(format!("create workspace directory: {e}")))?;
176
177    let workspace = fs::canonicalize(workspace_path)
178        .map_err(|e| generic_error(format!("canonicalize workspace path: {e}")))?;
179
180    let mem_repo_root = workspace.join("mem-repo");
181    if mem_repo_root.exists() {
182        return Err(CliError {
183            code: "MEM_DB_ALREADY_EXISTS",
184            kind: ExitKind::Validation,
185            message: format!(
186                "{} already exists — refusing to overwrite. Delete or move \
187                 the existing mem-repo before re-running `mem-repo init`.",
188                mem_repo_root.display()
189            ),
190            details: None,
191        }
192        .into());
193    }
194
195    fs::create_dir_all(&mem_repo_root)
196        .map_err(|e| generic_error(format!("create mem-repo directory: {e}")))?;
197
198    gix::init(&mem_repo_root).map_err(|e| generic_error(format!("init mem-repo gitdir: {e}")))?;
199
200    let repo = gix::open(mem_repo_root.join(".git"))
201        .map_err(|e| generic_error(format!("open mem-repo gitdir: {e}")))?;
202
203    // `main` carries operator-facing docs only (README.md). Schemas and
204    // per-mem configs live on the unified `__MEMSTEAD` registry ref.
205    let mut editor = repo
206        .empty_tree()
207        .edit()
208        .map_err(|e| generic_error(format!("init main tree editor: {e}")))?;
209
210    let readme_blob = repo
211        .write_blob(README_TEMPLATE.as_bytes())
212        .map_err(|e| generic_error(format!("write README blob: {e}")))?
213        .detach();
214    editor
215        .upsert("README.md", EntryKind::Blob, readme_blob)
216        .map_err(|e| generic_error(format!("upsert README.md: {e}")))?;
217
218    let main_tree = editor
219        .write()
220        .map_err(|e| generic_error(format!("write main tree: {e}")))?
221        .detach();
222
223    let actor = init_signature();
224    let mut buf = gix::date::parse::TimeBuf::default();
225    let actor_ref = actor.to_ref(&mut buf);
226    repo.commit_as(
227        actor_ref,
228        actor_ref,
229        "refs/heads/main",
230        "mem-repo init: initial main commit",
231        main_tree,
232        Vec::<gix::ObjectId>::new(),
233    )
234    .map_err(|e| generic_error(format!("commit main: {e}")))?;
235
236    // Seed the unified `__MEMSTEAD` registry ref with an empty tree. Schemas
237    // (`__MEMSTEAD:schemas/<name>/...`) and per-mem configs
238    // (`__MEMSTEAD:mems/<mem>/config.json`) are upserted by subsequent
239    // engine writes; the empty seed lets the engine's reader resolve
240    // the ref without surfacing a bootstrap error.
241    let empty_tree = repo.empty_tree().id().detach();
242    let mut buf = gix::date::parse::TimeBuf::default();
243    let actor_ref = actor.to_ref(&mut buf);
244    repo.commit_as(
245        actor_ref,
246        actor_ref,
247        "refs/heads/__MEMSTEAD",
248        "mem-repo init: seed __MEMSTEAD",
249        empty_tree,
250        Vec::<gix::ObjectId>::new(),
251    )
252    .map_err(|e| generic_error(format!("commit __MEMSTEAD: {e}")))?;
253
254    materialise_main_worktree(&mem_repo_root)?;
255    write_default_workspace_toml(&workspace)?;
256
257    // Outer-repo gitignore append: walk up from the workspace's parent
258    // (so we don't rediscover the new mem-repo/.git/ as our own outer)
259    // looking for an enclosing `.git/`, append `mem-repo/` to its
260    // `.gitignore`. Idempotent on re-run.
261    let gitignore = if skip_outer_gitignore {
262        OuterRepoOutcome::Skipped
263    } else {
264        let walk_start = workspace
265            .parent()
266            .map(|p| p.to_path_buf())
267            .unwrap_or_else(|| workspace.clone());
268        apply_outer_gitignore(&walk_start, &mem_repo_root)?
269    };
270
271    Ok(InitOutcome {
272        mem_repo_dir: mem_repo_root,
273        workspace_toml: workspace
274            .join(memstead_base::WORKSPACE_STORE_DIR)
275            .join("workspace.toml"),
276        gitignore,
277    })
278}
279
280/// Write `README.md` to the working tree. Mirrors the just-committed
281/// `main` tree so a human inspecting `<workspace>/mem-repo/` sees
282/// the file immediately. Schemas and per-mem configs live on the
283/// `__MEMSTEAD` registry ref and are not surfaced via the worktree.
284fn materialise_main_worktree(mem_repo_root: &Path) -> anyhow::Result<()> {
285    fs::write(mem_repo_root.join("README.md"), README_TEMPLATE)
286        .map_err(|e| generic_error(format!("write working-tree README.md: {e}")))?;
287    Ok(())
288}
289
290/// Materialise the minimum-viable `.memstead/workspace.toml`. Required by
291/// every subsequent CLI / MCP command — without the file the
292/// workspace-store loader bails with `StoreError::NotInitialised` and
293/// the freshly-init'd workspace is unusable. Idempotent: a
294/// pre-existing file is left untouched so operator-authored content
295/// survives a re-init under the same workspace path.
296fn write_default_workspace_toml(workspace_root: &Path) -> anyhow::Result<()> {
297    let memstead_dir = workspace_root.join(memstead_base::WORKSPACE_STORE_DIR);
298    fs::create_dir_all(&memstead_dir)
299        .map_err(|e| generic_error(format!("create .memstead directory: {e}")))?;
300    let toml_path = memstead_dir.join("workspace.toml");
301    if toml_path.exists() {
302        return Ok(());
303    }
304    fs::write(&toml_path, DEFAULT_WORKSPACE_TOML)
305        .map_err(|e| generic_error(format!("write .memstead/workspace.toml: {e}")))?;
306    Ok(())
307}
308
309/// Result of a successful `mem-repo init`. Useful in tests for
310/// asserting on the produced shape without re-walking the repo.
311#[derive(Debug)]
312pub(crate) struct InitOutcome {
313    pub mem_repo_dir: PathBuf,
314    pub workspace_toml: PathBuf,
315    pub gitignore: OuterRepoOutcome,
316}
317
318fn init_signature() -> gix::actor::Signature {
319    gix::actor::Signature {
320        name: "memstead-cli mem-repo init".into(),
321        email: "mem-repo-init@memstead".into(),
322        time: gix::date::Time {
323            seconds: 0,
324            offset: 0,
325        },
326    }
327}
328
329fn generic_error(msg: String) -> anyhow::Error {
330    CliError {
331        code: "MEM_REPO_INIT_FAILED",
332        kind: ExitKind::Generic,
333        message: msg,
334        details: None,
335    }
336    .into()
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use tempfile::TempDir;
343
344    #[test]
345    fn memstead_mem_repo_init_creates_layout() {
346        let tmp = TempDir::new().unwrap();
347        let workspace = tmp.path().join("ws");
348        let outcome = run_init(&workspace, true).unwrap();
349
350        assert!(outcome.mem_repo_dir.exists(), "mem-repo/ must exist");
351        assert!(
352            outcome.mem_repo_dir.join(".git").exists(),
353            "mem-repo/.git/ must exist"
354        );
355        assert!(
356            outcome.mem_repo_dir.join("README.md").is_file(),
357            "mem-repo/README.md must be checked out",
358        );
359        assert!(
360            !outcome.mem_repo_dir.join("schemas").exists(),
361            "mem-repo/schemas/ must NOT be materialised (schemas live on __MEMSTEAD)",
362        );
363
364        let repo = gix::open(outcome.mem_repo_dir.join(".git")).unwrap();
365        let id = repo
366            .find_reference("refs/heads/main")
367            .unwrap()
368            .into_fully_peeled_id()
369            .unwrap();
370        let tree = repo.find_object(id).unwrap().into_commit().tree().unwrap();
371        assert!(
372            tree.lookup_entry_by_path("README.md").unwrap().is_some(),
373            "main:README.md must be present"
374        );
375        assert!(
376            tree.lookup_entry_by_path("schemas").unwrap().is_none(),
377            "main must NOT carry schemas/"
378        );
379        assert!(
380            tree.lookup_entry_by_path("configs").unwrap().is_none(),
381            "main must NOT carry configs/"
382        );
383
384        assert!(
385            repo.find_reference("refs/heads/__MEMSTEAD").is_ok(),
386            "refs/heads/__MEMSTEAD must exist after init"
387        );
388        assert!(
389            repo.try_find_reference("refs/heads/__SYSTEM")
390                .unwrap()
391                .is_none(),
392            "refs/heads/__SYSTEM must NOT be written by init"
393        );
394        assert!(
395            repo.try_find_reference("refs/heads/__SCHEMAS")
396                .unwrap()
397                .is_none(),
398            "refs/heads/__SCHEMAS must NOT be written by init"
399        );
400
401        // `memstead mem-repo init` must leave the workspace in a state
402        // every subsequent command can boot from. Without
403        // `.memstead/workspace.toml` the engine's loader bails with
404        // `StoreError::NotInitialised` and `memstead stats` fails.
405        let workspace_toml = workspace
406            .canonicalize()
407            .unwrap()
408            .join(".memstead")
409            .join("workspace.toml");
410        assert_eq!(outcome.workspace_toml, workspace_toml);
411        assert!(
412            workspace_toml.is_file(),
413            ".memstead/workspace.toml must be materialised by init",
414        );
415        let body = fs::read_to_string(&workspace_toml).unwrap();
416        assert!(
417            body.contains("format = \"memstead-git-branch-2\""),
418            "workspace.toml must declare the engine format, got:\n{body}",
419        );
420        assert!(
421            body.contains("name = \"file-two-layer\""),
422            "workspace.toml must declare the file-two-layer adapter, got:\n{body}",
423        );
424    }
425
426    #[test]
427    fn memstead_mem_repo_init_preserves_existing_workspace_toml() {
428        // Operator-authored `.memstead/workspace.toml` survives a re-init
429        // under the same workspace path: the init must not clobber
430        // hand-edited allowlist / cross-link / mutation policy.
431        let tmp = TempDir::new().unwrap();
432        let workspace = tmp.path().join("ws");
433        fs::create_dir_all(workspace.join(".memstead")).unwrap();
434        let toml_path = workspace.join(".memstead").join("workspace.toml");
435        let authored = "# operator-authored\n\
436format = \"memstead-git-branch-2\"\n\
437\n\
438[persistence_adapter]\n\
439name = \"file-two-layer\"\n\
440\n\
441[[mem_management.create]]\n\
442pattern = \"exec-*\"\n\
443schemas = [\"default@1.0.0\"]\n";
444        fs::write(&toml_path, authored).unwrap();
445
446        run_init(&workspace, true).unwrap();
447        let actual = fs::read_to_string(&toml_path).unwrap();
448        assert_eq!(
449            actual, authored,
450            "init must not overwrite hand-edited workspace.toml"
451        );
452    }
453
454    #[test]
455    fn memstead_mem_repo_init_handles_outer_repo_gitignore() {
456        let tmp = TempDir::new().unwrap();
457        let outer = tmp.path().join("outer");
458        fs::create_dir_all(&outer).unwrap();
459        gix::init(&outer).unwrap();
460        let workspace = outer.join("ws");
461
462        let outcome = run_init(&workspace, false).unwrap();
463        match outcome.gitignore {
464            OuterRepoOutcome::Appended { ref outer_root, .. } => {
465                assert_eq!(
466                    outer_root.canonicalize().unwrap(),
467                    outer.canonicalize().unwrap()
468                );
469            }
470            other => panic!("expected Appended, got {other:?}"),
471        }
472
473        let gitignore = fs::read_to_string(outer.join(".gitignore")).unwrap();
474        assert!(
475            gitignore.contains("ws/mem-repo/"),
476            "expected ws/mem-repo/ in outer .gitignore, got:\n{gitignore}",
477        );
478
479        let workspace2 = outer.join("ws2");
480        fs::remove_dir_all(workspace.join("mem-repo")).unwrap();
481        let outcome2 = run_init(&workspace, false).unwrap();
482        match outcome2.gitignore {
483            OuterRepoOutcome::AlreadyIgnored { .. } => {}
484            _ => panic!("re-init under same workspace must be idempotent"),
485        }
486        let gitignore2 = fs::read_to_string(outer.join(".gitignore")).unwrap();
487        let count = gitignore2.matches("ws/mem-repo/").count();
488        assert_eq!(
489            count, 1,
490            "outer .gitignore must carry exactly one `ws/mem-repo/` line, got {count}\n{gitignore2}",
491        );
492        let _ = workspace2;
493    }
494
495    #[test]
496    fn memstead_mem_repo_init_no_gitignore_flag() {
497        let tmp = TempDir::new().unwrap();
498        let outer = tmp.path().join("outer");
499        fs::create_dir_all(&outer).unwrap();
500        gix::init(&outer).unwrap();
501        let workspace = outer.join("ws");
502
503        run_init(&workspace, true).unwrap();
504        let gitignore_path = outer.join(".gitignore");
505        if gitignore_path.exists() {
506            let body = fs::read_to_string(&gitignore_path).unwrap();
507            assert!(
508                !body.contains("mem-repo"),
509                "with --no-gitignore the outer repo's .gitignore must be untouched, got:\n{body}",
510            );
511        }
512    }
513
514    #[test]
515    fn memstead_mem_repo_init_existing_fails() {
516        let tmp = TempDir::new().unwrap();
517        let workspace = tmp.path().join("ws");
518        run_init(&workspace, true).unwrap();
519        let err = run_init(&workspace, true).unwrap_err();
520        let cli_err = err.downcast_ref::<CliError>().expect("CliError expected");
521        assert_eq!(cli_err.kind, ExitKind::Validation);
522        // The typed code is a first-class field on `CliError` rather than a
523        // `details.code` breadcrumb.
524        assert_eq!(cli_err.code, "MEM_DB_ALREADY_EXISTS");
525    }
526}