1use std::{
2 path::{Path, PathBuf},
3 sync::{Arc, Mutex},
4};
5
6use git2::{build::CheckoutBuilder, ErrorCode, MergeOptions, Repository, Signature};
7use tracing::{debug, info, warn};
8
9use secrecy::{ExposeSecret, SecretString};
10
11use crate::{
12 auth::AuthProvider,
13 error::MemoryError,
14 types::{validate_name, ChangedMemories, Memory, PullResult, Scope},
15};
16
17fn redact_url(url: &str) -> String {
25 if let Some(at_pos) = url.find('@') {
26 if let Some(scheme_end) = url.find("://") {
27 let scheme = &url[..scheme_end + 3];
28 let after_at = &url[at_pos + 1..];
29 return format!("{}[REDACTED]@{}", scheme, after_at);
30 }
31 }
32 url.to_string()
33}
34
35fn capture_head_oid(repo: &git2::Repository) -> Result<[u8; 20], MemoryError> {
39 match repo.head() {
40 Ok(h) => {
41 let oid = h.peel_to_commit()?.id();
42 let mut buf = [0u8; 20];
43 buf.copy_from_slice(oid.as_bytes());
44 Ok(buf)
45 }
46 Err(e) if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => {
48 Ok([0u8; 20])
49 }
50 Err(e) => Err(MemoryError::Git(e)),
51 }
52}
53
54fn fast_forward(
59 repo: &git2::Repository,
60 fetch_commit: &git2::AnnotatedCommit,
61 branch: &str,
62) -> Result<PullResult, MemoryError> {
63 let old_head = capture_head_oid(repo)?;
64
65 let refname = format!("refs/heads/{branch}");
66 let target_oid = fetch_commit.id();
67
68 match repo.find_reference(&refname) {
69 Ok(mut reference) => {
70 reference.set_target(target_oid, &format!("pull: fast-forward to {}", target_oid))?;
71 }
72 Err(e) if e.code() == ErrorCode::NotFound => {
73 repo.reference(
75 &refname,
76 target_oid,
77 true,
78 &format!("pull: create branch {} from fetch", branch),
79 )?;
80 }
81 Err(e) => return Err(MemoryError::Git(e)),
82 }
83
84 repo.set_head(&refname)?;
85 let mut checkout = CheckoutBuilder::default();
86 checkout.force();
87 repo.checkout_head(Some(&mut checkout))?;
88
89 let mut new_head = [0u8; 20];
90 new_head.copy_from_slice(target_oid.as_bytes());
91
92 info!("pull: fast-forwarded to {}", target_oid);
93 Ok(PullResult::FastForward { old_head, new_head })
94}
95
96fn build_auth_callbacks(token: SecretString) -> git2::RemoteCallbacks<'static> {
100 let mut callbacks = git2::RemoteCallbacks::new();
101 callbacks.credentials(move |_url, _username, _allowed| {
102 git2::Cred::userpass_plaintext("x-access-token", token.expose_secret())
103 });
104 callbacks
105}
106
107pub struct MemoryRepo {
109 inner: Mutex<Repository>,
110 root: PathBuf,
111}
112
113unsafe impl Send for MemoryRepo {}
117unsafe impl Sync for MemoryRepo {}
118
119impl MemoryRepo {
120 pub fn init_or_open(path: &Path, remote_url: Option<&str>) -> Result<Self, MemoryError> {
125 let _span = tracing::info_span!("repo.init").entered();
126
127 let repo = if path.join(".git").exists() {
128 Repository::open(path)?
129 } else {
130 let mut opts = git2::RepositoryInitOptions::new();
131 opts.initial_head("main");
132 let repo = Repository::init_opts(path, &opts)?;
133 let gitignore = path.join(".gitignore");
135 if !gitignore.exists() {
136 std::fs::write(&gitignore, ".memory-mcp-index/\n")?;
137 }
138 {
140 let mut index = repo.index()?;
141 index.add_path(Path::new(".gitignore"))?;
142 index.write()?;
143 let tree_oid = index.write_tree()?;
144 let tree = repo.find_tree(tree_oid)?;
145 let sig = Signature::now("memory-mcp", "memory-mcp@local")?;
146 repo.commit(
147 Some("HEAD"),
148 &sig,
149 &sig,
150 "chore: init repository",
151 &tree,
152 &[],
153 )?;
154 }
155 repo
156 };
157
158 if let Some(url) = remote_url {
160 match repo.find_remote("origin") {
161 Ok(existing) => {
162 let current_url = existing.url().unwrap_or("");
164 if current_url != url {
165 repo.remote_set_url("origin", url)?;
166 info!("updated origin remote URL to {}", redact_url(url));
167 }
168 }
169 Err(e) if e.code() == ErrorCode::NotFound => {
170 repo.remote("origin", url)?;
171 info!("created origin remote pointing at {}", redact_url(url));
172 }
173 Err(e) => return Err(MemoryError::Git(e)),
174 }
175 }
176
177 Ok(Self {
178 inner: Mutex::new(repo),
179 root: path.to_path_buf(),
180 })
181 }
182
183 fn memory_path(&self, name: &str, scope: &Scope) -> PathBuf {
185 self.root
186 .join(scope.dir_prefix())
187 .join(format!("{}.md", name))
188 }
189
190 pub async fn save_memory(self: &Arc<Self>, memory: &Memory) -> Result<(), MemoryError> {
195 validate_name(&memory.name)?;
196 if let Scope::Project(ref project_name) = memory.metadata.scope {
197 validate_name(project_name)?;
198 }
199
200 let file_path = self.memory_path(&memory.name, &memory.metadata.scope);
201 self.assert_within_root(&file_path)?;
202
203 let arc = Arc::clone(self);
204 let memory = memory.clone();
205 let name = memory.name.clone();
206
207 let span = tracing::debug_span!("repo.save", name = %name, oid = tracing::field::Empty);
209
210 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
211 let _enter = span.entered();
212 let repo = arc
213 .inner
214 .lock()
215 .expect("lock poisoned — prior panic corrupted state");
216
217 if let Some(parent) = file_path.parent() {
219 std::fs::create_dir_all(parent)?;
220 }
221
222 let markdown = memory.to_markdown()?;
223 arc.write_memory_file(&file_path, markdown.as_bytes())?;
224
225 arc.git_add_and_commit(
226 &repo,
227 &file_path,
228 &format!("chore: save memory '{}'", memory.name),
229 )?;
230
231 if let Ok(head) = repo.head() {
233 if let Ok(commit) = head.peel_to_commit() {
234 tracing::Span::current().record("oid", commit.id().to_string().as_str());
235 debug!(oid = %commit.id(), "memory saved to repo");
236 }
237 }
238
239 Ok(())
240 })
241 .await
242 .map_err(|e| MemoryError::Join(e.to_string()))?
243 }
244
245 pub async fn delete_memory(
247 self: &Arc<Self>,
248 name: &str,
249 scope: &Scope,
250 ) -> Result<(), MemoryError> {
251 validate_name(name)?;
252 if let Scope::Project(ref project_name) = *scope {
253 validate_name(project_name)?;
254 }
255
256 let file_path = self.memory_path(name, scope);
257 self.assert_within_root(&file_path)?;
258
259 let arc = Arc::clone(self);
260 let name = name.to_string();
261 let file_path_clone = file_path.clone();
262 let span = tracing::debug_span!("repo.delete", name = %name);
263 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
264 let _enter = span.entered();
265 let repo = arc
266 .inner
267 .lock()
268 .expect("lock poisoned — prior panic corrupted state");
269
270 match std::fs::symlink_metadata(&file_path_clone) {
272 Err(_) => return Err(MemoryError::NotFound { name: name.clone() }),
273 Ok(m) if m.file_type().is_symlink() => {
274 return Err(MemoryError::InvalidInput {
275 reason: format!(
276 "path '{}' is a symlink, which is not permitted",
277 file_path_clone.display()
278 ),
279 });
280 }
281 Ok(_) => {}
282 }
283
284 std::fs::remove_file(&file_path_clone)?;
285 let relative =
287 file_path_clone
288 .strip_prefix(&arc.root)
289 .map_err(|e| MemoryError::InvalidInput {
290 reason: format!("path strip error: {}", e),
291 })?;
292 let mut index = repo.index()?;
293 index.remove_path(relative)?;
294 index.write()?;
295
296 let tree_oid = index.write_tree()?;
297 let tree = repo.find_tree(tree_oid)?;
298 let sig = arc.signature(&repo)?;
299 let message = format!("chore: delete memory '{}'", name);
300
301 match repo.head() {
302 Ok(head) => {
303 let parent_commit = head.peel_to_commit()?;
304 repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&parent_commit])?;
305 }
306 Err(e)
307 if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound =>
308 {
309 repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[])?;
310 }
311 Err(e) => return Err(MemoryError::Git(e)),
312 }
313
314 Ok(())
315 })
316 .await
317 .map_err(|e| MemoryError::Join(e.to_string()))?
318 }
319
320 pub async fn read_memory(
322 self: &Arc<Self>,
323 name: &str,
324 scope: &Scope,
325 ) -> Result<Memory, MemoryError> {
326 validate_name(name)?;
327 if let Scope::Project(ref project_name) = *scope {
328 validate_name(project_name)?;
329 }
330
331 let file_path = self.memory_path(name, scope);
332 self.assert_within_root(&file_path)?;
333
334 let arc = Arc::clone(self);
335 let name = name.to_string();
336 let span = tracing::debug_span!("repo.read", name = %name);
337 tokio::task::spawn_blocking(move || -> Result<Memory, MemoryError> {
338 let _enter = span.entered();
339 match std::fs::symlink_metadata(&file_path) {
341 Err(_) => return Err(MemoryError::NotFound { name }),
342 Ok(m) if m.file_type().is_symlink() => {
343 return Err(MemoryError::InvalidInput {
344 reason: format!(
345 "path '{}' is a symlink, which is not permitted",
346 file_path.display()
347 ),
348 });
349 }
350 Ok(_) => {}
351 }
352 let raw = arc.read_memory_file(&file_path)?;
353 Memory::from_markdown(&raw)
354 })
355 .await
356 .map_err(|e| MemoryError::Join(e.to_string()))?
357 }
358
359 pub async fn list_memories(
361 self: &Arc<Self>,
362 scope: Option<&Scope>,
363 ) -> Result<Vec<Memory>, MemoryError> {
364 let root = self.root.clone();
365 let scope_clone = scope.cloned();
366 let span = tracing::debug_span!("repo.list", file_count = tracing::field::Empty,);
367
368 tokio::task::spawn_blocking(move || -> Result<Vec<Memory>, MemoryError> {
369 let _enter = span.entered();
370 let dirs: Vec<PathBuf> = match scope_clone.as_ref() {
371 Some(s) => vec![root.join(s.dir_prefix())],
372 None => {
373 let mut dirs = Vec::new();
375 let global = root.join("global");
376 if global.exists() {
377 dirs.push(global);
378 }
379 let projects = root.join("projects");
380 if projects.exists() {
381 for entry in std::fs::read_dir(&projects)? {
382 let entry = entry?;
383 if entry.file_type()?.is_dir() {
384 dirs.push(entry.path());
385 }
386 }
387 }
388 dirs
389 }
390 };
391
392 fn collect_md_files(dir: &Path, out: &mut Vec<Memory>) -> Result<(), MemoryError> {
393 if !dir.exists() {
394 return Ok(());
395 }
396 for entry in std::fs::read_dir(dir)? {
397 let entry = entry?;
398 let path = entry.path();
399 let ft = entry.file_type()?;
400 if ft.is_symlink() {
402 warn!(
403 "skipping symlink at {:?} — symlinks are not permitted in the memory store",
404 path
405 );
406 continue;
407 }
408 if ft.is_dir() {
409 collect_md_files(&path, out)?;
410 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
411 let raw = std::fs::read_to_string(&path)?;
412 match Memory::from_markdown(&raw) {
413 Ok(m) => out.push(m),
414 Err(e) => {
415 warn!("skipping {:?}: {}", path, e);
416 }
417 }
418 }
419 }
420 Ok(())
421 }
422
423 let mut memories = Vec::new();
424 for dir in dirs {
425 collect_md_files(&dir, &mut memories)?;
426 }
427
428 tracing::Span::current().record("file_count", memories.len());
429
430 Ok(memories)
431 })
432 .await
433 .map_err(|e| MemoryError::Join(e.to_string()))?
434 }
435
436 pub async fn push(
441 self: &Arc<Self>,
442 auth: &AuthProvider,
443 branch: &str,
444 ) -> Result<(), MemoryError> {
445 let token_result = auth.resolve_token();
449 let arc = Arc::clone(self);
450 let branch = branch.to_string();
451 let span = tracing::debug_span!("repo.push", branch = %branch);
452
453 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
454 let _enter = span.entered();
455 let repo = arc
456 .inner
457 .lock()
458 .expect("lock poisoned — prior panic corrupted state");
459
460 let mut remote = match repo.find_remote("origin") {
461 Ok(r) => r,
462 Err(e) if e.code() == ErrorCode::NotFound => {
463 warn!("push: no origin remote configured — skipping (local-only mode)");
464 return Ok(());
465 }
466 Err(e) => return Err(MemoryError::Git(e)),
467 };
468
469 let token = token_result?;
471 let mut callbacks = build_auth_callbacks(token);
472
473 let rejections: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
476 let rej = Arc::clone(&rejections);
477 callbacks.push_update_reference(move |refname, status| {
478 if let Some(msg) = status {
479 rej.lock()
480 .expect("rejection lock poisoned")
481 .push(format!("{refname}: {msg}"));
482 }
483 Ok(())
484 });
485
486 let mut push_opts = git2::PushOptions::new();
487 push_opts.remote_callbacks(callbacks);
488
489 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
490 if let Err(e) = remote.push(&[&refspec], Some(&mut push_opts)) {
491 warn!("push to origin failed at transport level: {e}");
492 return Err(MemoryError::Git(e));
493 }
494
495 let rejected = rejections.lock().expect("rejection lock poisoned");
496 if !rejected.is_empty() {
497 return Err(MemoryError::PushRejected(rejected.join("; ")));
498 }
499
500 info!("pushed branch '{}' to origin", branch);
501 Ok(())
502 })
503 .await
504 .map_err(|e| MemoryError::Join(e.to_string()))?
505 }
506
507 fn merge_with_remote(
512 &self,
513 repo: &git2::Repository,
514 fetch_commit: &git2::AnnotatedCommit,
515 branch: &str,
516 ) -> Result<PullResult, MemoryError> {
517 let oid = repo.head()?.peel_to_commit()?.id();
521 let mut old_head = [0u8; 20];
522 old_head.copy_from_slice(oid.as_bytes());
523
524 let mut merge_opts = MergeOptions::new();
525 merge_opts.fail_on_conflict(false);
526 repo.merge(&[fetch_commit], Some(&mut merge_opts), None)?;
527
528 let mut index = repo.index()?;
529 let conflicts_resolved = if index.has_conflicts() {
530 self.resolve_conflicts_by_recency(repo, &mut index)?
531 } else {
532 0
533 };
534
535 if index.has_conflicts() {
539 let _ = repo.cleanup_state();
540 return Err(MemoryError::Internal(
541 "unresolved conflicts remain after auto-resolution".into(),
542 ));
543 }
544
545 index.write()?;
547 let tree_oid = index.write_tree()?;
548 let tree = repo.find_tree(tree_oid)?;
549 let sig = self.signature(repo)?;
550
551 let head_commit = repo.head()?.peel_to_commit()?;
552 let fetch_commit_obj = repo.find_commit(fetch_commit.id())?;
553
554 let new_commit_oid = repo.commit(
555 Some("HEAD"),
556 &sig,
557 &sig,
558 &format!("chore: merge origin/{}", branch),
559 &tree,
560 &[&head_commit, &fetch_commit_obj],
561 )?;
562
563 repo.cleanup_state()?;
564
565 let mut new_head = [0u8; 20];
566 new_head.copy_from_slice(new_commit_oid.as_bytes());
567
568 info!(
569 "pull: merge complete ({} conflicts auto-resolved)",
570 conflicts_resolved
571 );
572 Ok(PullResult::Merged {
573 conflicts_resolved,
574 old_head,
575 new_head,
576 })
577 }
578
579 pub async fn pull(
585 self: &Arc<Self>,
586 auth: &AuthProvider,
587 branch: &str,
588 ) -> Result<PullResult, MemoryError> {
589 let token_result = auth.resolve_token();
593 let arc = Arc::clone(self);
594 let branch = branch.to_string();
595 let span = tracing::debug_span!("repo.pull", branch = %branch);
596
597 tokio::task::spawn_blocking(move || -> Result<PullResult, MemoryError> {
598 let _enter = span.entered();
599 let repo = arc
600 .inner
601 .lock()
602 .expect("lock poisoned — prior panic corrupted state");
603
604 let mut remote = match repo.find_remote("origin") {
606 Ok(r) => r,
607 Err(e) if e.code() == ErrorCode::NotFound => {
608 warn!("pull: no origin remote configured — skipping (local-only mode)");
609 return Ok(PullResult::NoRemote);
610 }
611 Err(e) => return Err(MemoryError::Git(e)),
612 };
613
614 let token = token_result?;
616
617 let callbacks = build_auth_callbacks(token);
619 let mut fetch_opts = git2::FetchOptions::new();
620 fetch_opts.remote_callbacks(callbacks);
621 remote.fetch(&[&branch], Some(&mut fetch_opts), None)?;
622
623 let fetch_head = match repo.find_reference("FETCH_HEAD") {
625 Ok(r) => r,
626 Err(e) if e.code() == ErrorCode::NotFound => {
627 return Ok(PullResult::UpToDate);
629 }
630 Err(e)
631 if e.class() == git2::ErrorClass::Reference
632 && e.message().contains("corrupted") =>
633 {
634 info!("pull: FETCH_HEAD is empty or corrupted — treating as empty remote");
636 return Ok(PullResult::UpToDate);
637 }
638 Err(e) => return Err(MemoryError::Git(e)),
639 };
640 let fetch_commit = match repo.reference_to_annotated_commit(&fetch_head) {
641 Ok(c) => c,
642 Err(e) if e.class() == git2::ErrorClass::Reference => {
643 info!("pull: FETCH_HEAD not resolvable — treating as empty remote");
645 return Ok(PullResult::UpToDate);
646 }
647 Err(e) => return Err(MemoryError::Git(e)),
648 };
649
650 let (analysis, _preference) = repo.merge_analysis(&[&fetch_commit])?;
652
653 if analysis.is_up_to_date() {
654 info!("pull: already up to date");
655 return Ok(PullResult::UpToDate);
656 }
657
658 if analysis.is_fast_forward() {
659 return fast_forward(&repo, &fetch_commit, &branch);
660 }
661
662 arc.merge_with_remote(&repo, &fetch_commit, &branch)
663 })
664 .await
665 .map_err(|e| MemoryError::Join(e.to_string()))?
666 }
667
668 pub fn diff_changed_memories(
676 &self,
677 old_oid: [u8; 20],
678 new_oid: [u8; 20],
679 ) -> Result<ChangedMemories, MemoryError> {
680 let repo = self
681 .inner
682 .lock()
683 .expect("lock poisoned — prior panic corrupted state");
684
685 let new_git_oid = git2::Oid::from_bytes(&new_oid).map_err(MemoryError::Git)?;
686 let new_tree = repo.find_commit(new_git_oid)?.tree()?;
687
688 let diff = if old_oid == [0u8; 20] {
691 repo.diff_tree_to_tree(None, Some(&new_tree), None)?
692 } else {
693 let old_git_oid = git2::Oid::from_bytes(&old_oid).map_err(MemoryError::Git)?;
694 let old_tree = repo.find_commit(old_git_oid)?.tree()?;
695 repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?
696 };
697
698 let mut changes = ChangedMemories::default();
699
700 diff.foreach(
701 &mut |delta, _progress| {
702 use git2::Delta;
703
704 let path = match delta.new_file().path().or_else(|| delta.old_file().path()) {
705 Some(p) => p,
706 None => return true,
707 };
708
709 let path_str = match path.to_str() {
710 Some(s) => s,
711 None => return true,
712 };
713
714 if !path_str.ends_with(".md") {
716 return true;
717 }
718 if !path_str.starts_with("global/") && !path_str.starts_with("projects/") {
719 return true;
720 }
721
722 let qualified = &path_str[..path_str.len() - 3];
724
725 match delta.status() {
726 Delta::Added | Delta::Modified => {
727 changes.upserted.push(qualified.to_string());
728 }
729 Delta::Renamed | Delta::Copied => {
730 if matches!(delta.status(), Delta::Renamed) {
733 if let Some(old_path) = delta.old_file().path().and_then(|p| p.to_str())
734 {
735 if old_path.ends_with(".md")
736 && (old_path.starts_with("global/")
737 || old_path.starts_with("projects/"))
738 {
739 changes
740 .removed
741 .push(old_path[..old_path.len() - 3].to_string());
742 }
743 }
744 }
745 changes.upserted.push(qualified.to_string());
746 }
747 Delta::Deleted => {
748 changes.removed.push(qualified.to_string());
749 }
750 _ => {}
751 }
752
753 true
754 },
755 None,
756 None,
757 None,
758 )
759 .map_err(MemoryError::Git)?;
760
761 Ok(changes)
762 }
763
764 fn resolve_conflicts_by_recency(
774 &self,
775 repo: &Repository,
776 index: &mut git2::Index,
777 ) -> Result<usize, MemoryError> {
778 struct ConflictInfo {
780 path: PathBuf,
781 our_blob: Option<Vec<u8>>,
782 their_blob: Option<Vec<u8>>,
783 }
784
785 let mut conflicts_info: Vec<ConflictInfo> = Vec::new();
786
787 {
788 let conflicts = index.conflicts()?;
789 for conflict in conflicts {
790 let conflict = conflict?;
791
792 let path = conflict
793 .our
794 .as_ref()
795 .or(conflict.their.as_ref())
796 .and_then(|e| std::str::from_utf8(&e.path).ok())
797 .map(|s| self.root.join(s));
798
799 let path = match path {
800 Some(p) => p,
801 None => continue,
802 };
803
804 let our_blob = conflict
805 .our
806 .as_ref()
807 .and_then(|e| repo.find_blob(e.id).ok())
808 .map(|b| b.content().to_vec());
809
810 let their_blob = conflict
811 .their
812 .as_ref()
813 .and_then(|e| repo.find_blob(e.id).ok())
814 .map(|b| b.content().to_vec());
815
816 conflicts_info.push(ConflictInfo {
817 path,
818 our_blob,
819 their_blob,
820 });
821 }
822 }
823
824 let mut resolved = 0usize;
825
826 for info in conflicts_info {
827 let our_str = info
828 .our_blob
829 .as_deref()
830 .and_then(|b| std::str::from_utf8(b).ok())
831 .map(str::to_owned);
832 let their_str = info
833 .their_blob
834 .as_deref()
835 .and_then(|b| std::str::from_utf8(b).ok())
836 .map(str::to_owned);
837
838 let our_ts = our_str
839 .as_deref()
840 .and_then(|s| Memory::from_markdown(s).ok())
841 .map(|m| m.metadata.updated_at);
842 let their_ts = their_str
843 .as_deref()
844 .and_then(|s| Memory::from_markdown(s).ok())
845 .map(|m| m.metadata.updated_at);
846
847 let (chosen_bytes, label): (Vec<u8>, String) =
849 match (our_str.as_deref(), their_str.as_deref()) {
850 (Some(ours), Some(theirs)) => match (our_ts, their_ts) {
851 (Some(ot), Some(tt)) if tt > ot => (
852 theirs.as_bytes().to_vec(),
853 format!("theirs (updated_at: {})", tt),
854 ),
855 (Some(ot), _) => (
856 ours.as_bytes().to_vec(),
857 format!("ours (updated_at: {})", ot),
858 ),
859 _ => (
860 ours.as_bytes().to_vec(),
861 "ours (timestamp unparseable)".to_string(),
862 ),
863 },
864 (Some(ours), None) => (
865 ours.as_bytes().to_vec(),
866 "ours (theirs missing)".to_string(),
867 ),
868 (None, Some(theirs)) => (
869 theirs.as_bytes().to_vec(),
870 "theirs (ours missing)".to_string(),
871 ),
872 (None, None) => {
873 match (info.our_blob.as_deref(), info.their_blob.as_deref()) {
875 (Some(ours), _) => {
876 (ours.to_vec(), "ours (binary/non-UTF-8)".to_string())
877 }
878 (_, Some(theirs)) => {
879 (theirs.to_vec(), "theirs (binary/non-UTF-8)".to_string())
880 }
881 (None, None) => {
882 warn!(
885 "conflict at '{}': both sides missing — removing from index",
886 info.path.display()
887 );
888 let relative = info.path.strip_prefix(&self.root).map_err(|e| {
889 MemoryError::InvalidInput {
890 reason: format!(
891 "path strip error during conflict resolution: {}",
892 e
893 ),
894 }
895 })?;
896 index.conflict_remove(relative)?;
897 resolved += 1;
898 continue;
899 }
900 }
901 }
902 };
903
904 warn!(
905 "conflict resolved: {} — kept {}",
906 info.path.display(),
907 label
908 );
909
910 self.assert_within_root(&info.path)?;
914 if let Some(parent) = info.path.parent() {
915 std::fs::create_dir_all(parent)?;
916 }
917 self.write_memory_file(&info.path, &chosen_bytes)?;
918
919 let relative =
921 info.path
922 .strip_prefix(&self.root)
923 .map_err(|e| MemoryError::InvalidInput {
924 reason: format!("path strip error during conflict resolution: {}", e),
925 })?;
926 index.add_path(relative)?;
927
928 resolved += 1;
929 }
930
931 Ok(resolved)
932 }
933
934 fn signature<'r>(&self, repo: &'r Repository) -> Result<Signature<'r>, MemoryError> {
935 let sig = repo
937 .signature()
938 .or_else(|_| Signature::now("memory-mcp", "memory-mcp@local"))?;
939 Ok(sig)
940 }
941
942 fn git_add_and_commit(
944 &self,
945 repo: &Repository,
946 file_path: &Path,
947 message: &str,
948 ) -> Result<(), MemoryError> {
949 let relative =
950 file_path
951 .strip_prefix(&self.root)
952 .map_err(|e| MemoryError::InvalidInput {
953 reason: format!("path strip error: {}", e),
954 })?;
955
956 let mut index = repo.index()?;
957 index.add_path(relative)?;
958 index.write()?;
959
960 let tree_oid = index.write_tree()?;
961 let tree = repo.find_tree(tree_oid)?;
962 let sig = self.signature(repo)?;
963
964 match repo.head() {
965 Ok(head) => {
966 let parent_commit = head.peel_to_commit()?;
967 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])?;
968 }
969 Err(e) if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => {
970 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
972 }
973 Err(e) => return Err(MemoryError::Git(e)),
974 }
975
976 Ok(())
977 }
978
979 fn assert_within_root(&self, path: &Path) -> Result<(), MemoryError> {
982 let parent = path.parent().unwrap_or(path);
985 let filename = path.file_name().ok_or_else(|| MemoryError::InvalidInput {
986 reason: "path has no filename component".to_string(),
987 })?;
988
989 let canon_parent = {
992 let mut p = parent.to_path_buf();
993 let mut suffixes: Vec<std::ffi::OsString> = Vec::new();
994 loop {
995 match p.canonicalize() {
996 Ok(c) => {
997 let mut full = c;
998 for s in suffixes.into_iter().rev() {
999 full.push(s);
1000 }
1001 break full;
1002 }
1003 Err(_) => {
1004 if let Some(name) = p.file_name() {
1005 suffixes.push(name.to_os_string());
1006 }
1007 match p.parent() {
1008 Some(par) => p = par.to_path_buf(),
1009 None => {
1010 return Err(MemoryError::InvalidInput {
1011 reason: "cannot resolve any ancestor of path".into(),
1012 });
1013 }
1014 }
1015 }
1016 }
1017 }
1018 };
1019
1020 let resolved = canon_parent.join(filename);
1021
1022 let canon_root = self
1023 .root
1024 .canonicalize()
1025 .map_err(|e| MemoryError::InvalidInput {
1026 reason: format!("cannot canonicalize repo root: {}", e),
1027 })?;
1028
1029 if !resolved.starts_with(&canon_root) {
1030 return Err(MemoryError::InvalidInput {
1031 reason: format!(
1032 "path '{}' escapes repository root '{}'",
1033 resolved.display(),
1034 canon_root.display()
1035 ),
1036 });
1037 }
1038
1039 {
1044 let mut probe = canon_root.clone();
1045 let relative =
1047 resolved
1048 .strip_prefix(&canon_root)
1049 .map_err(|e| MemoryError::InvalidInput {
1050 reason: format!("path strip error: {}", e),
1051 })?;
1052 for component in relative.components() {
1053 probe.push(component);
1054 if (probe.exists() || probe.symlink_metadata().is_ok())
1056 && probe
1057 .symlink_metadata()
1058 .map(|m| m.file_type().is_symlink())
1059 .unwrap_or(false)
1060 {
1061 return Err(MemoryError::InvalidInput {
1062 reason: format!(
1063 "path component '{}' is a symlink, which is not allowed",
1064 probe.display()
1065 ),
1066 });
1067 }
1068 }
1069 }
1070
1071 Ok(())
1072 }
1073
1074 fn write_memory_file(&self, path: &Path, data: &[u8]) -> Result<(), MemoryError> {
1085 if path
1087 .symlink_metadata()
1088 .map(|m| m.file_type().is_symlink())
1089 .unwrap_or(false)
1090 {
1091 return Err(MemoryError::InvalidInput {
1092 reason: format!("refusing to write through symlink: {}", path.display()),
1093 });
1094 }
1095
1096 #[cfg(unix)]
1100 {
1101 use std::os::unix::fs::OpenOptionsExt as _;
1102 if let Err(e) = std::fs::OpenOptions::new()
1103 .read(true)
1104 .custom_flags(libc::O_NOFOLLOW)
1105 .open(path)
1106 {
1107 if e.kind() != std::io::ErrorKind::NotFound {
1109 return Err(MemoryError::InvalidInput {
1110 reason: format!("O_NOFOLLOW check failed for {}: {e}", path.display()),
1111 });
1112 }
1113 }
1114 }
1115
1116 crate::fs_util::atomic_write(path, data)?;
1117 Ok(())
1118 }
1119
1120 fn read_memory_file(&self, path: &Path) -> Result<String, MemoryError> {
1125 #[cfg(unix)]
1126 {
1127 use std::io::Read as _;
1128 use std::os::unix::fs::OpenOptionsExt as _;
1129 let mut f = std::fs::OpenOptions::new()
1130 .read(true)
1131 .custom_flags(libc::O_NOFOLLOW)
1132 .open(path)?;
1133 let mut buf = String::new();
1134 f.read_to_string(&mut buf)?;
1135 Ok(buf)
1136 }
1137 #[cfg(not(unix))]
1138 {
1139 Ok(std::fs::read_to_string(path)?)
1140 }
1141 }
1142}
1143
1144#[cfg(test)]
1149mod tests {
1150 use super::*;
1151 use crate::auth::AuthProvider;
1152 use crate::types::{Memory, MemoryMetadata, PullResult, Scope};
1153 use std::sync::Arc;
1154
1155 fn test_auth() -> AuthProvider {
1156 AuthProvider::with_token("test-token-unused-for-file-remotes")
1157 }
1158
1159 fn make_memory(name: &str, content: &str, updated_at_secs: i64) -> Memory {
1160 let meta = MemoryMetadata {
1161 tags: vec![],
1162 scope: Scope::Global,
1163 created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
1164 updated_at: chrono::DateTime::from_timestamp(updated_at_secs, 0).unwrap(),
1165 source: None,
1166 };
1167 Memory::new(name.to_string(), content.to_string(), meta)
1168 }
1169
1170 fn setup_bare_remote() -> (tempfile::TempDir, String) {
1171 let dir = tempfile::tempdir().expect("failed to create temp dir");
1172 git2::Repository::init_bare(dir.path()).expect("failed to init bare repo");
1173 let url = format!("file://{}", dir.path().display());
1174 (dir, url)
1175 }
1176
1177 fn open_repo(dir: &tempfile::TempDir, remote_url: Option<&str>) -> Arc<MemoryRepo> {
1178 Arc::new(MemoryRepo::init_or_open(dir.path(), remote_url).expect("failed to init repo"))
1179 }
1180
1181 #[test]
1184 fn redact_url_strips_userinfo() {
1185 assert_eq!(
1186 redact_url("https://user:ghp_token123@github.com/org/repo.git"),
1187 "https://[REDACTED]@github.com/org/repo.git"
1188 );
1189 }
1190
1191 #[test]
1192 fn redact_url_no_at_passthrough() {
1193 let url = "https://github.com/org/repo.git";
1194 assert_eq!(redact_url(url), url);
1195 }
1196
1197 #[test]
1198 fn redact_url_file_protocol_passthrough() {
1199 let url = "file:///tmp/bare.git";
1200 assert_eq!(redact_url(url), url);
1201 }
1202
1203 #[test]
1206 fn assert_within_root_accepts_valid_path() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1209 let valid = dir.path().join("global").join("my-memory.md");
1210 std::fs::create_dir_all(valid.parent().unwrap()).unwrap();
1212 assert!(repo.assert_within_root(&valid).is_ok());
1213 }
1214
1215 #[test]
1216 fn assert_within_root_rejects_escape() {
1217 let dir = tempfile::tempdir().unwrap();
1218 let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1219 let _evil = dir
1222 .path()
1223 .join("..")
1224 .join("..")
1225 .join("..")
1226 .join("tmp")
1227 .join("evil.md");
1228 let outside = std::path::PathBuf::from("/tmp/definitely-outside");
1232 assert!(repo.assert_within_root(&outside).is_err());
1233 }
1234
1235 #[tokio::test]
1238 async fn push_local_only_returns_ok() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let repo = open_repo(&dir, None);
1241 let auth = test_auth();
1242 let result = repo.push(&auth, "main").await;
1244 assert!(result.is_ok());
1245 }
1246
1247 #[tokio::test]
1248 async fn pull_local_only_returns_no_remote() {
1249 let dir = tempfile::tempdir().unwrap();
1250 let repo = open_repo(&dir, None);
1251 let auth = test_auth();
1252 let result = repo.pull(&auth, "main").await.unwrap();
1253 assert!(matches!(result, PullResult::NoRemote));
1254 }
1255
1256 #[tokio::test]
1259 async fn push_to_bare_remote() {
1260 let (_remote_dir, remote_url) = setup_bare_remote();
1261 let local_dir = tempfile::tempdir().unwrap();
1262 let repo = open_repo(&local_dir, Some(&remote_url));
1263 let auth = test_auth();
1264
1265 let mem = make_memory("test-push", "push content", 1_700_000_000);
1267 repo.save_memory(&mem).await.unwrap();
1268
1269 repo.push(&auth, "main").await.unwrap();
1271
1272 let bare = git2::Repository::open_bare(_remote_dir.path()).unwrap();
1274 let head = bare.find_reference("refs/heads/main").unwrap();
1275 let commit = head.peel_to_commit().unwrap();
1276 assert!(commit.message().unwrap().contains("test-push"));
1277 }
1278
1279 #[tokio::test]
1280 async fn pull_from_empty_bare_remote_returns_up_to_date() {
1281 let (_remote_dir, remote_url) = setup_bare_remote();
1282 let local_dir = tempfile::tempdir().unwrap();
1283 let repo = open_repo(&local_dir, Some(&remote_url));
1284 let auth = test_auth();
1285
1286 let mem = make_memory("seed", "seed content", 1_700_000_000);
1288 repo.save_memory(&mem).await.unwrap();
1289
1290 let result = repo.pull(&auth, "main").await.unwrap();
1292 assert!(matches!(result, PullResult::UpToDate));
1293 }
1294
1295 #[tokio::test]
1296 async fn pull_fast_forward() {
1297 let (_remote_dir, remote_url) = setup_bare_remote();
1298 let auth = test_auth();
1299
1300 let dir_a = tempfile::tempdir().unwrap();
1302 let repo_a = open_repo(&dir_a, Some(&remote_url));
1303 let mem = make_memory("from-a", "content from A", 1_700_000_000);
1304 repo_a.save_memory(&mem).await.unwrap();
1305 repo_a.push(&auth, "main").await.unwrap();
1306
1307 let dir_b = tempfile::tempdir().unwrap();
1309 let repo_b = open_repo(&dir_b, Some(&remote_url));
1310 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1312 repo_b.save_memory(&seed).await.unwrap();
1313
1314 let result = repo_b.pull(&auth, "main").await.unwrap();
1315 assert!(
1316 matches!(
1317 result,
1318 PullResult::FastForward { .. } | PullResult::Merged { .. }
1319 ),
1320 "expected fast-forward or merge, got {:?}",
1321 result
1322 );
1323
1324 let file = dir_b.path().join("global").join("from-a.md");
1326 assert!(file.exists(), "from-a.md should exist in repo B after pull");
1327 }
1328
1329 #[tokio::test]
1330 async fn pull_up_to_date_after_push() {
1331 let (_remote_dir, remote_url) = setup_bare_remote();
1332 let local_dir = tempfile::tempdir().unwrap();
1333 let repo = open_repo(&local_dir, Some(&remote_url));
1334 let auth = test_auth();
1335
1336 let mem = make_memory("synced", "synced content", 1_700_000_000);
1337 repo.save_memory(&mem).await.unwrap();
1338 repo.push(&auth, "main").await.unwrap();
1339
1340 let result = repo.pull(&auth, "main").await.unwrap();
1342 assert!(matches!(result, PullResult::UpToDate));
1343 }
1344
1345 #[tokio::test]
1348 async fn pull_merge_conflict_theirs_newer_wins() {
1349 let (_remote_dir, remote_url) = setup_bare_remote();
1350 let auth = test_auth();
1351
1352 let dir_a = tempfile::tempdir().unwrap();
1354 let repo_a = open_repo(&dir_a, Some(&remote_url));
1355 let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1356 repo_a.save_memory(&mem_a1).await.unwrap();
1357 repo_a.push(&auth, "main").await.unwrap();
1358
1359 let dir_b = tempfile::tempdir().unwrap();
1361 let repo_b = open_repo(&dir_b, Some(&remote_url));
1362 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1363 repo_b.save_memory(&seed).await.unwrap();
1364 repo_b.pull(&auth, "main").await.unwrap();
1365
1366 let mem_b = make_memory("shared", "version from B (newer)", 1_700_000_300);
1367 repo_b.save_memory(&mem_b).await.unwrap();
1368 repo_b.push(&auth, "main").await.unwrap();
1369
1370 let mem_a2 = make_memory("shared", "version from A (older)", 1_700_000_200);
1372 repo_a.save_memory(&mem_a2).await.unwrap();
1373 let result = repo_a.pull(&auth, "main").await.unwrap();
1374
1375 assert!(
1376 matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1377 "expected merge with conflicts resolved, got {:?}",
1378 result
1379 );
1380
1381 let file = dir_a.path().join("global").join("shared.md");
1383 let content = std::fs::read_to_string(&file).unwrap();
1384 assert!(
1385 content.contains("version from B (newer)"),
1386 "expected B's version to win (newer timestamp), got: {}",
1387 content
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn pull_merge_conflict_ours_newer_wins() {
1393 let (_remote_dir, remote_url) = setup_bare_remote();
1394 let auth = test_auth();
1395
1396 let dir_a = tempfile::tempdir().unwrap();
1398 let repo_a = open_repo(&dir_a, Some(&remote_url));
1399 let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1400 repo_a.save_memory(&mem_a1).await.unwrap();
1401 repo_a.push(&auth, "main").await.unwrap();
1402
1403 let dir_b = tempfile::tempdir().unwrap();
1405 let repo_b = open_repo(&dir_b, Some(&remote_url));
1406 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1407 repo_b.save_memory(&seed).await.unwrap();
1408 repo_b.pull(&auth, "main").await.unwrap();
1409
1410 let mem_b = make_memory("shared", "version from B (older)", 1_700_000_200);
1411 repo_b.save_memory(&mem_b).await.unwrap();
1412 repo_b.push(&auth, "main").await.unwrap();
1413
1414 let mem_a2 = make_memory("shared", "version from A (newer)", 1_700_000_300);
1416 repo_a.save_memory(&mem_a2).await.unwrap();
1417 let result = repo_a.pull(&auth, "main").await.unwrap();
1418
1419 assert!(
1420 matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1421 "expected merge with conflicts resolved, got {:?}",
1422 result
1423 );
1424
1425 let file = dir_a.path().join("global").join("shared.md");
1427 let content = std::fs::read_to_string(&file).unwrap();
1428 assert!(
1429 content.contains("version from A (newer)"),
1430 "expected A's version to win (newer timestamp), got: {}",
1431 content
1432 );
1433 }
1434
1435 #[tokio::test]
1436 async fn pull_merge_no_conflict_different_files() {
1437 let (_remote_dir, remote_url) = setup_bare_remote();
1438 let auth = test_auth();
1439
1440 let dir_a = tempfile::tempdir().unwrap();
1442 let repo_a = open_repo(&dir_a, Some(&remote_url));
1443 let mem_a = make_memory("mem-a", "from A", 1_700_000_100);
1444 repo_a.save_memory(&mem_a).await.unwrap();
1445 repo_a.push(&auth, "main").await.unwrap();
1446
1447 let dir_b = tempfile::tempdir().unwrap();
1449 let repo_b = open_repo(&dir_b, Some(&remote_url));
1450 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1451 repo_b.save_memory(&seed).await.unwrap();
1452 repo_b.pull(&auth, "main").await.unwrap();
1453 let mem_b = make_memory("mem-b", "from B", 1_700_000_200);
1454 repo_b.save_memory(&mem_b).await.unwrap();
1455 repo_b.push(&auth, "main").await.unwrap();
1456
1457 let mem_a2 = make_memory("mem-a2", "also from A", 1_700_000_300);
1459 repo_a.save_memory(&mem_a2).await.unwrap();
1460 let result = repo_a.pull(&auth, "main").await.unwrap();
1461
1462 assert!(
1463 matches!(
1464 result,
1465 PullResult::Merged {
1466 conflicts_resolved: 0,
1467 ..
1468 }
1469 ),
1470 "expected clean merge, got {:?}",
1471 result
1472 );
1473
1474 assert!(dir_a.path().join("global").join("mem-b.md").exists());
1476 }
1477
1478 fn commit_file(repo: &Arc<MemoryRepo>, rel_path: &str, content: &str) -> [u8; 20] {
1482 let inner = repo.inner.lock().expect("lock poisoned");
1483 let full_path = repo.root.join(rel_path);
1484 if let Some(parent) = full_path.parent() {
1485 std::fs::create_dir_all(parent).unwrap();
1486 }
1487 std::fs::write(&full_path, content).unwrap();
1488
1489 let mut index = inner.index().unwrap();
1490 index.add_path(std::path::Path::new(rel_path)).unwrap();
1491 index.write().unwrap();
1492 let tree_oid = index.write_tree().unwrap();
1493 let tree = inner.find_tree(tree_oid).unwrap();
1494 let sig = git2::Signature::now("test", "test@test.com").unwrap();
1495
1496 let oid = match inner.head() {
1497 Ok(head) => {
1498 let parent = head.peel_to_commit().unwrap();
1499 inner
1500 .commit(Some("HEAD"), &sig, &sig, "test commit", &tree, &[&parent])
1501 .unwrap()
1502 }
1503 Err(_) => inner
1504 .commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
1505 .unwrap(),
1506 };
1507
1508 let mut buf = [0u8; 20];
1509 buf.copy_from_slice(oid.as_bytes());
1510 buf
1511 }
1512
1513 #[test]
1514 fn diff_changed_memories_detects_added_global() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let repo = open_repo(&dir, None);
1517
1518 let old_oid = {
1520 let inner = repo.inner.lock().unwrap();
1521 let head = inner.head().unwrap();
1522 let mut buf = [0u8; 20];
1523 buf.copy_from_slice(head.peel_to_commit().unwrap().id().as_bytes());
1524 buf
1525 };
1526
1527 let new_oid = commit_file(&repo, "global/my-note.md", "# content");
1528
1529 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1530 assert_eq!(changes.upserted, vec!["global/my-note".to_string()]);
1531 assert!(changes.removed.is_empty());
1532 }
1533
1534 #[test]
1535 fn diff_changed_memories_detects_deleted() {
1536 let dir = tempfile::tempdir().unwrap();
1537 let repo = open_repo(&dir, None);
1538
1539 let first_oid = commit_file(&repo, "global/to-delete.md", "hello");
1540 let second_oid = {
1541 let inner = repo.inner.lock().unwrap();
1542 let full_path = dir.path().join("global/to-delete.md");
1543 std::fs::remove_file(&full_path).unwrap();
1544 let mut index = inner.index().unwrap();
1545 index
1546 .remove_path(std::path::Path::new("global/to-delete.md"))
1547 .unwrap();
1548 index.write().unwrap();
1549 let tree_oid = index.write_tree().unwrap();
1550 let tree = inner.find_tree(tree_oid).unwrap();
1551 let sig = git2::Signature::now("test", "test@test.com").unwrap();
1552 let parent = inner.head().unwrap().peel_to_commit().unwrap();
1553 let oid = inner
1554 .commit(Some("HEAD"), &sig, &sig, "delete file", &tree, &[&parent])
1555 .unwrap();
1556 let mut buf = [0u8; 20];
1557 buf.copy_from_slice(oid.as_bytes());
1558 buf
1559 };
1560
1561 let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1562 assert!(changes.upserted.is_empty());
1563 assert_eq!(changes.removed, vec!["global/to-delete".to_string()]);
1564 }
1565
1566 #[test]
1567 fn diff_changed_memories_ignores_non_md_files() {
1568 let dir = tempfile::tempdir().unwrap();
1569 let repo = open_repo(&dir, None);
1570
1571 let old_oid = {
1572 let inner = repo.inner.lock().unwrap();
1573 let mut buf = [0u8; 20];
1574 buf.copy_from_slice(
1575 inner
1576 .head()
1577 .unwrap()
1578 .peel_to_commit()
1579 .unwrap()
1580 .id()
1581 .as_bytes(),
1582 );
1583 buf
1584 };
1585
1586 let _ = commit_file(&repo, "global/config.json", "{}");
1588 let new_oid = commit_file(&repo, "other/note.md", "# ignored");
1589
1590 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1591 assert!(
1592 changes.upserted.is_empty(),
1593 "should ignore non-.md and out-of-scope files"
1594 );
1595 assert!(changes.removed.is_empty());
1596 }
1597
1598 #[test]
1599 fn diff_changed_memories_detects_modified() {
1600 let dir = tempfile::tempdir().unwrap();
1601 let repo = open_repo(&dir, None);
1602
1603 let first_oid = commit_file(&repo, "projects/myproject/note.md", "version 1");
1604 let second_oid = commit_file(&repo, "projects/myproject/note.md", "version 2");
1605
1606 let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1607 assert_eq!(
1608 changes.upserted,
1609 vec!["projects/myproject/note".to_string()]
1610 );
1611 assert!(changes.removed.is_empty());
1612 }
1613
1614 #[test]
1617 fn diff_changed_memories_zero_oid_treats_all_as_added() {
1618 let dir = tempfile::tempdir().unwrap();
1619 let repo = open_repo(&dir, None);
1620
1621 let new_oid = commit_file(&repo, "global/first-memory.md", "# Hello");
1623
1624 let old_oid = [0u8; 20];
1626
1627 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1628 assert_eq!(
1629 changes.upserted,
1630 vec!["global/first-memory".to_string()],
1631 "zero OID: all new-tree files should be additions"
1632 );
1633 assert!(changes.removed.is_empty(), "zero OID: no removals expected");
1634 }
1635}