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 role: None,
462 entity_ids: Vec::new(),
463 timestamp: 0,
464 }
465 }
466
467 #[test]
468 fn parse_rename_entity_field_extracts_old_and_new_ids() {
469 let parsed = parse_rename_entity_field("specs--old-name → specs--new-name").unwrap();
470 assert_eq!(parsed.0.0, "specs--old-name");
471 assert_eq!(parsed.1.0, "specs--new-name");
472 }
473
474 #[test]
475 fn parse_rename_entity_field_rejects_cross_mem_rewrite_qualifier() {
476 assert!(
480 parse_rename_entity_field("specs--old → specs--new (cross-mem rewrite in `other`)")
481 .is_none()
482 );
483 }
484
485 #[test]
486 fn build_authoritative_rename_map_collapses_transitive_chain() {
487 let notes = vec![
495 rename_note("specs--b", "specs--c"),
496 rename_note("specs--a", "specs--b"),
497 ];
498 let map = build_authoritative_rename_map(¬es);
499 assert_eq!(map.len(), 1, "transitive collapse: {map:?}");
500 let final_target = map
501 .get(&EntityId("specs--a".to_string()))
502 .expect("composed map keyed on original source");
503 assert_eq!(final_target.0, "specs--c");
504 }
505
506 #[test]
507 fn build_authoritative_rename_map_skips_cross_mem_peer_commits() {
508 let mut peer = rename_note("specs--a", "specs--b");
513 peer.subject =
514 "memstead: rename specs--a → specs--b (cross-mem rewrite in `peers`)".to_string();
515 peer.entity_id = Some("specs--a → specs--b (cross-mem rewrite in `peers`)".to_string());
516 let map = build_authoritative_rename_map(&[peer]);
517 assert!(
518 map.is_empty(),
519 "cross-mem peer commit must not enter the map: {map:?}"
520 );
521 }
522
523 #[test]
524 fn build_authoritative_rename_map_ignores_non_rename_verbs() {
525 let note = CommitNote {
526 mem: "specs".to_string(),
527 sha: "abc".to_string(),
528 subject: "memstead: update specs--foo".to_string(),
529 tool_verb: Some("update".to_string()),
530 entity_id: Some("specs--foo".to_string()),
531 note: None,
532 actor: None,
533 tool: None,
534 client: None,
535 logical_operation_id: None,
536 role: None,
537 entity_ids: Vec::new(),
538 timestamp: 0,
539 };
540 let map = build_authoritative_rename_map(&[note]);
541 assert!(map.is_empty(), "non-rename verbs ignored: {map:?}");
542 }
543
544 #[test]
545 fn combine_with_rename_map_pairs_authoritative_add_and_del() {
546 use crate::store::Store;
547 let store = Store::new();
548 let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
549 rename_map.insert(
550 EntityId("specs--a".to_string()),
551 EntityId("specs--b".to_string()),
552 );
553 let envelopes = combine_with_rename_map(
554 &store,
555 rename_map,
556 vec![EntityId("specs--b".to_string())],
557 vec![EntityId("specs--a".to_string())],
558 Vec::new(),
559 Vec::new(),
560 );
561 assert_eq!(envelopes.len(), 1);
562 match &envelopes[0] {
563 ChangeEnvelope::Renamed { from_id, to_id, .. } => {
564 assert_eq!(from_id.0, "specs--a");
565 assert_eq!(to_id.0, "specs--b");
566 }
567 other => panic!("expected Renamed, got {other:?}"),
568 }
569 }
570
571 #[test]
572 fn combine_with_rename_map_rename_then_delete_emits_removed() {
573 use crate::store::Store;
578 let store = Store::new();
579 let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
580 rename_map.insert(
581 EntityId("specs--a".to_string()),
582 EntityId("specs--b".to_string()),
583 );
584 let envelopes = combine_with_rename_map(
585 &store,
586 rename_map,
587 Vec::new(),
588 vec![EntityId("specs--a".to_string())],
589 Vec::new(),
590 Vec::new(),
591 );
592 assert_eq!(envelopes.len(), 1);
593 match &envelopes[0] {
594 ChangeEnvelope::Removed { id, .. } => {
595 assert_eq!(id.0, "specs--a");
596 }
597 other => panic!("expected Removed, got {other:?}"),
598 }
599 }
600
601 #[test]
602 fn combine_with_rename_map_keeps_gix_rewrites_as_fallback() {
603 use crate::store::Store;
609 let store = Store::new();
610 let envelopes = combine_with_rename_map(
611 &store,
612 HashMap::new(),
613 Vec::new(),
614 Vec::new(),
615 Vec::new(),
616 vec![(
617 EntityId("specs--external-old".to_string()),
618 EntityId("specs--external-new".to_string()),
619 )],
620 );
621 assert_eq!(envelopes.len(), 1);
622 match &envelopes[0] {
623 ChangeEnvelope::Renamed { from_id, to_id, .. } => {
624 assert_eq!(from_id.0, "specs--external-old");
625 assert_eq!(to_id.0, "specs--external-new");
626 }
627 other => panic!("expected Renamed from gix fallback, got {other:?}"),
628 }
629 }
630
631 #[test]
632 fn combine_with_rename_map_authoritative_wins_over_gix_rewrite() {
633 use crate::store::Store;
640 let store = Store::new();
641 let mut rename_map: HashMap<EntityId, EntityId> = HashMap::new();
642 rename_map.insert(
643 EntityId("specs--a".to_string()),
644 EntityId("specs--c".to_string()),
645 );
646 let envelopes = combine_with_rename_map(
647 &store,
648 rename_map,
649 vec![EntityId("specs--c".to_string())],
650 vec![EntityId("specs--a".to_string())],
651 Vec::new(),
652 vec![(
653 EntityId("specs--a".to_string()),
654 EntityId("specs--b".to_string()),
655 )],
656 );
657 assert_eq!(envelopes.len(), 1);
661 match &envelopes[0] {
662 ChangeEnvelope::Renamed { from_id, to_id, .. } => {
663 assert_eq!(from_id.0, "specs--a");
664 assert_eq!(to_id.0, "specs--c");
665 }
666 other => panic!("expected note-driven Renamed, got {other:?}"),
667 }
668 }
669
670 #[test]
671 fn path_to_entity_id_strips_md_and_prefixes_mem() {
672 let bstr = gix::bstr::BString::from("architecture/result.md");
673 let id = path_to_entity_id("specs", bstr.as_ref()).unwrap();
674 assert_eq!(id.0, "specs--architecture/result");
675 }
676
677 #[test]
678 fn path_to_entity_id_skips_memstead_internal_files() {
679 let bstr = gix::bstr::BString::from(".memstead/config.json");
680 assert!(path_to_entity_id("specs", bstr.as_ref()).is_none());
681 let internal_md = gix::bstr::BString::from(".memstead/notes.md");
683 assert!(path_to_entity_id("specs", internal_md.as_ref()).is_none());
684 }
685
686 #[test]
687 fn path_to_entity_id_skips_non_markdown() {
688 let bstr = gix::bstr::BString::from("image.png");
689 assert!(path_to_entity_id("specs", bstr.as_ref()).is_none());
690 }
691
692 #[test]
693 fn empty_tree_sentinel_is_canonical_git_hash() {
694 assert_eq!(EMPTY_TREE_SHA.len(), 40);
695 assert!(EMPTY_TREE_SHA.starts_with("4b825dc6"));
697 }
698}