1use 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
44pub(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
70fn 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
108fn 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
123fn 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
138fn 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
147pub 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 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 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 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 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 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 apply_agent_notes_renames(gitdir, mem, ref_a, ref_b, &mut entries, config);
297
298 if config.include_content {
305 demote_invalid_entries(&mut entries);
306 }
307
308 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
331fn 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 Err(_) => return,
354 };
355
356 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 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 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(¤t) 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 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 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 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(¤t) {
477 if next == to_id {
478 break;
479 }
480 chain.push(next.clone());
481 current = next.clone();
482 }
483 if forward.get(¤t).is_some_and(|n| n == to_id) {
488 *rename_chain = chain;
489 }
490 }
491 }
492}
493
494fn 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 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
524fn 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 EntityDiff::Renamed { .. } | EntityDiff::InvalidEntity { .. } => {}
596 }
597 }
598}
599
600fn 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
623fn 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 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 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
683fn 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
722fn 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 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 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 assert_eq!(normalise_ref_for_mem("v", "HEADER"), "HEADER");
815 assert_eq!(normalise_ref_for_mem("v", "HEAD-foo"), "HEAD-foo");
816 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 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 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 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 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 assert_eq!(statuses, vec!["deleted", "modified", "added"]);
907
908 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 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 #[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"; 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 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 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 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 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 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 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 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 #[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 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 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 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 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 assert!(diff.entries.is_empty());
1447 }
1448
1449 #[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 #[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 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 #[test]
1534 fn diff_resolves_bare_head_to_mem_branch_tip() {
1535 let tmp = TempDir::new().unwrap();
1536 let gitdir = init_gitdir(&tmp);
1537 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 write_and_commit(
1548 &gitdir,
1549 "specs",
1550 &[("beta.md", &body_with_title("Beta"))],
1551 "add beta",
1552 );
1553
1554 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 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 #[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}