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 pub async fn head_sha(self: &Arc<Self>) -> Option<String> {
186 let me = Arc::clone(self);
187 tokio::task::spawn_blocking(move || {
188 let repo = me.inner.lock().expect("repo mutex poisoned");
189 let oid_bytes = capture_head_oid(&repo).ok()?;
190 if oid_bytes == [0u8; 20] {
191 return None;
192 }
193 git2::Oid::from_bytes(&oid_bytes)
194 .ok()
195 .map(|oid| oid.to_string())
196 })
197 .await
198 .ok()
199 .flatten()
200 }
201
202 fn memory_path(&self, name: &str, scope: &Scope) -> PathBuf {
204 self.root
205 .join(scope.dir_prefix())
206 .join(format!("{}.md", name))
207 }
208
209 pub async fn save_memory(self: &Arc<Self>, memory: &Memory) -> Result<(), MemoryError> {
214 validate_name(&memory.name)?;
215 if let Scope::Project(ref project_name) = memory.metadata.scope {
216 validate_name(project_name)?;
217 }
218
219 let file_path = self.memory_path(&memory.name, &memory.metadata.scope);
220 self.assert_within_root(&file_path)?;
221
222 let arc = Arc::clone(self);
223 let memory = memory.clone();
224 let name = memory.name.clone();
225
226 let span = tracing::debug_span!("repo.save", name = %name, oid = tracing::field::Empty);
228
229 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
230 let _enter = span.entered();
231 let repo = arc
232 .inner
233 .lock()
234 .expect("lock poisoned — prior panic corrupted state");
235
236 if let Some(parent) = file_path.parent() {
238 std::fs::create_dir_all(parent)?;
239 }
240
241 let markdown = memory.to_markdown()?;
242 arc.write_memory_file(&file_path, markdown.as_bytes())?;
243
244 arc.git_add_and_commit(
245 &repo,
246 &file_path,
247 &format!("chore: save memory '{}'", memory.name),
248 )?;
249
250 if let Ok(head) = repo.head() {
252 if let Ok(commit) = head.peel_to_commit() {
253 tracing::Span::current().record("oid", commit.id().to_string().as_str());
254 debug!(oid = %commit.id(), "memory saved to repo");
255 }
256 }
257
258 Ok(())
259 })
260 .await
261 .map_err(|e| MemoryError::Join(e.to_string()))?
262 }
263
264 pub async fn delete_memory(
266 self: &Arc<Self>,
267 name: &str,
268 scope: &Scope,
269 ) -> Result<(), MemoryError> {
270 validate_name(name)?;
271 if let Scope::Project(ref project_name) = *scope {
272 validate_name(project_name)?;
273 }
274
275 let file_path = self.memory_path(name, scope);
276 self.assert_within_root(&file_path)?;
277
278 let arc = Arc::clone(self);
279 let name = name.to_string();
280 let file_path_clone = file_path.clone();
281 let span = tracing::debug_span!("repo.delete", name = %name);
282 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
283 let _enter = span.entered();
284 let repo = arc
285 .inner
286 .lock()
287 .expect("lock poisoned — prior panic corrupted state");
288
289 match std::fs::symlink_metadata(&file_path_clone) {
291 Err(_) => return Err(MemoryError::NotFound { name: name.clone() }),
292 Ok(m) if m.file_type().is_symlink() => {
293 return Err(MemoryError::InvalidInput {
294 reason: format!(
295 "path '{}' is a symlink, which is not permitted",
296 file_path_clone.display()
297 ),
298 });
299 }
300 Ok(_) => {}
301 }
302
303 std::fs::remove_file(&file_path_clone)?;
304 let relative =
306 file_path_clone
307 .strip_prefix(&arc.root)
308 .map_err(|e| MemoryError::InvalidInput {
309 reason: format!("path strip error: {}", e),
310 })?;
311 let mut index = repo.index()?;
312 index.remove_path(relative)?;
313 index.write()?;
314
315 let tree_oid = index.write_tree()?;
316 let tree = repo.find_tree(tree_oid)?;
317 let sig = arc.signature(&repo)?;
318 let message = format!("chore: delete memory '{}'", name);
319
320 match repo.head() {
321 Ok(head) => {
322 let parent_commit = head.peel_to_commit()?;
323 repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&parent_commit])?;
324 }
325 Err(e)
326 if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound =>
327 {
328 repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[])?;
329 }
330 Err(e) => return Err(MemoryError::Git(e)),
331 }
332
333 Ok(())
334 })
335 .await
336 .map_err(|e| MemoryError::Join(e.to_string()))?
337 }
338
339 pub async fn read_memory(
341 self: &Arc<Self>,
342 name: &str,
343 scope: &Scope,
344 ) -> Result<Memory, MemoryError> {
345 validate_name(name)?;
346 if let Scope::Project(ref project_name) = *scope {
347 validate_name(project_name)?;
348 }
349
350 let file_path = self.memory_path(name, scope);
351 self.assert_within_root(&file_path)?;
352
353 let arc = Arc::clone(self);
354 let name = name.to_string();
355 let span = tracing::debug_span!("repo.read", name = %name);
356 tokio::task::spawn_blocking(move || -> Result<Memory, MemoryError> {
357 let _enter = span.entered();
358 match std::fs::symlink_metadata(&file_path) {
360 Err(_) => return Err(MemoryError::NotFound { name }),
361 Ok(m) if m.file_type().is_symlink() => {
362 return Err(MemoryError::InvalidInput {
363 reason: format!(
364 "path '{}' is a symlink, which is not permitted",
365 file_path.display()
366 ),
367 });
368 }
369 Ok(_) => {}
370 }
371 let raw = arc.read_memory_file(&file_path)?;
372 Memory::from_markdown(&raw)
373 })
374 .await
375 .map_err(|e| MemoryError::Join(e.to_string()))?
376 }
377
378 pub async fn list_memories(
380 self: &Arc<Self>,
381 scope: Option<&Scope>,
382 ) -> Result<Vec<Memory>, MemoryError> {
383 let root = self.root.clone();
384 let scope_clone = scope.cloned();
385 let span = tracing::debug_span!("repo.list", file_count = tracing::field::Empty,);
386
387 tokio::task::spawn_blocking(move || -> Result<Vec<Memory>, MemoryError> {
388 let _enter = span.entered();
389 let dirs: Vec<PathBuf> = match scope_clone.as_ref() {
390 Some(s) => vec![root.join(s.dir_prefix())],
391 None => {
392 let mut dirs = Vec::new();
394 let global = root.join("global");
395 if global.exists() {
396 dirs.push(global);
397 }
398 let projects = root.join("projects");
399 if projects.exists() {
400 for entry in std::fs::read_dir(&projects)? {
401 let entry = entry?;
402 if entry.file_type()?.is_dir() {
403 dirs.push(entry.path());
404 }
405 }
406 }
407 dirs
408 }
409 };
410
411 fn collect_md_files(dir: &Path, out: &mut Vec<Memory>) -> Result<(), MemoryError> {
412 if !dir.exists() {
413 return Ok(());
414 }
415 for entry in std::fs::read_dir(dir)? {
416 let entry = entry?;
417 let path = entry.path();
418 let ft = entry.file_type()?;
419 if ft.is_symlink() {
421 warn!(
422 "skipping symlink at {:?} — symlinks are not permitted in the memory store",
423 path
424 );
425 continue;
426 }
427 if ft.is_dir() {
428 collect_md_files(&path, out)?;
429 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
430 let raw = std::fs::read_to_string(&path)?;
431 match Memory::from_markdown(&raw) {
432 Ok(m) => out.push(m),
433 Err(e) => {
434 warn!("skipping {:?}: {}", path, e);
435 }
436 }
437 }
438 }
439 Ok(())
440 }
441
442 let mut memories = Vec::new();
443 for dir in dirs {
444 collect_md_files(&dir, &mut memories)?;
445 }
446
447 tracing::Span::current().record("file_count", memories.len());
448
449 Ok(memories)
450 })
451 .await
452 .map_err(|e| MemoryError::Join(e.to_string()))?
453 }
454
455 pub async fn push(
460 self: &Arc<Self>,
461 auth: &AuthProvider,
462 branch: &str,
463 ) -> Result<(), MemoryError> {
464 let token_result = auth.resolve_token();
468 let arc = Arc::clone(self);
469 let branch = branch.to_string();
470 let span = tracing::debug_span!("repo.push", branch = %branch);
471
472 tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
473 let _enter = span.entered();
474 let repo = arc
475 .inner
476 .lock()
477 .expect("lock poisoned — prior panic corrupted state");
478
479 let mut remote = match repo.find_remote("origin") {
480 Ok(r) => r,
481 Err(e) if e.code() == ErrorCode::NotFound => {
482 warn!("push: no origin remote configured — skipping (local-only mode)");
483 return Ok(());
484 }
485 Err(e) => return Err(MemoryError::Git(e)),
486 };
487
488 let token = token_result?;
490 let mut callbacks = build_auth_callbacks(token);
491
492 let rejections: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
495 let rej = Arc::clone(&rejections);
496 callbacks.push_update_reference(move |refname, status| {
497 if let Some(msg) = status {
498 rej.lock()
499 .expect("rejection lock poisoned")
500 .push(format!("{refname}: {msg}"));
501 }
502 Ok(())
503 });
504
505 let mut push_opts = git2::PushOptions::new();
506 push_opts.remote_callbacks(callbacks);
507
508 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
509 if let Err(e) = remote.push(&[&refspec], Some(&mut push_opts)) {
510 warn!("push to origin failed at transport level: {e}");
511 return Err(MemoryError::Git(e));
512 }
513
514 let rejected = rejections.lock().expect("rejection lock poisoned");
515 if !rejected.is_empty() {
516 return Err(MemoryError::PushRejected(rejected.join("; ")));
517 }
518
519 info!("pushed branch '{}' to origin", branch);
520 Ok(())
521 })
522 .await
523 .map_err(|e| MemoryError::Join(e.to_string()))?
524 }
525
526 fn merge_with_remote(
531 &self,
532 repo: &git2::Repository,
533 fetch_commit: &git2::AnnotatedCommit,
534 branch: &str,
535 ) -> Result<PullResult, MemoryError> {
536 let oid = repo.head()?.peel_to_commit()?.id();
540 let mut old_head = [0u8; 20];
541 old_head.copy_from_slice(oid.as_bytes());
542
543 let mut merge_opts = MergeOptions::new();
544 merge_opts.fail_on_conflict(false);
545 repo.merge(&[fetch_commit], Some(&mut merge_opts), None)?;
546
547 let mut index = repo.index()?;
548 let conflicts_resolved = if index.has_conflicts() {
549 self.resolve_conflicts_by_recency(repo, &mut index)?
550 } else {
551 0
552 };
553
554 if index.has_conflicts() {
558 let _ = repo.cleanup_state();
559 return Err(MemoryError::Internal(
560 "unresolved conflicts remain after auto-resolution".into(),
561 ));
562 }
563
564 index.write()?;
566 let tree_oid = index.write_tree()?;
567 let tree = repo.find_tree(tree_oid)?;
568 let sig = self.signature(repo)?;
569
570 let head_commit = repo.head()?.peel_to_commit()?;
571 let fetch_commit_obj = repo.find_commit(fetch_commit.id())?;
572
573 let new_commit_oid = repo.commit(
574 Some("HEAD"),
575 &sig,
576 &sig,
577 &format!("chore: merge origin/{}", branch),
578 &tree,
579 &[&head_commit, &fetch_commit_obj],
580 )?;
581
582 repo.cleanup_state()?;
583
584 let mut new_head = [0u8; 20];
585 new_head.copy_from_slice(new_commit_oid.as_bytes());
586
587 info!(
588 "pull: merge complete ({} conflicts auto-resolved)",
589 conflicts_resolved
590 );
591 Ok(PullResult::Merged {
592 conflicts_resolved,
593 old_head,
594 new_head,
595 })
596 }
597
598 pub async fn pull(
604 self: &Arc<Self>,
605 auth: &AuthProvider,
606 branch: &str,
607 ) -> Result<PullResult, MemoryError> {
608 let token_result = auth.resolve_token();
612 let arc = Arc::clone(self);
613 let branch = branch.to_string();
614 let span = tracing::debug_span!("repo.pull", branch = %branch);
615
616 tokio::task::spawn_blocking(move || -> Result<PullResult, MemoryError> {
617 let _enter = span.entered();
618 let repo = arc
619 .inner
620 .lock()
621 .expect("lock poisoned — prior panic corrupted state");
622
623 let mut remote = match repo.find_remote("origin") {
625 Ok(r) => r,
626 Err(e) if e.code() == ErrorCode::NotFound => {
627 warn!("pull: no origin remote configured — skipping (local-only mode)");
628 return Ok(PullResult::NoRemote);
629 }
630 Err(e) => return Err(MemoryError::Git(e)),
631 };
632
633 let token = token_result?;
635
636 let callbacks = build_auth_callbacks(token);
638 let mut fetch_opts = git2::FetchOptions::new();
639 fetch_opts.remote_callbacks(callbacks);
640 remote.fetch(&[&branch], Some(&mut fetch_opts), None)?;
641
642 let fetch_head = match repo.find_reference("FETCH_HEAD") {
644 Ok(r) => r,
645 Err(e) if e.code() == ErrorCode::NotFound => {
646 return Ok(PullResult::UpToDate);
648 }
649 Err(e)
650 if e.class() == git2::ErrorClass::Reference
651 && e.message().contains("corrupted") =>
652 {
653 info!("pull: FETCH_HEAD is empty or corrupted — treating as empty remote");
655 return Ok(PullResult::UpToDate);
656 }
657 Err(e) => return Err(MemoryError::Git(e)),
658 };
659 let fetch_commit = match repo.reference_to_annotated_commit(&fetch_head) {
660 Ok(c) => c,
661 Err(e) if e.class() == git2::ErrorClass::Reference => {
662 info!("pull: FETCH_HEAD not resolvable — treating as empty remote");
664 return Ok(PullResult::UpToDate);
665 }
666 Err(e) => return Err(MemoryError::Git(e)),
667 };
668
669 let (analysis, _preference) = repo.merge_analysis(&[&fetch_commit])?;
671
672 if analysis.is_up_to_date() {
673 info!("pull: already up to date");
674 return Ok(PullResult::UpToDate);
675 }
676
677 if analysis.is_fast_forward() {
678 return fast_forward(&repo, &fetch_commit, &branch);
679 }
680
681 arc.merge_with_remote(&repo, &fetch_commit, &branch)
682 })
683 .await
684 .map_err(|e| MemoryError::Join(e.to_string()))?
685 }
686
687 pub fn diff_changed_memories(
695 &self,
696 old_oid: [u8; 20],
697 new_oid: [u8; 20],
698 ) -> Result<ChangedMemories, MemoryError> {
699 let repo = self
700 .inner
701 .lock()
702 .expect("lock poisoned — prior panic corrupted state");
703
704 let new_git_oid = git2::Oid::from_bytes(&new_oid).map_err(MemoryError::Git)?;
705 let new_tree = repo.find_commit(new_git_oid)?.tree()?;
706
707 let diff = if old_oid == [0u8; 20] {
710 repo.diff_tree_to_tree(None, Some(&new_tree), None)?
711 } else {
712 let old_git_oid = git2::Oid::from_bytes(&old_oid).map_err(MemoryError::Git)?;
713 let old_tree = repo.find_commit(old_git_oid)?.tree()?;
714 repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?
715 };
716
717 let mut changes = ChangedMemories::default();
718
719 diff.foreach(
720 &mut |delta, _progress| {
721 use git2::Delta;
722
723 let path = match delta.new_file().path().or_else(|| delta.old_file().path()) {
724 Some(p) => p,
725 None => return true,
726 };
727
728 let path_str = match path.to_str() {
729 Some(s) => s,
730 None => return true,
731 };
732
733 if !path_str.ends_with(".md") {
735 return true;
736 }
737 if !path_str.starts_with("global/") && !path_str.starts_with("projects/") {
738 return true;
739 }
740
741 let qualified = &path_str[..path_str.len() - 3];
743
744 match delta.status() {
745 Delta::Added | Delta::Modified => {
746 changes.upserted.push(qualified.to_string());
747 }
748 Delta::Renamed | Delta::Copied => {
749 if matches!(delta.status(), Delta::Renamed) {
752 if let Some(old_path) = delta.old_file().path().and_then(|p| p.to_str())
753 {
754 if old_path.ends_with(".md")
755 && (old_path.starts_with("global/")
756 || old_path.starts_with("projects/"))
757 {
758 changes
759 .removed
760 .push(old_path[..old_path.len() - 3].to_string());
761 }
762 }
763 }
764 changes.upserted.push(qualified.to_string());
765 }
766 Delta::Deleted => {
767 changes.removed.push(qualified.to_string());
768 }
769 _ => {}
770 }
771
772 true
773 },
774 None,
775 None,
776 None,
777 )
778 .map_err(MemoryError::Git)?;
779
780 Ok(changes)
781 }
782
783 fn resolve_conflicts_by_recency(
793 &self,
794 repo: &Repository,
795 index: &mut git2::Index,
796 ) -> Result<usize, MemoryError> {
797 struct ConflictInfo {
799 path: PathBuf,
800 our_blob: Option<Vec<u8>>,
801 their_blob: Option<Vec<u8>>,
802 }
803
804 let mut conflicts_info: Vec<ConflictInfo> = Vec::new();
805
806 {
807 let conflicts = index.conflicts()?;
808 for conflict in conflicts {
809 let conflict = conflict?;
810
811 let path = conflict
812 .our
813 .as_ref()
814 .or(conflict.their.as_ref())
815 .and_then(|e| std::str::from_utf8(&e.path).ok())
816 .map(|s| self.root.join(s));
817
818 let path = match path {
819 Some(p) => p,
820 None => continue,
821 };
822
823 let our_blob = conflict
824 .our
825 .as_ref()
826 .and_then(|e| repo.find_blob(e.id).ok())
827 .map(|b| b.content().to_vec());
828
829 let their_blob = conflict
830 .their
831 .as_ref()
832 .and_then(|e| repo.find_blob(e.id).ok())
833 .map(|b| b.content().to_vec());
834
835 conflicts_info.push(ConflictInfo {
836 path,
837 our_blob,
838 their_blob,
839 });
840 }
841 }
842
843 let mut resolved = 0usize;
844
845 for info in conflicts_info {
846 let our_str = info
847 .our_blob
848 .as_deref()
849 .and_then(|b| std::str::from_utf8(b).ok())
850 .map(str::to_owned);
851 let their_str = info
852 .their_blob
853 .as_deref()
854 .and_then(|b| std::str::from_utf8(b).ok())
855 .map(str::to_owned);
856
857 let our_ts = our_str
858 .as_deref()
859 .and_then(|s| Memory::from_markdown(s).ok())
860 .map(|m| m.metadata.updated_at);
861 let their_ts = their_str
862 .as_deref()
863 .and_then(|s| Memory::from_markdown(s).ok())
864 .map(|m| m.metadata.updated_at);
865
866 let (chosen_bytes, label): (Vec<u8>, String) =
868 match (our_str.as_deref(), their_str.as_deref()) {
869 (Some(ours), Some(theirs)) => match (our_ts, their_ts) {
870 (Some(ot), Some(tt)) if tt > ot => (
871 theirs.as_bytes().to_vec(),
872 format!("theirs (updated_at: {})", tt),
873 ),
874 (Some(ot), _) => (
875 ours.as_bytes().to_vec(),
876 format!("ours (updated_at: {})", ot),
877 ),
878 _ => (
879 ours.as_bytes().to_vec(),
880 "ours (timestamp unparseable)".to_string(),
881 ),
882 },
883 (Some(ours), None) => (
884 ours.as_bytes().to_vec(),
885 "ours (theirs missing)".to_string(),
886 ),
887 (None, Some(theirs)) => (
888 theirs.as_bytes().to_vec(),
889 "theirs (ours missing)".to_string(),
890 ),
891 (None, None) => {
892 match (info.our_blob.as_deref(), info.their_blob.as_deref()) {
894 (Some(ours), _) => {
895 (ours.to_vec(), "ours (binary/non-UTF-8)".to_string())
896 }
897 (_, Some(theirs)) => {
898 (theirs.to_vec(), "theirs (binary/non-UTF-8)".to_string())
899 }
900 (None, None) => {
901 warn!(
904 "conflict at '{}': both sides missing — removing from index",
905 info.path.display()
906 );
907 let relative = info.path.strip_prefix(&self.root).map_err(|e| {
908 MemoryError::InvalidInput {
909 reason: format!(
910 "path strip error during conflict resolution: {}",
911 e
912 ),
913 }
914 })?;
915 index.conflict_remove(relative)?;
916 resolved += 1;
917 continue;
918 }
919 }
920 }
921 };
922
923 warn!(
924 "conflict resolved: {} — kept {}",
925 info.path.display(),
926 label
927 );
928
929 self.assert_within_root(&info.path)?;
933 if let Some(parent) = info.path.parent() {
934 std::fs::create_dir_all(parent)?;
935 }
936 self.write_memory_file(&info.path, &chosen_bytes)?;
937
938 let relative =
940 info.path
941 .strip_prefix(&self.root)
942 .map_err(|e| MemoryError::InvalidInput {
943 reason: format!("path strip error during conflict resolution: {}", e),
944 })?;
945 index.add_path(relative)?;
946
947 resolved += 1;
948 }
949
950 Ok(resolved)
951 }
952
953 fn signature<'r>(&self, repo: &'r Repository) -> Result<Signature<'r>, MemoryError> {
954 let sig = repo
956 .signature()
957 .or_else(|_| Signature::now("memory-mcp", "memory-mcp@local"))?;
958 Ok(sig)
959 }
960
961 fn git_add_and_commit(
963 &self,
964 repo: &Repository,
965 file_path: &Path,
966 message: &str,
967 ) -> Result<(), MemoryError> {
968 let relative =
969 file_path
970 .strip_prefix(&self.root)
971 .map_err(|e| MemoryError::InvalidInput {
972 reason: format!("path strip error: {}", e),
973 })?;
974
975 let mut index = repo.index()?;
976 index.add_path(relative)?;
977 index.write()?;
978
979 let tree_oid = index.write_tree()?;
980 let tree = repo.find_tree(tree_oid)?;
981 let sig = self.signature(repo)?;
982
983 match repo.head() {
984 Ok(head) => {
985 let parent_commit = head.peel_to_commit()?;
986 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])?;
987 }
988 Err(e) if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => {
989 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
991 }
992 Err(e) => return Err(MemoryError::Git(e)),
993 }
994
995 Ok(())
996 }
997
998 fn assert_within_root(&self, path: &Path) -> Result<(), MemoryError> {
1001 let parent = path.parent().unwrap_or(path);
1004 let filename = path.file_name().ok_or_else(|| MemoryError::InvalidInput {
1005 reason: "path has no filename component".to_string(),
1006 })?;
1007
1008 let canon_parent = {
1011 let mut p = parent.to_path_buf();
1012 let mut suffixes: Vec<std::ffi::OsString> = Vec::new();
1013 loop {
1014 match p.canonicalize() {
1015 Ok(c) => {
1016 let mut full = c;
1017 for s in suffixes.into_iter().rev() {
1018 full.push(s);
1019 }
1020 break full;
1021 }
1022 Err(_) => {
1023 if let Some(name) = p.file_name() {
1024 suffixes.push(name.to_os_string());
1025 }
1026 match p.parent() {
1027 Some(par) => p = par.to_path_buf(),
1028 None => {
1029 return Err(MemoryError::InvalidInput {
1030 reason: "cannot resolve any ancestor of path".into(),
1031 });
1032 }
1033 }
1034 }
1035 }
1036 }
1037 };
1038
1039 let resolved = canon_parent.join(filename);
1040
1041 let canon_root = self
1042 .root
1043 .canonicalize()
1044 .map_err(|e| MemoryError::InvalidInput {
1045 reason: format!("cannot canonicalize repo root: {}", e),
1046 })?;
1047
1048 if !resolved.starts_with(&canon_root) {
1049 return Err(MemoryError::InvalidInput {
1050 reason: format!(
1051 "path '{}' escapes repository root '{}'",
1052 resolved.display(),
1053 canon_root.display()
1054 ),
1055 });
1056 }
1057
1058 {
1063 let mut probe = canon_root.clone();
1064 let relative =
1066 resolved
1067 .strip_prefix(&canon_root)
1068 .map_err(|e| MemoryError::InvalidInput {
1069 reason: format!("path strip error: {}", e),
1070 })?;
1071 for component in relative.components() {
1072 probe.push(component);
1073 if (probe.exists() || probe.symlink_metadata().is_ok())
1075 && probe
1076 .symlink_metadata()
1077 .map(|m| m.file_type().is_symlink())
1078 .unwrap_or(false)
1079 {
1080 return Err(MemoryError::InvalidInput {
1081 reason: format!(
1082 "path component '{}' is a symlink, which is not allowed",
1083 probe.display()
1084 ),
1085 });
1086 }
1087 }
1088 }
1089
1090 Ok(())
1091 }
1092
1093 fn write_memory_file(&self, path: &Path, data: &[u8]) -> Result<(), MemoryError> {
1104 if path
1106 .symlink_metadata()
1107 .map(|m| m.file_type().is_symlink())
1108 .unwrap_or(false)
1109 {
1110 return Err(MemoryError::InvalidInput {
1111 reason: format!("refusing to write through symlink: {}", path.display()),
1112 });
1113 }
1114
1115 #[cfg(unix)]
1119 {
1120 use std::os::unix::fs::OpenOptionsExt as _;
1121 if let Err(e) = std::fs::OpenOptions::new()
1122 .read(true)
1123 .custom_flags(libc::O_NOFOLLOW)
1124 .open(path)
1125 {
1126 if e.kind() != std::io::ErrorKind::NotFound {
1128 return Err(MemoryError::InvalidInput {
1129 reason: format!("O_NOFOLLOW check failed for {}: {e}", path.display()),
1130 });
1131 }
1132 }
1133 }
1134
1135 crate::fs_util::atomic_write(path, data)?;
1136 Ok(())
1137 }
1138
1139 fn read_memory_file(&self, path: &Path) -> Result<String, MemoryError> {
1144 #[cfg(unix)]
1145 {
1146 use std::io::Read as _;
1147 use std::os::unix::fs::OpenOptionsExt as _;
1148 let mut f = std::fs::OpenOptions::new()
1149 .read(true)
1150 .custom_flags(libc::O_NOFOLLOW)
1151 .open(path)?;
1152 let mut buf = String::new();
1153 f.read_to_string(&mut buf)?;
1154 Ok(buf)
1155 }
1156 #[cfg(not(unix))]
1157 {
1158 Ok(std::fs::read_to_string(path)?)
1159 }
1160 }
1161}
1162
1163#[cfg(test)]
1168mod tests {
1169 use super::*;
1170 use crate::auth::AuthProvider;
1171 use crate::types::{Memory, MemoryMetadata, PullResult, Scope};
1172 use std::sync::Arc;
1173
1174 fn test_auth() -> AuthProvider {
1175 AuthProvider::with_token("test-token-unused-for-file-remotes")
1176 }
1177
1178 fn make_memory(name: &str, content: &str, updated_at_secs: i64) -> Memory {
1179 let meta = MemoryMetadata {
1180 tags: vec![],
1181 scope: Scope::Global,
1182 created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
1183 updated_at: chrono::DateTime::from_timestamp(updated_at_secs, 0).unwrap(),
1184 source: None,
1185 };
1186 Memory::new(name.to_string(), content.to_string(), meta)
1187 }
1188
1189 fn setup_bare_remote() -> (tempfile::TempDir, String) {
1190 let dir = tempfile::tempdir().expect("failed to create temp dir");
1191 git2::Repository::init_bare(dir.path()).expect("failed to init bare repo");
1192 let url = format!("file://{}", dir.path().display());
1193 (dir, url)
1194 }
1195
1196 fn open_repo(dir: &tempfile::TempDir, remote_url: Option<&str>) -> Arc<MemoryRepo> {
1197 Arc::new(MemoryRepo::init_or_open(dir.path(), remote_url).expect("failed to init repo"))
1198 }
1199
1200 #[test]
1203 fn redact_url_strips_userinfo() {
1204 assert_eq!(
1205 redact_url("https://user:ghp_token123@github.com/org/repo.git"),
1206 "https://[REDACTED]@github.com/org/repo.git"
1207 );
1208 }
1209
1210 #[test]
1211 fn redact_url_no_at_passthrough() {
1212 let url = "https://github.com/org/repo.git";
1213 assert_eq!(redact_url(url), url);
1214 }
1215
1216 #[test]
1217 fn redact_url_file_protocol_passthrough() {
1218 let url = "file:///tmp/bare.git";
1219 assert_eq!(redact_url(url), url);
1220 }
1221
1222 #[test]
1225 fn assert_within_root_accepts_valid_path() {
1226 let dir = tempfile::tempdir().unwrap();
1227 let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1228 let valid = dir.path().join("global").join("my-memory.md");
1229 std::fs::create_dir_all(valid.parent().unwrap()).unwrap();
1231 assert!(repo.assert_within_root(&valid).is_ok());
1232 }
1233
1234 #[test]
1235 fn assert_within_root_rejects_escape() {
1236 let dir = tempfile::tempdir().unwrap();
1237 let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1238 let _evil = dir
1241 .path()
1242 .join("..")
1243 .join("..")
1244 .join("..")
1245 .join("tmp")
1246 .join("evil.md");
1247 let outside = std::path::PathBuf::from("/tmp/definitely-outside");
1251 assert!(repo.assert_within_root(&outside).is_err());
1252 }
1253
1254 #[tokio::test]
1257 async fn push_local_only_returns_ok() {
1258 let dir = tempfile::tempdir().unwrap();
1259 let repo = open_repo(&dir, None);
1260 let auth = test_auth();
1261 let result = repo.push(&auth, "main").await;
1263 assert!(result.is_ok());
1264 }
1265
1266 #[tokio::test]
1267 async fn pull_local_only_returns_no_remote() {
1268 let dir = tempfile::tempdir().unwrap();
1269 let repo = open_repo(&dir, None);
1270 let auth = test_auth();
1271 let result = repo.pull(&auth, "main").await.unwrap();
1272 assert!(matches!(result, PullResult::NoRemote));
1273 }
1274
1275 #[tokio::test]
1278 async fn push_to_bare_remote() {
1279 let (_remote_dir, remote_url) = setup_bare_remote();
1280 let local_dir = tempfile::tempdir().unwrap();
1281 let repo = open_repo(&local_dir, Some(&remote_url));
1282 let auth = test_auth();
1283
1284 let mem = make_memory("test-push", "push content", 1_700_000_000);
1286 repo.save_memory(&mem).await.unwrap();
1287
1288 repo.push(&auth, "main").await.unwrap();
1290
1291 let bare = git2::Repository::open_bare(_remote_dir.path()).unwrap();
1293 let head = bare.find_reference("refs/heads/main").unwrap();
1294 let commit = head.peel_to_commit().unwrap();
1295 assert!(commit.message().unwrap().contains("test-push"));
1296 }
1297
1298 #[tokio::test]
1299 async fn pull_from_empty_bare_remote_returns_up_to_date() {
1300 let (_remote_dir, remote_url) = setup_bare_remote();
1301 let local_dir = tempfile::tempdir().unwrap();
1302 let repo = open_repo(&local_dir, Some(&remote_url));
1303 let auth = test_auth();
1304
1305 let mem = make_memory("seed", "seed content", 1_700_000_000);
1307 repo.save_memory(&mem).await.unwrap();
1308
1309 let result = repo.pull(&auth, "main").await.unwrap();
1311 assert!(matches!(result, PullResult::UpToDate));
1312 }
1313
1314 #[tokio::test]
1315 async fn pull_fast_forward() {
1316 let (_remote_dir, remote_url) = setup_bare_remote();
1317 let auth = test_auth();
1318
1319 let dir_a = tempfile::tempdir().unwrap();
1321 let repo_a = open_repo(&dir_a, Some(&remote_url));
1322 let mem = make_memory("from-a", "content from A", 1_700_000_000);
1323 repo_a.save_memory(&mem).await.unwrap();
1324 repo_a.push(&auth, "main").await.unwrap();
1325
1326 let dir_b = tempfile::tempdir().unwrap();
1328 let repo_b = open_repo(&dir_b, Some(&remote_url));
1329 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1331 repo_b.save_memory(&seed).await.unwrap();
1332
1333 let result = repo_b.pull(&auth, "main").await.unwrap();
1334 assert!(
1335 matches!(
1336 result,
1337 PullResult::FastForward { .. } | PullResult::Merged { .. }
1338 ),
1339 "expected fast-forward or merge, got {:?}",
1340 result
1341 );
1342
1343 let file = dir_b.path().join("global").join("from-a.md");
1345 assert!(file.exists(), "from-a.md should exist in repo B after pull");
1346 }
1347
1348 #[tokio::test]
1349 async fn pull_up_to_date_after_push() {
1350 let (_remote_dir, remote_url) = setup_bare_remote();
1351 let local_dir = tempfile::tempdir().unwrap();
1352 let repo = open_repo(&local_dir, Some(&remote_url));
1353 let auth = test_auth();
1354
1355 let mem = make_memory("synced", "synced content", 1_700_000_000);
1356 repo.save_memory(&mem).await.unwrap();
1357 repo.push(&auth, "main").await.unwrap();
1358
1359 let result = repo.pull(&auth, "main").await.unwrap();
1361 assert!(matches!(result, PullResult::UpToDate));
1362 }
1363
1364 #[tokio::test]
1367 async fn pull_merge_conflict_theirs_newer_wins() {
1368 let (_remote_dir, remote_url) = setup_bare_remote();
1369 let auth = test_auth();
1370
1371 let dir_a = tempfile::tempdir().unwrap();
1373 let repo_a = open_repo(&dir_a, Some(&remote_url));
1374 let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1375 repo_a.save_memory(&mem_a1).await.unwrap();
1376 repo_a.push(&auth, "main").await.unwrap();
1377
1378 let dir_b = tempfile::tempdir().unwrap();
1380 let repo_b = open_repo(&dir_b, Some(&remote_url));
1381 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1382 repo_b.save_memory(&seed).await.unwrap();
1383 repo_b.pull(&auth, "main").await.unwrap();
1384
1385 let mem_b = make_memory("shared", "version from B (newer)", 1_700_000_300);
1386 repo_b.save_memory(&mem_b).await.unwrap();
1387 repo_b.push(&auth, "main").await.unwrap();
1388
1389 let mem_a2 = make_memory("shared", "version from A (older)", 1_700_000_200);
1391 repo_a.save_memory(&mem_a2).await.unwrap();
1392 let result = repo_a.pull(&auth, "main").await.unwrap();
1393
1394 assert!(
1395 matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1396 "expected merge with conflicts resolved, got {:?}",
1397 result
1398 );
1399
1400 let file = dir_a.path().join("global").join("shared.md");
1402 let content = std::fs::read_to_string(&file).unwrap();
1403 assert!(
1404 content.contains("version from B (newer)"),
1405 "expected B's version to win (newer timestamp), got: {}",
1406 content
1407 );
1408 }
1409
1410 #[tokio::test]
1411 async fn pull_merge_conflict_ours_newer_wins() {
1412 let (_remote_dir, remote_url) = setup_bare_remote();
1413 let auth = test_auth();
1414
1415 let dir_a = tempfile::tempdir().unwrap();
1417 let repo_a = open_repo(&dir_a, Some(&remote_url));
1418 let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1419 repo_a.save_memory(&mem_a1).await.unwrap();
1420 repo_a.push(&auth, "main").await.unwrap();
1421
1422 let dir_b = tempfile::tempdir().unwrap();
1424 let repo_b = open_repo(&dir_b, Some(&remote_url));
1425 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1426 repo_b.save_memory(&seed).await.unwrap();
1427 repo_b.pull(&auth, "main").await.unwrap();
1428
1429 let mem_b = make_memory("shared", "version from B (older)", 1_700_000_200);
1430 repo_b.save_memory(&mem_b).await.unwrap();
1431 repo_b.push(&auth, "main").await.unwrap();
1432
1433 let mem_a2 = make_memory("shared", "version from A (newer)", 1_700_000_300);
1435 repo_a.save_memory(&mem_a2).await.unwrap();
1436 let result = repo_a.pull(&auth, "main").await.unwrap();
1437
1438 assert!(
1439 matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1440 "expected merge with conflicts resolved, got {:?}",
1441 result
1442 );
1443
1444 let file = dir_a.path().join("global").join("shared.md");
1446 let content = std::fs::read_to_string(&file).unwrap();
1447 assert!(
1448 content.contains("version from A (newer)"),
1449 "expected A's version to win (newer timestamp), got: {}",
1450 content
1451 );
1452 }
1453
1454 #[tokio::test]
1455 async fn pull_merge_no_conflict_different_files() {
1456 let (_remote_dir, remote_url) = setup_bare_remote();
1457 let auth = test_auth();
1458
1459 let dir_a = tempfile::tempdir().unwrap();
1461 let repo_a = open_repo(&dir_a, Some(&remote_url));
1462 let mem_a = make_memory("mem-a", "from A", 1_700_000_100);
1463 repo_a.save_memory(&mem_a).await.unwrap();
1464 repo_a.push(&auth, "main").await.unwrap();
1465
1466 let dir_b = tempfile::tempdir().unwrap();
1468 let repo_b = open_repo(&dir_b, Some(&remote_url));
1469 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1470 repo_b.save_memory(&seed).await.unwrap();
1471 repo_b.pull(&auth, "main").await.unwrap();
1472 let mem_b = make_memory("mem-b", "from B", 1_700_000_200);
1473 repo_b.save_memory(&mem_b).await.unwrap();
1474 repo_b.push(&auth, "main").await.unwrap();
1475
1476 let mem_a2 = make_memory("mem-a2", "also from A", 1_700_000_300);
1478 repo_a.save_memory(&mem_a2).await.unwrap();
1479 let result = repo_a.pull(&auth, "main").await.unwrap();
1480
1481 assert!(
1482 matches!(
1483 result,
1484 PullResult::Merged {
1485 conflicts_resolved: 0,
1486 ..
1487 }
1488 ),
1489 "expected clean merge, got {:?}",
1490 result
1491 );
1492
1493 assert!(dir_a.path().join("global").join("mem-b.md").exists());
1495 }
1496
1497 fn commit_file(repo: &Arc<MemoryRepo>, rel_path: &str, content: &str) -> [u8; 20] {
1501 let inner = repo.inner.lock().expect("lock poisoned");
1502 let full_path = repo.root.join(rel_path);
1503 if let Some(parent) = full_path.parent() {
1504 std::fs::create_dir_all(parent).unwrap();
1505 }
1506 std::fs::write(&full_path, content).unwrap();
1507
1508 let mut index = inner.index().unwrap();
1509 index.add_path(std::path::Path::new(rel_path)).unwrap();
1510 index.write().unwrap();
1511 let tree_oid = index.write_tree().unwrap();
1512 let tree = inner.find_tree(tree_oid).unwrap();
1513 let sig = git2::Signature::now("test", "test@test.com").unwrap();
1514
1515 let oid = match inner.head() {
1516 Ok(head) => {
1517 let parent = head.peel_to_commit().unwrap();
1518 inner
1519 .commit(Some("HEAD"), &sig, &sig, "test commit", &tree, &[&parent])
1520 .unwrap()
1521 }
1522 Err(_) => inner
1523 .commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
1524 .unwrap(),
1525 };
1526
1527 let mut buf = [0u8; 20];
1528 buf.copy_from_slice(oid.as_bytes());
1529 buf
1530 }
1531
1532 #[test]
1533 fn diff_changed_memories_detects_added_global() {
1534 let dir = tempfile::tempdir().unwrap();
1535 let repo = open_repo(&dir, None);
1536
1537 let old_oid = {
1539 let inner = repo.inner.lock().unwrap();
1540 let head = inner.head().unwrap();
1541 let mut buf = [0u8; 20];
1542 buf.copy_from_slice(head.peel_to_commit().unwrap().id().as_bytes());
1543 buf
1544 };
1545
1546 let new_oid = commit_file(&repo, "global/my-note.md", "# content");
1547
1548 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1549 assert_eq!(changes.upserted, vec!["global/my-note".to_string()]);
1550 assert!(changes.removed.is_empty());
1551 }
1552
1553 #[test]
1554 fn diff_changed_memories_detects_deleted() {
1555 let dir = tempfile::tempdir().unwrap();
1556 let repo = open_repo(&dir, None);
1557
1558 let first_oid = commit_file(&repo, "global/to-delete.md", "hello");
1559 let second_oid = {
1560 let inner = repo.inner.lock().unwrap();
1561 let full_path = dir.path().join("global/to-delete.md");
1562 std::fs::remove_file(&full_path).unwrap();
1563 let mut index = inner.index().unwrap();
1564 index
1565 .remove_path(std::path::Path::new("global/to-delete.md"))
1566 .unwrap();
1567 index.write().unwrap();
1568 let tree_oid = index.write_tree().unwrap();
1569 let tree = inner.find_tree(tree_oid).unwrap();
1570 let sig = git2::Signature::now("test", "test@test.com").unwrap();
1571 let parent = inner.head().unwrap().peel_to_commit().unwrap();
1572 let oid = inner
1573 .commit(Some("HEAD"), &sig, &sig, "delete file", &tree, &[&parent])
1574 .unwrap();
1575 let mut buf = [0u8; 20];
1576 buf.copy_from_slice(oid.as_bytes());
1577 buf
1578 };
1579
1580 let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1581 assert!(changes.upserted.is_empty());
1582 assert_eq!(changes.removed, vec!["global/to-delete".to_string()]);
1583 }
1584
1585 #[test]
1586 fn diff_changed_memories_ignores_non_md_files() {
1587 let dir = tempfile::tempdir().unwrap();
1588 let repo = open_repo(&dir, None);
1589
1590 let old_oid = {
1591 let inner = repo.inner.lock().unwrap();
1592 let mut buf = [0u8; 20];
1593 buf.copy_from_slice(
1594 inner
1595 .head()
1596 .unwrap()
1597 .peel_to_commit()
1598 .unwrap()
1599 .id()
1600 .as_bytes(),
1601 );
1602 buf
1603 };
1604
1605 let _ = commit_file(&repo, "global/config.json", "{}");
1607 let new_oid = commit_file(&repo, "other/note.md", "# ignored");
1608
1609 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1610 assert!(
1611 changes.upserted.is_empty(),
1612 "should ignore non-.md and out-of-scope files"
1613 );
1614 assert!(changes.removed.is_empty());
1615 }
1616
1617 #[test]
1618 fn diff_changed_memories_detects_modified() {
1619 let dir = tempfile::tempdir().unwrap();
1620 let repo = open_repo(&dir, None);
1621
1622 let first_oid = commit_file(&repo, "projects/myproject/note.md", "version 1");
1623 let second_oid = commit_file(&repo, "projects/myproject/note.md", "version 2");
1624
1625 let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1626 assert_eq!(
1627 changes.upserted,
1628 vec!["projects/myproject/note".to_string()]
1629 );
1630 assert!(changes.removed.is_empty());
1631 }
1632
1633 #[test]
1636 fn diff_changed_memories_zero_oid_treats_all_as_added() {
1637 let dir = tempfile::tempdir().unwrap();
1638 let repo = open_repo(&dir, None);
1639
1640 let new_oid = commit_file(&repo, "global/first-memory.md", "# Hello");
1642
1643 let old_oid = [0u8; 20];
1645
1646 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1647 assert_eq!(
1648 changes.upserted,
1649 vec!["global/first-memory".to_string()],
1650 "zero OID: all new-tree files should be additions"
1651 );
1652 assert!(changes.removed.is_empty(), "zero OID: no removals expected");
1653 }
1654}