1use 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
22const DEFAULT_WORKSPACE_TOML: &str = "\
29format = \"memstead-git-branch-2\"\n\
30\n\
31[persistence_adapter]\n\
32name = \"file-two-layer\"\n";
33
34#[derive(Subcommand, Debug)]
36pub enum MemRepoAction {
37 Init(InitArgs),
39
40 #[command(name = "remote-add")]
44 RemoteAdd(RemoteAddArgs),
45}
46
47#[derive(Args, Debug)]
49pub struct RemoteAddArgs {
50 pub name: String,
52 pub url: String,
55}
56
57#[derive(Args, Debug)]
59pub struct InitArgs {
60 #[arg(default_value = ".")]
63 pub path: PathBuf,
64
65 #[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 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 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
164pub(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 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 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 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
280fn 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
290fn 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#[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 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 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 assert_eq!(cli_err.code, "MEM_DB_ALREADY_EXISTS");
525 }
526}