Skip to main content

memstead_git_branch/ops/
diff.rs

1//! `Engine::diff(ref_a, ref_b)` implementation for git-branch mounts.
2//!
3//! Walks the two tree objects pointed to by `ref_a` and `ref_b` inside
4//! the workspace's mem-repo gitdir, produces a per-entity diff
5//! ([`Diff`]) that downstream replay / review / preview / audit
6//! tooling consumes. Same backbone the [`changes_since`](super::changes)
7//! op uses — two-tree diff with rename detection — but expanded into a
8//! two-endpoint shape with optional content enrichment.
9//!
10//!
11//! ## V1 scope
12//!
13//! - Added / Modified / Deleted entries from a vanilla gix tree-diff.
14//! - Content enrichment per `DiffConfig::include_content`.
15//! - `InvalidEntity` entries for paths that fail UTF-8 / parse.
16//!
17//! ## V1 gaps (handover candidates)
18//!
19//! - **Rename detection**: deferred. The agent-notes-driven rename
20//!   collapse `changes_since` performs requires a `since` cursor in
21//!   the same mem's history. A generic two-ref diff (potentially
22//!   cross-mem) needs a different walk; v1 emits Added/Deleted pairs
23//!   instead of `Renamed` and leaves rename-chain unfilled.
24//! - **Cross-entity ripple**: `IncomingRipple` lists stay empty in
25//!   the entries. Populating them requires a per-side wiki-link graph
26//!   reconstruction that the engine's current in-memory store does
27//!   not maintain for arbitrary refs.
28
29use std::collections::{HashMap, HashSet};
30use std::path::Path;
31
32use gix::object::tree::diff::Change;
33
34use memstead_base::backend::BackendError;
35use memstead_base::entity::EntityId;
36use memstead_base::entity::id::file_path_to_id;
37use memstead_base::entity::parser::{extract_inline_links_lenient, peek_title_and_type};
38use memstead_base::ops::{Diff, DiffConfig, EntityDiff, IncomingRipple};
39
40use crate::EMPTY_TREE_SHA;
41
42/// Normalise a caller-supplied ref against the per-mem branch
43/// convention, shared by `memstead_diff` and `memstead_changes_since`.
44///
45/// Rewrites a leading `HEAD` *token* — the whole ref (`HEAD`) or the base
46/// of a revspec (`HEAD~5`, `HEAD^`, `HEAD^{tree}`, `HEAD@{1}`) — to
47/// `refs/heads/<mem>`, preserving the suffix, so resolution targets the
48/// selected mem's branch tip rather than the mem-repo gitdir's
49/// symbolic HEAD (which points at the dummy default branch). gix already
50/// parses the revspec suffix; this only re-anchors the `HEAD` base. A ref
51/// that merely *starts with* `HEAD` (e.g. `HEADER`, `HEAD-foo`) is left
52/// alone — the character after `HEAD` must be a revspec operator
53/// (`~ ^ : @`) or end-of-string. Targeted at the per-mem entry point
54/// only: mem-less callers (cross-mem diffs naming a peer branch) pass
55/// fully-qualified refs that don't begin with the `HEAD` token.
56///
57/// The empty-tree sentinel is handled inside `resolve_tree` so callers see
58/// a single dispatch.
59pub(crate) fn normalise_ref_for_mem(mem: &str, raw: &str) -> String {
60    if let Some(rest) = raw.strip_prefix("HEAD")
61        && (rest.is_empty() || rest.starts_with(['~', '^', ':', '@']))
62    {
63        return format!("refs/heads/{mem}{rest}");
64    }
65    raw.to_string()
66}
67
68/// Resolve a ref to its tree, returning a typed-marker error
69/// (`UNKNOWN_REF:<raw>`) when `rev_parse` refuses.
70///
71/// The canonical empty-tree SHA (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`)
72/// short-circuits to `repo.empty_tree()`. Matches
73/// `memstead_changes_since`'s sentinel handling so callers who learned
74/// the sentinel from the sibling tool find it works here too. Real
75/// tree-only SHAs that are not the canonical sentinel continue to
76/// refuse with `UNKNOWN_REF` — the sentinel handling is keyed on
77/// the literal hash, not on "is it a tree".
78fn resolve_tree<'r>(repo: &'r gix::Repository, raw: &str) -> Result<gix::Tree<'r>, BackendError> {
79    if raw == EMPTY_TREE_SHA {
80        return Ok(repo.empty_tree());
81    }
82    let id = repo
83        .rev_parse_single(raw)
84        .map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
85    let object = id
86        .object()
87        .map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
88    let commit = object
89        .try_into_commit()
90        .map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw} is not a commit")))?;
91    commit
92        .tree()
93        .map_err(|e| BackendError::Other(format!("tree({raw}): {e}")))
94}
95
96fn sha_for(repo: &gix::Repository, raw: &str) -> Result<String, BackendError> {
97    if raw == EMPTY_TREE_SHA {
98        return Ok(EMPTY_TREE_SHA.to_string());
99    }
100    let id = repo
101        .rev_parse_single(raw)
102        .map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
103    Ok(id.detach().to_string())
104}
105
106/// Translate a tree-diff path to a mem-qualified entity id, returning
107/// `None` for engine-internal paths (`.memstead/...`) and non-markdown
108/// entries. Mirrors `changes::path_to_entity_id` but is callable from
109/// here without exporting the private helper.
110fn path_to_entity_id(mem: &str, path: &gix::bstr::BStr) -> Option<EntityId> {
111    let s = std::str::from_utf8(path.as_ref()).ok()?;
112    if s.is_empty() || !s.ends_with(".md") {
113        return None;
114    }
115    if s.starts_with(".memstead/") {
116        return None;
117    }
118    Some(file_path_to_id(s, mem))
119}
120
121/// Read the markdown body for `path` from `tree`. Returns `None` when
122/// the path is not present in the tree, or when the lookup fails.
123fn read_md_at_path(
124    _repo: &gix::Repository,
125    tree: &gix::Tree<'_>,
126    path: &gix::bstr::BStr,
127) -> Option<String> {
128    let entry = tree
129        .lookup_entry_by_path(path.to_string().as_str())
130        .ok()??;
131    let object = entry.object().ok()?;
132    let blob = object.try_into_blob().ok()?;
133    String::from_utf8(blob.data.clone()).ok()
134}
135
136/// Extract `(title, entity_type)` from an optionally-present markdown
137/// blob. `(None, None)` when the blob is absent / non-UTF-8 or carries
138/// neither a `# ` heading nor a `type:` frontmatter field.
139fn peek_meta(raw: &Option<String>) -> (Option<String>, Option<String>) {
140    raw.as_deref()
141        .map(peek_title_and_type)
142        .unwrap_or((None, None))
143}
144
145/// Two-ref structural diff. See module docs for the v1 scope and the
146/// known gaps (rename, ripple) that will surface as handover items.
147pub fn diff_two_refs(
148    gitdir: &Path,
149    mem: &str,
150    ref_a: &str,
151    ref_b: &str,
152    config: &DiffConfig,
153) -> Result<Diff, BackendError> {
154    if !gitdir.is_dir() {
155        return Err(BackendError::Other(format!(
156            "gitdir not found: {}",
157            gitdir.display()
158        )));
159    }
160    let repo = gix::open(gitdir).map_err(|e| BackendError::Other(format!("gix open: {e}")))?;
161    // Bare `HEAD` substitutes to `refs/heads/<mem>` so the diff targets
162    // the mem's branch tip (not the gitdir's symbolic HEAD on a
163    // dummy default branch). Per-mem callers always pass a mem
164    // selector; the substitution is unconditional on this entry
165    // point.
166    let normalised_a = normalise_ref_for_mem(mem, ref_a);
167    let normalised_b = normalise_ref_for_mem(mem, ref_b);
168    let tree_a = resolve_tree(&repo, &normalised_a)?;
169    let tree_b = resolve_tree(&repo, &normalised_b)?;
170    let resolved_a_sha = sha_for(&repo, &normalised_a)?;
171    let resolved_b_sha = sha_for(&repo, &normalised_b)?;
172
173    let mut platform = tree_a
174        .changes()
175        .map_err(|e| BackendError::Other(format!("diff init: {e}")))?;
176    let rewrites = gix::diff::Rewrites {
177        copies: None,
178        percentage: Some(config.rename_similarity),
179        limit: 1000,
180        track_empty: false,
181    };
182    platform.options(|opts| {
183        opts.track_rewrites(Some(rewrites));
184    });
185
186    let mut entries: Vec<EntityDiff> = Vec::new();
187    platform
188        .for_each_to_obtain_tree(
189            &tree_b,
190            |change| -> Result<std::ops::ControlFlow<()>, std::convert::Infallible> {
191                match change {
192                    Change::Addition { location, .. } => {
193                        if let Some(id) = path_to_entity_id(mem, location) {
194                            // Read the post-state blob unconditionally to
195                            // populate `title`/`entity_type` (the docstring's
196                            // metadata-only shape); keep the full body as
197                            // `content_after` only when content is requested.
198                            let raw = read_md_at_path(&repo, &tree_b, location);
199                            let (title, entity_type) = peek_meta(&raw);
200                            let content_after = if config.include_content { raw } else { None };
201                            entries.push(EntityDiff::Added {
202                                id,
203                                title,
204                                entity_type,
205                                content_after,
206                                ripple: Vec::new(),
207                            });
208                        }
209                    }
210                    Change::Deletion { location, .. } => {
211                        if let Some(id) = path_to_entity_id(mem, location) {
212                            // The entity still exists on `ref_a`; pull its
213                            // metadata from that side so a deleted entry
214                            // still carries `title`/`entity_type`.
215                            let raw = read_md_at_path(&repo, &tree_a, location);
216                            let (title, entity_type) = peek_meta(&raw);
217                            let content_before = if config.include_content { raw } else { None };
218                            entries.push(EntityDiff::Deleted {
219                                id,
220                                title,
221                                entity_type,
222                                content_before,
223                                ripple: Vec::new(),
224                            });
225                        }
226                    }
227                    Change::Modification { location, .. } => {
228                        if let Some(id) = path_to_entity_id(mem, location) {
229                            // Post-state (`ref_b`) is the source for the
230                            // current metadata, mirroring `memstead_changes_since`.
231                            let raw_b = read_md_at_path(&repo, &tree_b, location);
232                            let (title, entity_type) = peek_meta(&raw_b);
233                            let content_before = if config.include_content {
234                                read_md_at_path(&repo, &tree_a, location)
235                            } else {
236                                None
237                            };
238                            let content_after = if config.include_content { raw_b } else { None };
239                            entries.push(EntityDiff::Modified {
240                                id,
241                                title,
242                                entity_type,
243                                content_before,
244                                content_after,
245                                ripple: Vec::new(),
246                            });
247                        }
248                    }
249                    Change::Rewrite {
250                        source_location,
251                        location,
252                        ..
253                    } => {
254                        let from = path_to_entity_id(mem, source_location);
255                        let to = path_to_entity_id(mem, location);
256                        if let (Some(from_id), Some(to_id)) = (from, to) {
257                            // Post-state (the `to` side) carries the surviving
258                            // metadata.
259                            let raw_b = read_md_at_path(&repo, &tree_b, location);
260                            let (title, entity_type) = peek_meta(&raw_b);
261                            let content_before = if config.include_content {
262                                read_md_at_path(&repo, &tree_a, source_location)
263                            } else {
264                                None
265                            };
266                            let content_after = if config.include_content { raw_b } else { None };
267                            entries.push(EntityDiff::Renamed {
268                                from_id,
269                                to_id,
270                                rename_chain: Vec::new(),
271                                title,
272                                entity_type,
273                                content_before,
274                                content_after,
275                                ripple: Vec::new(),
276                            });
277                        }
278                    }
279                }
280                Ok(std::ops::ControlFlow::Continue(()))
281            },
282        )
283        .map_err(|e| BackendError::Other(format!("diff: {e}")))?;
284
285    // Agent-notes-driven rename collapse + chain trace. Walks the
286    // commit history between `ref_a` and `ref_b` (when `ref_a` is an
287    // ancestor of `ref_b` the walk is exact; for unrelated refs the
288    // notes set may be empty / partial — gix-similarity rewrites
289    // still win as a fallback). For each engine-authored rename note
290    // pair `old → new`, pair surviving `Added(new) + Deleted(old)`
291    // entries into `Renamed`; for any `Renamed` entry where the notes
292    // record a multi-step chain, fill `rename_chain` with the
293    // intermediates.
294    apply_agent_notes_renames(gitdir, mem, ref_a, ref_b, &mut entries, config);
295
296    // Schema-strictness pass: entries whose markdown body fails the
297    // cheap parse check (missing / malformed frontmatter) get demoted
298    // to `InvalidEntity` with the surviving bytes attached. Runs only
299    // when content is included; the no-content shape carries no body
300    // to evaluate. Renamed pairs are exempt — see
301    // `demote_invalid_entries` for the rationale.
302    if config.include_content {
303        demote_invalid_entries(&mut entries);
304    }
305
306    // Stable ordering: sort by primary entity id so consumers can
307    // structurally compare diff outputs across runs.
308    entries.sort_by_key(primary_id);
309
310    if config.include_ripple {
311        let affected = collect_affected_ids(&entries);
312        if !affected.is_empty() {
313            let ripple_a = scan_tree_ripple(&repo, &tree_a, mem, &affected, "ref_a");
314            let ripple_b = scan_tree_ripple(&repo, &tree_b, mem, &affected, "ref_b");
315            attach_ripple(&mut entries, &ripple_a, &ripple_b);
316        }
317    }
318
319    Ok(Diff {
320        ref_a: ref_a.to_string(),
321        ref_b: ref_b.to_string(),
322        resolved_a_sha,
323        resolved_b_sha,
324        config: config.clone(),
325        entries,
326    })
327}
328
329/// Walk commit notes between `ref_a` and `ref_b`, derive the
330/// engine-authored rename graph, and fold the result into `entries`:
331/// `Added(new) + Deleted(old)` pairs where the notes show `old → new`
332/// promote to `Renamed`; existing `Renamed` entries fill in their
333/// `rename_chain` with intermediate ids when the notes record a
334/// multi-step chain. No-op when the notes lookup fails or returns
335/// nothing — gix-similarity stays the fallback rename signal.
336fn apply_agent_notes_renames(
337    gitdir: &Path,
338    mem: &str,
339    ref_a: &str,
340    ref_b: &str,
341    entries: &mut Vec<EntityDiff>,
342    config: &DiffConfig,
343) {
344    let report = match crate::ops::agent_notes::agent_notes_since(mem, gitdir, ref_a, Some(ref_b)) {
345        Ok(r) => r,
346        // The notes walker may refuse with `ObjectNotFound` for
347        // unrelated refs — that's expected and not fatal. The diff
348        // already includes the rev_parse errors via the resolve_tree
349        // call above, so a notes-walk refusal here is just "no chain
350        // data" and the gix-similarity result stands.
351        Err(_) => return,
352    };
353
354    // Build the per-step rename map in chronological order (notes come
355    // newest-first; reverse to walk oldest → newest so multi-step
356    // chains compose left-to-right).
357    let mut forward: HashMap<EntityId, EntityId> = HashMap::new();
358    for note in report.notes.iter().rev() {
359        if note.tool_verb.as_deref() != Some("rename") {
360            continue;
361        }
362        let Some(id_str) = note.entity_id.as_deref() else {
363            continue;
364        };
365        let Some((old_id, new_id)) = crate::ops::changes::parse_rename_entity_field(id_str) else {
366            continue;
367        };
368        forward.insert(old_id, new_id);
369    }
370    if forward.is_empty() {
371        return;
372    }
373
374    // Pair Added + Deleted entries that the notes flag as a rename.
375    // First pass: index entries by id so we can replace them in place.
376    let mut deleted_idx: HashMap<EntityId, usize> = HashMap::new();
377    let mut added_idx: HashMap<EntityId, usize> = HashMap::new();
378    for (idx, entry) in entries.iter().enumerate() {
379        match entry {
380            EntityDiff::Deleted { id, .. } => {
381                deleted_idx.insert(id.clone(), idx);
382            }
383            EntityDiff::Added { id, .. } => {
384                added_idx.insert(id.clone(), idx);
385            }
386            _ => {}
387        }
388    }
389
390    let mut to_remove: Vec<usize> = Vec::new();
391    let mut promotions: Vec<(usize, EntityDiff)> = Vec::new();
392    for (origin, _terminal) in forward.iter() {
393        let Some(&del_idx) = deleted_idx.get(origin) else {
394            continue;
395        };
396        // Walk forward from origin to find the surviving terminal id
397        // among the additions.
398        let mut chain: Vec<EntityId> = Vec::new();
399        let mut current = origin.clone();
400        let final_id: Option<EntityId> = loop {
401            let Some(next) = forward.get(&current) else {
402                break None;
403            };
404            if added_idx.contains_key(next) {
405                break Some(next.clone());
406            }
407            chain.push(next.clone());
408            current = next.clone();
409        };
410        let Some(terminal) = final_id else { continue };
411        let Some(&add_idx) = added_idx.get(&terminal) else {
412            continue;
413        };
414        let (content_before, content_after) = if config.include_content {
415            let cb = match &entries[del_idx] {
416                EntityDiff::Deleted { content_before, .. } => content_before.clone(),
417                _ => None,
418            };
419            let ca = match &entries[add_idx] {
420                EntityDiff::Added { content_after, .. } => content_after.clone(),
421                _ => None,
422            };
423            (cb, ca)
424        } else {
425            (None, None)
426        };
427        // Carry the post-state metadata from the surviving Added entry
428        // into the promoted Renamed — the collapse must not drop the
429        // `title`/`entity_type` the addition already resolved.
430        let (title, entity_type) = match &entries[add_idx] {
431            EntityDiff::Added {
432                title, entity_type, ..
433            } => (title.clone(), entity_type.clone()),
434            _ => (None, None),
435        };
436        let promoted = EntityDiff::Renamed {
437            from_id: origin.clone(),
438            to_id: terminal.clone(),
439            rename_chain: chain,
440            title,
441            entity_type,
442            content_before,
443            content_after,
444            ripple: Vec::new(),
445        };
446        promotions.push((add_idx, promoted));
447        to_remove.push(del_idx);
448    }
449
450    // Apply promotions (replace Added entries) and remove the paired
451    // Deletions. Sort indices descending so removals don't shift the
452    // others.
453    for (idx, promoted) in promotions {
454        entries[idx] = promoted;
455    }
456    to_remove.sort_by(|a, b| b.cmp(a));
457    for idx in to_remove {
458        entries.remove(idx);
459    }
460
461    // Fill rename_chain on entries that came in as Renamed (from gix
462    // similarity) when the notes record a multi-step chain.
463    for entry in entries.iter_mut() {
464        if let EntityDiff::Renamed {
465            from_id,
466            to_id,
467            rename_chain,
468            ..
469        } = entry
470            && rename_chain.is_empty()
471        {
472            let mut chain: Vec<EntityId> = Vec::new();
473            let mut current = from_id.clone();
474            while let Some(next) = forward.get(&current) {
475                if next == to_id {
476                    break;
477                }
478                chain.push(next.clone());
479                current = next.clone();
480            }
481            // Only attach the chain if it actually leads to `to_id` —
482            // otherwise the notes describe a different rename graph
483            // than the gix-similarity pairing, and falsely attributing
484            // intermediates would mislead consumers.
485            if forward.get(&current).is_some_and(|n| n == to_id) {
486                *rename_chain = chain;
487            }
488        }
489    }
490}
491
492/// Classify a markdown body as well-formed or parse-failing. v1
493/// covers the cheapest, most common failure (missing or malformed
494/// frontmatter): a body that does not start with `---\n` plus a
495/// matching `\n---` line is reported as `InvalidEntity`. Deeper
496/// schema-reparse validation (type-bound section / field checks) is a
497/// follow-up — the surface admits future expansion without changing
498/// the wire shape.
499fn classify_parse_failure(content: &str) -> Option<String> {
500    let trimmed_bom = content.trim_start_matches('\u{FEFF}');
501    if !trimmed_bom.starts_with("---") {
502        return Some("missing frontmatter (body does not open with `---`)".to_string());
503    }
504    let after_open = match trimmed_bom.strip_prefix("---") {
505        Some(rest) => rest.trim_start_matches('\r').trim_start_matches('\n'),
506        None => return Some("missing frontmatter".to_string()),
507    };
508    // Look for a line that is just "---" closing the frontmatter.
509    let mut closed = false;
510    for line in after_open.lines() {
511        if line.trim_end() == "---" {
512            closed = true;
513            break;
514        }
515    }
516    if !closed {
517        return Some("malformed frontmatter (no closing `---` line)".to_string());
518    }
519    None
520}
521
522/// Rewrite `entries` so any entry whose surviving content fails the
523/// minimum-bar parse check classifies as [`EntityDiff::InvalidEntity`]
524/// instead. Preserves the surviving content on whichever side is
525/// available so consumers can still surface what's there.
526fn demote_invalid_entries(entries: &mut [EntityDiff]) {
527    for entry in entries.iter_mut() {
528        match entry {
529            EntityDiff::Added {
530                id, content_after, ..
531            } => {
532                if let Some(c) = content_after.as_ref()
533                    && let Some(err) = classify_parse_failure(c)
534                {
535                    *entry = EntityDiff::InvalidEntity {
536                        id: id.clone(),
537                        side: "ref_b".to_string(),
538                        error: err,
539                        content_before: None,
540                        content_after: content_after.clone(),
541                    };
542                }
543            }
544            EntityDiff::Deleted {
545                id, content_before, ..
546            } => {
547                if let Some(c) = content_before.as_ref()
548                    && let Some(err) = classify_parse_failure(c)
549                {
550                    *entry = EntityDiff::InvalidEntity {
551                        id: id.clone(),
552                        side: "ref_a".to_string(),
553                        error: err,
554                        content_before: content_before.clone(),
555                        content_after: None,
556                    };
557                }
558            }
559            EntityDiff::Modified {
560                id,
561                content_before,
562                content_after,
563                ..
564            } => {
565                let err_a = content_before
566                    .as_ref()
567                    .and_then(|c| classify_parse_failure(c));
568                let err_b = content_after
569                    .as_ref()
570                    .and_then(|c| classify_parse_failure(c));
571                if err_a.is_some() || err_b.is_some() {
572                    let (side, error) = match (err_a, err_b) {
573                        (Some(a), Some(b)) => {
574                            ("both".to_string(), format!("{a} (ref_a); {b} (ref_b)"))
575                        }
576                        (Some(a), None) => ("ref_a".to_string(), a),
577                        (None, Some(b)) => ("ref_b".to_string(), b),
578                        (None, None) => unreachable!(),
579                    };
580                    *entry = EntityDiff::InvalidEntity {
581                        id: id.clone(),
582                        side,
583                        error,
584                        content_before: content_before.clone(),
585                        content_after: content_after.clone(),
586                    };
587                }
588            }
589            // Renamed entries skip the demote — a successful rename
590            // pairing implies the engine still understood both
591            // versions. InvalidEntity is reserved for the simpler
592            // Added / Modified / Deleted shapes.
593            EntityDiff::Renamed { .. } | EntityDiff::InvalidEntity { .. } => {}
594        }
595    }
596}
597
598/// Collect every entity id that an `EntityDiff` entry concerns. For
599/// `Renamed` both `from_id` and `to_id` are affected so the ripple
600/// scan can find inbound links to either name (the pre-rename id on
601/// the `ref_a` side, the post-rename id on the `ref_b` side).
602fn collect_affected_ids(entries: &[EntityDiff]) -> HashSet<EntityId> {
603    let mut out = HashSet::new();
604    for e in entries {
605        match e {
606            EntityDiff::Added { id, .. }
607            | EntityDiff::Modified { id, .. }
608            | EntityDiff::Deleted { id, .. }
609            | EntityDiff::InvalidEntity { id, .. } => {
610                out.insert(id.clone());
611            }
612            EntityDiff::Renamed { from_id, to_id, .. } => {
613                out.insert(from_id.clone());
614                out.insert(to_id.clone());
615            }
616        }
617    }
618    out
619}
620
621/// Walk a tree's `.md` blobs, scan each body for `[[…]]` wiki-links,
622/// and collect every (referrer → affected target, side) triple. Used
623/// twice — once per side — to produce both halves of the ripple list.
624fn scan_tree_ripple(
625    repo: &gix::Repository,
626    tree: &gix::Tree<'_>,
627    mem: &str,
628    affected: &HashSet<EntityId>,
629    side: &str,
630) -> HashMap<EntityId, Vec<IncomingRipple>> {
631    let mut out: HashMap<EntityId, Vec<IncomingRipple>> = HashMap::new();
632    let entries = match tree.traverse().breadthfirst.files() {
633        Ok(e) => e,
634        Err(_) => return out,
635    };
636    for entry in entries {
637        if !entry.mode.is_blob() {
638            continue;
639        }
640        let path = match std::str::from_utf8(entry.filepath.as_slice()) {
641            Ok(s) => s,
642            Err(_) => continue,
643        };
644        if !path.ends_with(".md") || path.starts_with(".memstead/") {
645            continue;
646        }
647        let referrer_id = file_path_to_id(path, mem);
648        // Don't list an entity as its own referrer — a wiki-link in
649        // the entity's own body pointing at itself is not "ripple".
650        let object = match repo.find_object(entry.oid) {
651            Ok(o) => o,
652            Err(_) => continue,
653        };
654        let blob = match object.try_into_blob() {
655            Ok(b) => b,
656            Err(_) => continue,
657        };
658        let content = match std::str::from_utf8(&blob.data) {
659            Ok(s) => s,
660            Err(_) => continue,
661        };
662        for target in extract_inline_links_lenient(content, mem) {
663            if target == referrer_id {
664                continue;
665            }
666            if affected.contains(&target) {
667                out.entry(target).or_default().push(IncomingRipple {
668                    from_id: referrer_id.clone(),
669                    side: side.to_string(),
670                    section: None,
671                });
672            }
673        }
674    }
675    out
676}
677
678/// Splice ripple lists into the entries. For each entry, the ripple
679/// payload combines the pre-state (`ref_a`) and post-state (`ref_b`)
680/// referrers. `Renamed` entries pull both halves: pre-state lookups
681/// key on `from_id`, post-state on `to_id`.
682fn attach_ripple(
683    entries: &mut [EntityDiff],
684    ripple_a: &HashMap<EntityId, Vec<IncomingRipple>>,
685    ripple_b: &HashMap<EntityId, Vec<IncomingRipple>>,
686) {
687    for entry in entries.iter_mut() {
688        match entry {
689            EntityDiff::Added { id, ripple, .. }
690            | EntityDiff::Modified { id, ripple, .. }
691            | EntityDiff::Deleted { id, ripple, .. } => {
692                if let Some(list) = ripple_a.get(id) {
693                    ripple.extend(list.iter().cloned());
694                }
695                if let Some(list) = ripple_b.get(id) {
696                    ripple.extend(list.iter().cloned());
697                }
698            }
699            EntityDiff::Renamed {
700                from_id,
701                to_id,
702                ripple,
703                ..
704            } => {
705                if let Some(list) = ripple_a.get(from_id) {
706                    ripple.extend(list.iter().cloned());
707                }
708                if let Some(list) = ripple_b.get(to_id) {
709                    ripple.extend(list.iter().cloned());
710                }
711            }
712            EntityDiff::InvalidEntity { .. } => {}
713        }
714    }
715}
716
717/// Sort key for the per-entry stable ordering. `Renamed` uses
718/// `to_id` (the surviving id); `InvalidEntity` carries `id`.
719fn primary_id(entry: &EntityDiff) -> String {
720    match entry {
721        EntityDiff::Added { id, .. }
722        | EntityDiff::Modified { id, .. }
723        | EntityDiff::Deleted { id, .. }
724        | EntityDiff::InvalidEntity { id, .. } => id.to_string(),
725        EntityDiff::Renamed { to_id, .. } => to_id.to_string(),
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use crate::storage::MemWriter;
733    use crate::storage::git_tree::GitTreeMemWriter;
734    use crate::vcs::CommitContext;
735    use std::path::PathBuf;
736    use tempfile::TempDir;
737
738    fn init_gitdir(tmp: &TempDir) -> PathBuf {
739        let gitdir = tmp.path().join("mem-repo").join(".git");
740        std::fs::create_dir_all(&gitdir).unwrap();
741        gix::init_bare(&gitdir).unwrap();
742        gitdir
743    }
744
745    fn body_with_title(title: &str) -> String {
746        // Per-title unique padding so gix's similarity-driven rename
747        // detection (50% default) does not pair unrelated test
748        // entities as a single Rewrite event. Plain `# {title}` bodies
749        // were too similar across entities; the repeated title token
750        // here pushes each body's hash far enough apart that gix sees
751        // distinct adds and deletes.
752        let unique = title.repeat(64);
753        format!(
754            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# {title}\n\n## Identity\n\n{unique}\n"
755        )
756    }
757
758    fn write_and_commit(
759        gitdir: &Path,
760        mem: &str,
761        entries: &[(&str, &str)],
762        subject: &str,
763    ) -> String {
764        let writer = GitTreeMemWriter::new(gitdir.to_path_buf(), format!("refs/heads/{mem}"));
765        for (path, content) in entries {
766            writer
767                .write_entity(Path::new(path), content.as_bytes())
768                .unwrap();
769        }
770        writer.commit(subject, &CommitContext::internal()).unwrap()
771    }
772
773    #[test]
774    fn diff_unknown_ref_returns_unknown_ref_marker() {
775        let tmp = TempDir::new().unwrap();
776        let gitdir = init_gitdir(&tmp);
777        let err = diff_two_refs(
778            &gitdir,
779            "specs",
780            "no-such-ref",
781            "no-such-other",
782            &DiffConfig::default(),
783        )
784        .unwrap_err();
785        match err {
786            BackendError::Other(msg) => {
787                assert!(
788                    msg.starts_with("UNKNOWN_REF:"),
789                    "expected UNKNOWN_REF marker, got: {msg}",
790                );
791            }
792            other => panic!("expected Other, got {other:?}"),
793        }
794    }
795
796    #[test]
797    fn normalise_rewrites_only_the_head_token() {
798        // #53: the HEAD base of a revspec re-anchors on the mem branch,
799        // suffix preserved.
800        assert_eq!(normalise_ref_for_mem("v", "HEAD"), "refs/heads/v");
801        assert_eq!(normalise_ref_for_mem("v", "HEAD~5"), "refs/heads/v~5");
802        assert_eq!(normalise_ref_for_mem("v", "HEAD^"), "refs/heads/v^");
803        assert_eq!(
804            normalise_ref_for_mem("v", "HEAD^{tree}"),
805            "refs/heads/v^{tree}"
806        );
807        assert_eq!(normalise_ref_for_mem("v", "HEAD@{1}"), "refs/heads/v@{1}");
808        // Refusal: a ref that merely starts with "HEAD" is left alone.
809        assert_eq!(normalise_ref_for_mem("v", "HEADER"), "HEADER");
810        assert_eq!(normalise_ref_for_mem("v", "HEAD-foo"), "HEAD-foo");
811        // Refusal: a plain branch / SHA passes through unchanged.
812        assert_eq!(normalise_ref_for_mem("v", "main"), "main");
813        assert_eq!(normalise_ref_for_mem("v", "deadbeef"), "deadbeef");
814    }
815
816    #[test]
817    fn diff_head_revspec_anchors_on_mem_branch() {
818        // #53: `HEAD~1` / `HEAD` resolve against `refs/heads/<mem>`, not
819        // the gitdir's symbolic HEAD (the dummy default branch, which has no
820        // commits here — before the fix these revspecs would refuse).
821        let tmp = TempDir::new().unwrap();
822        let gitdir = init_gitdir(&tmp);
823        write_and_commit(
824            &gitdir,
825            "specs",
826            &[("alpha.md", &body_with_title("Alpha"))],
827            "c1",
828        );
829        write_and_commit(
830            &gitdir,
831            "specs",
832            &[("beta.md", &body_with_title("Beta"))],
833            "c2 add beta",
834        );
835
836        let diff = diff_two_refs(&gitdir, "specs", "HEAD~1", "HEAD", &DiffConfig::default())
837            .expect("HEAD revspec must resolve against the mem branch");
838        assert_eq!(diff.entries.len(), 1, "only beta added between c1 and c2");
839        assert!(
840            matches!(diff.entries[0], EntityDiff::Added { .. }),
841            "the one change is beta added: {:?}",
842            diff.entries[0]
843        );
844    }
845
846    #[test]
847    fn diff_added_modified_deleted_surface() {
848        let tmp = TempDir::new().unwrap();
849        let gitdir = init_gitdir(&tmp);
850
851        // Ref A: alpha + beta with body B0.
852        write_and_commit(
853            &gitdir,
854            "specs",
855            &[
856                ("alpha.md", &body_with_title("Alpha")),
857                ("beta.md", &body_with_title("Beta-v0")),
858            ],
859            "seed",
860        );
861        let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
862
863        // Ref B: alpha unchanged, beta body changed, gamma added.
864        write_and_commit(
865            &gitdir,
866            "specs",
867            &[
868                ("beta.md", &body_with_title("Beta-v1")),
869                ("gamma.md", &body_with_title("Gamma")),
870            ],
871            "update beta + add gamma",
872        );
873        // Drop alpha in a third commit so it surfaces as a deletion.
874        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
875        writer.delete_entity(Path::new("alpha.md")).unwrap();
876        writer
877            .commit("drop alpha", &CommitContext::internal())
878            .unwrap();
879
880        let diff = diff_two_refs(
881            &gitdir,
882            "specs",
883            &sha_a,
884            "refs/heads/specs",
885            &DiffConfig::default(),
886        )
887        .unwrap();
888        assert_eq!(diff.entries.len(), 3, "Add+Modify+Delete expected");
889        let statuses: Vec<&str> = diff
890            .entries
891            .iter()
892            .map(|e| match e {
893                EntityDiff::Added { .. } => "added",
894                EntityDiff::Modified { .. } => "modified",
895                EntityDiff::Deleted { .. } => "deleted",
896                EntityDiff::Renamed { .. } => "renamed",
897                EntityDiff::InvalidEntity { .. } => "invalid",
898            })
899            .collect();
900        // Sorted by primary id: alpha (deleted), beta (modified), gamma (added).
901        assert_eq!(statuses, vec!["deleted", "modified", "added"]);
902
903        // Content enrichment defaults to on: both sides populated for
904        // the modified entry; one-sided for added/deleted.
905        let beta = diff
906            .entries
907            .iter()
908            .find(|e| matches!(e, EntityDiff::Modified { .. }))
909            .unwrap();
910        match beta {
911            EntityDiff::Modified {
912                content_before,
913                content_after,
914                ..
915            } => {
916                assert!(content_before.as_ref().unwrap().contains("Beta-v0"));
917                assert!(content_after.as_ref().unwrap().contains("Beta-v1"));
918            }
919            _ => unreachable!(),
920        }
921    }
922
923    #[test]
924    fn diff_include_content_false_strips_bodies() {
925        let tmp = TempDir::new().unwrap();
926        let gitdir = init_gitdir(&tmp);
927        write_and_commit(
928            &gitdir,
929            "specs",
930            &[("alpha.md", &body_with_title("Alpha"))],
931            "seed",
932        );
933        let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
934        write_and_commit(
935            &gitdir,
936            "specs",
937            &[("alpha.md", &body_with_title("Alpha-v2"))],
938            "rev2",
939        );
940
941        let cfg = DiffConfig {
942            include_content: false,
943            ..DiffConfig::default()
944        };
945        let diff = diff_two_refs(&gitdir, "specs", &sha_seed, "refs/heads/specs", &cfg).unwrap();
946        assert_eq!(diff.entries.len(), 1);
947        match &diff.entries[0] {
948            EntityDiff::Modified {
949                title,
950                entity_type,
951                content_before,
952                content_after,
953                ..
954            } => {
955                assert!(
956                    content_before.is_none(),
957                    "include_content=false elides before"
958                );
959                assert!(
960                    content_after.is_none(),
961                    "include_content=false elides after"
962                );
963                // Metadata is present regardless of the content toggle —
964                // the docstring's metadata-only shape promises it and a
965                // JSON consumer needs `title`/`entity_type` without
966                // parsing bodies. Sourced from the post-state (`ref_b`).
967                assert_eq!(
968                    title.as_deref(),
969                    Some("Alpha-v2"),
970                    "title present (from ref_b) even with include_content=false"
971                );
972                assert_eq!(
973                    entity_type.as_deref(),
974                    Some("spec"),
975                    "entity_type present even with include_content=false"
976                );
977            }
978            other => panic!("expected Modified, got {other:?}"),
979        }
980    }
981
982    /// With `include_content: true` the metadata fields are additive to
983    /// the body fields — `{id, title, entity_type, status}` plus
984    /// `content_before`/`content_after`, not a replacement. Also pins
985    /// the added-entry shape (post-state metadata from `ref_b`).
986    #[test]
987    fn diff_populates_title_and_type_with_content_on() {
988        let tmp = TempDir::new().unwrap();
989        let gitdir = init_gitdir(&tmp);
990        let sha_empty = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; // empty tree
991        write_and_commit(
992            &gitdir,
993            "specs",
994            &[("alpha.md", &body_with_title("Alpha"))],
995            "seed",
996        );
997
998        let cfg = DiffConfig {
999            include_content: true,
1000            ..DiffConfig::default()
1001        };
1002        let diff = diff_two_refs(&gitdir, "specs", sha_empty, "refs/heads/specs", &cfg).unwrap();
1003        assert_eq!(diff.entries.len(), 1);
1004        match &diff.entries[0] {
1005            EntityDiff::Added {
1006                title,
1007                entity_type,
1008                content_after,
1009                ..
1010            } => {
1011                assert_eq!(title.as_deref(), Some("Alpha"), "title populated on Added");
1012                assert_eq!(
1013                    entity_type.as_deref(),
1014                    Some("spec"),
1015                    "entity_type populated"
1016                );
1017                assert!(
1018                    content_after.is_some(),
1019                    "content_after present and additive to the metadata fields"
1020                );
1021            }
1022            other => panic!("expected Added, got {other:?}"),
1023        }
1024    }
1025
1026    #[test]
1027    fn diff_rename_chain_collapses_multi_step_engine_authored_renames() {
1028        // Seed alpha, rename to beta via an engine-style commit, then
1029        // rename beta to gamma. Diffing seed → head should surface a
1030        // single `Renamed { from: alpha, to: gamma, chain: [beta] }`
1031        // rather than a chain of intermediate edits.
1032        let tmp = TempDir::new().unwrap();
1033        let gitdir = init_gitdir(&tmp);
1034
1035        let body = body_with_title("Title");
1036        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1037        writer
1038            .write_entity(Path::new("alpha.md"), body.as_bytes())
1039            .unwrap();
1040        writer.commit("seed", &CommitContext::internal()).unwrap();
1041        let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1042
1043        // alpha → beta. Move the file (delete + write) and emit the
1044        // commit subject the engine uses on its rename pipeline.
1045        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1046        writer.delete_entity(Path::new("alpha.md")).unwrap();
1047        writer
1048            .write_entity(Path::new("beta.md"), body.as_bytes())
1049            .unwrap();
1050        writer
1051            .commit(
1052                "memstead: rename specs--alpha → specs--beta",
1053                &CommitContext::internal(),
1054            )
1055            .unwrap();
1056
1057        // beta → gamma. Same shape.
1058        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1059        writer.delete_entity(Path::new("beta.md")).unwrap();
1060        writer
1061            .write_entity(Path::new("gamma.md"), body.as_bytes())
1062            .unwrap();
1063        writer
1064            .commit(
1065                "memstead: rename specs--beta → specs--gamma",
1066                &CommitContext::internal(),
1067            )
1068            .unwrap();
1069
1070        let diff = diff_two_refs(
1071            &gitdir,
1072            "specs",
1073            &sha_seed,
1074            "refs/heads/specs",
1075            &DiffConfig::default(),
1076        )
1077        .unwrap();
1078
1079        let renamed = diff
1080            .entries
1081            .iter()
1082            .find(|e| matches!(e, EntityDiff::Renamed { .. }))
1083            .expect("a Renamed entry must surface");
1084        match renamed {
1085            EntityDiff::Renamed {
1086                from_id,
1087                to_id,
1088                rename_chain,
1089                ..
1090            } => {
1091                assert_eq!(from_id.to_string(), "specs--alpha");
1092                assert_eq!(to_id.to_string(), "specs--gamma");
1093                assert_eq!(
1094                    rename_chain
1095                        .iter()
1096                        .map(|id| id.to_string())
1097                        .collect::<Vec<_>>(),
1098                    vec!["specs--beta".to_string()],
1099                    "the multi-step rename's intermediate id must surface in rename_chain",
1100                );
1101            }
1102            _ => unreachable!(),
1103        }
1104        // No leftover Added/Deleted entries for the chain endpoints.
1105        let leftover: Vec<_> = diff
1106            .entries
1107            .iter()
1108            .filter(|e| matches!(e, EntityDiff::Added { .. } | EntityDiff::Deleted { .. }))
1109            .collect();
1110        assert!(
1111            leftover.is_empty(),
1112            "agent-notes promotion must absorb the Added+Deleted pair, got: {leftover:?}",
1113        );
1114    }
1115
1116    #[test]
1117    fn diff_ripple_lists_incoming_wikilinks_on_each_side() {
1118        // Build a mem with three entities: alpha, beta, gamma.
1119        // beta links to alpha on ref_a; gamma links to alpha on ref_b.
1120        // Modify alpha between the two refs. The diff entry for
1121        // alpha should surface both referrers in its ripple list,
1122        // each tagged with the right side.
1123        let tmp = TempDir::new().unwrap();
1124        let gitdir = init_gitdir(&tmp);
1125
1126        let alpha_v1 = body_with_title("Alpha-v1");
1127        let beta_links_alpha = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Beta\n\n## Identity\n\nLinks to [[specs--alpha]].\n".to_string();
1128        write_and_commit(
1129            &gitdir,
1130            "specs",
1131            &[("alpha.md", &alpha_v1), ("beta.md", &beta_links_alpha)],
1132            "seed",
1133        );
1134        let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1135
1136        let alpha_v2 = body_with_title("Alpha-v2");
1137        let gamma_links_alpha = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Gamma\n\n## Identity\n\nLinks to [[specs--alpha]].\n".to_string();
1138        // Drop beta to break its outbound link on ref_b. Add gamma
1139        // with a fresh inbound link to alpha.
1140        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1141        writer
1142            .write_entity(Path::new("alpha.md"), alpha_v2.as_bytes())
1143            .unwrap();
1144        writer
1145            .write_entity(Path::new("gamma.md"), gamma_links_alpha.as_bytes())
1146            .unwrap();
1147        writer.delete_entity(Path::new("beta.md")).unwrap();
1148        writer.commit("rev2", &CommitContext::internal()).unwrap();
1149
1150        let diff = diff_two_refs(
1151            &gitdir,
1152            "specs",
1153            &sha_a,
1154            "refs/heads/specs",
1155            &DiffConfig::default(),
1156        )
1157        .unwrap();
1158
1159        // Find the entry for alpha; both ripple sides must surface.
1160        let alpha = diff
1161            .entries
1162            .iter()
1163            .find(|e| matches!(e, EntityDiff::Modified { id, .. } if id.to_string() == "specs--alpha"))
1164            .expect("alpha must show as modified");
1165        match alpha {
1166            EntityDiff::Modified { ripple, .. } => {
1167                let mut sides_seen: Vec<String> = ripple
1168                    .iter()
1169                    .map(|r| format!("{}@{}", r.from_id, r.side))
1170                    .collect();
1171                sides_seen.sort();
1172                assert_eq!(
1173                    sides_seen,
1174                    vec![
1175                        "specs--beta@ref_a".to_string(),
1176                        "specs--gamma@ref_b".to_string(),
1177                    ],
1178                    "ripple must list beta on ref_a and gamma on ref_b",
1179                );
1180            }
1181            _ => unreachable!(),
1182        }
1183    }
1184
1185    #[test]
1186    fn diff_include_ripple_false_leaves_ripple_empty() {
1187        let tmp = TempDir::new().unwrap();
1188        let gitdir = init_gitdir(&tmp);
1189        let beta_links_alpha = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Beta\n\n## Identity\n\n[[specs--alpha]]\n";
1190        write_and_commit(
1191            &gitdir,
1192            "specs",
1193            &[
1194                ("alpha.md", &body_with_title("Alpha-v1")),
1195                ("beta.md", beta_links_alpha),
1196            ],
1197            "seed",
1198        );
1199        let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1200        write_and_commit(
1201            &gitdir,
1202            "specs",
1203            &[("alpha.md", &body_with_title("Alpha-v2"))],
1204            "rev2",
1205        );
1206
1207        let cfg = DiffConfig {
1208            include_ripple: false,
1209            ..DiffConfig::default()
1210        };
1211        let diff = diff_two_refs(&gitdir, "specs", &sha_a, "refs/heads/specs", &cfg).unwrap();
1212        for entry in &diff.entries {
1213            let ripple = match entry {
1214                EntityDiff::Added { ripple, .. }
1215                | EntityDiff::Modified { ripple, .. }
1216                | EntityDiff::Deleted { ripple, .. }
1217                | EntityDiff::Renamed { ripple, .. } => ripple.clone(),
1218                EntityDiff::InvalidEntity { .. } => Vec::new(),
1219            };
1220            assert!(
1221                ripple.is_empty(),
1222                "include_ripple=false must produce empty ripple lists: {entry:?}",
1223            );
1224        }
1225    }
1226
1227    #[test]
1228    fn diff_invalid_entity_surfaces_for_missing_frontmatter() {
1229        // An entity whose markdown body has no frontmatter block
1230        // demotes to `InvalidEntity` instead of `Modified` / `Added`.
1231        let tmp = TempDir::new().unwrap();
1232        let gitdir = init_gitdir(&tmp);
1233        write_and_commit(
1234            &gitdir,
1235            "specs",
1236            &[("alpha.md", &body_with_title("Alpha-v1"))],
1237            "seed",
1238        );
1239        let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1240        // Overwrite alpha with a body that has no frontmatter.
1241        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1242        writer
1243            .write_entity(
1244                Path::new("alpha.md"),
1245                b"# Alpha but no frontmatter\n\nbody.\n",
1246            )
1247            .unwrap();
1248        writer.commit("break", &CommitContext::internal()).unwrap();
1249
1250        let diff = diff_two_refs(
1251            &gitdir,
1252            "specs",
1253            &sha_a,
1254            "refs/heads/specs",
1255            &DiffConfig::default(),
1256        )
1257        .unwrap();
1258        let alpha = diff.entries.first().expect("alpha should appear");
1259        match alpha {
1260            EntityDiff::InvalidEntity {
1261                id, side, error, ..
1262            } => {
1263                assert_eq!(id.to_string(), "specs--alpha");
1264                assert_eq!(side, "ref_b");
1265                assert!(error.contains("frontmatter"), "unexpected error: {error}");
1266            }
1267            other => panic!("expected InvalidEntity, got {other:?}"),
1268        }
1269    }
1270
1271    #[test]
1272    fn engine_diff_routes_git_branch_mount_through_hook() {
1273        // End-to-end: build an engine with a git-branch mount that
1274        // points at our seeded gitdir, install the full ops bundle so
1275        // the engine's `diff` dispatcher reaches our `diff_two_refs`
1276        // implementation, and assert the returned `Diff` is the same
1277        // one calling the function directly would produce.
1278        let tmp = TempDir::new().unwrap();
1279        let gitdir = init_gitdir(&tmp);
1280        write_and_commit(
1281            &gitdir,
1282            "specs",
1283            &[("alpha.md", &body_with_title("Alpha"))],
1284            "seed",
1285        );
1286        let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1287        write_and_commit(
1288            &gitdir,
1289            "specs",
1290            &[("alpha.md", &body_with_title("Alpha-v2"))],
1291            "rev2",
1292        );
1293
1294        let mount = memstead_base::Mount {
1295            mem: "specs".to_string(),
1296            schema: Some(memstead_schema::SchemaRef::new(
1297                "default",
1298                semver::Version::new(1, 0, 0),
1299            )),
1300            storage: memstead_base::MountStorage::GitBranch {
1301                gitdir: gitdir.clone(),
1302                branch: "specs".to_string(),
1303            },
1304            capability: memstead_base::MountCapability::Write,
1305            lifecycle: memstead_base::MountLifecycle::Eager,
1306            cross_linkable: true,
1307            migration_target: None,
1308        };
1309        let backend = crate::storage::instantiate_full_backend(&mount).unwrap();
1310        let mut engine = memstead_base::Engine::from_mounts(vec![(mount, backend)]).unwrap();
1311        engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
1312
1313        let diff = engine
1314            .diff("specs", &sha_seed, "refs/heads/specs", None)
1315            .unwrap();
1316        assert_eq!(diff.entries.len(), 1);
1317        assert!(matches!(diff.entries[0], EntityDiff::Modified { .. }));
1318        assert_eq!(diff.resolved_a_sha, sha_seed);
1319    }
1320
1321    #[test]
1322    fn engine_diff_unknown_ref_surfaces_typed_engine_error() {
1323        let tmp = TempDir::new().unwrap();
1324        let gitdir = init_gitdir(&tmp);
1325        write_and_commit(
1326            &gitdir,
1327            "specs",
1328            &[("alpha.md", &body_with_title("Alpha"))],
1329            "seed",
1330        );
1331
1332        let mount = memstead_base::Mount {
1333            mem: "specs".to_string(),
1334            schema: Some(memstead_schema::SchemaRef::new(
1335                "default",
1336                semver::Version::new(1, 0, 0),
1337            )),
1338            storage: memstead_base::MountStorage::GitBranch {
1339                gitdir: gitdir.clone(),
1340                branch: "specs".to_string(),
1341            },
1342            capability: memstead_base::MountCapability::Write,
1343            lifecycle: memstead_base::MountLifecycle::Eager,
1344            cross_linkable: true,
1345            migration_target: None,
1346        };
1347        let backend = crate::storage::instantiate_full_backend(&mount).unwrap();
1348        let mut engine = memstead_base::Engine::from_mounts(vec![(mount, backend)]).unwrap();
1349        engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
1350
1351        let err = engine.diff("specs", "nope-a", "nope-b", None).unwrap_err();
1352        match err {
1353            memstead_base::EngineError::UnknownRef(raw) => {
1354                assert!(raw.contains("nope"), "unexpected UnknownRef payload: {raw}");
1355            }
1356            other => panic!("expected UnknownRef, got {other:?}"),
1357        }
1358    }
1359
1360    #[test]
1361    fn diff_resolves_refs_to_sha_in_response() {
1362        let tmp = TempDir::new().unwrap();
1363        let gitdir = init_gitdir(&tmp);
1364        write_and_commit(
1365            &gitdir,
1366            "specs",
1367            &[("alpha.md", &body_with_title("Alpha"))],
1368            "seed",
1369        );
1370        let sha = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1371
1372        let diff = diff_two_refs(
1373            &gitdir,
1374            "specs",
1375            "refs/heads/specs",
1376            "refs/heads/specs",
1377            &DiffConfig::default(),
1378        )
1379        .unwrap();
1380        assert_eq!(diff.resolved_a_sha, sha);
1381        assert_eq!(diff.resolved_b_sha, sha);
1382        // Same ref vs. same ref → no entries.
1383        assert!(diff.entries.is_empty());
1384    }
1385
1386    /// The canonical empty-tree SHA is accepted as
1387    /// `ref_a` and short-circuits to the empty tree, so a first-sync
1388    /// diff lists every entity in the mem as `added`.
1389    /// `resolved_a_sha` echoes the sentinel verbatim.
1390    #[test]
1391    fn diff_accepts_empty_tree_sentinel_for_ref_a() {
1392        let tmp = TempDir::new().unwrap();
1393        let gitdir = init_gitdir(&tmp);
1394        write_and_commit(
1395            &gitdir,
1396            "specs",
1397            &[
1398                ("alpha.md", &body_with_title("Alpha")),
1399                ("beta.md", &body_with_title("Beta")),
1400            ],
1401            "seed",
1402        );
1403
1404        let diff = diff_two_refs(
1405            &gitdir,
1406            "specs",
1407            EMPTY_TREE_SHA,
1408            "refs/heads/specs",
1409            &DiffConfig::default(),
1410        )
1411        .expect("empty-tree sentinel must be accepted");
1412        assert_eq!(diff.resolved_a_sha, EMPTY_TREE_SHA);
1413        assert_eq!(diff.entries.len(), 2, "first-sync lists every entity");
1414        for entry in &diff.entries {
1415            assert!(
1416                matches!(entry, EntityDiff::Added { .. }),
1417                "first-sync entries must all be `added`, got: {entry:?}",
1418            );
1419        }
1420    }
1421
1422    /// A real tree-only SHA that
1423    /// is NOT the canonical empty-tree sentinel continues to refuse
1424    /// with `UNKNOWN_REF`. The sentinel handling is keyed on the
1425    /// literal hash, not on "is it a tree".
1426    #[test]
1427    fn diff_refuses_arbitrary_tree_sha_with_unknown_ref() {
1428        let tmp = TempDir::new().unwrap();
1429        let gitdir = init_gitdir(&tmp);
1430        write_and_commit(
1431            &gitdir,
1432            "specs",
1433            &[("alpha.md", &body_with_title("Alpha"))],
1434            "seed",
1435        );
1436        // Find a real tree SHA (the seed commit's tree) — that SHA
1437        // is a tree, not a commit, and isn't the canonical empty
1438        // tree, so resolve_tree's "is it a commit" gate refuses it.
1439        let repo = gix::open(&gitdir).unwrap();
1440        let head = repo.rev_parse_single("refs/heads/specs").unwrap();
1441        let head_commit = head.object().unwrap().try_into_commit().unwrap();
1442        let tree_sha = head_commit.tree().unwrap().id.to_string();
1443        assert_ne!(
1444            tree_sha, EMPTY_TREE_SHA,
1445            "tree SHA must differ from sentinel for this test to be meaningful"
1446        );
1447
1448        let err = diff_two_refs(
1449            &gitdir,
1450            "specs",
1451            &tree_sha,
1452            "refs/heads/specs",
1453            &DiffConfig::default(),
1454        )
1455        .unwrap_err();
1456        match err {
1457            BackendError::Other(msg) => assert!(
1458                msg.starts_with("UNKNOWN_REF:"),
1459                "expected UNKNOWN_REF marker, got: {msg}",
1460            ),
1461            other => panic!("expected Other, got {other:?}"),
1462        }
1463    }
1464
1465    /// Bare `HEAD` substitutes to `refs/heads/<mem>`
1466    /// so the diff targets the mem's branch tip, not the gitdir's
1467    /// symbolic HEAD on a dummy default branch. Compares behaviour
1468    /// against the explicit `refs/heads/<mem>` form — both calls
1469    /// produce the same `resolved_b_sha` and same entries.
1470    #[test]
1471    fn diff_resolves_bare_head_to_mem_branch_tip() {
1472        let tmp = TempDir::new().unwrap();
1473        let gitdir = init_gitdir(&tmp);
1474        // Seed a commit on the mem branch.
1475        write_and_commit(
1476            &gitdir,
1477            "specs",
1478            &[("alpha.md", &body_with_title("Alpha"))],
1479            "seed",
1480        );
1481        let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1482
1483        // Advance the mem branch.
1484        write_and_commit(
1485            &gitdir,
1486            "specs",
1487            &[("beta.md", &body_with_title("Beta"))],
1488            "add beta",
1489        );
1490
1491        // The gitdir's symbolic HEAD points at `refs/heads/main` (an
1492        // unrelated default branch with no commits) by default —
1493        // resolving bare HEAD literally would either refuse or hit
1494        // the wrong branch. The substitution targets
1495        // `refs/heads/<mem>` per the mem selector.
1496        let via_head = diff_two_refs(&gitdir, "specs", &sha_a, "HEAD", &DiffConfig::default())
1497            .expect("bare HEAD must resolve via mem substitution");
1498        let via_explicit = diff_two_refs(
1499            &gitdir,
1500            "specs",
1501            &sha_a,
1502            "refs/heads/specs",
1503            &DiffConfig::default(),
1504        )
1505        .expect("explicit refs/heads/<mem> still works");
1506
1507        // Both calls land on the same commit and produce the same
1508        // entry set — the bare-HEAD substitution is structurally
1509        // equivalent to the explicit form.
1510        assert_eq!(via_head.resolved_b_sha, via_explicit.resolved_b_sha);
1511        assert_eq!(via_head.entries.len(), via_explicit.entries.len());
1512    }
1513
1514    /// First-sync diff against
1515    /// the mem-branch HEAD using only the canonical sentinel and
1516    /// bare `HEAD`. Collapses to one call (no need to look up the
1517    /// explicit ref names).
1518    #[test]
1519    fn diff_empty_tree_sentinel_and_bare_head_compose() {
1520        let tmp = TempDir::new().unwrap();
1521        let gitdir = init_gitdir(&tmp);
1522        write_and_commit(
1523            &gitdir,
1524            "specs",
1525            &[
1526                ("alpha.md", &body_with_title("Alpha")),
1527                ("beta.md", &body_with_title("Beta")),
1528            ],
1529            "seed",
1530        );
1531        let diff = diff_two_refs(
1532            &gitdir,
1533            "specs",
1534            EMPTY_TREE_SHA,
1535            "HEAD",
1536            &DiffConfig::default(),
1537        )
1538        .expect("sentinel + HEAD must compose");
1539        assert_eq!(diff.resolved_a_sha, EMPTY_TREE_SHA);
1540        assert_eq!(diff.entries.len(), 2);
1541        assert!(
1542            diff.entries
1543                .iter()
1544                .all(|e| matches!(e, EntityDiff::Added { .. })),
1545            "every entity surfaces as added on first-sync diff"
1546        );
1547    }
1548}