use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use gix::objs::tree::EntryKind;
static BRANCH_MUTEXES: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
#[cfg(debug_assertions)]
thread_local! {
static HELD_BRANCH_KEYS: std::cell::RefCell<Vec<String>> =
const { std::cell::RefCell::new(Vec::new()) };
}
pub(crate) fn acquire_branch_mutex(ref_name: &str) -> Arc<Mutex<()>> {
#[cfg(debug_assertions)]
{
HELD_BRANCH_KEYS.with(|held| {
let held = held.borrow();
if let Some(top) = held.last() {
assert!(
top.as_str() < ref_name,
"out-of-order branch-mutex acquisition: \
thread already holds '{top}', cannot now acquire '{ref_name}' \
(lexicographic order required)"
);
}
});
}
let registry = BRANCH_MUTEXES.get_or_init(|| Mutex::new(HashMap::new()));
let mut map = registry
.lock()
.expect("branch mutex registry poisoned — previous commit panicked inside the registry critical section");
map.entry(ref_name.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) struct BranchMutexGuard {
_guard: MutexGuard<'static, ()>,
_arc: Arc<Mutex<()>>,
#[cfg(debug_assertions)]
key: String,
}
#[cfg(debug_assertions)]
impl Drop for BranchMutexGuard {
fn drop(&mut self) {
HELD_BRANCH_KEYS.with(|held| {
let mut held = held.borrow_mut();
if let Some(pos) = held.iter().rposition(|k| k == &self.key) {
held.remove(pos);
}
});
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn acquire_branch_mutexes_in_order(refs: &[&str]) -> Vec<BranchMutexGuard> {
let mut sorted: Vec<&str> = refs.to_vec();
sorted.sort_unstable();
sorted.dedup();
let mut guards: Vec<BranchMutexGuard> = Vec::with_capacity(sorted.len());
for r in sorted {
let arc = acquire_branch_mutex(r);
let raw_guard: MutexGuard<'_, ()> = arc
.lock()
.expect("branch mutex poisoned during ordered acquisition");
let guard: MutexGuard<'static, ()> = unsafe {
std::mem::transmute::<MutexGuard<'_, ()>, MutexGuard<'static, ()>>(raw_guard)
};
#[cfg(debug_assertions)]
HELD_BRANCH_KEYS.with(|held| {
held.borrow_mut().push(r.to_string());
});
guards.push(BranchMutexGuard {
_guard: guard,
_arc: arc,
#[cfg(debug_assertions)]
key: r.to_string(),
});
}
guards
}
pub(crate) fn head_branch_ref(repo: &gix::Repository) -> String {
match repo.head_ref() {
Ok(Some(reference)) => reference.name().as_bstr().to_string(),
_ => "HEAD".to_string(),
}
}
const COMMITTER_NAME: &str = "engine";
const COMMITTER_EMAIL: &str = "noreply@memstead.io";
pub use memstead_base::vcs::{
Actor, ClientId, CommitContext, author_identity, format_commit_message, sanitise_client_name,
};
pub trait Vcs: Send + Sync {
fn commit(
&self,
paths: &[&Path],
message: &str,
ctx: &CommitContext<'_>,
) -> Result<String, VcsError>;
}
#[derive(Debug, thiserror::Error)]
pub enum VcsError {
#[error("not a git repository: {0}")]
NotRepo(String),
#[error("object not found: {0}")]
ObjectNotFound(String),
#[error("reference conflict: {0}")]
RefConflict(String),
#[error("git error: {0}")]
Git(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
impl From<gix::open::Error> for VcsError {
fn from(e: gix::open::Error) -> Self {
VcsError::NotRepo(e.to_string())
}
}
impl From<gix::init::Error> for VcsError {
fn from(e: gix::init::Error) -> Self {
VcsError::Git(format!("init: {e}"))
}
}
impl From<gix::commit::Error> for VcsError {
fn from(e: gix::commit::Error) -> Self {
VcsError::Git(format!("commit: {e}"))
}
}
impl From<gix::object::write::Error> for VcsError {
fn from(e: gix::object::write::Error) -> Self {
VcsError::Git(format!("write-object: {e}"))
}
}
pub fn create_vcs(git_dir: &Path, work_tree: &Path) -> Result<Arc<dyn Vcs>, VcsError> {
let is_new = !git_dir.join("HEAD").exists();
if is_new {
std::fs::create_dir_all(work_tree)?;
if let Some(parent) = git_dir.parent() {
std::fs::create_dir_all(parent)?;
}
gix::init_bare(git_dir)?;
let mut kvs: Vec<(&str, &str, &str)> = vec![
("core", "bare", "false"),
("core", "logallrefupdates", "true"),
("commit", "gpgsign", "false"),
];
let worktree_rel_storage;
let gitdir_parent_is_worktree = git_dir
.parent()
.map(|p| paths_equal(p, work_tree))
.unwrap_or(false);
if !gitdir_parent_is_worktree {
worktree_rel_storage = relative_path(git_dir, work_tree)
.unwrap_or_else(|| work_tree.to_string_lossy().into_owned());
kvs.push(("core", "worktree", &worktree_rel_storage));
}
write_per_repo_config(git_dir, &kvs)?;
} else {
write_per_repo_config(git_dir, &[("commit", "gpgsign", "false")])?;
}
let _repo = gix::open(git_dir)?;
let git_dir_canon = std::fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
let work_tree_canon =
std::fs::canonicalize(work_tree).unwrap_or_else(|_| work_tree.to_path_buf());
Ok(Arc::new(GixVcs {
git_dir: git_dir_canon,
work_tree: work_tree_canon,
}))
}
fn paths_equal(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => a == b,
}
}
fn relative_path(from: &Path, to: &Path) -> Option<String> {
let from_comps: Vec<_> = from.components().collect();
let to_comps: Vec<_> = to.components().collect();
let mut shared = 0;
while shared < from_comps.len()
&& shared < to_comps.len()
&& from_comps[shared] == to_comps[shared]
{
shared += 1;
}
if shared == 0 {
return None;
}
let ups = from_comps.len().saturating_sub(shared);
let mut out = PathBuf::new();
for _ in 0..ups {
out.push("..");
}
for comp in &to_comps[shared..] {
out.push(comp.as_os_str());
}
if out.as_os_str().is_empty() {
Some(".".to_string())
} else {
Some(out.to_string_lossy().into_owned())
}
}
fn write_per_repo_config(git_dir: &Path, kvs: &[(&str, &str, &str)]) -> Result<(), VcsError> {
use gix::bstr::BStr;
let config_path = git_dir.join("config");
let mut file =
gix::config::File::from_path_no_includes(config_path.clone(), gix::config::Source::Local)
.map_err(|e| VcsError::Git(format!("config parse: {e}")))?;
for (section, key, value) in kvs {
let key_owned = String::from(*key);
let value_bytes: &BStr = (*value).as_bytes().into();
file.set_raw_value_by(*section, None, key_owned, value_bytes)
.map_err(|e| VcsError::Git(format!("config set {section}.{key}: {e}")))?;
}
let mut buf = Vec::new();
file.write_to(&mut buf)
.map_err(|e| VcsError::Git(format!("config serialize: {e}")))?;
std::fs::write(&config_path, buf)?;
Ok(())
}
struct GixVcs {
git_dir: PathBuf,
work_tree: PathBuf,
}
impl Vcs for GixVcs {
fn commit(
&self,
paths: &[&Path],
message: &str,
ctx: &CommitContext<'_>,
) -> Result<String, VcsError> {
let repo = gix::open(&self.git_dir)?;
let head_ref = head_branch_ref(&repo);
let mutex = acquire_branch_mutex(&head_ref);
let _guard = mutex.lock().map_err(|_| {
VcsError::Git(format!(
"branch mutex poisoned (a previous commit panicked); inspect {} ref {} and restart the process",
self.git_dir.display(),
head_ref,
))
})?;
let subpaths: Vec<String> = paths
.iter()
.map(|p| mem_subpath(&self.work_tree, p))
.collect::<Result<_, _>>()?;
debug_assert!(
subpaths.iter().all(|s| s.is_empty()) || subpaths.iter().all(|s| !s.is_empty()),
"commit() paths must not mix isolated and shared subpaths",
);
let any_empty_subpath = subpaths.iter().any(|s| s.is_empty());
let head_commit = repo.head_commit().ok();
let parents = head_commit.as_ref().map(|c| vec![c.id]).unwrap_or_default();
let mut editor = if any_empty_subpath {
repo.empty_tree()
.edit()
.map_err(|e| VcsError::Git(format!("editor init: {e}")))?
} else if let Some(head) = head_commit.as_ref() {
let tree = head
.tree()
.map_err(|e| VcsError::Git(format!("head tree: {e}")))?;
tree.edit()
.map_err(|e| VcsError::Git(format!("editor init: {e}")))?
} else {
repo.empty_tree()
.edit()
.map_err(|e| VcsError::Git(format!("editor init: {e}")))?
};
for (path, subpath) in paths.iter().zip(subpaths.iter()) {
if !subpath.is_empty() {
editor
.remove(subpath.as_str())
.map_err(|e| VcsError::Git(format!("tree remove: {e}")))?;
}
apply_path(&repo, &mut editor, &self.work_tree, path, subpath)?;
}
let tree_id = editor
.write()
.map_err(|e| VcsError::Git(format!("tree write: {e}")))?
.detach();
let time = gix::date::Time::now_local_or_utc();
let committer_sig = gix::actor::Signature {
name: COMMITTER_NAME.into(),
email: COMMITTER_EMAIL.into(),
time,
};
let author_sig = match author_identity(ctx) {
Some((name, email)) => gix::actor::Signature {
name: name.into(),
email: email.into(),
time,
},
None => committer_sig.clone(),
};
let mut author_buf = gix::date::parse::TimeBuf::default();
let mut committer_buf = gix::date::parse::TimeBuf::default();
let author_ref = author_sig.to_ref(&mut author_buf);
let committer_ref = committer_sig.to_ref(&mut committer_buf);
let full_message = format_commit_message(message, ctx);
let commit_id = repo.commit_as(
committer_ref,
author_ref,
"HEAD",
full_message,
tree_id,
parents,
)?;
Ok(commit_id.to_hex().to_string())
}
}
pub(crate) fn mem_subpath(work_tree: &Path, mem_path: &Path) -> Result<String, VcsError> {
let canon_mem = std::fs::canonicalize(mem_path).unwrap_or_else(|_| mem_path.to_path_buf());
let rel = canon_mem.strip_prefix(work_tree).map_err(|_| {
VcsError::Git(format!(
"mem path {} is not under worktree {}",
mem_path.display(),
work_tree.display(),
))
})?;
Ok(rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/"))
}
fn join_subpath(subpath: &str, rel: &str) -> String {
if subpath.is_empty() {
rel.to_string()
} else if rel.is_empty() {
subpath.to_string()
} else {
format!("{subpath}/{rel}")
}
}
fn apply_path(
repo: &gix::Repository,
editor: &mut gix::object::tree::Editor<'_>,
work_tree: &Path,
path: &Path,
subpath: &str,
) -> Result<(), VcsError> {
if path.is_file() {
if let Ok(rel) = path.strip_prefix(work_tree) {
let rel_str = rel.to_string_lossy();
if !is_ignored(rel.components()) {
upsert_file(repo, editor, path, &rel_str)?;
}
}
return Ok(());
}
if path.is_dir() {
for entry in walkdir::WalkDir::new(path)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
if e.file_name() == OsStr::new(".git") {
return false;
}
match e.path().strip_prefix(path) {
Ok(rel) => !is_ignored(rel.components()),
Err(_) => true,
}
})
{
let entry = entry.map_err(|e| VcsError::Git(format!("walk: {e}")))?;
if !entry.file_type().is_file() {
continue;
}
let rel_in_mem = match entry.path().strip_prefix(path) {
Ok(p) => p
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/"),
Err(_) => continue,
};
let in_tree_path = join_subpath(subpath, &rel_in_mem);
upsert_file(repo, editor, entry.path(), &in_tree_path)?;
}
}
Ok(())
}
fn is_ignored(components: std::path::Components<'_>) -> bool {
let mut comps = components;
let first = comps.next().map(|c| c.as_os_str());
if first == Some(OsStr::new(".memstead")) {
return matches!(
comps.next().map(|c| c.as_os_str()),
Some(c) if c == OsStr::new("cache")
);
}
false
}
fn upsert_file(
repo: &gix::Repository,
editor: &mut gix::object::tree::Editor<'_>,
path: &Path,
rel: &str,
) -> Result<(), VcsError> {
let bytes = std::fs::read(path)?;
let blob_id = repo.write_blob(&bytes)?.detach();
let kind = if is_executable(path) {
EntryKind::BlobExecutable
} else {
EntryKind::Blob
};
editor
.upsert(rel, kind, blob_id)
.map_err(|e| VcsError::Git(format!("tree upsert: {e}")))?;
Ok(())
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(_path: &Path) -> bool {
false
}
pub struct NoopVcs {
counter: std::sync::atomic::AtomicU64,
}
impl NoopVcs {
pub fn new() -> Self {
Self {
counter: std::sync::atomic::AtomicU64::new(0),
}
}
}
impl Default for NoopVcs {
fn default() -> Self {
Self::new()
}
}
impl Vcs for NoopVcs {
fn commit(
&self,
_paths: &[&Path],
_message: &str,
_ctx: &CommitContext<'_>,
) -> Result<String, VcsError> {
let n = self
.counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(format!("noop-{n}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_mem_paths(tmp: &Path) -> (PathBuf, PathBuf) {
let mem = tmp.join("mem");
let git_dir = mem.join(".git");
fs::create_dir_all(mem.join(".memstead")).unwrap();
(mem, git_dir)
}
#[test]
fn create_vcs_initializes_fresh_dir() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
let vcs = create_vcs(&git_dir, &mem).unwrap();
let sha = vcs
.commit(&[&mem], "initial", &CommitContext::internal())
.unwrap();
assert_eq!(sha.len(), 40, "commit sha must be 40-char hex");
}
#[test]
fn create_vcs_writes_structural_config_on_first_init() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
let _vcs = create_vcs(&git_dir, &mem).unwrap();
let config = fs::read_to_string(git_dir.join("config")).unwrap();
assert!(
!config.contains("worktree = "),
"no core.worktree override for isolated layout, got:\n{config}"
);
assert!(
config.contains("logallrefupdates = true"),
"core.logallrefupdates must be set, got:\n{config}"
);
assert!(
config.contains("gpgsign = false"),
"commit.gpgsign must be forced false, got:\n{config}"
);
}
#[test]
fn create_vcs_reapplies_gpgsign_on_reopen() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
let _ = create_vcs(&git_dir, &mem).unwrap();
let original = fs::read_to_string(git_dir.join("config")).unwrap();
let tampered = original.replace("gpgsign = false", "gpgsign = true");
fs::write(git_dir.join("config"), tampered).unwrap();
let _ = create_vcs(&git_dir, &mem).unwrap();
let after = fs::read_to_string(git_dir.join("config")).unwrap();
assert!(after.contains("gpgsign = false"), "got:\n{after}");
assert!(after.contains("logallrefupdates = true"), "got:\n{after}");
}
#[test]
fn commit_writes_file_into_tree() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
fs::write(mem.join("test.md"), "hello").unwrap();
let vcs = create_vcs(&git_dir, &mem).unwrap();
let sha = vcs
.commit(&[&mem], "add test.md", &CommitContext::internal())
.unwrap();
assert_eq!(sha.len(), 40);
let repo = gix::open(&git_dir).unwrap();
let commit = repo.head_commit().unwrap();
let tree = commit.tree().unwrap();
let entry = tree.find_entry("test.md").expect("test.md in tree");
let blob = entry.object().unwrap().try_into_blob().unwrap();
assert_eq!(blob.data, b"hello");
}
#[test]
fn commit_excludes_cache_subdir() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
fs::write(mem.join("real.md"), "real").unwrap();
fs::create_dir_all(mem.join(".memstead/cache/prompts")).unwrap();
fs::write(mem.join(".memstead/cache/prompts/p.txt"), "noise").unwrap();
let vcs = create_vcs(&git_dir, &mem).unwrap();
vcs.commit(&[&mem], "initial", &CommitContext::internal())
.unwrap();
let repo = gix::open(&git_dir).unwrap();
let tree = repo.head_commit().unwrap().tree().unwrap();
assert!(tree.find_entry("real.md").is_some());
if let Some(memstead_entry) = tree.find_entry(".memstead") {
let memstead_tree = memstead_entry.object().unwrap().try_into_tree().unwrap();
for entry in memstead_tree.iter() {
let entry = entry.unwrap();
let name = entry.filename().to_string();
assert!(name != "cache", ".memstead subtree must skip {name}");
}
}
}
#[test]
fn commit_second_time_with_deletion_removes_from_tree() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
fs::write(mem.join("keep.md"), "keep").unwrap();
fs::write(mem.join("drop.md"), "drop").unwrap();
let vcs = create_vcs(&git_dir, &mem).unwrap();
vcs.commit(&[&mem], "initial", &CommitContext::internal())
.unwrap();
fs::remove_file(mem.join("drop.md")).unwrap();
vcs.commit(&[&mem], "drop one", &CommitContext::internal())
.unwrap();
let repo = gix::open(&git_dir).unwrap();
let tree = repo.head_commit().unwrap().tree().unwrap();
assert!(tree.find_entry("keep.md").is_some());
assert!(
tree.find_entry("drop.md").is_none(),
"deleted file must disappear from the tree on the next commit"
);
}
#[test]
fn commit_author_is_deterministic() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
fs::write(mem.join("a.md"), "a").unwrap();
let vcs = create_vcs(&git_dir, &mem).unwrap();
vcs.commit(&[&mem], "x", &CommitContext::internal())
.unwrap();
let repo = gix::open(&git_dir).unwrap();
let commit = repo.head_commit().unwrap();
let author = commit.author().unwrap();
assert_eq!(author.name, COMMITTER_NAME);
assert_eq!(author.email, COMMITTER_EMAIL);
}
#[test]
fn noop_vcs_returns_distinguishable_shas() {
let vcs = NoopVcs::new();
let s1 = vcs.commit(&[], "x", &CommitContext::internal()).unwrap();
let s2 = vcs.commit(&[], "y", &CommitContext::internal()).unwrap();
assert!(s1.starts_with("noop-"));
assert!(s2.starts_with("noop-"));
assert_ne!(s1, s2);
}
fn head_commit_parts(git_dir: &Path) -> (String, String, String) {
let repo = gix::open(git_dir).unwrap();
let commit = repo.head_commit().unwrap();
let author = commit.author().unwrap();
let message = commit.message_raw().unwrap().to_string();
(author.name.to_string(), author.email.to_string(), message)
}
#[test]
fn commit_with_agent_context_sets_author_and_trailers() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
fs::write(mem.join("a.md"), "a").unwrap();
let vcs = create_vcs(&git_dir, &mem).unwrap();
let ctx = CommitContext {
actor: Actor::Agent,
client: Some(ClientId {
name: "claude-code".into(),
version: "2.1.0".into(),
}),
tool: Some("memstead_update"),
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
vcs.commit(&[&mem], "memstead: update specs--a", &ctx)
.unwrap();
let (name, email, message) = head_commit_parts(&git_dir);
assert_eq!(name, "claude-code");
assert_eq!(email, "claude-code@memstead.io");
assert!(
message.ends_with("\n\nTool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"),
"got message: {message:?}"
);
}
#[test]
fn commit_with_external_context_sets_external_author_and_actor_trailer() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
fs::write(mem.join("a.md"), "a").unwrap();
let vcs = create_vcs(&git_dir, &mem).unwrap();
let ctx = CommitContext {
actor: Actor::External,
client: None,
tool: None,
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
vcs.commit(&[&mem], "external edits (1 files)", &ctx)
.unwrap();
let (name, email, message) = head_commit_parts(&git_dir);
assert_eq!(name, "external");
assert_eq!(email, "external@memstead.io");
assert!(message.contains("\n\nActor: external"));
assert!(!message.contains("Tool:"));
assert!(!message.contains("Client:"));
}
#[test]
fn commit_with_cli_context_emits_trailers_and_author() {
let dir = TempDir::new().unwrap();
let (mem, git_dir) = make_mem_paths(dir.path());
fs::write(mem.join("a.md"), "a").unwrap();
let vcs = create_vcs(&git_dir, &mem).unwrap();
let ctx_no_client = CommitContext {
actor: Actor::Cli,
client: None,
tool: None,
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
vcs.commit(&[&mem], "memstead: create specs--a", &ctx_no_client)
.unwrap();
let (name, email, message) = head_commit_parts(&git_dir);
assert_eq!(name, COMMITTER_NAME);
assert_eq!(email, COMMITTER_EMAIL);
assert!(message.contains("\n\nActor: cli"));
assert!(!message.contains("Client:"));
fs::write(mem.join("b.md"), "b").unwrap();
let ctx_with_client = CommitContext {
actor: Actor::Cli,
client: Some(ClientId {
name: "memstead-cli".into(),
version: "0.1.0".into(),
}),
tool: None,
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
vcs.commit(&[&mem], "memstead: create specs--b", &ctx_with_client)
.unwrap();
let (name, email, message) = head_commit_parts(&git_dir);
assert_eq!(name, "memstead-cli");
assert_eq!(email, "memstead-cli@memstead.io");
assert!(message.contains("\n\nActor: cli\nClient: memstead-cli@0.1.0"));
}
#[test]
fn sanitise_client_name_collapses_disallowed_chars() {
let out = sanitise_client_name("Claude Code/2.1 @ macOS");
assert_eq!(out, "claude-code-2.1---macos");
assert!(
out.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
"{out}"
);
}
#[test]
fn sanitise_client_name_empty_falls_back_to_unknown() {
assert_eq!(sanitise_client_name(""), "unknown");
assert_eq!(sanitise_client_name(" "), "unknown");
assert_eq!(sanitise_client_name("@@@"), "unknown");
}
#[test]
fn prose_and_trailers_separated_by_exactly_one_blank_line() {
let ctx = CommitContext {
actor: Actor::Agent,
client: None,
tool: Some("memstead_create"),
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
let msg = format_commit_message("subject\n", &ctx);
assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
let msg = format_commit_message("subject", &ctx);
assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
}
#[test]
fn trailers_are_git_interpret_trailers_compatible() {
let ctx = CommitContext {
actor: Actor::Agent,
client: Some(ClientId {
name: "claude-code".into(),
version: "2.1.0".into(),
}),
tool: Some("memstead_update"),
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
let msg = format_commit_message("memstead: update specs--a", &ctx);
let (_prose, trailer_block) = msg.rsplit_once("\n\n").expect("blank line before trailers");
for line in trailer_block.lines() {
let (key, value) = line
.split_once(": ")
.unwrap_or_else(|| panic!("malformed trailer line: {line:?}"));
assert!(!key.is_empty());
assert!(!value.is_empty());
assert!(matches!(key, "Tool" | "Actor" | "Client"), "{key}");
}
assert_eq!(
trailer_block,
"Tool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"
);
}
#[test]
fn internal_context_preserves_deterministic_author() {
let ctx = CommitContext::internal();
assert!(matches!(ctx.actor, Actor::Unknown));
assert!(ctx.client.is_none());
assert!(ctx.tool.is_none());
assert!(ctx.note.is_none());
assert!(author_identity(&ctx).is_none());
}
#[test]
fn commit_message_with_note_inserts_body_between_prose_and_trailers() {
let ctx = CommitContext {
actor: Actor::Agent,
client: Some(ClientId {
name: "claude-code".into(),
version: "2.1.0".into(),
}),
tool: Some("memstead_update"),
note: Some("documenting the foo invariant".into()),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
let msg = format_commit_message("memstead: update specs--a", &ctx);
assert_eq!(
msg,
"memstead: update specs--a\n\n\
documenting the foo invariant\n\n\
Tool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"
);
}
#[test]
fn commit_message_with_blank_note_behaves_like_absent() {
let ctx = CommitContext {
actor: Actor::Agent,
client: None,
tool: Some("memstead_update"),
note: Some(" \n \t ".into()),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
let msg = format_commit_message("subject", &ctx);
assert_eq!(msg, "subject\n\nTool: memstead_update\nActor: agent");
}
#[test]
fn commit_message_with_empty_note_string_behaves_like_absent() {
let ctx = CommitContext {
actor: Actor::Agent,
client: None,
tool: Some("memstead_create"),
note: Some(String::new()),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
let msg = format_commit_message("subject", &ctx);
assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
}
fn unique_ref(prefix: &str) -> String {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("refs/heads/{prefix}-{n}")
}
#[test]
fn per_branch_mutex_serialises_same_ref() {
let r = unique_ref("serialises");
let arc = acquire_branch_mutex(&r);
let guard = arc.lock().unwrap();
let r_clone = r.clone();
let started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let started_t = started.clone();
let acquired_t = acquired.clone();
let handle = std::thread::spawn(move || {
started_t.store(true, std::sync::atomic::Ordering::SeqCst);
let arc2 = acquire_branch_mutex(&r_clone);
let _g2 = arc2.lock().unwrap();
acquired_t.store(true, std::sync::atomic::Ordering::SeqCst);
});
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
started.load(std::sync::atomic::Ordering::SeqCst),
"spawned thread did not start within 50ms"
);
assert!(
!acquired.load(std::sync::atomic::Ordering::SeqCst),
"spawned thread acquired the mutex while main held it"
);
drop(guard);
handle.join().unwrap();
assert!(
acquired.load(std::sync::atomic::Ordering::SeqCst),
"spawned thread did not acquire after drop"
);
}
#[test]
fn per_branch_mutex_parallelises_different_refs() {
let a = unique_ref("parallel-a");
let b = unique_ref("parallel-b");
let arc_a = acquire_branch_mutex(&a);
let guard_a = arc_a.lock().unwrap();
let b_clone = b.clone();
let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let acquired_t = acquired.clone();
let handle = std::thread::spawn(move || {
let arc_b = acquire_branch_mutex(&b_clone);
let _g = arc_b.lock().unwrap();
acquired_t.store(true, std::sync::atomic::Ordering::SeqCst);
});
handle.join().unwrap();
assert!(
acquired.load(std::sync::atomic::Ordering::SeqCst),
"different-ref acquisition was blocked by another ref's mutex"
);
drop(guard_a);
}
#[test]
fn cross_mem_acquires_in_lex_order() {
let a = unique_ref("cross-aaa");
let b = unique_ref("cross-bbb");
let c = unique_ref("cross-ccc");
let mut expected = vec![a.as_str(), b.as_str(), c.as_str()];
expected.sort_unstable();
let _guards = acquire_branch_mutexes_in_order(&[c.as_str(), a.as_str(), b.as_str()]);
#[cfg(debug_assertions)]
HELD_BRANCH_KEYS.with(|held| {
let held = held.borrow();
let tail: Vec<&str> = held.iter().rev().take(3).map(String::as_str).collect();
let mut pushed: Vec<&str> = tail.into_iter().rev().collect();
pushed.sort();
assert_eq!(pushed, expected, "lex-order acquisition violated");
});
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "out-of-order branch-mutex acquisition")]
fn out_of_order_acquisition_panics_in_debug() {
let high = unique_ref("zzz-high");
let low = unique_ref("aaa-low");
let arc_high = acquire_branch_mutex(&high);
let _g_high = arc_high.lock().unwrap();
HELD_BRANCH_KEYS.with(|held| {
held.borrow_mut().push(high.clone());
});
let _arc_low = acquire_branch_mutex(&low); }
}