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 "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 println!();
143 println!(
144 "{}",
145 crate::setup::shape_disclosure_lines(crate::setup::WorkspaceShape::MemRepo).join("\n"),
146 );
147 }
148
149 match outcome.gitignore {
154 OuterRepoOutcome::Appended { outer_root, rel } => {
155 if !ctx.quiet {
156 eprintln!(
157 " outer: {} — added `{}` to .gitignore \
158 (`.memstead/` is intentionally trackable — don't ignore it)",
159 outer_root.display(),
160 rel,
161 );
162 eprint_source_layout_hint();
163 }
164 }
165 OuterRepoOutcome::AlreadyIgnored { outer_root, rel } => {
166 if !ctx.quiet {
167 eprintln!(
168 " outer: {} — `{}` already in .gitignore, no change \
169 (`.memstead/` is intentionally trackable — don't ignore it)",
170 outer_root.display(),
171 rel,
172 );
173 eprint_source_layout_hint();
174 }
175 }
176 OuterRepoOutcome::NoOuter | OuterRepoOutcome::Skipped => {}
177 }
178 Ok(())
179}
180
181fn eprint_source_layout_hint() {
191 eprintln!(
192 " layout: sources you later bind (`projection init`) resolve against this \
193 workspace root. A source outside it is supported — enumeration, change \
194 detection, and anchor resolution all work — but its artifact ids render as \
195 `../…` chains and the workspace-to-source layout must stay fixed. To model \
196 several sibling repos, root the workspace at their common parent directory."
197 );
198}
199
200pub(crate) fn run_init(
207 workspace_path: &Path,
208 skip_outer_gitignore: bool,
209) -> anyhow::Result<InitOutcome> {
210 fs::create_dir_all(workspace_path)
211 .map_err(|e| generic_error(format!("create workspace directory: {e}")))?;
212
213 let workspace = fs::canonicalize(workspace_path)
214 .map_err(|e| generic_error(format!("canonicalize workspace path: {e}")))?;
215
216 let mem_repo_root = workspace.join("mem-repo");
217 if mem_repo_root.exists() {
218 return Err(CliError {
219 code: "MEM_DB_ALREADY_EXISTS",
220 kind: ExitKind::Validation,
221 message: format!(
222 "{} already exists — refusing to overwrite. Delete or move \
223 the existing mem-repo before re-running `mem-repo init`.",
224 mem_repo_root.display()
225 ),
226 details: None,
227 }
228 .into());
229 }
230
231 fs::create_dir_all(&mem_repo_root)
232 .map_err(|e| generic_error(format!("create mem-repo directory: {e}")))?;
233
234 gix::init(&mem_repo_root).map_err(|e| generic_error(format!("init mem-repo gitdir: {e}")))?;
235
236 let repo = gix::open(mem_repo_root.join(".git"))
237 .map_err(|e| generic_error(format!("open mem-repo gitdir: {e}")))?;
238
239 let mut editor = repo
242 .empty_tree()
243 .edit()
244 .map_err(|e| generic_error(format!("init main tree editor: {e}")))?;
245
246 let readme_blob = repo
247 .write_blob(README_TEMPLATE.as_bytes())
248 .map_err(|e| generic_error(format!("write README blob: {e}")))?
249 .detach();
250 editor
251 .upsert("README.md", EntryKind::Blob, readme_blob)
252 .map_err(|e| generic_error(format!("upsert README.md: {e}")))?;
253
254 let main_tree = editor
255 .write()
256 .map_err(|e| generic_error(format!("write main tree: {e}")))?
257 .detach();
258
259 let actor = init_signature();
260 let mut buf = gix::date::parse::TimeBuf::default();
261 let actor_ref = actor.to_ref(&mut buf);
262 repo.commit_as(
263 actor_ref,
264 actor_ref,
265 "refs/heads/main",
266 "mem-repo init: initial main commit",
267 main_tree,
268 Vec::<gix::ObjectId>::new(),
269 )
270 .map_err(|e| generic_error(format!("commit main: {e}")))?;
271
272 let empty_tree = repo.empty_tree().id().detach();
278 let mut buf = gix::date::parse::TimeBuf::default();
279 let actor_ref = actor.to_ref(&mut buf);
280 repo.commit_as(
281 actor_ref,
282 actor_ref,
283 "refs/heads/__MEMSTEAD",
284 "mem-repo init: seed __MEMSTEAD",
285 empty_tree,
286 Vec::<gix::ObjectId>::new(),
287 )
288 .map_err(|e| generic_error(format!("commit __MEMSTEAD: {e}")))?;
289
290 materialise_main_worktree(&mem_repo_root)?;
291 write_default_workspace_toml(&workspace)?;
292
293 let gitignore = if skip_outer_gitignore {
302 OuterRepoOutcome::Skipped
303 } else {
304 apply_outer_gitignore(&workspace, &mem_repo_root)?
305 };
306
307 Ok(InitOutcome {
308 mem_repo_dir: mem_repo_root,
309 workspace_toml: workspace
310 .join(memstead_base::WORKSPACE_STORE_DIR)
311 .join("workspace.toml"),
312 gitignore,
313 })
314}
315
316fn materialise_main_worktree(mem_repo_root: &Path) -> anyhow::Result<()> {
321 fs::write(mem_repo_root.join("README.md"), README_TEMPLATE)
322 .map_err(|e| generic_error(format!("write working-tree README.md: {e}")))?;
323 Ok(())
324}
325
326fn write_default_workspace_toml(workspace_root: &Path) -> anyhow::Result<()> {
333 let memstead_dir = workspace_root.join(memstead_base::WORKSPACE_STORE_DIR);
334 fs::create_dir_all(&memstead_dir)
335 .map_err(|e| generic_error(format!("create .memstead directory: {e}")))?;
336 let toml_path = memstead_dir.join("workspace.toml");
337 if toml_path.exists() {
338 return Ok(());
339 }
340 fs::write(&toml_path, DEFAULT_WORKSPACE_TOML)
341 .map_err(|e| generic_error(format!("write .memstead/workspace.toml: {e}")))?;
342 Ok(())
343}
344
345#[derive(Debug)]
348pub(crate) struct InitOutcome {
349 pub mem_repo_dir: PathBuf,
350 pub workspace_toml: PathBuf,
351 pub gitignore: OuterRepoOutcome,
352}
353
354fn init_signature() -> gix::actor::Signature {
355 gix::actor::Signature {
356 name: "memstead-cli mem-repo init".into(),
357 email: "mem-repo-init@memstead".into(),
358 time: gix::date::Time {
359 seconds: 0,
360 offset: 0,
361 },
362 }
363}
364
365fn generic_error(msg: String) -> anyhow::Error {
366 CliError {
367 code: "MEM_REPO_INIT_FAILED",
368 kind: ExitKind::Generic,
369 message: msg,
370 details: None,
371 }
372 .into()
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use tempfile::TempDir;
379
380 #[test]
381 fn memstead_mem_repo_init_creates_layout() {
382 let tmp = TempDir::new().unwrap();
383 let workspace = tmp.path().join("ws");
384 let outcome = run_init(&workspace, true).unwrap();
385
386 assert!(outcome.mem_repo_dir.exists(), "mem-repo/ must exist");
387 assert!(
388 outcome.mem_repo_dir.join(".git").exists(),
389 "mem-repo/.git/ must exist"
390 );
391 assert!(
392 outcome.mem_repo_dir.join("README.md").is_file(),
393 "mem-repo/README.md must be checked out",
394 );
395 assert!(
396 !outcome.mem_repo_dir.join("schemas").exists(),
397 "mem-repo/schemas/ must NOT be materialised (schemas live on __MEMSTEAD)",
398 );
399
400 let repo = gix::open(outcome.mem_repo_dir.join(".git")).unwrap();
401 let id = repo
402 .find_reference("refs/heads/main")
403 .unwrap()
404 .into_fully_peeled_id()
405 .unwrap();
406 let tree = repo.find_object(id).unwrap().into_commit().tree().unwrap();
407 assert!(
408 tree.lookup_entry_by_path("README.md").unwrap().is_some(),
409 "main:README.md must be present"
410 );
411 assert!(
412 tree.lookup_entry_by_path("schemas").unwrap().is_none(),
413 "main must NOT carry schemas/"
414 );
415 assert!(
416 tree.lookup_entry_by_path("configs").unwrap().is_none(),
417 "main must NOT carry configs/"
418 );
419
420 assert!(
421 repo.find_reference("refs/heads/__MEMSTEAD").is_ok(),
422 "refs/heads/__MEMSTEAD must exist after init"
423 );
424 assert!(
425 repo.try_find_reference("refs/heads/__SYSTEM")
426 .unwrap()
427 .is_none(),
428 "refs/heads/__SYSTEM must NOT be written by init"
429 );
430 assert!(
431 repo.try_find_reference("refs/heads/__SCHEMAS")
432 .unwrap()
433 .is_none(),
434 "refs/heads/__SCHEMAS must NOT be written by init"
435 );
436
437 let workspace_toml = workspace
442 .canonicalize()
443 .unwrap()
444 .join(".memstead")
445 .join("workspace.toml");
446 assert_eq!(outcome.workspace_toml, workspace_toml);
447 assert!(
448 workspace_toml.is_file(),
449 ".memstead/workspace.toml must be materialised by init",
450 );
451 let body = fs::read_to_string(&workspace_toml).unwrap();
452 assert!(
453 body.contains("format = \"memstead-git-branch-2\""),
454 "workspace.toml must declare the engine format, got:\n{body}",
455 );
456 assert!(
457 body.contains("name = \"file-two-layer\""),
458 "workspace.toml must declare the file-two-layer adapter, got:\n{body}",
459 );
460 }
461
462 #[test]
463 fn memstead_mem_repo_init_preserves_existing_workspace_toml() {
464 let tmp = TempDir::new().unwrap();
468 let workspace = tmp.path().join("ws");
469 fs::create_dir_all(workspace.join(".memstead")).unwrap();
470 let toml_path = workspace.join(".memstead").join("workspace.toml");
471 let authored = "# operator-authored\n\
472format = \"memstead-git-branch-2\"\n\
473\n\
474[persistence_adapter]\n\
475name = \"file-two-layer\"\n\
476\n\
477[[mem_management.create]]\n\
478pattern = \"exec-*\"\n\
479schemas = [\"default@1.0.0\"]\n";
480 fs::write(&toml_path, authored).unwrap();
481
482 run_init(&workspace, true).unwrap();
483 let actual = fs::read_to_string(&toml_path).unwrap();
484 assert_eq!(
485 actual, authored,
486 "init must not overwrite hand-edited workspace.toml"
487 );
488 }
489
490 #[test]
491 fn memstead_mem_repo_init_handles_outer_repo_gitignore() {
492 let tmp = TempDir::new().unwrap();
493 let outer = tmp.path().join("outer");
494 fs::create_dir_all(&outer).unwrap();
495 gix::init(&outer).unwrap();
496 let workspace = outer.join("ws");
497
498 let outcome = run_init(&workspace, false).unwrap();
499 match outcome.gitignore {
500 OuterRepoOutcome::Appended { ref outer_root, .. } => {
501 assert_eq!(
502 outer_root.canonicalize().unwrap(),
503 outer.canonicalize().unwrap()
504 );
505 }
506 other => panic!("expected Appended, got {other:?}"),
507 }
508
509 let gitignore = fs::read_to_string(outer.join(".gitignore")).unwrap();
510 assert!(
511 gitignore.contains("ws/mem-repo/"),
512 "expected ws/mem-repo/ in outer .gitignore, got:\n{gitignore}",
513 );
514
515 let workspace2 = outer.join("ws2");
516 fs::remove_dir_all(workspace.join("mem-repo")).unwrap();
517 let outcome2 = run_init(&workspace, false).unwrap();
518 match outcome2.gitignore {
519 OuterRepoOutcome::AlreadyIgnored { .. } => {}
520 _ => panic!("re-init under same workspace must be idempotent"),
521 }
522 let gitignore2 = fs::read_to_string(outer.join(".gitignore")).unwrap();
523 let count = gitignore2.matches("ws/mem-repo/").count();
524 assert_eq!(
525 count, 1,
526 "outer .gitignore must carry exactly one `ws/mem-repo/` line, got {count}\n{gitignore2}",
527 );
528 let _ = workspace2;
529 }
530
531 #[test]
537 fn memstead_mem_repo_init_workspace_at_repo_root_appends() {
538 let tmp = TempDir::new().unwrap();
539 let workspace = tmp.path().join("repo");
540 fs::create_dir_all(&workspace).unwrap();
541 gix::init(&workspace).unwrap();
542
543 let outcome = run_init(&workspace, false).unwrap();
544 match outcome.gitignore {
545 OuterRepoOutcome::Appended {
546 ref outer_root,
547 ref rel,
548 } => {
549 assert_eq!(
550 outer_root.canonicalize().unwrap(),
551 workspace.canonicalize().unwrap(),
552 "the workspace itself is the outer repo"
553 );
554 assert_eq!(rel, "mem-repo/");
555 }
556 other => panic!("expected Appended, got {other:?}"),
557 }
558 let gitignore = fs::read_to_string(workspace.join(".gitignore")).unwrap();
559 assert!(
560 gitignore.contains("mem-repo/"),
561 "expected mem-repo/ in the repo's own .gitignore, got:\n{gitignore}",
562 );
563 }
564
565 #[test]
568 fn memstead_mem_repo_init_without_any_outer_repo_appends_nowhere() {
569 let tmp = TempDir::new().unwrap();
570 let workspace = tmp.path().join("free").join("ws");
571
572 let outcome = run_init(&workspace, false).unwrap();
573 assert!(
574 matches!(outcome.gitignore, OuterRepoOutcome::NoOuter),
575 "expected NoOuter, got {:?}",
576 outcome.gitignore
577 );
578 assert!(
579 !tmp.path().join(".gitignore").exists()
580 && !workspace.join(".gitignore").exists()
581 && !tmp.path().join("free").join(".gitignore").exists(),
582 "no .gitignore may be invented anywhere on the walk path"
583 );
584 }
585
586 #[test]
587 fn memstead_mem_repo_init_no_gitignore_flag() {
588 let tmp = TempDir::new().unwrap();
589 let outer = tmp.path().join("outer");
590 fs::create_dir_all(&outer).unwrap();
591 gix::init(&outer).unwrap();
592 let workspace = outer.join("ws");
593
594 run_init(&workspace, true).unwrap();
595 let gitignore_path = outer.join(".gitignore");
596 if gitignore_path.exists() {
597 let body = fs::read_to_string(&gitignore_path).unwrap();
598 assert!(
599 !body.contains("mem-repo"),
600 "with --no-gitignore the outer repo's .gitignore must be untouched, got:\n{body}",
601 );
602 }
603 }
604
605 #[test]
606 fn memstead_mem_repo_init_existing_fails() {
607 let tmp = TempDir::new().unwrap();
608 let workspace = tmp.path().join("ws");
609 run_init(&workspace, true).unwrap();
610 let err = run_init(&workspace, true).unwrap_err();
611 let cli_err = err.downcast_ref::<CliError>().expect("CliError expected");
612 assert_eq!(cli_err.kind, ExitKind::Validation);
613 assert_eq!(cli_err.code, "MEM_DB_ALREADY_EXISTS");
616 }
617}