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    /// Absolute path for a memory's markdown file inside the repo.
184    fn memory_path(&self, name: &str, scope: &Scope) -> PathBuf {
185        self.root
186            .join(scope.dir_prefix())
187            .join(format!("{}.md", name))
188    }
189
190    /// Write the memory file to disk, then `git add` + `git commit`.
191    ///
192    /// All blocking work (mutex lock + fs ops + git2 ops) is performed inside
193    /// `tokio::task::spawn_blocking` so the async executor is not stalled.
194    pub async fn save_memory(self: &Arc<Self>, memory: &Memory) -> Result<(), MemoryError> {
195        validate_name(&memory.name)?;
196        if let Scope::Project(ref project_name) = memory.metadata.scope {
197            validate_name(project_name)?;
198        }
199
200        let file_path = self.memory_path(&memory.name, &memory.metadata.scope);
201        self.assert_within_root(&file_path)?;
202
203        let arc = Arc::clone(self);
204        let memory = memory.clone();
205        let name = memory.name.clone();
206
207        // Capture span before entering spawn_blocking so the child thread can record oid.
208        let span = tracing::debug_span!("repo.save", name = %name, oid = tracing::field::Empty);
209
210        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
211            let _enter = span.entered();
212            let repo = arc
213                .inner
214                .lock()
215                .expect("lock poisoned — prior panic corrupted state");
216
217            // Ensure the parent directory exists.
218            if let Some(parent) = file_path.parent() {
219                std::fs::create_dir_all(parent)?;
220            }
221
222            let markdown = memory.to_markdown()?;
223            arc.write_memory_file(&file_path, markdown.as_bytes())?;
224
225            arc.git_add_and_commit(
226                &repo,
227                &file_path,
228                &format!("chore: save memory '{}'", memory.name),
229            )?;
230
231            // Record the new HEAD OID after commit.
232            if let Ok(head) = repo.head() {
233                if let Ok(commit) = head.peel_to_commit() {
234                    tracing::Span::current().record("oid", commit.id().to_string().as_str());
235                    debug!(oid = %commit.id(), "memory saved to repo");
236                }
237            }
238
239            Ok(())
240        })
241        .await
242        .map_err(|e| MemoryError::Join(e.to_string()))?
243    }
244
245    /// Remove a memory's file and commit the deletion.
246    pub async fn delete_memory(
247        self: &Arc<Self>,
248        name: &str,
249        scope: &Scope,
250    ) -> Result<(), MemoryError> {
251        validate_name(name)?;
252        if let Scope::Project(ref project_name) = *scope {
253            validate_name(project_name)?;
254        }
255
256        let file_path = self.memory_path(name, scope);
257        self.assert_within_root(&file_path)?;
258
259        let arc = Arc::clone(self);
260        let name = name.to_string();
261        let file_path_clone = file_path.clone();
262        let span = tracing::debug_span!("repo.delete", name = %name);
263        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
264            let _enter = span.entered();
265            let repo = arc
266                .inner
267                .lock()
268                .expect("lock poisoned — prior panic corrupted state");
269
270            // Check existence and symlink status atomically via symlink_metadata.
271            match std::fs::symlink_metadata(&file_path_clone) {
272                Err(_) => return Err(MemoryError::NotFound { name: name.clone() }),
273                Ok(m) if m.file_type().is_symlink() => {
274                    return Err(MemoryError::InvalidInput {
275                        reason: format!(
276                            "path '{}' is a symlink, which is not permitted",
277                            file_path_clone.display()
278                        ),
279                    });
280                }
281                Ok(_) => {}
282            }
283
284            std::fs::remove_file(&file_path_clone)?;
285            // git rm equivalent: stage the removal
286            let relative =
287                file_path_clone
288                    .strip_prefix(&arc.root)
289                    .map_err(|e| MemoryError::InvalidInput {
290                        reason: format!("path strip error: {}", e),
291                    })?;
292            let mut index = repo.index()?;
293            index.remove_path(relative)?;
294            index.write()?;
295
296            let tree_oid = index.write_tree()?;
297            let tree = repo.find_tree(tree_oid)?;
298            let sig = arc.signature(&repo)?;
299            let message = format!("chore: delete memory '{}'", name);
300
301            match repo.head() {
302                Ok(head) => {
303                    let parent_commit = head.peel_to_commit()?;
304                    repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&parent_commit])?;
305                }
306                Err(e)
307                    if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound =>
308                {
309                    repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[])?;
310                }
311                Err(e) => return Err(MemoryError::Git(e)),
312            }
313
314            Ok(())
315        })
316        .await
317        .map_err(|e| MemoryError::Join(e.to_string()))?
318    }
319
320    /// Read and parse a memory from disk.
321    pub async fn read_memory(
322        self: &Arc<Self>,
323        name: &str,
324        scope: &Scope,
325    ) -> Result<Memory, MemoryError> {
326        validate_name(name)?;
327        if let Scope::Project(ref project_name) = *scope {
328            validate_name(project_name)?;
329        }
330
331        let file_path = self.memory_path(name, scope);
332        self.assert_within_root(&file_path)?;
333
334        let arc = Arc::clone(self);
335        let name = name.to_string();
336        let span = tracing::debug_span!("repo.read", name = %name);
337        tokio::task::spawn_blocking(move || -> Result<Memory, MemoryError> {
338            let _enter = span.entered();
339            // Check existence/symlink status before opening.
340            match std::fs::symlink_metadata(&file_path) {
341                Err(_) => return Err(MemoryError::NotFound { name }),
342                Ok(m) if m.file_type().is_symlink() => {
343                    return Err(MemoryError::InvalidInput {
344                        reason: format!(
345                            "path '{}' is a symlink, which is not permitted",
346                            file_path.display()
347                        ),
348                    });
349                }
350                Ok(_) => {}
351            }
352            let raw = arc.read_memory_file(&file_path)?;
353            Memory::from_markdown(&raw)
354        })
355        .await
356        .map_err(|e| MemoryError::Join(e.to_string()))?
357    }
358
359    /// List all memories, optionally filtered by scope.
360    pub async fn list_memories(
361        self: &Arc<Self>,
362        scope: Option<&Scope>,
363    ) -> Result<Vec<Memory>, MemoryError> {
364        let root = self.root.clone();
365        let scope_clone = scope.cloned();
366        let span = tracing::debug_span!("repo.list", file_count = tracing::field::Empty,);
367
368        tokio::task::spawn_blocking(move || -> Result<Vec<Memory>, MemoryError> {
369            let _enter = span.entered();
370            let dirs: Vec<PathBuf> = match scope_clone.as_ref() {
371                Some(s) => vec![root.join(s.dir_prefix())],
372                None => {
373                    // Walk both global/ and projects/*
374                    let mut dirs = Vec::new();
375                    let global = root.join("global");
376                    if global.exists() {
377                        dirs.push(global);
378                    }
379                    let projects = root.join("projects");
380                    if projects.exists() {
381                        for entry in std::fs::read_dir(&projects)? {
382                            let entry = entry?;
383                            if entry.file_type()?.is_dir() {
384                                dirs.push(entry.path());
385                            }
386                        }
387                    }
388                    dirs
389                }
390            };
391
392            fn collect_md_files(dir: &Path, out: &mut Vec<Memory>) -> Result<(), MemoryError> {
393                if !dir.exists() {
394                    return Ok(());
395                }
396                for entry in std::fs::read_dir(dir)? {
397                    let entry = entry?;
398                    let path = entry.path();
399                    let ft = entry.file_type()?;
400                    // Skip symlinks entirely to prevent directory traversal.
401                    if ft.is_symlink() {
402                        warn!(
403                            "skipping symlink at {:?} — symlinks are not permitted in the memory store",
404                            path
405                        );
406                        continue;
407                    }
408                    if ft.is_dir() {
409                        collect_md_files(&path, out)?;
410                    } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
411                        let raw = std::fs::read_to_string(&path)?;
412                        match Memory::from_markdown(&raw) {
413                            Ok(m) => out.push(m),
414                            Err(e) => {
415                                warn!("skipping {:?}: {}", path, e);
416                            }
417                        }
418                    }
419                }
420                Ok(())
421            }
422
423            let mut memories = Vec::new();
424            for dir in dirs {
425                collect_md_files(&dir, &mut memories)?;
426            }
427
428            tracing::Span::current().record("file_count", memories.len());
429
430            Ok(memories)
431        })
432        .await
433        .map_err(|e| MemoryError::Join(e.to_string()))?
434    }
435
436    /// Push local commits to `origin/<branch>`.
437    ///
438    /// If no `origin` remote is configured the call is a no-op (local-only
439    /// mode). Auth failures are propagated as `MemoryError::Auth`.
440    pub async fn push(
441        self: &Arc<Self>,
442        auth: &AuthProvider,
443        branch: &str,
444    ) -> Result<(), MemoryError> {
445        // Resolve the token early so we can move it (Send) into the
446        // spawn_blocking closure. We defer failing until after we've confirmed
447        // that origin exists — local-only mode needs no token at all.
448        let token_result = auth.resolve_token();
449        let arc = Arc::clone(self);
450        let branch = branch.to_string();
451        let span = tracing::debug_span!("repo.push", branch = %branch);
452
453        tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
454            let _enter = span.entered();
455            let repo = arc
456                .inner
457                .lock()
458                .expect("lock poisoned — prior panic corrupted state");
459
460            let mut remote = match repo.find_remote("origin") {
461                Ok(r) => r,
462                Err(e) if e.code() == ErrorCode::NotFound => {
463                    warn!("push: no origin remote configured — skipping (local-only mode)");
464                    return Ok(());
465                }
466                Err(e) => return Err(MemoryError::Git(e)),
467            };
468
469            // Origin exists — we need the token now.
470            let token = token_result?;
471            let mut callbacks = build_auth_callbacks(token);
472
473            // git2's Remote::push() does not surface server-side rejections
474            // through its return value — they arrive via this callback.
475            let rejections: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
476            let rej = Arc::clone(&rejections);
477            callbacks.push_update_reference(move |refname, status| {
478                if let Some(msg) = status {
479                    rej.lock()
480                        .expect("rejection lock poisoned")
481                        .push(format!("{refname}: {msg}"));
482                }
483                Ok(())
484            });
485
486            let mut push_opts = git2::PushOptions::new();
487            push_opts.remote_callbacks(callbacks);
488
489            let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
490            if let Err(e) = remote.push(&[&refspec], Some(&mut push_opts)) {
491                warn!("push to origin failed at transport level: {e}");
492                return Err(MemoryError::Git(e));
493            }
494
495            let rejected = rejections.lock().expect("rejection lock poisoned");
496            if !rejected.is_empty() {
497                return Err(MemoryError::PushRejected(rejected.join("; ")));
498            }
499
500            info!("pushed branch '{}' to origin", branch);
501            Ok(())
502        })
503        .await
504        .map_err(|e| MemoryError::Join(e.to_string()))?
505    }
506
507    /// Perform a normal (non-fast-forward) merge of `fetch_commit` into HEAD.
508    ///
509    /// Resolves any conflicts using recency-based auto-resolution, creates the
510    /// merge commit, and cleans up MERGE state.
511    fn merge_with_remote(
512        &self,
513        repo: &git2::Repository,
514        fetch_commit: &git2::AnnotatedCommit,
515        branch: &str,
516    ) -> Result<PullResult, MemoryError> {
517        // Capture old HEAD before the merge commit.
518        // HEAD must exist here — merge analysis would not reach this path
519        // with an unborn branch. Propagate the error if it somehow does.
520        let oid = repo.head()?.peel_to_commit()?.id();
521        let mut old_head = [0u8; 20];
522        old_head.copy_from_slice(oid.as_bytes());
523
524        let mut merge_opts = MergeOptions::new();
525        merge_opts.fail_on_conflict(false);
526        repo.merge(&[fetch_commit], Some(&mut merge_opts), None)?;
527
528        let mut index = repo.index()?;
529        let conflicts_resolved = if index.has_conflicts() {
530            self.resolve_conflicts_by_recency(repo, &mut index)?
531        } else {
532            0
533        };
534
535        // Safety check: if any conflicts remain after auto-resolution,
536        // clean up the MERGE state and surface a clear error rather than
537        // letting write_tree() fail with an opaque message.
538        if index.has_conflicts() {
539            let _ = repo.cleanup_state();
540            return Err(MemoryError::Internal(
541                "unresolved conflicts remain after auto-resolution".into(),
542            ));
543        }
544
545        // Write the merged tree and create the merge commit.
546        index.write()?;
547        let tree_oid = index.write_tree()?;
548        let tree = repo.find_tree(tree_oid)?;
549        let sig = self.signature(repo)?;
550
551        let head_commit = repo.head()?.peel_to_commit()?;
552        let fetch_commit_obj = repo.find_commit(fetch_commit.id())?;
553
554        let new_commit_oid = repo.commit(
555            Some("HEAD"),
556            &sig,
557            &sig,
558            &format!("chore: merge origin/{}", branch),
559            &tree,
560            &[&head_commit, &fetch_commit_obj],
561        )?;
562
563        repo.cleanup_state()?;
564
565        let mut new_head = [0u8; 20];
566        new_head.copy_from_slice(new_commit_oid.as_bytes());
567
568        info!(
569            "pull: merge complete ({} conflicts auto-resolved)",
570            conflicts_resolved
571        );
572        Ok(PullResult::Merged {
573            conflicts_resolved,
574            old_head,
575            new_head,
576        })
577    }
578
579    /// Pull from `origin/<branch>` and merge into the current HEAD.
580    ///
581    /// Uses a recency-based auto-resolution strategy for conflicts: the version
582    /// with the more recent `updated_at` frontmatter timestamp wins. If
583    /// timestamps are equal or unparseable, the local version is kept.
584    pub async fn pull(
585        self: &Arc<Self>,
586        auth: &AuthProvider,
587        branch: &str,
588    ) -> Result<PullResult, MemoryError> {
589        // Resolve the token early so we can move it (Send) into the
590        // spawn_blocking closure. We defer failing until after we've confirmed
591        // that origin exists — local-only mode needs no token at all.
592        let token_result = auth.resolve_token();
593        let arc = Arc::clone(self);
594        let branch = branch.to_string();
595        let span = tracing::debug_span!("repo.pull", branch = %branch);
596
597        tokio::task::spawn_blocking(move || -> Result<PullResult, MemoryError> {
598            let _enter = span.entered();
599            let repo = arc
600                .inner
601                .lock()
602                .expect("lock poisoned — prior panic corrupted state");
603
604            // ---- 1. Find origin -------------------------------------------------
605            let mut remote = match repo.find_remote("origin") {
606                Ok(r) => r,
607                Err(e) if e.code() == ErrorCode::NotFound => {
608                    warn!("pull: no origin remote configured — skipping (local-only mode)");
609                    return Ok(PullResult::NoRemote);
610                }
611                Err(e) => return Err(MemoryError::Git(e)),
612            };
613
614            // Origin exists — we need the token now.
615            let token = token_result?;
616
617            // ---- 2. Fetch -------------------------------------------------------
618            let callbacks = build_auth_callbacks(token);
619            let mut fetch_opts = git2::FetchOptions::new();
620            fetch_opts.remote_callbacks(callbacks);
621            remote.fetch(&[&branch], Some(&mut fetch_opts), None)?;
622
623            // ---- 3. Resolve FETCH_HEAD ------------------------------------------
624            let fetch_head = match repo.find_reference("FETCH_HEAD") {
625                Ok(r) => r,
626                Err(e) if e.code() == ErrorCode::NotFound => {
627                    // Empty remote — nothing to merge.
628                    return Ok(PullResult::UpToDate);
629                }
630                Err(e)
631                    if e.class() == git2::ErrorClass::Reference
632                        && e.message().contains("corrupted") =>
633                {
634                    // Empty/corrupted FETCH_HEAD (e.g. remote has no commits yet).
635                    info!("pull: FETCH_HEAD is empty or corrupted — treating as empty remote");
636                    return Ok(PullResult::UpToDate);
637                }
638                Err(e) => return Err(MemoryError::Git(e)),
639            };
640            let fetch_commit = match repo.reference_to_annotated_commit(&fetch_head) {
641                Ok(c) => c,
642                Err(e) if e.class() == git2::ErrorClass::Reference => {
643                    // FETCH_HEAD exists but can't be resolved (empty remote).
644                    info!("pull: FETCH_HEAD not resolvable — treating as empty remote");
645                    return Ok(PullResult::UpToDate);
646                }
647                Err(e) => return Err(MemoryError::Git(e)),
648            };
649
650            // ---- 4. Merge analysis ----------------------------------------------
651            let (analysis, _preference) = repo.merge_analysis(&[&fetch_commit])?;
652
653            if analysis.is_up_to_date() {
654                info!("pull: already up to date");
655                return Ok(PullResult::UpToDate);
656            }
657
658            if analysis.is_fast_forward() {
659                return fast_forward(&repo, &fetch_commit, &branch);
660            }
661
662            arc.merge_with_remote(&repo, &fetch_commit, &branch)
663        })
664        .await
665        .map_err(|e| MemoryError::Join(e.to_string()))?
666    }
667
668    /// Diff two commits and return the memory files that changed.
669    ///
670    /// Only `.md` files under `global/` or `projects/` are considered.
671    /// Added/modified files go into `upserted`; deleted files go into `removed`.
672    /// Qualified names are returned without the `.md` suffix (e.g. `"global/foo"`).
673    ///
674    /// Must be called from within `spawn_blocking` since it uses git2.
675    pub fn diff_changed_memories(
676        &self,
677        old_oid: [u8; 20],
678        new_oid: [u8; 20],
679    ) -> Result<ChangedMemories, MemoryError> {
680        let repo = self
681            .inner
682            .lock()
683            .expect("lock poisoned — prior panic corrupted state");
684
685        let new_git_oid = git2::Oid::from_bytes(&new_oid).map_err(MemoryError::Git)?;
686        let new_tree = repo.find_commit(new_git_oid)?.tree()?;
687
688        // A zero OID indicates an unborn branch (no prior commits). In that case,
689        // diff against an empty tree so all files appear as additions.
690        let diff = if old_oid == [0u8; 20] {
691            repo.diff_tree_to_tree(None, Some(&new_tree), None)?
692        } else {
693            let old_git_oid = git2::Oid::from_bytes(&old_oid).map_err(MemoryError::Git)?;
694            let old_tree = repo.find_commit(old_git_oid)?.tree()?;
695            repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?
696        };
697
698        let mut changes = ChangedMemories::default();
699
700        diff.foreach(
701            &mut |delta, _progress| {
702                use git2::Delta;
703
704                let path = match delta.new_file().path().or_else(|| delta.old_file().path()) {
705                    Some(p) => p,
706                    None => return true,
707                };
708
709                let path_str = match path.to_str() {
710                    Some(s) => s,
711                    None => return true,
712                };
713
714                // Only care about .md files under global/ or projects/
715                if !path_str.ends_with(".md") {
716                    return true;
717                }
718                if !path_str.starts_with("global/") && !path_str.starts_with("projects/") {
719                    return true;
720                }
721
722                // Strip the .md suffix to get the qualified name.
723                let qualified = &path_str[..path_str.len() - 3];
724
725                match delta.status() {
726                    Delta::Added | Delta::Modified => {
727                        changes.upserted.push(qualified.to_string());
728                    }
729                    Delta::Renamed | Delta::Copied => {
730                        // For renames, the old path must be removed from the index
731                        // to avoid leaving a ghost vector behind.
732                        if matches!(delta.status(), Delta::Renamed) {
733                            if let Some(old_path) = delta.old_file().path().and_then(|p| p.to_str())
734                            {
735                                if old_path.ends_with(".md")
736                                    && (old_path.starts_with("global/")
737                                        || old_path.starts_with("projects/"))
738                                {
739                                    changes
740                                        .removed
741                                        .push(old_path[..old_path.len() - 3].to_string());
742                                }
743                            }
744                        }
745                        changes.upserted.push(qualified.to_string());
746                    }
747                    Delta::Deleted => {
748                        changes.removed.push(qualified.to_string());
749                    }
750                    _ => {}
751                }
752
753                true
754            },
755            None,
756            None,
757            None,
758        )
759        .map_err(MemoryError::Git)?;
760
761        Ok(changes)
762    }
763
764    // -----------------------------------------------------------------------
765    // Private helpers
766    // -----------------------------------------------------------------------
767
768    /// Resolve all index conflicts using a recency-based strategy.
769    ///
770    /// For each conflicted entry, the version with the more recent `updated_at`
771    /// frontmatter timestamp wins. Ties and parse failures fall back to "ours"
772    /// (local). Returns the number of files resolved.
773    fn resolve_conflicts_by_recency(
774        &self,
775        repo: &Repository,
776        index: &mut git2::Index,
777    ) -> Result<usize, MemoryError> {
778        // Collect conflict info first to avoid borrow issues with the index.
779        struct ConflictInfo {
780            path: PathBuf,
781            our_blob: Option<Vec<u8>>,
782            their_blob: Option<Vec<u8>>,
783        }
784
785        let mut conflicts_info: Vec<ConflictInfo> = Vec::new();
786
787        {
788            let conflicts = index.conflicts()?;
789            for conflict in conflicts {
790                let conflict = conflict?;
791
792                let path = conflict
793                    .our
794                    .as_ref()
795                    .or(conflict.their.as_ref())
796                    .and_then(|e| std::str::from_utf8(&e.path).ok())
797                    .map(|s| self.root.join(s));
798
799                let path = match path {
800                    Some(p) => p,
801                    None => continue,
802                };
803
804                let our_blob = conflict
805                    .our
806                    .as_ref()
807                    .and_then(|e| repo.find_blob(e.id).ok())
808                    .map(|b| b.content().to_vec());
809
810                let their_blob = conflict
811                    .their
812                    .as_ref()
813                    .and_then(|e| repo.find_blob(e.id).ok())
814                    .map(|b| b.content().to_vec());
815
816                conflicts_info.push(ConflictInfo {
817                    path,
818                    our_blob,
819                    their_blob,
820                });
821            }
822        }
823
824        let mut resolved = 0usize;
825
826        for info in conflicts_info {
827            let our_str = info
828                .our_blob
829                .as_deref()
830                .and_then(|b| std::str::from_utf8(b).ok())
831                .map(str::to_owned);
832            let their_str = info
833                .their_blob
834                .as_deref()
835                .and_then(|b| std::str::from_utf8(b).ok())
836                .map(str::to_owned);
837
838            let our_ts = our_str
839                .as_deref()
840                .and_then(|s| Memory::from_markdown(s).ok())
841                .map(|m| m.metadata.updated_at);
842            let their_ts = their_str
843                .as_deref()
844                .and_then(|s| Memory::from_markdown(s).ok())
845                .map(|m| m.metadata.updated_at);
846
847            // Pick the winning content as raw bytes.
848            let (chosen_bytes, label): (Vec<u8>, String) =
849                match (our_str.as_deref(), their_str.as_deref()) {
850                    (Some(ours), Some(theirs)) => match (our_ts, their_ts) {
851                        (Some(ot), Some(tt)) if tt > ot => (
852                            theirs.as_bytes().to_vec(),
853                            format!("theirs (updated_at: {})", tt),
854                        ),
855                        (Some(ot), _) => (
856                            ours.as_bytes().to_vec(),
857                            format!("ours (updated_at: {})", ot),
858                        ),
859                        _ => (
860                            ours.as_bytes().to_vec(),
861                            "ours (timestamp unparseable)".to_string(),
862                        ),
863                    },
864                    (Some(ours), None) => (
865                        ours.as_bytes().to_vec(),
866                        "ours (theirs missing)".to_string(),
867                    ),
868                    (None, Some(theirs)) => (
869                        theirs.as_bytes().to_vec(),
870                        "theirs (ours missing)".to_string(),
871                    ),
872                    (None, None) => {
873                        // Both UTF-8 conversions failed — fall back to raw blob bytes.
874                        match (info.our_blob.as_deref(), info.their_blob.as_deref()) {
875                            (Some(ours), _) => {
876                                (ours.to_vec(), "ours (binary/non-UTF-8)".to_string())
877                            }
878                            (_, Some(theirs)) => {
879                                (theirs.to_vec(), "theirs (binary/non-UTF-8)".to_string())
880                            }
881                            (None, None) => {
882                                // Both blobs truly absent — remove the entry from
883                                // the index so write_tree() succeeds.
884                                warn!(
885                                    "conflict at '{}': both sides missing — removing from index",
886                                    info.path.display()
887                                );
888                                let relative = info.path.strip_prefix(&self.root).map_err(|e| {
889                                    MemoryError::InvalidInput {
890                                        reason: format!(
891                                            "path strip error during conflict resolution: {}",
892                                            e
893                                        ),
894                                    }
895                                })?;
896                                index.conflict_remove(relative)?;
897                                resolved += 1;
898                                continue;
899                            }
900                        }
901                    }
902                };
903
904            warn!(
905                "conflict resolved: {} — kept {}",
906                info.path.display(),
907                label
908            );
909
910            // Write the chosen content to the working directory — going through
911            // assert_within_root and write_memory_file enforces path-traversal
912            // and symlink protections.
913            self.assert_within_root(&info.path)?;
914            if let Some(parent) = info.path.parent() {
915                std::fs::create_dir_all(parent)?;
916            }
917            self.write_memory_file(&info.path, &chosen_bytes)?;
918
919            // Stage the resolution.
920            let relative =
921                info.path
922                    .strip_prefix(&self.root)
923                    .map_err(|e| MemoryError::InvalidInput {
924                        reason: format!("path strip error during conflict resolution: {}", e),
925                    })?;
926            index.add_path(relative)?;
927
928            resolved += 1;
929        }
930
931        Ok(resolved)
932    }
933
934    fn signature<'r>(&self, repo: &'r Repository) -> Result<Signature<'r>, MemoryError> {
935        // Try repo config first, then fall back to a default.
936        let sig = repo
937            .signature()
938            .or_else(|_| Signature::now("memory-mcp", "memory-mcp@local"))?;
939        Ok(sig)
940    }
941
942    /// Stage `file_path` and create a commit.
943    fn git_add_and_commit(
944        &self,
945        repo: &Repository,
946        file_path: &Path,
947        message: &str,
948    ) -> Result<(), MemoryError> {
949        let relative =
950            file_path
951                .strip_prefix(&self.root)
952                .map_err(|e| MemoryError::InvalidInput {
953                    reason: format!("path strip error: {}", e),
954                })?;
955
956        let mut index = repo.index()?;
957        index.add_path(relative)?;
958        index.write()?;
959
960        let tree_oid = index.write_tree()?;
961        let tree = repo.find_tree(tree_oid)?;
962        let sig = self.signature(repo)?;
963
964        match repo.head() {
965            Ok(head) => {
966                let parent_commit = head.peel_to_commit()?;
967                repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])?;
968            }
969            Err(e) if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => {
970                // Initial commit — no parent.
971                repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
972            }
973            Err(e) => return Err(MemoryError::Git(e)),
974        }
975
976        Ok(())
977    }
978
979    /// Assert that `path` remains under `self.root` after canonicalisation,
980    /// preventing path-traversal attacks.
981    fn assert_within_root(&self, path: &Path) -> Result<(), MemoryError> {
982        // The file may not exist yet, so we canonicalize its parent and
983        // then re-append the filename.
984        let parent = path.parent().unwrap_or(path);
985        let filename = path.file_name().ok_or_else(|| MemoryError::InvalidInput {
986            reason: "path has no filename component".to_string(),
987        })?;
988
989        // If the parent doesn't exist yet we check as many ancestors as
990        // necessary until we find one that does.
991        let canon_parent = {
992            let mut p = parent.to_path_buf();
993            let mut suffixes: Vec<std::ffi::OsString> = Vec::new();
994            loop {
995                match p.canonicalize() {
996                    Ok(c) => {
997                        let mut full = c;
998                        for s in suffixes.into_iter().rev() {
999                            full.push(s);
1000                        }
1001                        break full;
1002                    }
1003                    Err(_) => {
1004                        if let Some(name) = p.file_name() {
1005                            suffixes.push(name.to_os_string());
1006                        }
1007                        match p.parent() {
1008                            Some(par) => p = par.to_path_buf(),
1009                            None => {
1010                                return Err(MemoryError::InvalidInput {
1011                                    reason: "cannot resolve any ancestor of path".into(),
1012                                });
1013                            }
1014                        }
1015                    }
1016                }
1017            }
1018        };
1019
1020        let resolved = canon_parent.join(filename);
1021
1022        let canon_root = self
1023            .root
1024            .canonicalize()
1025            .map_err(|e| MemoryError::InvalidInput {
1026                reason: format!("cannot canonicalize repo root: {}", e),
1027            })?;
1028
1029        if !resolved.starts_with(&canon_root) {
1030            return Err(MemoryError::InvalidInput {
1031                reason: format!(
1032                    "path '{}' escapes repository root '{}'",
1033                    resolved.display(),
1034                    canon_root.display()
1035                ),
1036            });
1037        }
1038
1039        // Reject any symlinks within the repo root. We check each existing
1040        // component of `resolved` that lies inside `canon_root` — if any is a
1041        // symlink the request is rejected, because canonicalization already
1042        // followed it and the prefix check above would silently pass.
1043        {
1044            let mut probe = canon_root.clone();
1045            // Collect the path components that are beneath the root.
1046            let relative =
1047                resolved
1048                    .strip_prefix(&canon_root)
1049                    .map_err(|e| MemoryError::InvalidInput {
1050                        reason: format!("path strip error: {}", e),
1051                    })?;
1052            for component in relative.components() {
1053                probe.push(component);
1054                // Only check components that currently exist on disk.
1055                if (probe.exists() || probe.symlink_metadata().is_ok())
1056                    && probe
1057                        .symlink_metadata()
1058                        .map(|m| m.file_type().is_symlink())
1059                        .unwrap_or(false)
1060                {
1061                    return Err(MemoryError::InvalidInput {
1062                        reason: format!(
1063                            "path component '{}' is a symlink, which is not allowed",
1064                            probe.display()
1065                        ),
1066                    });
1067                }
1068            }
1069        }
1070
1071        Ok(())
1072    }
1073
1074    /// Atomically write `data` to `path` via temp-file + rename.
1075    ///
1076    /// Defense-in-depth against symlink attacks (layered):
1077    /// 1. `validate_path` rejects symlinks in all path components.
1078    /// 2. An `lstat` check here catches symlinks created between
1079    ///    validation and write (narrows the TOCTOU window).
1080    /// 3. On Unix, an `O_NOFOLLOW` probe on the final path detects
1081    ///    symlinks planted in the window between lstat and
1082    ///    `atomic_write`. The temp file itself is separately guarded
1083    ///    by `O_NOFOLLOW` inside `write_tmp`.
1084    fn write_memory_file(&self, path: &Path, data: &[u8]) -> Result<(), MemoryError> {
1085        // Layer 2: lstat — reject if the target is currently a symlink.
1086        if path
1087            .symlink_metadata()
1088            .map(|m| m.file_type().is_symlink())
1089            .unwrap_or(false)
1090        {
1091            return Err(MemoryError::InvalidInput {
1092                reason: format!("refusing to write through symlink: {}", path.display()),
1093            });
1094        }
1095
1096        // Layer 3 (Unix): O_NOFOLLOW probe — kernel-level symlink rejection.
1097        // NotFound is fine (file doesn't exist yet); any other error (ELOOP
1098        // from a symlink, permission denied, etc.) is rejected.
1099        #[cfg(unix)]
1100        {
1101            use std::os::unix::fs::OpenOptionsExt as _;
1102            if let Err(e) = std::fs::OpenOptions::new()
1103                .read(true)
1104                .custom_flags(libc::O_NOFOLLOW)
1105                .open(path)
1106            {
1107                // NotFound is fine — the file doesn't exist yet.
1108                if e.kind() != std::io::ErrorKind::NotFound {
1109                    return Err(MemoryError::InvalidInput {
1110                        reason: format!("O_NOFOLLOW check failed for {}: {e}", path.display()),
1111                    });
1112                }
1113            }
1114        }
1115
1116        crate::fs_util::atomic_write(path, data)?;
1117        Ok(())
1118    }
1119
1120    /// Open `path` for reading using `O_NOFOLLOW` on Unix, then return its
1121    /// contents as a `String`.
1122    ///
1123    /// On non-Unix platforms falls back to `std::fs::read_to_string`.
1124    fn read_memory_file(&self, path: &Path) -> Result<String, MemoryError> {
1125        #[cfg(unix)]
1126        {
1127            use std::io::Read as _;
1128            use std::os::unix::fs::OpenOptionsExt as _;
1129            let mut f = std::fs::OpenOptions::new()
1130                .read(true)
1131                .custom_flags(libc::O_NOFOLLOW)
1132                .open(path)?;
1133            let mut buf = String::new();
1134            f.read_to_string(&mut buf)?;
1135            Ok(buf)
1136        }
1137        #[cfg(not(unix))]
1138        {
1139            Ok(std::fs::read_to_string(path)?)
1140        }
1141    }
1142}
1143
1144// ---------------------------------------------------------------------------
1145// Tests
1146// ---------------------------------------------------------------------------
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151    use crate::auth::AuthProvider;
1152    use crate::types::{Memory, MemoryMetadata, PullResult, Scope};
1153    use std::sync::Arc;
1154
1155    fn test_auth() -> AuthProvider {
1156        AuthProvider::with_token("test-token-unused-for-file-remotes")
1157    }
1158
1159    fn make_memory(name: &str, content: &str, updated_at_secs: i64) -> Memory {
1160        let meta = MemoryMetadata {
1161            tags: vec![],
1162            scope: Scope::Global,
1163            created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
1164            updated_at: chrono::DateTime::from_timestamp(updated_at_secs, 0).unwrap(),
1165            source: None,
1166        };
1167        Memory::new(name.to_string(), content.to_string(), meta)
1168    }
1169
1170    fn setup_bare_remote() -> (tempfile::TempDir, String) {
1171        let dir = tempfile::tempdir().expect("failed to create temp dir");
1172        git2::Repository::init_bare(dir.path()).expect("failed to init bare repo");
1173        let url = format!("file://{}", dir.path().display());
1174        (dir, url)
1175    }
1176
1177    fn open_repo(dir: &tempfile::TempDir, remote_url: Option<&str>) -> Arc<MemoryRepo> {
1178        Arc::new(MemoryRepo::init_or_open(dir.path(), remote_url).expect("failed to init repo"))
1179    }
1180
1181    // -- redact_url tests --------------------------------------------------
1182
1183    #[test]
1184    fn redact_url_strips_userinfo() {
1185        assert_eq!(
1186            redact_url("https://user:ghp_token123@github.com/org/repo.git"),
1187            "https://[REDACTED]@github.com/org/repo.git"
1188        );
1189    }
1190
1191    #[test]
1192    fn redact_url_no_at_passthrough() {
1193        let url = "https://github.com/org/repo.git";
1194        assert_eq!(redact_url(url), url);
1195    }
1196
1197    #[test]
1198    fn redact_url_file_protocol_passthrough() {
1199        let url = "file:///tmp/bare.git";
1200        assert_eq!(redact_url(url), url);
1201    }
1202
1203    // -- assert_within_root tests ------------------------------------------
1204
1205    #[test]
1206    fn assert_within_root_accepts_valid_path() {
1207        let dir = tempfile::tempdir().unwrap();
1208        let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1209        let valid = dir.path().join("global").join("my-memory.md");
1210        // Create the parent so canonicalization works.
1211        std::fs::create_dir_all(valid.parent().unwrap()).unwrap();
1212        assert!(repo.assert_within_root(&valid).is_ok());
1213    }
1214
1215    #[test]
1216    fn assert_within_root_rejects_escape() {
1217        let dir = tempfile::tempdir().unwrap();
1218        let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1219        // Build a path that escapes the repo root. We need enough ".." to go
1220        // above the tmpdir, then descend into /tmp/evil.
1221        let _evil = dir
1222            .path()
1223            .join("..")
1224            .join("..")
1225            .join("..")
1226            .join("tmp")
1227            .join("evil.md");
1228        // Only assert if the path actually resolves outside root.
1229        // (If the temp dir is at root level, this might not escape — use an
1230        // explicit absolute path instead.)
1231        let outside = std::path::PathBuf::from("/tmp/definitely-outside");
1232        assert!(repo.assert_within_root(&outside).is_err());
1233    }
1234
1235    // -- local-only mode tests (no origin) ---------------------------------
1236
1237    #[tokio::test]
1238    async fn push_local_only_returns_ok() {
1239        let dir = tempfile::tempdir().unwrap();
1240        let repo = open_repo(&dir, None);
1241        let auth = test_auth();
1242        // No origin configured — push should silently succeed.
1243        let result = repo.push(&auth, "main").await;
1244        assert!(result.is_ok());
1245    }
1246
1247    #[tokio::test]
1248    async fn pull_local_only_returns_no_remote() {
1249        let dir = tempfile::tempdir().unwrap();
1250        let repo = open_repo(&dir, None);
1251        let auth = test_auth();
1252        let result = repo.pull(&auth, "main").await.unwrap();
1253        assert!(matches!(result, PullResult::NoRemote));
1254    }
1255
1256    // -- push/pull with local bare remote ----------------------------------
1257
1258    #[tokio::test]
1259    async fn push_to_bare_remote() {
1260        let (_remote_dir, remote_url) = setup_bare_remote();
1261        let local_dir = tempfile::tempdir().unwrap();
1262        let repo = open_repo(&local_dir, Some(&remote_url));
1263        let auth = test_auth();
1264
1265        // Save a memory so there's something to push.
1266        let mem = make_memory("test-push", "push content", 1_700_000_000);
1267        repo.save_memory(&mem).await.unwrap();
1268
1269        // Push should succeed.
1270        repo.push(&auth, "main").await.unwrap();
1271
1272        // Verify the bare repo received the commit.
1273        let bare = git2::Repository::open_bare(_remote_dir.path()).unwrap();
1274        let head = bare.find_reference("refs/heads/main").unwrap();
1275        let commit = head.peel_to_commit().unwrap();
1276        assert!(commit.message().unwrap().contains("test-push"));
1277    }
1278
1279    #[tokio::test]
1280    async fn pull_from_empty_bare_remote_returns_up_to_date() {
1281        let (_remote_dir, remote_url) = setup_bare_remote();
1282        let local_dir = tempfile::tempdir().unwrap();
1283        let repo = open_repo(&local_dir, Some(&remote_url));
1284        let auth = test_auth();
1285
1286        // First save something locally so we have an initial commit (HEAD exists).
1287        let mem = make_memory("seed", "seed content", 1_700_000_000);
1288        repo.save_memory(&mem).await.unwrap();
1289
1290        // Pull from empty remote — should be up-to-date (not an error).
1291        let result = repo.pull(&auth, "main").await.unwrap();
1292        assert!(matches!(result, PullResult::UpToDate));
1293    }
1294
1295    #[tokio::test]
1296    async fn pull_fast_forward() {
1297        let (_remote_dir, remote_url) = setup_bare_remote();
1298        let auth = test_auth();
1299
1300        // Repo A: save and push
1301        let dir_a = tempfile::tempdir().unwrap();
1302        let repo_a = open_repo(&dir_a, Some(&remote_url));
1303        let mem = make_memory("from-a", "content from A", 1_700_000_000);
1304        repo_a.save_memory(&mem).await.unwrap();
1305        repo_a.push(&auth, "main").await.unwrap();
1306
1307        // Repo B: init with same remote, then pull
1308        let dir_b = tempfile::tempdir().unwrap();
1309        let repo_b = open_repo(&dir_b, Some(&remote_url));
1310        // Repo B needs an initial commit for HEAD to exist.
1311        let seed = make_memory("seed-b", "seed", 1_700_000_000);
1312        repo_b.save_memory(&seed).await.unwrap();
1313
1314        let result = repo_b.pull(&auth, "main").await.unwrap();
1315        assert!(
1316            matches!(
1317                result,
1318                PullResult::FastForward { .. } | PullResult::Merged { .. }
1319            ),
1320            "expected fast-forward or merge, got {:?}",
1321            result
1322        );
1323
1324        // Verify the memory file from A exists in B's working directory.
1325        let file = dir_b.path().join("global").join("from-a.md");
1326        assert!(file.exists(), "from-a.md should exist in repo B after pull");
1327    }
1328
1329    #[tokio::test]
1330    async fn pull_up_to_date_after_push() {
1331        let (_remote_dir, remote_url) = setup_bare_remote();
1332        let local_dir = tempfile::tempdir().unwrap();
1333        let repo = open_repo(&local_dir, Some(&remote_url));
1334        let auth = test_auth();
1335
1336        let mem = make_memory("synced", "synced content", 1_700_000_000);
1337        repo.save_memory(&mem).await.unwrap();
1338        repo.push(&auth, "main").await.unwrap();
1339
1340        // Pull immediately after push — should be up to date.
1341        let result = repo.pull(&auth, "main").await.unwrap();
1342        assert!(matches!(result, PullResult::UpToDate));
1343    }
1344
1345    // -- conflict resolution tests -----------------------------------------
1346
1347    #[tokio::test]
1348    async fn pull_merge_conflict_theirs_newer_wins() {
1349        let (_remote_dir, remote_url) = setup_bare_remote();
1350        let auth = test_auth();
1351
1352        // Repo A: save "shared" with T1, push
1353        let dir_a = tempfile::tempdir().unwrap();
1354        let repo_a = open_repo(&dir_a, Some(&remote_url));
1355        let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1356        repo_a.save_memory(&mem_a1).await.unwrap();
1357        repo_a.push(&auth, "main").await.unwrap();
1358
1359        // Repo B: pull to get A's commit, then modify "shared" with T3 (newer), push
1360        let dir_b = tempfile::tempdir().unwrap();
1361        let repo_b = open_repo(&dir_b, Some(&remote_url));
1362        let seed = make_memory("seed-b", "seed", 1_700_000_000);
1363        repo_b.save_memory(&seed).await.unwrap();
1364        repo_b.pull(&auth, "main").await.unwrap();
1365
1366        let mem_b = make_memory("shared", "version from B (newer)", 1_700_000_300);
1367        repo_b.save_memory(&mem_b).await.unwrap();
1368        repo_b.push(&auth, "main").await.unwrap();
1369
1370        // Repo A: modify "shared" with T2 (older than T3), then pull — conflict
1371        let mem_a2 = make_memory("shared", "version from A (older)", 1_700_000_200);
1372        repo_a.save_memory(&mem_a2).await.unwrap();
1373        let result = repo_a.pull(&auth, "main").await.unwrap();
1374
1375        assert!(
1376            matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1377            "expected merge with conflicts resolved, got {:?}",
1378            result
1379        );
1380
1381        // Verify theirs (B's version, T3=300) won.
1382        let file = dir_a.path().join("global").join("shared.md");
1383        let content = std::fs::read_to_string(&file).unwrap();
1384        assert!(
1385            content.contains("version from B (newer)"),
1386            "expected B's version to win (newer timestamp), got: {}",
1387            content
1388        );
1389    }
1390
1391    #[tokio::test]
1392    async fn pull_merge_conflict_ours_newer_wins() {
1393        let (_remote_dir, remote_url) = setup_bare_remote();
1394        let auth = test_auth();
1395
1396        // Repo A: save "shared" with T1, push
1397        let dir_a = tempfile::tempdir().unwrap();
1398        let repo_a = open_repo(&dir_a, Some(&remote_url));
1399        let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1400        repo_a.save_memory(&mem_a1).await.unwrap();
1401        repo_a.push(&auth, "main").await.unwrap();
1402
1403        // Repo B: pull, modify with T2 (older), push
1404        let dir_b = tempfile::tempdir().unwrap();
1405        let repo_b = open_repo(&dir_b, Some(&remote_url));
1406        let seed = make_memory("seed-b", "seed", 1_700_000_000);
1407        repo_b.save_memory(&seed).await.unwrap();
1408        repo_b.pull(&auth, "main").await.unwrap();
1409
1410        let mem_b = make_memory("shared", "version from B (older)", 1_700_000_200);
1411        repo_b.save_memory(&mem_b).await.unwrap();
1412        repo_b.push(&auth, "main").await.unwrap();
1413
1414        // Repo A: modify with T3 (newer), pull — conflict
1415        let mem_a2 = make_memory("shared", "version from A (newer)", 1_700_000_300);
1416        repo_a.save_memory(&mem_a2).await.unwrap();
1417        let result = repo_a.pull(&auth, "main").await.unwrap();
1418
1419        assert!(
1420            matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1421            "expected merge with conflicts resolved, got {:?}",
1422            result
1423        );
1424
1425        // Verify ours (A's version, T3=300) won.
1426        let file = dir_a.path().join("global").join("shared.md");
1427        let content = std::fs::read_to_string(&file).unwrap();
1428        assert!(
1429            content.contains("version from A (newer)"),
1430            "expected A's version to win (newer timestamp), got: {}",
1431            content
1432        );
1433    }
1434
1435    #[tokio::test]
1436    async fn pull_merge_no_conflict_different_files() {
1437        let (_remote_dir, remote_url) = setup_bare_remote();
1438        let auth = test_auth();
1439
1440        // Repo A: save "mem-a", push
1441        let dir_a = tempfile::tempdir().unwrap();
1442        let repo_a = open_repo(&dir_a, Some(&remote_url));
1443        let mem_a = make_memory("mem-a", "from A", 1_700_000_100);
1444        repo_a.save_memory(&mem_a).await.unwrap();
1445        repo_a.push(&auth, "main").await.unwrap();
1446
1447        // Repo B: pull, save "mem-b", push
1448        let dir_b = tempfile::tempdir().unwrap();
1449        let repo_b = open_repo(&dir_b, Some(&remote_url));
1450        let seed = make_memory("seed-b", "seed", 1_700_000_000);
1451        repo_b.save_memory(&seed).await.unwrap();
1452        repo_b.pull(&auth, "main").await.unwrap();
1453        let mem_b = make_memory("mem-b", "from B", 1_700_000_200);
1454        repo_b.save_memory(&mem_b).await.unwrap();
1455        repo_b.push(&auth, "main").await.unwrap();
1456
1457        // Repo A: save "mem-a2" (different file), pull — should merge cleanly
1458        let mem_a2 = make_memory("mem-a2", "also from A", 1_700_000_300);
1459        repo_a.save_memory(&mem_a2).await.unwrap();
1460        let result = repo_a.pull(&auth, "main").await.unwrap();
1461
1462        assert!(
1463            matches!(
1464                result,
1465                PullResult::Merged {
1466                    conflicts_resolved: 0,
1467                    ..
1468                }
1469            ),
1470            "expected clean merge, got {:?}",
1471            result
1472        );
1473
1474        // Both repos should have all files.
1475        assert!(dir_a.path().join("global").join("mem-b.md").exists());
1476    }
1477
1478    // -- diff_changed_memories tests ----------------------------------------
1479
1480    /// Helper: commit a file with given content and return the new HEAD OID bytes.
1481    fn commit_file(repo: &Arc<MemoryRepo>, rel_path: &str, content: &str) -> [u8; 20] {
1482        let inner = repo.inner.lock().expect("lock poisoned");
1483        let full_path = repo.root.join(rel_path);
1484        if let Some(parent) = full_path.parent() {
1485            std::fs::create_dir_all(parent).unwrap();
1486        }
1487        std::fs::write(&full_path, content).unwrap();
1488
1489        let mut index = inner.index().unwrap();
1490        index.add_path(std::path::Path::new(rel_path)).unwrap();
1491        index.write().unwrap();
1492        let tree_oid = index.write_tree().unwrap();
1493        let tree = inner.find_tree(tree_oid).unwrap();
1494        let sig = git2::Signature::now("test", "test@test.com").unwrap();
1495
1496        let oid = match inner.head() {
1497            Ok(head) => {
1498                let parent = head.peel_to_commit().unwrap();
1499                inner
1500                    .commit(Some("HEAD"), &sig, &sig, "test commit", &tree, &[&parent])
1501                    .unwrap()
1502            }
1503            Err(_) => inner
1504                .commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
1505                .unwrap(),
1506        };
1507
1508        let mut buf = [0u8; 20];
1509        buf.copy_from_slice(oid.as_bytes());
1510        buf
1511    }
1512
1513    #[test]
1514    fn diff_changed_memories_detects_added_global() {
1515        let dir = tempfile::tempdir().unwrap();
1516        let repo = open_repo(&dir, None);
1517
1518        // Capture the initial HEAD (init commit).
1519        let old_oid = {
1520            let inner = repo.inner.lock().unwrap();
1521            let head = inner.head().unwrap();
1522            let mut buf = [0u8; 20];
1523            buf.copy_from_slice(head.peel_to_commit().unwrap().id().as_bytes());
1524            buf
1525        };
1526
1527        let new_oid = commit_file(&repo, "global/my-note.md", "# content");
1528
1529        let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1530        assert_eq!(changes.upserted, vec!["global/my-note".to_string()]);
1531        assert!(changes.removed.is_empty());
1532    }
1533
1534    #[test]
1535    fn diff_changed_memories_detects_deleted() {
1536        let dir = tempfile::tempdir().unwrap();
1537        let repo = open_repo(&dir, None);
1538
1539        let first_oid = commit_file(&repo, "global/to-delete.md", "hello");
1540        let second_oid = {
1541            let inner = repo.inner.lock().unwrap();
1542            let full_path = dir.path().join("global/to-delete.md");
1543            std::fs::remove_file(&full_path).unwrap();
1544            let mut index = inner.index().unwrap();
1545            index
1546                .remove_path(std::path::Path::new("global/to-delete.md"))
1547                .unwrap();
1548            index.write().unwrap();
1549            let tree_oid = index.write_tree().unwrap();
1550            let tree = inner.find_tree(tree_oid).unwrap();
1551            let sig = git2::Signature::now("test", "test@test.com").unwrap();
1552            let parent = inner.head().unwrap().peel_to_commit().unwrap();
1553            let oid = inner
1554                .commit(Some("HEAD"), &sig, &sig, "delete file", &tree, &[&parent])
1555                .unwrap();
1556            let mut buf = [0u8; 20];
1557            buf.copy_from_slice(oid.as_bytes());
1558            buf
1559        };
1560
1561        let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1562        assert!(changes.upserted.is_empty());
1563        assert_eq!(changes.removed, vec!["global/to-delete".to_string()]);
1564    }
1565
1566    #[test]
1567    fn diff_changed_memories_ignores_non_md_files() {
1568        let dir = tempfile::tempdir().unwrap();
1569        let repo = open_repo(&dir, None);
1570
1571        let old_oid = {
1572            let inner = repo.inner.lock().unwrap();
1573            let mut buf = [0u8; 20];
1574            buf.copy_from_slice(
1575                inner
1576                    .head()
1577                    .unwrap()
1578                    .peel_to_commit()
1579                    .unwrap()
1580                    .id()
1581                    .as_bytes(),
1582            );
1583            buf
1584        };
1585
1586        // Add a non-.md file under global/ and a .md file outside tracked dirs.
1587        let _ = commit_file(&repo, "global/config.json", "{}");
1588        let new_oid = commit_file(&repo, "other/note.md", "# ignored");
1589
1590        let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1591        assert!(
1592            changes.upserted.is_empty(),
1593            "should ignore non-.md and out-of-scope files"
1594        );
1595        assert!(changes.removed.is_empty());
1596    }
1597
1598    #[test]
1599    fn diff_changed_memories_detects_modified() {
1600        let dir = tempfile::tempdir().unwrap();
1601        let repo = open_repo(&dir, None);
1602
1603        let first_oid = commit_file(&repo, "projects/myproject/note.md", "version 1");
1604        let second_oid = commit_file(&repo, "projects/myproject/note.md", "version 2");
1605
1606        let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1607        assert_eq!(
1608            changes.upserted,
1609            vec!["projects/myproject/note".to_string()]
1610        );
1611        assert!(changes.removed.is_empty());
1612    }
1613
1614    /// A zero OID (unborn branch sentinel) must not crash; all files in the
1615    /// new commit should appear as additions.
1616    #[test]
1617    fn diff_changed_memories_zero_oid_treats_all_as_added() {
1618        let dir = tempfile::tempdir().unwrap();
1619        let repo = open_repo(&dir, None);
1620
1621        // Commit a global memory file — this is the "new" state.
1622        let new_oid = commit_file(&repo, "global/first-memory.md", "# Hello");
1623
1624        // old_oid = [0u8; 20] simulates an unborn branch (no prior commit).
1625        let old_oid = [0u8; 20];
1626
1627        let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1628        assert_eq!(
1629            changes.upserted,
1630            vec!["global/first-memory".to_string()],
1631            "zero OID: all new-tree files should be additions"
1632        );
1633        assert!(changes.removed.is_empty(), "zero OID: no removals expected");
1634    }
1635}