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//! - `memstead: <verb> <from> → <to>` (`rename`, `relate`, `unrelate`)
14//! - `memstead: mem_<verb> <name>` (lifecycle, with optional ` (config)` /
15//!   ` (seal)` qualifier)
16//!
17//! The parser captures the verb token and the remainder of the subject
18//! verbatim into `entity_id` — callers that need to split rename/relate
19//! arrows do so themselves; the engine does not over-structure here.
20//!
21//! Empty-tree sentinel (`EMPTY_TREE_SHA`) is accepted as `since` and is
22//! treated as "walk every commit reachable from head" — mirrors
23//! [`crate::ops::changes::changes_since`]'s convention.
24
25use std::path::Path;
26
27use crate::ops::changes::EMPTY_TREE_SHA;
28use crate::vcs::VcsError;
29
30// Data shapes live in `memstead-base`. Re-export here so downstream
31// callers that still import `memstead_git_branch::ops::agent_notes::*`
32// (and `memstead_git_branch::{CommitNote, ...}` via lib.rs) keep working.
33pub use memstead_base::ops::agent_notes::{AgentNotesReport, CommitNote};
34
35/// Resolve `refs/heads/__MEMSTEAD` (unified schemas + per-mem configs)
36/// in the mem-repo gitdir shared by every writable mem. Returns
37/// `None` when the ref does not exist — pre-migration workspaces and
38/// fresh repos legitimately have no `__MEMSTEAD` yet.
39pub 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/// Parsed shape of one commit body. Mirrors the layout produced by
48/// [`crate::vcs::format_commit_message`]:
49///
50/// ```text
51/// <subject>
52///
53/// <optional note paragraph>
54///
55/// Tool: <verb>
56/// Actor: <agent|cli|external|unknown>
57/// Client: <name>@<version>
58/// ```
59#[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    /// Value of the `Logical-Op:` trailer when present. Round-trips
69    /// with `memstead_base::vcs::format_commit_message`'s emission.
70    pub logical_operation_id: Option<String>,
71    /// Ids from the `Entities:` trailer (multi-entity commits, e.g.
72    /// `batch_update`). Empty when the trailer is absent — single-entity
73    /// commits carry their id in `entity_id` instead. Round-trips with
74    /// `format_commit_message`'s `Entities: id1, id2, …` emission.
75    pub entity_ids: Vec<String>,
76}
77
78/// Parse one commit body — subject on the first line, optional note
79/// paragraph, then a trailer block. Trailers are recognised as lines
80/// matching `<Capitalized>: <value>`. The note is everything between the
81/// subject (after one blank line) and the first trailer (or end of body
82/// if none).
83///
84/// Body input is the raw commit message including subject — the same
85/// shape `git log --format=%B` returns. Trailing newlines are tolerated.
86///
87/// Empty / whitespace-only bodies produce a `ParsedCommit` with an empty
88/// subject and every other field `None` — callers decide whether that
89/// counts as skippable.
90pub 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    // Subject parser: `memstead: <verb> <rest>`. The remainder may itself
96    // contain spaces (rename arrow, mem qualifier) — we capture it
97    // verbatim into `entity_id`. Non-engine subjects (e.g. external drift
98    // commits a developer typed by hand) leave both fields `None`.
99    let (tool_verb, entity_id) = parse_subject(&subject);
100
101    // Walk the body looking for the first trailer line; everything before
102    // it (after stripping leading/trailing blank lines) is the note.
103    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    // The engine writes `memstead:` subjects.
156    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
178/// Trailer lines match `^[A-Z][A-Za-z-]+:\s.+$`. Returns the key (without
179/// colon) and the value (trimmed) when matched. Mirrors the pattern the
180/// plugin uses today (`/^[A-Z][A-Za-z-]+:\s/`).
181fn 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    // Strip leading blank lines (the `\n\n` separator after subject) and
205    // trailing blank lines (the `\n\n` separator before trailers).
206    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
226/// Walk the per-mem branch from `since` (exclusive) to the current
227/// branch tip (inclusive) and return one [`CommitNote`] per commit on
228/// the path, parsed via [`parse_commit_message`]. Order: newest first
229/// (matches `git log` default).
230///
231/// `since` may be the canonical empty-tree SHA — in which case every
232/// reachable commit is returned. Empty repos / empty refs return an
233/// empty report with `head` echoing the empty-tree sentinel.
234///
235/// `head_ref` follows the same convention as
236/// [`crate::ops::changes::changes_since`]: pass `Some("refs/heads/<mem>")`
237/// for mem-repo-backed mems, `None` to fall back to the gix HEAD.
238pub 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    // Resolve `since` to an ObjectId for the walker's `with_hidden`
271    // boundary. The empty-tree sentinel means "no boundary" — walk every
272    // reachable commit. Unknown / unreachable since refs surface as
273    // `ObjectNotFound` exactly like `changes_since`.
274    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        // Resolve through to a commit so unreachable refs surface here
281        // rather than at walk time.
282        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        // gix exposes the message via `decode()`. The body is the raw
307        // bytes including subject + body — exactly what `format_commit_message`
308        // wrote.
309        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        // Multi-mem rename commits carry a `Logical-Op:` trailer so
365        // consumers reading the commit log can correlate every
366        // per-mem commit a single rename produced. The folder
367        // backend's JSONL writer carries the same id under its
368        // `"logical_op"` field — both paths must round-trip back to
369        // `Provenance.logical_operation_id`.
370        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        // batch_update collapses its subject to `(N entities)`; the
398        // `Entities:` trailer carries the real ids so an --include-notes
399        // reader can name them. format → parse must round-trip.
400        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        // The subject (and thus entity_id) keeps its count-string shape.
419        assert_eq!(parsed.entity_id.as_deref(), Some("(3 entities)"));
420        // The ids are recovered additively.
421        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        // A single-entity commit names its id in the subject — no
432        // Entities trailer, so entity_ids stays empty.
433        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        // A line starting `tool:` (lowercase) must not be confused for a
523        // trailer — keeps the parser robust against prose that happens to
524        // contain colon-bearing lines.
525        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        // `Foo:` with nothing after it is not a valid trailer — keeps
545        // `Foo:` from accidentally splitting a note.
546        assert!(!is_trailer_line("Foo:"));
547        assert!(!is_trailer_line("Foo: "));
548        assert!(is_trailer_line("Foo: bar"));
549    }
550
551    // The gix-walking integration is exercised through the engine-level
552    // tests once the Engine wrapper lands. Walking-without-a-repo here
553    // would just re-test gix; the parser tests above cover the trailer
554    // contract that is the engine's actual ownership boundary.
555}