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