memstead-git-branch 0.2.0

Mem-repo engine for Memstead — multi-mem, git-backed typed entity graphs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
//! `agent_notes_since` — walk a mem's branch from a caller-provided
//! cursor to the current tip and return one [`CommitNote`] per commit
//! along the way, with the body parsed into structured fields.
//!
//! This is the symmetric read-side of [`crate::vcs::format_commit_message`]:
//! the same engine layer that writes the trailer block (`Tool:`, `Actor:`,
//! `Client:`) parses it back out. Plugin and outer-repo cursor consumers
//! that want agent-note bullets read this surface instead of
//! shelling out to `git log` and re-implementing the trailer parser.
//!
//! Subject shapes the engine emits (see callers of `format_commit_message`):
//! - `memstead: <verb> <id>` (entity CRUD: `create`, `update`, `delete`)
//! - `memstead: <verb> <from> → <to>` (`rename`, `relate`, `unrelate`)
//! - `memstead: mem_<verb> <name>` (lifecycle, with optional ` (config)` /
//!   ` (seal)` qualifier)
//!
//! The parser captures the verb token and the remainder of the subject
//! verbatim into `entity_id` — callers that need to split rename/relate
//! arrows do so themselves; the engine does not over-structure here.
//!
//! Empty-tree sentinel (`EMPTY_TREE_SHA`) is accepted as `since` and is
//! treated as "walk every commit reachable from head" — mirrors
//! [`crate::ops::changes::changes_since`]'s convention.

use std::path::Path;

use crate::ops::changes::EMPTY_TREE_SHA;
use crate::vcs::VcsError;

// Data shapes live in `memstead-base`. Re-export here so downstream
// callers that still import `memstead_git_branch::ops::agent_notes::*`
// (and `memstead_git_branch::{CommitNote, ...}` via lib.rs) keep working.
pub use memstead_base::ops::agent_notes::{AgentNotesReport, CommitNote};

/// Resolve `refs/heads/__MEMSTEAD` (unified schemas + per-mem configs)
/// in the mem-repo gitdir shared by every writable mem. Returns
/// `None` when the ref does not exist — pre-migration workspaces and
/// fresh repos legitimately have no `__MEMSTEAD` yet.
pub fn read_memstead_ref(git_dir: &Path) -> Result<Option<String>, VcsError> {
    let repo = gix::open(git_dir)?;
    Ok(repo
        .rev_parse_single("refs/heads/__MEMSTEAD")
        .ok()
        .map(|id| id.to_hex().to_string()))
}

/// Parsed shape of one commit body. Mirrors the layout produced by
/// [`crate::vcs::format_commit_message`]:
///
/// ```text
/// <subject>
///
/// <optional note paragraph>
///
/// Tool: <verb>
/// Actor: <agent|cli|external|unknown>
/// Client: <name>@<version>
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedCommit {
    pub subject: String,
    pub tool_verb: Option<String>,
    pub entity_id: Option<String>,
    pub note: Option<String>,
    pub actor: Option<String>,
    pub tool: Option<String>,
    pub client: Option<String>,
    /// Value of the `Logical-Op:` trailer when present. Round-trips
    /// with `memstead_base::vcs::format_commit_message`'s emission.
    pub logical_operation_id: Option<String>,
    /// Ids from the `Entities:` trailer (multi-entity commits, e.g.
    /// `batch_update`). Empty when the trailer is absent — single-entity
    /// commits carry their id in `entity_id` instead. Round-trips with
    /// `format_commit_message`'s `Entities: id1, id2, …` emission.
    pub entity_ids: Vec<String>,
}

