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