Skip to main content

memstead_git_branch/ops/
changes.rs

1//! `memstead_changes_since` — two-tree diff between a caller-provided commit
2//! SHA and the mem's current HEAD, with rename detection tunable via
3//! `rename_similarity` (default 60%).
4//!
5//! Agents remember the `commit_sha` returned by every mutation and feed it
6//! back through this op to pick up incremental deltas without re-scanning
7//! the whole mem. The response is a flat list of [`ChangeEnvelope`]s —
8//! one per touched entity — with renames surfaced as a single event rather
9//! than a removed + added pair (at the selected similarity threshold).
10//!
11//! "Diff against nothing" sentinel: callers with no prior SHA pass the
12//! canonical git empty-tree hash (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`).
13//! The diff then treats HEAD as entirely new content.
14//!
15//! Non-entity paths (e.g. `.memstead/config.json`, schema files) are
16//! filtered out: the surface is entity-level. Only `.md` files outside
17//! `.memstead/` ever produce envelopes.
18
19use std::collections::HashMap;
20use std::path::Path;
21
22use serde::Serialize;
23
24use crate::entity::EntityId;
25use crate::entity::id::file_path_to_id;
26use crate::ops::WarningHint;
27use crate::ops::agent_notes::CommitNote;
28use crate::store::Store;
29use crate::vcs::VcsError;
30
31// `ChangeEnvelope`, `EMPTY_TREE_SHA`, and `RENAME_SIMILARITY_*` are
32// re-exports of the lifted-to-`memstead-base` originals. Existing
33// downstream consumers (MCP server, CLI, tests, the macOS UniFFI
34// path) keep their `memstead_git_branch::ChangeEnvelope` import untouched
35// — the type is the same one `memstead-base` defines, just reachable
36// from both crates.
37pub use memstead_base::ops::{
38    BackendChanges, ChangeEnvelope, EMPTY_TREE_SHA, RENAME_SIMILARITY_DEFAULT,
39    RENAME_SIMILARITY_MAX, RENAME_SIMILARITY_MIN,
40};
41
42/// Flat diff between `since` and HEAD for one mem. `head` echoes the
43/// resolved HEAD commit SHA so agents can remember it as the next
44/// polling cursor — saves a round-trip to `memstead_health`. `warnings`
45/// carries typed `{code, message, details}` envelopes (e.g.
46/// `LIMIT_CLAMPED` when the caller's `rename_similarity` was out of
47/// range); omitted from the wire when empty.
48///
49/// `notes` and `memstead_ref` are populated only when the caller requests
50/// `include_notes` (CLI `--include-notes`, MCP `include_notes: true`).
51/// They piggyback on the same response so the auto-commit outer-repo
52/// cursor flow gets entity-deltas + per-commit agent-notes + the
53/// `__MEMSTEAD` ref tip in one round-trip.
54#[derive(Debug, Clone, Serialize)]
55pub struct ChangesReport {
56    pub mem: String,
57    pub since: String,
58    pub head: String,
59    pub changes: Vec<ChangeEnvelope>,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub warnings: Vec<WarningHint>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub notes: Option<Vec<crate::ops::agent_notes::CommitNote>>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub memstead_ref: Option<String>,
66}
67
68/// Compute [`ChangesReport`] for `mem_name`, whose gix repository lives
69/// at `git_dir`. `since` is any gix-resolvable commit spec (commit SHA,
70/// ref name, `HEAD~1`, …) plus the empty-tree sentinel.
71///
72/// Error policy:
73/// - Unknown `since` ref → [`VcsError::ObjectNotFound`].
74/// - Empty repository (no HEAD) + non-sentinel `since` → same.
75/// - Empty repository + sentinel `since` → empty report (`head` echoes
76///   the sentinel). Matches the "nothing committed yet, nothing to diff"
77///   contract so fresh clients don't crash on a brand-new mem.
78pub fn changes_since(
79    store: &Store,
80    mem_name: &str,
81    git_dir: &Path,
82    since: &str,
83    rename_similarity: f32,
84    head_ref: Option<&str>,
85) -> Result<ChangesReport, VcsError> {
86    let repo = gix::open(git_dir)?;
87
88    // #53: anchor a `HEAD`-based `since` revspec on the mem branch so
89    // `HEAD~5` / `^` resolve against the per-mem branch tip, not the
90    // gitdir's symbolic HEAD (the dummy default branch). Only for the
91    // mem-repo backend (signalled by `head_ref` being `Some`); disk-
92    // backed mems keep gitdir-HEAD semantics. The original `since` is
93    // echoed in the response and error messages.
94    let resolve_since = match head_ref {
95        Some(_) => crate::ops::diff::normalise_ref_for_mem(mem_name, since),
96        None => since.to_string(),
97    };
98
99    // Resolve the head tip. For disk-backed mems `head_ref` is `None`
100    // and we read the symbolic HEAD; for mem-repo-backed mems the
101    // engine passes `refs/heads/<mem>` so we walk the per-mem branch
102    // tip the writer commits to. If the repo has no matching commit yet
103    // (fresh repo, fresh ref) the head tree is the empty tree — fresh
104    // clients sync against a fresh mem via the sentinel without first
105    // forcing a mutation.
106    let head_lookup: Result<gix::Commit<'_>, ()> = match head_ref {
107        Some(ref_name) => repo
108            .rev_parse_single(ref_name)
109            .ok()
110            .and_then(|id| id.object().ok())
111            .and_then(|obj| obj.try_into_commit().ok())
112            .ok_or(()),
113        None => repo.head_commit().map_err(|_| ()),
114    };
115    let (head_sha, head_tree) = match head_lookup {
116        Ok(c) => {
117            let sha = c.id.to_hex().to_string();
118            let tree = c
119                .tree()
120                .map_err(|e| VcsError::Git(format!("head tree: {e}")))?;
121            (sha, tree)
122        }
123        Err(()) => (EMPTY_TREE_SHA.to_string(), repo.empty_tree()),
124    };
125
126    // Walk the commit range (since, head] via `agent_notes_since` to
127    // collect the authoritative rename map and per-commit notes.
128    // The engine's own provenance is the deterministic source for
129    // rename pairing — relying on gix's content-similarity scorer alone
130    // produces false-positive pairings over wide cursor windows. The
131    // walk runs unconditionally; the resulting notes
132    // and `__MEMSTEAD` ref ride on `BackendChanges` so MCP `include_notes`
133    // becomes a renderer-side filter, not an engine-side trigger.
134    let notes_report =
135        crate::ops::agent_notes::agent_notes_since(mem_name, git_dir, &resolve_since, head_ref)?;
136    let rename_map = build_authoritative_rename_map(&notes_report.notes);
137
138    // Resolve `since`. The canonical empty-tree SHA bypasses rev_parse —
139    // git itself special-cases that hash, and gix may not have the tree
140    // object physically present in a fresh odb.
141    let since_tree = if resolve_since == EMPTY_TREE_SHA {
142        repo.empty_tree()
143    } else {
144        let id = repo
145            .rev_parse_single(resolve_since.as_str())
146            .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
147        let object = id
148            .object()
149            .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
150        let commit = object
151            .try_into_commit()
152            .map_err(|_| VcsError::ObjectNotFound(format!("{since} is not a commit")))?;
153        commit
154            .tree()
155            .map_err(|e| VcsError::Git(format!("since tree: {e}")))?
156    };
157
158    // Short-circuit identical trees. Skips the diff pipeline entirely
159    // for the common "poll with no changes" case. Notes + memstead_ref
160    // still ride along — a poll with no diff but with intermediate
161    // commits (e.g. all rewrites cancel) still wants the agent-note
162    // feed for provenance reconstruction.
163    if since_tree.id == head_tree.id {
164        return Ok(ChangesReport {
165            mem: mem_name.to_string(),
166            since: since.to_string(),
167            head: head_sha,
168            changes: Vec::new(),
169            warnings: Vec::new(),
170            notes: Some(notes_report.notes),
171            memstead_ref: notes_report.memstead_ref,
172        });
173    }
174
175    // Two-tree diff with 60% rename similarity. We walk deletions +
176    // additions and let gix pair them up via content-similarity into
177    // `Rewrite` events as the fallback for renames the engine did
178    // not author (external `git mv`, pre-provenance migrations).
179    let mut platform = since_tree
180        .changes()
181        .map_err(|e| VcsError::Git(format!("diff init: {e}")))?;
182    let rewrites = gix::diff::Rewrites {
183        copies: None,
184        percentage: Some(rename_similarity),
185        limit: 1000,
186        track_empty: false,
187    };
188    platform.options(|opts| {
189        opts.track_rewrites(Some(rewrites));
190    });
191
192    // Collect raw diff events first; commit-note-driven rename collapse
193    // runs over them in a second pass so authoritative pairs win over
194    // gix-similarity coincidences.
195    let mut additions: Vec<EntityId> = Vec::new();
196    let mut deletions: Vec<EntityId> = Vec::new();
197    let mut modifications: Vec<EntityId> = Vec::new();
198    let mut gix_rewrites: Vec<(EntityId, EntityId)> = Vec::new();
199    platform
200        .for_each_to_obtain_tree(
201            &head_tree,
202            |change| -> Result<std::ops::ControlFlow<()>, std::convert::Infallible> {
203                use gix::object::tree::diff::Change;
204                match change {
205                    Change::Addition { location, .. } => {
206                        if let Some(id) = path_to_entity_id(mem_name, location) {
207                            additions.push(id);
208                        }
209                    }
210                    Change::Deletion { location, .. } => {
211                        if let Some(id) = path_to_entity_id(mem_name, location) {
212                            deletions.push(id);
213                        }
214                    }
215                    Change::Modification { location, .. } => {
216                        if let Some(id) = path_to_entity_id(mem_name, location) {
217                            modifications.push(id);
218                        }
219                    }
220                    Change::Rewrite {
221                        source_location,
222                        location,
223                        ..
224                    } => {
225                        let from = path_to_entity_id(mem_name, source_location);
226                        let to = path_to_entity_id(mem_name, location);
227                        if let (Some(from_id), Some(to_id)) = (from, to) {
228                            gix_rewrites.push((from_id, to_id));
229                        }
230                    }
231                }
232                Ok(std::ops::ControlFlow::Continue(()))
233            },
234        )
235        .map_err(|e| VcsError::Git(format!("diff: {e}")))?;
236
237    let envelopes = combine_with_rename_map(
238        store,
239        rename_map,
240        additions,
241        deletions,
242        modifications,
243        gix_rewrites,
244    );
245
246    Ok(ChangesReport {
247        mem: mem_name.to_string(),
248        since: since.to_string(),
249        head: head_sha,
250        changes: envelopes,
251        warnings: Vec::new(),
252        notes: Some(notes_report.notes),
253        memstead_ref: notes_report.memstead_ref,
254    })
255}
256
257/// Build the authoritative `old_id → new_id` rename map by walking
258/// commit notes from oldest to newest and composing transitive
259/// rename chains in place.
260///
261/// `agent_notes_since` returns notes newest-first (git-log order); we
262/// reverse to chronological order so a follow-up rename `B → C` after
263/// a prior `A → B` collapses to a single `A → C` entry rather than
264/// the (logically equivalent but harder to consume) `A → B` plus
265/// `B → C` pair.
266///
267/// Cross-mem peer commits — subject `memstead: rename A → B (cross-mem
268/// rewrite in V)` — are filtered out. They modify wiki-link bodies in
269/// the peer mem but never add or remove A or B there, so the local
270/// diff would never match them; including them is harmless but
271/// confusing for downstream readers of the map.
272fn build_authoritative_rename_map(notes: &[CommitNote]) -> HashMap<EntityId, EntityId> {
273    let mut forward: HashMap<EntityId, EntityId> = HashMap::new();
274    let mut reverse: HashMap<EntityId, EntityId> = HashMap::new();
275    for note in notes.iter().rev() {
276        if note.tool_verb.as_deref() != Some("rename") {
277            continue;
278        }
279        let Some(id_str) = note.entity_id.as_deref() else {
280            continue;
281        };
282        let Some((old_id, new_id)) = parse_rename_entity_field(id_str) else {
283            continue;
284        };
285        // Transitive collapse: if `old_id` is the target of an earlier
286        // rename, replay the chain so the map ends up keyed on the
287        // original source.
288        let origin = reverse.remove(&old_id).unwrap_or_else(|| old_id.clone());
289        if origin != old_id {
290            forward.remove(&origin);
291        }
292        forward.insert(origin.clone(), new_id.clone());
293        reverse.insert(new_id, origin);
294    }
295    forward
296}
297
298/// Split the `entity_id` field of a parsed rename commit subject into
299/// `(old_id, new_id)`. Returns `None` for cross-mem peer rewrites
300/// (parenthetical qualifier) and malformed entries.
301pub(super) fn parse_rename_entity_field(field: &str) -> Option<(EntityId, EntityId)> {
302    if field.contains("(cross-mem rewrite") {
303        return None;
304    }
305    let mut parts = field.splitn(2, " → ");
306    let old = parts.next()?.trim();
307    let new = parts.next()?.trim();
308    if old.is_empty() || new.is_empty() {
309        return None;
310    }
311    Some((EntityId(old.to_string()), EntityId(new.to_string())))
312}
313
314/// Combine raw diff events with the authoritative rename map into the
315/// final [`ChangeEnvelope`] list. Pairs `Addition(new) + Deletion(old)`
316/// driven by the note map first; falls back to gix-similarity
317/// `Rewrite` events for renames the engine did not author; emits
318/// remaining additions/deletions/modifications as-is.
319///
320/// Rename-then-delete edge case: a note records `A → B` (transitively
321/// `A → C`) but the new id is also deleted in the same window — the
322/// gix diff sees only `Deletion(A)`. The map's `A → ?` lookup finds
323/// no matching addition; the entity at A is gone; emit
324/// `Removed { id: A }` — the rename was undone before the cursor saw it.
325fn combine_with_rename_map(
326    store: &Store,
327    rename_map: HashMap<EntityId, EntityId>,
328    additions: Vec<EntityId>,
329    deletions: Vec<EntityId>,
330    modifications: Vec<EntityId>,
331    gix_rewrites: Vec<(EntityId, EntityId)>,
332) -> Vec<ChangeEnvelope> {
333    use std::collections::HashSet;
334    let addition_set: HashSet<&EntityId> = additions.iter().collect();
335    let deletion_set: HashSet<&EntityId> = deletions.iter().collect();
336
337    let mut envelopes: Vec<ChangeEnvelope> = Vec::new();
338    let mut absorbed_add: HashSet<EntityId> = HashSet::new();
339    let mut absorbed_del: HashSet<EntityId> = HashSet::new();
340
341    // Authoritative renames first — the engine's own provenance wins.
342    for (old_id, new_id) in &rename_map {
343        let has_old_del = deletion_set.contains(old_id);
344        let has_new_add = addition_set.contains(new_id);
345        if has_old_del && has_new_add {
346            envelopes.push(ChangeEnvelope::Renamed {
347                from_id: old_id.clone(),
348                to_id: new_id.clone(),
349                title: title_for(store, new_id),
350                entity_type: type_for(store, new_id),
351            });
352            absorbed_add.insert(new_id.clone());
353            absorbed_del.insert(old_id.clone());
354        } else if has_old_del {
355            // Rename-then-delete: the entity at old_id is gone; the
356            // intermediate new id never lands. Emit Removed.
357            envelopes.push(ChangeEnvelope::Removed {
358                id: old_id.clone(),
359                title: None,
360                entity_type: None,
361            });
362            absorbed_del.insert(old_id.clone());
363        }
364        // Addition without matching Deletion: the new id appeared
365        // without the old id being removed — would mean a re-create
366        // at the new id outside the engine's rename path. Leave the
367        // addition unabsorbed; it falls through as Added.
368    }
369
370    // gix-similarity fallback for renames the engine did not author.
371    // Skip pairs the authoritative map already absorbed; otherwise
372    // emit as Renamed.
373    for (from_id, to_id) in gix_rewrites {
374        if absorbed_del.contains(&from_id) || absorbed_add.contains(&to_id) {
375            continue;
376        }
377        envelopes.push(ChangeEnvelope::Renamed {
378            title: title_for(store, &to_id),
379            entity_type: type_for(store, &to_id),
380            from_id,
381            to_id,
382        });
383    }
384
385    // Remaining additions / deletions / modifications.
386    for id in additions {
387        if absorbed_add.contains(&id) {
388            continue;
389        }
390        envelopes.push(ChangeEnvelope::Added {
391            title: title_for(store, &id),
392            entity_type: type_for(store, &id),
393            id,
394        });
395    }
396    for id in deletions {
397        if absorbed_del.contains(&id) {
398            continue;
399        }
400        envelopes.push(ChangeEnvelope::Removed {
401            id,
402            title: None,
403            entity_type: None,
404        });
405    }
406    for id in modifications {
407        envelopes.push(ChangeEnvelope::Updated {
408            title: title_for(store, &id),
409            entity_type: type_for(store, &id),
410            id,
411        });
412    }
413    envelopes
414}
415
416/// Translate a tree-diff path into a mem-qualified `EntityId`. Returns
417/// `None` for non-entity paths so engine config / schema edits don't
418/// leak into the entity-level delta surface.
419fn path_to_entity_id(mem: &str, path: &gix::bstr::BStr) -> Option<EntityId> {
420    let s = std::str::from_utf8(path.as_ref()).ok()?;
421    if s.is_empty() || !s.ends_with(".md") {
422        return None;
423    }
424    // `.memstead/` is engine-internal; nothing under it maps to an entity.
425    if s.starts_with(".memstead/") {
426        return None;
427    }
428    Some(file_path_to_id(s, mem))
429}
430
431/// Best-effort title lookup. Stubs resolve to their hollow title (usually
432/// the slug); real entities resolve to the authored title. Missing-from-
433/// store → `None`.
434fn title_for(store: &Store, id: &EntityId) -> Option<String> {
435    store.get(id).map(|e| e.title.clone())
436}
437
438/// Best-effort entity-type lookup. Mirrors `title_for`. Missing-from-store
439/// (including removed-in-this-diff entities) → `None`.
440fn type_for(store: &Store, id: &EntityId) -> Option<String> {
441    store.get(id).map(|e| e.entity_type.clone())
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::ops::agent_notes::CommitNote;
448
449    fn rename_note(old: &str, new: &str) -> CommitNote {
450        CommitNote {
451            mem: "specs".to_string(),
452            sha: "abc".to_string(),
453            subject: format!("memstead: rename {old} → {new}"),
454            tool_verb: Some("rename".to_string()),
455            entity_id: Some(format!("{old} → {new}")),
456            note: None,
457            actor: None,
458            tool: None,
459            client: None,
460            logical_operation_id: None,
461            role: None,
462            entity_ids: Vec::new(),
463            timestamp: 0,
464        }
465    }
466
467    #[test]
468    fn parse_rename_entity_field_extracts_old_and_new_ids() {
469        let parsed = parse_rename_entity_field("specs--old-name → specs--new-name").unwrap();
470        assert_eq!(parsed.0.0, "specs--old-name");
471        assert_eq!(parsed.1.0, "specs--new-name");
472    }
473
474    #[test]
475    fn parse_rename_entity_field_rejects_cross_mem_rewrite_qualifier() {
476        // Cross-mem peer rewrites carry a parenthetical qualifier
477        // (rename.rs:433 path); they describe a rename that happened
478        // in some other mem and so cannot drive local pairing.
479        assert!(
480            parse_rename_entity_field("specs--old → specs--new (cross-mem rewrite in `other`)")
481                .is_none()
482        );
483    }
484
485    #[test]
486    fn build_authoritative_rename_map_collapses_transitive_chain() {
487        // A → B then B → C in
488        // the same cursor window collapses to the single edge
489        // A → C — the gix diff sees only Addition(C) + Deletion(A)
490        // (B is intermediate and never lands on the head tree), so
491        // the map must end keyed on the original source.
492        // `agent_notes_since` returns notes newest-first, so the
493        // input order here mirrors that contract.
494        let notes = vec![
495            rename_note("specs--b", "specs--c"),
496            rename_note("specs--a", "specs--b"),
497        ];
498        let map = build_authoritative_rename_map(&notes);
499        assert_eq!(map.len(), 1, "transitive collapse: {map:?}");
500        let final_target = map
501            .get(&EntityId("specs--a".to_string()))
502            .expect("composed map keyed on original source");
503        assert_eq!(final_target.0, "specs--c");
504    }
505
506    #[test]
507    fn build_authoritative_rename_map_skips_cross_mem_peer_commits() {
508        // The renaming entity's mem commit drives pairing; cross-
509        // mem peer commits (subject carries the `(cross-mem
510        // rewrite in V)` qualifier) only rewrite wiki-link bodies in
511        // the peer and don't add/remove the renaming entity there.
512        let mut peer = rename_note("specs--a", "specs--b");
513        peer.subject =
514            "memstead: rename specs--a → specs--b (cross-mem rewrite in `peers`)".to_string();
515        peer.entity_id = Some("specs--a → specs--b (cross-mem rewrite in `peers`)".to_string());
516        let map = build_authoritative_rename_map(&[peer]);
517        assert!(
518            map.is_empty(),
519            "cross-mem peer commit must not enter the map: {map:?}"
520        );
521    }
522
523    #[test]
524    fn build_authoritative_rename_map_ignores_non_rename_verbs() {
525        let note = CommitNote {
526            mem: "specs".to_string(),
527            sha: "abc".to_string(),
528            subject: "memstead: update specs--foo".to_string(),
529            tool_verb: Some("update".to_string()),
530            entity_id: Some("specs--foo".to_string()),
531            note: None,
532            actor: None,
533            tool: None,
534            client: None,
535            logical_operation_id: None,
536            role: None,
537            entity_ids: Vec::new(),
538            timestamp: 0,
539        };
540        let map = build_authoritative_rename_map(&[note]);
541        assert!(map.is_empty(), "non-rename verbs ignored: {map:?}");
542    }
543
544    #[test]
545    fn combine_with_rename_map_pairs_authoritative_add_and_del() {
546        use crate::store::Store;
547        let store = Store::new();
548        let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
549        rename_map.insert(
550            EntityId("specs--a".to_string()),
551            EntityId("specs--b".to_string()),
552        );
553        let envelopes = combine_with_rename_map(
554            &store,
555            rename_map,
556            vec![EntityId("specs--b".to_string())],
557            vec![EntityId("specs--a".to_string())],
558            Vec::new(),
559            Vec::new(),
560        );
561        assert_eq!(envelopes.len(), 1);
562        match &envelopes[0] {
563            ChangeEnvelope::Renamed { from_id, to_id, .. } => {
564                assert_eq!(from_id.0, "specs--a");
565                assert_eq!(to_id.0, "specs--b");
566            }
567            other => panic!("expected Renamed, got {other:?}"),
568        }
569    }
570
571    #[test]
572    fn combine_with_rename_map_rename_then_delete_emits_removed() {
573        // A rename followed by a delete of the new
574        // id in the same window. Note says A → B; gix diff says
575        // Deletion(A) only (B was added then removed). Emit
576        // Removed { id: A } — the entity is gone.
577        use crate::store::Store;
578        let store = Store::new();
579        let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
580        rename_map.insert(
581            EntityId("specs--a".to_string()),
582            EntityId("specs--b".to_string()),
583        );
584        let envelopes = combine_with_rename_map(
585            &store,
586            rename_map,
587            Vec::new(),
588            vec![EntityId("specs--a".to_string())],
589            Vec::new(),
590            Vec::new(),
591        );
592        assert_eq!(envelopes.len(), 1);
593        match &envelopes[0] {
594            ChangeEnvelope::Removed { id, .. } => {
595                assert_eq!(id.0, "specs--a");
596            }
597            other => panic!("expected Removed, got {other:?}"),
598        }
599    }
600
601    #[test]
602    fn combine_with_rename_map_keeps_gix_rewrites_as_fallback() {
603        // External rename (e.g. `git mv` outside the engine) — no
604        // commit note exists; gix's content-similarity scorer pairs
605        // the two file paths and emits a Rewrite event. The combine
606        // pass keeps it as Renamed because neither side appears in
607        // the authoritative map.
608        use crate::store::Store;
609        let store = Store::new();
610        let envelopes = combine_with_rename_map(
611            &store,
612            HashMap::new(),
613            Vec::new(),
614            Vec::new(),
615            Vec::new(),
616            vec![(
617                EntityId("specs--external-old".to_string()),
618                EntityId("specs--external-new".to_string()),
619            )],
620        );
621        assert_eq!(envelopes.len(), 1);
622        match &envelopes[0] {
623            ChangeEnvelope::Renamed { from_id, to_id, .. } => {
624                assert_eq!(from_id.0, "specs--external-old");
625                assert_eq!(to_id.0, "specs--external-new");
626            }
627            other => panic!("expected Renamed from gix fallback, got {other:?}"),
628        }
629    }
630
631    #[test]
632    fn combine_with_rename_map_authoritative_wins_over_gix_rewrite() {
633        // Both sources fire: the engine wrote a `memstead: rename A → C`
634        // note AND gix's similarity scorer paired A↔B (false
635        // positive). The note wins; B never appears as a rename
636        // target. B falls through to whatever its underlying
637        // addition path produced (here we exercise only that the
638        // gix pair is suppressed).
639        use crate::store::Store;
640        let store = Store::new();
641        let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
642        rename_map.insert(
643            EntityId("specs--a".to_string()),
644            EntityId("specs--c".to_string()),
645        );
646        let envelopes = combine_with_rename_map(
647            &store,
648            rename_map,
649            vec![EntityId("specs--c".to_string())],
650            vec![EntityId("specs--a".to_string())],
651            Vec::new(),
652            vec![(
653                EntityId("specs--a".to_string()),
654                EntityId("specs--b".to_string()),
655            )],
656        );
657        // One Renamed (A → C from the note). The gix Rewrite A → B
658        // is suppressed because A was absorbed by the authoritative
659        // pairing.
660        assert_eq!(envelopes.len(), 1);
661        match &envelopes[0] {
662            ChangeEnvelope::Renamed { from_id, to_id, .. } => {
663                assert_eq!(from_id.0, "specs--a");
664                assert_eq!(to_id.0, "specs--c");
665            }
666            other => panic!("expected note-driven Renamed, got {other:?}"),
667        }
668    }
669
670    #[test]
671    fn path_to_entity_id_strips_md_and_prefixes_mem() {
672        let bstr = gix::bstr::BString::from("architecture/result.md");
673        let id = path_to_entity_id("specs", bstr.as_ref()).unwrap();
674        assert_eq!(id.0, "specs--architecture/result");
675    }
676
677    #[test]
678    fn path_to_entity_id_skips_memstead_internal_files() {
679        let bstr = gix::bstr::BString::from(".memstead/config.json");
680        assert!(path_to_entity_id("specs", bstr.as_ref()).is_none());
681        // A `.md` under `.memstead/` is engine-internal and never an entity.
682        let internal_md = gix::bstr::BString::from(".memstead/notes.md");
683        assert!(path_to_entity_id("specs", internal_md.as_ref()).is_none());
684    }
685
686    #[test]
687    fn path_to_entity_id_skips_non_markdown() {
688        let bstr = gix::bstr::BString::from("image.png");
689        assert!(path_to_entity_id("specs", bstr.as_ref()).is_none());
690    }
691
692    #[test]
693    fn empty_tree_sentinel_is_canonical_git_hash() {
694        assert_eq!(EMPTY_TREE_SHA.len(), 40);
695        // Prefix check — this SHA is stable across every git version.
696        assert!(EMPTY_TREE_SHA.starts_with("4b825dc6"));
697    }
698}