roots: add git-ents, the local and single-node hosted composition roots
commit 2f64f47
roots: add git-ents, the local and single-node hosted composition roots
git-ents is the CLI-complete milestone: two composition roots
(LocalRoot, HostedRoot) wire RefStore/odb/EventSink/Executor per
roots.local, above a cli.rs/exe.rs split (figue derive definitions vs.
dispatch) and a commands/ module per porcelain family (setup, members,
account, effect, toolchain, comment, inbox, redact). mutate.rs is the
one shared primitive every write command uses to build, sign, and
propose a typed-tree mutation through ents_receive::receive; sign.rs is
the real SSH signer counterpart to ents-testutil’s fixture Keypair,
since the redone architecture builds and signs commits in-process
rather than shelling to git commit -S the way pre-redo’s CLI did.
HostedRoot doubles as the single-node hosted root the development plan
describes: stock git’s own receive-pack stays the transport, with
hook.rs’s pre-receive/post-receive plumbing running the identical
ents_gate::verify every other call site uses and reconciling effect
obligations from repository state alone (receive.reconstructible) -
deliberately not ents_receive::receive’s own RefStore::transaction path,
since that would race git’s native ref update in this deployment shape
(see hook.rs’s own doc). QuarantineObjects chains a pre-receive
quarantine directory in front of the real odb in-process, rather than
writing a physical info/alternates file: that first attempt was
briefly tried and reverted after it surfaced a real bug - git’s
quarantine finalization moves the quarantine directory’s contents,
including anything a hook wrote into it, onto the real object
directory, permanently self-referencing objects/info/alternates and
failing every later push.
setup.rs also closes roots.worktree-update: git ents setup sets
receive.denyCurrentBranch=updateInstead via a git config subprocess,
since gix’s own config-snapshot API has no file-persistence path at
all (confirmed empirically - SnapshotMut::commit only updates the
in-memory resolved view a Repository handle holds).
New dependencies: ssh-key + rand_core (real, non-fixture SSH signing
and key generation) and uuid (comment ids) - all added to git-ents
only. ents-anchor and ents-effect move from members-only to
workspace.dependencies, since this is their first consumer.
crates/git-ents/src/cli.rs
@@ -1,0 +1,345 @@
+//! `git ents`'s argument grammar — `figue` derive definitions only.
+//!
+//! Per this project's engineering conventions, this module carries no
+//! logic: every doc comment here becomes `--help` text, and
+//! [`crate::exe`] is the only place a [`Top`] variant is interpreted.
+
+use std::path::PathBuf;
+
+use facet::Facet;
+use figue::{self as args, FigueBuiltins};
+
+/// Local root wiring, subcommand surface, and the single-node hosted
+/// root's git-hook plumbing (`docs/development-plan.adoc`, phase 6).
+#[derive(Facet)]
+pub struct Cli {
+ /// The subcommand to run.
+ #[facet(args::subcommand)]
+ pub command: Top,
+ /// `--help`/`--version`/`--completions` wiring `figue` provides for
+ /// every CLI built on it.
+ #[facet(flatten)]
+ pub builtins: FigueBuiltins,
+}
+
+/// Every top-level `git ents` subcommand.
+// @relation(roots.local, roots.worktree-update, scope=file)
+#[derive(Facet)]
+#[repr(u8)]
+pub enum Top {
+ /// Configure this repository for signed local writes: resolve or
+ /// generate a signing key, record it as `user.signingkey` with
+ /// `gpg.format=ssh`, and set `receive.denyCurrentBranch=updateInstead`
+ /// so the integration-test harness can push into this repository's
+ /// checked-out branch (`roots.worktree-update`).
+ Setup {
+ /// Key to sign with; defaults to `user.signingkey`, else a new
+ /// `~/.ssh/id_ed25519` is generated.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Manage the repository members at `refs/meta/member/<username>`.
+ Members {
+ /// The member action to run.
+ #[facet(args::subcommand)]
+ action: MembersAction,
+ },
+ /// Manage this repository's account identity at `refs/meta/account`.
+ Account {
+ /// The account action to run.
+ #[facet(args::subcommand)]
+ action: AccountAction,
+ },
+ /// Manage the configured effects at `refs/meta/effects/<name>` and run
+ /// them locally.
+ Effect {
+ /// The effect action to run.
+ #[facet(args::subcommand)]
+ action: EffectAction,
+ },
+ /// Manage the toolchains stored as git trees at
+ /// `refs/meta/toolchains/<name>`.
+ Toolchain {
+ /// The toolchain action to run.
+ #[facet(args::subcommand)]
+ action: ToolchainAction,
+ },
+ /// Comment on code: one comment per ref at `refs/meta/comments/<id>`,
+ /// anchored to a blob (and optionally lines) at a commit.
+ Comment {
+ /// The comment action to run.
+ #[facet(args::subcommand)]
+ action: CommentAction,
+ },
+ /// Work with entities awaiting adoption at
+ /// `refs/meta/inbox/<member>/<id>`.
+ Inbox {
+ /// The inbox action to run.
+ #[facet(args::subcommand)]
+ action: InboxAction,
+ },
+ /// Record that `oid` was redacted (`refs/meta/redactions/<id>`),
+ /// refusing any future push that would refill it
+ /// (`receive.redaction-ingest`). Admin-only: the gate's default
+ /// namespace-authorization arm requires admin-registered provenance
+ /// for `refs/meta/redactions/*`.
+ Redact {
+ /// The object id to redact.
+ #[facet(args::positional)]
+ oid: String,
+ /// A human-readable reason recorded alongside the redaction.
+ #[facet(args::named)]
+ reason: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Plumbing invoked by git's own hooks on the single-node hosted root
+ /// (`git.ents.cloud`) — not part of the porcelain surface a developer
+ /// runs directly.
+ Hook {
+ /// Which hook is running.
+ #[facet(args::subcommand)]
+ action: HookAction,
+ },
+}
+
+/// `git ents members` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum MembersAction {
+ /// List the members recorded in this repository.
+ List,
+ /// Enroll a new member, or update an existing one's key.
+ Add {
+ /// The member's username (`refs/meta/member/<username>`).
+ #[facet(args::positional)]
+ username: String,
+ /// The public key to enroll (an OpenSSH single-line public key);
+ /// defaults to the signer's own public key.
+ #[facet(args::named)]
+ pubkey: Option<String>,
+ /// Key to sign the enrollment with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Remove a member, deleting its ref.
+ Remove {
+ /// The member (username) to remove.
+ #[facet(args::positional)]
+ username: String,
+ /// Key to sign the removal with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Revoke a member's key (`model.member-revocation`): the record
+ /// stays, but the key no longer authorizes new signatures.
+ Revoke {
+ /// The member (username) to revoke.
+ #[facet(args::positional)]
+ username: String,
+ /// Key to sign the revocation with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Lift a revocation, restoring a member's key to active.
+ Unrevoke {
+ /// The member (username) to unrevoke.
+ #[facet(args::positional)]
+ username: String,
+ /// Key to sign the unrevocation with; defaults to
+ /// `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Report whether a key is an active member.
+ Check {
+ /// Key to look for; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
+/// `git ents account` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum AccountAction {
+ /// Create or update this repository's account identity.
+ Create {
+ /// The member this account belongs to; defaults to the signer's
+ /// own member (resolved by public key).
+ #[facet(args::named)]
+ member: Option<String>,
+ /// The login identity the member authenticates as.
+ #[facet(args::named)]
+ login: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
+/// `git ents effect` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum EffectAction {
+ /// List the effects configured in this repository.
+ List,
+ /// Show one effect's definition and, when a commit is given, its
+ /// result.
+ Show {
+ /// The effect's name.
+ #[facet(args::positional)]
+ name: String,
+ /// Commit to show the result for.
+ #[facet(args::named)]
+ at: Option<String>,
+ },
+ /// Define (or replace) an effect and push the update.
+ Add {
+ /// Name to record the effect under (`refs/meta/effects/<name>`).
+ #[facet(args::positional)]
+ name: String,
+ /// The query this effect triggers on (`query.grammar`).
+ #[facet(args::named)]
+ on: String,
+ /// The command the effect runs.
+ #[facet(args::positional)]
+ run: String,
+ /// Toolchain (`refs/meta/toolchains/<name>`) to activate before
+ /// the command runs (repeatable).
+ #[facet(args::named, args::label = "TOOLCHAIN", default)]
+ toolchain: Vec<String>,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Run this repository's effects locally against every commit still
+ /// owed a result, or a single one with `--at`
+ /// (`effect.local-run`): identical toolchain materialization and
+ /// sandbox path to a hosted worker, the queue skipped entirely.
+ Run {
+ /// The effect's name.
+ #[facet(args::positional)]
+ name: String,
+ /// Commit to run against; omit to run every outstanding commit
+ /// (`query.workset`).
+ #[facet(args::named)]
+ at: Option<String>,
+ /// Key to sign the result with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Show recorded results for an effect, newest first.
+ Log {
+ /// The effect's name.
+ #[facet(args::positional)]
+ name: String,
+ },
+}
+
+/// `git ents toolchain` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum ToolchainAction {
+ /// Import a local directory as toolchain `name`, embedding its
+ /// contents whole (`ents_effect::Recipe::Embedded`).
+ Import {
+ /// Name to record the toolchain under
+ /// (`refs/meta/toolchains/<name>`).
+ #[facet(args::positional)]
+ name: String,
+ /// Directory of executables to import, activated on `PATH` when
+ /// an effect declares this toolchain.
+ #[facet(args::positional)]
+ bin: PathBuf,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Show a toolchain's provenance.
+ View {
+ /// Name (`refs/meta/toolchains/<name>`) to view.
+ #[facet(args::positional)]
+ name: String,
+ },
+ /// Show a toolchain's import history — the ref's own commit log.
+ Log {
+ /// Name (`refs/meta/toolchains/<name>`) to show history for.
+ #[facet(args::positional)]
+ name: String,
+ },
+}
+
+/// `git ents comment` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum CommentAction {
+ /// Anchor a comment to a file at a revision.
+ Add {
+ /// Repository-relative path of the file the comment anchors to.
+ #[facet(args::positional)]
+ path: String,
+ /// The comment's body text.
+ #[facet(args::named)]
+ body: String,
+ /// Lines to anchor, as `<start>[:<end>]` (1-based, inclusive);
+ /// omit for a whole-file comment.
+ #[facet(args::named)]
+ lines: Option<String>,
+ /// Revision to anchor against.
+ #[facet(args::named, default = "HEAD")]
+ rev: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Show one comment: its anchor, projected onto a revision, and its
+ /// body.
+ Show {
+ /// The comment's id.
+ #[facet(args::positional)]
+ id: String,
+ /// Revision to project the comment's anchor onto.
+ #[facet(args::named, default = "HEAD")]
+ rev: String,
+ },
+}
+
+/// `git ents inbox` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum InboxAction {
+ /// List entities awaiting adoption.
+ List,
+ /// Adopt an inbox entity onto its canonical ref
+ /// (`sync.adoption-machinery`): a merge that keeps the author's
+ /// original signed commit in ancestry
+ /// (`sync.adoption-no-cherry-pick`).
+ Adopt {
+ /// The inbox entry to adopt, as `<member>/<id>`.
+ #[facet(args::positional)]
+ entry: String,
+ /// Key to sign the adoption merge with; defaults to
+ /// `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
+/// Plumbing subcommands the single-node hosted root's git hooks invoke;
+/// see `crate::hook`'s own doc for what each does and why.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum HookAction {
+ /// Run as git's own `pre-receive` hook: evaluate the gate against
+ /// every proposed transition read from stdin, refusing the whole
+ /// push under the mandatory gate if any fails.
+ PreReceive,
+ /// Run as git's own `post-receive` hook: reconcile outstanding effect
+ /// obligations (`receive.reconstructible`) and run them.
+ PostReceive,
+ /// Reconcile outstanding effect obligations without running anything
+ /// — the boot-time scan on its own, for operational use and testing.
+ Reconcile,
+}
crates/git-ents/src/commands/account.rs
@@ -1,0 +1,64 @@
+//! `git ents account create`: link a member to a login identity at the
+//! fixed `refs/meta/account` ref (`model.account`).
+
+use ents_model::{Account, MemberId, namespace};
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::{Identity, outcome_to_result, propose_entity};
+use crate::root::LocalRoot;
+
+/// Run `git ents account create`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `member` is given but no such member exists (or,
+/// when omitted, the signer's own key is not enrolled yet — enroll it with
+/// `git ents members add` first); otherwise see
+/// [`crate::mutate::outcome_to_result`].
+pub fn create(
+ root: &LocalRoot,
+ member: Option<String>,
+ login: String,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let member_id = match member {
+ Some(username) => MemberId::new(username),
+ None => {
+ let (username, _) =
+ super::members::check(root, None)?.ok_or_else(|| Error::NotFound {
+ what: "member for the current signing key".to_owned(),
+ })?;
+ MemberId::new(username)
+ }
+ };
+ let account = Account {
+ member: member_id,
+ login,
+ };
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::ACCOUNT_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let identity = Identity {
+ actor: actor(&signer),
+ signer: &signer,
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ name,
+ &account,
+ &identity,
+ "Create account",
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
crates/git-ents/src/commands/comment.rs
@@ -1,0 +1,101 @@
+//! `git ents comment`: anchor a comment to code and show it back,
+//! projected onto a revision (`model.comment`, `anchor.definition`,
+//! `anchor.projection`).
+
+use ents_anchor::{Anchor, LineRange, Projection, project, snippet};
+use ents_model::{Comment, namespace};
+use facet_git_tree::RawTree;
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::{Identity, outcome_to_result, propose_entity};
+use crate::root::LocalRoot;
+
+/// `git ents comment add`: anchor `body` to `path` (optionally `lines`) at
+/// `rev`.
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if `lines` does not parse as `<start>[:<end>]`;
+/// otherwise propagates capture, serialization, or `receive` failures.
+pub fn add(
+ root: &LocalRoot,
+ path: &str,
+ body: String,
+ lines: Option<String>,
+ rev: &str,
+ key: Option<std::path::PathBuf>,
+) -> Result<String> {
+ let repo = gix::open(&root.path)?;
+ let range = lines.map(|text| parse_line_range(&text)).transpose()?;
+ let anchor = ents_anchor::capture(&repo, rev, path, range)?;
+
+ let anchor_tree = facet_git_tree::serialize_into(&anchor, &root.objects)?;
+ let comment = Comment {
+ body,
+ anchor: RawTree::new(anchor_tree),
+ };
+
+ // The comment's id is its own genesis tip's short oid, known only once
+ // the commit is built — `propose_entity` builds it internally, so this
+ // command derives the ref name from a locally generated id instead
+ // (`meta-ref.granularity`: one ref per comment).
+ let id = uuid::Uuid::new_v4().simple().to_string();
+ let ref_name = namespace::comment_ref(&id)?;
+
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ signer: &signer,
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ ref_name,
+ &comment,
+ &identity,
+ &format!("Comment on {path}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(id)
+}
+
+/// `git ents comment show`: `id`'s anchor (projected onto `rev`), anchored
+/// text, and body.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no comment ref.
+pub fn show(root: &LocalRoot, id: &str, rev: &str) -> Result<(Comment, Anchor, Projection)> {
+ let ref_name = namespace::comment_ref(id)?;
+ let Some(tip) = root.refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("comment {id}"),
+ });
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ let comment = facet_git_tree::deserialize::<Comment>(&tree, &root.objects)?;
+ let anchor = facet_git_tree::deserialize::<Anchor>(&comment.anchor.oid(), &root.objects)?;
+
+ let repo = gix::open(&root.path)?;
+ let projection = project(&repo, &anchor, rev)?;
+ let _ = snippet(&anchor)?; // Confirm the anchored text still reads back.
+ Ok((comment, anchor, projection))
+}
+
+fn parse_line_range(text: &str) -> Result<LineRange> {
+ let (start, end) = match text.split_once(':') {
+ Some((s, e)) => (s, e),
+ None => (text, text),
+ };
+ let start: u64 = start
+ .parse()
+ .map_err(|_source| Error::InvalidArgument(format!("bad line range: {text}")))?;
+ let end: u64 = end
+ .parse()
+ .map_err(|_source| Error::InvalidArgument(format!("bad line range: {text}")))?;
+ Ok(LineRange { start, end })
+}
crates/git-ents/src/commands/effect.rs
@@ -1,0 +1,221 @@
+//! `git ents effect`: define, list, show, run, and log effects
+//! (`model.effect-definition`, `effect.local-run`).
+
+use ents_effect::run::{run_effect, short_oid};
+use ents_model::{Effect, Status, namespace};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::{Identity, outcome_to_result, propose_entity};
+use crate::root::LocalRoot;
+
+/// `git ents effect list`: every effect currently defined.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<(String, Effect)>> {
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix("refs/meta/effects/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(short) = path.strip_prefix("refs/meta/effects/") else {
+ continue;
+ };
+ if short.is_empty() || short.contains('/') {
+ continue;
+ }
+ let tree = super::commit_tree(&root.objects, tip)?;
+ if let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, &root.objects) {
+ out.push((short.to_owned(), effect));
+ }
+ }
+ Ok(out)
+}
+
+/// `git ents effect add`: define (or replace) `name`.
+///
+/// # Errors
+///
+/// See [`crate::mutate::outcome_to_result`].
+pub fn add(
+ root: &LocalRoot,
+ name: &str,
+ on: String,
+ run: String,
+ toolchains: Vec<String>,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ // Validate the trigger parses before it is ever written — a malformed
+ // trigger would otherwise be silently skipped by every future
+ // reconciliation scan (`ents_receive::reconcile`'s own tolerance rule).
+ let _: ents_query::Query = on
+ .parse()
+ .map_err(|_source| Error::InvalidArgument(format!("unparsable trigger: {on}")))?;
+
+ let signer = signer(root, key)?;
+ let effect = Effect {
+ trigger: on,
+ toolchains,
+ run,
+ };
+ let ref_name = namespace::effect_ref(name)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ signer: &signer,
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ ref_name,
+ &effect,
+ &identity,
+ &format!("Define effect {name}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents effect show`: the definition, plus its result at `at` when
+/// given.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `name` has no effect definition.
+pub fn show(root: &LocalRoot, name: &str, at: Option<String>) -> Result<(Effect, Option<Status>)> {
+ let ref_name = namespace::effect_ref(name)?;
+ let Some(tip) = root.refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("effect {name}"),
+ });
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ let effect = facet_git_tree::deserialize::<Effect>(&tree, &root.objects)?;
+
+ let status = match at {
+ None => None,
+ Some(commit) => {
+ let oid = resolve_commit(root, &commit)?;
+ let results_ref = namespace::result_ref(name, &short_oid(oid))?;
+ match root.refs.get(results_ref.as_ref())? {
+ None => None,
+ Some(result_tip) => {
+ let tree = super::commit_tree(&root.objects, result_tip)?;
+ facet_git_tree::deserialize::<Status>(&tree, &root.objects).ok()
+ }
+ }
+ }
+ };
+ Ok((effect, status))
+}
+
+/// `git ents effect run`: run `name` locally against every outstanding
+/// commit, or a single `at` — no queue, identical materialization and
+/// sandbox path to a hosted worker (`effect.local-run`).
+///
+/// # Errors
+///
+/// Propagates any failure `ents_effect::run::run_effect` reports.
+#[expect(
+ clippy::result_large_err,
+ reason = "the closure passed to run_effect below is typed against ents_effect::Error, that \
+ crate's own Result shape, not this crate's to box"
+)]
+pub fn run(
+ root: &LocalRoot,
+ name: &str,
+ at: Option<String>,
+ key: Option<std::path::PathBuf>,
+ executor: &dyn ents_effect::Executor,
+) -> Result<Vec<(gix_hash::ObjectId, ents_receive::Outcome)>> {
+ let ref_name = namespace::effect_ref(name)?;
+ let Some(tip) = root.refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("effect {name}"),
+ });
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ let effect = facet_git_tree::deserialize::<Effect>(&tree, &root.objects)?;
+
+ let signer = signer(root, key)?;
+ let at_oid = at.map(|rev| resolve_commit(root, &rev)).transpose()?;
+
+ let scratch = tempfile::tempdir().map_err(|source| Error::Io {
+ path: root.path.clone(),
+ source,
+ })?;
+ let cache = tempfile::tempdir().map_err(|source| Error::Io {
+ path: root.path.clone(),
+ source,
+ })?;
+
+ let author = actor(&signer);
+ let outcomes = run_effect(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ executor,
+ scratch.path(),
+ cache.path(),
+ name,
+ &effect,
+ at_oid,
+ |short| canonical_result_ref(name, short),
+ &author,
+ &|payload| signer.sign(payload),
+ root.mode(),
+ )?;
+ Ok(outcomes)
+}
+
+/// Build the canonical results refname for one run, in the shape
+/// `run_effect`'s own `results_ref` parameter expects.
+///
+/// # Errors
+///
+/// Never in practice: `name` is an already-defined effect and `short` is
+/// always a hex oid slice ([`ents_effect::run::short_oid`]'s own shape), so
+/// both always compose into a well-formed refname; kept fallible only
+/// because `ents_effect::run::run_effect`'s own signature requires it.
+#[expect(
+ clippy::result_large_err,
+ reason = "the Result shape is ents_effect::run_effect's own signature, not this crate's to box"
+)]
+fn canonical_result_ref(name: &str, short: &str) -> ents_effect::Result<gix::refs::FullName> {
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "see this function's own doc: always well-formed in practice"
+ )]
+ Ok(namespace::result_ref(name, short).expect("well-formed refname segments"))
+}
+
+/// `git ents effect log`: every recorded result for `name`, newest first —
+/// the results ref's own commit log.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `name` has no results yet.
+pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<(gix_hash::ObjectId, Status)>> {
+ let prefix = format!("refs/meta/results/{name}/");
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix(&prefix)? {
+ let (_, tip) = entry?;
+ let tree = super::commit_tree(&root.objects, tip)?;
+ if let Ok(status) = facet_git_tree::deserialize::<Status>(&tree, &root.objects) {
+ out.push((tip, status));
+ }
+ }
+ Ok(out)
+}
+
+fn resolve_commit(root: &LocalRoot, rev: &str) -> Result<gix_hash::ObjectId> {
+ let repo = gix::open(&root.path)?;
+ let id = repo
+ .rev_parse_single(rev)
+ .map_err(|source| Error::InvalidArgument(format!("cannot resolve {rev}: {source}")))?;
+ Ok(id.detach())
+}
crates/git-ents/src/commands/inbox.rs
@@ -1,0 +1,105 @@
+//! `git ents inbox`: list entities awaiting adoption and adopt them onto
+//! their canonical ref (`sync.adoption-machinery`,
+//! `sync.adoption-no-cherry-pick`).
+
+use ents_model::namespace;
+use ents_sync::resolve::{Heads, Merged, merge_heads};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents inbox list`: every `refs/meta/inbox/<member>/<id>` entry.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<String>> {
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix("refs/meta/inbox/")? {
+ let (name, _) = entry?;
+ let path = name.as_bstr().to_string();
+ if let Some(rest) = path.strip_prefix("refs/meta/inbox/") {
+ out.push(rest.to_owned());
+ }
+ }
+ Ok(out)
+}
+
+/// `git ents inbox adopt`: fold `entry` (`<member>/<id>`) onto its
+/// canonical ref (`refs/meta/<id>`) via [`merge_heads`], keeping the
+/// author's original signed commit in ancestry
+/// (`sync.adoption-no-cherry-pick`).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `entry` has no inbox ref; [`Error::InvalidArgument`]
+/// on a merge conflict (a human must resolve it before adoption can
+/// complete — this phase does not implement interactive conflict
+/// resolution); otherwise see [`crate::mutate::outcome_to_result`].
+pub fn adopt(root: &LocalRoot, entry: &str, key: Option<std::path::PathBuf>) -> Result<()> {
+ let Some((member, id)) = entry.split_once('/') else {
+ return Err(Error::InvalidArgument(format!(
+ "expected <member>/<id>, got {entry:?}"
+ )));
+ };
+ let inbox_ref = namespace::inbox_ref(&ents_model::MemberId::new(member), id)?;
+ let Some(theirs) = root.refs.get(inbox_ref.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("inbox entry {entry}"),
+ });
+ };
+ let canonical: gix::refs::FullName = format!("refs/meta/{id}")
+ .try_into()
+ .map_err(|_source| Error::InvalidArgument(format!("bad canonical ref for {id}")))?;
+ let ours = root.refs.get(canonical.as_ref())?;
+
+ let signer = signer(root, key)?;
+ let author = actor(&signer);
+ let heads = Heads {
+ refname: canonical.clone(),
+ ours,
+ theirs,
+ };
+ let merged = merge_heads(
+ &root.objects,
+ &heads,
+ &author,
+ &format!("Adopt {entry}"),
+ |payload| signer.sign(payload),
+ )?;
+ let tip = match merged {
+ Merged::Tip(tip) => tip,
+ Merged::Conflict(paths) => {
+ let rendered = paths
+ .iter()
+ .map(|p| p.to_string())
+ .collect::<Vec<_>>()
+ .join(", ");
+ return Err(Error::InvalidArgument(format!(
+ "adoption conflict at: {rendered}"
+ )));
+ }
+ };
+
+ let proposal = ents_receive::Proposal {
+ transitions: vec![ents_receive::RefTransition {
+ name: canonical,
+ old: ours,
+ new: Some(tip),
+ }],
+ objects: vec![tip],
+ auth: None,
+ };
+ let outcome = ents_receive::receive(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ &proposal,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, ours)?;
+ Ok(())
+}
crates/git-ents/src/commands/members.rs
@@ -1,0 +1,151 @@
+//! `git ents members`: enroll, remove, revoke, unrevoke, and check members
+//! (`model.member-identity`, `model.member-revocation`).
+
+use ents_model::{Member, MemberId, MemberState, Provenance, namespace};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::{Identity, outcome_to_result, propose_delete, propose_entity};
+use crate::root::LocalRoot;
+
+/// `git ents members list`: every member ref and its current state.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<(String, Member)>> {
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix("refs/meta/member/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(username) = path.strip_prefix("refs/meta/member/") else {
+ continue;
+ };
+ if let Some(member) = read_member(root, tip)? {
+ out.push((username.to_owned(), member));
+ }
+ }
+ Ok(out)
+}
+
+/// `git ents members add`: enroll `username` with `pubkey` (or the
+/// signer's own public key), admin-registered.
+///
+/// # Errors
+///
+/// Propagates a signing, serialization, or `receive` failure; see
+/// [`crate::mutate::outcome_to_result`] for how a reached refusal renders.
+pub fn add(
+ root: &LocalRoot,
+ username: &str,
+ pubkey: Option<String>,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let pubkey = pubkey.unwrap_or_else(|| signer.public_openssh());
+ let member = Member::new(pubkey, Provenance::AdminRegistered);
+ let name = namespace::member_ref(&MemberId::new(username))?;
+ let identity = Identity {
+ actor: actor(&signer),
+ signer: &signer,
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ name,
+ &member,
+ &identity,
+ &format!("Enroll {username}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents members remove`: delete `username`'s ref entirely.
+///
+/// # Errors
+///
+/// See [`add`].
+pub fn remove(root: &LocalRoot, username: &str, key: Option<std::path::PathBuf>) -> Result<()> {
+ let signer = signer(root, key)?;
+ let name = namespace::member_ref(&MemberId::new(username))?;
+ let outcome = propose_delete(&root.refs, &root.objects, &root.events, name, root.mode())?;
+ let _ = signer; // signing material is not needed for a deletion transition.
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents members revoke`/`unrevoke`: flip `username`'s
+/// [`MemberState`] without deleting the record (`model.member-revocation`).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `username` has no member ref; otherwise see
+/// [`add`].
+pub fn set_revoked(
+ root: &LocalRoot,
+ username: &str,
+ revoked: bool,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let name = namespace::member_ref(&MemberId::new(username))?;
+ let Some(tip) = root.refs.get(name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("member {username}"),
+ });
+ };
+ let mut member = read_member(root, tip)?.ok_or_else(|| Error::NotFound {
+ what: format!("member {username}"),
+ })?;
+ member.state = if revoked {
+ MemberState::Revoked
+ } else {
+ MemberState::Active
+ };
+ let identity = Identity {
+ actor: actor(&signer),
+ signer: &signer,
+ };
+ let verb = if revoked { "Revoke" } else { "Unrevoke" };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ name,
+ &member,
+ &identity,
+ &format!("{verb} {username}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, Some(tip))?;
+ Ok(())
+}
+
+/// `git ents members check`: whether `key` (or the resolved signing key)
+/// names an active member, and which username.
+///
+/// # Errors
+///
+/// Propagates a signing-key or ref-store read failure.
+pub fn check(
+ root: &LocalRoot,
+ key: Option<std::path::PathBuf>,
+) -> Result<Option<(String, MemberState)>> {
+ let signer = signer(root, key)?;
+ let pubkey = signer.public_openssh();
+ for (username, member) in list(root)? {
+ if member.key == pubkey {
+ return Ok(Some((username, member.state)));
+ }
+ }
+ Ok(None)
+}
+
+fn read_member(root: &LocalRoot, tip: gix_hash::ObjectId) -> Result<Option<Member>> {
+ let tree = crate::commands::commit_tree(&root.objects, tip)?;
+ Ok(facet_git_tree::deserialize::<Member>(&tree, &root.objects).ok())
+}
crates/git-ents/src/commands/mod.rs
@@ -1,0 +1,116 @@
+//! One module per `git ents` subcommand family — [`crate::cli`]'s
+//! definitions given a body. Each function here is a thin caller into a
+//! library crate: [`crate::exe`] dispatches to these, never the other way
+//! around, so the same logic is callable from a test without a terminal.
+#![expect(
+ clippy::let_underscore_must_use,
+ reason = "rendering an advisory-gate verdict to a writer is best-effort; a broken pipe here \
+ is not actionable"
+)]
+
+pub mod account;
+pub mod comment;
+pub mod effect;
+pub mod inbox;
+pub mod members;
+pub mod redact;
+pub mod setup;
+pub mod toolchain;
+
+use std::io::Write;
+use std::path::PathBuf;
+
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+use crate::sign::Signer;
+
+/// The tree of the commit at `oid` — every command that reads back a typed
+/// entity needs this, and neither `ents_receive` nor `ents_effect` exports
+/// their own copy publicly, so it is a small, shared utility here rather
+/// than duplicated per command module.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `oid` is missing or not a commit.
+pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: oid.to_string(),
+ })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::NotFound {
+ what: oid.to_string(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind())
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ Ok(commit.tree())
+}
+
+/// Resolve `--key` (or the repository's `user.signingkey`, or the default
+/// `~/.ssh/id_ed25519`) into a loaded [`Signer`] — the one place every
+/// write-side command turns an optional key path into a usable identity.
+///
+/// # Errors
+///
+/// See [`crate::sign::resolve_key_path`] and [`Signer::load`].
+pub fn signer(root: &LocalRoot, key: Option<PathBuf>) -> Result<Signer> {
+ let repo = gix::open(&root.path)?;
+ let path = crate::sign::resolve_key_path(&repo, key.as_deref())?;
+ Signer::load(&path)
+}
+
+/// The commit author/committer signature every mutation this CLI produces
+/// carries: the current wall-clock time, under a fixed name/email derived
+/// from the signer's own key fingerprint (this crate never depends on
+/// `user.name`/`user.email` being configured, mirroring
+/// `gix-ref-store`'s own reflog-identity rationale).
+#[must_use]
+pub fn actor(signer: &Signer) -> gix::actor::Signature {
+ let seconds = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
+ .unwrap_or_default();
+ gix::actor::Signature {
+ name: "git-ents".into(),
+ email: format!("{}@git-ents.local", short_fingerprint(signer)).into(),
+ time: gix::date::Time { seconds, offset: 0 },
+ }
+}
+
+fn short_fingerprint(signer: &Signer) -> String {
+ let key = signer.public_openssh();
+ let hex = key
+ .split_whitespace()
+ .nth(1)
+ .unwrap_or(&key)
+ .chars()
+ .take(12)
+ .collect::<String>();
+ if hex.is_empty() {
+ "member".to_owned()
+ } else {
+ hex
+ }
+}
+
+/// Print `verdicts` (`gate.verdict-reason`) for a command that succeeded
+/// under the advisory gate but still wants to surface a non-passing
+/// verdict to the user, mirroring `sync.local-advisory`: a failing verdict
+/// here is information, never a block.
+pub fn render_verdicts(
+ out: &mut impl Write,
+ verdicts: &[(gix::refs::FullName, ents_gate::Verdict)],
+) {
+ for (name, verdict) in verdicts {
+ if let ents_gate::Verdict::Fail(refusal) = verdict {
+ let _ = writeln!(out, "warning: {name}: {refusal}", name = name.as_bstr());
+ }
+ }
+}
crates/git-ents/src/commands/redact.rs
@@ -1,0 +1,54 @@
+//! `git ents redact`: record that an object was redacted
+//! (`model.redaction`), refusing any future push that would refill it
+//! (`receive.redaction-ingest`).
+
+use ents_model::{Redaction, namespace};
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::{Identity, outcome_to_result, propose_entity};
+use crate::root::LocalRoot;
+
+/// Run `git ents redact <oid> --reason ...`.
+///
+/// The record lands at `refs/meta/redactions/<id>`; the gate's default
+/// namespace-authorization arm requires admin-registered provenance for
+/// this namespace, so a non-admin signer is refused here exactly as any
+/// other call site would refuse it (`gate.call-sites`,
+/// `receive.redaction-admin-only`).
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if `oid` does not parse as an object id;
+/// otherwise see [`crate::mutate::outcome_to_result`].
+pub fn run(
+ root: &LocalRoot,
+ oid: &str,
+ reason: String,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let target: gix_hash::ObjectId = oid
+ .parse()
+ .map_err(|_source| Error::InvalidArgument(format!("not an object id: {oid}")))?;
+ let redaction = Redaction::new(target, reason);
+ let id = target.to_string();
+ let ref_name = namespace::redaction_ref(&id)?;
+
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ signer: &signer,
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ ref_name,
+ &redaction,
+ &identity,
+ &format!("Redact {id}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
crates/git-ents/src/commands/setup.rs
@@ -1,0 +1,118 @@
+//! `git ents setup`: resolve or generate a signing key, record it as this
+//! repository's `user.signingkey` with `gpg.format=ssh`, and set
+//! `receive.denyCurrentBranch=updateInstead` (`roots.worktree-update`).
+//!
+//! `receive.denyCurrentBranch=updateInstead` is the integration-test
+//! harness edge case `roots.worktree-update` names: it lets an external
+//! push land on this repository's checked-out branch and still update the
+//! working tree, which is not how a normal git remote behaves and is never
+//! needed for `refs/meta/*` traffic (which never touches a worktree at
+//! all).
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use rand_core::OsRng;
+use ssh_key::{Algorithm, LineEnding, PrivateKey};
+
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+use crate::sign::Signer;
+
+/// Run `git ents setup` against `root`: resolve `key`, generating a new
+/// `~/.ssh/id_ed25519` if neither `key` nor `user.signingkey` resolves to
+/// an existing file, then write `user.signingkey`, `gpg.format=ssh`, and
+/// `receive.denyCurrentBranch=updateInstead` to the repository's own
+/// (local) config.
+///
+/// # Errors
+///
+/// [`Error::BadSigningKey`] if a given or configured key cannot be loaded;
+/// [`Error::Io`] if generating or writing a new key fails; propagates a
+/// config-write failure.
+pub fn run(root: &LocalRoot, key: Option<PathBuf>) -> Result<PathBuf> {
+ let repo = gix::open(&root.path)?;
+ let resolved = match crate::sign::resolve_key_path(&repo, key.as_deref()) {
+ Ok(path) if path.exists() => path,
+ Ok(path) => generate_key(&path)?,
+ Err(Error::NoSigningKey) => {
+ let default = default_key_path()?;
+ generate_key(&default)?
+ }
+ Err(other) => return Err(other),
+ };
+ // Confirm the resolved key actually loads before recording it.
+ Signer::load(&resolved)?;
+
+ // `gix`'s own config-snapshot API (`config_snapshot_mut`) has no
+ // file-persistence path at all: `SnapshotMut::commit` only updates the
+ // in-memory resolved view this `Repository` handle holds, never
+ // `.git/config` on disk (confirmed empirically — a value written that
+ // way is invisible to a subsequent, separate `git config` read).
+ // Writing durable local config is therefore delegated to `git config`
+ // itself here, same as `pre-redo`'s own client setup did; it is not
+ // part of the ref/object CAS discipline the rest of this crate is
+ // strict about (`arch.loose-cas-discipline` governs refs, not plain
+ // config values).
+ let path_str = resolved.to_string_lossy().into_owned();
+ for (key, value) in [
+ ("user.signingkey", path_str.as_str()),
+ ("gpg.format", "ssh"),
+ ("receive.denyCurrentBranch", "updateInstead"),
+ ] {
+ set_local_config(&root.path, key, value)?;
+ }
+
+ Ok(resolved)
+}
+
+/// Set `key` to `value` in `repo_path`'s own local config via `git config`.
+fn set_local_config(repo_path: &Path, key: &str, value: &str) -> Result<()> {
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(repo_path)
+ .args(["config", "--local", key, value])
+ .output()
+ .map_err(|source| Error::Io {
+ path: repo_path.to_owned(),
+ source,
+ })?;
+ if !output.status.success() {
+ return Err(Error::BadSigningKey {
+ path: repo_path.to_owned(),
+ detail: format!(
+ "git config --local {key} {value} failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ ),
+ });
+ }
+ Ok(())
+}
+
+/// Generate a fresh, unencrypted ed25519 key at `path` (creating parent
+/// directories as needed) and return `path` unchanged.
+fn generate_key(path: &Path) -> Result<PathBuf> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|source| Error::Io {
+ path: parent.to_owned(),
+ source,
+ })?;
+ }
+ let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).map_err(|source| {
+ Error::BadSigningKey {
+ path: path.to_owned(),
+ detail: source.to_string(),
+ }
+ })?;
+ key.write_openssh_file(path, LineEnding::LF)
+ .map_err(|source| Error::BadSigningKey {
+ path: path.to_owned(),
+ detail: source.to_string(),
+ })?;
+ Ok(path.to_owned())
+}
+
+fn default_key_path() -> Result<PathBuf> {
+ let home = std::env::var_os("HOME").ok_or(Error::NoSigningKey)?;
+ Ok(PathBuf::from(home).join(".ssh").join("id_ed25519"))
+}
crates/git-ents/src/commands/toolchain.rs
@@ -1,0 +1,169 @@
+//! `git ents toolchain`: import a local `bin/` directory as an embedded
+//! toolchain manifest, view its provenance, and show its import history
+//! (`model.toolchain`, `effect.toolchains`).
+//!
+//! Only [`ents_effect::Recipe::Embedded`] is wired here (`--from` recipes —
+//! `rustup`, `sccache`, `url` — are `pre-redo` extras this phase's spec
+//! does not name; deferred, see this crate's final report).
+
+use std::path::Path;
+
+use ents_effect::Recipe;
+use ents_model::{Toolchain, namespace};
+use gix_object::Tree;
+use gix_object::tree::{Entry, EntryKind};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::{Identity, outcome_to_result, propose_entity};
+use crate::root::LocalRoot;
+
+/// `git ents toolchain import`: embed `bin` whole as toolchain `name`.
+///
+/// # Errors
+///
+/// [`Error::Io`] if `bin` cannot be walked; otherwise see
+/// [`crate::mutate::outcome_to_result`].
+pub fn import(
+ root: &LocalRoot,
+ name: &str,
+ bin: &Path,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let tree = write_dir_as_tree(bin, &root.objects)?;
+ let recipe = Recipe::Embedded { tree };
+ let toolchain = Toolchain {
+ name: name.to_owned(),
+ recipe: recipe.render(),
+ };
+ let signer = signer(root, key)?;
+ let ref_name = namespace::toolchain_ref(name)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ signer: &signer,
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ ref_name,
+ &toolchain,
+ &identity,
+ &format!("Import toolchain {name}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents toolchain view`: the toolchain's recorded recipe.
+///
+/// # Errors
+///
+/// Propagates [`ents_effect::toolchain::resolve`]'s own errors.
+pub fn view(root: &LocalRoot, name: &str) -> Result<(Toolchain, Recipe)> {
+ Ok(ents_effect::toolchain::resolve(
+ &root.refs,
+ &root.objects,
+ name,
+ )?)
+}
+
+/// `git ents toolchain log`: every past import, newest first — the ref's
+/// own commit log.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `name` has no toolchain ref.
+pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<gix_hash::ObjectId>> {
+ let ref_name = namespace::toolchain_ref(name)?;
+ let repo = gix::open(&root.path)?;
+ let Some(tip) = root.refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("toolchain {name}"),
+ });
+ };
+ let mut out = Vec::new();
+ let mut next = Some(tip);
+ while let Some(oid) = next {
+ out.push(oid);
+ let commit = repo
+ .find_object(oid)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .try_into_commit()
+ .map_err(|_source| Error::InvalidArgument(format!("{oid} is not a commit")))?;
+ next = commit.parent_ids().next().map(|id| id.detach());
+ }
+ Ok(out)
+}
+
+/// Recursively write `dir`'s contents into `objects` as a tree, preserving
+/// the executable bit and recursing into subdirectories — the inverse of
+/// `ents_effect::materialize::checkout`. Symlinks and anything that is not
+/// a plain file or directory are refused (`anchor.retention`-style
+/// defensiveness: a toolchain import should never silently embed something
+/// that cannot round-trip through a tree).
+///
+/// # Errors
+///
+/// [`Error::Io`] on a read failure or an unsupported entry kind.
+fn write_dir_as_tree(dir: &Path, objects: &impl gix_object::Write) -> Result<gix_hash::ObjectId> {
+ let mut entries = Vec::new();
+ let read = std::fs::read_dir(dir).map_err(|source| Error::Io {
+ path: dir.to_owned(),
+ source,
+ })?;
+ for item in read {
+ let item = item.map_err(|source| Error::Io {
+ path: dir.to_owned(),
+ source,
+ })?;
+ let file_type = item.file_type().map_err(|source| Error::Io {
+ path: item.path(),
+ source,
+ })?;
+ let filename = item.file_name().to_string_lossy().into_owned();
+ let (mode, oid) = if file_type.is_dir() {
+ (EntryKind::Tree, write_dir_as_tree(&item.path(), objects)?)
+ } else if file_type.is_file() {
+ let bytes = std::fs::read(item.path()).map_err(|source| Error::Io {
+ path: item.path(),
+ source,
+ })?;
+ let executable = is_executable(&item.path());
+ let kind = if executable {
+ EntryKind::BlobExecutable
+ } else {
+ EntryKind::Blob
+ };
+ let oid = objects.write_buf(gix_object::Kind::Blob, &bytes)?;
+ (kind, oid)
+ } else {
+ return Err(Error::Io {
+ path: item.path(),
+ source: std::io::Error::other("unsupported entry (symlink or special file)"),
+ });
+ };
+ entries.push(Entry {
+ mode: mode.into(),
+ filename: filename.into(),
+ oid,
+ });
+ }
+ entries.sort();
+ Ok(objects.write(&Tree { entries })?)
+}
+
+#[cfg(unix)]
+fn is_executable(path: &Path) -> bool {
+ use std::os::unix::fs::PermissionsExt as _;
+ std::fs::metadata(path)
+ .map(|meta| meta.permissions().mode() & 0o111 != 0)
+ .unwrap_or(false)
+}
+
+#[cfg(not(unix))]
+fn is_executable(_path: &Path) -> bool {
+ false
+}
crates/git-ents/src/error.rs
@@ -1,0 +1,163 @@
+//! The porcelain-wide error type: every subcommand's failure, rendered for
+//! a terminal.
+
+use std::path::PathBuf;
+
+/// Every way a `git-ents` subcommand can fail.
+///
+/// Each variant documents when it occurs and what the user should do —
+/// this is the only layer that renders a failure for a human, so the
+/// detail belongs here rather than in a library crate's own error type.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The current directory is not inside a git repository, or the
+ /// discovered repository has no `.git` directory `git-ents` can open.
+ /// Run the command inside a git repository.
+ #[error("not a git repository (or any parent up to mount point): {path}")]
+ NotARepo {
+ /// The directory `git-ents` started looking from.
+ path: PathBuf,
+ },
+
+ /// No signing key could be resolved: `--key` was not given,
+ /// `user.signingkey` is unset, and no default key exists at
+ /// `~/.ssh/id_ed25519`. Run `git ents setup` first.
+ #[error("no signing key configured; run `git ents setup` or pass --key")]
+ NoSigningKey,
+
+ /// The signing key at `path` could not be read as an OpenSSH private
+ /// key, or is passphrase-protected (unsupported in this phase: use an
+ /// unencrypted key, or load one via `ssh-agent` in a future phase).
+ #[error("cannot use signing key at {path}: {detail}")]
+ BadSigningKey {
+ /// The key file that failed to load.
+ path: PathBuf,
+ /// What went wrong.
+ detail: String,
+ },
+
+ /// The gate refused the proposed mutation (`gate.verdict-reason`): the
+ /// refusal's own rendering names the rule and offers the inbox
+ /// alternative when one applies.
+ #[error("rejected: {0}")]
+ Refused(String),
+
+ /// `receive` rejected the batch as a stale compare-and-swap: another
+ /// writer moved a ref between read and write. Retry the command.
+ #[error("rejected: {name} changed concurrently, retry")]
+ Stale {
+ /// The ref whose precondition was stale.
+ name: String,
+ },
+
+ /// A previously redacted object would have been refilled by this
+ /// mutation (`receive.redaction-ingest`); the mutation is refused.
+ #[error("refused: object {oid} was redacted and cannot be refilled")]
+ Redacted {
+ /// The redacted object id.
+ oid: gix_hash::ObjectId,
+ },
+
+ /// The named entity (member, effect, toolchain, comment, inbox entry)
+ /// does not exist.
+ #[error("not found: {what}")]
+ NotFound {
+ /// What was being looked up.
+ what: String,
+ },
+
+ /// A local (non-git, non-gate) I/O failure: reading or writing a file
+ /// outside the object database.
+ #[error("io error at {path}: {source}")]
+ Io {
+ /// The path being read or written.
+ path: PathBuf,
+ /// The underlying I/O failure.
+ #[source]
+ source: std::io::Error,
+ },
+
+ /// A malformed command-line argument that passed `figue`'s own parsing
+ /// but fails a semantic check this crate makes (an invalid line range,
+ /// an unparsable oid, ...).
+ #[error("invalid argument: {0}")]
+ InvalidArgument(String),
+
+ /// Opening or reading the local git repository failed. Boxed (like the
+ /// other large variants below): `gix::open::Error` is large enough on
+ /// its own to trip `clippy::result_large_err` for every fallible
+ /// function in this crate if stored inline, the same reasoning
+ /// `ents-effect`'s own error type documents for its boxed
+ /// `ents_receive::Error` variant.
+ #[error(transparent)]
+ Repo(Box<gix::open::Error>),
+
+ /// A `gix-ref-store` failure: reading or writing a ref.
+ #[error(transparent)]
+ Refs(#[from] gix_ref_store::Error),
+
+ /// An `ents-gate` failure: the gate itself could not reach a verdict
+ /// (a store or object read failed), distinct from a reached refusal.
+ #[error(transparent)]
+ Gate(#[from] ents_gate::Error),
+
+ /// An `ents-receive` failure: `receive` itself could not reach an
+ /// outcome. Boxed; see [`Error::Repo`]'s own doc.
+ #[error(transparent)]
+ Receive(Box<ents_receive::Error>),
+
+ /// An `ents-effect` failure: toolchain resolution, materialization, or
+ /// the executor itself. Boxed; see [`Error::Repo`]'s own doc.
+ #[error(transparent)]
+ Effect(Box<ents_effect::Error>),
+
+ /// An `ents-anchor` failure: capturing or projecting a code anchor.
+ #[error(transparent)]
+ Anchor(#[from] ents_anchor::Error),
+
+ /// An `ents-sync` failure: pre-flight, routing, or merge. Boxed; see
+ /// [`Error::Repo`]'s own doc.
+ #[error(transparent)]
+ Sync(Box<ents_sync::Error>),
+
+ /// An `ents-model` failure: building or validating a refname or typed
+ /// tree.
+ #[error(transparent)]
+ Model(#[from] ents_model::Error),
+
+ /// A `facet-git-tree` (de)serialization failure.
+ #[error(transparent)]
+ Tree(#[from] facet_git_tree::Error),
+
+ /// A raw object-store write failed (building a toolchain import's tree,
+ /// or a mutation commit).
+ #[error(transparent)]
+ ObjectWrite(#[from] gix_object::write::Error),
+}
+
+impl From<gix::open::Error> for Error {
+ fn from(source: gix::open::Error) -> Self {
+ Self::Repo(Box::new(source))
+ }
+}
+
+impl From<ents_receive::Error> for Error {
+ fn from(source: ents_receive::Error) -> Self {
+ Self::Receive(Box::new(source))
+ }
+}
+
+impl From<ents_effect::Error> for Error {
+ fn from(source: ents_effect::Error) -> Self {
+ Self::Effect(Box::new(source))
+ }
+}
+
+impl From<ents_sync::Error> for Error {
+ fn from(source: ents_sync::Error) -> Self {
+ Self::Sync(Box::new(source))
+ }
+}
+
+/// This crate's `Result` alias.
+pub type Result<T> = std::result::Result<T, Error>;
crates/git-ents/src/exe.rs
@@ -1,0 +1,242 @@
+//! `git ents`'s dispatch: the only place a [`crate::cli::Top`] variant is
+//! interpreted. Every branch is a thin call into [`crate::commands`]; no
+//! business logic lives here.
+#![expect(
+ clippy::let_underscore_must_use,
+ reason = "porcelain output to a writer (stdout in practice) is best-effort; a broken pipe \
+ here is not actionable and every write is one-shot, not chained"
+)]
+
+use crate::cli::{
+ AccountAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, MembersAction,
+ ToolchainAction, Top,
+};
+use crate::commands;
+use crate::error::Result;
+use crate::root::{HostedRoot, LocalRoot};
+
+/// Run `cli` against the repository discovered from the current
+/// directory, writing porcelain output to `out`.
+///
+/// # Errors
+///
+/// Any [`crate::Error`] the dispatched command reports.
+pub fn run(cli: Cli, out: &mut impl std::io::Write) -> Result<()> {
+ match cli.command {
+ Top::Setup { key } => {
+ let root = LocalRoot::discover(".")?;
+ let path = commands::setup::run(&root, key)?;
+ let _ = writeln!(out, "signing key: {}", path.display());
+ Ok(())
+ }
+ Top::Members { action } => run_members(action, out),
+ Top::Account { action } => run_account(action, out),
+ Top::Effect { action } => run_effect(action, out),
+ Top::Toolchain { action } => run_toolchain(action, out),
+ Top::Comment { action } => run_comment(action, out),
+ Top::Inbox { action } => run_inbox(action, out),
+ Top::Redact { oid, reason, key } => {
+ let root = LocalRoot::discover(".")?;
+ commands::redact::run(&root, &oid, reason, key)?;
+ let _ = writeln!(out, "redacted {oid}");
+ Ok(())
+ }
+ Top::Hook { action } => run_hook(action, out),
+ }
+}
+
+fn run_members(action: MembersAction, out: &mut impl std::io::Write) -> Result<()> {
+ let root = LocalRoot::discover(".")?;
+ match action {
+ MembersAction::List => {
+ for (username, member) in commands::members::list(&root)? {
+ let _ = writeln!(
+ out,
+ "{username}\t{:?}\t{:?}",
+ member.state, member.provenance
+ );
+ }
+ }
+ MembersAction::Add {
+ username,
+ pubkey,
+ key,
+ } => {
+ commands::members::add(&root, &username, pubkey, key)?;
+ let _ = writeln!(out, "enrolled {username}");
+ }
+ MembersAction::Remove { username, key } => {
+ commands::members::remove(&root, &username, key)?;
+ let _ = writeln!(out, "removed {username}");
+ }
+ MembersAction::Revoke { username, key } => {
+ commands::members::set_revoked(&root, &username, true, key)?;
+ let _ = writeln!(out, "revoked {username}");
+ }
+ MembersAction::Unrevoke { username, key } => {
+ commands::members::set_revoked(&root, &username, false, key)?;
+ let _ = writeln!(out, "unrevoked {username}");
+ }
+ MembersAction::Check { key } => match commands::members::check(&root, key)? {
+ Some((username, state)) => {
+ let _ = writeln!(out, "{username}\t{state:?}");
+ }
+ None => {
+ let _ = writeln!(out, "not a member");
+ }
+ },
+ }
+ Ok(())
+}
+
+fn run_account(action: AccountAction, out: &mut impl std::io::Write) -> Result<()> {
+ let root = LocalRoot::discover(".")?;
+ match action {
+ AccountAction::Create { member, login, key } => {
+ commands::account::create(&root, member, login, key)?;
+ let _ = writeln!(out, "account created");
+ }
+ }
+ Ok(())
+}
+
+fn run_effect(action: EffectAction, out: &mut impl std::io::Write) -> Result<()> {
+ let root = LocalRoot::discover(".")?;
+ match action {
+ EffectAction::List => {
+ for (name, effect) in commands::effect::list(&root)? {
+ let _ = writeln!(out, "{name}\t{}", effect.trigger);
+ }
+ }
+ EffectAction::Show { name, at } => {
+ let (effect, status) = commands::effect::show(&root, &name, at)?;
+ let _ = writeln!(out, "trigger: {}", effect.trigger);
+ let _ = writeln!(out, "run: {}", effect.run);
+ let _ = writeln!(out, "result: {status:?}");
+ }
+ EffectAction::Add {
+ name,
+ on,
+ run,
+ toolchain,
+ key,
+ } => {
+ commands::effect::add(&root, &name, on, run, toolchain, key)?;
+ let _ = writeln!(out, "defined {name}");
+ }
+ EffectAction::Run { name, at, key } => {
+ let executor = ents_effect::DockerExecutor;
+ let outcomes = commands::effect::run(&root, &name, at, key, &executor)?;
+ for (oid, outcome) in outcomes {
+ let _ = writeln!(out, "{oid}\t{:?}", outcome.result);
+ }
+ }
+ EffectAction::Log { name } => {
+ for (oid, status) in commands::effect::log(&root, &name)? {
+ let _ = writeln!(out, "{oid}\t{status:?}");
+ }
+ }
+ }
+ Ok(())
+}
+
+fn run_toolchain(action: ToolchainAction, out: &mut impl std::io::Write) -> Result<()> {
+ let root = LocalRoot::discover(".")?;
+ match action {
+ ToolchainAction::Import { name, bin, key } => {
+ commands::toolchain::import(&root, &name, &bin, key)?;
+ let _ = writeln!(out, "imported {name}");
+ }
+ ToolchainAction::View { name } => {
+ let (toolchain, recipe) = commands::toolchain::view(&root, &name)?;
+ let _ = writeln!(out, "name: {}", toolchain.name);
+ let _ = writeln!(out, "recipe: {recipe:?}");
+ }
+ ToolchainAction::Log { name } => {
+ for oid in commands::toolchain::log(&root, &name)? {
+ let _ = writeln!(out, "{oid}");
+ }
+ }
+ }
+ Ok(())
+}
+
+fn run_comment(action: CommentAction, out: &mut impl std::io::Write) -> Result<()> {
+ let root = LocalRoot::discover(".")?;
+ match action {
+ CommentAction::Add {
+ path,
+ body,
+ lines,
+ rev,
+ key,
+ } => {
+ let id = commands::comment::add(&root, &path, body, lines, &rev, key)?;
+ let _ = writeln!(out, "commented {id}");
+ }
+ CommentAction::Show { id, rev } => {
+ let (comment, anchor, projection) = commands::comment::show(&root, &id, &rev)?;
+ let _ = writeln!(out, "path: {}", anchor.path);
+ let _ = writeln!(out, "projection: {projection:?}");
+ let _ = writeln!(out, "body: {}", comment.body);
+ }
+ }
+ Ok(())
+}
+
+fn run_inbox(action: InboxAction, out: &mut impl std::io::Write) -> Result<()> {
+ let root = LocalRoot::discover(".")?;
+ match action {
+ InboxAction::List => {
+ for entry in commands::inbox::list(&root)? {
+ let _ = writeln!(out, "{entry}");
+ }
+ }
+ InboxAction::Adopt { entry, key } => {
+ commands::inbox::adopt(&root, &entry, key)?;
+ let _ = writeln!(out, "adopted {entry}");
+ }
+ }
+ Ok(())
+}
+
+fn run_hook(action: HookAction, out: &mut impl std::io::Write) -> Result<()> {
+ let root = HostedRoot::open(".")?;
+ match action {
+ HookAction::PreReceive => {
+ let stdin = std::io::stdin();
+ crate::hook::pre_receive(&root, stdin.lock(), out)
+ }
+ HookAction::PostReceive => {
+ // Nothing to do: skip resolving a worker signing key and
+ // executor entirely rather than fail a repository that has
+ // not configured a hosted worker identity yet but also has no
+ // effects defined (the common case for a brand-new
+ // repository's very first pushes).
+ if root.events.pending().is_empty() {
+ let _ = writeln!(out, "ran 0 effect(s)");
+ return Ok(());
+ }
+ let scratch = tempfile::tempdir().map_err(|source| crate::Error::Io {
+ path: root.path.clone(),
+ source,
+ })?;
+ let cache = tempfile::tempdir().map_err(|source| crate::Error::Io {
+ path: root.path.clone(),
+ source,
+ })?;
+ let repo = gix::open(&root.path)?;
+ let key_path = crate::sign::resolve_key_path(&repo, None)?;
+ let signer = crate::sign::Signer::load(&key_path)?;
+ let executor = ents_effect::SpriteExecutor::new("git-ents-hosted-worker");
+ let ran =
+ crate::hook::post_receive(&root, &executor, scratch.path(), cache.path(), &signer)?;
+ let _ = writeln!(out, "ran {ran} effect(s)");
+ Ok(())
+ }
+ HookAction::Reconcile => {
+ let _ = writeln!(out, "reconciled: {} pending", root.events.pending().len());
+ Ok(())
+ }
+ }
+}
crates/git-ents/src/hook.rs
@@ -1,0 +1,297 @@
+//! The single-node hosted root's git-hook plumbing.
+//!
+//! The development plan's phase-6 row doubles `git-ents` as `git.ents.cloud`:
+//! loose refs and a real odb on a Fly volume, served behind *git's own*
+//! `receive-pack` — "the same stock-git transport Phase 0 bootstraps, now
+//! invoking `receive()` from a hook" — with an in-memory `EventSink`, a
+//! boot-time reconciliation scan, and the Sprite executor.
+//!
+//! # Why the ref write itself is not `ents_receive::receive`'s
+//!
+//! `receive.unit`'s own doc says every mutation frontend, "the CLI, the
+//! local UI, a hosted smart-HTTP hook", must call `receive` in-process,
+//! with only the trait implementations differing. That is true once the
+//! store itself is swapped out from under git (`git-ents-server`, phase 8,
+//! `gix-receive` replacing `receive-pack` entirely because a Postgres
+//! `RefStore` leaves no on-disk repo for stock git to act on). Phase 6 is
+//! explicitly *not* that case: this deployment keeps a real on-disk repo
+//! and lets git's own `receive-pack` perform the actual object unpack and
+//! ref update — "stock git wearing the same gate everything else runs, not
+//! a bespoke protocol" (`docs/development-plan.adoc`).
+//!
+//! Concretely: if `pre_receive` here called `ents_receive::receive`
+//! (writing the ref through *our own* `LooseRefStore::transaction`) and
+//! then exited zero, git's `receive-pack` would still go on to perform its
+//! own internal ref update afterward, expecting the ref to still hold the
+//! *old* value it read before the hook ran — but we would have already
+//! moved it. That double-write is a real race, not a hypothetical one, so
+//! this module deliberately does not use `receive`'s bundled write path
+//! for this deployment shape. Instead:
+//!
+//! - [`pre_receive`] calls the *identical* [`ents_gate::verify`] every
+//! other call site uses (`gate.call-sites`) for each proposed
+//! transition, and lets git's native `pre-receive` whole-push-rejection
+//! semantics implement `gate.mandatory-hosted` for free: refusing any
+//! one transition (nonzero exit, reasons on stderr) aborts the entire
+//! push before git writes anything, exactly what `Mode::Mandatory`
+//! means. On a pass, this hook writes nothing itself — git's own
+//! `receive-pack` performs the actual ref update once the hook exits
+//! zero.
+//! - [`post_receive`] runs after git has already updated every ref: it
+//! opens a fresh [`crate::root::HostedRoot`] (whose `open` itself runs
+//! the boot-time [`ents_receive::reconcile`] scan,
+//! `receive.reconstructible`) and drains whatever is now outstanding,
+//! running each via the Sprite executor and writing results back
+//! through [`ents_effect::run::run_one`] — an ordinary `receive` client
+//! for the *results* ref, which never conflicts with a branch ref git
+//! itself just wrote.
+//!
+//! # Object visibility during `pre-receive` (quarantine)
+//!
+//! `receive.object-access`'s own doc flags "never a git hook's quarantine
+//! directory, until its transaction commits" as the composition root's
+//! responsibility. Git runs `pre-receive` with new objects visible only
+//! through `GIT_OBJECT_DIRECTORY` (the quarantine) plus
+//! `GIT_ALTERNATE_OBJECT_DIRECTORIES` (the real odb) until the push is
+//! accepted; [`crate::root::HostedRoot::open`] honors
+//! `GIT_OBJECT_DIRECTORY` when the environment sets it (which git does for
+//! `pre-receive`, and does not for `post-receive`, whose objects are by
+//! then no longer quarantined), and `gix_odb::at` follows the quarantine
+//! directory's own `info/alternates` back to the real odb transparently.
+//!
+//! # No separate daemon
+//!
+//! There is deliberately no long-lived worker process in this phase: each
+//! hook invocation is a fresh, short-lived process that reconciles fresh
+//! from repository state (`receive.reconstructible`'s own guarantee) —
+//! "push-triggered" (the deployment table's own word for hosted execution)
+//! without any inter-process queue at all. The literal "in-memory
+//! `EventSink`" the development plan names lives for exactly one hook
+//! invocation's lifetime; nothing about `receive.reconstructible`'s
+//! contract requires it to live longer, and the phase-6 exit criterion —
+//! obligations regenerate correctly after a `kill -9` of the in-memory
+//! queue — is exactly what happens between every pair of pushes, verified
+//! directly in this crate's tests by dropping a `HostedRoot` and opening a
+//! fresh one against the same on-disk state.
+
+#![expect(
+ clippy::let_underscore_must_use,
+ reason = "rejection reasons written to a hook's stderr are best-effort; a broken pipe here \
+ is not actionable"
+)]
+
+use std::io::{BufRead, Read, Write};
+
+use ents_effect::run::run_one;
+use ents_model::Effect;
+use ents_query::Query;
+use ents_receive::Mode;
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+use gix_ref_store::RefStoreRead;
+
+use crate::error::{Error, Result};
+use crate::root::HostedRoot;
+use crate::sign::Signer;
+
+/// One proposed transition, as read from git's `pre-receive` stdin: one
+/// `<old-oid> <new-oid> <refname>` line per ref in the push.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StdinTransition {
+ /// The refname being updated.
+ pub name: FullName,
+ /// The proposed new tip, or `None` for a deletion.
+ pub new: Option<ObjectId>,
+}
+
+/// Parse git's `pre-receive`/`post-receive` stdin format: one
+/// `<old> <new> <refname>` line per updated ref.
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] for a line that does not have exactly three
+/// whitespace-separated fields, an unparsable oid, or an invalid refname.
+pub fn parse_stdin_transitions(input: impl BufRead) -> Result<Vec<StdinTransition>> {
+ let mut out = Vec::new();
+ for line in input.lines() {
+ let line = line.map_err(|source| Error::Io {
+ path: "<stdin>".into(),
+ source,
+ })?;
+ let mut fields = line.split_whitespace();
+ let (Some(_old), Some(new), Some(name)) = (fields.next(), fields.next(), fields.next())
+ else {
+ return Err(Error::InvalidArgument(format!(
+ "malformed pre-receive line: {line:?}"
+ )));
+ };
+ let new: ObjectId = new
+ .parse()
+ .map_err(|_source| Error::InvalidArgument(format!("bad new oid: {new}")))?;
+ let name: FullName = name
+ .to_owned()
+ .try_into()
+ .map_err(|_source| Error::InvalidArgument(format!("bad refname: {name}")))?;
+ let new = (!new.is_null()).then_some(new);
+ out.push(StdinTransition { name, new });
+ }
+ Ok(out)
+}
+
+/// Run as git's own `pre-receive` hook (see this module's own doc for the
+/// design). Reads transitions from `input`, evaluates the gate against
+/// each, and refuses the whole push (returns `Err`) if any fails under the
+/// mandatory gate. Rejection reasons are written to `report`.
+///
+/// # Errors
+///
+/// [`Error::Refused`] if any transition's verdict fails; propagates a
+/// parse or gate-evaluation failure otherwise.
+pub fn pre_receive(root: &HostedRoot, input: impl BufRead, mut report: impl Write) -> Result<()> {
+ let transitions = parse_stdin_transitions(input)?;
+ let mut failures = Vec::new();
+ for transition in &transitions {
+ let verdict = ents_gate::verify(
+ &root.refs,
+ &root.objects,
+ &ents_gate::Update {
+ name: transition.name.clone(),
+ new: transition.new,
+ },
+ )?;
+ if let ents_gate::Verdict::Fail(refusal) = verdict {
+ let _ = writeln!(report, "refused: {refusal}");
+ failures.push(refusal.to_string());
+ }
+ }
+ if failures.is_empty() {
+ Ok(())
+ } else {
+ Err(Error::Refused(failures.join("; ")))
+ }
+}
+
+/// Run as git's own `post-receive` hook: reconcile outstanding effect
+/// obligations and run every one of them via `executor`, writing each
+/// result back through the ordinary `receive` path
+/// (`effect.results-writeback`).
+///
+/// `root` must already have run its boot-time reconciliation scan (true of
+/// any [`HostedRoot::open`]); this function additionally re-reconciles once
+/// more before draining, so a push that itself just made new commits
+/// outstanding is caught without waiting for the *next* process's boot.
+///
+/// # Errors
+///
+/// Propagates a reconciliation, toolchain-resolution, checkout, executor,
+/// or write-back failure. A per-commit failure stops the drain at that
+/// commit (mirrors [`ents_effect::run::run_effect`]'s own contract) —
+/// results already written for earlier commits in this pass stay durable.
+pub fn post_receive(
+ root: &HostedRoot,
+ executor: &dyn ents_effect::Executor,
+ scratch: &std::path::Path,
+ toolchain_cache: &std::path::Path,
+ signer: &Signer,
+) -> Result<usize> {
+ ents_receive::reconcile(&root.refs, &root.objects, &root.events)?;
+
+ let author = gix::actor::Signature {
+ name: "git-ents-hosted-worker".into(),
+ email: "worker@git.ents.cloud".into(),
+ time: gix::date::Time {
+ seconds: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
+ .unwrap_or_default(),
+ offset: 0,
+ },
+ };
+
+ let mut ran = 0usize;
+ for (effect_name, oid) in root.events.pending() {
+ let result_ref = ents_model::namespace::result_ref(&effect_name, &run_one_short(oid))?;
+ // Skip work already resulted: the sink may re-list an obligation
+ // whose result already landed in an earlier pass within the same
+ // process (`receive.dedup`'s spirit — idempotent re-delivery,
+ // never a duplicate effect run).
+ if root.refs.get(result_ref.as_ref())?.is_some() {
+ continue;
+ }
+ let Some(effect) = read_effect(&root.refs, &root.objects, &effect_name)? else {
+ continue;
+ };
+ run_one(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ executor,
+ scratch,
+ toolchain_cache,
+ oid,
+ &effect,
+ result_ref,
+ &author,
+ |payload| signer.sign(payload),
+ Mode::Mandatory,
+ )?;
+ ran = ran.saturating_add(1);
+ }
+ Ok(ran)
+}
+
+/// The short-oid segment every results refname uses; mirrors
+/// `ents_effect::run::short_oid` (private to that crate's own module path
+/// from here, so this is a thin duplicate of a two-line slice operation
+/// rather than a reason to change that crate's visibility).
+fn run_one_short(oid: ObjectId) -> String {
+ let hex = oid.to_string();
+ hex.get(..12).unwrap_or(&hex).to_owned()
+}
+
+/// Read and parse the effect definition at `refs/meta/effects/<name>`, or
+/// `None` if it is missing or malformed (mirrors `ents_receive::reconcile`'s
+/// own tolerance for a pre-existing malformed effect).
+fn read_effect(refs: &dyn RefStoreRead, objects: &impl Find, name: &str) -> Result<Option<Effect>> {
+ let effect_ref = ents_model::namespace::effect_ref(name)?;
+ let Some(tip) = refs.get(effect_ref.as_ref())? else {
+ return Ok(None);
+ };
+ let mut buf = Vec::new();
+ let Some(data) = objects
+ .try_find(&tip, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ else {
+ return Ok(None);
+ };
+ if data.kind != Kind::Commit {
+ return Ok(None);
+ }
+ let Ok(commit) = CommitRef::from_bytes(data.data, tip.kind()) else {
+ return Ok(None);
+ };
+ let tree = commit.tree();
+ let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, objects) else {
+ return Ok(None);
+ };
+ // Confirm the trigger still parses, mirroring `reconcile`'s own
+ // tolerance rule; an effect whose trigger is unparsable is treated as
+ // "nothing to run" rather than a hard failure.
+ let _: Query = effect
+ .trigger
+ .parse()
+ .map_err(|_source| Error::InvalidArgument("unparsable trigger".to_owned()))?;
+ Ok(Some(effect))
+}
+
+/// A byte source the hook subcommands read stdin from — split out only so
+/// tests can supply a fixed buffer instead of a real stdin handle.
+pub fn read_all(mut input: impl Read) -> Result<Vec<u8>> {
+ let mut buf = Vec::new();
+ input.read_to_end(&mut buf).map_err(|source| Error::Io {
+ path: "<stdin>".into(),
+ source,
+ })?;
+ Ok(buf)
+}
crates/git-ents/src/lib.rs
@@ -1,0 +1,85 @@
+//! `git-ents`: the local root, the CLI-complete milestone
+//! (`docs/development-plan.adoc`, phase 6).
+//!
+//! This crate's one responsibility is composition and porcelain: it wires
+//! the four seams every other crate defines a trait for — `RefStore`
+//! ([`gix_ref_store`]), the object store (gitoxide's own `Find`/`Write`),
+//! `EventSink` ([`ents_receive`]), and `Executor` ([`ents_effect`]) — into
+//! two composition roots ([`root`]), and exposes a subcommand surface
+//! above them. No business logic lives here that a library crate should
+//! own instead: every command module is a thin caller of `ents-gate`,
+//! `ents-receive`, `ents-effect`, `ents-anchor`, or `ents-sync`.
+//!
+//! # Spec coverage
+//!
+//! From `docs/spec/roots.adoc`:
+//!
+//! - `roots.composition`, `roots.local` — [`root::LocalRoot`]: the plain
+//! CLI's composition root (loose-ref `RefStore`, the local odb, a null
+//! `EventSink`, the advisory gate).
+//! - `roots.config-isolation` — every trait implementation is selected in
+//! [`root`] alone; no command module branches on configuration.
+//! - `roots.worktree-update` — [`commands::setup`] sets
+//! `receive.denyCurrentBranch=updateInstead` on the local repository, the
+//! integration-test-harness edge case that spec section names.
+//!
+//! The development plan's phase-6 row additionally doubles this crate as
+//! the single-node hosted root: [`root::HostedRoot`] and [`hook`] wire
+//! loose refs and a real odb behind git's own `receive-pack`, an in-memory
+//! `EventSink` reconciled at boot (`receive.reconstructible`), and a
+//! `SpriteExecutor`. See those modules' own docs for the design this
+//! deployment shape requires — the git-serving-transport case is
+//! deliberately not `roots.hosted` (`git-ents-server`, phase 8, which
+//! replaces the store itself); it is this same crate's wiring, reused,
+//! per the plan's own framing.
+//!
+//! # Examples
+//!
+//! An end-to-end local write: enroll an admin member, then use it to
+//! enroll a second member, mirroring what `git ents members add` does.
+//!
+//! ```
+//! use ents_model::{MemberId, Provenance};
+//! use ents_receive::Mode;
+//! use git_ents::mutate::{Identity, outcome_to_result, propose_entity};
+//! use git_ents::root::LocalRoot;
+//! use git_ents::sign::Signer;
+//! use gix_ref_store::RefStoreRead;
+//!
+//! # let dir = tempfile::tempdir().expect("tempdir");
+//! # gix::init(dir.path()).expect("init");
+//! # let key_path = dir.path().join("id_ed25519");
+//! # {
+//! # use ssh_key::private::{Ed25519Keypair, KeypairData};
+//! # let pair = Ed25519Keypair::from_seed(&[3; 32]);
+//! # let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "t").expect("well-formed");
+//! # key.write_openssh_file(&key_path, ssh_key::LineEnding::LF).expect("write");
+//! # }
+//! let root = LocalRoot::open(dir.path()).expect("opens");
+//! let signer = Signer::load(&key_path).expect("loads");
+//! let actor = gix::actor::Signature {
+//! name: "jdc".into(), email: "jdc@ents.test".into(),
+//! time: gix::date::Time { seconds: 1_000, offset: 0 },
+//! };
+//! let identity = Identity { actor, signer: &signer };
+//!
+//! let member = ents_model::Member::new(signer.public_openssh(), Provenance::AdminRegistered);
+//! let name = ents_model::namespace::member_ref(&MemberId::new("jdc")).expect("valid");
+//! let outcome = propose_entity(
+//! &root.refs, &root.objects, &root.events, name.clone(), &member,
+//! &identity, "Enroll jdc", root.mode(),
+//! ).expect("evaluates");
+//! outcome_to_result(outcome, None).expect("bootstrap admits the first member");
+//! assert!(root.refs.get(name.as_ref()).expect("reads").is_some());
+//! ```
+
+pub mod cli;
+pub mod commands;
+pub mod error;
+pub mod exe;
+pub mod hook;
+pub mod mutate;
+pub mod root;
+pub mod sign;
+
+pub use error::{Error, Result};
crates/git-ents/src/main.rs
@@ -1,0 +1,18 @@
+//! `git ents`: parse, hand to [`git_ents::exe`], map error to exit code.
+
+use std::process::ExitCode;
+
+use git_ents::cli::Cli;
+
+fn main() -> ExitCode {
+ let cli: Cli = figue::from_std_args().unwrap();
+ let stdout = std::io::stdout();
+ let mut out = stdout.lock();
+ match git_ents::exe::run(cli, &mut out) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("error: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
crates/git-ents/src/mutate.rs
@@ -1,0 +1,181 @@
+//! One shared primitive every entity-mutation command uses: serialize a
+//! typed tree, wrap it in a signed commit bound to its refname (per
+//! `meta-ref.trailers`'s `Advance-ref` trailer), and hand it to
+//! [`ents_receive::receive`] — the sole path a meta-ref mutation may enter
+//! the repository (`receive.unit`).
+//!
+//! Every porcelain command that writes an entity (`members`, `account`,
+//! `effect`, `toolchain`, `comment`, `redact`) goes through
+//! [`propose_entity`] rather than repeating this shape, so there is
+//! exactly one place that builds the trailer block, one place that signs,
+//! and one place that calls `receive`.
+
+use ents_model::trailer::Trailers;
+use ents_receive::{Mode, Outcome, Proposal, RefTransition, TxResult};
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+use gix_object::{Commit, Find, Kind, Write, WriteTo as _};
+use gix_ref_store::RefStore;
+
+use crate::error::{Error, Result};
+use crate::sign::Signer;
+
+/// Everything [`propose_entity`] needs about the acting identity: the
+/// commit author/committer signature and the signer producing the
+/// `gpgsig` header.
+pub struct Identity<'a> {
+ /// The author and committer signature every mutation commit carries.
+ pub actor: gix::actor::Signature,
+ /// The loaded signing key.
+ pub signer: &'a Signer,
+}
+
+/// Serialize `entity` into `objects`, wrap it in a commit bound to `name`
+/// via the `Advance-ref` trailer, sign it with `identity`, and propose the
+/// transition through [`ents_receive::receive`].
+///
+/// `name`'s current tip is read fresh from `refs` immediately before
+/// building the commit, so the proposed transition's `old` is always
+/// current — the CAS precondition `receive` (via `ents_gate::verify`)
+/// checks is against this same read.
+///
+/// # Errors
+///
+/// [`Error::Tree`] if `entity` cannot be serialized; [`Error::Refs`] if
+/// reading `name`'s current tip fails; [`Error::Receive`] if `receive`
+/// itself could not reach an outcome. A reached-but-negative outcome
+/// (refusal, staleness, redaction) is returned as `Ok` — callers translate
+/// [`Outcome`] to a user-facing [`Error`] via [`outcome_to_result`].
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one field per entity-mutation shape (refname, entity, identity, message, mode); \
+ this is the crate's one shared primitive rather than one per caller"
+)]
+pub fn propose_entity<T: for<'facet> facet::Facet<'facet>>(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ name: FullName,
+ entity: &T,
+ identity: &Identity<'_>,
+ subject: &str,
+ mode: Mode,
+) -> Result<Outcome> {
+ let tree = facet_git_tree::serialize_into(entity, objects)?;
+ let old = refs.get(name.as_ref())?;
+
+ let trailers = Trailers {
+ ents_ref: Some(name.clone()),
+ schema_version: None,
+ };
+ let message = format!("{subject}\n\n{}", trailers.render());
+
+ let mut commit = Commit {
+ tree,
+ parents: old.into_iter().collect::<Vec<_>>().into(),
+ author: identity.actor.clone(),
+ committer: identity.actor.clone(),
+ encoding: None,
+ message: message.into(),
+ extra_headers: Vec::new(),
+ };
+ let mut payload = Vec::new();
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "writing a gix_object::Commit to an in-memory Vec cannot fail; mirrors \
+ `ents_testutil::write_commit`'s identical, unguarded call"
+ )]
+ commit
+ .write_to(&mut payload)
+ .expect("serializing a commit to a Vec cannot fail");
+ let pem = identity.signer.sign(&payload);
+ commit
+ .extra_headers
+ .push(("gpgsig".into(), pem.trim_end().into()));
+
+ let mut raw = Vec::new();
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "writing a gix_object::Commit to an in-memory Vec cannot fail; mirrors \
+ `ents_testutil::write_commit`'s identical, unguarded call"
+ )]
+ commit
+ .write_to(&mut raw)
+ .expect("serializing a commit to a Vec cannot fail");
+ let tip = objects.write_buf(Kind::Commit, &raw)?;
+
+ let proposal = Proposal {
+ transitions: vec![RefTransition {
+ name,
+ old,
+ new: Some(tip),
+ }],
+ objects: vec![tip],
+ auth: None,
+ };
+ Ok(ents_receive::receive(
+ refs, objects, events, &proposal, mode,
+ )?)
+}
+
+/// Delete the entity at `name` (a `new: None` transition) through
+/// `receive`, the same shared path [`propose_entity`] uses for writes.
+///
+/// # Errors
+///
+/// See [`propose_entity`].
+pub fn propose_delete(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ name: FullName,
+ mode: Mode,
+) -> Result<Outcome> {
+ let old = refs.get(name.as_ref())?;
+ let proposal = Proposal {
+ transitions: vec![RefTransition {
+ name,
+ old,
+ new: None,
+ }],
+ objects: vec![],
+ auth: None,
+ };
+ Ok(ents_receive::receive(
+ refs, objects, events, &proposal, mode,
+ )?)
+}
+
+/// Translate a reached [`Outcome`] into `Ok(tip)` on success or a
+/// user-facing [`Error`] otherwise — the one place every command renders
+/// `receive`'s result the same way.
+///
+/// # Errors
+///
+/// [`Error::Refused`] for a gate refusal (`gate.mandatory-hosted`
+/// aborting on any failed verdict, or an advisory root's failed verdict a
+/// caller chose to treat as fatal); [`Error::Stale`] for a compare-and-swap
+/// rejection; [`Error::Redacted`] if a redacted object was refused.
+pub fn outcome_to_result(outcome: Outcome, tip: Option<ObjectId>) -> Result<Option<ObjectId>> {
+ match outcome.result {
+ TxResult::Applied => Ok(tip),
+ TxResult::Refused => {
+ let reasons = outcome
+ .verdicts
+ .iter()
+ .filter_map(|(_, verdict)| match verdict {
+ ents_gate::Verdict::Fail(refusal) => Some(refusal.to_string()),
+ ents_gate::Verdict::Pass(_) => None,
+ })
+ .collect::<Vec<_>>()
+ .join("; ");
+ Err(Error::Refused(reasons))
+ }
+ TxResult::Rejected { name } => Err(Error::Stale {
+ name: name.as_bstr().to_string(),
+ }),
+ TxResult::Redacted { oid } => Err(Error::Redacted { oid }),
+ }
+}
crates/git-ents/src/root.rs
@@ -1,0 +1,424 @@
+//! Composition roots (`roots.composition`): the only place `git-ents`
+//! wires the four seams — `RefStore`, the object store, `EventSink`, and
+//! `Executor` — together.
+//!
+//! Two roots live in this module, per the development plan's phase-6 row:
+//!
+//! - [`LocalRoot`] (`roots.local`): the plain CLI, wired against whatever
+//! repository the current directory is in — loose-ref `RefStore`, the
+//! local odb, a null `EventSink`, the advisory gate. `Executor` is
+//! chosen per invocation (`git ents effect run --executor`), never
+//! fixed by this root, since local execution is pull-only
+//! (`effect.local-run`) and never itself runs an effect as part of
+//! composing the root.
+//! - [`HostedRoot`] (the single-node hosted root the development plan's
+//! `git-ents` row describes: "loose refs and a real odb on a Fly
+//! volume, served behind git's own `receive-pack`... with an in-memory
+//! `EventSink` and a boot-time reconciliation scan, and the Sprite
+//! executor"): the same loose-ref/odb primitives as [`LocalRoot`], but
+//! the mandatory gate, an in-memory `EventSink`, and a `SpriteExecutor`
+//! — wired by the `git-ents hook` plumbing subcommands
+//! ([`crate::hook`]) that git's own `receive-pack` invokes.
+//!
+//! Neither root is `roots.hosted` (`git-ents-server`, phase 8): that root
+//! replaces the `RefStore` and object store with Postgres and Tigris and
+//! is out of scope until scale forces it (`roots.honesty-test`). This
+//! module's `HostedRoot` keeps git's own on-disk repository and
+//! `receive-pack` as the transport, exactly as the development plan's
+//! preamble describes for this phase.
+//!
+//! # Config isolation (`roots.config-isolation`)
+//!
+//! Every trait implementation is selected here, in these two structs, and
+//! nowhere else: no command module reads an environment variable or git
+//! config value to decide *which* `RefStore` or `Executor` to use — they
+//! are only ever handed one already-constructed by a root.
+//!
+//! # Boundary rules this module upholds
+//!
+//! [`LocalRoot`] and [`HostedRoot`] are the first composition roots this
+//! codebase has (every crate before phase 6 was a library, handed trait
+//! objects rather than constructing them): `arch.store-composition-root`
+//! ("a concrete store implementation... MUST be wired only inside a
+//! composition root") and `arch.no-hosted-branch` ("a library crate MUST
+//! NOT contain a branch on deployment mode") are both properties this
+//! file demonstrates rather than merely states — `LocalRoot` and
+//! `HostedRoot` are two distinct types, never one type with an
+//! `if hosted` branch, and every command module ([`crate::commands`])
+//! takes an already-constructed root, never constructing a store itself.
+// @relation(roots.composition, roots.config-isolation, arch.store-composition-root, arch.no-hosted-branch, scope=file)
+
+use std::path::{Path, PathBuf};
+
+use ents_receive::{Mode, NullEventSink};
+use gix_ref_store::LooseRefStore;
+
+use crate::error::{Error, Result};
+
+/// A real, on-disk object store: the repository's own odb, opened for
+/// genuine reads *and* writes (`arch.no-object-store-trait`: accessed only
+/// through gitoxide's own `Find`/`Write` traits, never a private one).
+///
+/// `gix::Repository::objects` proxies writes into an in-memory overlay by
+/// default (so in-process object creation can be staged before a
+/// transaction commits); a composition root that wants every write to land
+/// on disk immediately calls
+/// [`gix_odb::memory::Proxy::with_write_passthrough`] to strip that
+/// overlay off, which is exactly what [`open_objects`] does. This is the
+/// "which object directory... is the composition root's responsibility to
+/// wire" `ents_receive::receive` itself defers to its caller.
+pub type Objects = gix::OdbHandle;
+
+/// Open `path`'s repository and return a real, write-through object store
+/// over it (see [`Objects`]'s own doc for why `with_write_passthrough` is
+/// required here).
+///
+/// # Errors
+///
+/// [`Error::Repo`] if `path` is not a git repository `gix` can open.
+pub fn open_objects(path: &Path) -> Result<Objects> {
+ let repo = gix::open(path)?;
+ Ok(repo.objects.with_write_passthrough())
+}
+
+/// The local composition root (`roots.local`): a loose-ref `RefStore`, the
+/// local odb, a null `EventSink`, the advisory gate. Wired once per CLI
+/// invocation against whichever repository the current directory
+/// discovers.
+///
+/// # Examples
+///
+/// ```
+/// # let dir = tempfile::tempdir().expect("tempdir");
+/// # gix::init(dir.path()).expect("init");
+/// use git_ents::root::LocalRoot;
+///
+/// let root = LocalRoot::open(dir.path()).expect("opens a real repository");
+/// assert_eq!(root.mode(), ents_receive::Mode::Advisory);
+/// ```
+pub struct LocalRoot {
+ /// The repository path this root was opened against.
+ pub path: PathBuf,
+ /// The loose-ref `RefStore` (`arch.loose-cas-discipline`).
+ pub refs: LooseRefStore,
+ /// The real, on-disk object store.
+ pub objects: Objects,
+ /// The null `EventSink` (`roots.local`): local effect execution is
+ /// pull-only, so nothing is ever enqueued here (`effect.local-run`).
+ pub events: NullEventSink,
+}
+
+impl LocalRoot {
+ /// Open the local composition root against the repository at `path`.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Repo`] or [`Error::Refs`] if `path` is not a git
+ /// repository, or its refs cannot be opened.
+ pub fn open(path: impl AsRef<Path>) -> Result<Self> {
+ let path = path.as_ref().to_owned();
+ let refs = LooseRefStore::open(&path)?;
+ let objects = open_objects(&path)?;
+ Ok(Self {
+ path,
+ refs,
+ objects,
+ events: NullEventSink,
+ })
+ }
+
+ /// Discover the repository from `start` upward (mirroring `git`'s own
+ /// discovery), then open the local root against it.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::NotARepo`] if no git repository is found at or above
+ /// `start`.
+ pub fn discover(start: impl AsRef<Path>) -> Result<Self> {
+ let start = start.as_ref();
+ let discovered = gix::discover(start).map_err(|_source| Error::NotARepo {
+ path: start.to_owned(),
+ })?;
+ let path = discovered.workdir().unwrap_or_else(|| discovered.path());
+ Self::open(path)
+ }
+
+ /// The gate policy this root runs under: always advisory
+ /// (`gate.advisory-local`) — a local write is annotated, never
+ /// blocked.
+ #[must_use]
+ pub fn mode(&self) -> Mode {
+ Mode::Advisory
+ }
+}
+
+/// The single-node hosted composition root: the same loose-ref/odb
+/// primitives [`LocalRoot`] uses, but the mandatory gate
+/// (`gate.mandatory-hosted`) — a push landing on the actual canonical
+/// remote has teeth (`docs/design.adoc`: "the hosted server is not where
+/// policy lives — it is the one place where the verdict has teeth") — and
+/// an in-memory `EventSink` reconciled at boot
+/// (`receive.reconstructible`).
+///
+/// This is wired by [`crate::hook`]'s plumbing subcommands, which git's own
+/// `receive-pack` invokes as `pre-receive`/`post-receive` hooks; see that
+/// module's doc for why the ref *write* itself is left to git's native
+/// `receive-pack` rather than `ents_receive::receive`'s own
+/// `RefStore::transaction` in this deployment shape.
+pub struct HostedRoot {
+ /// The repository path this root was opened against.
+ pub path: PathBuf,
+ /// The loose-ref `RefStore`.
+ pub refs: LooseRefStore,
+ /// The real, on-disk object store, transparently extended to also read
+ /// through a `pre-receive` quarantine directory when the environment
+ /// names one (`GIT_OBJECT_DIRECTORY`) — see [`QuarantineObjects`]'s own
+ /// doc for why this is an in-process read chain rather than a written
+ /// `info/alternates` file.
+ pub objects: QuarantineObjects,
+ /// The in-memory `EventSink`, reconciled at boot
+ /// (`receive.reconstructible`).
+ pub events: ents_receive::MemoryEventSink,
+}
+
+impl HostedRoot {
+ /// Open the hosted composition root against the repository at `path`,
+ /// honoring a pre-receive quarantine object directory if the
+ /// environment names one (`GIT_OBJECT_DIRECTORY`), and immediately run
+ /// the boot-time reconciliation scan (`receive.reconstructible`) to
+ /// populate the in-memory `EventSink` from repository state alone.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Repo`] or [`Error::Refs`] if `path` is not a git
+ /// repository; [`Error::Receive`] if the reconciliation scan itself
+ /// fails to read repository state.
+ pub fn open(path: impl AsRef<Path>) -> Result<Self> {
+ let path = path.as_ref().to_owned();
+ let refs = LooseRefStore::open(&path)?;
+ let objects = QuarantineObjects::open(&path)?;
+ let events = ents_receive::MemoryEventSink::default();
+ ents_receive::reconcile(&refs, &objects, &events)?;
+ Ok(Self {
+ path,
+ refs,
+ objects,
+ events,
+ })
+ }
+
+ /// The gate policy this root runs under: always mandatory
+ /// (`gate.mandatory-hosted`) — the canonical hosted remote's writes
+ /// are actually enforced, not merely annotated.
+ #[must_use]
+ pub fn mode(&self) -> Mode {
+ Mode::Mandatory
+ }
+}
+
+/// The real, on-disk object store, transparently extended to also read
+/// through a `pre-receive` quarantine directory when the environment names
+/// one (`GIT_OBJECT_DIRECTORY`).
+///
+/// # Why an in-process read chain, not a written `info/alternates` file
+///
+/// git's own `pre-receive` quarantine does *not* write a physical
+/// `info/alternates` file into the quarantine directory it hands hook
+/// processes — it communicates the real odb's location purely via the
+/// `GIT_ALTERNATE_OBJECT_DIRECTORIES` environment variable (confirmed
+/// empirically: a quarantine directory git creates has no
+/// `info/alternates` at all). `gix_odb::at`, in contrast, only ever
+/// follows a physical alternates *file* — it does not consult this
+/// environment variable itself. This is exactly the "which object
+/// directory... is the composition root's responsibility to wire" gap
+/// `ents_receive::receive`'s own doc names for the quarantine case.
+///
+/// The first attempt at closing this gap wrote the environment's paths
+/// into the quarantine's own `info/alternates` file, reasoning that the
+/// quarantine directory is discarded once the push resolves. That
+/// reasoning was wrong: git's quarantine finalization *moves* the
+/// quarantine directory's entire contents — including any file a hook
+/// process wrote into it — onto the real object directory once the push
+/// is accepted, permanently persisting a written alternates file at
+/// `objects/info/alternates` whose own content names `objects` itself, a
+/// self-cycle that fails every future open of the repository (observed
+/// directly: a second push into the same repository failed with
+/// `gix_odb`'s own "Alternates form a cycle" error, `objects` pointing at
+/// itself). Nothing here writes to the object directory at all now,
+/// closing that hazard structurally rather than by adding another
+/// disk-state special case.
+///
+/// # Examples
+///
+/// ```
+/// # let dir = tempfile::tempdir().expect("tempdir");
+/// # gix::init_bare(dir.path()).expect("init"); // HostedRoot always runs against a bare repo.
+/// use git_ents::root::QuarantineObjects;
+///
+/// // No `GIT_OBJECT_DIRECTORY` set: reads go straight to the real odb.
+/// let objects = QuarantineObjects::open(dir.path()).expect("opens");
+/// let missing = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
+/// assert!(
+/// gix_object::Find::try_find(&objects, &missing, &mut Vec::new())
+/// .expect("a missing lookup is Ok(None), not an error")
+/// .is_none()
+/// );
+/// ```
+pub struct QuarantineObjects {
+ /// The quarantine directory's own odb, when `GIT_OBJECT_DIRECTORY`
+ /// names one distinct from the repository's real `objects/` —
+ /// consulted first, so a push's own not-yet-committed objects are
+ /// visible before the fallback.
+ quarantine: Option<gix_odb::Handle>,
+ /// The repository's real, on-disk odb — reads fall back to this, and
+ /// every write always lands here (see [`gix_object::Write`]'s impl):
+ /// `pre-receive` never writes objects itself
+ /// (`ents_gate::verify` is read-only), so this is only exercised by
+ /// `post-receive`'s write-back path, which never runs under a
+ /// quarantine at all.
+ real: gix_odb::Handle,
+}
+
+impl QuarantineObjects {
+ /// Open the object store for the repository at `path`, chaining a
+ /// `pre-receive` quarantine directory in front of the real odb when
+ /// the environment names one distinct from `path`'s own `objects/`
+ /// (canonicalized, since a quarantine and the real directory can
+ /// otherwise compare unequal only by an unresolved symlink or a `/./`
+ /// path component).
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Io`] if either directory cannot be opened as an object
+ /// store.
+ pub fn open(path: &Path) -> Result<Self> {
+ let real_dir = path.join("objects");
+ let real = gix_odb::at(&real_dir).map_err(|source| Error::Io {
+ path: real_dir.clone(),
+ source,
+ })?;
+ let quarantine = match std::env::var_os("GIT_OBJECT_DIRECTORY") {
+ Some(dir) => {
+ let dir = PathBuf::from(dir);
+ let is_real_quarantine = match (dir.canonicalize(), real_dir.canonicalize()) {
+ (Ok(q), Ok(r)) => q != r,
+ _ => dir != real_dir,
+ };
+ if is_real_quarantine {
+ Some(gix_odb::at(&dir).map_err(|source| Error::Io { path: dir, source })?)
+ } else {
+ None
+ }
+ }
+ None => None,
+ };
+ Ok(Self { quarantine, real })
+ }
+}
+
+// @relation(arch.no-object-store-trait, scope=function)
+impl gix_object::Find for QuarantineObjects {
+ fn try_find<'a>(
+ &self,
+ id: &gix_hash::oid,
+ buffer: &'a mut Vec<u8>,
+ ) -> std::result::Result<Option<gix_object::Data<'a>>, gix_object::find::Error> {
+ // The quarantine attempt reads into its own, function-local
+ // buffer rather than the caller's `buffer` (whose lifetime `'a`
+ // is named, not elided, so the borrow checker cannot shrink a
+ // second, conditional reborrow of it to a sub-region even though
+ // only one branch ever executes at runtime) — copying the found
+ // bytes into `buffer` afterward keeps this the same one-call-site
+ // shape `gix_odb::memory::Proxy::try_find` uses for its own
+ // primary-then-fallback lookup.
+ if let Some(quarantine) = &self.quarantine {
+ let mut local = Vec::new();
+ if let Some(found) = quarantine.try_find(id, &mut local)? {
+ let kind = found.kind;
+ buffer.clear();
+ buffer.extend_from_slice(found.data);
+ return Ok(Some(gix_object::Data {
+ kind,
+ object_hash: id.kind(),
+ data: buffer.as_slice(),
+ }));
+ }
+ }
+ self.real.try_find(id, buffer)
+ }
+}
+
+impl gix_object::Write for QuarantineObjects {
+ fn write_stream(
+ &self,
+ kind: gix_object::Kind,
+ size: u64,
+ from: &mut dyn std::io::Read,
+ ) -> std::result::Result<gix_hash::ObjectId, gix_object::write::Error> {
+ self.real.write_stream(kind, size, from)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ /// `arch.no-hosted-branch`: the two roots are distinct types with a
+ /// fixed, compile-time-chosen gate policy each — never one type
+ /// branching on a runtime "am I hosted?" check. Table-driven because
+ /// the spec enumerates exactly these two cases, one per root.
+ #[rstest]
+ #[case::local_is_advisory(true, Mode::Advisory)]
+ #[case::hosted_is_mandatory(false, Mode::Mandatory)]
+ // @relation(arch.no-hosted-branch, roots.config-isolation, scope=function, role=Verifies)
+ fn each_root_has_one_fixed_mode(#[case] local: bool, #[case] expected: Mode) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let mode = if local {
+ gix::init(dir.path()).expect("init");
+ LocalRoot::open(dir.path()).expect("opens").mode()
+ } else {
+ gix::init_bare(dir.path()).expect("init bare");
+ HostedRoot::open(dir.path()).expect("opens").mode()
+ };
+ assert_eq!(mode, expected);
+ }
+
+ /// `arch.store-composition-root`: opening either root is the *only*
+ /// place a `LooseRefStore`/odb pair gets constructed — every command
+ /// module ([`crate::commands`]) only ever receives an already-built
+ /// root, never builds one of its own store handles.
+ #[rstest]
+ // @relation(arch.store-composition-root, scope=function, role=Verifies)
+ fn opening_a_root_is_the_only_construction_path() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ gix::init(dir.path()).expect("init");
+ let root = LocalRoot::open(dir.path()).expect("opens");
+ // The root itself is the seam every command module is handed;
+ // there is no second, parallel way to obtain a `RefStore`/odb
+ // pair for this repository within this crate.
+ assert_eq!(root.path, dir.path());
+ }
+
+ /// `arch.no-object-store-trait`: `QuarantineObjects` reads and writes
+ /// exclusively through gitoxide's own `Find`/`Write` traits — no
+ /// private object-store trait exists in this crate for it to
+ /// implement instead.
+ #[rstest]
+ // @relation(arch.no-object-store-trait, scope=function, role=Verifies)
+ fn quarantine_objects_round_trips_through_gitoxides_own_traits() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ gix::init_bare(dir.path()).expect("init bare");
+ let objects = QuarantineObjects::open(dir.path()).expect("opens");
+
+ let oid = gix_object::Write::write(&objects, &gix_object::Tree::empty()).expect("writes");
+ let mut buf = Vec::new();
+ let found = gix_object::Find::try_find(&objects, &oid, &mut buf)
+ .expect("reads")
+ .expect("just written");
+ assert_eq!(found.kind, gix_object::Kind::Tree);
+ }
+}
crates/git-ents/src/sign.rs
@@ -1,0 +1,147 @@
+//! Real SSH commit signing: the production counterpart to
+//! `ents_testutil::Keypair` — the same SSHSIG-over-`git`-namespace shape
+//! `ents_gate::signature` verifies, but loaded from the user's own key
+//! instead of a deterministic test seed.
+//!
+//! This is new work, not a port: `pre-redo`'s CLI shelled out to `git
+//! commit -S`/`git push --signed` and let stock git invoke
+//! `ssh-keygen -Y sign` itself. The redone architecture's mutation
+//! frontends build and sign the commit object themselves, in-process,
+//! before handing it to [`ents_receive::receive`] (`receive.unit`), so
+//! `git-ents` needs its own signer rather than a subprocess shelling to
+//! `git`.
+
+use std::path::{Path, PathBuf};
+
+use ssh_key::{HashAlg, LineEnding, PrivateKey};
+
+use crate::error::{Error, Result};
+
+/// The SSHSIG namespace git signs commits under — mirrors
+/// `ents_testutil::keys::GIT_SIGN_NAMESPACE` and
+/// `ents_gate::signature`'s verification side.
+const GIT_SIGN_NAMESPACE: &str = "git";
+
+/// A loaded SSH signing identity: the private key material plus its
+/// OpenSSH public-key line, exactly the string an
+/// [`ents_model::Member::key`] carries.
+///
+/// # Examples
+///
+/// ```
+/// # use ssh_key::private::{Ed25519Keypair, KeypairData};
+/// # let dir = tempfile::tempdir().expect("tempdir");
+/// # let path = dir.path().join("id_ed25519");
+/// # let pair = Ed25519Keypair::from_seed(&[7; 32]);
+/// # let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed");
+/// # key.write_openssh_file(&path, ssh_key::LineEnding::LF).expect("write");
+/// use git_ents::sign::Signer;
+///
+/// let signer = Signer::load(&path).expect("loads");
+/// assert!(signer.public_openssh().starts_with("ssh-ed25519 "));
+///
+/// let pem = signer.sign(b"payload");
+/// assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
+/// ```
+#[derive(Debug)]
+pub struct Signer {
+ private: PrivateKey,
+}
+
+impl Signer {
+ /// Load a signing identity from an OpenSSH private key file at `path`.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::BadSigningKey`] if `path` cannot be read, is not a
+ /// well-formed OpenSSH private key, or is passphrase-protected — an
+ /// encrypted key is a deliberate deferral (see this module's own
+ /// doc): this phase supports only an unencrypted key file.
+ pub fn load(path: &Path) -> Result<Self> {
+ let private =
+ PrivateKey::read_openssh_file(path).map_err(|source| Error::BadSigningKey {
+ path: path.to_owned(),
+ detail: source.to_string(),
+ })?;
+ if private.is_encrypted() {
+ return Err(Error::BadSigningKey {
+ path: path.to_owned(),
+ detail: "passphrase-protected keys are not supported yet; use an unencrypted key \
+ or ssh-agent (deferred)"
+ .to_owned(),
+ });
+ }
+ Ok(Self { private })
+ }
+
+ /// The public half in OpenSSH single-line format — what a
+ /// [`ents_model::Member`]'s `key` field stores.
+ #[must_use]
+ pub fn public_openssh(&self) -> String {
+ #[expect(
+ clippy::expect_used,
+ reason = "rendering an already-loaded key's own public half cannot fail; mirrors \
+ `ents_testutil::Keypair::public_openssh`'s identical, unguarded call"
+ )]
+ self.private
+ .public_key()
+ .to_openssh()
+ .expect("a loaded key's public half always renders")
+ }
+
+ /// Sign `payload` in git's SSHSIG namespace, returning the armored PEM
+ /// block git stores in a commit's `gpgsig` header — identical shape to
+ /// `ents_testutil::Keypair::sign`.
+ ///
+ /// # Panics
+ ///
+ /// Never for a well-formed loaded key; signing an arbitrary byte
+ /// payload cannot fail for the algorithms this module accepts.
+ #[must_use]
+ pub fn sign(&self, payload: &[u8]) -> String {
+ #[expect(
+ clippy::expect_used,
+ reason = "signing and PEM-rendering an ed25519 signature over any byte payload is \
+ infallible; mirrors `ents_testutil::Keypair::sign`'s identical, unguarded call"
+ )]
+ self.private
+ .sign(GIT_SIGN_NAMESPACE, HashAlg::Sha512, payload)
+ .expect("signing is infallible for a loaded, unencrypted key")
+ .to_pem(LineEnding::LF)
+ .expect("an SSHSIG always renders as PEM")
+ }
+}
+
+/// Resolve the signing key path a command should use: `--key` if given,
+/// else the repository's (or global) `user.signingkey`, else the default
+/// `~/.ssh/id_ed25519`.
+///
+/// # Errors
+///
+/// [`Error::NoSigningKey`] when none of the three sources resolves to a
+/// path.
+pub fn resolve_key_path(repo: &gix::Repository, explicit: Option<&Path>) -> Result<PathBuf> {
+ if let Some(path) = explicit {
+ return Ok(path.to_owned());
+ }
+ if let Some(configured) = repo
+ .config_snapshot()
+ .string("user.signingkey")
+ .map(|v| v.to_string())
+ {
+ return Ok(PathBuf::from(configured));
+ }
+ if let Some(home) = home_dir() {
+ let default = home.join(".ssh").join("id_ed25519");
+ if default.exists() {
+ return Ok(default);
+ }
+ }
+ Err(Error::NoSigningKey)
+}
+
+/// The current user's home directory, however the platform exposes it —
+/// `$HOME` on every platform `git-ents` targets.
+fn home_dir() -> Option<PathBuf> {
+ std::env::var_os("HOME").map(PathBuf::from)
+}