1use std::path::Path;
26
27use crate::ops::changes::EMPTY_TREE_SHA;
28use crate::vcs::VcsError;
29
30pub use memstead_base::ops::agent_notes::{AgentNotesReport, CommitNote};
34
35pub fn read_memstead_ref(git_dir: &Path) -> Result<Option<String>, VcsError> {
40 let repo = gix::open(git_dir)?;
41 Ok(repo
42 .rev_parse_single("refs/heads/__MEMSTEAD")
43 .ok()
44 .map(|id| id.to_hex().to_string()))
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ParsedCommit {
61 pub subject: String,
62 pub tool_verb: Option<String>,
63 pub entity_id: Option<String>,
64 pub note: Option<String>,
65 pub actor: Option<String>,
66 pub tool: Option<String>,
67 pub client: Option<String>,
68 pub logical_operation_id: Option<String>,
71 pub entity_ids: Vec<String>,
76}
77
78pub fn parse_commit_message(body: &str) -> ParsedCommit {
91 let trimmed = body.trim_matches('\n');
92 let mut lines = trimmed.split('\n');
93 let subject = lines.next().unwrap_or("").trim_end().to_string();
94
95 let (tool_verb, entity_id) = parse_subject(&subject);
100
101 let body_lines: Vec<&str> = lines.collect();
104 let first_trailer_idx = body_lines.iter().position(|l| is_trailer_line(l));
105
106 let note_slice = match first_trailer_idx {
107 Some(idx) => &body_lines[..idx],
108 None => &body_lines[..],
109 };
110 let note = collect_note(note_slice);
111
112 let mut tool: Option<String> = None;
113 let mut actor: Option<String> = None;
114 let mut client: Option<String> = None;
115 let mut logical_operation_id: Option<String> = None;
116 let mut entity_ids: Vec<String> = Vec::new();
117 if let Some(start) = first_trailer_idx {
118 for line in &body_lines[start..] {
119 if let Some((key, value)) = split_trailer(line) {
120 match key {
121 "Tool" if tool.is_none() => tool = Some(value.to_string()),
122 "Actor" if actor.is_none() => actor = Some(value.to_string()),
123 "Client" if client.is_none() => client = Some(value.to_string()),
124 "Logical-Op" if logical_operation_id.is_none() => {
125 logical_operation_id = Some(value.to_string());
126 }
127 "Entities" if entity_ids.is_empty() => {
128 entity_ids = value
129 .split(',')
130 .map(str::trim)
131 .filter(|s| !s.is_empty())
132 .map(str::to_string)
133 .collect();
134 }
135 _ => {}
136 }
137 }
138 }
139 }
140
141 ParsedCommit {
142 subject,
143 tool_verb,
144 entity_id,
145 note,
146 actor,
147 tool,
148 client,
149 logical_operation_id,
150 entity_ids,
151 }
152}
153
154fn parse_subject(subject: &str) -> (Option<String>, Option<String>) {
155 let rest = match subject.strip_prefix("memstead:") {
157 Some(r) => r.trim_start(),
158 None => return (None, None),
159 };
160 let mut parts = rest.splitn(2, char::is_whitespace);
161 let verb = match parts.next() {
162 Some(v) if !v.is_empty() => v.to_string(),
163 _ => return (None, None),
164 };
165 let remainder = parts.next().unwrap_or("").trim().to_string();
166 let entity_id = if remainder.is_empty() {
167 None
168 } else {
169 Some(remainder)
170 };
171 (Some(verb), entity_id)
172}
173
174fn is_trailer_line(line: &str) -> bool {
175 split_trailer(line).is_some()
176}
177
178fn split_trailer(line: &str) -> Option<(&str, &str)> {
182 let colon = line.find(':')?;
183 let (key, rest) = line.split_at(colon);
184 if key.is_empty() {
185 return None;
186 }
187 let mut chars = key.chars();
188 let first = chars.next()?;
189 if !first.is_ascii_uppercase() {
190 return None;
191 }
192 if !chars.all(|c| c.is_ascii_alphabetic() || c == '-') {
193 return None;
194 }
195 let value_with_colon = &rest[1..];
196 let value = value_with_colon.strip_prefix(' ')?.trim_end();
197 if value.is_empty() {
198 return None;
199 }
200 Some((key, value))
201}
202
203fn collect_note(slice: &[&str]) -> Option<String> {
204 let mut start = 0;
207 while start < slice.len() && slice[start].trim().is_empty() {
208 start += 1;
209 }
210 let mut end = slice.len();
211 while end > start && slice[end - 1].trim().is_empty() {
212 end -= 1;
213 }
214 if start == end {
215 return None;
216 }
217 let joined = slice[start..end].join("\n");
218 let trimmed = joined.trim();
219 if trimmed.is_empty() {
220 None
221 } else {
222 Some(trimmed.to_string())
223 }
224}
225
226pub fn agent_notes_since(
239 mem_name: &str,
240 git_dir: &Path,
241 since: &str,
242 head_ref: Option<&str>,
243) -> Result<AgentNotesReport, VcsError> {
244 let repo = gix::open(git_dir)?;
245
246 let head_lookup: Result<gix::Commit<'_>, ()> = match head_ref {
247 Some(ref_name) => repo
248 .rev_parse_single(ref_name)
249 .ok()
250 .and_then(|id| id.object().ok())
251 .and_then(|obj| obj.try_into_commit().ok())
252 .ok_or(()),
253 None => repo.head_commit().map_err(|_| ()),
254 };
255 let head_commit = match head_lookup {
256 Ok(c) => c,
257 Err(()) => {
258 let memstead_ref = read_memstead_ref(git_dir)?;
259 return Ok(AgentNotesReport {
260 mem: mem_name.to_string(),
261 since: since.to_string(),
262 head: EMPTY_TREE_SHA.to_string(),
263 notes: Vec::new(),
264 memstead_ref,
265 });
266 }
267 };
268 let head_sha = head_commit.id.to_hex().to_string();
269
270 let hidden: Vec<gix::ObjectId> = if since == EMPTY_TREE_SHA {
275 Vec::new()
276 } else {
277 let id = repo
278 .rev_parse_single(since)
279 .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
280 let object = id
283 .object()
284 .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
285 object
286 .try_into_commit()
287 .map_err(|_| VcsError::ObjectNotFound(format!("{since} is not a commit")))?;
288 vec![id.detach()]
289 };
290
291 let walk = repo
292 .rev_walk([head_commit.id])
293 .with_hidden(hidden)
294 .all()
295 .map_err(|e| VcsError::Git(format!("rev-walk: {e}")))?;
296
297 let mut notes: Vec<CommitNote> = Vec::new();
298 for info in walk {
299 let info = info.map_err(|e| VcsError::Git(format!("rev-walk-step: {e}")))?;
300 let commit = info
301 .object()
302 .map_err(|e| VcsError::Git(format!("commit-load: {e}")))?;
303 let sha = commit.id.to_hex().to_string();
304 let timestamp = commit.time().map(|t| t.seconds).unwrap_or(0);
305
306 let body_string = match commit.message_raw() {
310 Ok(bstr) => std::str::from_utf8(bstr.as_ref())
311 .map(|s| s.to_string())
312 .unwrap_or_default(),
313 Err(_) => String::new(),
314 };
315
316 let parsed = parse_commit_message(&body_string);
317 notes.push(CommitNote {
318 mem: mem_name.to_string(),
319 sha,
320 subject: parsed.subject,
321 tool_verb: parsed.tool_verb,
322 entity_id: parsed.entity_id,
323 note: parsed.note,
324 actor: parsed.actor,
325 tool: parsed.tool,
326 client: parsed.client,
327 logical_operation_id: parsed.logical_operation_id,
328 entity_ids: parsed.entity_ids,
329 timestamp,
330 });
331 }
332
333 let memstead_ref = read_memstead_ref(git_dir)?;
334
335 Ok(AgentNotesReport {
336 mem: mem_name.to_string(),
337 since: since.to_string(),
338 head: head_sha,
339 notes,
340 memstead_ref,
341 })
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn ctx() -> crate::vcs::CommitContext<'static> {
349 crate::vcs::CommitContext {
350 actor: crate::vcs::Actor::Agent,
351 client: Some(crate::vcs::ClientId {
352 name: "claude-code".into(),
353 version: "2.1.0".into(),
354 }),
355 tool: Some("memstead_create"),
356 note: Some("Demoting drift hook to engine surface.".into()),
357 logical_operation_id: None,
358 entity_ids: None,
359 }
360 }
361
362 #[test]
363 fn parser_round_trips_logical_operation_id_trailer() {
364 let ctx = crate::vcs::CommitContext {
371 actor: crate::vcs::Actor::Agent,
372 client: Some(crate::vcs::ClientId {
373 name: "claude-code".into(),
374 version: "2.1.0".into(),
375 }),
376 tool: Some("rename_entity"),
377 note: None,
378 logical_operation_id: Some("logop-abc123def456"),
379 entity_ids: None,
380 };
381 let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
382 assert!(
383 raw.contains("Logical-Op: logop-abc123def456"),
384 "format_commit_message must emit the Logical-Op trailer; got:\n{raw}"
385 );
386 let parsed = parse_commit_message(&raw);
387 assert_eq!(
388 parsed.logical_operation_id.as_deref(),
389 Some("logop-abc123def456"),
390 "parser must reconstruct the Logical-Op trailer; got: {:?}",
391 parsed.logical_operation_id
392 );
393 }
394
395 #[test]
396 fn parser_round_trips_entities_trailer() {
397 let ctx = crate::vcs::CommitContext {
401 actor: crate::vcs::Actor::Cli,
402 client: None,
403 tool: Some("batch_update"),
404 note: None,
405 logical_operation_id: None,
406 entity_ids: Some(vec![
407 "specs--alpha".to_string(),
408 "specs--beta".to_string(),
409 "memos--gamma".to_string(),
410 ]),
411 };
412 let raw = crate::vcs::format_commit_message("memstead: batch-update (3 entities)", &ctx);
413 assert!(
414 raw.contains("Entities: specs--alpha, specs--beta, memos--gamma"),
415 "format_commit_message must emit the Entities trailer; got:\n{raw}"
416 );
417 let parsed = parse_commit_message(&raw);
418 assert_eq!(parsed.entity_id.as_deref(), Some("(3 entities)"));
420 assert_eq!(
422 parsed.entity_ids,
423 vec!["specs--alpha", "specs--beta", "memos--gamma"],
424 "parser must reconstruct the Entities trailer; got: {:?}",
425 parsed.entity_ids
426 );
427 }
428
429 #[test]
430 fn parser_leaves_entity_ids_empty_without_trailer() {
431 let raw = crate::vcs::format_commit_message("memstead: update specs--solo", &ctx());
434 let parsed = parse_commit_message(&raw);
435 assert!(
436 parsed.entity_ids.is_empty(),
437 "no trailer → empty: {:?}",
438 parsed.entity_ids
439 );
440 assert_eq!(parsed.entity_id.as_deref(), Some("specs--solo"));
441 }
442
443 #[test]
444 fn parser_round_trips_format_commit_message_with_note() {
445 let ctx = ctx();
446 let raw = crate::vcs::format_commit_message("memstead: create specs--demo", &ctx);
447 let parsed = parse_commit_message(&raw);
448 assert_eq!(parsed.subject, "memstead: create specs--demo");
449 assert_eq!(parsed.tool_verb.as_deref(), Some("create"));
450 assert_eq!(parsed.entity_id.as_deref(), Some("specs--demo"));
451 assert_eq!(parsed.tool.as_deref(), Some("memstead_create"));
452 assert_eq!(parsed.actor.as_deref(), Some("agent"));
453 assert_eq!(parsed.client.as_deref(), Some("claude-code@2.1.0"));
454 assert_eq!(
455 parsed.note.as_deref(),
456 Some("Demoting drift hook to engine surface.")
457 );
458 }
459
460 #[test]
461 fn parser_round_trips_format_commit_message_without_note() {
462 let ctx = crate::vcs::CommitContext {
463 actor: crate::vcs::Actor::External,
464 client: None,
465 tool: None,
466 note: None,
467 logical_operation_id: None,
468 entity_ids: None,
469 };
470 let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
471 let parsed = parse_commit_message(&raw);
472 assert_eq!(parsed.subject, "memstead: rename a → b");
473 assert_eq!(parsed.tool_verb.as_deref(), Some("rename"));
474 assert_eq!(parsed.entity_id.as_deref(), Some("a → b"));
475 assert_eq!(parsed.actor.as_deref(), Some("external"));
476 assert!(parsed.note.is_none());
477 assert!(parsed.tool.is_none());
478 assert!(parsed.client.is_none());
479 }
480
481 #[test]
482 fn parser_handles_unrecognized_subject() {
483 let parsed = parse_commit_message("hand-typed external drift\n");
484 assert_eq!(parsed.subject, "hand-typed external drift");
485 assert!(parsed.tool_verb.is_none());
486 assert!(parsed.entity_id.is_none());
487 assert!(parsed.actor.is_none());
488 }
489
490 #[test]
491 fn parser_recognises_multiline_note() {
492 let body = "\
493memstead: update specs--alpha
494
495First line of the note.
496Second line of the note.
497
498Tool: memstead_update
499Actor: agent
500Client: claude-code@2.1.0
501";
502 let parsed = parse_commit_message(body);
503 assert_eq!(
504 parsed.note.as_deref(),
505 Some("First line of the note.\nSecond line of the note.")
506 );
507 assert_eq!(parsed.actor.as_deref(), Some("agent"));
508 }
509
510 #[test]
511 fn parser_tolerates_subject_only_body() {
512 let parsed = parse_commit_message("memstead: update specs--alpha\n");
513 assert_eq!(parsed.subject, "memstead: update specs--alpha");
514 assert_eq!(parsed.tool_verb.as_deref(), Some("update"));
515 assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
516 assert!(parsed.note.is_none());
517 assert!(parsed.actor.is_none());
518 }
519
520 #[test]
521 fn parser_treats_lowercase_keys_as_body() {
522 let body = "\
526memstead: update specs--alpha
527
528note line one
529tool: this is prose, not a trailer
530
531Actor: agent
532";
533 let parsed = parse_commit_message(body);
534 assert_eq!(
535 parsed.note.as_deref(),
536 Some("note line one\ntool: this is prose, not a trailer")
537 );
538 assert_eq!(parsed.actor.as_deref(), Some("agent"));
539 assert!(parsed.tool.is_none());
540 }
541
542 #[test]
543 fn parser_skips_trailer_without_value() {
544 assert!(!is_trailer_line("Foo:"));
547 assert!(!is_trailer_line("Foo: "));
548 assert!(is_trailer_line("Foo: bar"));
549 }
550
551 }