Skip to main content

memstead_git_branch/ops/
agent_notes.rs

1//! `agent_notes_since` — walk a mem's branch from a caller-provided
2//! cursor to the current tip and return one [`CommitNote`] per commit
3//! along the way, with the body parsed into structured fields.
4//!
5//! This is the symmetric read-side of [`crate::vcs::format_commit_message`]:
6//! the same engine layer that writes the trailer block (`Tool:`, `Actor:`,
7//! `Client:`) parses it back out. Plugin and outer-repo cursor consumers
8//! that want agent-note bullets read this surface instead of
9//! shelling out to `git log` and re-implementing the trailer parser.
10//!
11//! Subject shapes the engine emits (see callers of `format_commit_message`):
12//! - `memstead: <verb> <id>` (entity CRUD: `create`, `update`, `delete`;
13//!   plus `anchor` for an anchor-only update — sole delta the anchors
14//!   sidecar, so it earns a distinct verb since it yields zero entity
15//!   deltas and is otherwise invisible in the note log)
16//! - `memstead: <verb> <from> → <to>` (`rename`, `relate`, `unrelate`)
17//! - `memstead: mem_<verb> <name>` (lifecycle, with optional ` (config)` /
18//!   ` (seal)` qualifier)
19//!
20//! The parser captures the verb token and the remainder of the subject
21//! verbatim into `entity_id` — callers that need to split rename/relate
22//! arrows do so themselves; the engine does not over-structure here.
23//!
24//! Empty-tree sentinel (`EMPTY_TREE_SHA`) is accepted as `since` and is
25//! treated as "walk every commit reachable from head" — mirrors
26//! [`crate::ops::changes::changes_since`]'s convention.
27
28use std::path::Path;
29
30use crate::ops::changes::EMPTY_TREE_SHA;
31use crate::vcs::VcsError;
32
33// Data shapes live in `memstead-base`. Re-export here so downstream
34// callers that still import `memstead_git_branch::ops::agent_notes::*`
35// (and `memstead_git_branch::{CommitNote, ...}` via lib.rs) keep working.
36pub use memstead_base::ops::agent_notes::{AgentNotesReport, CommitNote};
37
38/// Resolve `refs/heads/__MEMSTEAD` (unified schemas + per-mem configs)
39/// in the mem-repo gitdir shared by every writable mem. Returns
40/// `None` when the ref does not exist — pre-migration workspaces and
41/// fresh repos legitimately have no `__MEMSTEAD` yet.
42pub fn read_memstead_ref(git_dir: &Path) -> Result<Option<String>, VcsError> {
43    let repo = gix::open(git_dir)?;
44    Ok(repo
45        .rev_parse_single("refs/heads/__MEMSTEAD")
46        .ok()
47        .map(|id| id.to_hex().to_string()))
48}
49
50/// Parsed shape of one commit body. Mirrors the layout produced by
51/// [`crate::vcs::format_commit_message`]:
52///
53/// ```text
54/// <subject>
55///
56/// <optional note paragraph>
57///
58/// Tool: <verb>
59/// Actor: <agent|cli|external|unknown>
60/// Client: <name>@<version>
61/// ```
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ParsedCommit {
64    pub subject: String,
65    pub tool_verb: Option<String>,
66    pub entity_id: Option<String>,
67    pub note: Option<String>,
68    pub actor: Option<String>,
69    pub tool: Option<String>,
70    pub client: Option<String>,
71    /// Value of the `Logical-Op:` trailer when present. Round-trips
72    /// with `memstead_base::vcs::format_commit_message`'s emission.
73    pub logical_operation_id: Option<String>,
74    /// Ids from the `Entities:` trailer (multi-entity commits, e.g.
75    /// `batch_update`). Empty when the trailer is absent — single-entity
76    /// commits carry their id in `entity_id` instead. Round-trips with
77    /// `format_commit_message`'s `Entities: id1, id2, …` emission.
78    pub entity_ids: Vec<String>,
79}
80
81/// Parse one commit body — subject on the first line, optional note
82/// paragraph, then a trailer block. Trailers are recognised as lines
83/// matching `<Capitalized>: <value>`. The note is everything between the
84/// subject (after one blank line) and the first trailer (or end of body
85/// if none).
86///
87/// Body input is the raw commit message including subject — the same
88/// shape `git log --format=%B` returns. Trailing newlines are tolerated.
89///
90/// Empty / whitespace-only bodies produce a `ParsedCommit` with an empty
91/// subject and every other field `None` — callers decide whether that
92/// counts as skippable.
93pub fn parse_commit_message(body: &str) -> ParsedCommit {
94    let trimmed = body.trim_matches('\n');
95    let mut lines = trimmed.split('\n');
96    let subject = lines.next().unwrap_or("").trim_end().to_string();
97
98    // Subject parser: `memstead: <verb> <rest>`. The remainder may itself
99    // contain spaces (rename arrow, mem qualifier) — we capture it
100    // verbatim into `entity_id`. Non-engine subjects (e.g. external drift
101    // commits a developer typed by hand) leave both fields `None`.
102    let (tool_verb, entity_id) = parse_subject(&subject);
103
104    // Walk the body looking for the first trailer line; everything before
105    // it (after stripping leading/trailing blank lines) is the note.
106    let body_lines: Vec<&str> = lines.collect();
107    let first_trailer_idx = body_lines.iter().position(|l| is_trailer_line(l));
108
109    let note_slice = match first_trailer_idx {
110        Some(idx) => &body_lines[..idx],
111        None => &body_lines[..],
112    };
113    let note = collect_note(note_slice);
114
115    let mut tool: Option<String> = None;
116    let mut actor: Option<String> = None;
117    let mut client: Option<String> = None;
118    let mut logical_operation_id: Option<String> = None;
119    let mut entity_ids: Vec<String> = Vec::new();
120    if let Some(start) = first_trailer_idx {
121        for line in &body_lines[start..] {
122            if let Some((key, value)) = split_trailer(line) {
123                match key {
124                    "Tool" if tool.is_none() => tool = Some(value.to_string()),
125                    "Actor" if actor.is_none() => actor = Some(value.to_string()),
126                    "Client" if client.is_none() => client = Some(value.to_string()),
127                    "Logical-Op" if logical_operation_id.is_none() => {
128                        logical_operation_id = Some(value.to_string());
129                    }
130                    "Entities" if entity_ids.is_empty() => {
131                        entity_ids = value
132                            .split(',')
133                            .map(str::trim)
134                            .filter(|s| !s.is_empty())
135                            .map(str::to_string)
136                            .collect();
137                    }
138                    _ => {}
139                }
140            }
141        }
142    }
143
144    ParsedCommit {
145        subject,
146        tool_verb,
147        entity_id,
148        note,
149        actor,
150        tool,
151        client,
152        logical_operation_id,
153        entity_ids,
154    }
155}
156
157fn parse_subject(subject: &str) -> (Option<String>, Option<String>) {
158    // The engine writes `memstead:` subjects.
159    let rest = match subject.strip_prefix("memstead:") {
160        Some(r) => r.trim_start(),
161        None => return (None, None),
162    };
163    let mut parts = rest.splitn(2, char::is_whitespace);
164    let verb = match parts.next() {
165        Some(v) if !v.is_empty() => v.to_string(),
166        _ => return (None, None),
167    };
168    let remainder = parts.next().unwrap_or("").trim().to_string();
169    let entity_id = if remainder.is_empty() {
170        None
171    } else {
172        Some(remainder)
173    };
174    (Some(verb), entity_id)
175}
176
177fn is_trailer_line(line: &str) -> bool {
178    split_trailer(line).is_some()
179}
180
181/// Trailer lines match `^[A-Z][A-Za-z-]+:\s.+$`. Returns the key (without
182/// colon) and the value (trimmed) when matched. Mirrors the pattern the
183/// plugin uses today (`/^[A-Z][A-Za-z-]+:\s/`).
184fn split_trailer(line: &str) -> Option<(&str, &str)> {
185    let colon = line.find(':')?;
186    let (key, rest) = line.split_at(colon);
187    if key.is_empty() {
188        return None;
189    }
190    let mut chars = key.chars();
191    let first = chars.next()?;
192    if !first.is_ascii_uppercase() {
193        return None;
194    }
195    if !chars.all(|c| c.is_ascii_alphabetic() || c == '-') {
196        return None;
197    }
198    let value_with_colon = &rest[1..];
199    let value = value_with_colon.strip_prefix(' ')?.trim_end();
200    if value.is_empty() {
201        return None;
202    }
203    Some((key, value))
204}
205
206fn collect_note(slice: &[&str]) -> Option<String> {
207    // Strip leading blank lines (the `\n\n` separator after subject) and
208    // trailing blank lines (the `\n\n` separator before trailers).
209    let mut start = 0;
210    while start < slice.len() && slice[start].trim().is_empty() {
211        start += 1;
212    }
213    let mut end = slice.len();
214    while end > start && slice[end - 1].trim().is_empty() {
215        end -= 1;
216    }
217    if start == end {
218        return None;
219    }
220    let joined = slice[start..end].join("\n");
221    let trimmed = joined.trim();
222    if trimmed.is_empty() {
223        None
224    } else {
225        Some(trimmed.to_string())
226    }
227}
228
229/// Walk the per-mem branch from `since` (exclusive) to the current
230/// branch tip (inclusive) and return one [`CommitNote`] per commit on
231/// the path, parsed via [`parse_commit_message`]. Order: newest first
232/// (matches `git log` default).
233///
234/// `since` may be the canonical empty-tree SHA — in which case every
235/// reachable commit is returned. Empty repos / empty refs return an
236/// empty report with `head` echoing the empty-tree sentinel.
237///
238/// `head_ref` follows the same convention as
239/// [`crate::ops::changes::changes_since`]: pass `Some("refs/heads/<mem>")`
240/// for mem-repo-backed mems, `None` to fall back to the gix HEAD.
241pub fn agent_notes_since(
242    mem_name: &str,
243    git_dir: &Path,
244    since: &str,
245    head_ref: Option<&str>,
246) -> Result<AgentNotesReport, VcsError> {
247    let repo = gix::open(git_dir)?;
248
249    let head_lookup: Result<gix::Commit<'_>, ()> = match head_ref {
250        Some(ref_name) => repo
251            .rev_parse_single(ref_name)
252            .ok()
253            .and_then(|id| id.object().ok())
254            .and_then(|obj| obj.try_into_commit().ok())
255            .ok_or(()),
256        None => repo.head_commit().map_err(|_| ()),
257    };
258    let head_commit = match head_lookup {
259        Ok(c) => c,
260        Err(()) => {
261            let memstead_ref = read_memstead_ref(git_dir)?;
262            return Ok(AgentNotesReport {
263                mem: mem_name.to_string(),
264                since: since.to_string(),
265                head: EMPTY_TREE_SHA.to_string(),
266                notes: Vec::new(),
267                memstead_ref,
268            });
269        }
270    };
271    let head_sha = head_commit.id.to_hex().to_string();
272
273    // Resolve `since` to an ObjectId for the walker's `with_hidden`
274    // boundary. The empty-tree sentinel means "no boundary" — walk every
275    // reachable commit. Unknown / unreachable since refs surface as
276    // `ObjectNotFound` exactly like `changes_since`.
277    let hidden: Vec<gix::ObjectId> = if since == EMPTY_TREE_SHA {
278        Vec::new()
279    } else {
280        let id = repo
281            .rev_parse_single(since)
282            .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
283        // Resolve through to a commit so unreachable refs surface here
284        // rather than at walk time.
285        let object = id
286            .object()
287            .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
288        object
289            .try_into_commit()
290            .map_err(|_| VcsError::ObjectNotFound(format!("{since} is not a commit")))?;
291        vec![id.detach()]
292    };
293
294    let walk = repo
295        .rev_walk([head_commit.id])
296        .with_hidden(hidden)
297        .all()
298        .map_err(|e| VcsError::Git(format!("rev-walk: {e}")))?;
299
300    let mut notes: Vec<CommitNote> = Vec::new();
301    for info in walk {
302        let info = info.map_err(|e| VcsError::Git(format!("rev-walk-step: {e}")))?;
303        let commit = info
304            .object()
305            .map_err(|e| VcsError::Git(format!("commit-load: {e}")))?;
306        let sha = commit.id.to_hex().to_string();
307        let timestamp = commit.time().map(|t| t.seconds).unwrap_or(0);
308
309        // gix exposes the message via `decode()`. The body is the raw
310        // bytes including subject + body — exactly what `format_commit_message`
311        // wrote.
312        let body_string = match commit.message_raw() {
313            Ok(bstr) => std::str::from_utf8(bstr.as_ref())
314                .map(|s| s.to_string())
315                .unwrap_or_default(),
316            Err(_) => String::new(),
317        };
318
319        let parsed = parse_commit_message(&body_string);
320        notes.push(CommitNote {
321            mem: mem_name.to_string(),
322            sha,
323            subject: parsed.subject,
324            tool_verb: parsed.tool_verb,
325            entity_id: parsed.entity_id,
326            note: parsed.note,
327            actor: parsed.actor,
328            tool: parsed.tool,
329            client: parsed.client,
330            logical_operation_id: parsed.logical_operation_id,
331            entity_ids: parsed.entity_ids,
332            timestamp,
333        });
334    }
335
336    let memstead_ref = read_memstead_ref(git_dir)?;
337
338    Ok(AgentNotesReport {
339        mem: mem_name.to_string(),
340        since: since.to_string(),
341        head: head_sha,
342        notes,
343        memstead_ref,
344    })
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn ctx() -> crate::vcs::CommitContext<'static> {
352        crate::vcs::CommitContext {
353            actor: crate::vcs::Actor::Agent,
354            client: Some(crate::vcs::ClientId {
355                name: "claude-code".into(),
356                version: "2.1.0".into(),
357            }),
358            tool: Some("memstead_create"),
359            note: Some("Demoting drift hook to engine surface.".into()),
360            logical_operation_id: None,
361            entity_ids: None,
362        }
363    }
364
365    #[test]
366    fn parser_round_trips_logical_operation_id_trailer() {
367        // Multi-mem rename commits carry a `Logical-Op:` trailer so
368        // consumers reading the commit log can correlate every
369        // per-mem commit a single rename produced. The folder
370        // backend's JSONL writer carries the same id under its
371        // `"logical_op"` field — both paths must round-trip back to
372        // `Provenance.logical_operation_id`.
373        let ctx = crate::vcs::CommitContext {
374            actor: crate::vcs::Actor::Agent,
375            client: Some(crate::vcs::ClientId {
376                name: "claude-code".into(),
377                version: "2.1.0".into(),
378            }),
379            tool: Some("rename_entity"),
380            note: None,
381            logical_operation_id: Some("logop-abc123def456"),
382            entity_ids: None,
383        };
384        let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
385        assert!(
386            raw.contains("Logical-Op: logop-abc123def456"),
387            "format_commit_message must emit the Logical-Op trailer; got:\n{raw}"
388        );
389        let parsed = parse_commit_message(&raw);
390        assert_eq!(
391            parsed.logical_operation_id.as_deref(),
392            Some("logop-abc123def456"),
393            "parser must reconstruct the Logical-Op trailer; got: {:?}",
394            parsed.logical_operation_id
395        );
396    }
397
398    #[test]
399    fn parser_round_trips_entities_trailer() {
400        // batch_update collapses its subject to `(N entities)`; the
401        // `Entities:` trailer carries the real ids so an --include-notes
402        // reader can name them. format → parse must round-trip.
403        let ctx = crate::vcs::CommitContext {
404            actor: crate::vcs::Actor::Cli,
405            client: None,
406            tool: Some("batch_update"),
407            note: None,
408            logical_operation_id: None,
409            entity_ids: Some(vec![
410                "specs--alpha".to_string(),
411                "specs--beta".to_string(),
412                "memos--gamma".to_string(),
413            ]),
414        };
415        let raw = crate::vcs::format_commit_message("memstead: batch-update (3 entities)", &ctx);
416        assert!(
417            raw.contains("Entities: specs--alpha, specs--beta, memos--gamma"),
418            "format_commit_message must emit the Entities trailer; got:\n{raw}"
419        );
420        let parsed = parse_commit_message(&raw);
421        // The subject (and thus entity_id) keeps its count-string shape.
422        assert_eq!(parsed.entity_id.as_deref(), Some("(3 entities)"));
423        // The ids are recovered additively.
424        assert_eq!(
425            parsed.entity_ids,
426            vec!["specs--alpha", "specs--beta", "memos--gamma"],
427            "parser must reconstruct the Entities trailer; got: {:?}",
428            parsed.entity_ids
429        );
430    }
431
432    #[test]
433    fn parser_leaves_entity_ids_empty_without_trailer() {
434        // A single-entity commit names its id in the subject — no
435        // Entities trailer, so entity_ids stays empty.
436        let raw = crate::vcs::format_commit_message("memstead: update specs--solo", &ctx());
437        let parsed = parse_commit_message(&raw);
438        assert!(
439            parsed.entity_ids.is_empty(),
440            "no trailer → empty: {:?}",
441            parsed.entity_ids
442        );
443        assert_eq!(parsed.entity_id.as_deref(), Some("specs--solo"));
444    }
445
446    #[test]
447    fn parser_round_trips_format_commit_message_with_note() {
448        let ctx = ctx();
449        let raw = crate::vcs::format_commit_message("memstead: create specs--demo", &ctx);
450        let parsed = parse_commit_message(&raw);
451        assert_eq!(parsed.subject, "memstead: create specs--demo");
452        assert_eq!(parsed.tool_verb.as_deref(), Some("create"));
453        assert_eq!(parsed.entity_id.as_deref(), Some("specs--demo"));
454        assert_eq!(parsed.tool.as_deref(), Some("memstead_create"));
455        assert_eq!(parsed.actor.as_deref(), Some("agent"));
456        assert_eq!(parsed.client.as_deref(), Some("claude-code@2.1.0"));
457        assert_eq!(
458            parsed.note.as_deref(),
459            Some("Demoting drift hook to engine surface.")
460        );
461    }
462
463    #[test]
464    fn parser_round_trips_format_commit_message_without_note() {
465        let ctx = crate::vcs::CommitContext {
466            actor: crate::vcs::Actor::External,
467            client: None,
468            tool: None,
469            note: None,
470            logical_operation_id: None,
471            entity_ids: None,
472        };
473        let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
474        let parsed = parse_commit_message(&raw);
475        assert_eq!(parsed.subject, "memstead: rename a → b");
476        assert_eq!(parsed.tool_verb.as_deref(), Some("rename"));
477        assert_eq!(parsed.entity_id.as_deref(), Some("a → b"));
478        assert_eq!(parsed.actor.as_deref(), Some("external"));
479        assert!(parsed.note.is_none());
480        assert!(parsed.tool.is_none());
481        assert!(parsed.client.is_none());
482    }
483
484    #[test]
485    fn parser_recognises_anchor_verb() {
486        // An anchor-only update commits with the distinct `anchor` verb
487        // so an `--include-notes` reader can tell it apart from a content
488        // update. The subject shape is identical to `update` otherwise —
489        // `memstead: anchor <id>` — so the same `<verb> <id>` split
490        // applies and `entity_id` carries the id verbatim.
491        let raw = crate::vcs::format_commit_message("memstead: anchor specs--alpha", &ctx());
492        let parsed = parse_commit_message(&raw);
493        assert_eq!(parsed.subject, "memstead: anchor specs--alpha");
494        assert_eq!(parsed.tool_verb.as_deref(), Some("anchor"));
495        assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
496    }
497
498    #[test]
499    fn parser_handles_unrecognized_subject() {
500        let parsed = parse_commit_message("hand-typed external drift\n");
501        assert_eq!(parsed.subject, "hand-typed external drift");
502        assert!(parsed.tool_verb.is_none());
503        assert!(parsed.entity_id.is_none());
504        assert!(parsed.actor.is_none());
505    }
506
507    #[test]
508    fn parser_recognises_multiline_note() {
509        let body = "\
510memstead: update specs--alpha
511
512First line of the note.
513Second line of the note.
514
515Tool: memstead_update
516Actor: agent
517Client: claude-code@2.1.0
518";
519        let parsed = parse_commit_message(body);
520        assert_eq!(
521            parsed.note.as_deref(),
522            Some("First line of the note.\nSecond line of the note.")
523        );
524        assert_eq!(parsed.actor.as_deref(), Some("agent"));
525    }
526
527    #[test]
528    fn parser_tolerates_subject_only_body() {
529        let parsed = parse_commit_message("memstead: update specs--alpha\n");
530        assert_eq!(parsed.subject, "memstead: update specs--alpha");
531        assert_eq!(parsed.tool_verb.as_deref(), Some("update"));
532        assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
533        assert!(parsed.note.is_none());
534        assert!(parsed.actor.is_none());
535    }
536
537    #[test]
538    fn parser_treats_lowercase_keys_as_body() {
539        // A line starting `tool:` (lowercase) must not be confused for a
540        // trailer — keeps the parser robust against prose that happens to
541        // contain colon-bearing lines.
542        let body = "\
543memstead: update specs--alpha
544
545note line one
546tool: this is prose, not a trailer
547
548Actor: agent
549";
550        let parsed = parse_commit_message(body);
551        assert_eq!(
552            parsed.note.as_deref(),
553            Some("note line one\ntool: this is prose, not a trailer")
554        );
555        assert_eq!(parsed.actor.as_deref(), Some("agent"));
556        assert!(parsed.tool.is_none());
557    }
558
559    #[test]
560    fn parser_skips_trailer_without_value() {
561        // `Foo:` with nothing after it is not a valid trailer — keeps
562        // `Foo:` from accidentally splitting a note.
563        assert!(!is_trailer_line("Foo:"));
564        assert!(!is_trailer_line("Foo: "));
565        assert!(is_trailer_line("Foo: bar"));
566    }
567
568    // The gix-walking integration is exercised through the engine-level
569    // tests once the Engine wrapper lands. Walking-without-a-repo here
570    // would just re-test gix; the parser tests above cover the trailer
571    // contract that is the engine's actual ownership boundary.
572}