use std::collections::{HashMap, HashSet};
use std::path::Path;
use gix::object::tree::diff::Change;
use memstead_base::backend::BackendError;
use memstead_base::entity::EntityId;
use memstead_base::entity::id::file_path_to_id;
use memstead_base::entity::parser::{extract_inline_links_lenient, peek_title_and_type};
use memstead_base::ops::{Diff, DiffConfig, EntityDiff, IncomingRipple};
use crate::EMPTY_TREE_SHA;
pub(crate) fn normalise_ref_for_mem(mem: &str, raw: &str) -> String {
if let Some(rest) = raw.strip_prefix("HEAD")
&& (rest.is_empty() || rest.starts_with(['~', '^', ':', '@']))
{
return format!("refs/heads/{mem}{rest}");
}
raw.to_string()
}
fn resolve_tree<'r>(repo: &'r gix::Repository, raw: &str) -> Result<gix::Tree<'r>, BackendError> {
if raw == EMPTY_TREE_SHA {
return Ok(repo.empty_tree());
}
let id = repo
.rev_parse_single(raw)
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
let object = id
.object()
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
let commit = object
.try_into_commit()
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw} is not a commit")))?;
commit
.tree()
.map_err(|e| BackendError::Other(format!("tree({raw}): {e}")))
}
fn sha_for(repo: &gix::Repository, raw: &str) -> Result<String, BackendError> {
if raw == EMPTY_TREE_SHA {
return Ok(EMPTY_TREE_SHA.to_string());
}
let id = repo
.rev_parse_single(raw)
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {raw}")))?;
Ok(id.detach().to_string())
}
fn path_to_entity_id(mem: &str, path: &gix::bstr::BStr) -> Option<EntityId> {
let s = std::str::from_utf8(path.as_ref()).ok()?;
if s.is_empty() || !s.ends_with(".md") {
return None;
}
if s.starts_with(".memstead/") {
return None;
}
Some(file_path_to_id(s, mem))
}
fn read_md_at_path(
_repo: &gix::Repository,
tree: &gix::Tree<'_>,
path: &gix::bstr::BStr,
) -> Option<String> {
let entry = tree
.lookup_entry_by_path(path.to_string().as_str())
.ok()??;
let object = entry.object().ok()?;
let blob = object.try_into_blob().ok()?;
String::from_utf8(blob.data.clone()).ok()
}
fn peek_meta(raw: &Option<String>) -> (Option<String>, Option<String>) {
raw.as_deref()
.map(peek_title_and_type)
.unwrap_or((None, None))
}
pub fn diff_two_refs(
gitdir: &Path,
mem: &str,
ref_a: &str,
ref_b: &str,
config: &DiffConfig,
) -> Result<Diff, BackendError> {
if !gitdir.is_dir() {
return Err(BackendError::Other(format!(
"gitdir not found: {}",
gitdir.display()
)));
}
let repo = gix::open(gitdir).map_err(|e| BackendError::Other(format!("gix open: {e}")))?;
let normalised_a = normalise_ref_for_mem(mem, ref_a);
let normalised_b = normalise_ref_for_mem(mem, ref_b);
let tree_a = resolve_tree(&repo, &normalised_a)?;
let tree_b = resolve_tree(&repo, &normalised_b)?;
let resolved_a_sha = sha_for(&repo, &normalised_a)?;
let resolved_b_sha = sha_for(&repo, &normalised_b)?;
let mut platform = tree_a
.changes()
.map_err(|e| BackendError::Other(format!("diff init: {e}")))?;
let rewrites = gix::diff::Rewrites {
copies: None,
percentage: Some(config.rename_similarity),
limit: 1000,
track_empty: false,
};
platform.options(|opts| {
opts.track_rewrites(Some(rewrites));
});
let mut entries: Vec<EntityDiff> = Vec::new();
platform
.for_each_to_obtain_tree(
&tree_b,
|change| -> Result<std::ops::ControlFlow<()>, std::convert::Infallible> {
match change {
Change::Addition { location, .. } => {
if let Some(id) = path_to_entity_id(mem, location) {
let raw = read_md_at_path(&repo, &tree_b, location);
let (title, entity_type) = peek_meta(&raw);
let content_after = if config.include_content { raw } else { None };
entries.push(EntityDiff::Added {
id,
title,
entity_type,
content_after,
ripple: Vec::new(),
});
}
}
Change::Deletion { location, .. } => {
if let Some(id) = path_to_entity_id(mem, location) {
let raw = read_md_at_path(&repo, &tree_a, location);
let (title, entity_type) = peek_meta(&raw);
let content_before = if config.include_content { raw } else { None };
entries.push(EntityDiff::Deleted {
id,
title,
entity_type,
content_before,
ripple: Vec::new(),
});
}
}
Change::Modification { location, .. } => {
if let Some(id) = path_to_entity_id(mem, location) {
let raw_b = read_md_at_path(&repo, &tree_b, location);
let (title, entity_type) = peek_meta(&raw_b);
let content_before = if config.include_content {
read_md_at_path(&repo, &tree_a, location)
} else {
None
};
let content_after = if config.include_content { raw_b } else { None };
entries.push(EntityDiff::Modified {
id,
title,
entity_type,
content_before,
content_after,
ripple: Vec::new(),
});
}
}
Change::Rewrite {
source_location,
location,
..
} => {
let from = path_to_entity_id(mem, source_location);
let to = path_to_entity_id(mem, location);
if let (Some(from_id), Some(to_id)) = (from, to) {
let raw_b = read_md_at_path(&repo, &tree_b, location);
let (title, entity_type) = peek_meta(&raw_b);
let content_before = if config.include_content {
read_md_at_path(&repo, &tree_a, source_location)
} else {
None
};
let content_after = if config.include_content { raw_b } else { None };
entries.push(EntityDiff::Renamed {
from_id,
to_id,
rename_chain: Vec::new(),
title,
entity_type,
content_before,
content_after,
ripple: Vec::new(),
});
}
}
}
Ok(std::ops::ControlFlow::Continue(()))
},
)
.map_err(|e| BackendError::Other(format!("diff: {e}")))?;
apply_agent_notes_renames(gitdir, mem, ref_a, ref_b, &mut entries, config);
if config.include_content {
demote_invalid_entries(&mut entries);
}
entries.sort_by_key(primary_id);
if config.include_ripple {
let affected = collect_affected_ids(&entries);
if !affected.is_empty() {
let ripple_a = scan_tree_ripple(&repo, &tree_a, mem, &affected, "ref_a");
let ripple_b = scan_tree_ripple(&repo, &tree_b, mem, &affected, "ref_b");
attach_ripple(&mut entries, &ripple_a, &ripple_b);
}
}
Ok(Diff {
ref_a: ref_a.to_string(),
ref_b: ref_b.to_string(),
resolved_a_sha,
resolved_b_sha,
config: config.clone(),
entries,
})
}
fn apply_agent_notes_renames(
gitdir: &Path,
mem: &str,
ref_a: &str,
ref_b: &str,
entries: &mut Vec<EntityDiff>,
config: &DiffConfig,
) {
let report = match crate::ops::agent_notes::agent_notes_since(mem, gitdir, ref_a, Some(ref_b)) {
Ok(r) => r,
Err(_) => return,
};
let mut forward: HashMap<EntityId, EntityId> = HashMap::new();
for note in report.notes.iter().rev() {
if note.tool_verb.as_deref() != Some("rename") {
continue;
}
let Some(id_str) = note.entity_id.as_deref() else {
continue;
};
let Some((old_id, new_id)) = crate::ops::changes::parse_rename_entity_field(id_str) else {
continue;
};
forward.insert(old_id, new_id);
}
if forward.is_empty() {
return;
}
let mut deleted_idx: HashMap<EntityId, usize> = HashMap::new();
let mut added_idx: HashMap<EntityId, usize> = HashMap::new();
for (idx, entry) in entries.iter().enumerate() {
match entry {
EntityDiff::Deleted { id, .. } => {
deleted_idx.insert(id.clone(), idx);
}
EntityDiff::Added { id, .. } => {
added_idx.insert(id.clone(), idx);
}
_ => {}
}
}
let mut to_remove: Vec<usize> = Vec::new();
let mut promotions: Vec<(usize, EntityDiff)> = Vec::new();
for origin in forward.keys() {
let Some(&del_idx) = deleted_idx.get(origin) else {
continue;
};
let mut chain: Vec<EntityId> = Vec::new();
let mut current = origin.clone();
let final_id: Option<EntityId> = loop {
let Some(next) = forward.get(¤t) else {
break None;
};
if added_idx.contains_key(next) {
break Some(next.clone());
}
chain.push(next.clone());
current = next.clone();
};
let Some(terminal) = final_id else { continue };
let Some(&add_idx) = added_idx.get(&terminal) else {
continue;
};
let (content_before, content_after) = if config.include_content {
let cb = match &entries[del_idx] {
EntityDiff::Deleted { content_before, .. } => content_before.clone(),
_ => None,
};
let ca = match &entries[add_idx] {
EntityDiff::Added { content_after, .. } => content_after.clone(),
_ => None,
};
(cb, ca)
} else {
(None, None)
};
let (title, entity_type) = match &entries[add_idx] {
EntityDiff::Added {
title, entity_type, ..
} => (title.clone(), entity_type.clone()),
_ => (None, None),
};
let promoted = EntityDiff::Renamed {
from_id: origin.clone(),
to_id: terminal.clone(),
rename_chain: chain,
title,
entity_type,
content_before,
content_after,
ripple: Vec::new(),
};
promotions.push((add_idx, promoted));
to_remove.push(del_idx);
}
for (idx, promoted) in promotions {
entries[idx] = promoted;
}
to_remove.sort_by(|a, b| b.cmp(a));
for idx in to_remove {
entries.remove(idx);
}
for entry in entries.iter_mut() {
if let EntityDiff::Renamed {
from_id,
to_id,
rename_chain,
..
} = entry
&& rename_chain.is_empty()
{
let mut chain: Vec<EntityId> = Vec::new();
let mut current = from_id.clone();
while let Some(next) = forward.get(¤t) {
if next == to_id {
break;
}
chain.push(next.clone());
current = next.clone();
}
if forward.get(¤t).is_some_and(|n| n == to_id) {
*rename_chain = chain;
}
}
}
}
fn classify_parse_failure(content: &str) -> Option<String> {
let trimmed_bom = content.trim_start_matches('\u{FEFF}');
if !trimmed_bom.starts_with("---") {
return Some("missing frontmatter (body does not open with `---`)".to_string());
}
let after_open = match trimmed_bom.strip_prefix("---") {
Some(rest) => rest.trim_start_matches('\r').trim_start_matches('\n'),
None => return Some("missing frontmatter".to_string()),
};
let mut closed = false;
for line in after_open.lines() {
if line.trim_end() == "---" {
closed = true;
break;
}
}
if !closed {
return Some("malformed frontmatter (no closing `---` line)".to_string());
}
None
}
fn demote_invalid_entries(entries: &mut [EntityDiff]) {
for entry in entries.iter_mut() {
match entry {
EntityDiff::Added {
id, content_after, ..
} => {
if let Some(c) = content_after.as_ref()
&& let Some(err) = classify_parse_failure(c)
{
*entry = EntityDiff::InvalidEntity {
id: id.clone(),
side: "ref_b".to_string(),
error: err,
content_before: None,
content_after: content_after.clone(),
};
}
}
EntityDiff::Deleted {
id, content_before, ..
} => {
if let Some(c) = content_before.as_ref()
&& let Some(err) = classify_parse_failure(c)
{
*entry = EntityDiff::InvalidEntity {
id: id.clone(),
side: "ref_a".to_string(),
error: err,
content_before: content_before.clone(),
content_after: None,
};
}
}
EntityDiff::Modified {
id,
content_before,
content_after,
..
} => {
let err_a = content_before
.as_ref()
.and_then(|c| classify_parse_failure(c));
let err_b = content_after
.as_ref()
.and_then(|c| classify_parse_failure(c));
if err_a.is_some() || err_b.is_some() {
let (side, error) = match (err_a, err_b) {
(Some(a), Some(b)) => {
("both".to_string(), format!("{a} (ref_a); {b} (ref_b)"))
}
(Some(a), None) => ("ref_a".to_string(), a),
(None, Some(b)) => ("ref_b".to_string(), b),
(None, None) => unreachable!(),
};
*entry = EntityDiff::InvalidEntity {
id: id.clone(),
side,
error,
content_before: content_before.clone(),
content_after: content_after.clone(),
};
}
}
EntityDiff::Renamed { .. } | EntityDiff::InvalidEntity { .. } => {}
}
}
}
fn collect_affected_ids(entries: &[EntityDiff]) -> HashSet<EntityId> {
let mut out = HashSet::new();
for e in entries {
match e {
EntityDiff::Added { id, .. }
| EntityDiff::Modified { id, .. }
| EntityDiff::Deleted { id, .. }
| EntityDiff::InvalidEntity { id, .. } => {
out.insert(id.clone());
}
EntityDiff::Renamed { from_id, to_id, .. } => {
out.insert(from_id.clone());
out.insert(to_id.clone());
}
}
}
out
}
fn scan_tree_ripple(
repo: &gix::Repository,
tree: &gix::Tree<'_>,
mem: &str,
affected: &HashSet<EntityId>,
side: &str,
) -> HashMap<EntityId, Vec<IncomingRipple>> {
let mut out: HashMap<EntityId, Vec<IncomingRipple>> = HashMap::new();
let entries = match tree.traverse().breadthfirst.files() {
Ok(e) => e,
Err(_) => return out,
};
for entry in entries {
if !entry.mode.is_blob() {
continue;
}
let path = match std::str::from_utf8(entry.filepath.as_slice()) {
Ok(s) => s,
Err(_) => continue,
};
if !path.ends_with(".md") || path.starts_with(".memstead/") {
continue;
}
let referrer_id = file_path_to_id(path, mem);
let object = match repo.find_object(entry.oid) {
Ok(o) => o,
Err(_) => continue,
};
let blob = match object.try_into_blob() {
Ok(b) => b,
Err(_) => continue,
};
let content = match std::str::from_utf8(&blob.data) {
Ok(s) => s,
Err(_) => continue,
};
for target in extract_inline_links_lenient(content, mem) {
if target == referrer_id {
continue;
}
if affected.contains(&target) {
out.entry(target).or_default().push(IncomingRipple {
from_id: referrer_id.clone(),
side: side.to_string(),
section: None,
});
}
}
}
out
}
fn attach_ripple(
entries: &mut [EntityDiff],
ripple_a: &HashMap<EntityId, Vec<IncomingRipple>>,
ripple_b: &HashMap<EntityId, Vec<IncomingRipple>>,
) {
for entry in entries.iter_mut() {
match entry {
EntityDiff::Added { id, ripple, .. }
| EntityDiff::Modified { id, ripple, .. }
| EntityDiff::Deleted { id, ripple, .. } => {
if let Some(list) = ripple_a.get(id) {
ripple.extend(list.iter().cloned());
}
if let Some(list) = ripple_b.get(id) {
ripple.extend(list.iter().cloned());
}
}
EntityDiff::Renamed {
from_id,
to_id,
ripple,
..
} => {
if let Some(list) = ripple_a.get(from_id) {
ripple.extend(list.iter().cloned());
}
if let Some(list) = ripple_b.get(to_id) {
ripple.extend(list.iter().cloned());
}
}
EntityDiff::InvalidEntity { .. } => {}
}
}
}
fn primary_id(entry: &EntityDiff) -> String {
match entry {
EntityDiff::Added { id, .. }
| EntityDiff::Modified { id, .. }
| EntityDiff::Deleted { id, .. }
| EntityDiff::InvalidEntity { id, .. } => id.to_string(),
EntityDiff::Renamed { to_id, .. } => to_id.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::MemWriter;
use crate::storage::git_tree::GitTreeMemWriter;
use crate::vcs::CommitContext;
use std::path::PathBuf;
use tempfile::TempDir;
fn init_gitdir(tmp: &TempDir) -> PathBuf {
let gitdir = tmp.path().join("mem-repo").join(".git");
std::fs::create_dir_all(&gitdir).unwrap();
gix::init_bare(&gitdir).unwrap();
gitdir
}
fn body_with_title(title: &str) -> String {
let unique = title.repeat(64);
format!(
"---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# {title}\n\n## Identity\n\n{unique}\n"
)
}
fn write_and_commit(
gitdir: &Path,
mem: &str,
entries: &[(&str, &str)],
subject: &str,
) -> String {
let writer = GitTreeMemWriter::new(gitdir.to_path_buf(), format!("refs/heads/{mem}"));
for (path, content) in entries {
writer
.write_entity(Path::new(path), content.as_bytes())
.unwrap();
}
writer.commit(subject, &CommitContext::internal()).unwrap()
}
#[test]
fn diff_unknown_ref_returns_unknown_ref_marker() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let err = diff_two_refs(
&gitdir,
"specs",
"no-such-ref",
"no-such-other",
&DiffConfig::default(),
)
.unwrap_err();
match err {
BackendError::Other(msg) => {
assert!(
msg.starts_with("UNKNOWN_REF:"),
"expected UNKNOWN_REF marker, got: {msg}",
);
}
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn normalise_rewrites_only_the_head_token() {
assert_eq!(normalise_ref_for_mem("v", "HEAD"), "refs/heads/v");
assert_eq!(normalise_ref_for_mem("v", "HEAD~5"), "refs/heads/v~5");
assert_eq!(normalise_ref_for_mem("v", "HEAD^"), "refs/heads/v^");
assert_eq!(
normalise_ref_for_mem("v", "HEAD^{tree}"),
"refs/heads/v^{tree}"
);
assert_eq!(normalise_ref_for_mem("v", "HEAD@{1}"), "refs/heads/v@{1}");
assert_eq!(normalise_ref_for_mem("v", "HEADER"), "HEADER");
assert_eq!(normalise_ref_for_mem("v", "HEAD-foo"), "HEAD-foo");
assert_eq!(normalise_ref_for_mem("v", "main"), "main");
assert_eq!(normalise_ref_for_mem("v", "deadbeef"), "deadbeef");
}
#[test]
fn diff_head_revspec_anchors_on_mem_branch() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha"))],
"c1",
);
write_and_commit(
&gitdir,
"specs",
&[("beta.md", &body_with_title("Beta"))],
"c2 add beta",
);
let diff = diff_two_refs(&gitdir, "specs", "HEAD~1", "HEAD", &DiffConfig::default())
.expect("HEAD revspec must resolve against the mem branch");
assert_eq!(diff.entries.len(), 1, "only beta added between c1 and c2");
assert!(
matches!(diff.entries[0], EntityDiff::Added { .. }),
"the one change is beta added: {:?}",
diff.entries[0]
);
}
#[test]
fn diff_added_modified_deleted_surface() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[
("alpha.md", &body_with_title("Alpha")),
("beta.md", &body_with_title("Beta-v0")),
],
"seed",
);
let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
write_and_commit(
&gitdir,
"specs",
&[
("beta.md", &body_with_title("Beta-v1")),
("gamma.md", &body_with_title("Gamma")),
],
"update beta + add gamma",
);
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer.delete_entity(Path::new("alpha.md")).unwrap();
writer
.commit("drop alpha", &CommitContext::internal())
.unwrap();
let diff = diff_two_refs(
&gitdir,
"specs",
&sha_a,
"refs/heads/specs",
&DiffConfig::default(),
)
.unwrap();
assert_eq!(diff.entries.len(), 3, "Add+Modify+Delete expected");
let statuses: Vec<&str> = diff
.entries
.iter()
.map(|e| match e {
EntityDiff::Added { .. } => "added",
EntityDiff::Modified { .. } => "modified",
EntityDiff::Deleted { .. } => "deleted",
EntityDiff::Renamed { .. } => "renamed",
EntityDiff::InvalidEntity { .. } => "invalid",
})
.collect();
assert_eq!(statuses, vec!["deleted", "modified", "added"]);
let beta = diff
.entries
.iter()
.find(|e| matches!(e, EntityDiff::Modified { .. }))
.unwrap();
match beta {
EntityDiff::Modified {
content_before,
content_after,
..
} => {
assert!(content_before.as_ref().unwrap().contains("Beta-v0"));
assert!(content_after.as_ref().unwrap().contains("Beta-v1"));
}
_ => unreachable!(),
}
}
#[test]
fn diff_include_content_false_strips_bodies() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha"))],
"seed",
);
let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha-v2"))],
"rev2",
);
let cfg = DiffConfig {
include_content: false,
..DiffConfig::default()
};
let diff = diff_two_refs(&gitdir, "specs", &sha_seed, "refs/heads/specs", &cfg).unwrap();
assert_eq!(diff.entries.len(), 1);
match &diff.entries[0] {
EntityDiff::Modified {
title,
entity_type,
content_before,
content_after,
..
} => {
assert!(
content_before.is_none(),
"include_content=false elides before"
);
assert!(
content_after.is_none(),
"include_content=false elides after"
);
assert_eq!(
title.as_deref(),
Some("Alpha-v2"),
"title present (from ref_b) even with include_content=false"
);
assert_eq!(
entity_type.as_deref(),
Some("spec"),
"entity_type present even with include_content=false"
);
}
other => panic!("expected Modified, got {other:?}"),
}
}
#[test]
fn diff_populates_title_and_type_with_content_on() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let sha_empty = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha"))],
"seed",
);
let cfg = DiffConfig {
include_content: true,
..DiffConfig::default()
};
let diff = diff_two_refs(&gitdir, "specs", sha_empty, "refs/heads/specs", &cfg).unwrap();
assert_eq!(diff.entries.len(), 1);
match &diff.entries[0] {
EntityDiff::Added {
title,
entity_type,
content_after,
..
} => {
assert_eq!(title.as_deref(), Some("Alpha"), "title populated on Added");
assert_eq!(
entity_type.as_deref(),
Some("spec"),
"entity_type populated"
);
assert!(
content_after.is_some(),
"content_after present and additive to the metadata fields"
);
}
other => panic!("expected Added, got {other:?}"),
}
}
#[test]
fn diff_rename_chain_collapses_multi_step_engine_authored_renames() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let body = body_with_title("Title");
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer
.write_entity(Path::new("alpha.md"), body.as_bytes())
.unwrap();
writer.commit("seed", &CommitContext::internal()).unwrap();
let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer.delete_entity(Path::new("alpha.md")).unwrap();
writer
.write_entity(Path::new("beta.md"), body.as_bytes())
.unwrap();
writer
.commit(
"memstead: rename specs--alpha → specs--beta",
&CommitContext::internal(),
)
.unwrap();
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer.delete_entity(Path::new("beta.md")).unwrap();
writer
.write_entity(Path::new("gamma.md"), body.as_bytes())
.unwrap();
writer
.commit(
"memstead: rename specs--beta → specs--gamma",
&CommitContext::internal(),
)
.unwrap();
let diff = diff_two_refs(
&gitdir,
"specs",
&sha_seed,
"refs/heads/specs",
&DiffConfig::default(),
)
.unwrap();
let renamed = diff
.entries
.iter()
.find(|e| matches!(e, EntityDiff::Renamed { .. }))
.expect("a Renamed entry must surface");
match renamed {
EntityDiff::Renamed {
from_id,
to_id,
rename_chain,
..
} => {
assert_eq!(from_id.to_string(), "specs--alpha");
assert_eq!(to_id.to_string(), "specs--gamma");
assert_eq!(
rename_chain
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>(),
vec!["specs--beta".to_string()],
"the multi-step rename's intermediate id must surface in rename_chain",
);
}
_ => unreachable!(),
}
let leftover: Vec<_> = diff
.entries
.iter()
.filter(|e| matches!(e, EntityDiff::Added { .. } | EntityDiff::Deleted { .. }))
.collect();
assert!(
leftover.is_empty(),
"agent-notes promotion must absorb the Added+Deleted pair, got: {leftover:?}",
);
}
#[test]
fn diff_ripple_lists_incoming_wikilinks_on_each_side() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let alpha_v1 = body_with_title("Alpha-v1");
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();
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &alpha_v1), ("beta.md", &beta_links_alpha)],
"seed",
);
let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
let alpha_v2 = body_with_title("Alpha-v2");
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();
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer
.write_entity(Path::new("alpha.md"), alpha_v2.as_bytes())
.unwrap();
writer
.write_entity(Path::new("gamma.md"), gamma_links_alpha.as_bytes())
.unwrap();
writer.delete_entity(Path::new("beta.md")).unwrap();
writer.commit("rev2", &CommitContext::internal()).unwrap();
let diff = diff_two_refs(
&gitdir,
"specs",
&sha_a,
"refs/heads/specs",
&DiffConfig::default(),
)
.unwrap();
let alpha = diff
.entries
.iter()
.find(|e| matches!(e, EntityDiff::Modified { id, .. } if id.to_string() == "specs--alpha"))
.expect("alpha must show as modified");
match alpha {
EntityDiff::Modified { ripple, .. } => {
let mut sides_seen: Vec<String> = ripple
.iter()
.map(|r| format!("{}@{}", r.from_id, r.side))
.collect();
sides_seen.sort();
assert_eq!(
sides_seen,
vec![
"specs--beta@ref_a".to_string(),
"specs--gamma@ref_b".to_string(),
],
"ripple must list beta on ref_a and gamma on ref_b",
);
}
_ => unreachable!(),
}
}
#[test]
fn diff_include_ripple_false_leaves_ripple_empty() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
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";
write_and_commit(
&gitdir,
"specs",
&[
("alpha.md", &body_with_title("Alpha-v1")),
("beta.md", beta_links_alpha),
],
"seed",
);
let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha-v2"))],
"rev2",
);
let cfg = DiffConfig {
include_ripple: false,
..DiffConfig::default()
};
let diff = diff_two_refs(&gitdir, "specs", &sha_a, "refs/heads/specs", &cfg).unwrap();
for entry in &diff.entries {
let ripple = match entry {
EntityDiff::Added { ripple, .. }
| EntityDiff::Modified { ripple, .. }
| EntityDiff::Deleted { ripple, .. }
| EntityDiff::Renamed { ripple, .. } => ripple.clone(),
EntityDiff::InvalidEntity { .. } => Vec::new(),
};
assert!(
ripple.is_empty(),
"include_ripple=false must produce empty ripple lists: {entry:?}",
);
}
}
#[test]
fn diff_invalid_entity_surfaces_for_missing_frontmatter() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha-v1"))],
"seed",
);
let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer
.write_entity(
Path::new("alpha.md"),
b"# Alpha but no frontmatter\n\nbody.\n",
)
.unwrap();
writer.commit("break", &CommitContext::internal()).unwrap();
let diff = diff_two_refs(
&gitdir,
"specs",
&sha_a,
"refs/heads/specs",
&DiffConfig::default(),
)
.unwrap();
let alpha = diff.entries.first().expect("alpha should appear");
match alpha {
EntityDiff::InvalidEntity {
id, side, error, ..
} => {
assert_eq!(id.to_string(), "specs--alpha");
assert_eq!(side, "ref_b");
assert!(error.contains("frontmatter"), "unexpected error: {error}");
}
other => panic!("expected InvalidEntity, got {other:?}"),
}
}
#[test]
fn engine_diff_routes_git_branch_mount_through_hook() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha"))],
"seed",
);
let sha_seed = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha-v2"))],
"rev2",
);
let mount = memstead_base::Mount {
mem: "specs".to_string(),
schema: Some(memstead_schema::SchemaRef::new(
"default",
semver::Version::new(1, 0, 0),
)),
storage: memstead_base::MountStorage::GitBranch {
gitdir: gitdir.clone(),
branch: "specs".to_string(),
},
capability: memstead_base::MountCapability::Write,
lifecycle: memstead_base::MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let backend = crate::storage::instantiate_full_backend(&mount).unwrap();
let mut engine = memstead_base::Engine::from_mounts(vec![(mount, backend)]).unwrap();
engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
let diff = engine
.diff("specs", &sha_seed, "refs/heads/specs", None)
.unwrap();
assert_eq!(diff.entries.len(), 1);
assert!(matches!(diff.entries[0], EntityDiff::Modified { .. }));
assert_eq!(diff.resolved_a_sha, sha_seed);
}
#[test]
fn engine_diff_unknown_ref_surfaces_typed_engine_error() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha"))],
"seed",
);
let mount = memstead_base::Mount {
mem: "specs".to_string(),
schema: Some(memstead_schema::SchemaRef::new(
"default",
semver::Version::new(1, 0, 0),
)),
storage: memstead_base::MountStorage::GitBranch {
gitdir: gitdir.clone(),
branch: "specs".to_string(),
},
capability: memstead_base::MountCapability::Write,
lifecycle: memstead_base::MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let backend = crate::storage::instantiate_full_backend(&mount).unwrap();
let mut engine = memstead_base::Engine::from_mounts(vec![(mount, backend)]).unwrap();
engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
let err = engine.diff("specs", "nope-a", "nope-b", None).unwrap_err();
match err {
memstead_base::EngineError::UnknownRef(raw) => {
assert!(raw.contains("nope"), "unexpected UnknownRef payload: {raw}");
}
other => panic!("expected UnknownRef, got {other:?}"),
}
}
#[test]
fn diff_resolves_refs_to_sha_in_response() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha"))],
"seed",
);
let sha = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
let diff = diff_two_refs(
&gitdir,
"specs",
"refs/heads/specs",
"refs/heads/specs",
&DiffConfig::default(),
)
.unwrap();
assert_eq!(diff.resolved_a_sha, sha);
assert_eq!(diff.resolved_b_sha, sha);
assert!(diff.entries.is_empty());
}
#[test]
fn diff_accepts_empty_tree_sentinel_for_ref_a() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[
("alpha.md", &body_with_title("Alpha")),
("beta.md", &body_with_title("Beta")),
],
"seed",
);
let diff = diff_two_refs(
&gitdir,
"specs",
EMPTY_TREE_SHA,
"refs/heads/specs",
&DiffConfig::default(),
)
.expect("empty-tree sentinel must be accepted");
assert_eq!(diff.resolved_a_sha, EMPTY_TREE_SHA);
assert_eq!(diff.entries.len(), 2, "first-sync lists every entity");
for entry in &diff.entries {
assert!(
matches!(entry, EntityDiff::Added { .. }),
"first-sync entries must all be `added`, got: {entry:?}",
);
}
}
#[test]
fn diff_refuses_arbitrary_tree_sha_with_unknown_ref() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha"))],
"seed",
);
let repo = gix::open(&gitdir).unwrap();
let head = repo.rev_parse_single("refs/heads/specs").unwrap();
let head_commit = head.object().unwrap().try_into_commit().unwrap();
let tree_sha = head_commit.tree().unwrap().id.to_string();
assert_ne!(
tree_sha, EMPTY_TREE_SHA,
"tree SHA must differ from sentinel for this test to be meaningful"
);
let err = diff_two_refs(
&gitdir,
"specs",
&tree_sha,
"refs/heads/specs",
&DiffConfig::default(),
)
.unwrap_err();
match err {
BackendError::Other(msg) => assert!(
msg.starts_with("UNKNOWN_REF:"),
"expected UNKNOWN_REF marker, got: {msg}",
),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn diff_resolves_bare_head_to_mem_branch_tip() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[("alpha.md", &body_with_title("Alpha"))],
"seed",
);
let sha_a = sha_for(&gix::open(&gitdir).unwrap(), "refs/heads/specs").unwrap();
write_and_commit(
&gitdir,
"specs",
&[("beta.md", &body_with_title("Beta"))],
"add beta",
);
let via_head = diff_two_refs(&gitdir, "specs", &sha_a, "HEAD", &DiffConfig::default())
.expect("bare HEAD must resolve via mem substitution");
let via_explicit = diff_two_refs(
&gitdir,
"specs",
&sha_a,
"refs/heads/specs",
&DiffConfig::default(),
)
.expect("explicit refs/heads/<mem> still works");
assert_eq!(via_head.resolved_b_sha, via_explicit.resolved_b_sha);
assert_eq!(via_head.entries.len(), via_explicit.entries.len());
}
#[test]
fn diff_empty_tree_sentinel_and_bare_head_compose() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
write_and_commit(
&gitdir,
"specs",
&[
("alpha.md", &body_with_title("Alpha")),
("beta.md", &body_with_title("Beta")),
],
"seed",
);
let diff = diff_two_refs(
&gitdir,
"specs",
EMPTY_TREE_SHA,
"HEAD",
&DiffConfig::default(),
)
.expect("sentinel + HEAD must compose");
assert_eq!(diff.resolved_a_sha, EMPTY_TREE_SHA);
assert_eq!(diff.entries.len(), 2);
assert!(
diff.entries
.iter()
.all(|e| matches!(e, EntityDiff::Added { .. })),
"every entity surfaces as added on first-sync diff"
);
}
}