roots: add git-ents integration and reconciliation test coverage
commit 5a078c4
roots: add git-ents integration and reconciliation test coverage
Fixture support (tests/common) builds a real, on-disk repository plus a
deterministic signing key - the counterpart to ents-testutil’s
in-memory fixtures, needed here because LocalRoot/HostedRoot wire a
real LooseRefStore and a real odb rather than the in-memory pair every
library crate’s own tests use.
members.rs and worktree_update.rs exercise the local root end to end:
bootstrap enrollment, the admin-registered add/revoke/unrevoke/check
lifecycle, and roots.worktree-update’s denyCurrentBranch=updateInstead
edge via a real external push into a checked-out branch.
hosted_root.rs is the literal single-node hosted root: a real bare
repository with pre-receive/post-receive hooks shelling to the built
binary, pushed into over real git subprocesses - one round trip that a
bootstrap enrollment is admitted by the mandatory gate, and one that an
unenrolled signer’s push is refused once an admin has turned on the tip
invariant (no porcelain command sets refs/meta/config’s epoch yet, so
this test writes it directly through mutate::propose_entity, the same
primitive git ents commands use).
reconcile.rs is the phase-6 exit criterion run literally: open a
HostedRoot, advance a branch into an effect’s trigger set, then drop
that root and open a fresh one against the same on-disk state -
standing in for a kill -9 of whatever process held the in-memory
EventSink, since nothing here persists across that boundary except
repository state itself.
cli_help.rs checks --help content by substring rather than a byte
snapshot: figue’s help renderer emits ANSI color codes unconditionally
(confirmed by inspecting raw output bytes even when redirected to a
file, not a terminal), which would make a literal snapshot fragile
against figue version bumps rather than against this crate’s own
--help content.
No reviews of this commit yet — record a verdict below.
Start a review
crates/git-ents/tests/cli_help.rs
@@ -1,0 +1,71 @@
+//! A snapshot-style check of `git-ents --help`'s content — every
+//! porcelain subcommand family named, with the crate's own doc as the
+//! description.
+//!
+//! Not a byte-for-byte `trycmd` snapshot: `figue`'s help renderer emits
+//! ANSI color codes unconditionally (confirmed by inspecting raw output
+//! bytes even when redirected to a file, not a terminal), which would
+//! make a literal snapshot fragile against `figue` version bumps rather
+//! than against this crate's own `--help` content. Substring assertions
+//! on the captured (ANSI-code-interspersed but still substring-intact)
+//! output check the content that matters — every subcommand family is
+//! named — without pinning exact styling.
+
+#![allow(clippy::expect_used, reason = "integration test")]
+
+mod common;
+
+use std::process::Command;
+
+fn help_output() -> String {
+ let output = Command::new(common::bin_path())
+ .arg("--help")
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+ String::from_utf8(output.stdout).expect("utf8")
+}
+
+/// Every top-level porcelain subcommand family this phase lands
+/// (`docs/development-plan.adoc`'s phase-6 row) is named in `--help`.
+// @relation(roots.local, scope=function, role=Verifies)
+#[test]
+fn help_names_every_subcommand_family() {
+ let help = help_output();
+ for name in [
+ "setup",
+ "members",
+ "account",
+ "effect",
+ "toolchain",
+ "comment",
+ "inbox",
+ "redact",
+ "hook",
+ ] {
+ assert!(help.contains(name), "--help must mention {name:?}:\n{help}");
+ }
+}
+
+/// `--help` carries this crate's own one-line responsibility, not a
+/// generic placeholder.
+#[test]
+fn help_carries_the_crate_doc() {
+ let help = help_output();
+ assert!(help.contains("Local root wiring"), "{help}");
+}
+
+/// A subcommand's own `--help` (e.g. `members --help`) also renders,
+/// confirming the nested subcommand grammar is reachable, not just the
+/// top level.
+#[test]
+fn nested_help_reaches_a_subcommand() {
+ let output = Command::new(common::bin_path())
+ .args(["members", "--help"])
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+ let text = String::from_utf8(output.stdout).expect("utf8");
+ assert!(text.contains("list"), "{text}");
+ assert!(text.contains("revoke"), "{text}");
+}
crates/git-ents/tests/common/mod.rs
@@ -1,0 +1,74 @@
+//! Shared test fixtures for `git-ents`'s integration suite: a real,
+//! on-disk repository plus a deterministic signing key — the counterpart
+//! to `ents-testutil`'s in-memory fixtures, needed here because this
+//! crate's composition roots ([`git_ents::root::LocalRoot`],
+//! [`git_ents::root::HostedRoot`]) wire a real `LooseRefStore` and a real
+//! odb, not the in-memory `MemRefStore`/`ObjectStore` pair every library
+//! crate's own tests use.
+
+#![allow(dead_code, reason = "not every test file uses every helper")]
+#![allow(clippy::expect_used, reason = "integration test")]
+
+use std::path::{Path, PathBuf};
+
+use ssh_key::private::{Ed25519Keypair, KeypairData};
+use ssh_key::{LineEnding, PrivateKey};
+use tempfile::TempDir;
+
+/// A real, empty git repository plus a deterministic signing key, ready
+/// for [`git_ents::root::LocalRoot::open`] or [`git_ents::root::HostedRoot::open`].
+pub struct Fixture {
+ pub dir: TempDir,
+ pub key_path: PathBuf,
+}
+
+impl Fixture {
+ /// Initialize a fresh, non-bare repository with a deterministic
+ /// ed25519 signing key at `<repo>/../id_ed25519`, seeded by `seed`.
+ pub fn new(seed: u8) -> Self {
+ let dir = tempfile::tempdir().expect("tempdir");
+ gix::init(dir.path()).expect("init");
+ let key_path = dir.path().join(".id_ed25519");
+ write_key(&key_path, seed);
+ Self { dir, key_path }
+ }
+
+ /// Initialize a fresh *bare* repository (the single-node hosted root's
+ /// shape) with a deterministic signing key.
+ pub fn new_bare(seed: u8) -> Self {
+ let dir = tempfile::tempdir().expect("tempdir");
+ gix::init_bare(dir.path()).expect("init bare");
+ let key_path = dir.path().join(".id_ed25519");
+ write_key(&key_path, seed);
+ Self { dir, key_path }
+ }
+
+ pub fn path(&self) -> &Path {
+ self.dir.path()
+ }
+}
+
+/// Write a deterministic key inside `dir` (as `.id_ed25519`) and return its
+/// path — for tests that need a key living alongside a specific working
+/// directory (a clone) rather than a [`Fixture`]'s own repo directory.
+pub fn write_key_in(dir: &Path, seed: u8) -> PathBuf {
+ let path = dir.join(".id_ed25519");
+ write_key(&path, seed);
+ path
+}
+
+/// Write a deterministic, unencrypted ed25519 key at `path` — the fixture
+/// counterpart to `ents_testutil::Keypair::from_seed`, but a real file a
+/// [`git_ents::sign::Signer`] can load.
+pub fn write_key(path: &Path, seed: u8) {
+ let pair = Ed25519Keypair::from_seed(&[seed; 32]);
+ let key = PrivateKey::new(KeypairData::from(pair), "git-ents-test").expect("well-formed");
+ key.write_openssh_file(path, LineEnding::LF)
+ .expect("write key");
+}
+
+/// The path to the built `git-ents` binary under test — `cargo test`
+/// exposes this via `CARGO_BIN_EXE_<name>`.
+pub fn bin_path() -> PathBuf {
+ PathBuf::from(env!("CARGO_BIN_EXE_git-ents"))
+}
crates/git-ents/tests/hosted_root.rs
@@ -1,0 +1,206 @@
+//! End-to-end coverage of the single-node hosted root
+//! (`docs/development-plan.adoc`'s phase-6 row): a real bare repository
+//! served by *stock git's own* `receive-pack`, with `pre-receive` /
+//! `post-receive` hooks shelling to the built `git-ents` binary's `hook`
+//! plumbing subcommands (`crate::hook`).
+//!
+//! This is the literal shape `git.ents.cloud` runs: nothing here is
+//! simulated at the library-call level — every push goes through a real
+//! `git push` subprocess against a real bare repository, exactly as an
+//! external contributor's client would see it.
+#![allow(clippy::expect_used, reason = "integration test")]
+
+mod common;
+
+use std::path::Path;
+use std::process::Command;
+
+use git_ents::root::LocalRoot;
+
+/// Install `pre-receive` and `post-receive` hooks on `bare` that shell to
+/// the built `git-ents` binary's plumbing subcommands
+/// (`crate::hook::pre_receive`, `crate::hook::post_receive`) — exactly
+/// what a real deployment's hook scripts do.
+fn install_hooks(bare: &Path) {
+ let bin = common::bin_path();
+ let hooks_dir = bare.join("hooks");
+ std::fs::create_dir_all(&hooks_dir).expect("hooks dir");
+ for hook in ["pre-receive", "post-receive"] {
+ let script = format!("#!/bin/sh\nexec {:?} hook {hook}\n", bin.display());
+ let path = hooks_dir.join(hook);
+ std::fs::write(&path, script).expect("write hook");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mut perms = std::fs::metadata(&path).expect("meta").permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&path, perms).expect("chmod");
+ }
+ }
+}
+
+fn git(dir: &Path, args: &[&str]) -> std::process::Output {
+ Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(args)
+ .env("GIT_AUTHOR_NAME", "test")
+ .env("GIT_AUTHOR_EMAIL", "test@ents.test")
+ .env("GIT_COMMITTER_NAME", "test")
+ .env("GIT_COMMITTER_EMAIL", "test@ents.test")
+ // Isolate from whatever `~/.gitconfig`/`~/.ssh` the machine
+ // running this test happens to have — a hosted worker's signing
+ // key resolution must never depend on the ambient environment.
+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
+ .env("GIT_CONFIG_SYSTEM", "/dev/null")
+ .env_remove("HOME")
+ .output()
+ .expect("git runs")
+}
+
+/// A signed enrollment commit on `refs/meta/member/<username>`, built
+/// in-process against a scratch clone via `git-ents`'s own local root
+/// (`LocalRoot`, exactly what `git ents members add` does) — this is how a
+/// real client would produce the bytes a push transmits.
+fn build_member_commit(clone: &Path, key: &Path, username: &str) {
+ let root = LocalRoot::open(clone).expect("opens clone as a local root");
+ git_ents::commands::members::add(&root, username, None, Some(key.to_owned()))
+ .expect("builds and lands the signed enrollment commit locally");
+}
+
+/// A bootstrap enrollment pushed to the single-node hosted root round
+/// trips: the mandatory gate (`gate.mandatory-hosted`) admits it under the
+/// bootstrap window (`gate.bootstrap`) exactly as the advisory local root
+/// would, and the ref lands on the bare repository for real, over a real
+/// `git push`.
+// @relation(roots.local, roots.composition, gate.mandatory-hosted, gate.bootstrap, scope=function, role=Verifies)
+#[test]
+fn bootstrap_push_round_trips_through_the_hosted_root() {
+ let bare = common::Fixture::new_bare(20);
+ install_hooks(bare.path());
+
+ let clone_dir = tempfile::tempdir().expect("tempdir");
+ let clone_output = git(
+ clone_dir.path(),
+ &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."],
+ );
+ assert!(clone_output.status.success(), "{clone_output:?}");
+
+ let key = common::write_key_in(clone_dir.path(), 21);
+ build_member_commit(clone_dir.path(), &key, "jdc");
+
+ let push = git(
+ clone_dir.path(),
+ &["push", "origin", "refs/meta/member/jdc"],
+ );
+ assert!(
+ push.status.success(),
+ "bootstrap push must be accepted by the mandatory gate: {push:?}"
+ );
+
+ // The ref really landed on the bare (hosted) repository, not just the
+ // client's own clone.
+ let show = git(bare.path(), &["show-ref", "refs/meta/member/jdc"]);
+ assert!(
+ show.status.success(),
+ "ref must exist on the hosted root: {show:?}"
+ );
+}
+
+/// A second push, from a *different, unenrolled* signer, straight onto a
+/// canonical meta-ref with no admin standing, must be refused by the
+/// mandatory gate — and because `pre-receive` rejects the whole batch
+/// before git writes anything, the object graph never lands either.
+// @relation(gate.mandatory-hosted, gate.verdict-reason, scope=function, role=Verifies)
+#[test]
+fn unauthorized_push_is_refused_by_the_hosted_root() {
+ let bare = common::Fixture::new_bare(22);
+ install_hooks(bare.path());
+
+ // First, a legitimate admin bootstraps the repository.
+ let admin_clone = tempfile::tempdir().expect("tempdir");
+ let clone_output = git(
+ admin_clone.path(),
+ &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."],
+ );
+ assert!(clone_output.status.success());
+ let admin_key = common::write_key_in(admin_clone.path(), 23);
+ build_member_commit(admin_clone.path(), &admin_key, "admin");
+ let push = git(
+ admin_clone.path(),
+ &["push", "origin", "refs/meta/member/admin"],
+ );
+ assert!(push.status.success(), "{push:?}");
+
+ // Turn on the tip invariant (`gate.epoch`): before an epoch is
+ // recorded, every `refs/meta/*` update passes as `PreEpoch` — history
+ // before verification is archival, not yet gated. No porcelain command
+ // sets this yet (a genuine, explicitly deferred gap; see this crate's
+ // final report), so this test writes the config entity the same way
+ // `ents-receive`'s own doctest does: directly through
+ // `git_ents::mutate::propose_entity`, admin-signed.
+ let admin_root = LocalRoot::open(admin_clone.path()).expect("opens");
+ let admin_signer = git_ents::sign::Signer::load(&admin_key).expect("loads");
+ let identity = git_ents::mutate::Identity {
+ actor: gix::actor::Signature {
+ name: "admin".into(),
+ email: "admin@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ },
+ signer: &admin_signer,
+ };
+ let config_ref: gix::refs::FullName =
+ ents_model::namespace::CONFIG_REF.try_into().expect("valid");
+ let outcome = git_ents::mutate::propose_entity(
+ &admin_root.refs,
+ &admin_root.objects,
+ &admin_root.events,
+ config_ref,
+ &ents_gate::Config { epoch: Some(1_000) },
+ &identity,
+ "Enable the tip invariant",
+ admin_root.mode(),
+ )
+ .expect("evaluates");
+ git_ents::mutate::outcome_to_result(outcome, None).expect("admin may set the epoch");
+ let push = git(admin_clone.path(), &["push", "origin", "refs/meta/config"]);
+ assert!(push.status.success(), "{push:?}");
+
+ // Now a second, unenrolled signer tries to enroll a member directly —
+ // an ordinary member ref is unauthorized-namespace by default without
+ // an admin doing it, so this must be refused.
+ let outsider_clone = tempfile::tempdir().expect("tempdir");
+ let clone_output = git(
+ outsider_clone.path(),
+ &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."],
+ );
+ assert!(clone_output.status.success());
+ let outsider_key = common::write_key_in(outsider_clone.path(), 24);
+ // Fetch the admin's enrollment first so the local root's own gate
+ // check has the current member list to read (mirrors a real client's
+ // fetch-before-push).
+ let fetch = git(
+ outsider_clone.path(),
+ &["fetch", "origin", "+refs/meta/*:refs/meta/*"],
+ );
+ assert!(fetch.status.success(), "{fetch:?}");
+ build_member_commit(outsider_clone.path(), &outsider_key, "mallory");
+
+ let push = git(
+ outsider_clone.path(),
+ &["push", "origin", "refs/meta/member/mallory"],
+ );
+ assert!(
+ !push.status.success(),
+ "an unauthorized signer's push must be refused by the mandatory gate"
+ );
+
+ let show = git(bare.path(), &["show-ref", "refs/meta/member/mallory"]);
+ assert!(
+ !show.status.success(),
+ "a refused pre-receive push must leave no trace on the hosted root"
+ );
+}
crates/git-ents/tests/members.rs
@@ -1,0 +1,101 @@
+//! Integration coverage for `git ents members` against a real local
+//! composition root (`roots.local`) — the bootstrap enrollment, then the
+//! full add/revoke/unrevoke/check lifecycle atop it.
+//!
+//! rstest table-driven: the spec enumerates member-state transitions
+//! (`model.member-revocation`) as a small closed set of cases, exactly the
+//! shape the engineering conventions call out for table tests rather than
+//! property tests.
+#![allow(
+ clippy::expect_used,
+ clippy::indexing_slicing,
+ reason = "integration test"
+)]
+
+mod common;
+
+use ents_model::MemberState;
+use git_ents::commands::members;
+use git_ents::root::LocalRoot;
+use rstest::rstest;
+
+/// The bootstrap window (`gate.bootstrap`) admits the very first member
+/// with no prior enrollment — [`git_ents::lib`]'s own doctest exercises
+/// this too; this test additionally confirms `git ents members list` then
+/// reads it back through the real composition root.
+// @relation(roots.local, model.member-identity, scope=function, role=Verifies)
+#[test]
+fn bootstrap_enrolls_the_first_member() {
+ let fixture = common::Fixture::new(1);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+
+ members::add(&root, "jdc", None, Some(fixture.key_path.clone())).expect("bootstrap admits it");
+
+ let listed = members::list(&root).expect("lists");
+ assert_eq!(listed.len(), 1);
+ assert_eq!(listed[0].0, "jdc");
+ assert_eq!(listed[0].1.state, MemberState::Active);
+}
+
+/// Enroll an admin, then use that same key to add a second member — the
+/// ordinary (non-bootstrap) admin-registered path.
+// @relation(roots.local, model.member-identity, scope=function, role=Verifies)
+#[test]
+fn admin_enrolls_a_second_member() {
+ let fixture = common::Fixture::new(2);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "admin", None, Some(fixture.key_path.clone())).expect("bootstrap");
+
+ members::add(
+ &root,
+ "bob",
+ Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBogus bob".to_owned()),
+ Some(fixture.key_path.clone()),
+ )
+ .expect("admin-registered add");
+
+ let listed = members::list(&root).expect("lists");
+ assert_eq!(listed.len(), 2);
+ assert!(listed.iter().any(|(name, _)| name == "bob"));
+}
+
+#[rstest]
+#[case::revoke_then_check(true)]
+#[case::unrevoke_then_check(false)]
+// @relation(model.member-revocation, scope=function, role=Verifies)
+fn revoke_and_unrevoke_round_trip(#[case] end_revoked: bool) {
+ let fixture = common::Fixture::new(3);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "jdc", None, Some(fixture.key_path.clone())).expect("bootstrap");
+
+ members::set_revoked(&root, "jdc", true, Some(fixture.key_path.clone())).expect("revoke");
+ if !end_revoked {
+ members::set_revoked(&root, "jdc", false, Some(fixture.key_path.clone()))
+ .expect("unrevoke");
+ }
+
+ let (_, state) = members::check(&root, Some(fixture.key_path.clone()))
+ .expect("reads")
+ .expect("still a member record (revocation records state, never deletes)");
+ let expected = if end_revoked {
+ MemberState::Revoked
+ } else {
+ MemberState::Active
+ };
+ assert_eq!(state, expected);
+}
+
+/// `git ents members remove` deletes the ref entirely, distinct from
+/// revocation (`model.member-revocation`'s "never deletes" contrast).
+// @relation(model.member-revocation, scope=function, role=Verifies)
+#[test]
+fn remove_deletes_the_member_ref_entirely() {
+ let fixture = common::Fixture::new(4);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "jdc", None, Some(fixture.key_path.clone())).expect("bootstrap");
+
+ members::remove(&root, "jdc", Some(fixture.key_path.clone())).expect("removes");
+
+ let listed = members::list(&root).expect("lists");
+ assert!(listed.is_empty());
+}
crates/git-ents/tests/reconcile.rs
@@ -1,0 +1,205 @@
+//! The phase-6 exit criterion, run literally: "the boot-time
+//! reconciliation scan regenerates obligations correctly after a `kill -9`
+//! of the in-memory queue."
+//!
+//! [`HostedRoot::open`] runs [`ents_receive::reconcile`] at open time
+//! (`receive.reconstructible`) to populate its in-memory `EventSink`; this
+//! test defines an effect, advances a branch into its trigger set, opens a
+//! `HostedRoot` once, then *drops it and opens a fresh one* against the
+//! same on-disk repository — standing in for a `kill -9` of whatever
+//! process held the in-memory queue, since nothing here persists across
+//! that boundary except repository state itself.
+#![allow(
+ clippy::expect_used,
+ clippy::indexing_slicing,
+ clippy::string_slice,
+ reason = "integration test"
+)]
+
+mod common;
+
+use ents_model::Effect;
+use git_ents::root::HostedRoot;
+use gix_object::{Commit, Kind, Write as _};
+use gix_ref_store::{Expected, RefEdit, RefStore};
+
+/// Write an empty-tree commit and move `refname` to it directly through
+/// the ref store — a branch ref needs no signature at all
+/// (`gate.principled-split`: code refs keep transport-level authorization,
+/// never the tip invariant), so this bypasses `receive` entirely, the way
+/// a plain `git commit` would.
+fn advance_branch(root: &HostedRoot, refname: &str, seconds: i64) -> gix_hash::ObjectId {
+ let empty_tree = root
+ .objects
+ .write(&gix_object::Tree::empty())
+ .expect("tree");
+ let actor = gix::actor::Signature {
+ name: "test".into(),
+ email: "test@ents.test".into(),
+ time: gix::date::Time { seconds, offset: 0 },
+ };
+ let commit = Commit {
+ tree: empty_tree,
+ parents: Default::default(),
+ author: actor.clone(),
+ committer: actor,
+ encoding: None,
+ message: "advance".into(),
+ extra_headers: Vec::new(),
+ };
+ let mut raw = Vec::new();
+ gix_object::WriteTo::write_to(&commit, &mut raw).expect("serialize");
+ let oid = root.objects.write_buf(Kind::Commit, &raw).expect("write");
+
+ let name: gix::refs::FullName = refname.try_into().expect("valid refname");
+ root.refs
+ .transaction(&[RefEdit {
+ name,
+ expected: Expected::Any,
+ new: Some(oid),
+ }])
+ .expect("moves the ref");
+ oid
+}
+
+fn define_effect(root: &HostedRoot, name: &str, trigger: &str) {
+ let tree = facet_git_tree::serialize_into(
+ &Effect {
+ trigger: trigger.to_owned(),
+ toolchains: vec![],
+ run: "true".to_owned(),
+ },
+ &root.objects,
+ )
+ .expect("serialize effect");
+ let commit = Commit {
+ tree,
+ parents: Default::default(),
+ author: gix::actor::Signature {
+ name: "test".into(),
+ email: "test@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1,
+ offset: 0,
+ },
+ },
+ committer: gix::actor::Signature {
+ name: "test".into(),
+ email: "test@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1,
+ offset: 0,
+ },
+ },
+ encoding: None,
+ message: "define effect".into(),
+ extra_headers: Vec::new(),
+ };
+ let mut raw = Vec::new();
+ gix_object::WriteTo::write_to(&commit, &mut raw).expect("serialize");
+ let oid = root.objects.write_buf(Kind::Commit, &raw).expect("write");
+
+ let ref_name = ents_model::namespace::effect_ref(name).expect("valid");
+ root.refs
+ .transaction(&[RefEdit {
+ name: ref_name,
+ expected: Expected::Any,
+ new: Some(oid),
+ }])
+ .expect("moves the ref");
+}
+
+/// The literal phase-6 exit test.
+// @relation(receive.reconstructible, scope=function, role=Verifies)
+#[test]
+fn boot_time_reconciliation_survives_a_simulated_crash() {
+ let fixture = common::Fixture::new_bare(10);
+
+ // Set up repository state entirely before any `HostedRoot` exists —
+ // "queue" state here is purely derived, never authored directly.
+ {
+ let root = HostedRoot::open(fixture.path()).expect("opens");
+ define_effect(&root, "unit", "rev(refs/heads/main)");
+ advance_branch(&root, "refs/heads/main", 100);
+ // This first root's own boot scan already saw the commit (it
+ // opened after the effect existed but the commit came after)...
+ }
+ // ...so open fresh once more with everything in place, exactly as a
+ // process starting for the first time against this repository would.
+ let expected_oid = {
+ let root = HostedRoot::open(fixture.path()).expect("second open reconciles fresh");
+ let pending = root.events.pending();
+ assert_eq!(pending.len(), 1, "exactly one outstanding obligation");
+ assert_eq!(pending[0].0, "unit");
+ pending[0].1
+ };
+
+ // Simulate `kill -9` of the in-memory queue: drop this handle (its
+ // `MemoryEventSink` goes with it — nothing persists it) and open an
+ // entirely fresh `HostedRoot` against the same on-disk repository.
+ let root = HostedRoot::open(fixture.path()).expect("reconciles again, from scratch");
+ let pending = root.events.pending();
+ assert_eq!(
+ pending,
+ vec![("unit".to_owned(), expected_oid)],
+ "the boot-time scan regenerates the exact same obligation from repository state alone"
+ );
+}
+
+/// Once a result exists for a commit, reconciliation must not re-list it —
+/// otherwise a restarted worker would re-run every effect it had ever
+/// completed.
+// @relation(receive.reconstructible, query.workset, scope=function, role=Verifies)
+#[test]
+fn reconciliation_excludes_already_resulted_commits() {
+ let fixture = common::Fixture::new_bare(11);
+ let root = HostedRoot::open(fixture.path()).expect("opens");
+ define_effect(&root, "unit", "rev(refs/heads/main)");
+ let oid = advance_branch(&root, "refs/heads/main", 100);
+
+ // Record a result directly (bypassing `write_result`'s signing
+ // requirement — this test only needs the ref to exist).
+ let short = &oid.to_string()[..12];
+ let status_tree = facet_git_tree::serialize_into(&ents_model::Status::Pass, &root.objects)
+ .expect("serialize");
+ let commit = gix_object::Commit {
+ tree: status_tree,
+ parents: Default::default(),
+ author: gix::actor::Signature {
+ name: "worker".into(),
+ email: "worker@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 200,
+ offset: 0,
+ },
+ },
+ committer: gix::actor::Signature {
+ name: "worker".into(),
+ email: "worker@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 200,
+ offset: 0,
+ },
+ },
+ encoding: None,
+ message: "result".into(),
+ extra_headers: Vec::new(),
+ };
+ let mut raw = Vec::new();
+ gix_object::WriteTo::write_to(&commit, &mut raw).expect("serialize");
+ let result_oid = root.objects.write_buf(Kind::Commit, &raw).expect("write");
+ let result_ref = ents_model::namespace::result_ref("unit", short).expect("valid");
+ root.refs
+ .transaction(&[RefEdit {
+ name: result_ref,
+ expected: Expected::Any,
+ new: Some(result_oid),
+ }])
+ .expect("records the result");
+
+ let root = HostedRoot::open(fixture.path()).expect("reconciles fresh");
+ assert!(
+ root.events.pending().is_empty(),
+ "a commit with a recorded result must never be re-enqueued"
+ );
+}
crates/git-ents/tests/worktree_update.rs
@@ -1,0 +1,101 @@
+//! `roots.worktree-update`: `git ents setup` sets
+//! `receive.denyCurrentBranch=updateInstead` on the local repository, so
+//! the integration-test-harness case — an external push landing on this
+//! repository's own checked-out branch — also updates the working tree,
+//! rather than the ordinary git behavior of refusing such a push outright.
+
+#![allow(clippy::expect_used, reason = "integration test")]
+
+mod common;
+
+use std::path::Path;
+use std::process::Command;
+
+use git_ents::commands;
+use git_ents::root::LocalRoot;
+
+fn git(dir: &Path, args: &[&str]) -> std::process::Output {
+ Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(args)
+ .env("GIT_AUTHOR_NAME", "test")
+ .env("GIT_AUTHOR_EMAIL", "test@ents.test")
+ .env("GIT_COMMITTER_NAME", "test")
+ .env("GIT_COMMITTER_EMAIL", "test@ents.test")
+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
+ .env("GIT_CONFIG_SYSTEM", "/dev/null")
+ .env_remove("HOME")
+ .output()
+ .expect("git runs")
+}
+
+/// After `git ents setup`, an external push into this repository's own
+/// checked-out branch both succeeds and updates the working tree —
+/// `receive.denyCurrentBranch=updateInstead` in effect
+/// (`roots.worktree-update`).
+// @relation(roots.worktree-update, scope=function, role=Verifies)
+#[test]
+fn setup_lets_a_harness_push_update_the_checked_out_branch() {
+ let origin_dir = tempfile::tempdir().expect("tempdir");
+ let init = git(origin_dir.path(), &["init", "-q", "-b", "main"]);
+ assert!(init.status.success(), "{init:?}");
+ std::fs::write(origin_dir.path().join("file.txt"), "before\n").expect("write");
+ let add = git(origin_dir.path(), &["add", "-A"]);
+ assert!(add.status.success());
+ let commit = git(origin_dir.path(), &["commit", "-q", "-m", "before"]);
+ assert!(commit.status.success(), "{commit:?}");
+
+ // `git ents setup` is what this test exercises: it must record
+ // `receive.denyCurrentBranch=updateInstead` on `origin_dir`'s own
+ // config. An explicit `--key` sidesteps `resolve_key_path`'s
+ // ambient-fallback resolution (`user.signingkey`, `~/.ssh/id_ed25519`)
+ // entirely, keeping this test isolated from whatever the machine
+ // running it happens to have configured globally, without mutating
+ // process environment (`unsafe_code` is workspace-forbidden even in
+ // tests).
+ let key_path = common::write_key_in(origin_dir.path(), 30);
+ let root = LocalRoot::open(origin_dir.path()).expect("opens");
+ commands::setup::run(&root, Some(key_path)).expect("configures the repo");
+
+ let config = git(origin_dir.path(), &["config", "receive.denyCurrentBranch"]);
+ assert_eq!(
+ String::from_utf8_lossy(&config.stdout).trim(),
+ "updateInstead",
+ "{config:?}"
+ );
+
+ // An external clone pushes a change directly onto `main` — the
+ // integration-test-harness case `roots.worktree-update` names.
+ let clone_dir = tempfile::tempdir().expect("tempdir");
+ let clone = git(
+ clone_dir.path(),
+ &[
+ "clone",
+ "--quiet",
+ origin_dir.path().to_str().expect("utf8"),
+ ".",
+ ],
+ );
+ assert!(clone.status.success(), "{clone:?}");
+ std::fs::write(clone_dir.path().join("file.txt"), "after\n").expect("write");
+ let add = git(clone_dir.path(), &["add", "-A"]);
+ assert!(add.status.success());
+ let commit = git(clone_dir.path(), &["commit", "-q", "-m", "after"]);
+ assert!(commit.status.success(), "{commit:?}");
+
+ let push = git(clone_dir.path(), &["push", "origin", "main"]);
+ assert!(
+ push.status.success(),
+ "a push into the checked-out branch must be accepted, not refused: {push:?}"
+ );
+
+ // The working tree on `origin_dir` reflects the push, not just its
+ // ref — this is the "also updates the working tree" half of
+ // `updateInstead`, distinct from an ordinary bare remote.
+ let updated = std::fs::read_to_string(origin_dir.path().join("file.txt")).expect("read");
+ assert_eq!(
+ updated, "after\n",
+ "the checked-out working tree must update"
+ );
+}