/// Parse one commit body — subject on the first line, optional note
/// paragraph, then a trailer block. Trailers are recognised as lines
/// matching `<Capitalized>: <value>`. The note is everything between the
/// subject (after one blank line) and the first trailer (or end of body
/// if none).
///
/// Body input is the raw commit message including subject — the same
/// shape `git log --format=%B` returns. Trailing newlines are tolerated.
///
/// Empty / whitespace-only bodies produce a `ParsedCommit` with an empty
/// subject and every other field `None` — callers decide whether that
/// counts as skippable.
pub fn parse_commit_message(body: &str) -> ParsedCommit {
    let trimmed = body.trim_matches('\n');
    let mut lines = trimmed.split('\n');
    let subject = lines.next().unwrap_or("").trim_end().to_string();

    // Subject parser: `memstead: <verb> <rest>`. The remainder may itself
    // contain spaces (rename arrow, mem qualifier) — we capture it
    // verbatim into `entity_id`. Non-engine subjects (e.g. external drift
    // commits a developer typed by hand) leave both fields `None`.
    let (tool_verb, entity_id) = parse_subject(&subject);

    // Walk the body looking for the first trailer line; everything before
    // it (after stripping leading/trailing blank lines) is the note.
    let body_lines: Vec<&str> = lines.collect();
    let first_trailer_idx = body_lines.iter().position(|l| is_trailer_line(l));

    let note_slice = match first_trailer_idx {
        Some(idx) => &body_lines[..idx],
        None => &body_lines[..],
    };
    let note = collect_note(note_slice);

    let mut tool: Option<String> = None;
    let mut actor: Option<String> = None;
    let mut client: Option<String> = None;
    let mut logical_operation_id: Option<String> = None;
    let mut entity_ids: Vec<String> = Vec::new();
    if let Some(start) = first_trailer_idx {
        for line in &body_lines[start..] {
            if let Some((key, value)) = split_trailer(line) {
                match key {
                    "Tool" if tool.is_none() => tool = Some(value.to_string()),
                    "Actor" if actor.is_none() => actor = Some(value.to_string()),
                    "Client" if client.is_none() => client = Some(value.to_string()),
                    "Logical-Op" if logical_operation_id.is_none() => {
                        logical_operation_id = Some(value.to_string());
                    }
                    "Entities" if entity_ids.is_empty() => {
                        entity_ids = value
                            .split(',')
                            .map(str::trim)
                            .filter(|s| !s.is_empty())
                            .map(str::to_string)
                            .collect();
                    }
                    _ => {}
                }
            }
        }
    }

    ParsedCommit {
        subject,
        tool_verb,
        entity_id,
        note,
        actor,
        tool,
        client,
        logical_operation_id,
        entity_ids,
    }
}

fn parse_subject(subject: &str) -> (Option<String>, Option<String>) {
    // The engine writes `memstead:` subjects.
    let rest = match subject.strip_prefix("memstead:") {
        Some(r) => r.trim_start(),
        None => return (None, None),
    };
    let mut parts = rest.splitn(2, char::is_whitespace);
    let verb = match parts.next() {
        Some(v) if !v.is_empty() => v.to_string(),
        _ => return (None, None),
    };
    let remainder = parts.next().unwrap_or("").trim().to_string();
    let entity_id = if remainder.is_empty() {
        None
    } else {
        Some(remainder)
    };
    (Some(verb), entity_id)
}

fn is_trailer_line(line: &str) -> bool {
    split_trailer(line).is_some()
}

/// Trailer lines match `^[A-Z][A-Za-z-]+:\s.+$`. Returns the key (without
/// colon) and the value (trimmed) when matched. Mirrors the pattern the
/// plugin uses today (`/^[A-Z][A-Za-z-]+:\s/`).
fn split_trailer(line: &str) -> Option<(&str, &str)> {
    let colon = line.find(':')?;
    let (key, rest) = line.split_at(colon);
    if key.is_empty() {
        return None;
    }
    let mut chars = key.chars();
    let first = chars.next()?;
    if !first.is_ascii_uppercase() {
        return None;
    }
    if !chars.all(|c| c.is_ascii_alphabetic() || c == '-') {
        return None;
    }
    let value_with_colon = &rest[1..];
    let value = value_with_colon.strip_prefix(' ')?.trim_end();
    if value.is_empty() {
        return None;
    }
    Some((key, value))
}

