Skip to main content

memory_mcp/
repo.rs

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
17// ---------------------------------------------------------------------------
18// Module-level helpers
19// ---------------------------------------------------------------------------
20
21/// Strip userinfo (credentials) from a URL before logging.
22///
23/// `https://user:token@host/path` → `https://[REDACTED]@host/path`
24fn 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
35/// Return the current HEAD commit OID as a 20-byte array.
36///
37/// Returns `[0u8; 20]` as a sentinel when the branch is unborn (no commits yet).
38fn 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        // Unborn branch — use zero OID as sentinel.
47        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
54/// Perform a fast-forward of `fetch_commit` into `branch`.
55///
56/// Captures the old HEAD OID (zero sentinel if unborn), advances the branch
57/// ref, sets HEAD, and force-checks out the new tree.
58fn 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            // Branch doesn't exist locally yet — create it.
74            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
96/// Build a `RemoteCallbacks` that authenticates with the given token.
97///
98/// The callbacks live for `'static` because the token is moved in.
99fn 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
107/// Git-backed repository for persisting and syncing memory files.
108pub struct MemoryRepo {
109    inner: Mutex<Repository>,
110    root: PathBuf,
111}
112
113// SAFETY: Repository holds raw pointers but is documented as safe to send
114// across threads when not used concurrently. We guarantee exclusive access via
115// the Mutex, so MemoryRepo is Send + Sync.
116unsafe impl Send for MemoryRepo {}
117unsafe impl Sync for MemoryRepo {}
118
119impl MemoryRepo {
120    /// Open an existing git repo at `path`, or initialise a new one.
121    ///
122    /// If `remote_url` is provided, ensures an `origin` remote exists pointing
123    /// at that URL (creating or updating it as necessary).
124    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            // Write a .gitignore so the vector index is never committed.
134            let gitignore = path.join(".gitignore");
135            if !gitignore.exists() {
136                std::fs::write(&gitignore, ".memory-mcp-index/\n")?;
137            }
138            // Commit .gitignore as the initial commit.
139            {
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        // Set up or update the origin remote if a URL was provided.
159        if let Some(url) = remote_url {
160            match repo.find_remote("origin") {
161                Ok(existing) => {
162                    // Update the URL only when it differs from the current one.
163                    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    /// Return the current HEAD commit SHA as a hex string, or `None` if the
184    /// branch is unborn (no commits yet).
185    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    /// Absolute path for a memory's markdown file inside the repo.
203    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    /// Write the memory file to disk, then `git add` + `git commit`.
210    ///
211    /// All blocking work (mutex lock + fs ops + git2 ops) is performed inside
212    /// `tokio::task::spawn_blocking` so the async executor is not stalled.
213    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        // Capture span before entering spawn_blocking so the child thread can record oid.
227        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            // Ensure the parent directory exists.
237            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            // Record the new HEAD OID after commit.
251            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    /// Remove a memory's file and commit the deletion.
265    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            // Check existence and symlink status atomically via symlink_metadata.
290            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            // git rm equivalent: stage the removal
305            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    /// Read and parse a memory from disk.
340    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            // Check existence/symlink status before opening.
359            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    /// List all memories, optionally filtered by scope.
379    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                    // Walk both global/ and projects/*
393                    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                    // Skip symlinks entirely to prevent directory traversal.
420                    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    /// Push local commits to `origin/<branch>`.
456    ///
457    /// If no `origin` remote is configured the call is a no-op (local-only
458    /// mode). Auth failures are propagated as `MemoryError::Auth`.
459    pub async fn push(
460        self: &Arc<Self>,
461        auth: &AuthProvider,
462        branch: &str,
463    ) -> Result<(), MemoryError> {
464        // Resolve the token early so we can move it (Send) into the
465        // spawn_blocking closure. We defer failing until after we've confirmed
466        // that origin exists — local-only mode needs no token at all.
467        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            // Origin exists — we need the token now.
489            let token = token_result?;
490            let mut callbacks = build_auth_callbacks(token);
491
492            // git2's Remote::push() does not surface server-side rejections
493            // through its return value — they arrive via this callback.
494            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    /// Perform a normal (non-fast-forward) merge of `fetch_commit` into HEAD.
527    ///
528    /// Resolves any conflicts using recency-based auto-resolution, creates the
529    /// merge commit, and cleans up MERGE state.
530    fn merge_with_remote(
531        &self,
532        repo: &git2::Repository,
533        fetch_commit: &git2::AnnotatedCommit,
534        branch: &str,
535    ) -> Result<PullResult, MemoryError> {
536        // Capture old HEAD before the merge commit.
537        // HEAD must exist here — merge analysis would not reach this path
538        // with an unborn branch. Propagate the error if it somehow does.
539        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        // Safety check: if any conflicts remain after auto-resolution,
555        // clean up the MERGE state and surface a clear error rather than
556        // letting write_tree() fail with an opaque message.
557        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        // Write the merged tree and create the merge commit.
565        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    /// Pull from `origin/<branch>` and merge into the current HEAD.
599    ///
600    /// Uses a recency-based auto-resolution strategy for conflicts: the version
601    /// with the more recent `updated_at` frontmatter timestamp wins. If
602    /// timestamps are equal or unparseable, the local version is kept.
603    pub async fn pull(
604        self: &Arc<Self>,
605        auth: &AuthProvider,
606        branch: &str,
607    ) -> Result<PullResult, MemoryError> {
608        // Resolve the token early so we can move it (Send) into the
609        // spawn_blocking closure. We defer failing until after we've confirmed
610        // that origin exists — local-only mode needs no token at all.
611        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            // ---- 1. Find origin -------------------------------------------------
624            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            // Origin exists — we need the token now.
634            let token = token_result?;
635
636            // ---- 2. Fetch -------------------------------------------------------
637            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            // ---- 3. Resolve FETCH_HEAD ------------------------------------------
643            let fetch_head = match repo.find_reference("FETCH_HEAD") {
644                Ok(r) => r,
645                Err(e) if e.code() == ErrorCode::NotFound => {
646                    // Empty remote — nothing to merge.
647                    return Ok(PullResult::UpToDate);
648                }
649                Err(e)
650                    if e.class() == git2::ErrorClass::Reference
651                        && e.message().contains("corrupted") =>
652                {
653                    // Empty/corrupted FETCH_HEAD (e.g. remote has no commits yet).
654                    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                    // FETCH_HEAD exists but can't be resolved (empty remote).
663                    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            // ---- 4. Merge analysis ----------------------------------------------
670            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    /// Diff two commits and return the memory files that changed.
688    ///
689    /// Only `.md` files under `global/` or `projects/` are considered.
690    /// Added/modified files go into `upserted`; deleted files go into `removed`.
691    /// Qualified names are returned without the `.md` suffix (e.g. `"global/foo"`).
692    ///
693    /// Must be called from within `spawn_blocking` since it uses git2.
694    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        // A zero OID indicates an unborn branch (no prior commits). In that case,
708        // diff against an empty tree so all files appear as additions.
709        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                // Only care about .md files under global/ or projects/
734                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                // Strip the .md suffix to get the qualified name.
742                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                        // For renames, the old path must be removed from the index
750                        // to avoid leaving a ghost vector behind.
751                        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    // -----------------------------------------------------------------------
784    // Private helpers
785    // -----------------------------------------------------------------------
786
787    /// Resolve all index conflicts using a recency-based strategy.
788    ///
789    /// For each conflicted entry, the version with the more recent `updated_at`
790    /// frontmatter timestamp wins. Ties and parse failures fall back to "ours"
791    /// (local). Returns the number of files resolved.
792    fn resolve_conflicts_by_recency(
793        &self,
794        repo: &Repository,
795        index: &mut git2::Index,
796    ) -> Result<usize, MemoryError> {
797        // Collect conflict info first to avoid borrow issues with the index.
798        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            // Pick the winning content as raw bytes.
867            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                        // Both UTF-8 conversions failed — fall back to raw blob bytes.
893                        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                                // Both blobs truly absent — remove the entry from
902                                // the index so write_tree() succeeds.
903                                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            // Write the chosen content to the working directory — going through
930            // assert_within_root and write_memory_file enforces path-traversal
931            // and symlink protections.
932            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            // Stage the resolution.
939            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        // Try repo config first, then fall back to a default.
955        let sig = repo
956            .signature()
957            .or_else(|_| Signature::now("memory-mcp", "memory-mcp@local"))?;
958        Ok(sig)
959    }
960
961    /// Stage `file_path` and create a commit.
962    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                // Initial commit — no parent.
990                repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
991            }
992            Err(e) => return Err(MemoryError::Git(e)),
993        }
994
995        Ok(())
996    }
997
998    /// Assert that `path` remains under `self.root` after canonicalisation,
999    /// preventing path-traversal attacks.
1000    fn assert_within_root(&self, path: &Path) -> Result<(), MemoryError> {
1001        // The file may not exist yet, so we canonicalize its parent and
1002        // then re-append the filename.
1003        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        // If the parent doesn't exist yet we check as many ancestors as
1009        // necessary until we find one that does.
1010        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        // Reject any symlinks within the repo root. We check each existing
1059        // component of `resolved` that lies inside `canon_root` — if any is a
1060        // symlink the request is rejected, because canonicalization already
1061        // followed it and the prefix check above would silently pass.
1062        {
1063            let mut probe = canon_root.clone();
1064            // Collect the path components that are beneath the root.
1065            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                // Only check components that currently exist on disk.
1074                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    /// Atomically write `data` to `path` via temp-file + rename.
1094    ///
1095    /// Defense-in-depth against symlink attacks (layered):
1096    /// 1. `validate_path` rejects symlinks in all path components.
1097    /// 2. An `lstat` check here catches symlinks created between
1098    ///    validation and write (narrows the TOCTOU window).
1099    /// 3. On Unix, an `O_NOFOLLOW` probe on the final path detects
1100    ///    symlinks planted in the window between lstat and
1101    ///    `atomic_write`. The temp file itself is separately guarded
1102    ///    by `O_NOFOLLOW` inside `write_tmp`.
1103    fn write_memory_file(&self, path: &Path, data: &[u8]) -> Result<(), MemoryError> {
1104        // Layer 2: lstat — reject if the target is currently a symlink.
1105        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        // Layer 3 (Unix): O_NOFOLLOW probe — kernel-level symlink rejection.
1116        // NotFound is fine (file doesn't exist yet); any other error (ELOOP
1117        // from a symlink, permission denied, etc.) is rejected.
1118        #[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                // NotFound is fine — the file doesn't exist yet.
1127                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    /// Open `path` for reading using `O_NOFOLLOW` on Unix, then return its
1140    /// contents as a `String`.
1141    ///
1142    /// On non-Unix platforms falls back to `std::fs::read_to_string`.
1143    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// ---------------------------------------------------------------------------
1164// Tests
1165// ---------------------------------------------------------------------------
1166
1167#[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    // -- redact_url tests --------------------------------------------------
1201
1202    #[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    // -- assert_within_root tests ------------------------------------------
1223
1224    #[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        // Create the parent so canonicalization works.
1230        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        // Build a path that escapes the repo root. We need enough ".." to go
1239        // above the tmpdir, then descend into /tmp/evil.
1240        let _evil = dir
1241            .path()
1242            .join("..")
1243            .join("..")
1244            .join("..")
1245            .join("tmp")
1246            .join("evil.md");
1247        // Only assert if the path actually resolves outside root.
1248        // (If the temp dir is at root level, this might not escape — use an
1249        // explicit absolute path instead.)
1250        let outside = std::path::PathBuf::from("/tmp/definitely-outside");
1251        assert!(repo.assert_within_root(&outside).is_err());
1252    }
1253
1254    // -- local-only mode tests (no origin) ---------------------------------
1255
1256    #[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        // No origin configured — push should silently succeed.
1262        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    // -- push/pull with local bare remote ----------------------------------
1276
1277    #[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        // Save a memory so there's something to push.
1285        let mem = make_memory("test-push", "push content", 1_700_000_000);
1286        repo.save_memory(&mem).await.unwrap();
1287
1288        // Push should succeed.
1289        repo.push(&auth, "main").await.unwrap();
1290
1291        // Verify the bare repo received the commit.
1292        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        // First save something locally so we have an initial commit (HEAD exists).
1306        let mem = make_memory("seed", "seed content", 1_700_000_000);
1307        repo.save_memory(&mem).await.unwrap();
1308
1309        // Pull from empty remote — should be up-to-date (not an error).
1310        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        // Repo A: save and push
1320        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        // Repo B: init with same remote, then pull
1327        let dir_b = tempfile::tempdir().unwrap();
1328        let repo_b = open_repo(&dir_b, Some(&remote_url));
1329        // Repo B needs an initial commit for HEAD to exist.
1330        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        // Verify the memory file from A exists in B's working directory.
1344        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        // Pull immediately after push — should be up to date.
1360        let result = repo.pull(&auth, "main").await.unwrap();
1361        assert!(matches!(result, PullResult::UpToDate));
1362    }
1363
1364    // -- conflict resolution tests -----------------------------------------
1365
1366    #[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        // Repo A: save "shared" with T1, push
1372        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        // Repo B: pull to get A's commit, then modify "shared" with T3 (newer), push
1379        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        // Repo A: modify "shared" with T2 (older than T3), then pull — conflict
1390        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        // Verify theirs (B's version, T3=300) won.
1401        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        // Repo A: save "shared" with T1, push
1416        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        // Repo B: pull, modify with T2 (older), push
1423        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        // Repo A: modify with T3 (newer), pull — conflict
1434        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        // Verify ours (A's version, T3=300) won.
1445        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        // Repo A: save "mem-a", push
1460        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        // Repo B: pull, save "mem-b", push
1467        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        // Repo A: save "mem-a2" (different file), pull — should merge cleanly
1477        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        // Both repos should have all files.
1494        assert!(dir_a.path().join("global").join("mem-b.md").exists());
1495    }
1496
1497    // -- diff_changed_memories tests ----------------------------------------
1498
1499    /// Helper: commit a file with given content and return the new HEAD OID bytes.
1500    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        // Capture the initial HEAD (init commit).
1538        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        // Add a non-.md file under global/ and a .md file outside tracked dirs.
1606        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    /// A zero OID (unborn branch sentinel) must not crash; all files in the
1634    /// new commit should appear as additions.
1635    #[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        // Commit a global memory file — this is the "new" state.
1641        let new_oid = commit_file(&repo, "global/first-memory.md", "# Hello");
1642
1643        // old_oid = [0u8; 20] simulates an unborn branch (no prior commit).
1644        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}