1use std::{
2 path::{Path, PathBuf},
3 sync::{Arc, Mutex},
4};
5
6use git2::{build::CheckoutBuilder, ErrorCode, MergeOptions, Repository, Signature};
7use tracing::{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(crate) fn traced_spawn_blocking<F, T>(f: F) -> tokio::task::JoinHandle<T>
115where
116 F: FnOnce() -> T + Send + 'static,
117 T: Send + 'static,
118{
119 let dispatch = tracing::dispatcher::get_default(|d| d.clone());
120 #[allow(clippy::disallowed_methods)]
121 tokio::task::spawn_blocking(move || {
122 let _guard = tracing::dispatcher::set_default(&dispatch);
123 f()
124 })
125}
126
127pub struct MemoryRepo {
129 inner: Mutex<Repository>,
130 root: PathBuf,
131 reporter: SubsystemReporter,
132 sync_reporter: SubsystemReporter,
133}
134
135unsafe impl Send for MemoryRepo {}
139unsafe impl Sync for MemoryRepo {}
140
141impl MemoryRepo {
142 pub fn init_or_open(path: &Path, remote_url: Option<&str>) -> Result<Self, MemoryError> {
150 Self::init_or_open_with_reporter(
151 path,
152 remote_url,
153 SubsystemReporter::new(),
154 SubsystemReporter::new(),
155 )
156 }
157
158 pub fn init_or_open_with_reporter(
162 path: &Path,
163 remote_url: Option<&str>,
164 reporter: SubsystemReporter,
165 sync_reporter: SubsystemReporter,
166 ) -> Result<Self, MemoryError> {
167 let _span = tracing::info_span!("repo.init").entered();
168
169 let repo = if path.join(".git").exists() {
170 Repository::open(path)?
171 } else {
172 let mut opts = git2::RepositoryInitOptions::new();
173 opts.initial_head("main");
174 let repo = Repository::init_opts(path, &opts)?;
175 let gitignore = path.join(".gitignore");
177 if !gitignore.exists() {
178 std::fs::write(&gitignore, ".memory-mcp-index/\n")?;
179 }
180 {
182 let mut index = repo.index()?;
183 index.add_path(Path::new(".gitignore"))?;
184 index.write()?;
185 let tree_oid = index.write_tree()?;
186 let tree = repo.find_tree(tree_oid)?;
187 let sig = Signature::now("memory-mcp", "memory-mcp@local")?;
188 repo.commit(
189 Some("HEAD"),
190 &sig,
191 &sig,
192 "chore: init repository",
193 &tree,
194 &[],
195 )?;
196 }
197 repo
198 };
199
200 if let Some(url) = remote_url {
202 match repo.find_remote("origin") {
203 Ok(existing) => {
204 let current_url = existing.url().unwrap_or("");
206 if current_url != url {
207 repo.remote_set_url("origin", url)?;
208 info!("updated origin remote URL to {}", redact_url(url));
209 }
210 }
211 Err(e) if e.code() == ErrorCode::NotFound => {
212 repo.remote("origin", url)?;
213 info!("created origin remote pointing at {}", redact_url(url));
214 }
215 Err(e) => return Err(MemoryError::Git(e)),
216 }
217 }
218
219 Ok(Self {
220 inner: Mutex::new(repo),
221 root: path.to_path_buf(),
222 reporter,
223 sync_reporter,
224 })
225 }
226
227 pub async fn head_sha(self: &Arc<Self>) -> Option<String> {
230 let me = Arc::clone(self);
231 traced_spawn_blocking(move || {
232 let repo = me.inner.lock().expect("repo mutex poisoned");
233 let oid_bytes = capture_head_oid(&repo).ok()?;
234 if oid_bytes == [0u8; 20] {
235 return None;
236 }
237 git2::Oid::from_bytes(&oid_bytes)
238 .ok()
239 .map(|oid| oid.to_string())
240 })
241 .await
242 .ok()
243 .flatten()
244 }
245
246 fn memory_path(&self, name: &str, scope: &Scope) -> PathBuf {
248 self.root
249 .join(scope.dir_prefix())
250 .join(format!("{}.md", name))
251 }
252
253 pub async fn save_memory(self: &Arc<Self>, memory: &Memory) -> Result<(), MemoryError> {
258 validate_name(&memory.name)?;
259 if let Scope::Project(ref project_name) = memory.metadata.scope {
260 validate_name(project_name)?;
261 }
262
263 let file_path = self.memory_path(&memory.name, &memory.metadata.scope);
264 self.assert_within_root(&file_path)?;
265
266 let arc = Arc::clone(self);
267 let memory = memory.clone();
268 let name = memory.name.clone();
269
270 let span = tracing::info_span!("repo.save", name = %name, oid = tracing::field::Empty);
271
272 let result = traced_spawn_blocking(move || -> Result<(), MemoryError> {
273 let _enter = span.entered();
274 let repo = arc
275 .inner
276 .lock()
277 .expect("lock poisoned — prior panic corrupted state");
278
279 if let Some(parent) = file_path.parent() {
281 std::fs::create_dir_all(parent)?;
282 }
283
284 let markdown = memory.to_markdown()?;
285 arc.write_memory_file(&file_path, markdown.as_bytes())?;
286
287 let oid = arc.git_add_and_commit(
288 &repo,
289 &file_path,
290 &format!("chore: save memory '{}'", memory.name),
291 )?;
292
293 tracing::Span::current().record("oid", oid.to_string().as_str());
294 info!(oid = %oid, "memory saved to repo");
295
296 Ok(())
297 })
298 .await
299 .map_err(|e| MemoryError::Join(e.to_string()))?;
300
301 match &result {
302 Ok(_) => self.reporter.report_ok(),
303 Err(_) => self.reporter.report_err("save_memory failed"),
304 }
305 result
306 }
307
308 pub async fn delete_memory(
310 self: &Arc<Self>,
311 name: &str,
312 scope: &Scope,
313 ) -> Result<(), MemoryError> {
314 validate_name(name)?;
315 if let Scope::Project(ref project_name) = *scope {
316 validate_name(project_name)?;
317 }
318
319 let file_path = self.memory_path(name, scope);
320 self.assert_within_root(&file_path)?;
321
322 let arc = Arc::clone(self);
323 let name = name.to_string();
324 let file_path_clone = file_path.clone();
325 let span = tracing::info_span!("repo.delete", name = %name, oid = tracing::field::Empty);
326 let result = traced_spawn_blocking(move || -> Result<(), MemoryError> {
327 let _enter = span.entered();
328 let repo = arc
329 .inner
330 .lock()
331 .expect("lock poisoned — prior panic corrupted state");
332
333 match std::fs::symlink_metadata(&file_path_clone) {
335 Err(_) => return Err(MemoryError::NotFound { name: name.clone() }),
336 Ok(m) if m.file_type().is_symlink() => {
337 return Err(MemoryError::InvalidInput {
338 reason: format!(
339 "path '{}' is a symlink, which is not permitted",
340 file_path_clone.display()
341 ),
342 });
343 }
344 Ok(_) => {}
345 }
346
347 std::fs::remove_file(&file_path_clone)?;
348 let relative =
350 file_path_clone
351 .strip_prefix(&arc.root)
352 .map_err(|e| MemoryError::InvalidInput {
353 reason: format!("path strip error: {}", e),
354 })?;
355 let mut index = repo.index()?;
356 index.remove_path(relative)?;
357
358 let message = format!("chore: delete memory '{}'", name);
359 let oid = arc.commit_index(&repo, &mut index, &message)?;
360
361 tracing::Span::current().record("oid", oid.to_string().as_str());
362 info!(oid = %oid, "memory deleted from repo");
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 = traced_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 = traced_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 = traced_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 = traced_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 commit_index(
1028 &self,
1029 repo: &Repository,
1030 index: &mut git2::Index,
1031 message: &str,
1032 ) -> Result<git2::Oid, MemoryError> {
1033 index.write()?;
1034 let tree_oid = index.write_tree()?;
1035 let tree = repo.find_tree(tree_oid)?;
1036 let sig = self.signature(repo)?;
1037
1038 let oid = match repo.head() {
1039 Ok(head) => {
1040 let parent_commit = head.peel_to_commit()?;
1041 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])?
1042 }
1043 Err(e) if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => {
1044 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
1045 }
1046 Err(e) => return Err(MemoryError::Git(e)),
1047 };
1048
1049 Ok(oid)
1050 }
1051
1052 fn git_add_and_commit(
1054 &self,
1055 repo: &Repository,
1056 file_path: &Path,
1057 message: &str,
1058 ) -> Result<git2::Oid, MemoryError> {
1059 let relative =
1060 file_path
1061 .strip_prefix(&self.root)
1062 .map_err(|e| MemoryError::InvalidInput {
1063 reason: format!("path strip error: {}", e),
1064 })?;
1065
1066 let mut index = repo.index()?;
1067 index.add_path(relative)?;
1068
1069 self.commit_index(repo, &mut index, message)
1070 }
1071
1072 fn assert_within_root(&self, path: &Path) -> Result<(), MemoryError> {
1075 let parent = path.parent().unwrap_or(path);
1078 let filename = path.file_name().ok_or_else(|| MemoryError::InvalidInput {
1079 reason: "path has no filename component".to_string(),
1080 })?;
1081
1082 let canon_parent = {
1085 let mut p = parent.to_path_buf();
1086 let mut suffixes: Vec<std::ffi::OsString> = Vec::new();
1087 loop {
1088 match p.canonicalize() {
1089 Ok(c) => {
1090 let mut full = c;
1091 for s in suffixes.into_iter().rev() {
1092 full.push(s);
1093 }
1094 break full;
1095 }
1096 Err(_) => {
1097 if let Some(name) = p.file_name() {
1098 suffixes.push(name.to_os_string());
1099 }
1100 match p.parent() {
1101 Some(par) => p = par.to_path_buf(),
1102 None => {
1103 return Err(MemoryError::InvalidInput {
1104 reason: "cannot resolve any ancestor of path".into(),
1105 });
1106 }
1107 }
1108 }
1109 }
1110 }
1111 };
1112
1113 let resolved = canon_parent.join(filename);
1114
1115 let canon_root = self
1116 .root
1117 .canonicalize()
1118 .map_err(|e| MemoryError::InvalidInput {
1119 reason: format!("cannot canonicalize repo root: {}", e),
1120 })?;
1121
1122 if !resolved.starts_with(&canon_root) {
1123 return Err(MemoryError::InvalidInput {
1124 reason: format!(
1125 "path '{}' escapes repository root '{}'",
1126 resolved.display(),
1127 canon_root.display()
1128 ),
1129 });
1130 }
1131
1132 {
1137 let mut probe = canon_root.clone();
1138 let relative =
1140 resolved
1141 .strip_prefix(&canon_root)
1142 .map_err(|e| MemoryError::InvalidInput {
1143 reason: format!("path strip error: {}", e),
1144 })?;
1145 for component in relative.components() {
1146 probe.push(component);
1147 if (probe.exists() || probe.symlink_metadata().is_ok())
1149 && probe
1150 .symlink_metadata()
1151 .map(|m| m.file_type().is_symlink())
1152 .unwrap_or(false)
1153 {
1154 return Err(MemoryError::InvalidInput {
1155 reason: format!(
1156 "path component '{}' is a symlink, which is not allowed",
1157 probe.display()
1158 ),
1159 });
1160 }
1161 }
1162 }
1163
1164 Ok(())
1165 }
1166
1167 fn write_memory_file(&self, path: &Path, data: &[u8]) -> Result<(), MemoryError> {
1178 if path
1180 .symlink_metadata()
1181 .map(|m| m.file_type().is_symlink())
1182 .unwrap_or(false)
1183 {
1184 return Err(MemoryError::InvalidInput {
1185 reason: format!("refusing to write through symlink: {}", path.display()),
1186 });
1187 }
1188
1189 #[cfg(unix)]
1193 {
1194 use std::os::unix::fs::OpenOptionsExt as _;
1195 if let Err(e) = std::fs::OpenOptions::new()
1196 .read(true)
1197 .custom_flags(libc::O_NOFOLLOW)
1198 .open(path)
1199 {
1200 if e.kind() != std::io::ErrorKind::NotFound {
1202 return Err(MemoryError::InvalidInput {
1203 reason: format!("O_NOFOLLOW check failed for {}: {e}", path.display()),
1204 });
1205 }
1206 }
1207 }
1208
1209 crate::fs_util::atomic_write(path, data)?;
1210 Ok(())
1211 }
1212
1213 fn read_memory_file(&self, path: &Path) -> Result<String, MemoryError> {
1218 #[cfg(unix)]
1219 {
1220 use std::io::Read as _;
1221 use std::os::unix::fs::OpenOptionsExt as _;
1222 let mut f = std::fs::OpenOptions::new()
1223 .read(true)
1224 .custom_flags(libc::O_NOFOLLOW)
1225 .open(path)?;
1226 let mut buf = String::new();
1227 f.read_to_string(&mut buf)?;
1228 Ok(buf)
1229 }
1230 #[cfg(not(unix))]
1231 {
1232 Ok(std::fs::read_to_string(path)?)
1233 }
1234 }
1235}
1236
1237#[cfg(test)]
1242mod tests {
1243 use super::*;
1244 use crate::auth::AuthProvider;
1245 use crate::types::{Memory, MemoryMetadata, PullResult, Scope};
1246 use std::sync::Arc;
1247
1248 fn test_auth() -> AuthProvider {
1249 AuthProvider::with_token("test-token-unused-for-file-remotes")
1250 }
1251
1252 fn make_memory(name: &str, content: &str, updated_at_secs: i64) -> Memory {
1253 let meta = MemoryMetadata {
1254 tags: vec![],
1255 scope: Scope::Global,
1256 created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
1257 updated_at: chrono::DateTime::from_timestamp(updated_at_secs, 0).unwrap(),
1258 source: None,
1259 };
1260 Memory::new(name.to_string(), content.to_string(), meta)
1261 }
1262
1263 fn setup_bare_remote() -> (tempfile::TempDir, String) {
1264 let dir = tempfile::tempdir().expect("failed to create temp dir");
1265 git2::Repository::init_bare(dir.path()).expect("failed to init bare repo");
1266 let url = format!("file://{}", dir.path().display());
1267 (dir, url)
1268 }
1269
1270 fn open_repo(dir: &tempfile::TempDir, remote_url: Option<&str>) -> Arc<MemoryRepo> {
1271 Arc::new(MemoryRepo::init_or_open(dir.path(), remote_url).expect("failed to init repo"))
1272 }
1273
1274 #[test]
1277 fn redact_url_strips_userinfo() {
1278 assert_eq!(
1279 redact_url("https://user:ghp_token123@github.com/org/repo.git"),
1280 "https://[REDACTED]@github.com/org/repo.git"
1281 );
1282 }
1283
1284 #[test]
1285 fn redact_url_no_at_passthrough() {
1286 let url = "https://github.com/org/repo.git";
1287 assert_eq!(redact_url(url), url);
1288 }
1289
1290 #[test]
1291 fn redact_url_file_protocol_passthrough() {
1292 let url = "file:///tmp/bare.git";
1293 assert_eq!(redact_url(url), url);
1294 }
1295
1296 #[test]
1299 fn assert_within_root_accepts_valid_path() {
1300 let dir = tempfile::tempdir().unwrap();
1301 let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1302 let valid = dir.path().join("global").join("my-memory.md");
1303 std::fs::create_dir_all(valid.parent().unwrap()).unwrap();
1305 assert!(repo.assert_within_root(&valid).is_ok());
1306 }
1307
1308 #[test]
1309 fn assert_within_root_rejects_escape() {
1310 let dir = tempfile::tempdir().unwrap();
1311 let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1312 let _evil = dir
1315 .path()
1316 .join("..")
1317 .join("..")
1318 .join("..")
1319 .join("tmp")
1320 .join("evil.md");
1321 let outside = std::path::PathBuf::from("/tmp/definitely-outside");
1325 assert!(repo.assert_within_root(&outside).is_err());
1326 }
1327
1328 #[tokio::test]
1331 async fn push_local_only_returns_ok() {
1332 let dir = tempfile::tempdir().unwrap();
1333 let repo = open_repo(&dir, None);
1334 let auth = test_auth();
1335 let result = repo.push(&auth, "main").await;
1337 assert!(result.is_ok());
1338 }
1339
1340 #[tokio::test]
1341 async fn pull_local_only_returns_no_remote() {
1342 let dir = tempfile::tempdir().unwrap();
1343 let repo = open_repo(&dir, None);
1344 let auth = test_auth();
1345 let result = repo.pull(&auth, "main").await.unwrap();
1346 assert!(matches!(result, PullResult::NoRemote));
1347 }
1348
1349 #[tokio::test]
1352 async fn push_to_bare_remote() {
1353 let (_remote_dir, remote_url) = setup_bare_remote();
1354 let local_dir = tempfile::tempdir().unwrap();
1355 let repo = open_repo(&local_dir, Some(&remote_url));
1356 let auth = test_auth();
1357
1358 let mem = make_memory("test-push", "push content", 1_700_000_000);
1360 repo.save_memory(&mem).await.unwrap();
1361
1362 repo.push(&auth, "main").await.unwrap();
1364
1365 let bare = git2::Repository::open_bare(_remote_dir.path()).unwrap();
1367 let head = bare.find_reference("refs/heads/main").unwrap();
1368 let commit = head.peel_to_commit().unwrap();
1369 assert!(commit.message().unwrap().contains("test-push"));
1370 }
1371
1372 #[tokio::test]
1373 async fn pull_from_empty_bare_remote_returns_up_to_date() {
1374 let (_remote_dir, remote_url) = setup_bare_remote();
1375 let local_dir = tempfile::tempdir().unwrap();
1376 let repo = open_repo(&local_dir, Some(&remote_url));
1377 let auth = test_auth();
1378
1379 let mem = make_memory("seed", "seed content", 1_700_000_000);
1381 repo.save_memory(&mem).await.unwrap();
1382
1383 let result = repo.pull(&auth, "main").await.unwrap();
1385 assert!(matches!(result, PullResult::UpToDate));
1386 }
1387
1388 #[tokio::test]
1389 async fn pull_fast_forward() {
1390 let (_remote_dir, remote_url) = setup_bare_remote();
1391 let auth = test_auth();
1392
1393 let dir_a = tempfile::tempdir().unwrap();
1395 let repo_a = open_repo(&dir_a, Some(&remote_url));
1396 let mem = make_memory("from-a", "content from A", 1_700_000_000);
1397 repo_a.save_memory(&mem).await.unwrap();
1398 repo_a.push(&auth, "main").await.unwrap();
1399
1400 let dir_b = tempfile::tempdir().unwrap();
1402 let repo_b = open_repo(&dir_b, Some(&remote_url));
1403 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1405 repo_b.save_memory(&seed).await.unwrap();
1406
1407 let result = repo_b.pull(&auth, "main").await.unwrap();
1408 assert!(
1409 matches!(
1410 result,
1411 PullResult::FastForward { .. } | PullResult::Merged { .. }
1412 ),
1413 "expected fast-forward or merge, got {:?}",
1414 result
1415 );
1416
1417 let file = dir_b.path().join("global").join("from-a.md");
1419 assert!(file.exists(), "from-a.md should exist in repo B after pull");
1420 }
1421
1422 #[tokio::test]
1423 async fn pull_up_to_date_after_push() {
1424 let (_remote_dir, remote_url) = setup_bare_remote();
1425 let local_dir = tempfile::tempdir().unwrap();
1426 let repo = open_repo(&local_dir, Some(&remote_url));
1427 let auth = test_auth();
1428
1429 let mem = make_memory("synced", "synced content", 1_700_000_000);
1430 repo.save_memory(&mem).await.unwrap();
1431 repo.push(&auth, "main").await.unwrap();
1432
1433 let result = repo.pull(&auth, "main").await.unwrap();
1435 assert!(matches!(result, PullResult::UpToDate));
1436 }
1437
1438 #[tokio::test]
1441 async fn pull_merge_conflict_theirs_newer_wins() {
1442 let (_remote_dir, remote_url) = setup_bare_remote();
1443 let auth = test_auth();
1444
1445 let dir_a = tempfile::tempdir().unwrap();
1447 let repo_a = open_repo(&dir_a, Some(&remote_url));
1448 let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1449 repo_a.save_memory(&mem_a1).await.unwrap();
1450 repo_a.push(&auth, "main").await.unwrap();
1451
1452 let dir_b = tempfile::tempdir().unwrap();
1454 let repo_b = open_repo(&dir_b, Some(&remote_url));
1455 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1456 repo_b.save_memory(&seed).await.unwrap();
1457 repo_b.pull(&auth, "main").await.unwrap();
1458
1459 let mem_b = make_memory("shared", "version from B (newer)", 1_700_000_300);
1460 repo_b.save_memory(&mem_b).await.unwrap();
1461 repo_b.push(&auth, "main").await.unwrap();
1462
1463 let mem_a2 = make_memory("shared", "version from A (older)", 1_700_000_200);
1465 repo_a.save_memory(&mem_a2).await.unwrap();
1466 let result = repo_a.pull(&auth, "main").await.unwrap();
1467
1468 assert!(
1469 matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1470 "expected merge with conflicts resolved, got {:?}",
1471 result
1472 );
1473
1474 let file = dir_a.path().join("global").join("shared.md");
1476 let content = std::fs::read_to_string(&file).unwrap();
1477 assert!(
1478 content.contains("version from B (newer)"),
1479 "expected B's version to win (newer timestamp), got: {}",
1480 content
1481 );
1482 }
1483
1484 #[tokio::test]
1485 async fn pull_merge_conflict_ours_newer_wins() {
1486 let (_remote_dir, remote_url) = setup_bare_remote();
1487 let auth = test_auth();
1488
1489 let dir_a = tempfile::tempdir().unwrap();
1491 let repo_a = open_repo(&dir_a, Some(&remote_url));
1492 let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1493 repo_a.save_memory(&mem_a1).await.unwrap();
1494 repo_a.push(&auth, "main").await.unwrap();
1495
1496 let dir_b = tempfile::tempdir().unwrap();
1498 let repo_b = open_repo(&dir_b, Some(&remote_url));
1499 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1500 repo_b.save_memory(&seed).await.unwrap();
1501 repo_b.pull(&auth, "main").await.unwrap();
1502
1503 let mem_b = make_memory("shared", "version from B (older)", 1_700_000_200);
1504 repo_b.save_memory(&mem_b).await.unwrap();
1505 repo_b.push(&auth, "main").await.unwrap();
1506
1507 let mem_a2 = make_memory("shared", "version from A (newer)", 1_700_000_300);
1509 repo_a.save_memory(&mem_a2).await.unwrap();
1510 let result = repo_a.pull(&auth, "main").await.unwrap();
1511
1512 assert!(
1513 matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1514 "expected merge with conflicts resolved, got {:?}",
1515 result
1516 );
1517
1518 let file = dir_a.path().join("global").join("shared.md");
1520 let content = std::fs::read_to_string(&file).unwrap();
1521 assert!(
1522 content.contains("version from A (newer)"),
1523 "expected A's version to win (newer timestamp), got: {}",
1524 content
1525 );
1526 }
1527
1528 #[tokio::test]
1529 async fn pull_merge_no_conflict_different_files() {
1530 let (_remote_dir, remote_url) = setup_bare_remote();
1531 let auth = test_auth();
1532
1533 let dir_a = tempfile::tempdir().unwrap();
1535 let repo_a = open_repo(&dir_a, Some(&remote_url));
1536 let mem_a = make_memory("mem-a", "from A", 1_700_000_100);
1537 repo_a.save_memory(&mem_a).await.unwrap();
1538 repo_a.push(&auth, "main").await.unwrap();
1539
1540 let dir_b = tempfile::tempdir().unwrap();
1542 let repo_b = open_repo(&dir_b, Some(&remote_url));
1543 let seed = make_memory("seed-b", "seed", 1_700_000_000);
1544 repo_b.save_memory(&seed).await.unwrap();
1545 repo_b.pull(&auth, "main").await.unwrap();
1546 let mem_b = make_memory("mem-b", "from B", 1_700_000_200);
1547 repo_b.save_memory(&mem_b).await.unwrap();
1548 repo_b.push(&auth, "main").await.unwrap();
1549
1550 let mem_a2 = make_memory("mem-a2", "also from A", 1_700_000_300);
1552 repo_a.save_memory(&mem_a2).await.unwrap();
1553 let result = repo_a.pull(&auth, "main").await.unwrap();
1554
1555 assert!(
1556 matches!(
1557 result,
1558 PullResult::Merged {
1559 conflicts_resolved: 0,
1560 ..
1561 }
1562 ),
1563 "expected clean merge, got {:?}",
1564 result
1565 );
1566
1567 assert!(dir_a.path().join("global").join("mem-b.md").exists());
1569 }
1570
1571 fn commit_file(repo: &Arc<MemoryRepo>, rel_path: &str, content: &str) -> [u8; 20] {
1575 let inner = repo.inner.lock().expect("lock poisoned");
1576 let full_path = repo.root.join(rel_path);
1577 if let Some(parent) = full_path.parent() {
1578 std::fs::create_dir_all(parent).unwrap();
1579 }
1580 std::fs::write(&full_path, content).unwrap();
1581
1582 let mut index = inner.index().unwrap();
1583 index.add_path(std::path::Path::new(rel_path)).unwrap();
1584 index.write().unwrap();
1585 let tree_oid = index.write_tree().unwrap();
1586 let tree = inner.find_tree(tree_oid).unwrap();
1587 let sig = git2::Signature::now("test", "test@test.com").unwrap();
1588
1589 let oid = match inner.head() {
1590 Ok(head) => {
1591 let parent = head.peel_to_commit().unwrap();
1592 inner
1593 .commit(Some("HEAD"), &sig, &sig, "test commit", &tree, &[&parent])
1594 .unwrap()
1595 }
1596 Err(_) => inner
1597 .commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
1598 .unwrap(),
1599 };
1600
1601 let mut buf = [0u8; 20];
1602 buf.copy_from_slice(oid.as_bytes());
1603 buf
1604 }
1605
1606 #[test]
1607 fn diff_changed_memories_detects_added_global() {
1608 let dir = tempfile::tempdir().unwrap();
1609 let repo = open_repo(&dir, None);
1610
1611 let old_oid = {
1613 let inner = repo.inner.lock().unwrap();
1614 let head = inner.head().unwrap();
1615 let mut buf = [0u8; 20];
1616 buf.copy_from_slice(head.peel_to_commit().unwrap().id().as_bytes());
1617 buf
1618 };
1619
1620 let new_oid = commit_file(&repo, "global/my-note.md", "# content");
1621
1622 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1623 assert_eq!(changes.upserted, vec!["global/my-note".to_string()]);
1624 assert!(changes.removed.is_empty());
1625 }
1626
1627 #[test]
1628 fn diff_changed_memories_detects_deleted() {
1629 let dir = tempfile::tempdir().unwrap();
1630 let repo = open_repo(&dir, None);
1631
1632 let first_oid = commit_file(&repo, "global/to-delete.md", "hello");
1633 let second_oid = {
1634 let inner = repo.inner.lock().unwrap();
1635 let full_path = dir.path().join("global/to-delete.md");
1636 std::fs::remove_file(&full_path).unwrap();
1637 let mut index = inner.index().unwrap();
1638 index
1639 .remove_path(std::path::Path::new("global/to-delete.md"))
1640 .unwrap();
1641 index.write().unwrap();
1642 let tree_oid = index.write_tree().unwrap();
1643 let tree = inner.find_tree(tree_oid).unwrap();
1644 let sig = git2::Signature::now("test", "test@test.com").unwrap();
1645 let parent = inner.head().unwrap().peel_to_commit().unwrap();
1646 let oid = inner
1647 .commit(Some("HEAD"), &sig, &sig, "delete file", &tree, &[&parent])
1648 .unwrap();
1649 let mut buf = [0u8; 20];
1650 buf.copy_from_slice(oid.as_bytes());
1651 buf
1652 };
1653
1654 let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1655 assert!(changes.upserted.is_empty());
1656 assert_eq!(changes.removed, vec!["global/to-delete".to_string()]);
1657 }
1658
1659 #[test]
1660 fn diff_changed_memories_ignores_non_md_files() {
1661 let dir = tempfile::tempdir().unwrap();
1662 let repo = open_repo(&dir, None);
1663
1664 let old_oid = {
1665 let inner = repo.inner.lock().unwrap();
1666 let mut buf = [0u8; 20];
1667 buf.copy_from_slice(
1668 inner
1669 .head()
1670 .unwrap()
1671 .peel_to_commit()
1672 .unwrap()
1673 .id()
1674 .as_bytes(),
1675 );
1676 buf
1677 };
1678
1679 let _ = commit_file(&repo, "global/config.json", "{}");
1681 let new_oid = commit_file(&repo, "other/note.md", "# ignored");
1682
1683 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1684 assert!(
1685 changes.upserted.is_empty(),
1686 "should ignore non-.md and out-of-scope files"
1687 );
1688 assert!(changes.removed.is_empty());
1689 }
1690
1691 #[test]
1692 fn diff_changed_memories_detects_modified() {
1693 let dir = tempfile::tempdir().unwrap();
1694 let repo = open_repo(&dir, None);
1695
1696 let first_oid = commit_file(&repo, "projects/myproject/note.md", "version 1");
1697 let second_oid = commit_file(&repo, "projects/myproject/note.md", "version 2");
1698
1699 let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1700 assert_eq!(
1701 changes.upserted,
1702 vec!["projects/myproject/note".to_string()]
1703 );
1704 assert!(changes.removed.is_empty());
1705 }
1706
1707 #[test]
1710 fn diff_changed_memories_zero_oid_treats_all_as_added() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let repo = open_repo(&dir, None);
1713
1714 let new_oid = commit_file(&repo, "global/first-memory.md", "# Hello");
1716
1717 let old_oid = [0u8; 20];
1719
1720 let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1721 assert_eq!(
1722 changes.upserted,
1723 vec!["global/first-memory".to_string()],
1724 "zero OID: all new-tree files should be additions"
1725 );
1726 assert!(changes.removed.is_empty(), "zero OID: no removals expected");
1727 }
1728}