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