use std::path::Path;
use std::process::{Command, Stdio};
use memstead_base::backend::BackendError;
use memstead_base::ops::{FetchOutcome, PullOutcome, PushOutcome, UpdatedRef};
fn run_git(
gitdir: &Path,
args: &[&str],
marker_for_failure: impl Fn(&str) -> String,
) -> Result<String, BackendError> {
let output = Command::new("git")
.arg("-C")
.arg(gitdir)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.output()
.map_err(|e| {
BackendError::Other(format!(
"git subprocess failed to start (`git -C {} {}`): {e}",
gitdir.display(),
args.join(" "),
))
})?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !output.status.success() {
return Err(BackendError::Other(marker_for_failure(stderr.trim())));
}
Ok(stdout)
}
pub fn fetch_in_gitdir(
gitdir: &Path,
remote: &str,
refspecs: &[String],
) -> Result<FetchOutcome, BackendError> {
if !gitdir.is_dir() {
return Err(BackendError::Other(format!(
"gitdir not found: {}",
gitdir.display()
)));
}
let pre = ref_snapshot(gitdir);
let mut args: Vec<String> = vec!["fetch".to_string(), remote.to_string()];
for spec in refspecs {
args.push(spec.clone());
}
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
let remote_owned = remote.to_string();
run_git(gitdir, &args_ref, move |stderr| {
classify_remote_failure(&remote_owned, stderr)
})?;
let post = ref_snapshot(gitdir);
let updated_refs = diff_ref_snapshots(&pre, &post);
Ok(FetchOutcome {
remote: remote.to_string(),
refspecs: refspecs.to_vec(),
updated_refs,
})
}
pub fn pull_in_gitdir(gitdir: &Path, remote: &str, mem: &str) -> Result<PullOutcome, BackendError> {
let fetched = fetch_in_gitdir(gitdir, remote, &[])?;
let branch_ref = format!("refs/heads/{mem}");
let remote_ref = format!("refs/remotes/{remote}/{mem}");
let remote_sha = resolve_ref(gitdir, &remote_ref).ok_or_else(|| {
BackendError::Other(format!(
"remote-tracking ref `{remote_ref}` is absent after fetch; \
the remote may not carry mem `{mem}`"
))
})?;
let local_sha = resolve_ref(gitdir, &branch_ref);
let can_fast_forward = match &local_sha {
None => true,
Some(local) => is_ancestor(gitdir, local, &remote_sha),
};
if !can_fast_forward {
return Err(BackendError::Other(format!(
"LOCAL_DIVERGENCE:{mem}:{remote_ref}"
)));
}
let previous_sha = local_sha.clone().unwrap_or_default();
let update_args = [
"update-ref",
"-m",
"memstead_pull",
&branch_ref,
&remote_sha,
&previous_sha,
];
let branch_for_err = branch_ref.clone();
run_git(gitdir, &update_args, move |stderr| {
format!("update-ref `{branch_for_err}` failed: {stderr}")
})?;
Ok(PullOutcome {
mem: mem.to_string(),
source_ref: remote_ref,
branch_ref,
previous_sha,
new_sha: remote_sha,
updated_refs: fetched.updated_refs,
})
}
pub fn push_in_gitdir(
gitdir: &Path,
remote: &str,
mem: &str,
force: bool,
) -> Result<PushOutcome, BackendError> {
let branch_ref = format!("refs/heads/{mem}");
let local_sha = resolve_ref(gitdir, &branch_ref)
.ok_or_else(|| BackendError::Other(format!("UNKNOWN_REF: {branch_ref}")))?;
let mut args: Vec<String> = vec!["push".to_string()];
if force {
args.push("--force-with-lease".to_string());
}
args.push(remote.to_string());
args.push(format!("{branch_ref}:{branch_ref}"));
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
let remote_owned = remote.to_string();
let mem_owned = mem.to_string();
run_git(gitdir, &args_ref, move |stderr| {
let lower = stderr.to_lowercase();
if lower.contains("non-fast-forward")
|| lower.contains("non-fast forward")
|| lower.contains("fetch first")
|| lower.contains("[rejected]")
|| lower.contains("updates were rejected")
{
return format!("NON_FAST_FORWARD:{mem_owned}:{remote_owned}");
}
if lower.contains("does not appear to be a git repository")
|| lower.contains("could not read from remote repository")
|| lower.contains("repository not found")
|| (lower.contains("fatal: '") && lower.contains("does not exist"))
{
return format!("UNKNOWN_REMOTE:{remote_owned}");
}
format!("push to `{remote_owned}` failed: {stderr}")
})?;
Ok(PushOutcome {
mem: mem.to_string(),
remote: remote.to_string(),
branch_ref,
new_sha: local_sha,
forced: force,
})
}
pub fn remote_add_in_gitdir(
gitdir: &Path,
name: &str,
url: &str,
) -> Result<memstead_base::ops::RemoteAddOutcome, BackendError> {
let existing = run_git(gitdir, &["remote"], |stderr| {
format!("git remote failed: {stderr}")
})?;
let exists = existing.lines().any(|l| l.trim() == name);
let args: &[&str] = if exists {
&["remote", "set-url", name, url]
} else {
&["remote", "add", name, url]
};
let name_owned = name.to_string();
run_git(gitdir, args, move |stderr| {
format!("configuring remote `{name_owned}` failed: {stderr}")
})?;
Ok(memstead_base::ops::RemoteAddOutcome {
remote: name.to_string(),
url: url.to_string(),
updated: exists,
})
}
pub fn read_md_blobs_at_ref(
gitdir: &Path,
ref_name: &str,
) -> Result<Vec<(String, String)>, BackendError> {
let repo = gix::open(gitdir).map_err(|e| BackendError::Other(format!("gix open: {e}")))?;
let id = repo
.rev_parse_single(ref_name)
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {ref_name}")))?;
let object = id
.object()
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {ref_name}")))?;
let commit = object
.try_into_commit()
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {ref_name} is not a commit")))?;
let tree = commit
.tree()
.map_err(|e| BackendError::Other(format!("tree({ref_name}): {e}")))?;
let entries = tree
.traverse()
.breadthfirst
.files()
.map_err(|e| BackendError::Other(format!("traverse({ref_name}): {e}")))?;
let mut out: Vec<(String, String)> = Vec::new();
for entry in entries {
if !entry.mode.is_blob() {
continue;
}
let path = match std::str::from_utf8(entry.filepath.as_slice()) {
Ok(s) => s.to_string(),
Err(_) => continue,
};
if !path.ends_with(".md") || path.starts_with(".memstead/") {
continue;
}
let object = match repo.find_object(entry.oid) {
Ok(o) => o,
Err(_) => continue,
};
let blob = match object.try_into_blob() {
Ok(b) => b,
Err(_) => continue,
};
let content = String::from_utf8(blob.data.clone()).unwrap_or_default();
out.push((path, content));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
fn classify_remote_failure(remote: &str, stderr: &str) -> String {
let normalized = stderr.to_lowercase();
if normalized.contains("does not appear to be a git repository")
|| normalized.contains("could not read from remote repository")
|| normalized.contains("repository not found")
|| normalized.contains("name or service not known")
|| (normalized.contains("fatal:") && normalized.contains("not found"))
{
return format!("UNKNOWN_REMOTE:{remote}");
}
if normalized.contains("not configured")
|| (normalized.contains("fatal:") && normalized.contains("'") && normalized.contains("'"))
{
}
format!("git fetch `{remote}` failed: {stderr}")
}
fn resolve_ref(gitdir: &Path, ref_name: &str) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(gitdir)
.args(["rev-parse", "--verify", ref_name])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
fn is_ancestor(gitdir: &Path, ancestor_sha: &str, descendant_sha: &str) -> bool {
let status = Command::new("git")
.arg("-C")
.arg(gitdir)
.args(["merge-base", "--is-ancestor", ancestor_sha, descendant_sha])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok();
status.map(|s| s.success()).unwrap_or(false)
}
fn ref_snapshot(gitdir: &Path) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
let result = Command::new("git")
.arg("-C")
.arg(gitdir)
.args(["for-each-ref", "--format=%(refname) %(objectname)"])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.output();
let Ok(output) = result else { return out };
if !output.status.success() {
return out;
}
for line in String::from_utf8_lossy(&output.stdout).lines() {
if let Some((name, sha)) = line.split_once(' ') {
out.insert(name.to_string(), sha.to_string());
}
}
out
}
fn diff_ref_snapshots(
pre: &std::collections::HashMap<String, String>,
post: &std::collections::HashMap<String, String>,
) -> Vec<UpdatedRef> {
let mut out = Vec::new();
for (name, new_sha) in post {
let previous = pre.get(name).cloned().unwrap_or_default();
if &previous != new_sha {
out.push(UpdatedRef {
ref_name: name.clone(),
previous_sha: previous,
new_sha: new_sha.clone(),
});
}
}
out.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::MemWriter;
use crate::storage::git_tree::GitTreeMemWriter;
use crate::vcs::CommitContext;
use std::path::PathBuf;
use tempfile::TempDir;
fn body(title: &str) -> String {
format!(
"---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# {title}\n\n## Identity\n\n{title}\n"
)
}
fn init_local(tmp: &TempDir, name: &str) -> PathBuf {
let gitdir = tmp.path().join(name).join(".git");
std::fs::create_dir_all(&gitdir).unwrap();
gix::init_bare(&gitdir).unwrap();
gitdir
}
fn init_bare_remote(tmp: &TempDir, name: &str) -> PathBuf {
let gitdir = tmp.path().join(name);
std::fs::create_dir_all(&gitdir).unwrap();
gix::init_bare(&gitdir).unwrap();
gitdir
}
fn add_remote(local: &Path, name: &str, url: &Path) {
let status = Command::new("git")
.arg("-C")
.arg(local)
.args(["remote", "add", name, url.to_str().unwrap()])
.status()
.unwrap();
assert!(status.success());
}
fn commit(gitdir: &Path, branch: &str, file: &str, content: &str) -> String {
let writer = GitTreeMemWriter::new(gitdir.to_path_buf(), format!("refs/heads/{branch}"));
writer
.write_entity(Path::new(file), content.as_bytes())
.unwrap();
writer.commit("seed", &CommitContext::internal()).unwrap()
}
#[test]
fn remote_add_adds_then_upserts() {
let tmp = TempDir::new().unwrap();
let gitdir = init_local(&tmp, "local");
let remote_a = init_bare_remote(&tmp, "backup-a.git");
let remote_b = init_bare_remote(&tmp, "backup-b.git");
let added = remote_add_in_gitdir(&gitdir, "origin", remote_a.to_str().unwrap()).unwrap();
assert!(!added.updated, "first add is not an update");
assert_eq!(added.remote, "origin");
let updated = remote_add_in_gitdir(&gitdir, "origin", remote_b.to_str().unwrap()).unwrap();
assert!(updated.updated, "second add must report the upsert");
assert_eq!(updated.url, remote_b.to_str().unwrap());
let url = run_git(&gitdir, &["remote", "get-url", "origin"], |s| s.to_string()).unwrap();
assert_eq!(url.trim(), remote_b.to_str().unwrap());
}
#[test]
fn fetch_unknown_remote_returns_typed_marker() {
let tmp = TempDir::new().unwrap();
let gitdir = init_local(&tmp, "local");
commit(&gitdir, "specs", "a.md", &body("A"));
let err = fetch_in_gitdir(&gitdir, "nope", &[]).unwrap_err();
match err {
BackendError::Other(msg) => {
assert!(msg.contains("nope"), "got: {msg}");
}
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn push_and_fetch_round_trip_with_local_pair() {
let tmp = TempDir::new().unwrap();
let local = init_local(&tmp, "local");
let remote = init_bare_remote(&tmp, "remote.git");
add_remote(&local, "origin", &remote);
let sha = commit(&local, "specs", "a.md", &body("A"));
let push = push_in_gitdir(&local, "origin", "specs", false).unwrap();
assert_eq!(push.new_sha, sha);
assert_eq!(push.branch_ref, "refs/heads/specs");
assert!(!push.forced);
let remote_sha = resolve_ref(&remote, "refs/heads/specs").unwrap();
assert_eq!(remote_sha, sha);
let second = init_local(&tmp, "second");
add_remote(&second, "origin", &remote);
let fetched = fetch_in_gitdir(&second, "origin", &[]).unwrap();
let tracking = resolve_ref(&second, "refs/remotes/origin/specs").unwrap();
assert_eq!(tracking, sha);
assert!(
fetched
.updated_refs
.iter()
.any(|u| u.ref_name == "refs/remotes/origin/specs" && u.new_sha == sha),
"fetched outcome must list the new remote-tracking ref: {fetched:?}",
);
}
#[test]
fn push_pull_round_trips_the_anchors_sidecar() {
let tmp = TempDir::new().unwrap();
let local = init_local(&tmp, "local");
let remote = init_bare_remote(&tmp, "remote.git");
add_remote(&local, "origin", &remote);
let sidecar = br#"{"version":1,"entities":{"specs--a":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
{
let writer = GitTreeMemWriter::new(local.to_path_buf(), "refs/heads/specs".to_string());
writer
.write_entity(Path::new("a.md"), body("A").as_bytes())
.unwrap();
writer
.write_entity(Path::new(".memstead/anchors.json"), sidecar)
.unwrap();
writer.commit("seed", &CommitContext::internal()).unwrap();
}
push_in_gitdir(&local, "origin", "specs", false).unwrap();
let second = init_local(&tmp, "second");
add_remote(&second, "origin", &remote);
pull_in_gitdir(&second, "origin", "specs").unwrap();
let reader = GitTreeMemWriter::new(second.to_path_buf(), "refs/heads/specs".to_string());
let pulled = memstead_base::backend::MemBackend::read_anchors_sidecar(&reader).unwrap();
assert_eq!(pulled.as_deref(), Some(&sidecar[..]));
}
#[test]
fn pull_happy_path_fast_forwards_local_branch() {
let tmp = TempDir::new().unwrap();
let local = init_local(&tmp, "local");
let remote = init_bare_remote(&tmp, "remote.git");
add_remote(&local, "origin", &remote);
let sha = commit(&local, "specs", "a.md", &body("A"));
push_in_gitdir(&local, "origin", "specs", false).unwrap();
let second = init_local(&tmp, "second");
add_remote(&second, "origin", &remote);
let pull = pull_in_gitdir(&second, "origin", "specs").unwrap();
assert_eq!(pull.new_sha, sha);
assert_eq!(pull.previous_sha, "");
let head = resolve_ref(&second, "refs/heads/specs").unwrap();
assert_eq!(head, sha);
}
#[test]
fn pull_refuses_diverged_local_branch() {
let tmp = TempDir::new().unwrap();
let local = init_local(&tmp, "local");
let remote = init_bare_remote(&tmp, "remote.git");
add_remote(&local, "origin", &remote);
commit(&local, "specs", "a.md", &body("A"));
push_in_gitdir(&local, "origin", "specs", false).unwrap();
let second = init_local(&tmp, "second");
add_remote(&second, "origin", &remote);
pull_in_gitdir(&second, "origin", "specs").unwrap();
commit(&second, "specs", "second.md", &body("local"));
commit(&local, "specs", "first.md", &body("upstream"));
push_in_gitdir(&local, "origin", "specs", false).unwrap();
let err = pull_in_gitdir(&second, "origin", "specs").unwrap_err();
match err {
BackendError::Other(msg) => assert!(
msg.starts_with("LOCAL_DIVERGENCE:"),
"expected LOCAL_DIVERGENCE marker, got: {msg}",
),
other => panic!("expected Other(LOCAL_DIVERGENCE), got {other:?}"),
}
}
#[test]
fn read_md_blobs_at_ref_lists_only_md_outside_memstead_namespace() {
let tmp = TempDir::new().unwrap();
let gitdir = init_local(&tmp, "local");
commit(&gitdir, "specs", "alpha.md", &body("Alpha"));
commit(&gitdir, "specs", "nested/beta.md", &body("Beta"));
let blobs = read_md_blobs_at_ref(&gitdir, "refs/heads/specs").unwrap();
let mut paths: Vec<String> = blobs.iter().map(|(p, _)| p.clone()).collect();
paths.sort();
assert_eq!(
paths,
vec!["alpha.md".to_string(), "nested/beta.md".to_string()]
);
for (_, content) in &blobs {
assert!(
content.contains("type: spec"),
"blob content must round-trip"
);
}
}
#[test]
fn engine_pull_refuses_schema_violation_with_typed_envelope() {
let tmp = TempDir::new().unwrap();
let local_upstream = init_local(&tmp, "upstream");
let remote = init_bare_remote(&tmp, "remote.git");
add_remote(&local_upstream, "origin", &remote);
commit(&local_upstream, "specs", "alpha.md", &body("Alpha"));
push_in_gitdir(&local_upstream, "origin", "specs", false).unwrap();
let writer = GitTreeMemWriter::new(local_upstream.clone(), "refs/heads/specs".to_string());
writer
.write_entity(
Path::new("broken.md"),
b"# Broken\n\nbody without frontmatter.\n",
)
.unwrap();
writer.commit("oops", &CommitContext::internal()).unwrap();
push_in_gitdir(&local_upstream, "origin", "specs", false).unwrap();
let downstream_gitdir = init_local(&tmp, "downstream");
add_remote(&downstream_gitdir, "origin", &remote);
let mount = memstead_base::Mount {
mem: "specs".to_string(),
schema: Some(memstead_schema::SchemaRef::new(
"default",
semver::Version::new(1, 0, 0),
)),
storage: memstead_base::MountStorage::GitBranch {
gitdir: downstream_gitdir.clone(),
branch: "specs".to_string(),
},
capability: memstead_base::MountCapability::Write,
lifecycle: memstead_base::MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let backend = crate::storage::instantiate_full_backend(&mount).unwrap();
let mut engine = memstead_base::Engine::from_mounts(vec![(mount, backend)]).unwrap();
engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
let err = engine.pull("specs", "origin").unwrap_err();
match err {
memstead_base::EngineError::SchemaViolationInFetch {
mem,
ref_name,
violations,
} => {
assert_eq!(mem, "specs");
assert!(ref_name.starts_with("refs/remotes/origin/specs"));
assert!(
!violations.is_empty(),
"violations must list the broken entity"
);
assert!(
violations.iter().any(|v| v.contains("broken.md")),
"violation must name the offending file: {violations:?}",
);
}
other => panic!("expected SchemaViolationInFetch, got {other:?}"),
}
assert!(resolve_ref(&downstream_gitdir, "refs/heads/specs").is_none());
}
#[test]
fn engine_push_refuses_local_invalid_state() {
let tmp = TempDir::new().unwrap();
let local = init_local(&tmp, "local");
let remote = init_bare_remote(&tmp, "remote.git");
add_remote(&local, "origin", &remote);
let writer = GitTreeMemWriter::new(local.clone(), "refs/heads/specs".to_string());
writer
.write_entity(
Path::new("broken.md"),
b"# Bad\n\nbody without frontmatter.\n",
)
.unwrap();
writer.commit("seed", &CommitContext::internal()).unwrap();
let mount = memstead_base::Mount {
mem: "specs".to_string(),
schema: Some(memstead_schema::SchemaRef::new(
"default",
semver::Version::new(1, 0, 0),
)),
storage: memstead_base::MountStorage::GitBranch {
gitdir: local.clone(),
branch: "specs".to_string(),
},
capability: memstead_base::MountCapability::Write,
lifecycle: memstead_base::MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let backend = crate::storage::instantiate_full_backend(&mount).unwrap();
let mut engine = memstead_base::Engine::from_mounts(vec![(mount, backend)]).unwrap();
engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
let err = engine.push("specs", "origin", false).unwrap_err();
match err {
memstead_base::EngineError::LocalInvalidState {
mem,
remote: r,
detail,
} => {
assert_eq!(mem, "specs");
assert_eq!(r, "origin");
assert!(
detail.contains("broken.md") || detail.contains("violation"),
"detail = {detail}"
);
}
other => panic!("expected LocalInvalidState, got {other:?}"),
}
assert!(resolve_ref(&remote, "refs/heads/specs").is_none());
}
#[test]
fn push_refuses_non_fast_forward_without_force() {
let tmp = TempDir::new().unwrap();
let local = init_local(&tmp, "local");
let remote = init_bare_remote(&tmp, "remote.git");
add_remote(&local, "origin", &remote);
commit(&local, "specs", "a.md", &body("A"));
push_in_gitdir(&local, "origin", "specs", false).unwrap();
let second = init_local(&tmp, "second");
add_remote(&second, "origin", &remote);
commit(&second, "specs", "diff.md", &body("divergent"));
let err = push_in_gitdir(&second, "origin", "specs", false).unwrap_err();
match err {
BackendError::Other(msg) => {
assert!(
msg.contains("NON_FAST_FORWARD") || msg.contains("non-fast"),
"got: {msg}"
);
}
other => panic!("expected Other, got {other:?}"),
}
}
}