use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use gix::objs::tree::EntryKind;
use super::{CommitId, MemWriter, MemWriterError};
use crate::vcs::{CommitContext, acquire_branch_mutex, author_identity, format_commit_message};
enum PendingState {
Upsert(Vec<u8>),
Delete,
}
struct Pending {
parent: Option<gix::ObjectId>,
ops: HashMap<String, PendingState>,
}
impl Pending {
fn new() -> Self {
Self {
parent: None,
ops: HashMap::new(),
}
}
fn clear(&mut self) {
self.parent = None;
self.ops.clear();
}
}
pub struct GitTreeMemWriter {
gitdir: PathBuf,
ref_name: String,
pending: Mutex<Pending>,
}
impl GitTreeMemWriter {
pub fn new(gitdir: PathBuf, ref_name: String) -> Self {
Self {
gitdir,
ref_name,
pending: Mutex::new(Pending::new()),
}
}
fn open_repo(&self) -> Result<gix::Repository, MemWriterError> {
gix::open(&self.gitdir).map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: open repo at {}: {e}",
self.gitdir.display()
))
})
}
fn ensure_snapshot(&self, pending: &mut Pending) -> Result<(), MemWriterError> {
if pending.parent.is_some() || !pending.ops.is_empty() {
return Ok(());
}
let repo = self.open_repo()?;
let mut reference = match repo.try_find_reference(&self.ref_name).map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: resolve ref {}: {e}",
self.ref_name
))
})? {
Some(r) => r,
None => return Ok(()),
};
let id = reference.peel_to_id().map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: peel ref {} to id: {e}",
self.ref_name
))
})?;
pending.parent = Some(id.detach());
Ok(())
}
fn live_tip(&self) -> Result<Option<gix::ObjectId>, MemWriterError> {
let repo = self.open_repo()?;
let mut reference = match repo.try_find_reference(&self.ref_name).map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: resolve ref {}: {e}",
self.ref_name
))
})? {
Some(r) => r,
None => return Ok(None),
};
let id = reference.peel_to_id().map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: peel ref {} to id: {e}",
self.ref_name
))
})?;
Ok(Some(id.detach()))
}
fn read_blob_from_parent(
&self,
parent: gix::ObjectId,
path: &str,
) -> Result<Option<Vec<u8>>, MemWriterError> {
let repo = self.open_repo()?;
let commit = repo
.find_object(parent)
.map_err(|e| MemWriterError::Path(format!("git-tree writer: open parent commit: {e}")))?
.into_commit();
let tree = commit.tree().map_err(|e| {
MemWriterError::Path(format!("git-tree writer: peel commit to tree: {e}"))
})?;
let entry = match tree.lookup_entry_by_path(path).map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: lookup {path} in parent tree: {e}"
))
})? {
Some(e) => e,
None => return Ok(None),
};
if !entry.mode().is_blob() {
return Ok(None);
}
let object = repo
.find_object(entry.id())
.map_err(|e| MemWriterError::Path(format!("git-tree writer: read blob {path}: {e}")))?;
Ok(Some(object.data.clone()))
}
}
fn normalise_rel_path(rel_path: &Path) -> Result<String, MemWriterError> {
if rel_path.as_os_str().is_empty() {
return Err(MemWriterError::Path(
"mem-relative path is empty".to_string(),
));
}
let mut parts: Vec<String> = Vec::new();
for component in rel_path.components() {
use std::path::Component;
match component {
Component::Normal(s) => match s.to_str() {
Some(p) if !p.is_empty() => parts.push(p.to_string()),
_ => {
return Err(MemWriterError::Path(format!(
"non-utf-8 or empty path component in {}",
rel_path.display()
)));
}
},
Component::CurDir => continue,
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(MemWriterError::Path(format!(
"path traversal or absolute component in {}",
rel_path.display()
)));
}
}
}
if parts.is_empty() {
return Err(MemWriterError::Path(
"mem-relative path is empty after normalisation".to_string(),
));
}
Ok(parts.join("/"))
}
impl MemWriter for GitTreeMemWriter {
fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), MemWriterError> {
let key = normalise_rel_path(rel_path)?;
let mut pending = self.pending.lock().map_err(|_| {
MemWriterError::Path("git-tree writer pending state poisoned".to_string())
})?;
self.ensure_snapshot(&mut pending)?;
pending
.ops
.insert(key, PendingState::Upsert(content.to_vec()));
Ok(())
}
fn delete_entity(&self, rel_path: &Path) -> Result<(), MemWriterError> {
let key = normalise_rel_path(rel_path)?;
let mut pending = self.pending.lock().map_err(|_| {
MemWriterError::Path("git-tree writer pending state poisoned".to_string())
})?;
self.ensure_snapshot(&mut pending)?;
pending.ops.insert(key, PendingState::Delete);
Ok(())
}
fn move_entity(&self, from: &Path, to: &Path) -> Result<(), MemWriterError> {
let from_key = normalise_rel_path(from)?;
let to_key = normalise_rel_path(to)?;
let mut pending = self.pending.lock().map_err(|_| {
MemWriterError::Path("git-tree writer pending state poisoned".to_string())
})?;
self.ensure_snapshot(&mut pending)?;
let bytes = match pending.ops.remove(&from_key) {
Some(PendingState::Upsert(b)) => b,
Some(PendingState::Delete) => {
pending.ops.insert(from_key, PendingState::Delete);
return Err(MemWriterError::Path(format!(
"move source {} is already pending deletion",
from.display()
)));
}
None => {
let parent = pending.parent;
let blob = match parent {
Some(p) => self.read_blob_from_parent(p, &from_key)?,
None => None,
};
match blob {
Some(b) => b,
None => {
return Err(MemWriterError::Path(format!(
"move source {} does not exist",
from.display()
)));
}
}
}
};
if matches!(pending.ops.get(&to_key), Some(PendingState::Upsert(_))) {
return Err(MemWriterError::Path(format!(
"move target {} already has a pending write",
to.display()
)));
}
pending.ops.insert(from_key, PendingState::Delete);
pending.ops.insert(to_key, PendingState::Upsert(bytes));
Ok(())
}
fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, MemWriterError> {
let mutex = acquire_branch_mutex(&self.ref_name);
let _guard = mutex.lock().map_err(|_| {
MemWriterError::Path(format!(
"git-tree writer mutex poisoned for ref {} (gitdir {})",
self.ref_name,
self.gitdir.display()
))
})?;
let repo = self.open_repo()?;
let mut pending = self.pending.lock().map_err(|_| {
MemWriterError::Path("git-tree writer pending state poisoned".to_string())
})?;
self.ensure_snapshot(&mut pending)?;
let parent_snapshot = pending.parent;
let mut editor = match parent_snapshot {
Some(parent_id) => {
let commit = repo
.find_object(parent_id)
.map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: open parent {parent_id}: {e}"
))
})?
.into_commit();
let tree = commit.tree().map_err(|e| {
MemWriterError::Path(format!("git-tree writer: peel parent tree: {e}"))
})?;
tree.edit().map_err(|e| {
MemWriterError::Path(format!("git-tree writer: editor init: {e}"))
})?
}
None => repo.empty_tree().edit().map_err(|e| {
MemWriterError::Path(format!("git-tree writer: empty editor init: {e}"))
})?,
};
for (path, state) in pending.ops.iter() {
match state {
PendingState::Upsert(bytes) => {
let blob_id = repo
.write_blob(bytes.as_slice())
.map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: write blob for {path}: {e}"
))
})?
.detach();
editor
.upsert(path.as_str(), EntryKind::Blob, blob_id)
.map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: tree upsert {path}: {e}"
))
})?;
}
PendingState::Delete => {
editor.remove(path.as_str()).map_err(|e| {
MemWriterError::Path(format!("git-tree writer: tree remove {path}: {e}"))
})?;
}
}
}
let tree_id = editor
.write()
.map_err(|e| MemWriterError::Path(format!("git-tree writer: 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 parents: Vec<gix::ObjectId> = match parent_snapshot {
Some(p) => vec![p],
None => Vec::new(),
};
let commit_result = repo.commit_as(
committer_ref,
author_ref,
self.ref_name.as_str(),
full_message,
tree_id,
parents,
);
pending.clear();
let commit_id = match commit_result {
Ok(id) => id,
Err(gix::commit::Error::ReferenceEdit(_)) => {
let mut reference = repo
.try_find_reference(&self.ref_name)
.map_err(|e| {
MemWriterError::Path(format!(
"git-tree writer: re-resolve ref after CAS: {e}"
))
})?
.ok_or_else(|| {
MemWriterError::Path(format!(
"git-tree writer: ref {} vanished during CAS recovery",
self.ref_name
))
})?;
let live_id = reference.peel_to_id().map_err(|e| {
MemWriterError::Path(format!("git-tree writer: peel live tip after CAS: {e}"))
})?;
return Err(MemWriterError::HashMismatch {
current: live_id.to_hex().to_string(),
});
}
Err(e) => {
return Err(MemWriterError::Path(format!(
"git-tree writer: commit_as failed: {e}"
)));
}
};
let sha_hex = commit_id.to_hex().to_string();
sync_index_and_worktree(&repo, &self.ref_name)?;
Ok(sha_hex)
}
}
impl memstead_base::backend::MemBackend for GitTreeMemWriter {
fn list_entities(&self) -> Result<Vec<PathBuf>, memstead_base::backend::BackendError> {
let blobs = match read_branch_blobs(&self.gitdir, &self.ref_name) {
Ok(b) => b,
Err(BranchReadError::BranchMissing { .. }) => return Ok(Vec::new()),
Err(e) => {
return Err(memstead_base::backend::BackendError::Other(format!(
"git-tree backend list_entities: {e}"
)));
}
};
Ok(blobs
.into_iter()
.filter_map(|b| {
if b.path.ends_with(".md") && !b.path.starts_with(".memstead/") {
Some(PathBuf::from(b.path))
} else {
None
}
})
.collect())
}
fn read_entity(
&self,
rel_path: &Path,
) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
let key = normalise_rel_path(rel_path)?;
let pending = self.pending.lock().map_err(|_| {
memstead_base::backend::BackendError::Other(
"git-tree backend pending state poisoned".to_string(),
)
})?;
if let Some(state) = pending.ops.get(&key) {
return Ok(match state {
PendingState::Upsert(b) => Some(b.clone()),
PendingState::Delete => None,
});
}
let snapshot_parent = if pending.ops.is_empty() {
None
} else {
pending.parent
};
drop(pending);
let source = match snapshot_parent {
Some(p) => Some(p),
None => self
.live_tip()
.map_err(memstead_base::backend::BackendError::from)?,
};
match source {
Some(p) => self
.read_blob_from_parent(p, &key)
.map_err(memstead_base::backend::BackendError::from),
None => Ok(None),
}
}
fn write_entity(
&self,
rel_path: &Path,
content: &[u8],
) -> Result<(), memstead_base::backend::BackendError> {
<Self as MemWriter>::write_entity(self, rel_path, content).map_err(Into::into)
}
fn delete_entity(&self, rel_path: &Path) -> Result<(), memstead_base::backend::BackendError> {
<Self as MemWriter>::delete_entity(self, rel_path).map_err(Into::into)
}
fn move_entity(
&self,
from: &Path,
to: &Path,
) -> Result<(), memstead_base::backend::BackendError> {
<Self as MemWriter>::move_entity(self, from, to).map_err(Into::into)
}
fn discard_pending(&self) -> Result<(), memstead_base::backend::BackendError> {
let mut pending = self.pending.lock().map_err(|_| {
memstead_base::backend::BackendError::Other(
"git-tree writer pending state poisoned".to_string(),
)
})?;
pending.clear();
Ok(())
}
fn commit(
&self,
message: &str,
ctx: &CommitContext<'_>,
) -> Result<CommitId, memstead_base::backend::BackendError> {
<Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
}
fn commit_with_expected_parent(
&self,
message: &str,
ctx: &CommitContext<'_>,
expected_parent: Option<&str>,
) -> Result<CommitId, memstead_base::backend::BackendError> {
let Some(expected) = expected_parent else {
return <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into);
};
let mutex = acquire_branch_mutex(&self.ref_name);
let guard = mutex.lock().map_err(|_| {
memstead_base::backend::BackendError::Other(format!(
"git-tree writer mutex poisoned for ref {} (gitdir {})",
self.ref_name,
self.gitdir.display()
))
})?;
let actual = match gix::open(&self.gitdir) {
Ok(repo) => match repo.try_find_reference(&self.ref_name) {
Ok(Some(mut r)) => r
.peel_to_id()
.ok()
.map(|id| id.detach().to_hex().to_string()),
Ok(None) => None,
Err(e) => {
return Err(memstead_base::backend::BackendError::Other(format!(
"git-tree writer: resolve ref {} for parent check: {e}",
self.ref_name
)));
}
},
Err(e) => {
return Err(memstead_base::backend::BackendError::Other(format!(
"git-tree writer: open repo at {} for parent check: {e}",
self.gitdir.display()
)));
}
};
let actual_str = actual.unwrap_or_default();
if actual_str != expected {
return Err(memstead_base::backend::BackendError::ParentMismatch {
expected: expected.to_string(),
actual: actual_str,
});
}
drop(guard);
<Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
}
fn append_provenance(
&self,
_record: &memstead_base::Provenance,
) -> Result<(), memstead_base::backend::BackendError> {
Ok(())
}
fn read_provenance(
&self,
cursor: Option<&str>,
) -> Result<Vec<memstead_base::Provenance>, memstead_base::backend::BackendError> {
let since = cursor.unwrap_or(crate::ops::changes::EMPTY_TREE_SHA);
let report = match crate::ops::agent_notes::agent_notes_since(
"",
&self.gitdir,
since,
Some(&self.ref_name),
) {
Ok(r) => r,
Err(e) => {
return Err(memstead_base::backend::BackendError::Other(format!(
"git-tree backend read_provenance: {e}"
)));
}
};
let mut out: Vec<memstead_base::Provenance> = report
.notes
.into_iter()
.map(commit_note_to_provenance)
.collect();
out.reverse();
Ok(out)
}
fn current_head(&self) -> Result<Option<String>, memstead_base::backend::BackendError> {
let repo = match gix::open(&self.gitdir) {
Ok(r) => r,
Err(e) => {
tracing::debug!(
gitdir = %self.gitdir.display(),
error = %e,
"current_head: open gitdir failed; treating as no baseline"
);
return Ok(None);
}
};
let mut reference = match repo.try_find_reference(&self.ref_name) {
Ok(Some(r)) => r,
Ok(None) => return Ok(None),
Err(e) => {
tracing::debug!(
ref_name = %self.ref_name,
error = %e,
"current_head: ref lookup failed; treating as no baseline"
);
return Ok(None);
}
};
Ok(reference
.peel_to_id()
.ok()
.map(|id| id.detach().to_hex().to_string()))
}
fn read_mem_config(&self) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
let leaf = self
.ref_name
.strip_prefix("refs/heads/")
.unwrap_or(&self.ref_name);
read_blob_from_ref(
&self.gitdir,
"refs/heads/__MEMSTEAD",
&format!("mems/{leaf}/config.json"),
)
}
fn read_anchors_sidecar(
&self,
) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
<Self as memstead_base::backend::MemBackend>::read_entity(
self,
Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
)
}
fn write_anchors_sidecar(
&self,
bytes: &[u8],
) -> Result<(), memstead_base::backend::BackendError> {
<Self as MemWriter>::write_entity(
self,
Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
bytes,
)
.map_err(Into::into)
}
fn delete_artifacts(&self) -> Result<(), memstead_base::backend::BackendError> {
let branch_leaf = self
.ref_name
.strip_prefix("refs/heads/")
.unwrap_or(&self.ref_name);
let ctx = CommitContext {
actor: memstead_base::vcs::Actor::Agent,
client: None,
tool: Some("memstead_mem_delete"),
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
crate::storage_memstead::delete_mem_artifacts_at_gitdir(&self.gitdir, branch_leaf, &ctx)
.map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
}
fn write_mem_config(&self, bytes: &[u8]) -> Result<(), memstead_base::backend::BackendError> {
self.write_mem_config_with_note(bytes, None)
}
fn write_mem_config_with_note(
&self,
bytes: &[u8],
note: Option<&str>,
) -> Result<(), memstead_base::backend::BackendError> {
let leaf = self
.ref_name
.strip_prefix("refs/heads/")
.unwrap_or(&self.ref_name);
let ctx = CommitContext {
actor: memstead_base::vcs::Actor::Agent,
client: None,
tool: Some("memstead_mem_config_write"),
note: note.map(str::to_string),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
crate::storage_memstead::commit_config_to_memstead_at_gitdir(
&self.gitdir,
leaf,
bytes,
&ctx,
&format!("memstead: commit __MEMSTEAD:mems/{leaf}/config.json"),
)
.map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
}
fn record_pipeline_edit(
&self,
kind: &str,
edits: &[(String, Option<Vec<u8>>)],
note: Option<&str>,
verb: &str,
) -> Result<(), memstead_base::backend::BackendError> {
let leaf = self
.ref_name
.strip_prefix("refs/heads/")
.unwrap_or(&self.ref_name);
let tree_edits: Vec<(String, Option<Vec<u8>>)> = edits
.iter()
.map(|(name, bytes)| (format!("pipeline/{kind}/{leaf}/{name}.json"), bytes.clone()))
.collect();
let ctx = CommitContext {
actor: memstead_base::vcs::Actor::Agent,
client: None,
tool: Some("memstead_pipeline_edit"),
note: note.map(str::to_string),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
let names: Vec<&str> = edits.iter().map(|(n, _)| n.as_str()).collect();
crate::storage_memstead::commit_paths_to_memstead_at_gitdir(
&self.gitdir,
&tree_edits,
&ctx,
&format!("memstead: {verb} {kind} {leaf}/{}", names.join(", ")),
)
.map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
}
}
fn read_blob_from_ref(
gitdir: &Path,
ref_name: &str,
path: &str,
) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
let repo = match gix::open(gitdir) {
Ok(r) => r,
Err(_) => return Ok(None),
};
let reference = match repo.try_find_reference(ref_name) {
Ok(Some(r)) => r,
Ok(None) => return Ok(None),
Err(e) => {
return Err(memstead_base::backend::BackendError::Other(format!(
"find ref {ref_name}: {e}"
)));
}
};
let id = reference.into_fully_peeled_id().map_err(|e| {
memstead_base::backend::BackendError::Other(format!("peel {ref_name}: {e}"))
})?;
let object = id.object().map_err(|e| {
memstead_base::backend::BackendError::Other(format!("read obj {ref_name}: {e}"))
})?;
let commit = match object.try_into_commit() {
Ok(c) => c,
Err(_) => return Ok(None),
};
let tree = commit.tree().map_err(|e| {
memstead_base::backend::BackendError::Other(format!("read tree {ref_name}: {e}"))
})?;
let entry = match tree.lookup_entry_by_path(path) {
Ok(Some(e)) => e,
Ok(None) => return Ok(None),
Err(e) => {
return Err(memstead_base::backend::BackendError::Other(format!(
"lookup {ref_name}:{path}: {e}"
)));
}
};
let blob = entry.object().map_err(|e| {
memstead_base::backend::BackendError::Other(format!("read blob {ref_name}:{path}: {e}"))
})?;
Ok(Some(blob.data.clone()))
}
fn commit_note_to_provenance(n: crate::ops::agent_notes::CommitNote) -> memstead_base::Provenance {
let kind = n
.tool_verb
.as_deref()
.and_then(memstead_base::ProvenanceKind::parse)
.unwrap_or(memstead_base::ProvenanceKind::Update);
let actor = n
.actor
.as_deref()
.and_then(memstead_base::vcs::Actor::from_trailer)
.unwrap_or(memstead_base::vcs::Actor::Unknown);
let client = n
.client
.as_deref()
.and_then(memstead_base::vcs::parse_client_id);
let timestamp = if n.timestamp >= 0 {
std::time::UNIX_EPOCH + std::time::Duration::from_secs(n.timestamp as u64)
} else {
std::time::UNIX_EPOCH
};
let mut record =
memstead_base::Provenance::new(timestamp, kind, n.entity_id, actor, client, n.note);
if let Some(id) = n.logical_operation_id {
record = record.with_logical_operation_id(id);
}
if let Some(role) = n
.role
.as_deref()
.and_then(memstead_base::vcs::Role::from_wire)
{
record = record.with_role(role);
}
record
}
fn sync_index_and_worktree(repo: &gix::Repository, ref_name: &str) -> Result<(), MemWriterError> {
let Some(workdir) = repo.workdir() else {
return Ok(());
};
let head_matches = matches!(repo.head_name(), Ok(Some(name)) if name.as_bstr() == ref_name);
if !head_matches {
return Ok(());
}
let output = std::process::Command::new("git")
.arg("-C")
.arg(workdir)
.args(["read-tree", "--reset", "-u", "HEAD"])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| {
std::io::Error::other(format!(
"worktree sync: spawn `git -C {} read-tree --reset -u HEAD`: {e}; \
commit already landed in the object store, recover with \
`git -C {} reset --hard HEAD`",
workdir.display(),
workdir.display()
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(MemWriterError::Io(std::io::Error::other(format!(
"worktree sync: `git -C {} read-tree --reset -u HEAD` failed (status {}): {}; \
commit already landed in the object store, recover with \
`git -C {} reset --hard HEAD` (or remove a stale \
`<workdir>/.git/index.lock` if one exists)",
workdir.display(),
output.status,
stderr.trim(),
workdir.display()
))));
}
Ok(())
}
const COMMITTER_NAME: &str = "engine";
const COMMITTER_EMAIL: &str = "noreply@memstead.io";
#[derive(Debug, Clone)]
pub struct BranchBlob {
pub path: String,
pub bytes: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum BranchReadError {
#[error("git-tree reader: open repo at {path}: {source}")]
Open {
path: String,
#[source]
source: gix::open::Error,
},
#[error("git-tree reader: branch {ref_name} not found")]
BranchMissing { ref_name: String },
#[error("git-tree reader: resolve branch {ref_name}: {message}")]
Resolve { ref_name: String, message: String },
#[error("git-tree reader: read object: {message}")]
Read { message: String },
}
pub fn read_branch_blobs(
gitdir: &Path,
ref_name: &str,
) -> Result<Vec<BranchBlob>, BranchReadError> {
let repo = gix::open(gitdir).map_err(|e| BranchReadError::Open {
path: gitdir.display().to_string(),
source: e,
})?;
let mut reference =
match repo
.try_find_reference(ref_name)
.map_err(|e| BranchReadError::Resolve {
ref_name: ref_name.to_string(),
message: e.to_string(),
})? {
Some(r) => r,
None => {
return Err(BranchReadError::BranchMissing {
ref_name: ref_name.to_string(),
});
}
};
let id = reference
.peel_to_id()
.map_err(|e| BranchReadError::Resolve {
ref_name: ref_name.to_string(),
message: e.to_string(),
})?;
let commit = repo
.find_object(id)
.map_err(|e| BranchReadError::Read {
message: format!("open commit {id}: {e}"),
})?
.into_commit();
let tree = commit.tree().map_err(|e| BranchReadError::Read {
message: format!("peel commit to tree: {e}"),
})?;
let mut out: Vec<BranchBlob> = Vec::new();
walk_tree(&repo, &tree, "", &mut out)?;
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
fn walk_tree(
repo: &gix::Repository,
tree: &gix::Tree<'_>,
prefix: &str,
out: &mut Vec<BranchBlob>,
) -> Result<(), BranchReadError> {
use gix::objs::tree::EntryKind;
let iter = tree.iter();
for entry_res in iter {
let entry = entry_res.map_err(|e| BranchReadError::Read {
message: format!("decode tree entry: {e}"),
})?;
let name = entry.filename().to_string();
let full = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
match entry.mode().kind() {
EntryKind::Blob | EntryKind::BlobExecutable => {
let object = repo
.find_object(entry.oid())
.map_err(|e| BranchReadError::Read {
message: format!("read blob {full}: {e}"),
})?;
out.push(BranchBlob {
path: full,
bytes: object.data.clone(),
});
}
EntryKind::Tree => {
let subtree = repo
.find_object(entry.oid())
.map_err(|e| BranchReadError::Read {
message: format!("read subtree {full}: {e}"),
})?
.into_tree();
walk_tree(repo, &subtree, &full, out)?;
}
EntryKind::Link | EntryKind::Commit => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vcs::{Actor, ClientId, CommitContext};
use std::path::Path;
use tempfile::TempDir;
fn fresh_repo_dir(tmp: &Path) -> PathBuf {
let git_dir = tmp.join("mem-repo.git");
gix::init_bare(&git_dir).unwrap();
std::fs::canonicalize(&git_dir).unwrap()
}
fn ctx_for_test<'a>() -> CommitContext<'a> {
CommitContext {
actor: Actor::Cli,
client: Some(ClientId {
name: "claude-code".to_string(),
version: "0.1.0".to_string(),
}),
tool: Some("test"),
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
}
}
fn read_blob(gitdir: &Path, ref_name: &str, path: &str) -> Option<Vec<u8>> {
let repo = gix::open(gitdir).unwrap();
let mut reference = repo.try_find_reference(ref_name).unwrap()?;
let id = reference.peel_to_id().unwrap();
let commit = repo.find_object(id).unwrap().into_commit();
let tree = commit.tree().unwrap();
let entry = tree.lookup_entry_by_path(path).unwrap()?;
let object = repo.find_object(entry.id()).unwrap();
Some(object.data.clone())
}
fn tree_path_exists(gitdir: &Path, ref_name: &str, path: &str) -> bool {
read_blob(gitdir, ref_name, path).is_some()
}
#[test]
fn git_tree_writer_round_trip() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
writer
.write_entity(Path::new("notes/hello.md"), b"# hi\n")
.unwrap();
let sha = writer.commit("first commit", &ctx_for_test()).unwrap();
assert_eq!(sha.len(), 40);
let bytes = read_blob(&gitdir, "refs/heads/test", "notes/hello.md").unwrap();
assert_eq!(bytes, b"# hi\n");
}
#[test]
fn git_tree_writer_anchors_sidecar_rides_commit_and_survives_reload() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
MemBackend::write_entity(&writer, Path::new("hello.md"), b"# hi\n").unwrap();
writer
.write_anchors_sidecar(b"{\"version\":1,\"entities\":{}}")
.unwrap();
MemBackend::commit(&writer, "entity+anchors", &ctx_for_test()).unwrap();
let sidecar = read_blob(&gitdir, "refs/heads/test", ".memstead/anchors.json").unwrap();
assert_eq!(sidecar, b"{\"version\":1,\"entities\":{}}");
let reloaded = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
assert_eq!(
reloaded.read_anchors_sidecar().unwrap(),
Some(b"{\"version\":1,\"entities\":{}}".to_vec())
);
assert_eq!(
MemBackend::list_entities(&reloaded).unwrap(),
vec![PathBuf::from("hello.md")]
);
}
#[test]
fn git_tree_writer_delete_removes_path() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
writer.write_entity(Path::new("a.md"), b"a").unwrap();
writer.write_entity(Path::new("b.md"), b"b").unwrap();
writer.commit("seed", &ctx_for_test()).unwrap();
writer.delete_entity(Path::new("a.md")).unwrap();
writer.commit("drop a", &ctx_for_test()).unwrap();
assert!(!tree_path_exists(&gitdir, "refs/heads/test", "a.md"));
assert!(tree_path_exists(&gitdir, "refs/heads/test", "b.md"));
}
#[test]
fn git_tree_writer_move_renames_path() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
writer
.write_entity(Path::new("from.md"), b"payload")
.unwrap();
writer.commit("seed", &ctx_for_test()).unwrap();
writer
.move_entity(Path::new("from.md"), Path::new("nested/to.md"))
.unwrap();
writer.commit("rename", &ctx_for_test()).unwrap();
assert!(!tree_path_exists(&gitdir, "refs/heads/test", "from.md"));
let moved = read_blob(&gitdir, "refs/heads/test", "nested/to.md").unwrap();
assert_eq!(moved, b"payload");
}
#[test]
fn git_tree_writer_multi_op_commit() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
writer.commit("seed", &ctx_for_test()).unwrap();
writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
writer
.write_entity(Path::new("nested/b.md"), b"beta")
.unwrap();
writer.delete_entity(Path::new("doomed.md")).unwrap();
writer.commit("multi-op", &ctx_for_test()).unwrap();
assert!(!tree_path_exists(&gitdir, "refs/heads/test", "doomed.md"));
assert_eq!(
read_blob(&gitdir, "refs/heads/test", "a.md").unwrap(),
b"alpha"
);
assert_eq!(
read_blob(&gitdir, "refs/heads/test", "nested/b.md").unwrap(),
b"beta"
);
}
#[test]
fn git_tree_writer_cas_conflict_surfaces_hash_mismatch() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
seeder.write_entity(Path::new("seed.md"), b"x").unwrap();
let seed_sha = seeder.commit("seed", &ctx_for_test()).unwrap();
let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
a.write_entity(Path::new("a.md"), b"a").unwrap();
b.write_entity(Path::new("b.md"), b"b").unwrap();
assert_eq!(
a.pending
.lock()
.unwrap()
.parent
.unwrap()
.to_hex()
.to_string(),
seed_sha
);
assert_eq!(
b.pending
.lock()
.unwrap()
.parent
.unwrap()
.to_hex()
.to_string(),
seed_sha
);
let new_tip = a.commit("a wins", &ctx_for_test()).unwrap();
let err = b
.commit("b loses", &ctx_for_test())
.expect_err("B's commit must fail with HashMismatch");
match err {
MemWriterError::HashMismatch { current } => {
assert_eq!(current, new_tip);
}
other => panic!("expected HashMismatch, got {other:?}"),
}
}
#[test]
fn cas_conflict_clears_pending_so_reads_fall_back_to_committed_truth() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
seeder.write_entity(Path::new("shared.md"), b"v1").unwrap();
seeder.commit("seed", &ctx_for_test()).unwrap();
let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
a.write_entity(Path::new("shared.md"), b"A-committed")
.unwrap();
b.write_entity(Path::new("shared.md"), b"B-phantom")
.unwrap();
a.commit("a wins", &ctx_for_test()).unwrap();
let err = b
.commit("b loses", &ctx_for_test())
.expect_err("B must lose the CAS race");
assert!(
matches!(err, MemWriterError::HashMismatch { .. }),
"expected HashMismatch, got {err:?}"
);
assert!(
b.pending.lock().unwrap().ops.is_empty(),
"pending must be cleared after a failed commit"
);
let read = <GitTreeMemWriter as memstead_base::backend::MemBackend>::read_entity(
&b,
Path::new("shared.md"),
)
.unwrap();
assert_eq!(
read.as_deref(),
Some(&b"A-committed"[..]),
"read must serve committed truth, not the phantom staged write"
);
}
#[test]
fn commit_with_expected_parent_succeeds_when_ref_matches_pin() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
let seed_sha =
<GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
let expected = <GitTreeMemWriter as MemBackend>::current_head(&writer)
.unwrap()
.expect("seeded ref has a head");
assert_eq!(expected, seed_sha);
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("after.md"), b"after")
.unwrap();
let new_tip = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
&writer,
"pinned commit",
&ctx_for_test(),
Some(&expected),
)
.expect("parent matches pin → commit must succeed");
assert_ne!(new_tip, seed_sha);
assert_eq!(
read_blob(&gitdir, "refs/heads/test", "after.md").unwrap(),
b"after"
);
}
#[test]
fn commit_with_expected_parent_surfaces_parent_mismatch_when_sibling_advances_ref() {
use memstead_base::backend::{BackendError, MemBackend};
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
let seed_sha =
<GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
let pin = <GitTreeMemWriter as MemBackend>::current_head(&a)
.unwrap()
.expect("seeded ref has a head");
assert_eq!(pin, seed_sha);
let sibling = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(&sibling, Path::new("drift.md"), b"drift")
.unwrap();
let new_tip =
<GitTreeMemWriter as MemWriter>::commit(&sibling, "sibling advance", &ctx_for_test())
.unwrap();
assert_ne!(new_tip, seed_sha);
<GitTreeMemWriter as MemWriter>::write_entity(&a, Path::new("a.md"), b"a").unwrap();
let err = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
&a,
"pinned commit",
&ctx_for_test(),
Some(&pin),
)
.expect_err("pin no longer matches live tip → commit must refuse");
match err {
BackendError::ParentMismatch { expected, actual } => {
assert_eq!(expected, pin);
assert_eq!(actual, new_tip);
}
other => panic!("expected ParentMismatch, got {other:?}"),
}
}
#[test]
fn commit_with_expected_parent_none_pin_is_equivalent_to_commit() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("hello.md"), b"hi")
.unwrap();
let sha = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
&writer,
"unpinned",
&ctx_for_test(),
None,
)
.expect("None pin → plain commit semantics, must succeed against empty ref");
assert_eq!(sha.len(), 40);
assert_eq!(
read_blob(&gitdir, "refs/heads/test", "hello.md").unwrap(),
b"hi"
);
}
#[test]
fn git_tree_writer_blob_oid_is_content_addressed() {
let tmp_a = TempDir::new().unwrap();
let tmp_b = TempDir::new().unwrap();
let payload = b"shared content\n";
let gitdir_a = fresh_repo_dir(tmp_a.path());
let writer_a = GitTreeMemWriter::new(gitdir_a.clone(), "refs/heads/a".to_string());
writer_a
.write_entity(Path::new("file.md"), payload)
.unwrap();
let sha_a = writer_a.commit("a", &ctx_for_test()).unwrap();
let repo_a = gix::open(&gitdir_a).unwrap();
let commit_a = repo_a
.find_object(gix::ObjectId::from_hex(sha_a.as_bytes()).unwrap())
.unwrap()
.into_commit();
let blob_id_a = commit_a
.tree()
.unwrap()
.lookup_entry_by_path("file.md")
.unwrap()
.unwrap()
.id()
.detach();
let gitdir_b = fresh_repo_dir(tmp_b.path());
let writer_b = GitTreeMemWriter::new(gitdir_b.clone(), "refs/heads/b".to_string());
writer_b
.write_entity(Path::new("file.md"), payload)
.unwrap();
let sha_b = writer_b.commit("b", &ctx_for_test()).unwrap();
let repo_b = gix::open(&gitdir_b).unwrap();
let commit_b = repo_b
.find_object(gix::ObjectId::from_hex(sha_b.as_bytes()).unwrap())
.unwrap()
.into_commit();
let blob_id_b = commit_b
.tree()
.unwrap()
.lookup_entry_by_path("file.md")
.unwrap()
.unwrap()
.id()
.detach();
assert_eq!(
blob_id_a, blob_id_b,
"same content must produce byte-identical blob OIDs"
);
}
fn fresh_non_bare_repo(tmp: &Path) -> (PathBuf, PathBuf) {
let workdir = tmp.join("mem-repo-workdir");
std::fs::create_dir_all(&workdir).unwrap();
let status = std::process::Command::new("git")
.arg("-C")
.arg(&workdir)
.args(["init", "--initial-branch=main", "--quiet"])
.status()
.expect("git init must succeed");
assert!(status.success(), "git init failed");
let workdir = std::fs::canonicalize(&workdir).unwrap();
let gitdir = workdir.join(".git");
(workdir, gitdir)
}
#[test]
fn sync_helper_skips_on_bare_repo() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let repo = gix::open(&gitdir).unwrap();
sync_index_and_worktree(&repo, "refs/heads/main").unwrap();
}
#[test]
fn sync_helper_updates_worktree_when_ref_matches_head() {
let tmp = TempDir::new().unwrap();
let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
writer
.write_entity(Path::new("configs/alpha.json"), b"{\"name\":\"alpha\"}\n")
.unwrap();
writer.commit("seed alpha", &ctx_for_test()).unwrap();
let on_disk = workdir.join("configs/alpha.json");
assert!(
on_disk.exists(),
"worktree sync must materialise the new blob at {}",
on_disk.display()
);
let bytes = std::fs::read(&on_disk).unwrap();
assert_eq!(bytes, b"{\"name\":\"alpha\"}\n");
let output = std::process::Command::new("git")
.arg("-C")
.arg(&workdir)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
output.stdout.is_empty(),
"git status --porcelain must be empty post-sync, got: {:?}",
String::from_utf8_lossy(&output.stdout)
);
}
#[test]
fn sync_helper_skips_when_ref_does_not_match_head() {
let tmp = TempDir::new().unwrap();
let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/feature".to_string());
writer
.write_entity(Path::new("only-on-feature.md"), b"feature-only\n")
.unwrap();
writer
.commit("first commit on feature", &ctx_for_test())
.unwrap();
assert!(tree_path_exists(
&gitdir,
"refs/heads/feature",
"only-on-feature.md"
));
assert!(
!workdir.join("only-on-feature.md").exists(),
"worktree must not be polluted by writes to a non-checked-out branch"
);
}
#[test]
fn sync_helper_preserves_untracked_files() {
let tmp = TempDir::new().unwrap();
let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
let untracked = workdir.join("scratch.txt");
std::fs::write(&untracked, b"operator notes\n").unwrap();
writer
.write_entity(Path::new("seed.md"), b"seed\n")
.unwrap();
writer.commit("create seed", &ctx_for_test()).unwrap();
assert!(
untracked.exists(),
"sync must leave untracked files in place"
);
assert_eq!(std::fs::read(&untracked).unwrap(), b"operator notes\n");
assert_eq!(std::fs::read(workdir.join("seed.md")).unwrap(), b"seed\n");
}
#[test]
fn sync_helper_updates_through_delete_and_overwrite() {
let tmp = TempDir::new().unwrap();
let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
writer.write_entity(Path::new("a.md"), b"first\n").unwrap();
writer.commit("create a", &ctx_for_test()).unwrap();
assert_eq!(std::fs::read(workdir.join("a.md")).unwrap(), b"first\n");
writer.write_entity(Path::new("a.md"), b"second\n").unwrap();
writer.commit("overwrite a", &ctx_for_test()).unwrap();
assert_eq!(
std::fs::read(workdir.join("a.md")).unwrap(),
b"second\n",
"overwrite must propagate to the worktree"
);
writer.delete_entity(Path::new("a.md")).unwrap();
writer.commit("delete a", &ctx_for_test()).unwrap();
assert!(
!workdir.join("a.md").exists(),
"delete must remove the file from the worktree"
);
let output = std::process::Command::new("git")
.arg("-C")
.arg(&workdir)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
output.stdout.is_empty(),
"git status --porcelain must be empty after every commit, got: {:?}",
String::from_utf8_lossy(&output.stdout)
);
}
fn commit_with_verb(
writer: &GitTreeMemWriter,
verb: &str,
entity_id: &str,
ctx: &CommitContext<'_>,
) {
let subject = format!("memstead: {verb} {entity_id}");
<GitTreeMemWriter as MemWriter>::commit(writer, &subject, ctx).unwrap();
}
fn ctx_with_note<'a>(note: &'a str) -> CommitContext<'a> {
CommitContext {
actor: Actor::Agent,
client: Some(ClientId {
name: "claude-code".to_string(),
version: "2.1.0".to_string(),
}),
tool: Some("memstead_create"),
note: Some(note.to_string()),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
}
}
#[test]
fn backend_list_entities_returns_only_md_outside_memstead_namespace() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"# a").unwrap();
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"# b")
.unwrap();
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
.unwrap();
<GitTreeMemWriter as MemWriter>::write_entity(
&writer,
Path::new(".memstead/config.json"),
b"{}",
)
.unwrap();
<GitTreeMemWriter as MemWriter>::write_entity(
&writer,
Path::new(".memstead/notes.md"),
b"# skip me",
)
.unwrap();
<GitTreeMemWriter as MemWriter>::write_entity(
&writer,
Path::new(".other/notes.md"),
b"# no longer special, walked like any non-meta dir",
)
.unwrap();
<GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
let backend: &dyn MemBackend = &writer;
let mut paths: Vec<String> = backend
.list_entities()
.unwrap()
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
paths.sort();
assert_eq!(
paths,
vec![
".other/notes.md".to_string(),
"a.md".to_string(),
"nested/b.md".to_string(),
]
);
}
#[test]
fn backend_list_entities_returns_empty_for_missing_branch() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
let backend: &dyn MemBackend = &writer;
assert!(backend.list_entities().unwrap().is_empty());
}
#[test]
fn backend_read_entity_consults_pending_then_branch_tip() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(
&writer,
Path::new("on_branch.md"),
b"branch",
)
.unwrap();
<GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
let backend: &dyn MemBackend = &writer;
assert_eq!(
backend.read_entity(Path::new("on_branch.md")).unwrap(),
Some(b"branch".to_vec())
);
backend
.write_entity(Path::new("on_branch.md"), b"buffered")
.unwrap();
assert_eq!(
backend.read_entity(Path::new("on_branch.md")).unwrap(),
Some(b"buffered".to_vec())
);
backend.delete_entity(Path::new("on_branch.md")).unwrap();
assert_eq!(
backend.read_entity(Path::new("on_branch.md")).unwrap(),
None
);
assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
}
#[test]
fn backend_read_provenance_reconstructs_from_commit_log() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
commit_with_verb(&writer, "create", "v:a", &ctx_with_note("first draft"));
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
commit_with_verb(
&writer,
"update",
"v:a",
&CommitContext {
actor: Actor::Cli,
client: None,
tool: Some("memstead_update"),
note: None,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
},
);
let backend: &dyn MemBackend = &writer;
backend
.append_provenance(&memstead_base::Provenance::new(
std::time::UNIX_EPOCH,
memstead_base::ProvenanceKind::Create,
Some("ignored".into()),
Actor::Unknown,
None,
None,
))
.unwrap();
let records = backend.read_provenance(None).unwrap();
assert_eq!(records.len(), 2, "expected two commits, got {records:?}");
assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Create);
assert_eq!(records[0].entity.as_deref(), Some("v:a"));
assert_eq!(records[0].actor, Actor::Agent);
assert_eq!(records[0].note.as_deref(), Some("first draft"));
assert_eq!(
records[0]
.client
.as_ref()
.map(|c| (c.name.as_str(), c.version.as_str())),
Some(("claude-code", "2.1.0"))
);
assert_eq!(records[1].kind, memstead_base::ProvenanceKind::Update);
assert_eq!(records[1].actor, Actor::Cli);
assert!(records[1].note.is_none());
assert!(records[1].client.is_none());
}
#[test]
fn backend_read_provenance_filters_by_cursor_sha() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
let first_sha = <GitTreeMemWriter as MemWriter>::commit(
&writer,
"memstead: create v:a",
&ctx_for_test(),
)
.unwrap();
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
<GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
.unwrap();
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a3").unwrap();
<GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
.unwrap();
let backend: &dyn MemBackend = &writer;
let after = backend.read_provenance(Some(&first_sha)).unwrap();
assert_eq!(
after.len(),
2,
"expected commits after cursor, got {after:?}"
);
for r in &after {
assert_eq!(r.kind, memstead_base::ProvenanceKind::Update);
}
}
#[test]
fn backend_read_provenance_empty_for_missing_branch() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
let backend: &dyn MemBackend = &writer;
assert!(backend.read_provenance(None).unwrap().is_empty());
}
#[test]
fn backend_unknown_verb_falls_back_to_update_kind() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
<GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
commit_with_verb(&writer, "mem_create", "v:a", &ctx_for_test());
let backend: &dyn MemBackend = &writer;
let records = backend.read_provenance(None).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Update);
}
#[test]
fn instantiate_full_backend_constructs_git_branch_writer() {
use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let mount = Mount {
mem: "engine".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::GitBranch {
gitdir,
branch: "engine".to_string(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let backend: Box<dyn MemBackend> =
crate::storage::instantiate_full_backend(&mount).unwrap();
assert!(backend.list_entities().unwrap().is_empty());
assert!(backend.read_provenance(None).unwrap().is_empty());
}
#[test]
fn instantiate_full_backend_accepts_branch_with_or_without_refs_prefix() {
use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
for branch in ["engine", "refs/heads/engine"] {
let mount = Mount {
mem: "engine".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::GitBranch {
gitdir: gitdir.clone(),
branch: branch.to_string(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let backend: Box<dyn MemBackend> =
crate::storage::instantiate_full_backend(&mount).unwrap();
assert!(backend.list_entities().unwrap().is_empty());
}
}
#[test]
fn current_head_returns_none_for_empty_branch() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir, "refs/heads/specs".to_string());
let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
.unwrap();
assert!(head.is_none());
}
#[test]
fn current_head_returns_hex_sha_after_commit() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer.write_entity(Path::new("a.md"), b"a").unwrap();
let sha = writer.commit("first", &ctx_for_test()).unwrap();
assert_eq!(sha.len(), 40);
let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
.unwrap()
.expect("head present after commit");
assert_eq!(head, sha);
}
#[test]
fn current_head_advances_on_subsequent_commits() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer.write_entity(Path::new("a.md"), b"a").unwrap();
let first = writer.commit("first", &ctx_for_test()).unwrap();
let head_after_first =
<GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
.unwrap()
.unwrap();
assert_eq!(head_after_first, first);
writer.write_entity(Path::new("b.md"), b"b").unwrap();
let second = writer.commit("second", &ctx_for_test()).unwrap();
assert_ne!(first, second);
let head_after_second =
<GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
.unwrap()
.unwrap();
assert_eq!(head_after_second, second);
}
#[test]
fn current_head_returns_none_for_missing_gitdir() {
let tmp = TempDir::new().unwrap();
let writer = GitTreeMemWriter::new(
tmp.path().join("does-not-exist.git"),
"refs/heads/specs".to_string(),
);
let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
.unwrap();
assert!(head.is_none());
}
fn dispatch_changes(
gitdir: &Path,
branch: &str,
mem: &str,
since: &str,
) -> Result<memstead_base::ops::BackendChanges, memstead_base::backend::BackendError> {
(crate::storage::FULL_GIT_BRANCH_OPS.changes_since)(
gitdir,
branch,
mem,
since,
memstead_base::ops::RENAME_SIMILARITY_DEFAULT,
)
}
#[test]
fn changes_since_empty_repo_with_sentinel_returns_empty_changes() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let result = dispatch_changes(
&gitdir,
"specs",
"specs",
memstead_base::ops::EMPTY_TREE_SHA,
)
.unwrap();
assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
assert_eq!(result.head, memstead_base::ops::EMPTY_TREE_SHA);
assert!(result.changes.is_empty());
}
#[test]
fn changes_since_after_commit_returns_added_envelopes_id_only() {
use memstead_base::ops::ChangeEnvelope;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer
.write_entity(Path::new("alpha.md"), b"# Alpha")
.unwrap();
writer
.write_entity(Path::new("beta.md"), b"# Beta")
.unwrap();
let head_sha = writer.commit("seed", &ctx_for_test()).unwrap();
let result = dispatch_changes(
&gitdir,
"specs",
"specs",
memstead_base::ops::EMPTY_TREE_SHA,
)
.unwrap();
assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
assert_eq!(result.head, head_sha);
assert_eq!(result.changes.len(), 2);
for env in &result.changes {
match env {
ChangeEnvelope::Added {
id,
title,
entity_type,
} => {
assert!(
id.0.starts_with("specs--"),
"expected mem-prefixed id, got {}",
id.0
);
assert!(title.is_none(), "dispatch must not enrich title");
assert!(
entity_type.is_none(),
"dispatch must not enrich entity_type"
);
}
other => panic!("expected Added envelope, got {other:?}"),
}
}
}
#[test]
fn changes_since_between_two_commits_yields_updated_envelope() {
use memstead_base::ops::ChangeEnvelope;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer
.write_entity(Path::new("alpha.md"), b"# Alpha v1")
.unwrap();
let sha_v1 = writer.commit("v1", &ctx_for_test()).unwrap();
writer
.write_entity(Path::new("alpha.md"), b"# Alpha v2")
.unwrap();
let sha_v2 = writer.commit("v2", &ctx_for_test()).unwrap();
assert_ne!(sha_v1, sha_v2);
let result = dispatch_changes(&gitdir, "specs", "specs", &sha_v1).unwrap();
assert_eq!(result.since, sha_v1);
assert_eq!(result.head, sha_v2);
assert_eq!(result.changes.len(), 1);
match &result.changes[0] {
ChangeEnvelope::Updated {
id,
title,
entity_type,
} => {
assert!(id.0.starts_with("specs--"));
assert!(title.is_none());
assert!(entity_type.is_none());
}
other => panic!("expected Updated envelope, got {other:?}"),
}
}
#[test]
fn anchor_only_commit_yields_zero_entity_deltas_and_valid_cursor() {
use memstead_base::backend::MemBackend;
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
MemBackend::write_entity(&writer, Path::new("alpha.md"), b"# Alpha").unwrap();
let seed_sha = MemBackend::commit(&writer, "seed", &ctx_for_test()).unwrap();
writer
.write_anchors_sidecar(
br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
)
.unwrap();
let anchor_sha = MemBackend::commit(&writer, "anchors", &ctx_for_test()).unwrap();
assert_ne!(seed_sha, anchor_sha);
let from_seed = dispatch_changes(&gitdir, "specs", "specs", &seed_sha).unwrap();
assert_eq!(from_seed.head, anchor_sha);
assert_eq!(
from_seed.changes.len(),
0,
"an anchor-only commit must produce zero entity deltas, got {:?}",
from_seed.changes
);
let from_anchor = dispatch_changes(&gitdir, "specs", "specs", &anchor_sha).unwrap();
assert_eq!(from_anchor.head, anchor_sha);
assert_eq!(from_anchor.changes.len(), 0);
}
#[test]
fn changes_since_unknown_cursor_returns_typed_commit_not_found_marker() {
let tmp = TempDir::new().unwrap();
let gitdir = fresh_repo_dir(tmp.path());
let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
writer.write_entity(Path::new("a.md"), b"a").unwrap();
writer.commit("seed", &ctx_for_test()).unwrap();
let bad_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let err = dispatch_changes(&gitdir, "specs", "specs", bad_sha).unwrap_err();
match err {
memstead_base::backend::BackendError::Other(msg) => {
assert_eq!(
msg,
format!("COMMIT_NOT_FOUND:{bad_sha}"),
"bad-since must carry the typed marker with the untruncated sha: {msg}",
);
}
other => panic!("expected BackendError::Other, got {other:?}"),
}
}
}