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            "workspace_shape": crate::setup::WorkspaceShape::MemRepo.label(),
124            "workspace_shape_disclosure":
125                crate::setup::shape_disclosure(crate::setup::WorkspaceShape::MemRepo).to_json(),
126        }))?;
127    } else {
128        println!(
129            "Initialised mem-repo-git at {}",
130            outcome.mem_repo_dir.display(),
131        );
132        println!("  main: README.md (initial commit)");
133        println!(
134            "  __MEMSTEAD: empty (unified registry ref for workspace schemas + per-mem configs)"
135        );
136        println!("  config: {}", outcome.workspace_toml.display());
137        // Symmetric disclosure: whichever verb opened the workspace
138        // says which of the two shapes the user now has, what it costs,
139        // and the command for the other one. A shape statement attached
140        // only to the filesystem branch would read as a warning about
141        // that branch rather than as the fork it actually is.
142        println!();
143        println!(
144            "{}",
145            crate::setup::shape_disclosure_lines(crate::setup::WorkspaceShape::MemRepo).join("\n"),
146        );
147    }
148
149    // Outer-repo provenance is human-facing context, not part of the
150    // structured result — it always goes to stderr (never stdout) so a
151    // `--json` caller's stdout stays exactly one JSON document, and is
152    // suppressed under `--quiet`. A human still sees it on the terminal.
153    match outcome.gitignore {
154        OuterRepoOutcome::Appended { outer_root, rel } => {
155            if !ctx.quiet {
156                eprintln!(
157                    "  outer:    {} — added `{}` to .gitignore",
158                    outer_root.display(),
159                    rel,
160                );
161            }
162        }
163        OuterRepoOutcome::AlreadyIgnored { outer_root, rel } => {
164            if !ctx.quiet {
165                eprintln!(
166                    "  outer:    {} — `{}` already in .gitignore, no change",
167                    outer_root.display(),
168                    rel,
169                );
170            }
171        }
172        OuterRepoOutcome::NoOuter | OuterRepoOutcome::Skipped => {}
173    }
174    Ok(())
175}
176
177/// Library-form entry point for `memstead mem-repo init`. Creates
178/// `<workspace>/mem-repo/.git/` with a working tree on `main`, makes
179/// the initial commit, seeds the unified `__MEMSTEAD` registry ref with an
180/// empty tree, and (unless suppressed) appends `mem-repo/` to an
181/// enclosing outer-repo's `.gitignore`. Idempotent on a fresh target;
182/// refuses to overwrite an existing `mem-repo/` directory.
183pub(crate) fn run_init(
184    workspace_path: &Path,
185    skip_outer_gitignore: bool,
186) -> anyhow::Result<InitOutcome> {
187    fs::create_dir_all(workspace_path)
188        .map_err(|e| generic_error(format!("create workspace directory: {e}")))?;
189
190    let workspace = fs::canonicalize(workspace_path)
191        .map_err(|e| generic_error(format!("canonicalize workspace path: {e}")))?;
192
193    let mem_repo_root = workspace.join("mem-repo");
194    if mem_repo_root.exists() {
195        return Err(CliError {
196            code: "MEM_DB_ALREADY_EXISTS",
197            kind: ExitKind::Validation,
198            message: format!(
199                "{} already exists — refusing to overwrite. Delete or move \
200                 the existing mem-repo before re-running `mem-repo init`.",
201                mem_repo_root.display()
202            ),
203            details: None,
204        }
205        .into());
206    }
207
208    fs::create_dir_all(&mem_repo_root)
209        .map_err(|e| generic_error(format!("create mem-repo directory: {e}")))?;
210
211    gix::init(&mem_repo_root).map_err(|e| generic_error(format!("init mem-repo gitdir: {e}")))?;
212
213    let repo = gix::open(mem_repo_root.join(".git"))
214        .map_err(|e| generic_error(format!("open mem-repo gitdir: {e}")))?;
215
216    // `main` carries operator-facing docs only (README.md). Schemas and
217    // per-mem configs live on the unified `__MEMSTEAD` registry ref.
218    let mut editor = repo
219        .empty_tree()
220        .edit()
221        .map_err(|e| generic_error(format!("init main tree editor: {e}")))?;
222
223    let readme_blob = repo
224        .write_blob(README_TEMPLATE.as_bytes())
225        .map_err(|e| generic_error(format!("write README blob: {e}")))?
226        .detach();
227    editor
228        .upsert("README.md", EntryKind::Blob, readme_blob)
229        .map_err(|e| generic_error(format!("upsert README.md: {e}")))?;
230
231    let main_tree = editor
232        .write()
233        .map_err(|e| generic_error(format!("write main tree: {e}")))?
234        .detach();
235
236    let actor = init_signature();
237    let mut buf = gix::date::parse::TimeBuf::default();
238    let actor_ref = actor.to_ref(&mut buf);
239    repo.commit_as(
240        actor_ref,
241        actor_ref,
242        "refs/heads/main",
243        "mem-repo init: initial main commit",
244        main_tree,
245        Vec::<gix::ObjectId>::new(),
246    )
247    .map_err(|e| generic_error(format!("commit main: {e}")))?;
248
249    // Seed the unified `__MEMSTEAD` registry ref with an empty tree. Schemas
250    // (`__MEMSTEAD:schemas/<name>/...`) and per-mem configs
251    // (`__MEMSTEAD:mems/<mem>/config.json`) are upserted by subsequent
252    // engine writes; the empty seed lets the engine's reader resolve
253    // the ref without surfacing a bootstrap error.
254    let empty_tree = repo.empty_tree().id().detach();
255    let mut buf = gix::date::parse::TimeBuf::default();
256    let actor_ref = actor.to_ref(&mut buf);
257    repo.commit_as(
258        actor_ref,
259        actor_ref,
260        "refs/heads/__MEMSTEAD",
261        "mem-repo init: seed __MEMSTEAD",
262        empty_tree,
263        Vec::<gix::ObjectId>::new(),
264    )
265    .map_err(|e| generic_error(format!("commit __MEMSTEAD: {e}")))?;
266
267    materialise_main_worktree(&mem_repo_root)?;
268    write_default_workspace_toml(&workspace)?;
269
270    // Outer-repo gitignore append: walk up from the workspace's parent
271    // (so we don't rediscover the new mem-repo/.git/ as our own outer)
272    // looking for an enclosing `.git/`, append `mem-repo/` to its
273    // `.gitignore`. Idempotent on re-run.
274    let gitignore = if skip_outer_gitignore {
275        OuterRepoOutcome::Skipped
276    } else {
277        let walk_start = workspace
278            .parent()
279            .map(|p| p.to_path_buf())
280            .unwrap_or_else(|| workspace.clone());
281        apply_outer_gitignore(&walk_start, &mem_repo_root)?
282    };
283
284    Ok(InitOutcome {
285        mem_repo_dir: mem_repo_root,
286        workspace_toml: workspace
287            .join(memstead_base::WORKSPACE_STORE_DIR)
288            .join("workspace.toml"),
289        gitignore,
290    })
291}
292
293/// Write `README.md` to the working tree. Mirrors the just-committed
294/// `main` tree so a human inspecting `<workspace>/mem-repo/` sees
295/// the file immediately. Schemas and per-mem configs live on the
296/// `__MEMSTEAD` registry ref and are not surfaced via the worktree.
297fn materialise_main_worktree(mem_repo_root: &Path) -> anyhow::Result<()> {
298    fs::write(mem_repo_root.join("README.md"), README_TEMPLATE)
299        .map_err(|e| generic_error(format!("write working-tree README.md: {e}")))?;
300    Ok(())
301}
302
303/// Materialise the minimum-viable `.memstead/workspace.toml`. Required by
304/// every subsequent CLI / MCP command — without the file the
305/// workspace-store loader bails with `StoreError::NotInitialised` and
306/// the freshly-init'd workspace is unusable. Idempotent: a
307/// pre-existing file is left untouched so operator-authored content
308/// survives a re-init under the same workspace path.
309fn write_default_workspace_toml(workspace_root: &Path) -> anyhow::Result<()> {
310    let memstead_dir = workspace_root.join(memstead_base::WORKSPACE_STORE_DIR);
311    fs::create_dir_all(&memstead_dir)
312        .map_err(|e| generic_error(format!("create .memstead directory: {e}")))?;
313    let toml_path = memstead_dir.join("workspace.toml");
314    if toml_path.exists() {
315        return Ok(());
316    }
317    fs::write(&toml_path, DEFAULT_WORKSPACE_TOML)
318        .map_err(|e| generic_error(format!("write .memstead/workspace.toml: {e}")))?;
319    Ok(())
320}
321
322/// Result of a successful `mem-repo init`. Useful in tests for
323/// asserting on the produced shape without re-walking the repo.
324#[derive(Debug)]
325pub(crate) struct InitOutcome {
326    pub mem_repo_dir: PathBuf,
327    pub workspace_toml: PathBuf,
328    pub gitignore: OuterRepoOutcome,
329}
330
331fn init_signature() -> gix::actor::Signature {
332    gix::actor::Signature {
333        name: "memstead-cli mem-repo init".into(),
334        email: "mem-repo-init@memstead".into(),
335        time: gix::date::Time {
336            seconds: 0,
337            offset: 0,
338        },
339    }
340}
341
342fn generic_error(msg: String) -> anyhow::Error {
343    CliError {
344        code: "MEM_REPO_INIT_FAILED",
345        kind: ExitKind::Generic,
346        message: msg,
347        details: None,
348    }
349    .into()
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use tempfile::TempDir;
356
357    #[test]
358    fn memstead_mem_repo_init_creates_layout() {
359        let tmp = TempDir::new().unwrap();
360        let workspace = tmp.path().join("ws");
361        let outcome = run_init(&workspace, true).unwrap();
362
363        assert!(outcome.mem_repo_dir.exists(), "mem-repo/ must exist");
364        assert!(
365            outcome.mem_repo_dir.join(".git").exists(),
366            "mem-repo/.git/ must exist"
367        );
368        assert!(
369            outcome.mem_repo_dir.join("README.md").is_file(),
370            "mem-repo/README.md must be checked out",
371        );
372        assert!(
373            !outcome.mem_repo_dir.join("schemas").exists(),
374            "mem-repo/schemas/ must NOT be materialised (schemas live on __MEMSTEAD)",
375        );
376
377        let repo = gix::open(outcome.mem_repo_dir.join(".git")).unwrap();
378        let id = repo
379            .find_reference("refs/heads/main")
380            .unwrap()
381            .into_fully_peeled_id()
382            .unwrap();
383        let tree = repo.find_object(id).unwrap().into_commit().tree().unwrap();
384        assert!(
385            tree.lookup_entry_by_path("README.md").unwrap().is_some(),
386            "main:README.md must be present"
387        );
388        assert!(
389            tree.lookup_entry_by_path("schemas").unwrap().is_none(),
390            "main must NOT carry schemas/"
391        );
392        assert!(
393            tree.lookup_entry_by_path("configs").unwrap().is_none(),
394            "main must NOT carry configs/"
395        );
396
397        assert!(
398            repo.find_reference("refs/heads/__MEMSTEAD").is_ok(),
399            "refs/heads/__MEMSTEAD must exist after init"
400        );
401        assert!(
402            repo.try_find_reference("refs/heads/__SYSTEM")
403                .unwrap()
404                .is_none(),
405            "refs/heads/__SYSTEM must NOT be written by init"
406        );
407        assert!(
408            repo.try_find_reference("refs/heads/__SCHEMAS")
409                .unwrap()
410                .is_none(),
411            "refs/heads/__SCHEMAS must NOT be written by init"
412        );
413
414        // `memstead mem-repo init` must leave the workspace in a state
415        // every subsequent command can boot from. Without
416        // `.memstead/workspace.toml` the engine's loader bails with
417        // `StoreError::NotInitialised` and `memstead status` fails.
418        let workspace_toml = workspace
419            .canonicalize()
420            .unwrap()
421            .join(".memstead")
422            .join("workspace.toml");
423        assert_eq!(outcome.workspace_toml, workspace_toml);
424        assert!(
425            workspace_toml.is_file(),
426            ".memstead/workspace.toml must be materialised by init",
427        );
428        let body = fs::read_to_string(&workspace_toml).unwrap();
429        assert!(
430            body.contains("format = \"memstead-git-branch-2\""),
431            "workspace.toml must declare the engine format, got:\n{body}",
432        );
433        assert!(
434            body.contains("name = \"file-two-layer\""),
435            "workspace.toml must declare the file-two-layer adapter, got:\n{body}",
436        );
437    }
438
439    #[test]
440    fn memstead_mem_repo_init_preserves_existing_workspace_toml() {
441        // Operator-authored `.memstead/workspace.toml` survives a re-init
442        // under the same workspace path: the init must not clobber
443        // hand-edited allowlist / cross-link / mutation policy.
444        let tmp = TempDir::new().unwrap();
445        let workspace = tmp.path().join("ws");
446        fs::create_dir_all(workspace.join(".memstead")).unwrap();
447        let toml_path = workspace.join(".memstead").join("workspace.toml");
448        let authored = "# operator-authored\n\
449format = \"memstead-git-branch-2\"\n\
450\n\
451[persistence_adapter]\n\
452name = \"file-two-layer\"\n\
453\n\
454[[mem_management.create]]\n\
455pattern = \"exec-*\"\n\
456schemas = [\"default@1.0.0\"]\n";
457        fs::write(&toml_path, authored).unwrap();
458
459        run_init(&workspace, true).unwrap();
460        let actual = fs::read_to_string(&toml_path).unwrap();
461        assert_eq!(
462            actual, authored,
463            "init must not overwrite hand-edited workspace.toml"
464        );
465    }
466
467    #[test]
468    fn memstead_mem_repo_init_handles_outer_repo_gitignore() {
469        let tmp = TempDir::new().unwrap();
470        let outer = tmp.path().join("outer");
471        fs::create_dir_all(&outer).unwrap();
472        gix::init(&outer).unwrap();
473        let workspace = outer.join("ws");
474
475        let outcome = run_init(&workspace, false).unwrap();
476        match outcome.gitignore {
477            OuterRepoOutcome::Appended { ref outer_root, .. } => {
478                assert_eq!(
479                    outer_root.canonicalize().unwrap(),
480                    outer.canonicalize().unwrap()
481                );
482            }
483            other => panic!("expected Appended, got {other:?}"),
484        }
485
486        let gitignore = fs::read_to_string(outer.join(".gitignore")).unwrap();
487        assert!(
488            gitignore.contains("ws/mem-repo/"),
489            "expected ws/mem-repo/ in outer .gitignore, got:\n{gitignore}",
490        );
491
492        let workspace2 = outer.join("ws2");
493        fs::remove_dir_all(workspace.join("mem-repo")).unwrap();
494        let outcome2 = run_init(&workspace, false).unwrap();
495        match outcome2.gitignore {
496            OuterRepoOutcome::AlreadyIgnored { .. } => {}
497            _ => panic!("re-init under same workspace must be idempotent"),
498        }
499        let gitignore2 = fs::read_to_string(outer.join(".gitignore")).unwrap();
500        let count = gitignore2.matches("ws/mem-repo/").count();
501        assert_eq!(
502            count, 1,
503            "outer .gitignore must carry exactly one `ws/mem-repo/` line, got {count}\n{gitignore2}",
504        );
505        let _ = workspace2;
506    }
507
508    #[test]
509    fn memstead_mem_repo_init_no_gitignore_flag() {
510        let tmp = TempDir::new().unwrap();
511        let outer = tmp.path().join("outer");
512        fs::create_dir_all(&outer).unwrap();
513        gix::init(&outer).unwrap();
514        let workspace = outer.join("ws");
515
516        run_init(&workspace, true).unwrap();
517        let gitignore_path = outer.join(".gitignore");
518        if gitignore_path.exists() {
519            let body = fs::read_to_string(&gitignore_path).unwrap();
520            assert!(
521                !body.contains("mem-repo"),
522                "with --no-gitignore the outer repo's .gitignore must be untouched, got:\n{body}",
523            );
524        }
525    }
526
527    #[test]
528    fn memstead_mem_repo_init_existing_fails() {
529        let tmp = TempDir::new().unwrap();
530        let workspace = tmp.path().join("ws");
531        run_init(&workspace, true).unwrap();
532        let err = run_init(&workspace, true).unwrap_err();
533        let cli_err = err.downcast_ref::<CliError>().expect("CliError expected");
534        assert_eq!(cli_err.kind, ExitKind::Validation);
535        // The typed code is a first-class field on `CliError` rather than a
536        // `details.code` breadcrumb.
537        assert_eq!(cli_err.code, "MEM_DB_ALREADY_EXISTS");
538    }
539}