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::{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/// Spawn a blocking task with the caller's `tracing::Dispatch` propagated.
109///
110/// Blocking-pool threads do not inherit the caller's thread-local dispatch,
111/// so `Span::current()`, events, and `Span::record()` are silently dropped
112/// unless the dispatch is explicitly re-installed. This wrapper captures the
113/// current dispatch and sets it as the thread-local default inside the closure.
114pub(crate) fn traced_spawn_blocking<F, T>(f: F) -> tokio::task::JoinHandle<T>
115where
116    F: FnOnce() -> T + Send + 'static,
117    T: Send + 'static,
118{
119    let dispatch = tracing::dispatcher::get_default(|d| d.clone());
120    #[allow(clippy::disallowed_methods)]
121    tokio::task::spawn_blocking(move || {
122        let _guard = tracing::dispatcher::set_default(&dispatch);
123        f()
124    })
125}
126
127/// Git-backed repository for persisting and syncing memory files.
128pub struct MemoryRepo {
129    inner: Mutex<Repository>,
130    root: PathBuf,
131    reporter: SubsystemReporter,
132    sync_reporter: SubsystemReporter,
133}
134
135// SAFETY: Repository holds raw pointers but is documented as safe to send
136// across threads when not used concurrently. We guarantee exclusive access via
137// the Mutex, so MemoryRepo is Send + Sync.
138unsafe impl Send for MemoryRepo {}
139unsafe impl Sync for MemoryRepo {}
140
141impl MemoryRepo {
142    /// Open an existing git repo at `path`, or initialise a new one.
143    ///
144    /// If `remote_url` is provided, ensures an `origin` remote exists pointing
145    /// at that URL (creating or updating it as necessary).
146    ///
147    /// `reporter` receives `report_ok`/`report_err` after local git operations
148    /// so `/readyz` reflects the repository's operational state passively.
149    pub fn init_or_open(path: &Path, remote_url: Option<&str>) -> Result<Self, MemoryError> {
150        Self::init_or_open_with_reporter(
151            path,
152            remote_url,
153            SubsystemReporter::new(),
154            SubsystemReporter::new(),
155        )
156    }
157
158    /// Open or initialise a git repo with specific health reporters.
159    ///
160    /// `reporter` tracks local git operations; `sync_reporter` tracks push/pull.
161    pub fn init_or_open_with_reporter(
162        path: &Path,
163        remote_url: Option<&str>,
164        reporter: SubsystemReporter,
165        sync_reporter: SubsystemReporter,
166    ) -> Result<Self, MemoryError> {
167        let _span = tracing::info_span!("repo.init").entered();
168
169        let repo = if path.join(".git").exists() {
170            Repository::open(path)?
171        } else {
172            let mut opts = git2::RepositoryInitOptions::new();
173            opts.initial_head("main");
174            let repo = Repository::init_opts(path, &opts)?;
175            // Write a .gitignore so the vector index is never committed.
176            let gitignore = path.join(".gitignore");
177            if !gitignore.exists() {
178                std::fs::write(&gitignore, ".memory-mcp-index/\n")?;
179            }
180            // Commit .gitignore as the initial commit.
181            {
182                let mut index = repo.index()?;
183                index.add_path(Path::new(".gitignore"))?;
184                index.write()?;
185                let tree_oid = index.write_tree()?;
186                let tree = repo.find_tree(tree_oid)?;
187                let sig = Signature::now("memory-mcp", "memory-mcp@local")?;
188                repo.commit(
189                    Some("HEAD"),
190                    &sig,
191                    &sig,
192                    "chore: init repository",
193                    &tree,
194                    &[],
195                )?;
196            }
197            repo
198        };
199
200        // Set up or update the origin remote if a URL was provided.
201        if let Some(url) = remote_url {
202            match repo.find_remote("origin") {
203                Ok(existing) => {
204                    // Update the URL only when it differs from the current one.
205                    let current_url = existing.url().unwrap_or("");
206                    if current_url != url {
207                        repo.remote_set_url("origin", url)?;
208                        info!("updated origin remote URL to {}", redact_url(url));
209                    }
210                }
211                Err(e) if e.code() == ErrorCode::NotFound => {
212                    repo.remote("origin", url)?;
213                    info!("created origin remote pointing at {}", redact_url(url));
214                }
215                Err(e) => return Err(MemoryError::Git(e)),
216            }
217        }
218
219        Ok(Self {
220            inner: Mutex::new(repo),
221            root: path.to_path_buf(),
222            reporter,
223            sync_reporter,
224        })
225    }
226
227    /// Return the current HEAD commit SHA as a hex string, or `None` if the
228    /// branch is unborn (no commits yet).
229    pub async fn head_sha(self: &Arc<Self>) -> Option<String> {
230        let me = Arc::clone(self);
231        traced_spawn_blocking(move || {
232            let repo = me.inner.lock().expect("repo mutex poisoned");
233            let oid_bytes = capture_head_oid(&repo).ok()?;
234            if oid_bytes == [0u8; 20] {
235                return None;
236            }
237            git2::Oid::from_bytes(&oid_bytes)
238                .ok()
239                .map(|oid| oid.to_string())
240        })
241        .await
242        .ok()
243        .flatten()
244    }
245
246    /// Absolute path for a memory's markdown file inside the repo.
247    fn memory_path(&self, name: &str, scope: &Scope) -> PathBuf {
248        self.root
249            .join(scope.dir_prefix())
250            .join(format!("{}.md", name))
251    }
252
253    /// Write the memory file to disk, then `git add` + `git commit`.
254    ///
255    /// All blocking work (mutex lock + fs ops + git2 ops) is performed inside
256    /// `tokio::task::spawn_blocking` so the async executor is not stalled.
257    pub async fn save_memory(self: &Arc<Self>, memory: &Memory) -> Result<(), MemoryError> {
258        validate_name(&memory.name)?;
259        if let Scope::Project(ref project_name) = memory.metadata.scope {
260            validate_name(project_name)?;
261        }
262
263        let file_path = self.memory_path(&memory.name, &memory.metadata.scope);
264        self.assert_within_root(&file_path)?;
265
266        let arc = Arc::clone(self);
267        let memory = memory.clone();
268        let name = memory.name.clone();
269
270        let span = tracing::info_span!("repo.save", name = %name, oid = tracing::field::Empty);
271
272        let result = traced_spawn_blocking(move || -> Result<(), MemoryError> {
273            let _enter = span.entered();
274            let repo = arc
275                .inner
276                .lock()
277                .expect("lock poisoned — prior panic corrupted state");
278
279            // Ensure the parent directory exists.
280            if let Some(parent) = file_path.parent() {
281                std::fs::create_dir_all(parent)?;
282            }
283
284            let markdown = memory.to_markdown()?;
285            arc.write_memory_file(&file_path, markdown.as_bytes())?;
286
287            let oid = arc.git_add_and_commit(
288                &repo,
289                &file_path,
290                &format!("chore: save memory '{}'", memory.name),
291            )?;
292
293            tracing::Span::current().record("oid", oid.to_string().as_str());
294            info!(oid = %oid, "memory saved to repo");
295
296            Ok(())
297        })
298        .await
299        .map_err(|e| MemoryError::Join(e.to_string()))?;
300
301        match &result {
302            Ok(_) => self.reporter.report_ok(),
303            Err(_) => self.reporter.report_err("save_memory failed"),
304        }
305        result
306    }
307
308    /// Remove a memory's file and commit the deletion.
309    pub async fn delete_memory(
310        self: &Arc<Self>,
311        name: &str,
312        scope: &Scope,
313    ) -> Result<(), MemoryError> {
314        validate_name(name)?;
315        if let Scope::Project(ref project_name) = *scope {
316            validate_name(project_name)?;
317        }
318
319        let file_path = self.memory_path(name, scope);
320        self.assert_within_root(&file_path)?;
321
322        let arc = Arc::clone(self);
323        let name = name.to_string();
324        let file_path_clone = file_path.clone();
325        let span = tracing::info_span!("repo.delete", name = %name, oid = tracing::field::Empty);
326        let result = traced_spawn_blocking(move || -> Result<(), MemoryError> {
327            let _enter = span.entered();
328            let repo = arc
329                .inner
330                .lock()
331                .expect("lock poisoned — prior panic corrupted state");
332
333            // Check existence and symlink status atomically via symlink_metadata.
334            match std::fs::symlink_metadata(&file_path_clone) {
335                Err(_) => return Err(MemoryError::NotFound { name: name.clone() }),
336                Ok(m) if m.file_type().is_symlink() => {
337                    return Err(MemoryError::InvalidInput {
338                        reason: format!(
339                            "path '{}' is a symlink, which is not permitted",
340                            file_path_clone.display()
341                        ),
342                    });
343                }
344                Ok(_) => {}
345            }
346
347            std::fs::remove_file(&file_path_clone)?;
348            // git rm equivalent: stage the removal
349            let relative =
350                file_path_clone
351                    .strip_prefix(&arc.root)
352                    .map_err(|e| MemoryError::InvalidInput {
353                        reason: format!("path strip error: {}", e),
354                    })?;
355            let mut index = repo.index()?;
356            index.remove_path(relative)?;
357
358            let message = format!("chore: delete memory '{}'", name);
359            let oid = arc.commit_index(&repo, &mut index, &message)?;
360
361            tracing::Span::current().record("oid", oid.to_string().as_str());
362            info!(oid = %oid, "memory deleted from repo");
363
364            Ok(())
365        })
366        .await
367        .map_err(|e| MemoryError::Join(e.to_string()))?;
368
369        match &result {
370            Ok(_) => self.reporter.report_ok(),
371            Err(_) => self.reporter.report_err("delete_memory failed"),
372        }
373        result
374    }
375
376    /// 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 = traced_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 = traced_spawn_blocking(move || -> Result<Vec<Memory>, MemoryError> {
435            let _enter = span.entered();
436            let dirs: Vec<PathBuf> = match scope_clone.as_ref() {
437                Some(s) => vec![root.join(s.dir_prefix())],
438                None => {
439                    // 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 = traced_spawn_blocking(move || -> Result<(), MemoryError> {
526            let _enter = span.entered();
527            let repo = arc
528                .inner
529                .lock()
530                .expect("lock poisoned — prior panic corrupted state");
531
532            let mut remote = match repo.find_remote("origin") {
533                Ok(r) => r,
534                Err(e) if e.code() == ErrorCode::NotFound => {
535                    warn!("push: no origin remote configured — skipping (local-only mode)");
536                    return Ok(());
537                }
538                Err(e) => return Err(MemoryError::Git(e)),
539            };
540
541            // 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 = traced_spawn_blocking(move || -> Result<PullResult, MemoryError> {
676            let _enter = span.entered();
677            let repo = arc
678                .inner
679                .lock()
680                .expect("lock poisoned — prior panic corrupted state");
681
682            // ---- 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    /// Commit the current index state and return the new commit's `Oid`.
1027    fn commit_index(
1028        &self,
1029        repo: &Repository,
1030        index: &mut git2::Index,
1031        message: &str,
1032    ) -> Result<git2::Oid, MemoryError> {
1033        index.write()?;
1034        let tree_oid = index.write_tree()?;
1035        let tree = repo.find_tree(tree_oid)?;
1036        let sig = self.signature(repo)?;
1037
1038        let oid = match repo.head() {
1039            Ok(head) => {
1040                let parent_commit = head.peel_to_commit()?;
1041                repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])?
1042            }
1043            Err(e) if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => {
1044                repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
1045            }
1046            Err(e) => return Err(MemoryError::Git(e)),
1047        };
1048
1049        Ok(oid)
1050    }
1051
1052    /// Stage `file_path` and create a commit.
1053    fn git_add_and_commit(
1054        &self,
1055        repo: &Repository,
1056        file_path: &Path,
1057        message: &str,
1058    ) -> Result<git2::Oid, MemoryError> {
1059        let relative =
1060            file_path
1061                .strip_prefix(&self.root)
1062                .map_err(|e| MemoryError::InvalidInput {
1063                    reason: format!("path strip error: {}", e),
1064                })?;
1065
1066        let mut index = repo.index()?;
1067        index.add_path(relative)?;
1068
1069        self.commit_index(repo, &mut index, message)
1070    }
1071
1072    /// Assert that `path` remains under `self.root` after canonicalisation,
1073    /// preventing path-traversal attacks.
1074    fn assert_within_root(&self, path: &Path) -> Result<(), MemoryError> {
1075        // The file may not exist yet, so we canonicalize its parent and
1076        // then re-append the filename.
1077        let parent = path.parent().unwrap_or(path);
1078        let filename = path.file_name().ok_or_else(|| MemoryError::InvalidInput {
1079            reason: "path has no filename component".to_string(),
1080        })?;
1081
1082        // If the parent doesn't exist yet we check as many ancestors as
1083        // necessary until we find one that does.
1084        let canon_parent = {
1085            let mut p = parent.to_path_buf();
1086            let mut suffixes: Vec<std::ffi::OsString> = Vec::new();
1087            loop {
1088                match p.canonicalize() {
1089                    Ok(c) => {
1090                        let mut full = c;
1091                        for s in suffixes.into_iter().rev() {
1092                            full.push(s);
1093                        }
1094                        break full;
1095                    }
1096                    Err(_) => {
1097                        if let Some(name) = p.file_name() {
1098                            suffixes.push(name.to_os_string());
1099                        }
1100                        match p.parent() {
1101                            Some(par) => p = par.to_path_buf(),
1102                            None => {
1103                                return Err(MemoryError::InvalidInput {
1104                                    reason: "cannot resolve any ancestor of path".into(),
1105                                });
1106                            }
1107                        }
1108                    }
1109                }
1110            }
1111        };
1112
1113        let resolved = canon_parent.join(filename);
1114
1115        let canon_root = self
1116            .root
1117            .canonicalize()
1118            .map_err(|e| MemoryError::InvalidInput {
1119                reason: format!("cannot canonicalize repo root: {}", e),
1120            })?;
1121
1122        if !resolved.starts_with(&canon_root) {
1123            return Err(MemoryError::InvalidInput {
1124                reason: format!(
1125                    "path '{}' escapes repository root '{}'",
1126                    resolved.display(),
1127                    canon_root.display()
1128                ),
1129            });
1130        }
1131
1132        // Reject any symlinks within the repo root. We check each existing
1133        // component of `resolved` that lies inside `canon_root` — if any is a
1134        // symlink the request is rejected, because canonicalization already
1135        // followed it and the prefix check above would silently pass.
1136        {
1137            let mut probe = canon_root.clone();
1138            // Collect the path components that are beneath the root.
1139            let relative =
1140                resolved
1141                    .strip_prefix(&canon_root)
1142                    .map_err(|e| MemoryError::InvalidInput {
1143                        reason: format!("path strip error: {}", e),
1144                    })?;
1145            for component in relative.components() {
1146                probe.push(component);
1147                // Only check components that currently exist on disk.
1148                if (probe.exists() || probe.symlink_metadata().is_ok())
1149                    && probe
1150                        .symlink_metadata()
1151                        .map(|m| m.file_type().is_symlink())
1152                        .unwrap_or(false)
1153                {
1154                    return Err(MemoryError::InvalidInput {
1155                        reason: format!(
1156                            "path component '{}' is a symlink, which is not allowed",
1157                            probe.display()
1158                        ),
1159                    });
1160                }
1161            }
1162        }
1163
1164        Ok(())
1165    }
1166
1167    /// Atomically write `data` to `path` via temp-file + rename.
1168    ///
1169    /// Defense-in-depth against symlink attacks (layered):
1170    /// 1. `validate_path` rejects symlinks in all path components.
1171    /// 2. An `lstat` check here catches symlinks created between
1172    ///    validation and write (narrows the TOCTOU window).
1173    /// 3. On Unix, an `O_NOFOLLOW` probe on the final path detects
1174    ///    symlinks planted in the window between lstat and
1175    ///    `atomic_write`. The temp file itself is separately guarded
1176    ///    by `O_NOFOLLOW` inside `write_tmp`.
1177    fn write_memory_file(&self, path: &Path, data: &[u8]) -> Result<(), MemoryError> {
1178        // Layer 2: lstat — reject if the target is currently a symlink.
1179        if path
1180            .symlink_metadata()
1181            .map(|m| m.file_type().is_symlink())
1182            .unwrap_or(false)
1183        {
1184            return Err(MemoryError::InvalidInput {
1185                reason: format!("refusing to write through symlink: {}", path.display()),
1186            });
1187        }
1188
1189        // Layer 3 (Unix): O_NOFOLLOW probe — kernel-level symlink rejection.
1190        // NotFound is fine (file doesn't exist yet); any other error (ELOOP
1191        // from a symlink, permission denied, etc.) is rejected.
1192        #[cfg(unix)]
1193        {
1194            use std::os::unix::fs::OpenOptionsExt as _;
1195            if let Err(e) = std::fs::OpenOptions::new()
1196                .read(true)
1197                .custom_flags(libc::O_NOFOLLOW)
1198                .open(path)
1199            {
1200                // NotFound is fine — the file doesn't exist yet.
1201                if e.kind() != std::io::ErrorKind::NotFound {
1202                    return Err(MemoryError::InvalidInput {
1203                        reason: format!("O_NOFOLLOW check failed for {}: {e}", path.display()),
1204                    });
1205                }
1206            }
1207        }
1208
1209        crate::fs_util::atomic_write(path, data)?;
1210        Ok(())
1211    }
1212
1213    /// Open `path` for reading using `O_NOFOLLOW` on Unix, then return its
1214    /// contents as a `String`.
1215    ///
1216    /// On non-Unix platforms falls back to `std::fs::read_to_string`.
1217    fn read_memory_file(&self, path: &Path) -> Result<String, MemoryError> {
1218        #[cfg(unix)]
1219        {
1220            use std::io::Read as _;
1221            use std::os::unix::fs::OpenOptionsExt as _;
1222            let mut f = std::fs::OpenOptions::new()
1223                .read(true)
1224                .custom_flags(libc::O_NOFOLLOW)
1225                .open(path)?;
1226            let mut buf = String::new();
1227            f.read_to_string(&mut buf)?;
1228            Ok(buf)
1229        }
1230        #[cfg(not(unix))]
1231        {
1232            Ok(std::fs::read_to_string(path)?)
1233        }
1234    }
1235}
1236
1237// ---------------------------------------------------------------------------
1238// Tests
1239// ---------------------------------------------------------------------------
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244    use crate::auth::AuthProvider;
1245    use crate::types::{Memory, MemoryMetadata, PullResult, Scope};
1246    use std::sync::Arc;
1247
1248    fn test_auth() -> AuthProvider {
1249        AuthProvider::with_token("test-token-unused-for-file-remotes")
1250    }
1251
1252    fn make_memory(name: &str, content: &str, updated_at_secs: i64) -> Memory {
1253        let meta = MemoryMetadata {
1254            tags: vec![],
1255            scope: Scope::Global,
1256            created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
1257            updated_at: chrono::DateTime::from_timestamp(updated_at_secs, 0).unwrap(),
1258            source: None,
1259        };
1260        Memory::new(name.to_string(), content.to_string(), meta)
1261    }
1262
1263    fn setup_bare_remote() -> (tempfile::TempDir, String) {
1264        let dir = tempfile::tempdir().expect("failed to create temp dir");
1265        git2::Repository::init_bare(dir.path()).expect("failed to init bare repo");
1266        let url = format!("file://{}", dir.path().display());
1267        (dir, url)
1268    }
1269
1270    fn open_repo(dir: &tempfile::TempDir, remote_url: Option<&str>) -> Arc<MemoryRepo> {
1271        Arc::new(MemoryRepo::init_or_open(dir.path(), remote_url).expect("failed to init repo"))
1272    }
1273
1274    // -- redact_url tests --------------------------------------------------
1275
1276    #[test]
1277    fn redact_url_strips_userinfo() {
1278        assert_eq!(
1279            redact_url("https://user:ghp_token123@github.com/org/repo.git"),
1280            "https://[REDACTED]@github.com/org/repo.git"
1281        );
1282    }
1283
1284    #[test]
1285    fn redact_url_no_at_passthrough() {
1286        let url = "https://github.com/org/repo.git";
1287        assert_eq!(redact_url(url), url);
1288    }
1289
1290    #[test]
1291    fn redact_url_file_protocol_passthrough() {
1292        let url = "file:///tmp/bare.git";
1293        assert_eq!(redact_url(url), url);
1294    }
1295
1296    // -- assert_within_root tests ------------------------------------------
1297
1298    #[test]
1299    fn assert_within_root_accepts_valid_path() {
1300        let dir = tempfile::tempdir().unwrap();
1301        let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1302        let valid = dir.path().join("global").join("my-memory.md");
1303        // Create the parent so canonicalization works.
1304        std::fs::create_dir_all(valid.parent().unwrap()).unwrap();
1305        assert!(repo.assert_within_root(&valid).is_ok());
1306    }
1307
1308    #[test]
1309    fn assert_within_root_rejects_escape() {
1310        let dir = tempfile::tempdir().unwrap();
1311        let repo = MemoryRepo::init_or_open(dir.path(), None).unwrap();
1312        // Build a path that escapes the repo root. We need enough ".." to go
1313        // above the tmpdir, then descend into /tmp/evil.
1314        let _evil = dir
1315            .path()
1316            .join("..")
1317            .join("..")
1318            .join("..")
1319            .join("tmp")
1320            .join("evil.md");
1321        // Only assert if the path actually resolves outside root.
1322        // (If the temp dir is at root level, this might not escape — use an
1323        // explicit absolute path instead.)
1324        let outside = std::path::PathBuf::from("/tmp/definitely-outside");
1325        assert!(repo.assert_within_root(&outside).is_err());
1326    }
1327
1328    // -- local-only mode tests (no origin) ---------------------------------
1329
1330    #[tokio::test]
1331    async fn push_local_only_returns_ok() {
1332        let dir = tempfile::tempdir().unwrap();
1333        let repo = open_repo(&dir, None);
1334        let auth = test_auth();
1335        // No origin configured — push should silently succeed.
1336        let result = repo.push(&auth, "main").await;
1337        assert!(result.is_ok());
1338    }
1339
1340    #[tokio::test]
1341    async fn pull_local_only_returns_no_remote() {
1342        let dir = tempfile::tempdir().unwrap();
1343        let repo = open_repo(&dir, None);
1344        let auth = test_auth();
1345        let result = repo.pull(&auth, "main").await.unwrap();
1346        assert!(matches!(result, PullResult::NoRemote));
1347    }
1348
1349    // -- push/pull with local bare remote ----------------------------------
1350
1351    #[tokio::test]
1352    async fn push_to_bare_remote() {
1353        let (_remote_dir, remote_url) = setup_bare_remote();
1354        let local_dir = tempfile::tempdir().unwrap();
1355        let repo = open_repo(&local_dir, Some(&remote_url));
1356        let auth = test_auth();
1357
1358        // Save a memory so there's something to push.
1359        let mem = make_memory("test-push", "push content", 1_700_000_000);
1360        repo.save_memory(&mem).await.unwrap();
1361
1362        // Push should succeed.
1363        repo.push(&auth, "main").await.unwrap();
1364
1365        // Verify the bare repo received the commit.
1366        let bare = git2::Repository::open_bare(_remote_dir.path()).unwrap();
1367        let head = bare.find_reference("refs/heads/main").unwrap();
1368        let commit = head.peel_to_commit().unwrap();
1369        assert!(commit.message().unwrap().contains("test-push"));
1370    }
1371
1372    #[tokio::test]
1373    async fn pull_from_empty_bare_remote_returns_up_to_date() {
1374        let (_remote_dir, remote_url) = setup_bare_remote();
1375        let local_dir = tempfile::tempdir().unwrap();
1376        let repo = open_repo(&local_dir, Some(&remote_url));
1377        let auth = test_auth();
1378
1379        // First save something locally so we have an initial commit (HEAD exists).
1380        let mem = make_memory("seed", "seed content", 1_700_000_000);
1381        repo.save_memory(&mem).await.unwrap();
1382
1383        // Pull from empty remote — should be up-to-date (not an error).
1384        let result = repo.pull(&auth, "main").await.unwrap();
1385        assert!(matches!(result, PullResult::UpToDate));
1386    }
1387
1388    #[tokio::test]
1389    async fn pull_fast_forward() {
1390        let (_remote_dir, remote_url) = setup_bare_remote();
1391        let auth = test_auth();
1392
1393        // Repo A: save and push
1394        let dir_a = tempfile::tempdir().unwrap();
1395        let repo_a = open_repo(&dir_a, Some(&remote_url));
1396        let mem = make_memory("from-a", "content from A", 1_700_000_000);
1397        repo_a.save_memory(&mem).await.unwrap();
1398        repo_a.push(&auth, "main").await.unwrap();
1399
1400        // Repo B: init with same remote, then pull
1401        let dir_b = tempfile::tempdir().unwrap();
1402        let repo_b = open_repo(&dir_b, Some(&remote_url));
1403        // Repo B needs an initial commit for HEAD to exist.
1404        let seed = make_memory("seed-b", "seed", 1_700_000_000);
1405        repo_b.save_memory(&seed).await.unwrap();
1406
1407        let result = repo_b.pull(&auth, "main").await.unwrap();
1408        assert!(
1409            matches!(
1410                result,
1411                PullResult::FastForward { .. } | PullResult::Merged { .. }
1412            ),
1413            "expected fast-forward or merge, got {:?}",
1414            result
1415        );
1416
1417        // Verify the memory file from A exists in B's working directory.
1418        let file = dir_b.path().join("global").join("from-a.md");
1419        assert!(file.exists(), "from-a.md should exist in repo B after pull");
1420    }
1421
1422    #[tokio::test]
1423    async fn pull_up_to_date_after_push() {
1424        let (_remote_dir, remote_url) = setup_bare_remote();
1425        let local_dir = tempfile::tempdir().unwrap();
1426        let repo = open_repo(&local_dir, Some(&remote_url));
1427        let auth = test_auth();
1428
1429        let mem = make_memory("synced", "synced content", 1_700_000_000);
1430        repo.save_memory(&mem).await.unwrap();
1431        repo.push(&auth, "main").await.unwrap();
1432
1433        // Pull immediately after push — should be up to date.
1434        let result = repo.pull(&auth, "main").await.unwrap();
1435        assert!(matches!(result, PullResult::UpToDate));
1436    }
1437
1438    // -- conflict resolution tests -----------------------------------------
1439
1440    #[tokio::test]
1441    async fn pull_merge_conflict_theirs_newer_wins() {
1442        let (_remote_dir, remote_url) = setup_bare_remote();
1443        let auth = test_auth();
1444
1445        // Repo A: save "shared" with T1, push
1446        let dir_a = tempfile::tempdir().unwrap();
1447        let repo_a = open_repo(&dir_a, Some(&remote_url));
1448        let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1449        repo_a.save_memory(&mem_a1).await.unwrap();
1450        repo_a.push(&auth, "main").await.unwrap();
1451
1452        // Repo B: pull to get A's commit, then modify "shared" with T3 (newer), push
1453        let dir_b = tempfile::tempdir().unwrap();
1454        let repo_b = open_repo(&dir_b, Some(&remote_url));
1455        let seed = make_memory("seed-b", "seed", 1_700_000_000);
1456        repo_b.save_memory(&seed).await.unwrap();
1457        repo_b.pull(&auth, "main").await.unwrap();
1458
1459        let mem_b = make_memory("shared", "version from B (newer)", 1_700_000_300);
1460        repo_b.save_memory(&mem_b).await.unwrap();
1461        repo_b.push(&auth, "main").await.unwrap();
1462
1463        // Repo A: modify "shared" with T2 (older than T3), then pull — conflict
1464        let mem_a2 = make_memory("shared", "version from A (older)", 1_700_000_200);
1465        repo_a.save_memory(&mem_a2).await.unwrap();
1466        let result = repo_a.pull(&auth, "main").await.unwrap();
1467
1468        assert!(
1469            matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1470            "expected merge with conflicts resolved, got {:?}",
1471            result
1472        );
1473
1474        // Verify theirs (B's version, T3=300) won.
1475        let file = dir_a.path().join("global").join("shared.md");
1476        let content = std::fs::read_to_string(&file).unwrap();
1477        assert!(
1478            content.contains("version from B (newer)"),
1479            "expected B's version to win (newer timestamp), got: {}",
1480            content
1481        );
1482    }
1483
1484    #[tokio::test]
1485    async fn pull_merge_conflict_ours_newer_wins() {
1486        let (_remote_dir, remote_url) = setup_bare_remote();
1487        let auth = test_auth();
1488
1489        // Repo A: save "shared" with T1, push
1490        let dir_a = tempfile::tempdir().unwrap();
1491        let repo_a = open_repo(&dir_a, Some(&remote_url));
1492        let mem_a1 = make_memory("shared", "version from A initial", 1_700_000_100);
1493        repo_a.save_memory(&mem_a1).await.unwrap();
1494        repo_a.push(&auth, "main").await.unwrap();
1495
1496        // Repo B: pull, modify with T2 (older), push
1497        let dir_b = tempfile::tempdir().unwrap();
1498        let repo_b = open_repo(&dir_b, Some(&remote_url));
1499        let seed = make_memory("seed-b", "seed", 1_700_000_000);
1500        repo_b.save_memory(&seed).await.unwrap();
1501        repo_b.pull(&auth, "main").await.unwrap();
1502
1503        let mem_b = make_memory("shared", "version from B (older)", 1_700_000_200);
1504        repo_b.save_memory(&mem_b).await.unwrap();
1505        repo_b.push(&auth, "main").await.unwrap();
1506
1507        // Repo A: modify with T3 (newer), pull — conflict
1508        let mem_a2 = make_memory("shared", "version from A (newer)", 1_700_000_300);
1509        repo_a.save_memory(&mem_a2).await.unwrap();
1510        let result = repo_a.pull(&auth, "main").await.unwrap();
1511
1512        assert!(
1513            matches!(result, PullResult::Merged { conflicts_resolved, .. } if conflicts_resolved >= 1),
1514            "expected merge with conflicts resolved, got {:?}",
1515            result
1516        );
1517
1518        // Verify ours (A's version, T3=300) won.
1519        let file = dir_a.path().join("global").join("shared.md");
1520        let content = std::fs::read_to_string(&file).unwrap();
1521        assert!(
1522            content.contains("version from A (newer)"),
1523            "expected A's version to win (newer timestamp), got: {}",
1524            content
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn pull_merge_no_conflict_different_files() {
1530        let (_remote_dir, remote_url) = setup_bare_remote();
1531        let auth = test_auth();
1532
1533        // Repo A: save "mem-a", push
1534        let dir_a = tempfile::tempdir().unwrap();
1535        let repo_a = open_repo(&dir_a, Some(&remote_url));
1536        let mem_a = make_memory("mem-a", "from A", 1_700_000_100);
1537        repo_a.save_memory(&mem_a).await.unwrap();
1538        repo_a.push(&auth, "main").await.unwrap();
1539
1540        // Repo B: pull, save "mem-b", push
1541        let dir_b = tempfile::tempdir().unwrap();
1542        let repo_b = open_repo(&dir_b, Some(&remote_url));
1543        let seed = make_memory("seed-b", "seed", 1_700_000_000);
1544        repo_b.save_memory(&seed).await.unwrap();
1545        repo_b.pull(&auth, "main").await.unwrap();
1546        let mem_b = make_memory("mem-b", "from B", 1_700_000_200);
1547        repo_b.save_memory(&mem_b).await.unwrap();
1548        repo_b.push(&auth, "main").await.unwrap();
1549
1550        // Repo A: save "mem-a2" (different file), pull — should merge cleanly
1551        let mem_a2 = make_memory("mem-a2", "also from A", 1_700_000_300);
1552        repo_a.save_memory(&mem_a2).await.unwrap();
1553        let result = repo_a.pull(&auth, "main").await.unwrap();
1554
1555        assert!(
1556            matches!(
1557                result,
1558                PullResult::Merged {
1559                    conflicts_resolved: 0,
1560                    ..
1561                }
1562            ),
1563            "expected clean merge, got {:?}",
1564            result
1565        );
1566
1567        // Both repos should have all files.
1568        assert!(dir_a.path().join("global").join("mem-b.md").exists());
1569    }
1570
1571    // -- diff_changed_memories tests ----------------------------------------
1572
1573    /// Helper: commit a file with given content and return the new HEAD OID bytes.
1574    fn commit_file(repo: &Arc<MemoryRepo>, rel_path: &str, content: &str) -> [u8; 20] {
1575        let inner = repo.inner.lock().expect("lock poisoned");
1576        let full_path = repo.root.join(rel_path);
1577        if let Some(parent) = full_path.parent() {
1578            std::fs::create_dir_all(parent).unwrap();
1579        }
1580        std::fs::write(&full_path, content).unwrap();
1581
1582        let mut index = inner.index().unwrap();
1583        index.add_path(std::path::Path::new(rel_path)).unwrap();
1584        index.write().unwrap();
1585        let tree_oid = index.write_tree().unwrap();
1586        let tree = inner.find_tree(tree_oid).unwrap();
1587        let sig = git2::Signature::now("test", "test@test.com").unwrap();
1588
1589        let oid = match inner.head() {
1590            Ok(head) => {
1591                let parent = head.peel_to_commit().unwrap();
1592                inner
1593                    .commit(Some("HEAD"), &sig, &sig, "test commit", &tree, &[&parent])
1594                    .unwrap()
1595            }
1596            Err(_) => inner
1597                .commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
1598                .unwrap(),
1599        };
1600
1601        let mut buf = [0u8; 20];
1602        buf.copy_from_slice(oid.as_bytes());
1603        buf
1604    }
1605
1606    #[test]
1607    fn diff_changed_memories_detects_added_global() {
1608        let dir = tempfile::tempdir().unwrap();
1609        let repo = open_repo(&dir, None);
1610
1611        // Capture the initial HEAD (init commit).
1612        let old_oid = {
1613            let inner = repo.inner.lock().unwrap();
1614            let head = inner.head().unwrap();
1615            let mut buf = [0u8; 20];
1616            buf.copy_from_slice(head.peel_to_commit().unwrap().id().as_bytes());
1617            buf
1618        };
1619
1620        let new_oid = commit_file(&repo, "global/my-note.md", "# content");
1621
1622        let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1623        assert_eq!(changes.upserted, vec!["global/my-note".to_string()]);
1624        assert!(changes.removed.is_empty());
1625    }
1626
1627    #[test]
1628    fn diff_changed_memories_detects_deleted() {
1629        let dir = tempfile::tempdir().unwrap();
1630        let repo = open_repo(&dir, None);
1631
1632        let first_oid = commit_file(&repo, "global/to-delete.md", "hello");
1633        let second_oid = {
1634            let inner = repo.inner.lock().unwrap();
1635            let full_path = dir.path().join("global/to-delete.md");
1636            std::fs::remove_file(&full_path).unwrap();
1637            let mut index = inner.index().unwrap();
1638            index
1639                .remove_path(std::path::Path::new("global/to-delete.md"))
1640                .unwrap();
1641            index.write().unwrap();
1642            let tree_oid = index.write_tree().unwrap();
1643            let tree = inner.find_tree(tree_oid).unwrap();
1644            let sig = git2::Signature::now("test", "test@test.com").unwrap();
1645            let parent = inner.head().unwrap().peel_to_commit().unwrap();
1646            let oid = inner
1647                .commit(Some("HEAD"), &sig, &sig, "delete file", &tree, &[&parent])
1648                .unwrap();
1649            let mut buf = [0u8; 20];
1650            buf.copy_from_slice(oid.as_bytes());
1651            buf
1652        };
1653
1654        let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1655        assert!(changes.upserted.is_empty());
1656        assert_eq!(changes.removed, vec!["global/to-delete".to_string()]);
1657    }
1658
1659    #[test]
1660    fn diff_changed_memories_ignores_non_md_files() {
1661        let dir = tempfile::tempdir().unwrap();
1662        let repo = open_repo(&dir, None);
1663
1664        let old_oid = {
1665            let inner = repo.inner.lock().unwrap();
1666            let mut buf = [0u8; 20];
1667            buf.copy_from_slice(
1668                inner
1669                    .head()
1670                    .unwrap()
1671                    .peel_to_commit()
1672                    .unwrap()
1673                    .id()
1674                    .as_bytes(),
1675            );
1676            buf
1677        };
1678
1679        // Add a non-.md file under global/ and a .md file outside tracked dirs.
1680        let _ = commit_file(&repo, "global/config.json", "{}");
1681        let new_oid = commit_file(&repo, "other/note.md", "# ignored");
1682
1683        let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1684        assert!(
1685            changes.upserted.is_empty(),
1686            "should ignore non-.md and out-of-scope files"
1687        );
1688        assert!(changes.removed.is_empty());
1689    }
1690
1691    #[test]
1692    fn diff_changed_memories_detects_modified() {
1693        let dir = tempfile::tempdir().unwrap();
1694        let repo = open_repo(&dir, None);
1695
1696        let first_oid = commit_file(&repo, "projects/myproject/note.md", "version 1");
1697        let second_oid = commit_file(&repo, "projects/myproject/note.md", "version 2");
1698
1699        let changes = repo.diff_changed_memories(first_oid, second_oid).unwrap();
1700        assert_eq!(
1701            changes.upserted,
1702            vec!["projects/myproject/note".to_string()]
1703        );
1704        assert!(changes.removed.is_empty());
1705    }
1706
1707    /// A zero OID (unborn branch sentinel) must not crash; all files in the
1708    /// new commit should appear as additions.
1709    #[test]
1710    fn diff_changed_memories_zero_oid_treats_all_as_added() {
1711        let dir = tempfile::tempdir().unwrap();
1712        let repo = open_repo(&dir, None);
1713
1714        // Commit a global memory file — this is the "new" state.
1715        let new_oid = commit_file(&repo, "global/first-memory.md", "# Hello");
1716
1717        // old_oid = [0u8; 20] simulates an unborn branch (no prior commit).
1718        let old_oid = [0u8; 20];
1719
1720        let changes = repo.diff_changed_memories(old_oid, new_oid).unwrap();
1721        assert_eq!(
1722            changes.upserted,
1723            vec!["global/first-memory".to_string()],
1724            "zero OID: all new-tree files should be additions"
1725        );
1726        assert!(changes.removed.is_empty(), "zero OID: no removals expected");
1727    }
1728}