fn collect_note(slice: &[&str]) -> Option<String> {
    // Strip leading blank lines (the `\n\n` separator after subject) and
    // trailing blank lines (the `\n\n` separator before trailers).
    let mut start = 0;
    while start < slice.len() && slice[start].trim().is_empty() {
        start += 1;
    }
    let mut end = slice.len();
    while end > start && slice[end - 1].trim().is_empty() {
        end -= 1;
    }
    if start == end {
        return None;
    }
    let joined = slice[start..end].join("\n");
    let trimmed = joined.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

/// Walk the per-mem branch from `since` (exclusive) to the current
/// branch tip (inclusive) and return one [`CommitNote`] per commit on
/// the path, parsed via [`parse_commit_message`]. Order: newest first
/// (matches `git log` default).
///
/// `since` may be the canonical empty-tree SHA — in which case every
/// reachable commit is returned. Empty repos / empty refs return an
/// empty report with `head` echoing the empty-tree sentinel.
///
/// `head_ref` follows the same convention as
/// [`crate::ops::changes::changes_since`]: pass `Some("refs/heads/<mem>")`
/// for mem-repo-backed mems, `None` to fall back to the gix HEAD.
pub fn agent_notes_since(
    mem_name: &str,
    git_dir: &Path,
    since: &str,
    head_ref: Option<&str>,
) -> Result<AgentNotesReport, VcsError> {
    let repo = gix::open(git_dir)?;

    let head_lookup: Result<gix::Commit<'_>, ()> = match head_ref {
        Some(ref_name) => repo
            .rev_parse_single(ref_name)
            .ok()
            .and_then(|id| id.object().ok())
            .and_then(|obj| obj.try_into_commit().ok())
            .ok_or(()),
        None => repo.head_commit().map_err(|_| ()),
    };
    let head_commit = match head_lookup {
        Ok(c) => c,
        Err(()) => {
            let memstead_ref = read_memstead_ref(git_dir)?;
            return Ok(AgentNotesReport {
                mem: mem_name.to_string(),
                since: since.to_string(),
                head: EMPTY_TREE_SHA.to_string(),
                notes: Vec::new(),
                memstead_ref,
            });
        }
    };
    let head_sha = head_commit.id.to_hex().to_string();

    // Resolve `since` to an ObjectId for the walker's `with_hidden`
    // boundary. The empty-tree sentinel means "no boundary" — walk every
    // reachable commit. Unknown / unreachable since refs surface as
    // `ObjectNotFound` exactly like `changes_since`.
    let hidden: Vec<gix::ObjectId> = if since == EMPTY_TREE_SHA {
        Vec::new()
    } else {
        let id = repo
            .rev_parse_single(since)
            .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
        // Resolve through to a commit so unreachable refs surface here
        // rather than at walk time.
        let object = id
            .object()
            .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
        object
            .try_into_commit()
            .map_err(|_| VcsError::ObjectNotFound(format!("{since} is not a commit")))?;
        vec![id.detach()]
    };

    let walk = repo
        .rev_walk([head_commit.id])
        .with_hidden(hidden)
        .all()
        .map_err(|e| VcsError::Git(format!("rev-walk: {e}")))?;

    let mut notes: Vec<CommitNote> = Vec::new();
    for info in walk {
        let info = info.map_err(|e| VcsError::Git(format!("rev-walk-step: {e}")))?;
        let commit = info
            .object()
            .map_err(|e| VcsError::Git(format!("commit-load: {e}")))?;
        let sha = commit.id.to_hex().to_string();
        let timestamp = commit.time().map(|t| t.seconds).unwrap_or(0);

        // gix exposes the message via `decode()`. The body is the raw
        // bytes including subject + body — exactly what `format_commit_message`
        // wrote.
        let body_string = match commit.message_raw() {
            Ok(bstr) => std::str::from_utf8(bstr.as_ref())
                .map(|s| s.to_string())
                .unwrap_or_default(),
            Err(_) => String::new(),
        };

        let parsed = parse_commit_message(&body_string);
        notes.push(CommitNote {
            mem: mem_name.to_string(),
            sha,
            subject: parsed.subject,
            tool_verb: parsed.tool_verb,
            entity_id: parsed.entity_id,
            note: parsed.note,
            actor: parsed.actor,
            tool: parsed.tool,
            client: parsed.client,
            logical_operation_id: parsed.logical_operation_id,
            entity_ids: parsed.entity_ids,
            timestamp,
        });
    }

    let memstead_ref = read_memstead_ref(git_dir)?;

    Ok(AgentNotesReport {
        mem: mem_name.to_string(),
        since: since.to_string(),
        head: head_sha,
        notes,
        memstead_ref,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ctx() -> crate::vcs::CommitContext<'static> {
        crate::vcs::CommitContext {
            actor: crate::vcs::Actor::Agent,
            client: Some(crate::vcs::ClientId {
                name: "claude-code".into(),
                version: "2.1.0".into(),
            }),
            tool: Some("memstead_create"),
            note: Some("Demoting drift hook to engine surface.".into()),
            logical_operation_id: None,
            entity_ids: None,
        }
    }

    #[test]
    fn parser_round_trips_logical_operation_id_trailer() {
        // Multi-mem rename commits carry a `Logical-Op:` trailer so
        // consumers reading the commit log can correlate every
        // per-mem commit a single rename produced. The folder
        // backend's JSONL writer carries the same id under its
        // `"logical_op"` field — both paths must round-trip back to
        // `Provenance.logical_operation_id`.
        let ctx = crate::vcs::CommitContext {
            actor: crate::vcs::Actor::Agent,
            client: Some(crate::vcs::ClientId {
                name: "claude-code".into(),
                version: "2.1.0".into(),
            }),
            tool: Some("rename_entity"),
            note: None,
            logical_operation_id: Some("logop-abc123def456"),
            entity_ids: None,
        };
        let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
        assert!(
            raw.contains("Logical-Op: logop-abc123def456"),
            "format_commit_message must emit the Logical-Op trailer; got:\n{raw}"
        );
        let parsed = parse_commit_message(&raw);
        assert_eq!(
            parsed.logical_operation_id.as_deref(),
            Some("logop-abc123def456"),
            "parser must reconstruct the Logical-Op trailer; got: {:?}",
            parsed.logical_operation_id
        );
    }

    #[test]
    fn parser_round_trips_entities_trailer() {
        // batch_update collapses its subject to `(N entities)`; the
        // `Entities:` trailer carries the real ids so an --include-notes
        // reader can name them. format → parse must round-trip.
        let ctx = crate::vcs::CommitContext {
            actor: crate::vcs::Actor::Cli,
            client: None,
            tool: Some("batch_update"),
            note: None,
            logical_operation_id: None,
            entity_ids: Some(vec![
                "specs--alpha".to_string(),
                "specs--beta".to_string(),
                "memos--gamma".to_string(),
            ]),
        };
        let raw = crate::vcs::format_commit_message("memstead: batch-update (3 entities)", &ctx);
        assert!(
            raw.contains("Entities: specs--alpha, specs--beta, memos--gamma"),
            "format_commit_message must emit the Entities trailer; got:\n{raw}"
        );
        let parsed = parse_commit_message(&raw);
        // The subject (and thus entity_id) keeps its count-string shape.
        assert_eq!(parsed.entity_id.as_deref(), Some("(3 entities)"));
        // The ids are recovered additively.
        assert_eq!(
            parsed.entity_ids,
            vec!["specs--alpha", "specs--beta", "memos--gamma"],
            "parser must reconstruct the Entities trailer; got: {:?}",
            parsed.entity_ids
        );
    }

    #[test]
    fn parser_leaves_entity_ids_empty_without_trailer() {
        // A single-entity commit names its id in the subject — no
        // Entities trailer, so entity_ids stays empty.
        let raw = crate::vcs::format_commit_message("memstead: update specs--solo", &ctx());
        let parsed = parse_commit_message(&raw);
        assert!(
            parsed.entity_ids.is_empty(),
            "no trailer → empty: {:?}",
            parsed.entity_ids
        );
        assert_eq!(parsed.entity_id.as_deref(), Some("specs--solo"));
    }

    #[test]
    fn parser_round_trips_format_commit_message_with_note() {
        let ctx = ctx();
        let raw = crate::vcs::format_commit_message("memstead: create specs--demo", &ctx);
        let parsed = parse_commit_message(&raw);
        assert_eq!(parsed.subject, "memstead: create specs--demo");
        assert_eq!(parsed.tool_verb.as_deref(), Some("create"));
        assert_eq!(parsed.entity_id.as_deref(), Some("specs--demo"));
        assert_eq!(parsed.tool.as_deref(), Some("memstead_create"));
        assert_eq!(parsed.actor.as_deref(), Some("agent"));
        assert_eq!(parsed.client.as_deref(), Some("claude-code@2.1.0"));
        assert_eq!(
            parsed.note.as_deref(),
            Some("Demoting drift hook to engine surface.")
        );
    }

    #[test]
    fn parser_round_trips_format_commit_message_without_note() {
        let ctx = crate::vcs::CommitContext {
            actor: crate::vcs::Actor::External,
            client: None,
            tool: None,
            note: None,
            logical_operation_id: None,
            entity_ids: None,
        };
        let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
        let parsed = parse_commit_message(&raw);
        assert_eq!(parsed.subject, "memstead: rename a → b");
        assert_eq!(parsed.tool_verb.as_deref(), Some("rename"));
        assert_eq!(parsed.entity_id.as_deref(), Some("a → b"));
        assert_eq!(parsed.actor.as_deref(), Some("external"));
        assert!(parsed.note.is_none());
        assert!(parsed.tool.is_none());
        assert!(parsed.client.is_none());
    }

    #[test]
    fn parser_handles_unrecognized_subject() {
        let parsed = parse_commit_message("hand-typed external drift\n");
        assert_eq!(parsed.subject, "hand-typed external drift");
        assert!(parsed.tool_verb.is_none());
        assert!(parsed.entity_id.is_none());
        assert!(parsed.actor.is_none());
    }

    #[test]
    fn parser_recognises_multiline_note() {
        let body = "\
memstead: update specs--alpha

First line of the note.
Second line of the note.

Tool: memstead_update
Actor: agent
Client: claude-code@2.1.0
";
        let parsed = parse_commit_message(body);
        assert_eq!(
            parsed.note.as_deref(),
            Some("First line of the note.\nSecond line of the note.")
        );
        assert_eq!(parsed.actor.as_deref(), Some("agent"));
    }

    #[test]
    fn parser_tolerates_subject_only_body() {
        let parsed = parse_commit_message("memstead: update specs--alpha\n");
        assert_eq!(parsed.subject, "memstead: update specs--alpha");
        assert_eq!(parsed.tool_verb.as_deref(), Some("update"));
        assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
        assert!(parsed.note.is_none());
        assert!(parsed.actor.is_none());
    }

    #[test]
    fn parser_treats_lowercase_keys_as_body() {
        // A line starting `tool:` (lowercase) must not be confused for a
        // trailer — keeps the parser robust against prose that happens to
        // contain colon-bearing lines.
        let body = "\
memstead: update specs--alpha

note line one
tool: this is prose, not a trailer

Actor: agent
";
        let parsed = parse_commit_message(body);
        assert_eq!(
            parsed.note.as_deref(),
            Some("note line one\ntool: this is prose, not a trailer")
        );
        assert_eq!(parsed.actor.as_deref(), Some("agent"));
        assert!(parsed.tool.is_none());
    }

    #[test]
    fn parser_skips_trailer_without_value() {
        // `Foo:` with nothing after it is not a valid trailer — keeps
        // `Foo:` from accidentally splitting a note.
        assert!(!is_trailer_line("Foo:"));
        assert!(!is_trailer_line("Foo: "));
        assert!(is_trailer_line("Foo: bar"));
    }

    // The gix-walking integration is exercised through the engine-level
    // tests once the Engine wrapper lands. Walking-without-a-repo here
    // would just re-test gix; the parser tests above cover the trailer
    // contract that is the engine's actual ownership boundary.
}