use std::collections::HashSet;
use std::path::Path;
use gix::ObjectId;
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix::refs::{FullName, Target};
use memstead_base::backend::BackendError;
use memstead_base::ops::BranchResetOutcome;
fn current_head(repo: &gix::Repository, branch: &str) -> Result<ObjectId, BackendError> {
let ref_name = format!("refs/heads/{branch}");
let id = repo
.rev_parse_single(ref_name.as_str())
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {ref_name}")))?;
Ok(id.detach())
}
fn resolve_target(repo: &gix::Repository, target: &str) -> Result<ObjectId, BackendError> {
let id = repo
.rev_parse_single(target)
.map_err(|_| BackendError::Other(format!("UNKNOWN_REF: {target}")))?;
Ok(id.detach())
}
fn pushed_commit_set(repo: &gix::Repository) -> Result<HashSet<ObjectId>, BackendError> {
let platform = repo
.references()
.map_err(|e| BackendError::Other(format!("references(): {e}")))?;
let iter = platform
.remote_branches()
.map_err(|e| BackendError::Other(format!("remote_branches(): {e}")))?;
let mut tips: Vec<ObjectId> = Vec::new();
for r in iter {
let mut reference = match r {
Ok(rf) => rf,
Err(_) => continue,
};
if let Ok(commit) = reference.peel_to_id() {
tips.push(commit.detach());
}
}
let mut reachable: HashSet<ObjectId> = HashSet::new();
if tips.is_empty() {
return Ok(reachable);
}
let walk = repo
.rev_walk(tips)
.all()
.map_err(|e| BackendError::Other(format!("rev-walk(remotes): {e}")))?;
for info in walk {
let info = match info {
Ok(i) => i,
Err(_) => continue,
};
reachable.insert(info.id);
}
Ok(reachable)
}
fn discarded_commits(
repo: &gix::Repository,
head: ObjectId,
target: ObjectId,
) -> Result<Vec<ObjectId>, BackendError> {
if head == target {
return Ok(Vec::new());
}
let walk = repo
.rev_walk([head])
.with_hidden([target])
.all()
.map_err(|e| BackendError::Other(format!("rev-walk(discard): {e}")))?;
let mut out = Vec::new();
for info in walk {
let info = info.map_err(|e| BackendError::Other(format!("rev-walk-step: {e}")))?;
out.push(info.id);
}
Ok(out)
}
pub fn branch_reset_in_gitdir(
gitdir: &Path,
branch: &str,
target_sha: &str,
expected_head: Option<&str>,
) -> Result<BranchResetOutcome, BackendError> {
if !gitdir.is_dir() {
return Err(BackendError::Other(format!(
"gitdir not found: {}",
gitdir.display()
)));
}
let repo = gix::open(gitdir).map_err(|e| BackendError::Other(format!("gix open: {e}")))?;
let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch);
let current = current_head(&repo, branch)?;
let target = resolve_target(&repo, target_sha)?;
let branch_ref = format!("refs/heads/{branch}");
if let Some(expected) = expected_head
&& current.to_string() != expected
{
return Err(BackendError::Other(format!(
"EXPECTED_HEAD_MISMATCH: {current}"
)));
}
if current == target {
return Ok(BranchResetOutcome {
mem: branch.to_string(),
branch_ref,
previous_sha: current.to_string(),
new_sha: target.to_string(),
discarded_commits: Vec::new(),
});
}
let discarded = discarded_commits(&repo, current, target)?;
let pushed = pushed_commit_set(&repo)?;
let blocked: Vec<String> = discarded
.iter()
.filter(|c| pushed.contains(*c))
.map(|c| c.to_string())
.collect();
if !blocked.is_empty() {
return Err(BackendError::Other(format!(
"PUSHED_COMMITS_PROTECTED: {}",
blocked.join(",")
)));
}
let name: FullName = branch_ref
.as_str()
.try_into()
.map_err(|e| BackendError::Other(format!("invalid ref name {branch_ref:?}: {e}")))?;
let edit = RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: format!("memstead_branch_reset {} -> {target_sha}", branch).into(),
},
expected: PreviousValue::MustExistAndMatch(Target::Object(current)),
new: Target::Object(target),
},
name,
deref: false,
};
repo.edit_references([edit])
.map_err(|e| BackendError::Other(format!("edit_references: {e}")))?;
Ok(BranchResetOutcome {
mem: branch.to_string(),
branch_ref,
previous_sha: current.to_string(),
new_sha: target.to_string(),
discarded_commits: discarded.into_iter().map(|c| c.to_string()).collect(),
})
}
#[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 init_gitdir(tmp: &TempDir) -> PathBuf {
let gitdir = tmp.path().join("mem-repo").join(".git");
std::fs::create_dir_all(&gitdir).unwrap();
gix::init_bare(&gitdir).unwrap();
gitdir
}
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 commit(gitdir: &Path, branch: &str, file: &str, content: &str, subject: &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(subject, &CommitContext::internal()).unwrap()
}
#[test]
fn branch_reset_rewinds_the_anchors_sidecar_with_entities() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let sidecar_a = br#"{"version":1,"entities":{"specs--a":[{"artifact":"a.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
let sha_a = {
let w = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
w.write_entity(Path::new("a.md"), body("A").as_bytes())
.unwrap();
w.write_entity(Path::new(".memstead/anchors.json"), sidecar_a)
.unwrap();
w.commit("A", &CommitContext::internal()).unwrap()
};
let sidecar_b = br#"{"version":1,"entities":{"specs--a":[{"artifact":"a.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"},{"artifact":"b.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h2"}]}}"#;
{
let w = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
w.write_entity(Path::new(".memstead/anchors.json"), sidecar_b)
.unwrap();
w.commit("B", &CommitContext::internal()).unwrap();
}
branch_reset_in_gitdir(&gitdir, "specs", &sha_a, None).unwrap();
let reader = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
let after = memstead_base::backend::MemBackend::read_anchors_sidecar(&reader).unwrap();
assert_eq!(
after.as_deref(),
Some(&sidecar_a[..]),
"post-reset sidecar matches the reset point (only anchor A)"
);
}
fn set_remote_tracking(gitdir: &PathBuf, remote: &str, branch: &str, sha: &str) {
let repo = gix::open(gitdir).unwrap();
let name: FullName = format!("refs/remotes/{remote}/{branch}")
.as_str()
.try_into()
.unwrap();
let oid: ObjectId = sha.parse().unwrap();
repo.edit_references([RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "test-remote".into(),
},
expected: PreviousValue::Any,
new: Target::Object(oid),
},
name,
deref: false,
}])
.unwrap();
}
#[test]
fn branch_reset_unknown_branch_returns_unknown_ref_marker() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let err = branch_reset_in_gitdir(&gitdir, "nope", "abc", None).unwrap_err();
match err {
BackendError::Other(msg) => assert!(msg.starts_with("UNKNOWN_REF:"), "got: {msg}"),
other => panic!("expected Other(UNKNOWN_REF), got {other:?}"),
}
}
#[test]
fn branch_reset_accepts_full_ref_branch_form() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let sha_a = commit(&gitdir, "specs", "a.md", &body("A"), "A");
let _sha_b = commit(&gitdir, "specs", "b.md", &body("B"), "B");
let outcome = branch_reset_in_gitdir(&gitdir, "refs/heads/specs", &sha_a, None).unwrap();
assert_eq!(outcome.new_sha, sha_a);
assert_eq!(outcome.branch_ref, "refs/heads/specs");
}
#[test]
fn branch_reset_no_op_when_target_equals_head() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let sha = commit(&gitdir, "specs", "a.md", &body("A"), "seed");
let outcome = branch_reset_in_gitdir(&gitdir, "specs", &sha, None).unwrap();
assert!(outcome.discarded_commits.is_empty());
assert_eq!(outcome.previous_sha, outcome.new_sha);
}
#[test]
fn branch_reset_unpushed_commits_succeeds() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let sha_a = commit(&gitdir, "specs", "a.md", &body("A"), "A");
let _sha_b = commit(&gitdir, "specs", "b.md", &body("B"), "B");
let sha_c = commit(&gitdir, "specs", "c.md", &body("C"), "C");
let outcome = branch_reset_in_gitdir(&gitdir, "specs", &sha_a, None).unwrap();
assert_eq!(outcome.new_sha, sha_a);
assert_eq!(outcome.previous_sha, sha_c);
assert_eq!(outcome.discarded_commits.len(), 2);
let repo = gix::open(&gitdir).unwrap();
let head = repo
.rev_parse_single("refs/heads/specs")
.unwrap()
.detach()
.to_string();
assert_eq!(head, sha_a);
}
#[test]
fn branch_reset_refuses_when_expected_head_moved() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let sha_a = commit(&gitdir, "specs", "a.md", &body("A"), "A");
let sha_b = commit(&gitdir, "specs", "b.md", &body("B"), "B");
let sha_c = commit(&gitdir, "specs", "c.md", &body("C"), "C");
let err = branch_reset_in_gitdir(&gitdir, "specs", &sha_a, Some(&sha_b)).unwrap_err();
let msg = format!("{err}");
assert!(
msg.starts_with("EXPECTED_HEAD_MISMATCH:") || msg.contains("EXPECTED_HEAD_MISMATCH"),
"expected mismatch marker, got: {msg}"
);
assert!(msg.contains(&sha_c), "refusal names the live head: {msg}");
let repo = gix::open(&gitdir).unwrap();
let head = repo
.rev_parse_single("refs/heads/specs")
.unwrap()
.detach()
.to_string();
assert_eq!(head, sha_c, "the branch pointer is untouched");
let outcome = branch_reset_in_gitdir(&gitdir, "specs", &sha_a, Some(&sha_c)).unwrap();
assert_eq!(outcome.new_sha, sha_a);
assert_eq!(outcome.discarded_commits.len(), 2);
}
#[test]
fn engine_branch_reset_routes_git_branch_mount_and_surfaces_typed_error() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let _sha_a = commit(&gitdir, "specs", "a.md", &body("A"), "A");
let sha_b = commit(&gitdir, "specs", "b.md", &body("B"), "B");
set_remote_tracking(&gitdir, "origin", "specs", &sha_b);
let _sha_c = commit(&gitdir, "specs", "c.md", &body("C"), "C");
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: 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.branch_reset("specs", &_sha_a, None).unwrap_err();
match err {
memstead_base::EngineError::PushedCommitsProtected {
mem,
target_sha,
pushed_shas,
} => {
assert_eq!(mem, "specs");
assert_eq!(target_sha, _sha_a);
assert!(pushed_shas.contains(&sha_b));
}
other => panic!("expected PushedCommitsProtected, got {other:?}"),
}
}
#[test]
fn engine_branch_reset_surfaces_head_moved_and_read_only_typed() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let sha_a = commit(&gitdir, "specs", "a.md", &body("A"), "A");
let sha_b = commit(&gitdir, "specs", "b.md", &body("B"), "B");
let sha_c = commit(&gitdir, "specs", "c.md", &body("C"), "C");
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: 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
.branch_reset("specs", &sha_a, Some(&sha_b))
.unwrap_err();
match err {
memstead_base::EngineError::BranchResetHeadMoved {
mem,
expected,
current,
} => {
assert_eq!(mem, "specs");
assert_eq!(expected, sha_b);
assert_eq!(current, sha_c);
}
other => panic!("expected BranchResetHeadMoved, got {other:?}"),
}
let ro_mount = memstead_base::Mount {
mem: "sealed".to_string(),
schema: Some(memstead_schema::SchemaRef::new(
"default",
semver::Version::new(1, 0, 0),
)),
storage: memstead_base::MountStorage::GitBranch {
gitdir: gitdir.clone(),
branch: "specs".to_string(),
},
capability: memstead_base::MountCapability::ReadOnly,
lifecycle: memstead_base::MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let ro_backend = crate::storage::instantiate_full_backend(&ro_mount).unwrap();
let mut ro_engine =
memstead_base::Engine::from_mounts(vec![(ro_mount, ro_backend)]).unwrap();
ro_engine.set_git_branch_ops(crate::storage::FULL_GIT_BRANCH_OPS);
let refused = ro_engine.branch_reset("sealed", &sha_a, None).unwrap_err();
assert!(
matches!(refused, memstead_base::EngineError::ReadOnlyMount(_)),
"expected ReadOnlyMount, got {refused:?}"
);
}
#[test]
fn branch_reset_refuses_when_discarded_commits_are_pushed() {
let tmp = TempDir::new().unwrap();
let gitdir = init_gitdir(&tmp);
let sha_a = commit(&gitdir, "specs", "a.md", &body("A"), "A");
let sha_b = commit(&gitdir, "specs", "b.md", &body("B"), "B");
let _sha_c = commit(&gitdir, "specs", "c.md", &body("C"), "C");
set_remote_tracking(&gitdir, "origin", "specs", &sha_b);
let err = branch_reset_in_gitdir(&gitdir, "specs", &sha_a, None).unwrap_err();
match err {
BackendError::Other(msg) => {
assert!(
msg.starts_with("PUSHED_COMMITS_PROTECTED:"),
"unexpected refusal: {msg}",
);
assert!(msg.contains(&sha_b), "must name the pushed commit: {msg}");
}
other => panic!("expected Other(PUSHED_COMMITS_PROTECTED), got {other:?}"),
}
let repo = gix::open(&gitdir).unwrap();
let head = repo
.rev_parse_single("refs/heads/specs")
.unwrap()
.detach()
.to_string();
assert_eq!(head, _sha_c);
}
}