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::{extract_inline_links_lenient, peek_title_and_type};
38use memstead_base::ops::{Diff, DiffConfig, EntityDiff, IncomingRipple};
39
40use crate::EMPTY_TREE_SHA;
41
42pub(crate) fn normalise_ref_for_mem(mem: &str, raw: &str) -> String {
60 if let Some(rest) = raw.strip_prefix("HEAD")
61 && (rest.is_empty() || rest.starts_with(['~', '^', ':', '@']))
62 {
63 return format!("refs/heads/{mem}{rest}");
64 }
65 raw.to_string()
66}
67
68fn resolve_tree<'r>(repo: &'r gix::Repository, raw: &str) -> Result<gix::Tree<'r>, BackendError> {
79 if raw == EMPTY_TREE_SHA {
80 return Ok(repo.empty_tree());
81 }
82 let id = repo
83 .rev_parse_single(raw)
84 .map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
85 let object = id
86 .object()
87 .map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
88 let commit = object
89 .try_into_commit()
90 .map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw} is not a commit")))?;
91 commit
92 .tree()
93 .map_err(|e| BackendError::Other(format!("tree({raw}): {e}")))
94}
95
96fn sha_for(repo: &gix::Repository, raw: &str) -> Result<String, BackendError> {
97 if raw == EMPTY_TREE_SHA {
98 return Ok(EMPTY_TREE_SHA.to_string());
99 }
100 let id = repo
101 .rev_parse_single(raw)
102 .map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
103 Ok(id.detach().to_string())
104}
105
106fn path_to_entity_id(mem: &str, path: &gix::bstr::BStr) -> Option<EntityId> {
111 let s = std::str::from_utf8(path.as_ref()).ok()?;
112 if s.is_empty() || !s.ends_with(".md") {
113 return None;
114 }
115 if s.starts_with(".memstead/") {
116 return None;
117 }
118 Some(file_path_to_id(s, mem))
119}
120
121fn read_md_at_path(
124 _repo: &gix::Repository,
125 tree: &gix::Tree<'_>,
126 path: &gix::bstr::BStr,
127) -> Option<String> {
128 let entry = tree
129 .lookup_entry_by_path(path.to_string().as_str())
130 .ok()??;
131 let object = entry.object().ok()?;
132 let blob = object.try_into_blob().ok()?;
133 String::from_utf8(blob.data.clone()).ok()
134}
135
136fn peek_meta(raw: &Option<String>) -> (Option<String>, Option<String>) {
140 raw.as_deref()
141 .map(peek_title_and_type)
142 .unwrap_or((None, None))
143}
144
145pub fn diff_two_refs(
148 gitdir: &Path,
149 mem: &str,
150 ref_a: &str,
151 ref_b: &str,
152 config: &DiffConfig,
153) -> Result<Diff, BackendError> {
154 if !gitdir.is_dir() {
155 return Err(BackendError::Other(format!(
156 "gitdir not found: {}",
157 gitdir.display()
158 )));
159 }
160 let repo = gix::open(gitdir).map_err(|e| BackendError::Other(format!("gix open: {e}")))?;
161 let normalised_a = normalise_ref_for_mem(mem, ref_a);
167 let normalised_b = normalise_ref_for_mem(mem, ref_b);
168 let tree_a = resolve_tree(&repo, &normalised_a)?;
169 let tree_b = resolve_tree(&repo, &normalised_b)?;
170 let resolved_a_sha = sha_for(&repo, &normalised_a)?;
171 let resolved_b_sha = sha_for(&repo, &normalised_b)?;
172
173 let mut platform = tree_a
174 .changes()
175 .map_err(|e| BackendError::Other(format!("diff init: {e}")))?;
176 let rewrites = gix::diff::Rewrites {
177 copies: None,
178 percentage: Some(config.rename_similarity),
179 limit: 1000,
180 track_empty: false,
181 };
182 platform.options(|opts| {
183 opts.track_rewrites(Some(rewrites));
184 });
185
186 let mut entries: Vec<EntityDiff> = Vec::new();
187 platform
188 .for_each_to_obtain_tree(
189 &tree_b,
190 |change| -> Result<std::ops::ControlFlow<()>, std::convert::Infallible> {
191 match change {
192 Change::Addition { location, .. } => {
193 if let Some(id) = path_to_entity_id(mem, location) {
194 let raw = read_md_at_path(&repo, &tree_b, location);
199 let (title, entity_type) = peek_meta(&raw);
200 let content_after = if config.include_content { raw } else { None };
201 entries.push(EntityDiff::Added {
202 id,
203 title,
204 entity_type,
205 content_after,
206 ripple: Vec::new(),
207 });
208 }
209 }
210 Change::Deletion { location, .. } => {
211 if let Some(id) = path_to_entity_id(mem, location) {
212 let raw = read_md_at_path(&repo, &tree_a, location);
216 let (title, entity_type) = peek_meta(&raw);
217 let content_before = if config.include_content { raw } else { None };
218 entries.push(EntityDiff::Deleted {
219 id,
220 title,
221 entity_type,
222 content_before,
223 ripple: Vec::new(),
224 });
225 }
226 }
227 Change::Modification { location, .. } => {
228 if let Some(id) = path_to_entity_id(mem, location) {
229 let raw_b = read_md_at_path(&repo, &tree_b, location);
232 let (title, entity_type) = peek_meta(&raw_b);
233 let content_before = if config.include_content {
234 read_md_at_path(&repo, &tree_a, location)
235 } else {
236 None
237 };
238 let content_after = if config.include_content { raw_b } else { None };
239 entries.push(EntityDiff::Modified {
240 id,
241 title,
242 entity_type,
243 content_before,
244 content_after,
245 ripple: Vec::new(),
246 });
247 }
248 }
249 Change::Rewrite {
250 source_location,
251 location,
252 ..
253 } => {
254 let from = path_to_entity_id(mem, source_location);
255 let to = path_to_entity_id(mem, location);
256 if let (Some(from_id), Some(to_id)) = (from, to) {
257 let raw_b = read_md_at_path(&repo, &tree_b, location);
260 let (title, entity_type) = peek_meta(&raw_b);
261 let content_before = if config.include_content {
262 read_md_at_path(&repo, &tree_a, source_location)
263 } else {
264 None
265 };
266 let content_after = if config.include_content { raw_b } else { None };
267 entries.push(EntityDiff::Renamed {
268 from_id,
269 to_id,
270 rename_chain: Vec::new(),
271 title,
272 entity_type,
273 content_before,
274 content_after,
275 ripple: Vec::new(),
276 });
277 }
278 }
279 }
280 Ok(std::ops::ControlFlow::Continue(()))
281 },
282 )
283 .map_err(|e| BackendError::Other(format!("diff: {e}")))?;
284
285 apply_agent_notes_renames(gitdir, mem, ref_a, ref_b, &mut entries, config);
295
296 if config.include_content {
303 demote_invalid_entries(&mut entries);
304 }
305
306 entries.sort_by_key(primary_id);
309
310 if config.include_ripple {
311 let affected = collect_affected_ids(&entries);
312 if !affected.is_empty() {
313 let ripple_a = scan_tree_ripple(&repo, &tree_a, mem, &affected, "ref_a");
314 let ripple_b = scan_tree_ripple(&repo, &tree_b, mem, &affected, "ref_b");
315 attach_ripple(&mut entries, &ripple_a, &ripple_b);
316 }
317 }
318
319 Ok(Diff {
320 ref_a: ref_a.to_string(),
321 ref_b: ref_b.to_string(),
322 resolved_a_sha,
323 resolved_b_sha,
324 config: config.clone(),
325 entries,
326 })
327}
328
329fn apply_agent_notes_renames(
337 gitdir: &Path,
338 mem: &str,
339 ref_a: &str,
340 ref_b: &str,
341 entries: &mut Vec<EntityDiff>,
342 config: &DiffConfig,
343) {
344 let report = match crate::ops::agent_notes::agent_notes_since(mem, gitdir, ref_a, Some(ref_b)) {
345 Ok(r) => r,
346 Err(_) => return,
352 };
353
354 let mut forward: HashMap<EntityId, EntityId> = HashMap::new();
358 for note in report.notes.iter().rev() {
359 if note.tool_verb.as_deref() != Some("rename") {
360 continue;
361 }
362 let Some(id_str) = note.entity_id.as_deref() else {
363 continue;
364 };
365 let Some((old_id, new_id)) = crate::ops::changes::parse_rename_entity_field(id_str) else {
366 continue;
367 };
368 forward.insert(old_id, new_id);
369 }
370 if forward.is_empty() {
371 return;
372 }
373
374 let mut deleted_idx: HashMap<EntityId, usize> = HashMap::new();
377 let mut added_idx: HashMap<EntityId, usize> = HashMap::new();
378 for (idx, entry) in entries.iter().enumerate() {
379 match entry {
380 EntityDiff::Deleted { id, .. } => {
381 deleted_idx.insert(id.clone(), idx);
382 }
383 EntityDiff::Added { id, .. } => {
384 added_idx.insert(id.clone(), idx);
385 }
386 _ => {}
387 }
388 }
389
390 let mut to_remove: Vec<usize> = Vec::new();
391 let mut promotions: Vec<(usize, EntityDiff)> = Vec::new();
392 for origin in forward.keys() {
393 let Some(&del_idx) = deleted_idx.get(origin) else {
394 continue;
395 };
396 let mut chain: Vec<EntityId> = Vec::new();
399 let mut current = origin.clone();
400 let final_id: Option<EntityId> = loop {
401 let Some(next) = forward.get(¤t) else {
402 break None;
403 };
404 if added_idx.contains_key(next) {
405 break Some(next.clone());
406 }
407 chain.push(next.clone());
408 current = next.clone();
409 };
410 let Some(terminal) = final_id else { continue };
411 let Some(&add_idx) = added_idx.get(&terminal) else {
412 continue;
413 };
414 let (content_before, content_after) = if config.include_content {
415 let cb = match &entries[del_idx] {
416 EntityDiff::Deleted { content_before, .. } => content_before.clone(),
417 _ => None,
418 };
419 let ca = match &entries[add_idx] {
420 EntityDiff::Added { content_after, .. } => content_after.clone(),
421 _ => None,
422 };
423 (cb, ca)
424 } else {
425 (None, None)
426 };
427 let (title, entity_type) = match &entries[add_idx] {
431 EntityDiff::Added {
432 title, entity_type, ..
433 } => (title.clone(), entity_type.clone()),
434 _ => (None, None),
435 };
436 let promoted = EntityDiff::Renamed {
437 from_id: origin.clone(),
438 to_id: terminal.clone(),
439 rename_chain: chain,
440 title,
441 entity_type,
442 content_before,
443 content_after,
444 ripple: Vec::new(),
445 };
446 promotions.push((add_idx, promoted));
447 to_remove.push(del_idx);
448 }
449
450 for (idx, promoted) in promotions {
454 entries[idx] = promoted;
455 }
456 to_remove.sort_by(|a, b| b.cmp(a));
457 for idx in to_remove {
458 entries.remove(idx);
459 }
460
461 for entry in entries.iter_mut() {
464 if let EntityDiff::Renamed {
465 from_id,
466 to_id,
467 rename_chain,
468 ..
469 } = entry
470 && rename_chain.is_empty()
471 {
472 let mut chain: Vec<EntityId> = Vec::new();
473 let mut current = from_id.clone();
474 while let Some(next) = forward.get(¤t) {
475 if next == to_id {
476 break;
477 }
478 chain.push(next.clone());
479 current = next.clone();
480 }
481 if forward.get(¤t).is_some_and(|n| n == to_id) {
486 *rename_chain = chain;
487 }
488 }
489 }
490}
491
492fn classify_parse_failure(content: &str) -> Option<String> {
500 let trimmed_bom = content.trim_start_matches('\u{FEFF}');
501 if !trimmed_bom.starts_with("---") {
502 return Some("missing frontmatter (body does not open with `---`)".to_string());
503 }
504 let after_open = match trimmed_bom.strip_prefix("---") {
505 Some(rest) => rest.trim_start_matches('\r').trim_start_matches('\n'),
506 None => return Some("missing frontmatter".to_string()),
507 };
508 let mut closed = false;
510 for line in after_open.lines() {
511 if line.trim_end() == "---" {
512 closed = true;
513 break;
514 }
515 }
516 if !closed {
517 return Some("malformed frontmatter (no closing `---` line)".to_string());
518 }
519 None
520}
521
522fn demote_invalid_entries(entries: &mut [EntityDiff]) {
527 for entry in entries.iter_mut() {
528 match entry {
529 EntityDiff::Added {
530 id, content_after, ..
531 } => {
532 if let Some(c) = content_after.as_ref()
533 && let Some(err) = classify_parse_failure(c)
534 {
535 *entry = EntityDiff::InvalidEntity {
536 id: id.clone(),
537 side: "ref_b".to_string(),
538 error: err,
539 content_before: None,
540 content_after: content_after.clone(),
541 };
542 }
543 }
544 EntityDiff::Deleted {
545 id, content_before, ..
546 } => {
547 if let Some(c) = content_before.as_ref()
548 && let Some(err) = classify_parse_failure(c)
549 {
550 *entry = EntityDiff::InvalidEntity {
551 id: id.clone(),
552 side: "ref_a".to_string(),
553 error: err,
554 content_before: content_before.clone(),
555 content_after: None,
556 };
557 }
558 }
559 EntityDiff::Modified {
560 id,
561 content_before,
562 content_after,
563 ..
564 } => {
565 let err_a = content_before
566 .as_ref()
567 .and_then(|c| classify_parse_failure(c));
568 let err_b = content_after
569 .as_ref()
570 .and_then(|c| classify_parse_failure(c));
571 if err_a.is_some() || err_b.is_some() {
572 let (side, error) = match (err_a, err_b) {
573 (Some(a), Some(b)) => {
574 ("both".to_string(), format!("{a} (ref_a); {b} (ref_b)"))
575 }
576 (Some(a), None) => ("ref_a".to_string(), a),
577 (None, Some(b)) => ("ref_b".to_string(), b),
578 (None, None) => unreachable!(),
579 };
580 *entry = EntityDiff::InvalidEntity {
581 id: id.clone(),
582 side,
583 error,
584 content_before: content_before.clone(),
585 content_after: content_after.clone(),
586 };
587 }
588 }
589 EntityDiff::Renamed { .. } | EntityDiff::InvalidEntity { .. } => {}
594 }
595 }
596}
597
598fn collect_affected_ids(entries: &[EntityDiff]) -> HashSet<EntityId> {
603 let mut out = HashSet::new();
604 for e in entries {
605 match e {
606 EntityDiff::Added { id, .. }
607 | EntityDiff::Modified { id, .. }
608 | EntityDiff::Deleted { id, .. }
609 | EntityDiff::InvalidEntity { id, .. } => {
610 out.insert(id.clone());
611 }
612 EntityDiff::Renamed { from_id, to_id, .. } => {
613 out.insert(from_id.clone());
614 out.insert(to_id.clone());
615 }
616 }
617 }
618 out
619}
620
621fn scan_tree_ripple(
625 repo: &gix::Repository,
626 tree: &gix::Tree<'_>,
627 mem: &str,
628 affected: &HashSet<EntityId>,
629 side: &str,
630) -> HashMap<EntityId, Vec<IncomingRipple>> {
631 let mut out: HashMap<EntityId, Vec<IncomingRipple>> = HashMap::new();
632 let entries = match tree.traverse().breadthfirst.files() {
633 Ok(e) => e,
634 Err(_) => return out,
635 };
636 for entry in entries {
637 if !entry.mode.is_blob() {
638 continue;
639 }
640 let path = match std::str::from_utf8(entry.filepath.as_slice()) {
641 Ok(s) => s,
642 Err(_) => continue,
643 };
644 if !path.ends_with(".md") || path.starts_with(".memstead/") {
645 continue;
646 }
647 let referrer_id = file_path_to_id(path, mem);
648 let object = match repo.find_object(entry.oid) {
651 Ok(o) => o,
652 Err(_) => continue,
653 };
654 let blob = match object.try_into_blob() {
655 Ok(b) => b,
656 Err(_) => continue,
657 };
658 let content = match std::str::from_utf8(&blob.data) {
659 Ok(s) => s,
660 Err(_) => continue,
661 };
662 for target in extract_inline_links_lenient(content, mem) {
663 if target == referrer_id {
664 continue;
665 }
666 if affected.contains(&target) {
667 out.entry(target).or_default().push(IncomingRipple {
668 from_id: referrer_id.clone(),
669 side: side.to_string(),
670 section: None,
671 });
672 }
673 }
674 }
675 out
676}
677
678fn attach_ripple(
683 entries: &mut [EntityDiff],
684 ripple_a: &HashMap<EntityId, Vec<IncomingRipple>>,
685 ripple_b: &HashMap<EntityId, Vec<IncomingRipple>>,
686) {
687 for entry in entries.iter_mut() {
688 match entry {
689 EntityDiff::Added { id, ripple, .. }
690 | EntityDiff::Modified { id, ripple, .. }
691 | EntityDiff::Deleted { id, ripple, .. } => {
692 if let Some(list) = ripple_a.get(id) {
693 ripple.extend(list.iter().cloned());
694 }
695 if let Some(list) = ripple_b.get(id) {
696 ripple.extend(list.iter().cloned());
697 }
698 }
699 EntityDiff::Renamed {
700 from_id,
701 to_id,
702 ripple,
703 ..
704 } => {
705 if let Some(list) = ripple_a.get(from_id) {
706 ripple.extend(list.iter().cloned());
707 }
708 if let Some(list) = ripple_b.get(to_id) {
709 ripple.extend(list.iter().cloned());
710 }
711 }
712 EntityDiff::InvalidEntity { .. } => {}
713 }
714 }
715}
716
717fn primary_id(entry: &EntityDiff) -> String {
720 match entry {
721 EntityDiff::Added { id, .. }
722 | EntityDiff::Modified { id, .. }
723 | EntityDiff::Deleted { id, .. }
724 | EntityDiff::InvalidEntity { id, .. } => id.to_string(),
725 EntityDiff::Renamed { to_id, .. } => to_id.to_string(),
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use crate::storage::MemWriter;
733 use crate::storage::git_tree::GitTreeMemWriter;
734 use crate::vcs::CommitContext;
735 use std::path::PathBuf;
736 use tempfile::TempDir;
737
738 fn init_gitdir(tmp: &TempDir) -> PathBuf {
739 let gitdir = tmp.path().join("mem-repo").join(".git");
740 std::fs::create_dir_all(&gitdir).unwrap();
741 gix::init_bare(&gitdir).unwrap();
742 gitdir
743 }
744
745 fn body_with_title(title: &str) -> String {
746 let unique = title.repeat(64);
753 format!(
754 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# {title}\n\n## Identity\n\n{unique}\n"
755 )
756 }
757
758 fn write_and_commit(
759 gitdir: &Path,
760 mem: &str,
761 entries: &[(&str, &str)],
762 subject: &str,
763 ) -> String {
764 let writer = GitTreeMemWriter::new(gitdir.to_path_buf(), format!("refs/heads/{mem}"));
765 for (path, content) in entries {
766 writer
767 .write_entity(Path::new(path), content.as_bytes())
768 .unwrap();
769 }
770 writer.commit(subject, &CommitContext::internal()).unwrap()
771 }
772
773 #[test]
774 fn diff_unknown_ref_returns_unknown_ref_marker() {
775 let tmp = TempDir::new().unwrap();
776 let gitdir = init_gitdir(&tmp);
777 let err = diff_two_refs(
778 &gitdir,
779 "specs",
780 "no-such-ref",
781 "no-such-other",
782 &DiffConfig::default(),
783 )
784 .unwrap_err();
785 match err {
786 BackendError::Other(msg) => {
787 assert!(
788 msg.starts_with("UNKNOWN_REF:"),
789 "expected UNKNOWN_REF marker, got: {msg}",
790 );
791 }
792 other => panic!("expected Other, got {other:?}"),
793 }
794 }
795
796 #[test]
797 fn normalise_rewrites_only_the_head_token() {
798 assert_eq!(normalise_ref_for_mem("v", "HEAD"), "refs/heads/v");
801 assert_eq!(normalise_ref_for_mem("v", "HEAD~5"), "refs/heads/v~5");
802 assert_eq!(normalise_ref_for_mem("v", "HEAD^"), "refs/heads/v^");
803 assert_eq!(
804 normalise_ref_for_mem("v", "HEAD^{tree}"),
805 "refs/heads/v^{tree}"
806 );
807 assert_eq!(normalise_ref_for_mem("v", "HEAD@{1}"), "refs/heads/v@{1}");
808 assert_eq!(normalise_ref_for_mem("v", "HEADER"), "HEADER");
810 assert_eq!(normalise_ref_for_mem("v", "HEAD-foo"), "HEAD-foo");
811 assert_eq!(normalise_ref_for_mem("v", "main"), "main");
813 assert_eq!(normalise_ref_for_mem("v", "deadbeef"), "deadbeef");
814 }
815
816 #[test]
817 fn diff_head_revspec_anchors_on_mem_branch() {
818 let tmp = TempDir::new().unwrap();
822 let gitdir = init_gitdir(&tmp);
823 write_and_commit(
824 &gitdir,
825 "specs",
826 &[("alpha.md", &body_with_title("Alpha"))],
827 "c1",
828 );
829 write_and_commit(
830 &gitdir,
831 "specs",
832 &[("beta.md", &body_with_title("Beta"))],
833 "c2 add beta",
834 );
835
836 let diff = diff_two_refs(&gitdir, "specs", "HEAD~1", "HEAD", &DiffConfig::default())
837 .expect("HEAD revspec must resolve against the mem branch");
838 assert_eq!(diff.entries.len(), 1, "only beta added between c1 and c2");
839 assert!(
840 matches!(diff.entries[0], EntityDiff::Added { .. }),
841 "the one change is beta added: {:?}",
842 diff.entries[0]
843 );
844 }
845
846 #[test]
847 fn diff_added_modified_deleted_surface() {
848 let tmp = TempDir::new().unwrap();
849 let gitdir = init_gitdir(&tmp);
850
851 write_and_commit(
853 &gitdir,
854 "specs",
855 &[
856 ("alpha.md", &body_with_title("Alpha")),
857 ("beta.md", &body_with_title("Beta-v0")),
858 ],
859 "seed",
860 );
861 let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
862
863 write_and_commit(
865 &gitdir,
866 "specs",
867 &[
868 ("beta.md", &body_with_title("Beta-v1")),
869 ("gamma.md", &body_with_title("Gamma")),
870 ],
871 "update beta + add gamma",
872 );
873 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
875 writer.delete_entity(Path::new("alpha.md")).unwrap();
876 writer
877 .commit("drop alpha", &CommitContext::internal())
878 .unwrap();
879
880 let diff = diff_two_refs(
881 &gitdir,
882 "specs",
883 &sha_a,
884 "refs/heads/specs",
885 &DiffConfig::default(),
886 )
887 .unwrap();
888 assert_eq!(diff.entries.len(), 3, "Add+Modify+Delete expected");
889 let statuses: Vec<&str> = diff
890 .entries
891 .iter()
892 .map(|e| match e {
893 EntityDiff::Added { .. } => "added",
894 EntityDiff::Modified { .. } => "modified",
895 EntityDiff::Deleted { .. } => "deleted",
896 EntityDiff::Renamed { .. } => "renamed",
897 EntityDiff::InvalidEntity { .. } => "invalid",
898 })
899 .collect();
900 assert_eq!(statuses, vec!["deleted", "modified", "added"]);
902
903 let beta = diff
906 .entries
907 .iter()
908 .find(|e| matches!(e, EntityDiff::Modified { .. }))
909 .unwrap();
910 match beta {
911 EntityDiff::Modified {
912 content_before,
913 content_after,
914 ..
915 } => {
916 assert!(content_before.as_ref().unwrap().contains("Beta-v0"));
917 assert!(content_after.as_ref().unwrap().contains("Beta-v1"));
918 }
919 _ => unreachable!(),
920 }
921 }
922
923 #[test]
924 fn diff_include_content_false_strips_bodies() {
925 let tmp = TempDir::new().unwrap();
926 let gitdir = init_gitdir(&tmp);
927 write_and_commit(
928 &gitdir,
929 "specs",
930 &[("alpha.md", &body_with_title("Alpha"))],
931 "seed",
932 );
933 let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
934 write_and_commit(
935 &gitdir,
936 "specs",
937 &[("alpha.md", &body_with_title("Alpha-v2"))],
938 "rev2",
939 );
940
941 let cfg = DiffConfig {
942 include_content: false,
943 ..DiffConfig::default()
944 };
945 let diff = diff_two_refs(&gitdir, "specs", &sha_seed, "refs/heads/specs", &cfg).unwrap();
946 assert_eq!(diff.entries.len(), 1);
947 match &diff.entries[0] {
948 EntityDiff::Modified {
949 title,
950 entity_type,
951 content_before,
952 content_after,
953 ..
954 } => {
955 assert!(
956 content_before.is_none(),
957 "include_content=false elides before"
958 );
959 assert!(
960 content_after.is_none(),
961 "include_content=false elides after"
962 );
963 assert_eq!(
968 title.as_deref(),
969 Some("Alpha-v2"),
970 "title present (from ref_b) even with include_content=false"
971 );
972 assert_eq!(
973 entity_type.as_deref(),
974 Some("spec"),
975 "entity_type present even with include_content=false"
976 );
977 }
978 other => panic!("expected Modified, got {other:?}"),
979 }
980 }
981
982 #[test]
987 fn diff_populates_title_and_type_with_content_on() {
988 let tmp = TempDir::new().unwrap();
989 let gitdir = init_gitdir(&tmp);
990 let sha_empty = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; write_and_commit(
992 &gitdir,
993 "specs",
994 &[("alpha.md", &body_with_title("Alpha"))],
995 "seed",
996 );
997
998 let cfg = DiffConfig {
999 include_content: true,
1000 ..DiffConfig::default()
1001 };
1002 let diff = diff_two_refs(&gitdir, "specs", sha_empty, "refs/heads/specs", &cfg).unwrap();
1003 assert_eq!(diff.entries.len(), 1);
1004 match &diff.entries[0] {
1005 EntityDiff::Added {
1006 title,
1007 entity_type,
1008 content_after,
1009 ..
1010 } => {
1011 assert_eq!(title.as_deref(), Some("Alpha"), "title populated on Added");
1012 assert_eq!(
1013 entity_type.as_deref(),
1014 Some("spec"),
1015 "entity_type populated"
1016 );
1017 assert!(
1018 content_after.is_some(),
1019 "content_after present and additive to the metadata fields"
1020 );
1021 }
1022 other => panic!("expected Added, got {other:?}"),
1023 }
1024 }
1025
1026 #[test]
1027 fn diff_rename_chain_collapses_multi_step_engine_authored_renames() {
1028 let tmp = TempDir::new().unwrap();
1033 let gitdir = init_gitdir(&tmp);
1034
1035 let body = body_with_title("Title");
1036 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1037 writer
1038 .write_entity(Path::new("alpha.md"), body.as_bytes())
1039 .unwrap();
1040 writer.commit("seed", &CommitContext::internal()).unwrap();
1041 let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1042
1043 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1046 writer.delete_entity(Path::new("alpha.md")).unwrap();
1047 writer
1048 .write_entity(Path::new("beta.md"), body.as_bytes())
1049 .unwrap();
1050 writer
1051 .commit(
1052 "memstead: rename specs--alpha → specs--beta",
1053 &CommitContext::internal(),
1054 )
1055 .unwrap();
1056
1057 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1059 writer.delete_entity(Path::new("beta.md")).unwrap();
1060 writer
1061 .write_entity(Path::new("gamma.md"), body.as_bytes())
1062 .unwrap();
1063 writer
1064 .commit(
1065 "memstead: rename specs--beta → specs--gamma",
1066 &CommitContext::internal(),
1067 )
1068 .unwrap();
1069
1070 let diff = diff_two_refs(
1071 &gitdir,
1072 "specs",
1073 &sha_seed,
1074 "refs/heads/specs",
1075 &DiffConfig::default(),
1076 )
1077 .unwrap();
1078
1079 let renamed = diff
1080 .entries
1081 .iter()
1082 .find(|e| matches!(e, EntityDiff::Renamed { .. }))
1083 .expect("a Renamed entry must surface");
1084 match renamed {
1085 EntityDiff::Renamed {
1086 from_id,
1087 to_id,
1088 rename_chain,
1089 ..
1090 } => {
1091 assert_eq!(from_id.to_string(), "specs--alpha");
1092 assert_eq!(to_id.to_string(), "specs--gamma");
1093 assert_eq!(
1094 rename_chain
1095 .iter()
1096 .map(|id| id.to_string())
1097 .collect::<Vec<_>>(),
1098 vec!["specs--beta".to_string()],
1099 "the multi-step rename's intermediate id must surface in rename_chain",
1100 );
1101 }
1102 _ => unreachable!(),
1103 }
1104 let leftover: Vec<_> = diff
1106 .entries
1107 .iter()
1108 .filter(|e| matches!(e, EntityDiff::Added { .. } | EntityDiff::Deleted { .. }))
1109 .collect();
1110 assert!(
1111 leftover.is_empty(),
1112 "agent-notes promotion must absorb the Added+Deleted pair, got: {leftover:?}",
1113 );
1114 }
1115
1116 #[test]
1117 fn diff_ripple_lists_incoming_wikilinks_on_each_side() {
1118 let tmp = TempDir::new().unwrap();
1124 let gitdir = init_gitdir(&tmp);
1125
1126 let alpha_v1 = body_with_title("Alpha-v1");
1127 let beta_links_alpha = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Beta\n\n## Identity\n\nLinks to [[specs--alpha]].\n".to_string();
1128 write_and_commit(
1129 &gitdir,
1130 "specs",
1131 &[("alpha.md", &alpha_v1), ("beta.md", &beta_links_alpha)],
1132 "seed",
1133 );
1134 let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1135
1136 let alpha_v2 = body_with_title("Alpha-v2");
1137 let gamma_links_alpha = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Gamma\n\n## Identity\n\nLinks to [[specs--alpha]].\n".to_string();
1138 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1141 writer
1142 .write_entity(Path::new("alpha.md"), alpha_v2.as_bytes())
1143 .unwrap();
1144 writer
1145 .write_entity(Path::new("gamma.md"), gamma_links_alpha.as_bytes())
1146 .unwrap();
1147 writer.delete_entity(Path::new("beta.md")).unwrap();
1148 writer.commit("rev2", &CommitContext::internal()).unwrap();
1149
1150 let diff = diff_two_refs(
1151 &gitdir,
1152 "specs",
1153 &sha_a,
1154 "refs/heads/specs",
1155 &DiffConfig::default(),
1156 )
1157 .unwrap();
1158
1159 let alpha = diff
1161 .entries
1162 .iter()
1163 .find(|e| matches!(e, EntityDiff::Modified { id, .. } if id.to_string() == "specs--alpha"))
1164 .expect("alpha must show as modified");
1165 match alpha {
1166 EntityDiff::Modified { ripple, .. } => {
1167 let mut sides_seen: Vec<String> = ripple
1168 .iter()
1169 .map(|r| format!("{}@{}", r.from_id, r.side))
1170 .collect();
1171 sides_seen.sort();
1172 assert_eq!(
1173 sides_seen,
1174 vec![
1175 "specs--beta@ref_a".to_string(),
1176 "specs--gamma@ref_b".to_string(),
1177 ],
1178 "ripple must list beta on ref_a and gamma on ref_b",
1179 );
1180 }
1181 _ => unreachable!(),
1182 }
1183 }
1184
1185 #[test]
1186 fn diff_include_ripple_false_leaves_ripple_empty() {
1187 let tmp = TempDir::new().unwrap();
1188 let gitdir = init_gitdir(&tmp);
1189 let beta_links_alpha = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Beta\n\n## Identity\n\n[[specs--alpha]]\n";
1190 write_and_commit(
1191 &gitdir,
1192 "specs",
1193 &[
1194 ("alpha.md", &body_with_title("Alpha-v1")),
1195 ("beta.md", beta_links_alpha),
1196 ],
1197 "seed",
1198 );
1199 let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1200 write_and_commit(
1201 &gitdir,
1202 "specs",
1203 &[("alpha.md", &body_with_title("Alpha-v2"))],
1204 "rev2",
1205 );
1206
1207 let cfg = DiffConfig {
1208 include_ripple: false,
1209 ..DiffConfig::default()
1210 };
1211 let diff = diff_two_refs(&gitdir, "specs", &sha_a, "refs/heads/specs", &cfg).unwrap();
1212 for entry in &diff.entries {
1213 let ripple = match entry {
1214 EntityDiff::Added { ripple, .. }
1215 | EntityDiff::Modified { ripple, .. }
1216 | EntityDiff::Deleted { ripple, .. }
1217 | EntityDiff::Renamed { ripple, .. } => ripple.clone(),
1218 EntityDiff::InvalidEntity { .. } => Vec::new(),
1219 };
1220 assert!(
1221 ripple.is_empty(),
1222 "include_ripple=false must produce empty ripple lists: {entry:?}",
1223 );
1224 }
1225 }
1226
1227 #[test]
1228 fn diff_invalid_entity_surfaces_for_missing_frontmatter() {
1229 let tmp = TempDir::new().unwrap();
1232 let gitdir = init_gitdir(&tmp);
1233 write_and_commit(
1234 &gitdir,
1235 "specs",
1236 &[("alpha.md", &body_with_title("Alpha-v1"))],
1237 "seed",
1238 );
1239 let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1240 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
1242 writer
1243 .write_entity(
1244 Path::new("alpha.md"),
1245 b"# Alpha but no frontmatter\n\nbody.\n",
1246 )
1247 .unwrap();
1248 writer.commit("break", &CommitContext::internal()).unwrap();
1249
1250 let diff = diff_two_refs(
1251 &gitdir,
1252 "specs",
1253 &sha_a,
1254 "refs/heads/specs",
1255 &DiffConfig::default(),
1256 )
1257 .unwrap();
1258 let alpha = diff.entries.first().expect("alpha should appear");
1259 match alpha {
1260 EntityDiff::InvalidEntity {
1261 id, side, error, ..
1262 } => {
1263 assert_eq!(id.to_string(), "specs--alpha");
1264 assert_eq!(side, "ref_b");
1265 assert!(error.contains("frontmatter"), "unexpected error: {error}");
1266 }
1267 other => panic!("expected InvalidEntity, got {other:?}"),
1268 }
1269 }
1270
1271 #[test]
1272 fn engine_diff_routes_git_branch_mount_through_hook() {
1273 let tmp = TempDir::new().unwrap();
1279 let gitdir = init_gitdir(&tmp);
1280 write_and_commit(
1281 &gitdir,
1282 "specs",
1283 &[("alpha.md", &body_with_title("Alpha"))],
1284 "seed",
1285 );
1286 let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1287 write_and_commit(
1288 &gitdir,
1289 "specs",
1290 &[("alpha.md", &body_with_title("Alpha-v2"))],
1291 "rev2",
1292 );
1293
1294 let mount = memstead_base::Mount {
1295 mem: "specs".to_string(),
1296 schema: Some(memstead_schema::SchemaRef::new(
1297 "default",
1298 semver::Version::new(1, 0, 0),
1299 )),
1300 storage: memstead_base::MountStorage::GitBranch {
1301 gitdir: gitdir.clone(),
1302 branch: "specs".to_string(),
1303 },
1304 capability: memstead_base::MountCapability::Write,
1305 lifecycle: memstead_base::MountLifecycle::Eager,
1306 cross_linkable: true,
1307 migration_target: None,
1308 };
1309 let backend = crate::storage::instantiate_full_backend(&mount).unwrap();
1310 let mut engine = memstead_base::Engine::from_mounts(vec![(mount, backend)]).unwrap();
1311 engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
1312
1313 let diff = engine
1314 .diff("specs", &sha_seed, "refs/heads/specs", None)
1315 .unwrap();
1316 assert_eq!(diff.entries.len(), 1);
1317 assert!(matches!(diff.entries[0], EntityDiff::Modified { .. }));
1318 assert_eq!(diff.resolved_a_sha, sha_seed);
1319 }
1320
1321 #[test]
1322 fn engine_diff_unknown_ref_surfaces_typed_engine_error() {
1323 let tmp = TempDir::new().unwrap();
1324 let gitdir = init_gitdir(&tmp);
1325 write_and_commit(
1326 &gitdir,
1327 "specs",
1328 &[("alpha.md", &body_with_title("Alpha"))],
1329 "seed",
1330 );
1331
1332 let mount = memstead_base::Mount {
1333 mem: "specs".to_string(),
1334 schema: Some(memstead_schema::SchemaRef::new(
1335 "default",
1336 semver::Version::new(1, 0, 0),
1337 )),
1338 storage: memstead_base::MountStorage::GitBranch {
1339 gitdir: gitdir.clone(),
1340 branch: "specs".to_string(),
1341 },
1342 capability: memstead_base::MountCapability::Write,
1343 lifecycle: memstead_base::MountLifecycle::Eager,
1344 cross_linkable: true,
1345 migration_target: None,
1346 };
1347 let backend = crate::storage::instantiate_full_backend(&mount).unwrap();
1348 let mut engine = memstead_base::Engine::from_mounts(vec![(mount, backend)]).unwrap();
1349 engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
1350
1351 let err = engine.diff("specs", "nope-a", "nope-b", None).unwrap_err();
1352 match err {
1353 memstead_base::EngineError::UnknownRef(raw) => {
1354 assert!(raw.contains("nope"), "unexpected UnknownRef payload: {raw}");
1355 }
1356 other => panic!("expected UnknownRef, got {other:?}"),
1357 }
1358 }
1359
1360 #[test]
1361 fn diff_resolves_refs_to_sha_in_response() {
1362 let tmp = TempDir::new().unwrap();
1363 let gitdir = init_gitdir(&tmp);
1364 write_and_commit(
1365 &gitdir,
1366 "specs",
1367 &[("alpha.md", &body_with_title("Alpha"))],
1368 "seed",
1369 );
1370 let sha = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1371
1372 let diff = diff_two_refs(
1373 &gitdir,
1374 "specs",
1375 "refs/heads/specs",
1376 "refs/heads/specs",
1377 &DiffConfig::default(),
1378 )
1379 .unwrap();
1380 assert_eq!(diff.resolved_a_sha, sha);
1381 assert_eq!(diff.resolved_b_sha, sha);
1382 assert!(diff.entries.is_empty());
1384 }
1385
1386 #[test]
1391 fn diff_accepts_empty_tree_sentinel_for_ref_a() {
1392 let tmp = TempDir::new().unwrap();
1393 let gitdir = init_gitdir(&tmp);
1394 write_and_commit(
1395 &gitdir,
1396 "specs",
1397 &[
1398 ("alpha.md", &body_with_title("Alpha")),
1399 ("beta.md", &body_with_title("Beta")),
1400 ],
1401 "seed",
1402 );
1403
1404 let diff = diff_two_refs(
1405 &gitdir,
1406 "specs",
1407 EMPTY_TREE_SHA,
1408 "refs/heads/specs",
1409 &DiffConfig::default(),
1410 )
1411 .expect("empty-tree sentinel must be accepted");
1412 assert_eq!(diff.resolved_a_sha, EMPTY_TREE_SHA);
1413 assert_eq!(diff.entries.len(), 2, "first-sync lists every entity");
1414 for entry in &diff.entries {
1415 assert!(
1416 matches!(entry, EntityDiff::Added { .. }),
1417 "first-sync entries must all be `added`, got: {entry:?}",
1418 );
1419 }
1420 }
1421
1422 #[test]
1427 fn diff_refuses_arbitrary_tree_sha_with_unknown_ref() {
1428 let tmp = TempDir::new().unwrap();
1429 let gitdir = init_gitdir(&tmp);
1430 write_and_commit(
1431 &gitdir,
1432 "specs",
1433 &[("alpha.md", &body_with_title("Alpha"))],
1434 "seed",
1435 );
1436 let repo = gix::open(&gitdir).unwrap();
1440 let head = repo.rev_parse_single("refs/heads/specs").unwrap();
1441 let head_commit = head.object().unwrap().try_into_commit().unwrap();
1442 let tree_sha = head_commit.tree().unwrap().id.to_string();
1443 assert_ne!(
1444 tree_sha, EMPTY_TREE_SHA,
1445 "tree SHA must differ from sentinel for this test to be meaningful"
1446 );
1447
1448 let err = diff_two_refs(
1449 &gitdir,
1450 "specs",
1451 &tree_sha,
1452 "refs/heads/specs",
1453 &DiffConfig::default(),
1454 )
1455 .unwrap_err();
1456 match err {
1457 BackendError::Other(msg) => assert!(
1458 msg.starts_with("UNKNOWN_REF:"),
1459 "expected UNKNOWN_REF marker, got: {msg}",
1460 ),
1461 other => panic!("expected Other, got {other:?}"),
1462 }
1463 }
1464
1465 #[test]
1471 fn diff_resolves_bare_head_to_mem_branch_tip() {
1472 let tmp = TempDir::new().unwrap();
1473 let gitdir = init_gitdir(&tmp);
1474 write_and_commit(
1476 &gitdir,
1477 "specs",
1478 &[("alpha.md", &body_with_title("Alpha"))],
1479 "seed",
1480 );
1481 let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
1482
1483 write_and_commit(
1485 &gitdir,
1486 "specs",
1487 &[("beta.md", &body_with_title("Beta"))],
1488 "add beta",
1489 );
1490
1491 let via_head = diff_two_refs(&gitdir, "specs", &sha_a, "HEAD", &DiffConfig::default())
1497 .expect("bare HEAD must resolve via mem substitution");
1498 let via_explicit = diff_two_refs(
1499 &gitdir,
1500 "specs",
1501 &sha_a,
1502 "refs/heads/specs",
1503 &DiffConfig::default(),
1504 )
1505 .expect("explicit refs/heads/<mem> still works");
1506
1507 assert_eq!(via_head.resolved_b_sha, via_explicit.resolved_b_sha);
1511 assert_eq!(via_head.entries.len(), via_explicit.entries.len());
1512 }
1513
1514 #[test]
1519 fn diff_empty_tree_sentinel_and_bare_head_compose() {
1520 let tmp = TempDir::new().unwrap();
1521 let gitdir = init_gitdir(&tmp);
1522 write_and_commit(
1523 &gitdir,
1524 "specs",
1525 &[
1526 ("alpha.md", &body_with_title("Alpha")),
1527 ("beta.md", &body_with_title("Beta")),
1528 ],
1529 "seed",
1530 );
1531 let diff = diff_two_refs(
1532 &gitdir,
1533 "specs",
1534 EMPTY_TREE_SHA,
1535 "HEAD",
1536 &DiffConfig::default(),
1537 )
1538 .expect("sentinel + HEAD must compose");
1539 assert_eq!(diff.resolved_a_sha, EMPTY_TREE_SHA);
1540 assert_eq!(diff.entries.len(), 2);
1541 assert!(
1542 diff.entries
1543 .iter()
1544 .all(|e| matches!(e, EntityDiff::Added { .. })),
1545 "every entity surfaces as added on first-sync diff"
1546 );
1547 }
1548}