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