use std::collections::HashMap;
use std::path::Path;
use serde::Serialize;
use crate::entity::EntityId;
use crate::entity::id::file_path_to_id;
use crate::ops::WarningHint;
use crate::ops::agent_notes::CommitNote;
use crate::store::Store;
use crate::vcs::VcsError;
pub use memstead_base::ops::{
BackendChanges, ChangeEnvelope, EMPTY_TREE_SHA, RENAME_SIMILARITY_DEFAULT,
RENAME_SIMILARITY_MAX, RENAME_SIMILARITY_MIN,
};
#[derive(Debug, Clone, Serialize)]
pub struct ChangesReport {
pub mem: String,
pub since: String,
pub head: String,
pub changes: Vec<ChangeEnvelope>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<WarningHint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<Vec<crate::ops::agent_notes::CommitNote>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memstead_ref: Option<String>,
}
pub fn changes_since(
store: &Store,
mem_name: &str,
git_dir: &Path,
since: &str,
rename_similarity: f32,
head_ref: Option<&str>,
) -> Result<ChangesReport, VcsError> {
let repo = gix::open(git_dir)?;
let resolve_since = match head_ref {
Some(_) => crate::ops::diff::normalise_ref_for_mem(mem_name, since),
None => since.to_string(),
};
let head_lookup: Result<gix::Commit<'_>, ()> = match head_ref {
Some(ref_name) => repo
.rev_parse_single(ref_name)
.ok()
.and_then(|id| id.object().ok())
.and_then(|obj| obj.try_into_commit().ok())
.ok_or(()),
None => repo.head_commit().map_err(|_| ()),
};
let (head_sha, head_tree) = match head_lookup {
Ok(c) => {
let sha = c.id.to_hex().to_string();
let tree = c
.tree()
.map_err(|e| VcsError::Git(format!("head tree: {e}")))?;
(sha, tree)
}
Err(()) => (EMPTY_TREE_SHA.to_string(), repo.empty_tree()),
};
let notes_report =
crate::ops::agent_notes::agent_notes_since(mem_name, git_dir, &resolve_since, head_ref)?;
let rename_map = build_authoritative_rename_map(¬es_report.notes);
let since_tree = if resolve_since == EMPTY_TREE_SHA {
repo.empty_tree()
} else {
let id = repo
.rev_parse_single(resolve_since.as_str())
.map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
let object = id
.object()
.map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
let commit = object
.try_into_commit()
.map_err(|_| VcsError::ObjectNotFound(format!("{since} is not a commit")))?;
commit
.tree()
.map_err(|e| VcsError::Git(format!("since tree: {e}")))?
};
if since_tree.id == head_tree.id {
return Ok(ChangesReport {
mem: mem_name.to_string(),
since: since.to_string(),
head: head_sha,
changes: Vec::new(),
warnings: Vec::new(),
notes: Some(notes_report.notes),
memstead_ref: notes_report.memstead_ref,
});
}
let mut platform = since_tree
.changes()
.map_err(|e| VcsError::Git(format!("diff init: {e}")))?;
let rewrites = gix::diff::Rewrites {
copies: None,
percentage: Some(rename_similarity),
limit: 1000,
track_empty: false,
};
platform.options(|opts| {
opts.track_rewrites(Some(rewrites));
});
let mut additions: Vec<EntityId> = Vec::new();
let mut deletions: Vec<EntityId> = Vec::new();
let mut modifications: Vec<EntityId> = Vec::new();
let mut gix_rewrites: Vec<(EntityId, EntityId)> = Vec::new();
platform
.for_each_to_obtain_tree(
&head_tree,
|change| -> Result<std::ops::ControlFlow<()>, std::convert::Infallible> {
use gix::object::tree::diff::Change;
match change {
Change::Addition { location, .. } => {
if let Some(id) = path_to_entity_id(mem_name, location) {
additions.push(id);
}
}
Change::Deletion { location, .. } => {
if let Some(id) = path_to_entity_id(mem_name, location) {
deletions.push(id);
}
}
Change::Modification { location, .. } => {
if let Some(id) = path_to_entity_id(mem_name, location) {
modifications.push(id);
}
}
Change::Rewrite {
source_location,
location,
..
} => {
let from = path_to_entity_id(mem_name, source_location);
let to = path_to_entity_id(mem_name, location);
if let (Some(from_id), Some(to_id)) = (from, to) {
gix_rewrites.push((from_id, to_id));
}
}
}
Ok(std::ops::ControlFlow::Continue(()))
},
)
.map_err(|e| VcsError::Git(format!("diff: {e}")))?;
let envelopes = combine_with_rename_map(
store,
rename_map,
additions,
deletions,
modifications,
gix_rewrites,
);
Ok(ChangesReport {
mem: mem_name.to_string(),
since: since.to_string(),
head: head_sha,
changes: envelopes,
warnings: Vec::new(),
notes: Some(notes_report.notes),
memstead_ref: notes_report.memstead_ref,
})
}
fn build_authoritative_rename_map(notes: &[CommitNote]) -> HashMap<EntityId, EntityId> {
let mut forward: HashMap<EntityId, EntityId> = HashMap::new();
let mut reverse: HashMap<EntityId, EntityId> = HashMap::new();
for note in 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)) = parse_rename_entity_field(id_str) else {
continue;
};
let origin = reverse.remove(&old_id).unwrap_or_else(|| old_id.clone());
if origin != old_id {
forward.remove(&origin);
}
forward.insert(origin.clone(), new_id.clone());
reverse.insert(new_id, origin);
}
forward
}
pub(super) fn parse_rename_entity_field(field: &str) -> Option<(EntityId, EntityId)> {
if field.contains("(cross-mem rewrite") {
return None;
}
let mut parts = field.splitn(2, " → ");
let old = parts.next()?.trim();
let new = parts.next()?.trim();
if old.is_empty() || new.is_empty() {
return None;
}
Some((EntityId(old.to_string()), EntityId(new.to_string())))
}
fn combine_with_rename_map(
store: &Store,
rename_map: HashMap<EntityId, EntityId>,
additions: Vec<EntityId>,
deletions: Vec<EntityId>,
modifications: Vec<EntityId>,
gix_rewrites: Vec<(EntityId, EntityId)>,
) -> Vec<ChangeEnvelope> {
use std::collections::HashSet;
let addition_set: HashSet<&EntityId> = additions.iter().collect();
let deletion_set: HashSet<&EntityId> = deletions.iter().collect();
let mut envelopes: Vec<ChangeEnvelope> = Vec::new();
let mut absorbed_add: HashSet<EntityId> = HashSet::new();
let mut absorbed_del: HashSet<EntityId> = HashSet::new();
for (old_id, new_id) in &rename_map {
let has_old_del = deletion_set.contains(old_id);
let has_new_add = addition_set.contains(new_id);
if has_old_del && has_new_add {
envelopes.push(ChangeEnvelope::Renamed {
from_id: old_id.clone(),
to_id: new_id.clone(),
title: title_for(store, new_id),
entity_type: type_for(store, new_id),
});
absorbed_add.insert(new_id.clone());
absorbed_del.insert(old_id.clone());
} else if has_old_del {
envelopes.push(ChangeEnvelope::Removed {
id: old_id.clone(),
title: None,
entity_type: None,
});
absorbed_del.insert(old_id.clone());
}
}
for (from_id, to_id) in gix_rewrites {
if absorbed_del.contains(&from_id) || absorbed_add.contains(&to_id) {
continue;
}
envelopes.push(ChangeEnvelope::Renamed {
title: title_for(store, &to_id),
entity_type: type_for(store, &to_id),
from_id,
to_id,
});
}
for id in additions {
if absorbed_add.contains(&id) {
continue;
}
envelopes.push(ChangeEnvelope::Added {
title: title_for(store, &id),
entity_type: type_for(store, &id),
id,
});
}
for id in deletions {
if absorbed_del.contains(&id) {
continue;
}
envelopes.push(ChangeEnvelope::Removed {
id,
title: None,
entity_type: None,
});
}
for id in modifications {
envelopes.push(ChangeEnvelope::Updated {
title: title_for(store, &id),
entity_type: type_for(store, &id),
id,
});
}
envelopes
}
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 title_for(store: &Store, id: &EntityId) -> Option<String> {
store.get(id).map(|e| e.title.clone())
}
fn type_for(store: &Store, id: &EntityId) -> Option<String> {
store.get(id).map(|e| e.entity_type.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::agent_notes::CommitNote;
fn rename_note(old: &str, new: &str) -> CommitNote {
CommitNote {
mem: "specs".to_string(),
sha: "abc".to_string(),
subject: format!("memstead: rename {old} → {new}"),
tool_verb: Some("rename".to_string()),
entity_id: Some(format!("{old} → {new}")),
note: None,
actor: None,
tool: None,
client: None,
logical_operation_id: None,
role: None,
entity_ids: Vec::new(),
timestamp: 0,
}
}
#[test]
fn parse_rename_entity_field_extracts_old_and_new_ids() {
let parsed = parse_rename_entity_field("specs--old-name → specs--new-name").unwrap();
assert_eq!(parsed.0.0, "specs--old-name");
assert_eq!(parsed.1.0, "specs--new-name");
}
#[test]
fn parse_rename_entity_field_rejects_cross_mem_rewrite_qualifier() {
assert!(
parse_rename_entity_field("specs--old → specs--new (cross-mem rewrite in `other`)")
.is_none()
);
}
#[test]
fn build_authoritative_rename_map_collapses_transitive_chain() {
let notes = vec![
rename_note("specs--b", "specs--c"),
rename_note("specs--a", "specs--b"),
];
let map = build_authoritative_rename_map(¬es);
assert_eq!(map.len(), 1, "transitive collapse: {map:?}");
let final_target = map
.get(&EntityId("specs--a".to_string()))
.expect("composed map keyed on original source");
assert_eq!(final_target.0, "specs--c");
}
#[test]
fn build_authoritative_rename_map_skips_cross_mem_peer_commits() {
let mut peer = rename_note("specs--a", "specs--b");
peer.subject =
"memstead: rename specs--a → specs--b (cross-mem rewrite in `peers`)".to_string();
peer.entity_id = Some("specs--a → specs--b (cross-mem rewrite in `peers`)".to_string());
let map = build_authoritative_rename_map(&[peer]);
assert!(
map.is_empty(),
"cross-mem peer commit must not enter the map: {map:?}"
);
}
#[test]
fn build_authoritative_rename_map_ignores_non_rename_verbs() {
let note = CommitNote {
mem: "specs".to_string(),
sha: "abc".to_string(),
subject: "memstead: update specs--foo".to_string(),
tool_verb: Some("update".to_string()),
entity_id: Some("specs--foo".to_string()),
note: None,
actor: None,
tool: None,
client: None,
logical_operation_id: None,
role: None,
entity_ids: Vec::new(),
timestamp: 0,
};
let map = build_authoritative_rename_map(&[note]);
assert!(map.is_empty(), "non-rename verbs ignored: {map:?}");
}
#[test]
fn combine_with_rename_map_pairs_authoritative_add_and_del() {
use crate::store::Store;
let store = Store::new();
let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
rename_map.insert(
EntityId("specs--a".to_string()),
EntityId("specs--b".to_string()),
);
let envelopes = combine_with_rename_map(
&store,
rename_map,
vec![EntityId("specs--b".to_string())],
vec![EntityId("specs--a".to_string())],
Vec::new(),
Vec::new(),
);
assert_eq!(envelopes.len(), 1);
match &envelopes[0] {
ChangeEnvelope::Renamed { from_id, to_id, .. } => {
assert_eq!(from_id.0, "specs--a");
assert_eq!(to_id.0, "specs--b");
}
other => panic!("expected Renamed, got {other:?}"),
}
}
#[test]
fn combine_with_rename_map_rename_then_delete_emits_removed() {
use crate::store::Store;
let store = Store::new();
let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
rename_map.insert(
EntityId("specs--a".to_string()),
EntityId("specs--b".to_string()),
);
let envelopes = combine_with_rename_map(
&store,
rename_map,
Vec::new(),
vec![EntityId("specs--a".to_string())],
Vec::new(),
Vec::new(),
);
assert_eq!(envelopes.len(), 1);
match &envelopes[0] {
ChangeEnvelope::Removed { id, .. } => {
assert_eq!(id.0, "specs--a");
}
other => panic!("expected Removed, got {other:?}"),
}
}
#[test]
fn combine_with_rename_map_keeps_gix_rewrites_as_fallback() {
use crate::store::Store;
let store = Store::new();
let envelopes = combine_with_rename_map(
&store,
HashMap::new(),
Vec::new(),
Vec::new(),
Vec::new(),
vec![(
EntityId("specs--external-old".to_string()),
EntityId("specs--external-new".to_string()),
)],
);
assert_eq!(envelopes.len(), 1);
match &envelopes[0] {
ChangeEnvelope::Renamed { from_id, to_id, .. } => {
assert_eq!(from_id.0, "specs--external-old");
assert_eq!(to_id.0, "specs--external-new");
}
other => panic!("expected Renamed from gix fallback, got {other:?}"),
}
}
#[test]
fn combine_with_rename_map_authoritative_wins_over_gix_rewrite() {
use crate::store::Store;
let store = Store::new();
let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
rename_map.insert(
EntityId("specs--a".to_string()),
EntityId("specs--c".to_string()),
);
let envelopes = combine_with_rename_map(
&store,
rename_map,
vec![EntityId("specs--c".to_string())],
vec![EntityId("specs--a".to_string())],
Vec::new(),
vec![(
EntityId("specs--a".to_string()),
EntityId("specs--b".to_string()),
)],
);
assert_eq!(envelopes.len(), 1);
match &envelopes[0] {
ChangeEnvelope::Renamed { from_id, to_id, .. } => {
assert_eq!(from_id.0, "specs--a");
assert_eq!(to_id.0, "specs--c");
}
other => panic!("expected note-driven Renamed, got {other:?}"),
}
}
#[test]
fn path_to_entity_id_strips_md_and_prefixes_mem() {
let bstr = gix::bstr::BString::from("architecture/result.md");
let id = path_to_entity_id("specs", bstr.as_ref()).unwrap();
assert_eq!(id.0, "specs--architecture/result");
}
#[test]
fn path_to_entity_id_skips_memstead_internal_files() {
let bstr = gix::bstr::BString::from(".memstead/config.json");
assert!(path_to_entity_id("specs", bstr.as_ref()).is_none());
let internal_md = gix::bstr::BString::from(".memstead/notes.md");
assert!(path_to_entity_id("specs", internal_md.as_ref()).is_none());
}
#[test]
fn path_to_entity_id_skips_non_markdown() {
let bstr = gix::bstr::BString::from("image.png");
assert!(path_to_entity_id("specs", bstr.as_ref()).is_none());
}
#[test]
fn empty_tree_sentinel_is_canonical_git_hash() {
assert_eq!(EMPTY_TREE_SHA.len(), 40);
assert!(EMPTY_TREE_SHA.starts_with("4b825dc6"));
}
}