use std::path::Path;
use memstead_schema::{ConfigError, MemConfig, SchemaRef};
use crate::MemInit;
use crate::vcs::CommitContext;
#[derive(Debug, thiserror::Error)]
pub enum MemRepoConfigError {
#[error("mem-repo gitdir not found at {0}")]
GitdirNotFound(String),
#[error("could not open mem-repo gitdir: {0}")]
GixOpen(String),
#[error("mem-repo has no `__MEMSTEAD` branch")]
NoMainBranch,
#[error("config not found in mem-repo: __MEMSTEAD:mems/{0}/config.json")]
ConfigNotFound(String),
#[error("git tree read error: {0}")]
GitTree(String),
#[error("config blob is not valid UTF-8: {0}")]
NotUtf8(String),
#[error("{0}")]
Schema(#[from] ConfigError),
}
pub fn resolve_full_path_at_gitdir(
gitdir: &Path,
leaf: &str,
) -> Result<Option<String>, MemRepoConfigError> {
if !gitdir.is_dir() {
return Err(MemRepoConfigError::GitdirNotFound(
gitdir.display().to_string(),
));
}
let repo = gix::open(gitdir).map_err(|e| MemRepoConfigError::GixOpen(e.to_string()))?;
let refs = repo
.references()
.map_err(|e| MemRepoConfigError::GitTree(e.to_string()))?;
let iter = refs
.local_branches()
.map_err(|e| MemRepoConfigError::GitTree(e.to_string()))?;
for r in iter {
let reference = match r {
Ok(reference) => reference,
Err(_) => continue,
};
let short = reference.name().shorten();
let name = match std::str::from_utf8(short) {
Ok(s) => s,
Err(_) => continue,
};
if name == "main" {
continue;
}
if name.starts_with("__") {
continue;
}
let last = name.rsplit('/').next().unwrap_or(name);
if last == leaf {
return Ok(Some(name.to_string()));
}
}
Ok(None)
}
pub fn find_branches_by_leaf_at_gitdir(
gitdir: &Path,
leaf: &str,
) -> Result<Vec<String>, MemRepoConfigError> {
if !gitdir.is_dir() {
return Err(MemRepoConfigError::GitdirNotFound(
gitdir.display().to_string(),
));
}
let repo = gix::open(gitdir).map_err(|e| MemRepoConfigError::GixOpen(e.to_string()))?;
let refs = repo
.references()
.map_err(|e| MemRepoConfigError::GitTree(e.to_string()))?;
let iter = refs
.local_branches()
.map_err(|e| MemRepoConfigError::GitTree(e.to_string()))?;
let mut matches: Vec<String> = Vec::new();
for r in iter {
let reference = match r {
Ok(reference) => reference,
Err(_) => continue,
};
let short = reference.name().shorten();
let name = match std::str::from_utf8(short) {
Ok(s) => s,
Err(_) => continue,
};
if name == "main" {
continue;
}
if name.starts_with("__") {
continue;
}
let last = name.rsplit('/').next().unwrap_or(name);
if last == leaf {
matches.push(name.to_string());
}
}
matches.sort();
Ok(matches)
}
pub fn branch_ref_for_mem(workspace_root: &Path, mem_name: &str) -> String {
branch_ref_for_mem_at_gitdir(&gitdir_for_leaf(workspace_root, mem_name), mem_name)
}
pub fn branch_ref_for_mem_at_gitdir(gitdir: &Path, mem_name: &str) -> String {
match resolve_full_path_at_gitdir(gitdir, mem_name) {
Ok(Some(full_path)) => format!("refs/heads/{full_path}"),
_ => format!("refs/heads/{mem_name}"),
}
}
fn default_gitdir(workspace_root: &Path) -> std::path::PathBuf {
workspace_root.join("mem-repo").join(".git")
}
fn gitdir_for_leaf(workspace_root: &Path, _mem_name: &str) -> std::path::PathBuf {
default_gitdir(workspace_root)
}
pub fn read_config(workspace_root: &Path, mem_name: &str) -> Result<MemConfig, MemRepoConfigError> {
read_config_at_gitdir(&gitdir_for_leaf(workspace_root, mem_name), mem_name)
}
pub fn read_config_at_gitdir(
gitdir: &Path,
mem_name: &str,
) -> Result<MemConfig, MemRepoConfigError> {
if !gitdir.is_dir() {
return Err(MemRepoConfigError::GitdirNotFound(
gitdir.display().to_string(),
));
}
crate::storage_memstead::read_mem_config_from_memstead_ref(gitdir, mem_name).map_err(|e| {
match e {
crate::storage_memstead::MemsteadRefError::GixOpen(msg) => {
MemRepoConfigError::GixOpen(msg)
}
crate::storage_memstead::MemsteadRefError::GitTree(msg) => {
MemRepoConfigError::GitTree(msg)
}
crate::storage_memstead::MemsteadRefError::Config { path, message } => {
if path == "refs/heads/__MEMSTEAD" {
MemRepoConfigError::NoMainBranch
} else if message.contains("config not found") {
MemRepoConfigError::ConfigNotFound(mem_name.to_string())
} else if message.contains("not utf-8") {
MemRepoConfigError::NotUtf8(message)
} else {
MemRepoConfigError::Schema(ConfigError::InvalidJson(message))
}
}
crate::storage_memstead::MemsteadRefError::GitCommit(msg) => {
MemRepoConfigError::GitTree(msg)
}
crate::storage_memstead::MemsteadRefError::NotUtf8(_, msg) => {
MemRepoConfigError::NotUtf8(msg)
}
crate::storage_memstead::MemsteadRefError::Schema { source, .. } => {
MemRepoConfigError::Schema(ConfigError::Other(source.to_string()))
}
}
})
}
pub fn mem_init_from_branch(
workspace_root: &Path,
mem_name: &str,
) -> Result<MemInit, MemRepoConfigError> {
mem_init_from_branch_at_gitdir(&gitdir_for_leaf(workspace_root, mem_name), mem_name)
}
pub fn mem_init_from_branch_at_gitdir(
gitdir: &Path,
mem_name: &str,
) -> Result<MemInit, MemRepoConfigError> {
let config = read_config_at_gitdir(gitdir, mem_name)?;
let schema_ref = config
.schema
.clone()
.unwrap_or_else(|| SchemaRef::new("default", semver::Version::new(1, 0, 0)));
Ok(MemInit {
name: mem_name.to_string(),
dir: None,
schema_ref,
})
}
pub fn has_real_mem_repo_main(workspace_root: &Path) -> bool {
has_real_mem_repo_main_at_gitdir(&default_gitdir(workspace_root))
}
pub fn has_real_mem_repo_main_at_gitdir(gitdir: &Path) -> bool {
let Ok(repo) = gix::open(gitdir) else {
return false;
};
matches!(
repo.try_find_reference("refs/heads/__MEMSTEAD"),
Ok(Some(_))
)
}
#[derive(Debug, thiserror::Error)]
pub enum MemRepoWriteError {
#[error("could not open mem-repo gitdir at {path}: {message}")]
GixOpen { path: String, message: String },
#[error("mem-repo has no refs/heads/__MEMSTEAD: {0}")]
NoMainBranch(String),
#[error("git tree write error: {0}")]
GitTree(String),
#[error("ref transaction rejected: {0}")]
RefTransaction(String),
}
pub(crate) fn reflog_committer() -> gix::actor::Signature {
gix::actor::Signature {
name: "engine".into(),
email: "noreply@memstead.io".into(),
time: gix::date::Time::now_local_or_utc(),
}
}
pub(crate) fn error_chain(e: &dyn std::error::Error) -> String {
let mut out = e.to_string();
let mut cur = e.source();
while let Some(inner) = cur {
use std::fmt::Write as _;
let _ = write!(out, ": {inner}");
cur = inner.source();
}
out
}
pub struct RefSpec {
pub ref_name: String,
pub new_oid: gix::ObjectId,
pub expected: gix::refs::transaction::PreviousValue,
pub log_message: String,
}
pub fn commit_refs(workspace_root: &Path, specs: &[RefSpec]) -> Result<(), MemRepoWriteError> {
commit_refs_at_gitdir(&default_gitdir(workspace_root), specs)
}
pub fn commit_refs_at_gitdir(gitdir: &Path, specs: &[RefSpec]) -> Result<(), MemRepoWriteError> {
use gix::refs::transaction::{Change, LogChange, RefEdit, RefLog};
use gix::refs::{FullName, Target};
let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
path: gitdir.display().to_string(),
message: e.to_string(),
})?;
let mut edits: Vec<RefEdit> = Vec::with_capacity(specs.len());
for spec in specs {
let name: FullName = spec.ref_name.as_str().try_into().map_err(|e| {
MemRepoWriteError::RefTransaction(format!("invalid ref name {:?}: {e}", spec.ref_name))
})?;
edits.push(RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: spec.log_message.as_str().into(),
},
expected: spec.expected.clone(),
new: Target::Object(spec.new_oid),
},
name,
deref: false,
});
}
repo.edit_references_as(
edits,
Some(reflog_committer().to_ref(&mut Default::default())),
)
.map_err(|e| MemRepoWriteError::RefTransaction(error_chain(&e)))?;
Ok(())
}
pub fn commit_config(
workspace_root: &Path,
mem_name: &str,
config_bytes: &[u8],
ctx: &CommitContext<'_>,
message: &str,
) -> Result<(), MemRepoWriteError> {
commit_config_at_gitdir(
&gitdir_for_leaf(workspace_root, mem_name),
mem_name,
config_bytes,
ctx,
message,
)
}
pub fn commit_config_at_gitdir(
gitdir: &Path,
mem_name: &str,
config_bytes: &[u8],
ctx: &CommitContext<'_>,
message: &str,
) -> Result<(), MemRepoWriteError> {
crate::storage_memstead::commit_config_to_memstead_at_gitdir(
gitdir,
mem_name,
config_bytes,
ctx,
message,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn init_mem_repo_with_config(mem_name: &str, config_json: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let gitdir = tmp.path().join("mem-repo").join(".git");
std::fs::create_dir_all(&gitdir).unwrap();
let repo = gix::init_bare(&gitdir).unwrap();
let blob = repo.write_blob(config_json.as_bytes()).unwrap().detach();
let mut editor = repo.empty_tree().edit().unwrap();
editor
.upsert(
format!("{mem_name}/config.json"),
gix::objs::tree::EntryKind::Blob,
blob,
)
.unwrap();
let tree_id = editor.write().unwrap().detach();
let actor = gix::actor::Signature {
name: "test".into(),
email: "test@example.com".into(),
time: gix::date::Time {
seconds: 0,
offset: 0,
},
};
let mut buf = gix::date::parse::TimeBuf::default();
let actor_ref = actor.to_ref(&mut buf);
repo.commit_as(
actor_ref,
actor_ref,
"refs/heads/__SYSTEM",
"seed",
tree_id,
Vec::<gix::ObjectId>::new(),
)
.unwrap();
crate::storage_memstead::migrate_to_memstead_ref(&gitdir).unwrap();
tmp
}
#[test]
fn reads_config_from_system_ref() {
let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
let config = read_config(tmp.path(), "alpha").unwrap();
assert!(config.name.is_none());
assert_eq!(
config.schema.as_ref().map(|s| s.name.as_str()),
Some("default")
);
}
#[test]
fn errors_when_gitdir_missing() {
let tmp = TempDir::new().unwrap();
let err = read_config(tmp.path(), "alpha").unwrap_err();
assert!(matches!(err, MemRepoConfigError::GitdirNotFound(_)));
}
#[test]
fn errors_when_config_missing_in_tree() {
let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
let err = read_config(tmp.path(), "beta").unwrap_err();
assert!(matches!(err, MemRepoConfigError::ConfigNotFound(name) if name == "beta"));
}
fn init_mem_repo_with_hierarchical_branch(full_path: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let gitdir = tmp.path().join("mem-repo").join(".git");
std::fs::create_dir_all(&gitdir).unwrap();
let repo = gix::init_bare(&gitdir).unwrap();
let actor = gix::actor::Signature {
name: "test".into(),
email: "test@example.com".into(),
time: gix::date::Time {
seconds: 0,
offset: 0,
},
};
let mut buf = gix::date::parse::TimeBuf::default();
let actor_ref = actor.to_ref(&mut buf);
let empty_tree = repo.empty_tree().id().detach();
repo.commit_as(
actor_ref,
actor_ref,
format!("refs/heads/{full_path}"),
"seal hierarchical",
empty_tree,
Vec::<gix::ObjectId>::new(),
)
.unwrap();
let blob = repo
.write_blob(br#"{"schema":"default@1.0.0"}"#)
.unwrap()
.detach();
let mut editor = repo.empty_tree().edit().unwrap();
editor
.upsert(
format!("{full_path}/config.json"),
gix::objs::tree::EntryKind::Blob,
blob,
)
.unwrap();
let tree_id = editor.write().unwrap().detach();
let mut buf = gix::date::parse::TimeBuf::default();
let actor_ref = actor.to_ref(&mut buf);
repo.commit_as(
actor_ref,
actor_ref,
"refs/heads/__SYSTEM",
"seed system",
tree_id,
Vec::<gix::ObjectId>::new(),
)
.unwrap();
crate::storage_memstead::migrate_to_memstead_ref(&gitdir).unwrap();
tmp
}
#[test]
fn resolve_full_path_returns_flat_branch_name() {
let tmp = init_mem_repo_with_hierarchical_branch("alpha");
let gitdir = tmp.path().join("mem-repo").join(".git");
let resolved = super::resolve_full_path_at_gitdir(&gitdir, "alpha").unwrap();
assert_eq!(resolved, Some("alpha".to_string()));
}
#[test]
fn resolve_full_path_returns_full_branch_for_hierarchical() {
let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
let gitdir = tmp.path().join("mem-repo").join(".git");
let resolved = super::resolve_full_path_at_gitdir(&gitdir, "engine").unwrap();
assert_eq!(resolved, Some("demo/engine".to_string()));
}
#[test]
fn resolve_full_path_returns_none_for_unknown_leaf() {
let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
let gitdir = tmp.path().join("mem-repo").join(".git");
let resolved = super::resolve_full_path_at_gitdir(&gitdir, "ghost").unwrap();
assert!(resolved.is_none());
}
fn seal_branches(gitdir: &std::path::Path, full_paths: &[&str]) {
let repo = gix::open(gitdir).unwrap();
let actor = gix::actor::Signature {
name: "test".into(),
email: "test@example.com".into(),
time: gix::date::Time {
seconds: 0,
offset: 0,
},
};
let empty_tree = repo.empty_tree().id().detach();
for full_path in full_paths {
let mut buf = gix::date::parse::TimeBuf::default();
let actor_ref = actor.to_ref(&mut buf);
repo.commit_as(
actor_ref,
actor_ref,
format!("refs/heads/{full_path}"),
"seal",
empty_tree,
Vec::<gix::ObjectId>::new(),
)
.unwrap();
}
}
#[test]
fn find_branches_by_leaf_returns_empty_for_unknown_leaf() {
let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
let gitdir = tmp.path().join("mem-repo").join(".git");
let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "ghost").unwrap();
assert!(matches.is_empty());
}
#[test]
fn find_branches_by_leaf_returns_single_full_path() {
let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
let gitdir = tmp.path().join("mem-repo").join(".git");
let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "engine").unwrap();
assert_eq!(matches, vec!["demo/engine".to_string()]);
}
#[test]
fn find_branches_by_leaf_returns_all_colliding_paths_sorted() {
let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
let gitdir = tmp.path().join("mem-repo").join(".git");
seal_branches(&gitdir, &["planning/engine"]);
let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "engine").unwrap();
assert_eq!(
matches,
vec!["demo/engine".to_string(), "planning/engine".to_string()]
);
}
#[test]
fn find_branches_by_leaf_skips_main_and_registry_refs() {
let tmp = init_mem_repo_with_hierarchical_branch("alpha");
let gitdir = tmp.path().join("mem-repo").join(".git");
let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "__SYSTEM").unwrap();
assert!(matches.is_empty());
let alpha = super::find_branches_by_leaf_at_gitdir(&gitdir, "alpha").unwrap();
assert_eq!(alpha, vec!["alpha".to_string()]);
}
#[test]
fn find_branches_by_leaf_errors_when_gitdir_missing() {
let tmp = TempDir::new().unwrap();
let gitdir = tmp.path().join("nonexistent").join(".git");
let err = super::find_branches_by_leaf_at_gitdir(&gitdir, "alpha")
.expect_err("missing gitdir must surface as GitdirNotFound");
assert!(matches!(err, super::MemRepoConfigError::GitdirNotFound(_)));
}
#[test]
fn at_gitdir_apis_target_arbitrary_gitdir() {
let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
let default_path = tmp.path().join("mem-repo");
let mount_path = tmp.path().join("external");
std::fs::rename(&default_path, &mount_path).unwrap();
let gitdir = mount_path.join(".git");
assert!(super::has_real_mem_repo_main_at_gitdir(&gitdir));
let cfg = super::read_config_at_gitdir(&gitdir, "alpha").unwrap();
assert!(cfg.name.is_none());
assert_eq!(
super::resolve_full_path_at_gitdir(&gitdir, "alpha").unwrap(),
None
);
assert_eq!(
super::branch_ref_for_mem_at_gitdir(&gitdir, "alpha"),
"refs/heads/alpha"
);
assert!(!super::has_real_mem_repo_main(tmp.path()));
let err = super::read_config(tmp.path(), "alpha").unwrap_err();
assert!(matches!(err, MemRepoConfigError::GitdirNotFound(_)));
}
#[test]
fn commit_config_at_gitdir_targets_arbitrary_mount() {
let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
let default_path = tmp.path().join("mem-repo");
let mount_path = tmp.path().join("external");
std::fs::rename(&default_path, &mount_path).unwrap();
let gitdir = mount_path.join(".git");
let pre_tip = {
let repo = gix::open(&gitdir).unwrap();
repo.find_reference("refs/heads/__MEMSTEAD")
.unwrap()
.into_fully_peeled_id()
.unwrap()
.detach()
};
let ctx = crate::vcs::CommitContext::internal();
super::commit_config_at_gitdir(
&gitdir,
"alpha",
br#"{"schema":"default@1.0.0","note":"v1"}"#,
&ctx,
"external mount commit",
)
.expect("commit_config_at_gitdir against external mount");
let post_tip = {
let repo = gix::open(&gitdir).unwrap();
repo.find_reference("refs/heads/__MEMSTEAD")
.unwrap()
.into_fully_peeled_id()
.unwrap()
.detach()
};
assert_ne!(pre_tip, post_tip, "external mount __MEMSTEAD must advance");
let result = super::commit_config(tmp.path(), "alpha", br#"{}"#, &ctx, "should fail");
assert!(matches!(result, Err(MemRepoWriteError::GixOpen { .. })));
}
#[test]
fn read_config_resolves_hierarchical_layout() {
let tmp = init_mem_repo_with_hierarchical_branch("planning/exec-foo");
let cfg = super::read_config(tmp.path(), "exec-foo").unwrap();
assert!(cfg.name.is_none());
assert_eq!(
cfg.schema.as_ref().map(|s| s.name.as_str()),
Some("default")
);
}
#[test]
fn errors_when_system_ref_missing() {
let tmp = TempDir::new().unwrap();
let gitdir = tmp.path().join("mem-repo").join(".git");
std::fs::create_dir_all(&gitdir).unwrap();
gix::init_bare(&gitdir).unwrap();
let err = read_config(tmp.path(), "alpha").unwrap_err();
assert!(matches!(err, MemRepoConfigError::NoMainBranch));
}
#[test]
fn commit_config_rejects_stale_main_tip() {
let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
let workspace_root = tmp.path();
let gitdir = workspace_root.join("mem-repo").join(".git");
let observed_t0 = {
let repo = gix::open(&gitdir).unwrap();
repo.find_reference("refs/heads/__MEMSTEAD")
.unwrap()
.into_fully_peeled_id()
.unwrap()
.detach()
};
let ctx = crate::vcs::CommitContext::internal();
commit_config(
workspace_root,
"alpha",
br#"{"schema":"default@1.0.0","note":"v1"}"#,
&ctx,
"first commit",
)
.expect("first commit_config should succeed");
let observed_t1 = {
let repo = gix::open(&gitdir).unwrap();
repo.find_reference("refs/heads/__MEMSTEAD")
.unwrap()
.into_fully_peeled_id()
.unwrap()
.detach()
};
assert_ne!(
observed_t0, observed_t1,
"__MEMSTEAD must have advanced after first commit_config"
);
let stale_result = {
let repo = gix::open(&gitdir).unwrap();
let memstead_tree = repo
.find_object(observed_t1)
.unwrap()
.into_commit()
.tree()
.unwrap()
.id()
.detach();
let sig = gix::actor::Signature {
name: "test".into(),
email: "test@example.com".into(),
time: gix::date::Time {
seconds: 0,
offset: 0,
},
};
let new_commit = gix::objs::Commit {
message: "stale".into(),
tree: memstead_tree,
author: sig.clone(),
committer: sig,
encoding: None,
parents: std::iter::once(observed_t1).collect(),
extra_headers: Default::default(),
};
let new_oid = repo.write_object(&new_commit).unwrap().detach();
commit_refs(
workspace_root,
&[RefSpec {
ref_name: "refs/heads/__MEMSTEAD".to_string(),
new_oid,
expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
gix::refs::Target::Object(observed_t0),
),
log_message: "memstead: stale RMW".to_string(),
}],
)
};
assert!(
matches!(stale_result, Err(MemRepoWriteError::RefTransaction(_))),
"expected RefTransaction precondition mismatch, got {:?}",
stale_result
);
let observed_after = {
let repo = gix::open(&gitdir).unwrap();
repo.find_reference("refs/heads/__MEMSTEAD")
.unwrap()
.into_fully_peeled_id()
.unwrap()
.detach()
};
assert_eq!(
observed_after, observed_t1,
"__MEMSTEAD must remain at T1 after rejected stale RMW"
);
}
}