use std::path::Path;
use crate::ops::changes::EMPTY_TREE_SHA;
use crate::vcs::VcsError;
pub use memstead_base::ops::agent_notes::{AgentNotesReport, CommitNote};
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()))
}
#[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>,
pub logical_operation_id: Option<String>,
pub role: Option<String>,
pub entity_ids: Vec<String>,
}
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();
let (tool_verb, entity_id) = parse_subject(&subject);
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 role: 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());
}
"Role" if role.is_none() => role = 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,
role,
entity_ids,
}
}
fn parse_subject(subject: &str) -> (Option<String>, Option<String>) {
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()
}
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> {
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())
}
}
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();
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}")))?;
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);
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,
role: parsed.role,
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()),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
}
}
#[test]
fn parser_round_trips_logical_operation_id_trailer() {
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,
role: Default::default(),
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() {
let ctx = crate::vcs::CommitContext {
actor: crate::vcs::Actor::Cli,
client: None,
tool: Some("batch_update"),
note: None,
role: Default::default(),
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);
assert_eq!(parsed.entity_id.as_deref(), Some("(3 entities)"));
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() {
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,
role: Default::default(),
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_round_trips_app_actor_with_client_identity() {
let ctx = crate::vcs::CommitContext {
actor: crate::vcs::Actor::App,
client: Some(crate::vcs::ClientId {
name: "memstead-node-app".into(),
version: "0.1.0".into(),
}),
tool: None,
note: Some("edited via the workspace room".into()),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
let raw = crate::vcs::format_commit_message("memstead: update specs--alpha", &ctx);
assert!(raw.contains("Actor: app"), "missing app trailer:\n{raw}");
let parsed = parse_commit_message(&raw);
assert_eq!(parsed.actor.as_deref(), Some("app"));
assert_eq!(parsed.client.as_deref(), Some("memstead-node-app@0.1.0"));
assert_eq!(
parsed.note.as_deref(),
Some("edited via the workspace room")
);
let author = crate::vcs::author_identity(&ctx).expect("app + client derives an author");
assert_eq!(author.0, "memstead-node-app");
assert_eq!(author.1, "memstead-node-app@memstead.io");
}
#[test]
fn parser_recognises_anchor_verb() {
let raw = crate::vcs::format_commit_message("memstead: anchor specs--alpha", &ctx());
let parsed = parse_commit_message(&raw);
assert_eq!(parsed.subject, "memstead: anchor specs--alpha");
assert_eq!(parsed.tool_verb.as_deref(), Some("anchor"));
assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
}
#[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() {
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() {
assert!(!is_trailer_line("Foo:"));
assert!(!is_trailer_line("Foo: "));
assert!(is_trailer_line("Foo: bar"));
}
}