1use std::collections::HashMap;
20use std::path::Path;
21
22use serde::Serialize;
23
24use crate::entity::EntityId;
25use crate::entity::id::file_path_to_id;
26use crate::ops::WarningHint;
27use crate::ops::agent_notes::CommitNote;
28use crate::store::Store;
29use crate::vcs::VcsError;
30
31pub use memstead_base::ops::{
38 BackendChanges, ChangeEnvelope, EMPTY_TREE_SHA, RENAME_SIMILARITY_DEFAULT,
39 RENAME_SIMILARITY_MAX, RENAME_SIMILARITY_MIN,
40};
41
42#[derive(Debug, Clone, Serialize)]
55pub struct ChangesReport {
56 pub mem: String,
57 pub since: String,
58 pub head: String,
59 pub changes: Vec<ChangeEnvelope>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub warnings: Vec<WarningHint>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub notes: Option<Vec<crate::ops::agent_notes::CommitNote>>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub memstead_ref: Option<String>,
66}
67
68pub fn changes_since(
79 store: &Store,
80 mem_name: &str,
81 git_dir: &Path,
82 since: &str,
83 rename_similarity: f32,
84 head_ref: Option<&str>,
85) -> Result<ChangesReport, VcsError> {
86 let repo = gix::open(git_dir)?;
87
88 let resolve_since = match head_ref {
95 Some(_) => crate::ops::diff::normalise_ref_for_mem(mem_name, since),
96 None => since.to_string(),
97 };
98
99 let head_lookup: Result<gix::Commit<'_>, ()> = match head_ref {
107 Some(ref_name) => repo
108 .rev_parse_single(ref_name)
109 .ok()
110 .and_then(|id| id.object().ok())
111 .and_then(|obj| obj.try_into_commit().ok())
112 .ok_or(()),
113 None => repo.head_commit().map_err(|_| ()),
114 };
115 let (head_sha, head_tree) = match head_lookup {
116 Ok(c) => {
117 let sha = c.id.to_hex().to_string();
118 let tree = c
119 .tree()
120 .map_err(|e| VcsError::Git(format!("head tree: {e}")))?;
121 (sha, tree)
122 }
123 Err(()) => (EMPTY_TREE_SHA.to_string(), repo.empty_tree()),
124 };
125
126 let notes_report =
135 crate::ops::agent_notes::agent_notes_since(mem_name, git_dir, &resolve_since, head_ref)?;
136 let rename_map = build_authoritative_rename_map(¬es_report.notes);
137
138 let since_tree = if resolve_since == EMPTY_TREE_SHA {
142 repo.empty_tree()
143 } else {
144 let id = repo
145 .rev_parse_single(resolve_since.as_str())
146 .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
147 let object = id
148 .object()
149 .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
150 let commit = object
151 .try_into_commit()
152 .map_err(|_| VcsError::ObjectNotFound(format!("{since} is not a commit")))?;
153 commit
154 .tree()
155 .map_err(|e| VcsError::Git(format!("since tree: {e}")))?
156 };
157
158 if since_tree.id == head_tree.id {
164 return Ok(ChangesReport {
165 mem: mem_name.to_string(),
166 since: since.to_string(),
167 head: head_sha,
168 changes: Vec::new(),
169 warnings: Vec::new(),
170 notes: Some(notes_report.notes),
171 memstead_ref: notes_report.memstead_ref,
172 });
173 }
174
175 let mut platform = since_tree
180 .changes()
181 .map_err(|e| VcsError::Git(format!("diff init: {e}")))?;
182 let rewrites = gix::diff::Rewrites {
183 copies: None,
184 percentage: Some(rename_similarity),
185 limit: 1000,
186 track_empty: false,
187 };
188 platform.options(|opts| {
189 opts.track_rewrites(Some(rewrites));
190 });
191
192 let mut additions: Vec<EntityId> = Vec::new();
196 let mut deletions: Vec<EntityId> = Vec::new();
197 let mut modifications: Vec<EntityId> = Vec::new();
198 let mut gix_rewrites: Vec<(EntityId, EntityId)> = Vec::new();
199 platform
200 .for_each_to_obtain_tree(
201 &head_tree,
202 |change| -> Result<std::ops::ControlFlow<()>, std::convert::Infallible> {
203 use gix::object::tree::diff::Change;
204 match change {
205 Change::Addition { location, .. } => {
206 if let Some(id) = path_to_entity_id(mem_name, location) {
207 additions.push(id);
208 }
209 }
210 Change::Deletion { location, .. } => {
211 if let Some(id) = path_to_entity_id(mem_name, location) {
212 deletions.push(id);
213 }
214 }
215 Change::Modification { location, .. } => {
216 if let Some(id) = path_to_entity_id(mem_name, location) {
217 modifications.push(id);
218 }
219 }
220 Change::Rewrite {
221 source_location,
222 location,
223 ..
224 } => {
225 let from = path_to_entity_id(mem_name, source_location);
226 let to = path_to_entity_id(mem_name, location);
227 if let (Some(from_id), Some(to_id)) = (from, to) {
228 gix_rewrites.push((from_id, to_id));
229 }
230 }
231 }
232 Ok(std::ops::ControlFlow::Continue(()))
233 },
234 )
235 .map_err(|e| VcsError::Git(format!("diff: {e}")))?;
236
237 let envelopes = combine_with_rename_map(
238 store,
239 rename_map,
240 additions,
241 deletions,
242 modifications,
243 gix_rewrites,
244 );
245
246 Ok(ChangesReport {
247 mem: mem_name.to_string(),
248 since: since.to_string(),
249 head: head_sha,
250 changes: envelopes,
251 warnings: Vec::new(),
252 notes: Some(notes_report.notes),
253 memstead_ref: notes_report.memstead_ref,
254 })
255}
256
257fn build_authoritative_rename_map(notes: &[CommitNote]) -> HashMap<EntityId, EntityId> {
273 let mut forward: HashMap<EntityId, EntityId> = HashMap::new();
274 let mut reverse: HashMap<EntityId, EntityId> = HashMap::new();
275 for note in notes.iter().rev() {
276 if note.tool_verb.as_deref() != Some("rename") {
277 continue;
278 }
279 let Some(id_str) = note.entity_id.as_deref() else {
280 continue;
281 };
282 let Some((old_id, new_id)) = parse_rename_entity_field(id_str) else {
283 continue;
284 };
285 let origin = reverse.remove(&old_id).unwrap_or_else(|| old_id.clone());
289 if origin != old_id {
290 forward.remove(&origin);
291 }
292 forward.insert(origin.clone(), new_id.clone());
293 reverse.insert(new_id, origin);
294 }
295 forward
296}
297
298pub(super) fn parse_rename_entity_field(field: &str) -> Option<(EntityId, EntityId)> {
302 if field.contains("(cross-mem rewrite") {
303 return None;
304 }
305 let mut parts = field.splitn(2, " → ");
306 let old = parts.next()?.trim();
307 let new = parts.next()?.trim();
308 if old.is_empty() || new.is_empty() {
309 return None;
310 }
311 Some((EntityId(old.to_string()), EntityId(new.to_string())))
312}
313
314fn combine_with_rename_map(
326 store: &Store,
327 rename_map: HashMap<EntityId, EntityId>,
328 additions: Vec<EntityId>,
329 deletions: Vec<EntityId>,
330 modifications: Vec<EntityId>,
331 gix_rewrites: Vec<(EntityId, EntityId)>,
332) -> Vec<ChangeEnvelope> {
333 use std::collections::HashSet;
334 let addition_set: HashSet<&EntityId> = additions.iter().collect();
335 let deletion_set: HashSet<&EntityId> = deletions.iter().collect();
336
337 let mut envelopes: Vec<ChangeEnvelope> = Vec::new();
338 let mut absorbed_add: HashSet<EntityId> = HashSet::new();
339 let mut absorbed_del: HashSet<EntityId> = HashSet::new();
340
341 for (old_id, new_id) in &rename_map {
343 let has_old_del = deletion_set.contains(old_id);
344 let has_new_add = addition_set.contains(new_id);
345 if has_old_del && has_new_add {
346 envelopes.push(ChangeEnvelope::Renamed {
347 from_id: old_id.clone(),
348 to_id: new_id.clone(),
349 title: title_for(store, new_id),
350 entity_type: type_for(store, new_id),
351 });
352 absorbed_add.insert(new_id.clone());
353 absorbed_del.insert(old_id.clone());
354 } else if has_old_del {
355 envelopes.push(ChangeEnvelope::Removed {
358 id: old_id.clone(),
359 title: None,
360 entity_type: None,
361 });
362 absorbed_del.insert(old_id.clone());
363 }
364 }
369
370 for (from_id, to_id) in gix_rewrites {
374 if absorbed_del.contains(&from_id) || absorbed_add.contains(&to_id) {
375 continue;
376 }
377 envelopes.push(ChangeEnvelope::Renamed {
378 title: title_for(store, &to_id),
379 entity_type: type_for(store, &to_id),
380 from_id,
381 to_id,
382 });
383 }
384
385 for id in additions {
387 if absorbed_add.contains(&id) {
388 continue;
389 }
390 envelopes.push(ChangeEnvelope::Added {
391 title: title_for(store, &id),
392 entity_type: type_for(store, &id),
393 id,
394 });
395 }
396 for id in deletions {
397 if absorbed_del.contains(&id) {
398 continue;
399 }
400 envelopes.push(ChangeEnvelope::Removed {
401 id,
402 title: None,
403 entity_type: None,
404 });
405 }
406 for id in modifications {
407 envelopes.push(ChangeEnvelope::Updated {
408 title: title_for(store, &id),
409 entity_type: type_for(store, &id),
410 id,
411 });
412 }
413 envelopes
414}
415
416fn path_to_entity_id(mem: &str, path: &gix::bstr::BStr) -> Option<EntityId> {
420 let s = std::str::from_utf8(path.as_ref()).ok()?;
421 if s.is_empty() || !s.ends_with(".md") {
422 return None;
423 }
424 if s.starts_with(".memstead/") {
426 return None;
427 }
428 Some(file_path_to_id(s, mem))
429}
430
431fn title_for(store: &Store, id: &EntityId) -> Option<String> {
435 store.get(id).map(|e| e.title.clone())
436}
437
438fn type_for(store: &Store, id: &EntityId) -> Option<String> {
441 store.get(id).map(|e| e.entity_type.clone())
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::ops::agent_notes::CommitNote;
448
449 fn rename_note(old: &str, new: &str) -> CommitNote {
450 CommitNote {
451 mem: "specs".to_string(),
452 sha: "abc".to_string(),
453 subject: format!("memstead: rename {old} → {new}"),
454 tool_verb: Some("rename".to_string()),
455 entity_id: Some(format!("{old} → {new}")),
456 note: None,
457 actor: None,
458 tool: None,
459 client: None,
460 logical_operation_id: None,
461 entity_ids: Vec::new(),
462 timestamp: 0,
463 }
464 }
465
466 #[test]
467 fn parse_rename_entity_field_extracts_old_and_new_ids() {
468 let parsed = parse_rename_entity_field("specs--old-name → specs--new-name").unwrap();
469 assert_eq!(parsed.0.0, "specs--old-name");
470 assert_eq!(parsed.1.0, "specs--new-name");
471 }
472
473 #[test]
474 fn parse_rename_entity_field_rejects_cross_mem_rewrite_qualifier() {
475 assert!(
479 parse_rename_entity_field("specs--old → specs--new (cross-mem rewrite in `other`)")
480 .is_none()
481 );
482 }
483
484 #[test]
485 fn build_authoritative_rename_map_collapses_transitive_chain() {
486 let notes = vec![
494 rename_note("specs--b", "specs--c"),
495 rename_note("specs--a", "specs--b"),
496 ];
497 let map = build_authoritative_rename_map(¬es);
498 assert_eq!(map.len(), 1, "transitive collapse: {map:?}");
499 let final_target = map
500 .get(&EntityId("specs--a".to_string()))
501 .expect("composed map keyed on original source");
502 assert_eq!(final_target.0, "specs--c");
503 }
504
505 #[test]
506 fn build_authoritative_rename_map_skips_cross_mem_peer_commits() {
507 let mut peer = rename_note("specs--a", "specs--b");
512 peer.subject =
513 "memstead: rename specs--a → specs--b (cross-mem rewrite in `peers`)".to_string();
514 peer.entity_id = Some("specs--a → specs--b (cross-mem rewrite in `peers`)".to_string());
515 let map = build_authoritative_rename_map(&[peer]);
516 assert!(
517 map.is_empty(),
518 "cross-mem peer commit must not enter the map: {map:?}"
519 );
520 }
521
522 #[test]
523 fn build_authoritative_rename_map_ignores_non_rename_verbs() {
524 let note = CommitNote {
525 mem: "specs".to_string(),
526 sha: "abc".to_string(),
527 subject: "memstead: update specs--foo".to_string(),
528 tool_verb: Some("update".to_string()),
529 entity_id: Some("specs--foo".to_string()),
530 note: None,
531 actor: None,
532 tool: None,
533 client: None,
534 logical_operation_id: None,
535 entity_ids: Vec::new(),
536 timestamp: 0,
537 };
538 let map = build_authoritative_rename_map(&[note]);
539 assert!(map.is_empty(), "non-rename verbs ignored: {map:?}");
540 }
541
542 #[test]
543 fn combine_with_rename_map_pairs_authoritative_add_and_del() {
544 use crate::store::Store;
545 let store = Store::new();
546 let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
547 rename_map.insert(
548 EntityId("specs--a".to_string()),
549 EntityId("specs--b".to_string()),
550 );
551 let envelopes = combine_with_rename_map(
552 &store,
553 rename_map,
554 vec![EntityId("specs--b".to_string())],
555 vec![EntityId("specs--a".to_string())],
556 Vec::new(),
557 Vec::new(),
558 );
559 assert_eq!(envelopes.len(), 1);
560 match &envelopes[0] {
561 ChangeEnvelope::Renamed { from_id, to_id, .. } => {
562 assert_eq!(from_id.0, "specs--a");
563 assert_eq!(to_id.0, "specs--b");
564 }
565 other => panic!("expected Renamed, got {other:?}"),
566 }
567 }
568
569 #[test]
570 fn combine_with_rename_map_rename_then_delete_emits_removed() {
571 use crate::store::Store;
576 let store = Store::new();
577 let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
578 rename_map.insert(
579 EntityId("specs--a".to_string()),
580 EntityId("specs--b".to_string()),
581 );
582 let envelopes = combine_with_rename_map(
583 &store,
584 rename_map,
585 Vec::new(),
586 vec![EntityId("specs--a".to_string())],
587 Vec::new(),
588 Vec::new(),
589 );
590 assert_eq!(envelopes.len(), 1);
591 match &envelopes[0] {
592 ChangeEnvelope::Removed { id, .. } => {
593 assert_eq!(id.0, "specs--a");
594 }
595 other => panic!("expected Removed, got {other:?}"),
596 }
597 }
598
599 #[test]
600 fn combine_with_rename_map_keeps_gix_rewrites_as_fallback() {
601 use crate::store::Store;
607 let store = Store::new();
608 let envelopes = combine_with_rename_map(
609 &store,
610 HashMap::new(),
611 Vec::new(),
612 Vec::new(),
613 Vec::new(),
614 vec![(
615 EntityId("specs--external-old".to_string()),
616 EntityId("specs--external-new".to_string()),
617 )],
618 );
619 assert_eq!(envelopes.len(), 1);
620 match &envelopes[0] {
621 ChangeEnvelope::Renamed { from_id, to_id, .. } => {
622 assert_eq!(from_id.0, "specs--external-old");
623 assert_eq!(to_id.0, "specs--external-new");
624 }
625 other => panic!("expected Renamed from gix fallback, got {other:?}"),
626 }
627 }
628
629 #[test]
630 fn combine_with_rename_map_authoritative_wins_over_gix_rewrite() {
631 use crate::store::Store;
638 let store = Store::new();
639 let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
640 rename_map.insert(
641 EntityId("specs--a".to_string()),
642 EntityId("specs--c".to_string()),
643 );
644 let envelopes = combine_with_rename_map(
645 &store,
646 rename_map,
647 vec![EntityId("specs--c".to_string())],
648 vec![EntityId("specs--a".to_string())],
649 Vec::new(),
650 vec![(
651 EntityId("specs--a".to_string()),
652 EntityId("specs--b".to_string()),
653 )],
654 );
655 assert_eq!(envelopes.len(), 1);
659 match &envelopes[0] {
660 ChangeEnvelope::Renamed { from_id, to_id, .. } => {
661 assert_eq!(from_id.0, "specs--a");
662 assert_eq!(to_id.0, "specs--c");
663 }
664 other => panic!("expected note-driven Renamed, got {other:?}"),
665 }
666 }
667
668 #[test]
669 fn path_to_entity_id_strips_md_and_prefixes_mem() {
670 let bstr = gix::bstr::BString::from("architecture/result.md");
671 let id = path_to_entity_id("specs", bstr.as_ref()).unwrap();
672 assert_eq!(id.0, "specs--architecture/result");
673 }
674
675 #[test]
676 fn path_to_entity_id_skips_memstead_internal_files() {
677 let bstr = gix::bstr::BString::from(".memstead/config.json");
678 assert!(path_to_entity_id("specs", bstr.as_ref()).is_none());
679 let internal_md = gix::bstr::BString::from(".memstead/notes.md");
681 assert!(path_to_entity_id("specs", internal_md.as_ref()).is_none());
682 }
683
684 #[test]
685 fn path_to_entity_id_skips_non_markdown() {
686 let bstr = gix::bstr::BString::from("image.png");
687 assert!(path_to_entity_id("specs", bstr.as_ref()).is_none());
688 }
689
690 #[test]
691 fn empty_tree_sentinel_is_canonical_git_hash() {
692 assert_eq!(EMPTY_TREE_SHA.len(), 40);
693 assert!(EMPTY_TREE_SHA.starts_with("4b825dc6"));
695 }
696}