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 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
177pub(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 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 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 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
293fn 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
303fn 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#[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 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 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 assert_eq!(cli_err.code, "MEM_DB_ALREADY_EXISTS");
538 }
539}