refactor: extract git-effect crate, decomposed one ref per effect
commit a09c7f8
refactor: extract git-effect crate, decomposed one ref per effect
Checks become effects, matching docs/abstractions.adoc’s target model:
definitions move off git-ents-core and the Sprite worker off
git-ents-server, combined into git-effect. The check set decomposes
from one aggregated refs/meta/checks map into one ref per effect at
refs/meta/effects/<name>, and run results from one ref per checked
commit into one ref per effect per commit at
refs/meta/results/<effect>/<short-oid> — enabling the admin-only
write rule to be stated as a single refname glob rather than gating a
shared ref, and letting an effect be added or removed as its own
independently-signed push. The CLI’s public shape (git ents checks
list/add/remove/debug/runs) is unchanged; its runs output
reassembles the decomposed per-effect records back into one aggregate
run per commit, matching the pre-decomposition view.
The admin-only write rule for refs/meta/effects/* and the
host-direct/Sprite execution backend split are follow-up work — see
docs/abstractions.adoc’s Effect section for the target shape.
feat: add git-effect crate (definition/results/engine modules)
refactor: rename Check/CheckBody to Effect/EffectBody, one ref per effect
refactor: decompose run results to refs/meta/results/<effect>/<short-oid>
refactor: remove checks.rs from git-ents-core and git-ents-server
docs: update conformance.adoc/checks.adoc/cli.adoc for the effects rename
Assisted-by: Claude:claude-sonnet-5
docs/spec/checks.adoc
@@ -1,7 +1,7 @@
== The Check
-A check is data (`refs/meta/checks`), not configuration living outside the
-repo; the toolchain that feeds it is a git tree
+A check is data (`refs/meta/effects/<name>`), not configuration living
+outside the repo; the toolchain that feeds it is a git tree
(`refs/meta/toolchains/<name>`).
The repository carries its own CI definition and environment, and nothing
ever blocks a push.
@@ -9,15 +9,18 @@
[role="requirement", id="checks.definition"]
.Check Set Definition
--
-The configured checks for a repository MUST be stored at `refs/meta/checks`
-as a map from check name to a check body holding an optional shell command,
-an optional sandbox image, an optional list of dependencies (names of
-sibling checks that must pass first), and an optional list of toolchain
-names (<<checks.toolchains>>).
+The configured checks for a repository MUST be stored one ref per check at
+`refs/meta/effects/<name>`, each holding an optional shell command, an
+optional sandbox image, an optional list of dependencies (names of sibling
+checks that must pass first), and an optional list of toolchain names
+(<<checks.toolchains>>).
A check with no command is a composite: it runs nothing itself and derives
its outcome from its dependencies alone.
A check's definition living on a meta ref means a branch under check cannot
-rewrite the check set that gates it.
+rewrite the check set that gates it; decomposing the set one ref per check
+means each check can be added or removed as its own independently-signed
+push, and the admin-only write rule can be stated as a single refname glob
+(`refs/meta/effects/*`) rather than gating one shared ref.
The dependency graph is fully static — no conditional edges, no runtime
expansion — and MUST be validated when the set is written: a dependency
@@ -96,10 +99,11 @@
[role="requirement", id="checks.outcomes"]
.Run Recording
--
-Run outcomes MUST be stored at `refs/meta/runs/<commit>`, one ref per
-checked commit.
-Each ref's commit chain is the run history; each commit's date is the run
-time, so no timestamp is duplicated in the document tree.
+Run outcomes MUST be stored at `refs/meta/results/<effect>/<short-oid>`, one
+ref per checked commit per check.
+Each ref's commit chain is that check's run history against that commit;
+each commit's date is the run time, so no timestamp is duplicated in the
+document tree.
Outcome values progress: `queued` → `running` → `pass` / `fail` / `error` /
`skipped` (per <<checks.worker>>).
A run that cannot complete due to an infrastructure error MUST be finalized
@@ -108,8 +112,11 @@
A check definition and a run outcome MAY each carry additional metadata (a
run's duration and log URL); optional fields absent from an older record
MUST load as unset.
-A check/outcome name is the map key and MUST NOT be stored redundantly
-inside the record.
+A run's checked commit MUST be recorded in full inside the record (not just
+the ref's abbreviated segment), so it can be recovered regardless of how the
+ref name truncates it; every check updated together by a single worker pass
+MUST be reassembled into one aggregate run by grouping their per-check
+records on that recorded moment.
--
[role="requirement", id="checks.debug"]
docs/spec/cli.adoc
@@ -66,7 +66,7 @@
(<<account.ref>>) and print the account's genesis identity
(<<account.genesis>>).
`git ents checks` MUST provide `list`, `add`, and `remove` over the
-`refs/meta/checks` set (<<checks.definition>>).
+`refs/meta/effects/<name>` set (<<checks.definition>>).
--
[role="requirement", id="cli.toolchains"]
docs/spec/conformance.adoc
@@ -42,11 +42,11 @@
|`account.ref` |`git-ents/src/account.rs` |`account::store_then_load_round_trips_the_account`, `the_account_ref_marks_an_account_repo`
|`account.genesis` |`git-ents/src/account.rs` (`genesis`) | _(exercised via `members add --account`; no dedicated unit test)_
|`config.ref` |`git-ents-core/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent`
-|`checks.definition` |`git-ents/src/checks.rs` (`load`/`store`, `CHECKS_REF`, `order`) |`checks::store_then_load_round_trips_the_check_set`, `store_then_load_round_trips_image_and_depends`, `order_runs_dependencies_first`, `order_rejects_a_cycle`, `order_rejects_an_unknown_dependency`, `order_rejects_self_and_duplicate_edges`, `order_rejects_an_empty_check`
-|`checks.post-receive` |`git-ents-server/src/checks.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_
-|`checks.worker` |`git-ents-server/src/checks.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`checks::tests::pending_jobs_groups_by_repo_and_drops_malformed`, `composite_status_derives_from_its_dependencies` _(dependency-ordered execution requires a live Sprite; `order` is unit-tested in `git-ents`)_
-|`checks.sandbox` |`git-ents-server/src/checks.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`) | _(requires a live Sprite; not covered by unit tests)_
-|`checks.outcomes` |`git-ents/src/checks.rs` (`Status`, `record`/`update_run`); `git-ents-server/src/checks.rs` (`CHECK_TIMEOUT`, `finalize_error`) |`checks::update_run_advances_in_place_rather_than_appending`, `round_trips_an_outcomes_duration_and_log_url`
+|`checks.definition` |`git-effect/src/definition.rs` (`load`/`store`, `EFFECTS_NS`, `order`) |`definition::tests::store_then_load_round_trips_an_effect`, `store_then_load_round_trips_image_and_depends`, `order_runs_dependencies_first`, `order_rejects_a_cycle`, `order_rejects_an_unknown_dependency`, `order_rejects_self_and_duplicate_edges`, `order_rejects_an_empty_effect`
+|`checks.post-receive` |`git-effect/src/engine.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_
+|`checks.worker` |`git-effect/src/engine.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`engine::tests::pending_jobs_groups_by_repo_and_drops_malformed`, `composite_status_derives_from_its_dependencies` _(dependency-ordered execution requires a live Sprite; `order` is unit-tested in `git-effect`)_
+|`checks.sandbox` |`git-effect/src/engine.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`) | _(requires a live Sprite; not covered by unit tests)_
+|`checks.outcomes` |`git-effect/src/results.rs` (`Status`, `record`/`update_run`); `git-effect/src/engine.rs` (`CHECK_TIMEOUT`, `finalize_error`) |`results::tests::update_run_advances_in_place_rather_than_appending`, `round_trips_an_outcomes_duration_and_recording`
|`checks.debug` |`git-ents-server/src/web/debug.rs` (`handshake`, broker); `git-ents/src/main.rs` (`checks debug`) | _(requires a live Sprite; manually verified)_
|`issues.ref` |`git-ents/src/issues.rs` (`Issue`, `State`) |`issues::store_then_load_round_trips_an_issue`
|`issues.id` |`git-ents/src/issues.rs` (`new_id`, `promote`) |`issues::new_id_hashes_its_own_content_with_no_origin`, `promotion_assigns_a_number_and_advances_the_counter_without_renaming_the_ref`
@@ -64,16 +64,16 @@
|`web.auth.session` |`git-ents-server/src/web/write.rs` (`Session`, `csrf_ok`, `logout`) |`web_edit::an_edit_without_a_valid_csrf_token_is_refused`
|`web.auth.edit` |`git-ents-server/src/web/write.rs` (`edit_config`, `signed_edit`, `require_admin_registered`) |`web_edit::a_member_edits_settings_through_the_browser`, `a_self_attested_member_is_refused_a_settings_edit`
|`web.auth.webauthn-onboarding` | _Planned — see the requirement's note_ | —
-|`nonfunctional.push-latency` |`git-ents-server/src/checks.rs` (`post_receive` returns after `enqueue`) | _(structural; see `checks.post-receive`)_
+|`nonfunctional.push-latency` |`git-effect/src/engine.rs` (`post_receive` returns after `enqueue`) | _(structural; see `checks.post-receive`)_
|`nonfunctional.memory-cap` |`git-ents-server/src/web/git.rs` (`capped_read`, `capped_read_bytes`) |`web::git::tests::capped_read_*`
-|`nonfunctional.concurrency` |`git-ents-server/src/http.rs` (`backend`, concurrent stdin/stdout); `checks.rs` (`spawn_blocking`) |`server::responds_to_requests`
+|`nonfunctional.concurrency` |`git-ents-server/src/http.rs` (`backend`, concurrent stdin/stdout); `git-effect/src/engine.rs` (`spawn_blocking`) |`server::responds_to_requests`
|`nonfunctional.no-panic` |workspace-wide clippy lint configuration (`#![forbid(clippy::unwrap_used, ...)]`) |`cargo clippy --workspace --all-targets`
|`nonfunctional.no-unsafe` |workspace-wide (`#![forbid(unsafe_code)]`) |`cargo clippy --workspace --all-targets`
|`nonfunctional.object-store` |`git-store/src/lib.rs` (`Store::open`, common-dir odb) | _(see the module's own doc comment; exercised by hook integration tests)_
|`compat.git` |`git-ents-server/src/http.rs`, `web/git.rs` (subprocess invocations) | _(implicit in all integration tests)_
|`compat.ssh-keygen` |`git-signed-push/src/lib.rs` (`verify_certificate`), `web/write.rs` (`verify_login_signature`) |`pre_receive::*`, `web_edit::*`
|`compat.openssh-signed-push` |`git-signed-push/src/lib.rs` (`GIT_PUSH_CERT`, `GIT_PUSH_CERT_NONCE_STATUS`) |`pre_receive::rejects_a_push_signed_by_an_expired_key`
-|`compat.sprite` |`git-ents-server/src/checks.rs` (`ensure_auth`, `ensure_sprite`) | _(requires a live Sprite; not covered by unit tests)_
+|`compat.sprite` |`git-effect/src/engine.rs` (`ensure_auth`, `ensure_sprite`) | _(requires a live Sprite; not covered by unit tests)_
|`compat.cgi` |`git-ents-server/src/http.rs` (`backend`, CGI env vars) |`server::push_then_clone_round_trip`
|`compat.edition` |workspace `Cargo.toml` (`edition = "2024"`, `publish = false`) | _(build-time)_
|`deploy.fly` |`.config/fly.toml` | _(deployment-time)_
crates/git-ents-core/src/lib.rs
@@ -2,7 +2,6 @@
//! `git_store`, common to the CLI porcelain and the server.
pub mod account;
-pub mod checks;
pub mod config;
pub mod issues;
#[cfg(test)]
crates/git-ents-core/src/testutil.rs
@@ -170,74 +170,6 @@
assert!(status.success());
}
-/// Lay the checks document out at [`crate::checks::CHECKS_REF`] as the real
-/// on-disk format: a bare scalar-keyed map at the ref's tree root, one
-/// `<name>/command/some` blob per configured check (the map value is a
-/// `CheckBody` subtree whose `command` is the `Option` tree encoding, and the
-/// map itself is the whole document — no wrapper struct), with the optional
-/// `image`/`depends` fields omitted entirely — asserting the loader fills a
-/// check's missing optional fields as unset, independent of the writer.
-pub(crate) fn write_checks_doc(repo: &Path, checks: &[(&str, &str)]) {
- let mut entries = String::new();
- for (name, command) in checks {
- let command_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], command);
- let some_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!("100644 blob {command_blob}\tsome\n"),
- );
- let check_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!("040000 tree {some_tree}\tcommand\n"),
- );
- entries.push_str(&format!("040000 tree {check_tree}\t{name}\n"));
- }
- let checks_tree = git_with_stdin(repo, &["mktree"], &entries);
- let commit = git_with_stdin(repo, &["commit-tree", &checks_tree, "-m", "fixture"], "");
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", crate::checks::CHECKS_REF, &commit])
- .status()
- .unwrap();
- assert!(status.success());
-}
-
-/// Lay a `results/<name>` run-outcomes document out at `refname` as the real
-/// on-disk format: a `results/<name>/status/<Variant>` subtree per outcome
-/// (the `Status` enum's unit variant resolving to an empty tree, exactly like
-/// `Member`'s `provenance`), with `duration_secs`/`recording` omitted entirely —
-/// asserting the loader fills a record's missing optional fields as unset,
-/// independent of the writer. `variant` is the `Status` variant's name
-/// (`"Pass"`, `"Fail"`, …).
-pub(crate) fn write_runs_doc(repo: &Path, refname: &str, outcomes: &[(&str, &str)]) {
- let empty_tree = git_with_stdin(repo, &["mktree"], "");
- let mut entries = String::new();
- for (name, variant) in outcomes {
- let variant_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!("040000 tree {empty_tree}\t{variant}\n"),
- );
- let outcome_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!("040000 tree {variant_tree}\tstatus\n"),
- );
- entries.push_str(&format!("040000 tree {outcome_tree}\t{name}\n"));
- }
- let results_tree = git_with_stdin(repo, &["mktree"], &entries);
- let commit = git_with_stdin(repo, &["commit-tree", &results_tree, "-m", "fixture"], "");
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", refname, &commit])
- .status()
- .unwrap();
- assert!(status.success());
-}
-
/// Run git in `repo` with `input` on stdin, returning its trimmed stdout.
fn git_with_stdin(repo: &Path, args: &[&str], input: &str) -> String {
git_store::test_support::git_with_stdin(repo, args, input)
crates/git-ents-server/src/http.rs
@@ -146,7 +146,7 @@
.env("REQUEST_METHOD", method.as_str())
// Hand the `post-receive` hook the queue it drops jobs into; it inherits
// this through the receive-pack process tree git spawns.
- .env(crate::checks::QUEUE_ENV, &state.checks_queue)
+ .env(git_effect::engine::QUEUE_ENV, &state.checks_queue)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
// Surface backend diagnostics in the server's own logs rather than
@@ -591,7 +591,7 @@
sessions: crate::web::new_sessions(),
challenges: crate::web::new_challenges(),
web_signing_key: None,
- live_runs: crate::checks::new_live_registry(),
+ live_runs: git_effect::engine::new_live_registry(),
}
}
crates/git-ents-server/src/lib.rs
@@ -4,7 +4,6 @@
//! subcommand, alongside the standalone `git-ents-server` binary.
mod asciidoc;
-mod checks;
mod http;
mod markdown;
/// MIME-keyed document rendering (HTML and plain-text), shared by the web
@@ -92,7 +91,7 @@
/// bundled `pre-receive` verifier.
pub(crate) hooks_dir: Option<PathBuf>,
/// Directory the `post-receive` hook queues pushes into and the check
- /// worker drains; passed down to the hook via [`checks::QUEUE_ENV`].
+ /// worker drains; passed down to the hook via [`git_effect::engine::QUEUE_ENV`].
pub(crate) checks_queue: PathBuf,
/// In-memory web sessions: a browser's signed-in public key, held for the
/// life of the process and never persisted.
@@ -104,7 +103,7 @@
pub(crate) web_signing_key: Option<PathBuf>,
/// Live output for checks the worker currently has running, polled by the
/// Checks tab's live view.
- pub(crate) live_runs: checks::LiveRegistry,
+ pub(crate) live_runs: git_effect::engine::LiveRegistry,
}
/// The non-empty value of the environment variable `key`, or `None`.
@@ -130,8 +129,8 @@
if let Some(Command::PostReceive) = args.command {
// A post-receive failure cannot undo the push; report and exit clean so
// a runner hiccup never looks like a rejected push.
- if let Err(reason) = checks::post_receive() {
- eprintln!("checks: {reason}");
+ if let Err(reason) = git_effect::engine::post_receive() {
+ eprintln!("effects: {reason}");
}
return ExitCode::SUCCESS;
}
@@ -177,11 +176,11 @@
sessions: web::new_sessions(),
challenges: web::new_challenges(),
web_signing_key,
- live_runs: checks::new_live_registry(),
+ live_runs: git_effect::engine::new_live_registry(),
};
- // Drain queued pushes and run their checks for the life of the server.
- tokio::spawn(checks::worker(
+ // Drain queued pushes and run their effects for the life of the server.
+ tokio::spawn(git_effect::engine::worker(
state.checks_queue.clone(),
state.live_runs.clone(),
));
crates/git-ents/src/main.rs
@@ -2,8 +2,9 @@
//!
//! It carries `git ents members` for managing the repository members recorded
//! one-ref-per-person at `refs/meta/member/<username>`, `git ents account` for
-//! the account identity at `refs/meta/account`, `git ents checks` for the check
-//! set, `git ents toolchain` for the toolchains stored as git trees at
+//! the account identity at `refs/meta/account`, `git ents checks` for the
+//! effect set at one-ref-per-effect `refs/meta/effects/<name>`, `git ents
+//! toolchain` for the toolchains stored as git trees at
//! `refs/meta/toolchains/<name>` (`git-toolchain`), `git ents comment` for the
//! code comments at `refs/meta/comments/<id>`, and the client setup that
//! produces the signed pushes the server requires. The member commands read
@@ -24,11 +25,10 @@
use figue::{self as args, FigueBuiltins};
use git_anchor::{LineRange, Projection};
use git_comment::{COMMENTS_NS, Comment};
+use git_effect::Effect;
use git_ents_core::account::{self, Account};
-use git_ents_core::checks::{self, CHECKS_REF, Check};
use git_member::members::{self, MEMBER_NS, Member, Trust, member_ref};
use git_member::revocations::{self, REVOKED_REF, Revocation};
-use git_store::component::{self, Component, MapDocument};
use git_toolchain::TOOLCHAINS_NS;
/// Helpful guardians of your git trees.
@@ -59,7 +59,7 @@
#[facet(args::subcommand)]
action: AccountAction,
},
- /// Manage the configured checks at `refs/meta/checks`.
+ /// Manage the configured checks at `refs/meta/effects/<name>`.
Checks {
#[facet(args::subcommand)]
action: ChecksAction,
@@ -235,7 +235,7 @@
/// `git ents login <remote>` first.
Debug,
/// Show recorded check runs (queued/running/pass/fail/error) from
- /// `refs/meta/runs/*` on a remote, newest first.
+ /// `refs/meta/results/*` on a remote, newest first.
Runs,
}
@@ -476,7 +476,7 @@
/// @relation(cli.account-checks)
fn run_checks(action: ChecksAction, remote: &str) -> Result<(), String> {
match action {
- ChecksAction::List => list::<Check>(remote),
+ ChecksAction::List => effect_list(remote),
ChecksAction::Add {
name,
command,
@@ -484,7 +484,7 @@
depends,
toolchains,
} => add_check(name, command, image, depends, toolchains, remote),
- ChecksAction::Remove { name } => remove::<Check>(&name, remote),
+ ChecksAction::Remove { name } => effect_remove(&name, remote),
ChecksAction::Debug => checks_debug(remote),
ChecksAction::Runs => checks_runs(remote),
}
@@ -494,8 +494,8 @@
/// newest commit first, as `<commit> <when> <check>=<status> …`.
fn checks_runs(remote: &str) -> Result<(), String> {
let repo = repo()?;
- sync_namespace(remote, checks::RUNS_NS)?;
- let commits = checks::runs(&repo).map_err(|error| error.to_string())?;
+ sync_namespace(remote, git_effect::RESULTS_NS)?;
+ let commits = git_effect::runs(&repo).map_err(|error| error.to_string())?;
if commits.is_empty() {
println!("no check runs on {remote}");
return Ok(());
@@ -952,54 +952,6 @@
id.get(..12).unwrap_or(id)
}
-/// A `refs/meta/*` set the porcelain manages uniformly: a named ref synced from
-/// and pushed to a remote, holding entries the CLI lists and removes from. The
-/// check set runs through this; the member set is decomposed across
-/// `refs/meta/member/*` and handled on its own, and the revocation set's CLI
-/// needs more than these four methods (fingerprint validation, a
-/// lock-yourself-out confirmation) so it stays bespoke too. `REF`, `NOUN`,
-/// and the default [`load`](Set::load)/[`store`](Set::store) come from the
-/// item's own [`MapDocument`]/[`Component`] impls, so a `Set` impl needs only
-/// say how an entry lists and what its key is.
-trait Set: MapDocument + Component {
- /// The line printed when the set is empty on `remote`.
- fn empty_listing(remote: &str) -> String;
- /// An item's key — its identity for removal and the left list column.
- fn key(item: &Self) -> String;
- /// The right list column for an item.
- fn value(item: &Self) -> String;
-
- /// The set's items.
- fn load(repo: &Path) -> Result<Vec<Self>, String> {
- component::load_map(&git_store::Store::open(repo).map_err(|error| error.to_string())?)
- .map_err(|error| error.to_string())
- }
-
- /// Replace the set with `items`.
- fn store(repo: &Path, items: &[Self]) -> Result<(), String> {
- component::store_map(
- &git_store::Store::open(repo).map_err(|error| error.to_string())?,
- items,
- &format!("Update {}", Self::PLURAL),
- )
- .map_err(|error| error.to_string())
- }
-}
-
-impl Set for Check {
- fn empty_listing(remote: &str) -> String {
- format!("no checks configured on {remote}")
- }
-
- fn key(item: &Check) -> String {
- item.name.clone()
- }
-
- fn value(item: &Check) -> String {
- item.pretty().to_string()
- }
-}
-
/// The trailing ` (after …, before …)` annotation for a member's validity
/// window, or `""` when unbounded — so an expiry that has been set is visible at
/// a glance rather than hidden in the stored `allowed_signers` options.
@@ -1019,46 +971,39 @@
}
// @relation(cli.account-checks, cli.remote-admin)
-/// Print each entry of the set `S` on `remote` as `<key> <value>`.
-fn list<S: Set>(remote: &str) -> Result<(), String> {
+/// Print every effect on `remote` as `<name> <command>`.
+fn effect_list(remote: &str) -> Result<(), String> {
let repo = repo()?;
- sync(remote, S::REF)?;
- let items = S::load(&repo)?;
- if items.is_empty() {
- println!("{}", S::empty_listing(remote));
+ sync_namespace(remote, git_effect::EFFECTS_NS)?;
+ let mut effects = git_effect::load_all(&repo).map_err(|error| error.to_string())?;
+ if effects.is_empty() {
+ println!("no checks configured on {remote}");
return Ok(());
}
- for item in items {
- println!("{} {}", S::key(&item), S::value(&item));
+ effects.sort_by(|a, b| a.name.cmp(&b.name));
+ for effect in effects {
+ println!("{} {}", effect.name, effect.pretty());
}
Ok(())
}
// @relation(cli.account-checks)
-/// Drop the entry keyed `key` from the set `S` on `remote` and push the update.
-fn remove<S: Set>(key: &str, remote: &str) -> Result<(), String> {
- let repo = repo()?;
- let expected = sync(remote, S::REF)?;
- let before = S::load(&repo)?;
- let count = before.len();
- let after: Vec<S> = before
- .into_iter()
- .filter(|item| S::key(item) != key)
- .collect();
- if after.len() == count {
- return Err(format!("no {} named {key} on {remote}", S::NOUN));
- }
- S::store(&repo, &after)?;
- push_signed(remote, S::REF, expected.as_deref())?;
- println!("removed {key}");
+/// Drop the effect named `name` on `remote` and push the update.
+fn effect_remove(name: &str, remote: &str) -> Result<(), String> {
+ let refname = git_effect::effect_ref(name);
+ let expected =
+ sync(remote, &refname)?.ok_or_else(|| format!("no check named {name} on {remote}"))?;
+ push_delete(remote, &refname, &expected)?;
+ println!("removed {name}");
Ok(())
}
-/// Add `name` running `command` to `remote`'s set, replacing any check already
-/// recorded under that name, and push the update. Prompts for any field left
-/// unset when run at an interactive terminal. The whole set is validated as a
-/// dependency graph (`checks::order`) before it is stored, so a cycle or a
-/// dangling dependency never lands on the remote.
+/// Add `name` running `command` to `remote`'s effect set, replacing any effect
+/// already recorded under that name, and push the update. Prompts for any
+/// field left unset when run at an interactive terminal. The whole set
+/// (fetched alongside `name`'s own ref) is validated as a dependency graph
+/// (`git_effect::order`) before it is stored, so a cycle or a dangling
+/// dependency never lands on the remote.
///
/// ## Requirements
///
@@ -1090,19 +1035,22 @@
toolchains
};
let repo = repo()?;
- let expected = sync(remote, CHECKS_REF)?;
- let mut checks = checks::load(&repo).map_err(|error| error.to_string())?;
- checks.retain(|check| check.name != name);
- checks.push(Check {
+ let refname = git_effect::effect_ref(&name);
+ let expected = sync(remote, &refname)?;
+ sync_namespace(remote, git_effect::EFFECTS_NS)?;
+ let mut effects = git_effect::load_all(&repo).map_err(|error| error.to_string())?;
+ effects.retain(|effect| effect.name != name);
+ let effect = Effect {
name: name.clone(),
command,
image,
depends,
toolchains,
- });
- let _ordered = checks::order(&checks)?;
- checks::store(&repo, &checks).map_err(|error| error.to_string())?;
- push_signed(remote, CHECKS_REF, expected.as_deref())?;
+ };
+ effects.push(effect.clone());
+ let _ordered = git_effect::order(&effects)?;
+ git_effect::store(&repo, &effect).map_err(|error| error.to_string())?;
+ push_signed(remote, &refname, expected.as_deref())?;
println!("recorded check {name}");
Ok(())
}
crates/git-ents-server/src/web/debug.rs
@@ -72,10 +72,13 @@
.into_response();
}
- let sprite = crate::checks::sprite_name(&repo);
+ let sprite = git_effect::engine::sprite_name(&repo);
let ready = tokio::task::spawn_blocking({
let sprite = sprite.clone();
- move || crate::checks::ensure_auth().and_then(|()| crate::checks::ensure_sprite(&sprite))
+ move || {
+ git_effect::engine::ensure_auth()
+ .and_then(|()| git_effect::engine::ensure_sprite(&sprite))
+ }
})
.await;
if !matches!(ready, Ok(Ok(()))) {
crates/git-ents-server/src/web/mod.rs
@@ -417,7 +417,7 @@
host: Option<&str>,
session: Option<write::SessionSnapshot>,
editing: bool,
- live_runs: &crate::checks::LiveRegistry,
+ live_runs: &git_effect::engine::LiveRegistry,
) -> Response {
let meta = gather_meta(repo, rel).await;
match rest.split_first() {
crates/git-ents-server/src/web/pages.rs
@@ -889,19 +889,20 @@
)
}
-/// The Checks tab. The check set lives on `refs/meta/checks` (managed with
-/// `git ents checks`); each push queues them and a worker runs them in a Sprite.
-/// "Checks on HEAD" mirrors a GitHub PR checks list — one row per configured
-/// check, its latest status against the current commit, linked to its recorded
-/// terminal session when it has one; Recent runs and Configuration below it are
-/// the full history and the raw set, as before.
+/// The Checks tab. The effect set lives one ref per effect under
+/// `refs/meta/effects` (managed with `git ents checks`); each push queues them
+/// and a worker runs them in a Sprite. "Checks on HEAD" mirrors a GitHub PR
+/// checks list — one row per configured effect, its latest status against the
+/// current commit, linked to its recorded terminal session when it has one;
+/// Recent runs and Configuration below it are the full history and the raw
+/// set, as before.
///
/// ## Requirements
///
/// @relation(web.tabs)
pub(super) async fn checks_page(repo: &Path, meta: &RepoMeta) -> Markup {
let rel = &meta.rel;
- let checks = component::load::<git_ents_core::checks::Check>(repo).await;
+ let checks = component::load::<git_effect::Effect>(repo).await;
let runs = load_runs(repo).await;
let head = git_output(repo, &["rev-parse", "HEAD"])
.await
@@ -923,9 +924,9 @@
html! {
div.page-header { h1.page-title { "Checks" } }
p.shell-note {
- "Checks are configured on " code { "refs/meta/checks" }
+ "Checks are configured on " code { "refs/meta/effects/<name>" }
" (" code { "git ents checks list" } ") and run in a Sprite after each push; "
- "each run is recorded under " code { "refs/meta/runs/<commit>" } "."
+ "each run is recorded under " code { "refs/meta/results/<effect>/<commit>" } "."
}
div.card {
div.card-header { "Checks on HEAD" }
@@ -933,7 +934,7 @@
Err(err) => div.card-row.muted { "Could not read checks: " (err) }
Ok(checks) if checks.is_empty() => {
div.card-row.muted {
- "No checks configured on " code { "refs/meta/checks" } "."
+ "No effects configured on " code { "refs/meta/effects" } "."
}
}
Ok(checks) => {
@@ -979,8 +980,8 @@
fn head_check_row(
rel: &str,
head: &str,
- check: &git_ents_core::checks::Check,
- head_run: Option<&git_ents_core::checks::Run>,
+ check: &git_effect::Effect,
+ head_run: Option<&git_effect::Run>,
) -> Markup {
let outcome =
head_run.and_then(|run| run.results.iter().find(|result| result.name == check.name));
@@ -999,7 +1000,7 @@
repo: &Path,
commit_oid: ObjectId,
name: &str,
-) -> Option<git_ents_core::checks::RunOutcome> {
+) -> Option<git_effect::RunOutcome> {
load_runs(repo)
.await
.ok()?
@@ -1021,7 +1022,7 @@
meta: &RepoMeta,
commit: &str,
name: &str,
- live_runs: &crate::checks::LiveRegistry,
+ live_runs: &git_effect::engine::LiveRegistry,
) -> Response {
let Some(commit_oid) = ObjectId::from_hex(commit.as_bytes()).ok() else {
return not_found().into_response();
@@ -1036,7 +1037,7 @@
let key = (repo.to_owned(), commit_oid, name.to_owned());
let fragment_url = format!("/{rel}/checks/{commit}/{name}/live");
let initial =
- super::render::live_fragment_body(crate::checks::live_snapshot(live_runs, &key));
+ super::render::live_fragment_body(git_effect::engine::live_snapshot(live_runs, &key));
html! {
p.shell-note {
"This check is still " (outcome.status.to_string()) "; the view below updates live."
@@ -1074,13 +1075,13 @@
repo: &Path,
commit: &str,
name: &str,
- live_runs: &crate::checks::LiveRegistry,
+ live_runs: &git_effect::engine::LiveRegistry,
) -> Response {
let Some(commit_oid) = ObjectId::from_hex(commit.as_bytes()).ok() else {
return not_found().into_response();
};
let key = (repo.to_owned(), commit_oid, name.to_owned());
- let recording = crate::checks::live_snapshot(live_runs, &key);
+ let recording = git_effect::engine::live_snapshot(live_runs, &key);
let done = recording.is_none();
let body = super::render::live_fragment_body(recording).into_string();
let header = if done { "done" } else { "running" };
@@ -1135,9 +1136,9 @@
}
/// Load the recorded runs off the async runtime, like [`component::load`].
-async fn load_runs(repo: &Path) -> Result<Vec<git_ents_core::checks::CommitRuns>, String> {
+async fn load_runs(repo: &Path) -> Result<Vec<git_effect::CommitRuns>, String> {
let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents_core::checks::runs(&repo))
+ tokio::task::spawn_blocking(move || git_effect::runs(&repo))
.await
.map_err(|err| err.to_string())?
.map_err(|err| err.to_string())
@@ -1215,7 +1216,7 @@
editing: bool,
) -> Markup {
let members = component::load::<git_member::members::Member>(repo).await;
- let checks = component::load::<git_ents_core::checks::Check>(repo).await;
+ let checks = component::load::<git_effect::Effect>(repo).await;
let config = load_repo_config(repo).await;
repo_shell(
meta,
@@ -1251,7 +1252,7 @@
(component::card(&members))
p.shell-note {
- "Commands on " code { "refs/meta/checks" } " run against each push "
+ "Commands on " code { "refs/meta/effects/*" } " run against each push "
"(" code { "git ents checks list" } ")."
}
(component::card(&checks))
crates/git-ents-server/src/web/render.rs
@@ -13,7 +13,7 @@
use facet::{Def, Facet, Peek, Type, UserType};
use maud::{Markup, PreEscaped, html};
-use git_ents_core::checks::{Check, Run, RunOutcome, Status};
+use git_effect::{Effect, Run, RunOutcome, Status};
use git_ents_core::config::{Config, RoleRules};
use git_ents_core::issues::Issue;
use git_member::members::Member;
@@ -31,11 +31,11 @@
}
}
-/// A check's name is the key and its command the value — `(composite)` for a
-/// check with none — with its image, dependencies, and toolchains appended
-/// as ` · `-joined annotations rather than the raw `Option`/`Vec` the
-/// structural walk would print.
-impl Render for Check {
+/// An effect's name is the key and its command the value — `(composite)` for
+/// an effect with none — with its image, dependencies, and toolchains
+/// appended as ` · `-joined annotations rather than the raw `Option`/`Vec`
+/// the structural walk would print.
+impl Render for Effect {
fn render(&self) -> Markup {
let mut value = self
.command
@@ -54,17 +54,17 @@
}
}
-impl Loadable for Check {
+impl Loadable for Effect {
fn load(repo: &Path) -> Result<Vec<Self>, String> {
- git_ents_core::checks::load(repo).map_err(|err| err.to_string())
+ git_effect::load_all(repo).map_err(|err| err.to_string())
}
}
-impl WebComponent for Check {
+impl WebComponent for Effect {
const TITLE: &'static str = "Checks";
fn empty() -> Markup {
- html! { div.card-row.muted { "No checks configured on " code { "refs/meta/checks" } "." } }
+ html! { div.card-row.muted { "No effects configured on " code { "refs/meta/effects" } "." } }
}
}
crates/git-ents-server/src/checks.rs → crates/git-effect/src/engine.rs
@@ -1,18 +1,18 @@
-//! Asynchronous check running: a `post-receive` hook that *queues* a push and a
-//! server-owned worker that runs the configured checks against it in a Fly.io
-//! [Sprite].
+//! Asynchronous effect running: a `post-receive` hook that *queues* a push and
+//! a server-owned worker that runs the configured effects against it in a
+//! Fly.io [Sprite].
//!
-//! Checks run *after* the refs are in and off the push connection. The hook
+//! Effects run *after* the refs are in and off the push connection. The hook
//! ([`post_receive`]) does almost nothing: it reads the pushed ref updates git
//! feeds it on stdin and drops a job file into the shared queue directory, so
-//! the push returns immediately. The long-running server drains that queue from
-//! a dedicated worker ([`worker`]); for each job it loads the check set from
-//! `refs/meta/checks` and runs every check in a Sprite — a persistent,
-//! hardware-isolated sandbox. One Sprite is kept per repository so its
-//! filesystem (and any build cache a check leaves behind) survives between
-//! pushes; the pushed tree is synced into it before the checks run. Results are
-//! recorded as run refs (and surfaced on the Checks tab), and logged to the
-//! server's own output rather than relayed to the pusher.
+//! the push returns immediately. The long-running server drains that queue
+//! from a dedicated worker ([`worker`]); for each job it loads the effect set
+//! from [`crate::definition::EFFECTS_NS`] and runs every effect in a Sprite —
+//! a persistent, hardware-isolated sandbox. One Sprite is kept per repository
+//! so its filesystem (and any build cache an effect leaves behind) survives
+//! between pushes; the pushed tree is synced into it before the effects run.
+//! Results are recorded as run refs (and surfaced on the Checks tab), and
+//! logged to the server's own output rather than relayed to the pusher.
//!
//! The Sprite is driven through the `sprite` CLI. The CLI authenticates from a
//! config file rather than the environment, so the worker first hands it the
@@ -30,11 +30,13 @@
use std::sync::{Arc, Mutex as StdMutex, PoisonError};
use std::time::{Duration, Instant};
-use git_ents_core::checks::{self, Check, RunOutcome, Status};
use gix_hash::ObjectId;
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use tokio::sync::Mutex;
+use crate::definition::{self, Effect};
+use crate::results::{self, RunOutcome, Status};
+
/// Where the pushed tree is unpacked inside the Sprite.
const WORKDIR: &str = "/work";
@@ -43,42 +45,44 @@
/// cleared: the Sprite's persistent filesystem is the extract-once cache.
const TOOLCHAINS_DIR: &str = "/toolchains";
-/// A currently-running check's growing asciicast v2 recording, keyed by the
-/// repository, the commit being checked, and the check's name.
-pub(crate) type LiveKey = (PathBuf, ObjectId, String);
+/// A currently-running effect's growing asciicast v2 recording, keyed by the
+/// repository, the commit being checked, and the effect's name.
+pub type LiveKey = (PathBuf, ObjectId, String);
-/// Live buffers for every check currently running, shared between the worker
-/// thread appending to a check's output as it arrives and the web layer
-/// polling it for a live view. A buffer exists only while its check is
+/// Live buffers for every effect currently running, shared between the
+/// worker thread appending to an effect's output as it arrives and the web
+/// layer polling it for a live view. A buffer exists only while its effect is
/// running — [`live_start`] adds it, [`live_finish`] removes it once the
/// result is recorded — so a lookup miss unambiguously means "not running"
/// rather than "running with no output yet". Asciicast is the definitive log
/// format end to end: the same string a live poll reads is, unmodified,
-/// what [`run_one`] hands back as the check's recorded `recording`.
-pub(crate) type LiveRegistry = Arc<StdMutex<HashMap<LiveKey, Arc<StdMutex<String>>>>>;
+/// what [`run_one`] hands back as the effect's recorded `recording`.
+pub type LiveRegistry = Arc<StdMutex<HashMap<LiveKey, Arc<StdMutex<String>>>>>;
-/// A fresh, empty [`LiveRegistry`] — one per server process, held on
-/// [`crate::AppState`].
-pub(crate) fn new_live_registry() -> LiveRegistry {
+/// A fresh, empty [`LiveRegistry`] — one per server process, held on the
+/// server's shared state.
+#[must_use]
+pub fn new_live_registry() -> LiveRegistry {
Arc::new(StdMutex::new(HashMap::new()))
}
-/// The text accumulated so far for a running check's live buffer, or `None`
-/// when no check is running under `key` (finished, or never started).
-pub(crate) fn live_snapshot(registry: &LiveRegistry, key: &LiveKey) -> Option<String> {
+/// The text accumulated so far for a running effect's live buffer, or `None`
+/// when no effect is running under `key` (finished, or never started).
+#[must_use]
+pub fn live_snapshot(registry: &LiveRegistry, key: &LiveKey) -> Option<String> {
let buffer = lock(registry).get(key).cloned()?;
Some(lock(&buffer).clone())
}
/// Register a fresh live buffer for `key`, returning the handle [`run_one`]
-/// appends to as the check's output arrives.
+/// appends to as the effect's output arrives.
fn live_start(registry: &LiveRegistry, key: LiveKey) -> Arc<StdMutex<String>> {
let buffer = Arc::new(StdMutex::new(String::new()));
lock(registry).insert(key, Arc::clone(&buffer));
buffer
}
-/// Remove `key`'s live buffer once its check has settled — recorded results
+/// Remove `key`'s live buffer once its effect has settled — recorded results
/// are read from the run ref from then on, not the live registry.
fn live_finish(registry: &LiveRegistry, key: &LiveKey) {
lock(registry).remove(key);
@@ -99,13 +103,14 @@
/// How often the worker scans the queue directory for new jobs.
const POLL: Duration = Duration::from_secs(2);
-/// Queue the push git is reporting for asynchronous checking, returning `Ok(())`
-/// once the jobs are enqueued. The ref updates are read from the stdin git
-/// populates for a `post-receive` hook (`<old> <new> <ref>` lines).
+/// Queue the push git is reporting for asynchronous effect running, returning
+/// `Ok(())` once the jobs are enqueued. The ref updates are read from the
+/// stdin git populates for a `post-receive` hook (`<old> <new> <ref>` lines).
///
-/// The hook does no check work itself: it writes one job file per updated branch
-/// into the shared queue directory ([`QUEUE_ENV`]) and returns, so the push is
-/// never blocked on a Sprite. The server's [`worker`] picks the jobs up.
+/// The hook does no effect work itself: it writes one job file per updated
+/// branch into the shared queue directory ([`QUEUE_ENV`]) and returns, so the
+/// push is never blocked on a Sprite. The server's [`worker`] picks the jobs
+/// up.
///
/// ## Requirements
///
@@ -122,14 +127,15 @@
return Ok(());
}
- // An empty check set leaves nothing to queue.
- let runnable = checks::load(&repo).map_err(|e| format!("could not read checks: {e}"))?;
+ // An empty effect set leaves nothing to queue.
+ let runnable =
+ definition::load_all(&repo).map_err(|e| format!("could not read effects: {e}"))?;
if runnable.is_empty() {
return Ok(());
}
let Some(queue) = std::env::var_os(QUEUE_ENV).map(PathBuf::from) else {
- eprintln!("checks: {QUEUE_ENV} is not set; skipping asynchronous checks");
+ eprintln!("effects: {QUEUE_ENV} is not set; skipping asynchronous effects");
return Ok(());
};
@@ -139,14 +145,14 @@
// tab the moment the push lands, before the worker picks it up; a
// recording hiccup is reported but never fails the hook.
let queued = statuses(&runnable, Status::Queued);
- if let Err(e) = checks::record(&repo, update.new, &queued) {
+ if let Err(e) = results::record(&repo, update.new, &queued) {
eprintln!(
- "checks: could not record queued run for {}: {e}",
+ "effects: could not record queued run for {}: {e}",
update.new
);
}
println!(
- "checks: queued {} check(s) on {}",
+ "effects: queued {} effect(s) on {}",
runnable.len(),
update.ref_name
);
@@ -154,13 +160,13 @@
Ok(())
}
-/// Every check's [`RunOutcome`] set to one shared `status` — the queued/running
-/// snapshot a run starts from before per-check results land.
-fn statuses(checks: &[Check], status: Status) -> Vec<RunOutcome> {
- checks
+/// Every effect's [`RunOutcome`] set to one shared `status` — the queued/running
+/// snapshot a run starts from before per-effect results land.
+fn statuses(effects: &[Effect], status: Status) -> Vec<RunOutcome> {
+ effects
.iter()
- .map(|check| RunOutcome {
- name: check.name.clone(),
+ .map(|effect| RunOutcome {
+ name: effect.name.clone(),
status,
duration_secs: None,
recording: None,
@@ -170,14 +176,14 @@
}
/// Run the worker that drains the queue directory, running and recording the
-/// checks for each queued push. Runs for the life of the server; the blocking
+/// effects for each queued push. Runs for the life of the server; the blocking
/// Sprite work is offloaded so it never stalls the async runtime.
///
/// Jobs are processed per repository: each tick, every repository with pending
/// jobs that is not already being worked gets its own blocking task that drains
-/// its jobs in order. Because a check can run up to [`CHECK_TIMEOUT`], serving
+/// its jobs in order. Because an effect can run up to [`CHECK_TIMEOUT`], serving
/// all repositories from one queue scan would let a single slow repository stall
-/// every other repository's checks; isolating them by repository keeps a slow
+/// every other repository's effects; isolating them by repository keeps a slow
/// repository's backlog from blocking the rest. Jobs for *one* repository stay
/// serialized so concurrent runs never collide in its single Sprite.
///
@@ -186,7 +192,7 @@
/// @relation(checks.worker, nonfunctional.concurrency)
pub async fn worker(queue: PathBuf, live: LiveRegistry) {
if let Err(e) = std::fs::create_dir_all(&queue) {
- eprintln!("checks: could not create queue directory {queue:?}: {e}");
+ eprintln!("effects: could not create queue directory {queue:?}: {e}");
return;
}
let inflight: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
@@ -242,28 +248,29 @@
fn drain_repo(jobs: &[(PathBuf, Job)], live: &LiveRegistry) {
for (path, job) in jobs {
if let Err(e) = process_job(job, live) {
- eprintln!("checks: {e}");
+ eprintln!("effects: {e}");
}
let _removed = std::fs::remove_file(path);
}
}
-/// Run the checks for one queued push in its repository's Sprite, advancing the
-/// recorded run as it goes: `running` while the Sprite is prepared, then each
-/// check flipped to its result as it finishes. Checks settle in the dependency
-/// order `checks::order` fixed at write time: a check whose dependency did not
-/// pass is recorded `skipped` without touching the Sprite, and a composite (no
-/// command) derives its status from its dependencies alone. An infra failure
-/// (an unreachable Sprite, a tree that will not sync, a check set that fails
-/// re-validation) finalizes the run as `error` rather than leaving it stuck at
-/// `running`, then returns `Err`. Returns `Ok` even when a check fails — a
-/// failing check is a recorded result, not an error.
+/// Run the effects for one queued push in its repository's Sprite, advancing
+/// the recorded run as it goes: `running` while the Sprite is prepared, then
+/// each effect flipped to its result as it finishes. Effects settle in the
+/// dependency order `definition::order` fixed at write time: an effect whose
+/// dependency did not pass is recorded `skipped` without touching the Sprite,
+/// and a composite (no command) derives its status from its dependencies
+/// alone. An infra failure (an unreachable Sprite, a tree that will not sync,
+/// an effect set that fails re-validation) finalizes the run as `error` rather
+/// than leaving it stuck at `running`, then returns `Err`. Returns `Ok` even
+/// when an effect fails — a failing effect is a recorded result, not an error.
///
/// ## Requirements
///
/// @relation(checks.worker)
fn process_job(job: &Job, live: &LiveRegistry) -> Result<(), String> {
- let runnable = checks::load(&job.repo).map_err(|e| format!("could not read checks: {e}"))?;
+ let runnable =
+ definition::load_all(&job.repo).map_err(|e| format!("could not read effects: {e}"))?;
if runnable.is_empty() {
return Ok(());
}
@@ -272,14 +279,14 @@
// Re-validate defensively: the CLI rejects an invalid graph before it is
// pushed, but a hand-crafted push could still land one. Indices into
// `runnable`/`outcomes` rather than borrows, so outcomes stay mutable.
- let ordered: Vec<usize> = match checks::order(&runnable) {
+ let ordered: Vec<usize> = match definition::order(&runnable) {
Ok(ordered) => ordered
.iter()
- .filter_map(|check| runnable.iter().position(|c| c.name == check.name))
+ .filter_map(|effect| runnable.iter().position(|c| c.name == effect.name))
.collect(),
Err(e) => {
finalize_error(&job.repo, job.new, &mut outcomes);
- return Err(format!("invalid check set: {e}"));
+ return Err(format!("invalid effect set: {e}"));
}
};
let sprite = sprite_name(&job.repo);
@@ -289,7 +296,7 @@
}
eprintln!(
- "checks: running {} check(s) on {}",
+ "effects: running {} effect(s) on {}",
runnable.len(),
job.ref_name
);
@@ -308,11 +315,11 @@
};
for index in ordered {
- let Some(check) = runnable.get(index) else {
+ let Some(effect) = runnable.get(index) else {
continue;
};
// Topological order guarantees every dependency settled already.
- let deps: Vec<Status> = check
+ let deps: Vec<Status> = effect
.depends
.iter()
.filter_map(|dep| {
@@ -323,12 +330,12 @@
})
.collect();
let all_pass = deps.iter().all(|status| *status == Status::Pass);
- match &check.command {
+ match &effect.command {
Some(command) if all_pass => {
- let command = activate(command, &check.toolchains, &toolchain_dirs);
- let key: LiveKey = (job.repo.clone(), job.new, check.name.clone());
+ let command = activate(command, &effect.toolchains, &toolchain_dirs);
+ let key: LiveKey = (job.repo.clone(), job.new, effect.name.clone());
let buffer = live_start(live, key.clone());
- let result = run_one(&sprite, &check.name, &command, &buffer);
+ let result = run_one(&sprite, &effect.name, &command, &buffer);
live_finish(live, &key);
if let Some(outcome) = outcomes.get_mut(index) {
outcome.status = result.status;
@@ -338,7 +345,7 @@
}
}
Some(_) => {
- eprintln!("checks: SKIP {} (a dependency did not pass)", check.name);
+ eprintln!("effects: SKIP {} (a dependency did not pass)", effect.name);
if let Some(outcome) = outcomes.get_mut(index) {
outcome.status = Status::Skipped;
}
@@ -346,9 +353,9 @@
None => {
let status = derive_composite(&deps);
eprintln!(
- "checks: {} {} (composite)",
+ "effects: {} {} (composite)",
status.to_string().to_uppercase(),
- check.name
+ effect.name
);
if let Some(outcome) = outcomes.get_mut(index) {
outcome.status = status;
@@ -360,7 +367,7 @@
Ok(())
}
-/// A composite check's status, derived from its dependencies' settled
+/// A composite effect's status, derived from its dependencies' settled
/// statuses: `pass` when everything passed, `fail` when anything failed or
/// errored, `skipped` when nothing failed but something was skipped.
///
@@ -383,13 +390,13 @@
/// Advance the recorded run for `new` to `outcomes`; a recording hiccup is
/// logged but never derails the worker.
fn advance(repo: &Path, new: ObjectId, outcomes: &[RunOutcome]) {
- if let Err(e) = checks::update_run(repo, new, outcomes) {
- eprintln!("checks: could not record run for {new}: {e}");
+ if let Err(e) = results::update_run(repo, new, outcomes) {
+ eprintln!("effects: could not record run for {new}: {e}");
}
}
-/// Mark every check in `outcomes` `error` and record it — the terminal state for
-/// a run the worker could not carry out.
+/// Mark every effect in `outcomes` `error` and record it — the terminal state
+/// for a run the worker could not carry out.
///
/// ## Requirements
///
@@ -457,8 +464,8 @@
/// Parse git's `<old-oid> <new-oid> <ref>` stdin into the updates worth
/// checking: branch updates with a real new tip. Deletions (a zero new oid) and
-/// the `refs/meta/*` control refs (auth, the check set itself) are skipped — the
-/// checks gate ordinary content, not the trust plumbing.
+/// the `refs/meta/*` control refs (auth, the effect set itself) are skipped —
+/// the effects gate ordinary content, not the trust plumbing.
fn parse_updates(input: &str) -> Vec<Update<'_>> {
input
.lines()
@@ -480,13 +487,14 @@
/// A Sprite name derived from the repository directory, kept to the
/// `[a-z0-9-]` a Sprite name allows so the same repo reuses the same sandbox.
///
-/// Shared with [`crate::web`]'s debug-session broker, which targets the same
-/// persistent per-repo Sprite a check run used.
+/// Shared with the web layer's debug-session broker, which targets the same
+/// persistent per-repo Sprite an effect run used.
///
/// ## Requirements
///
/// @relation(checks.sandbox)
-pub(crate) fn sprite_name(repo: &Path) -> String {
+#[must_use]
+pub fn sprite_name(repo: &Path) -> String {
let stem = repo
.file_name()
.map(|name| name.to_string_lossy())
@@ -517,7 +525,7 @@
/// ## Requirements
///
/// @relation(checks.sandbox, compat.sprite)
-pub(crate) fn ensure_auth() -> Result<(), String> {
+pub fn ensure_auth() -> Result<(), String> {
let token = std::env::var("SPRITES_TOKEN")
.ok()
.ok_or("SPRITES_TOKEN is not set in the hook environment")?;
@@ -543,7 +551,7 @@
/// ## Requirements
///
/// @relation(checks.sandbox, compat.sprite)
-pub(crate) fn ensure_sprite(sprite: &str) -> Result<(), String> {
+pub fn ensure_sprite(sprite: &str) -> Result<(), String> {
let _existing = Command::new("sprite")
.args(["create", "--skip-console", sprite])
.output()
@@ -595,8 +603,8 @@
/// Resolve and extract every distinct toolchain named across `runnable`,
/// returning each name's extracted `bin` directory inside the Sprite. A
/// failed resolution (the named ref does not exist) is the one place
-/// `checks::order` could not have caught it, since `refs/meta/toolchains/*`
-/// is a different namespace than the check set itself.
+/// `definition::order` could not have caught it, since `refs/meta/toolchains/*`
+/// is a different namespace than the effect set itself.
///
/// ## Requirements
///
@@ -604,11 +612,11 @@
fn resolve_toolchains(
repo: &Path,
sprite: &str,
- runnable: &[Check],
+ runnable: &[Effect],
) -> Result<HashMap<String, String>, String> {
let mut names: Vec<&str> = runnable
.iter()
- .flat_map(|check| check.toolchains.iter().map(String::as_str))
+ .flat_map(|effect| effect.toolchains.iter().map(String::as_str))
.collect();
names.sort_unstable();
names.dedup();
@@ -652,7 +660,7 @@
/// Prefix `command` with a `PATH` export activating `toolchains`' extracted
/// `bin` directories, declared order first (so the first-listed toolchain's
-/// `bin` wins on a name collision); a check with no toolchains is returned
+/// `bin` wins on a name collision); an effect with no toolchains is returned
/// unchanged.
///
/// ## Requirements
@@ -789,10 +797,10 @@
}
}
-/// How long a single check may run before the worker abandons it. A runaway
-/// check that outlived this — a hung build, a command blocked on input — is
+/// How long a single effect may run before the worker abandons it. A runaway
+/// effect that outlived this — a hung build, a command blocked on input — is
/// killed and recorded `error` rather than wedging the worker (and with it every
-/// other repository's checks) on the one blocking-pool thread the queue drains
+/// other repository's effects) on the one blocking-pool thread the queue drains
/// on.
///
/// ## Requirements
@@ -800,7 +808,7 @@
/// @relation(checks.outcomes)
const CHECK_TIMEOUT: Duration = Duration::from_secs(30 * 60);
-/// The fixed size a check's recorded terminal session runs at. Nothing
+/// The fixed size an effect's recorded terminal session runs at. Nothing
/// interactive ever attaches to it, so this only shapes the recording, not
/// anyone's actual terminal.
const CHECK_PTY_SIZE: PtySize = PtySize {
@@ -810,7 +818,7 @@
pixel_height: 0,
};
-/// A finished check run: its outcome, wall-clock duration, process exit code
+/// A finished effect run: its outcome, wall-clock duration, process exit code
/// (when the command ran to completion), and the full terminal session as an
/// asciicast v2 recording.
struct RunResult {
@@ -820,15 +828,15 @@
exit_code: Option<i32>,
}
-/// Run one check in the Sprite's [`WORKDIR`], recording its terminal session —
+/// Run one effect in the Sprite's [`WORKDIR`], recording its terminal session —
/// a real pty (`sprite exec --tty`), not a pipe, so the recording plays back
-/// exactly what a developer running the check by hand would see — and logging
+/// exactly what a developer running the effect by hand would see — and logging
/// a `PASS`/`FAIL` line. `live` is appended to as output arrives, in the same
/// asciicast v2 format as the final recording, so a browser can poll it for a
-/// live view of a check still in progress; it is what [`finish`] hands back
+/// live view of an effect still in progress; it is what [`finish`] hands back
/// as the recorded `recording`, not a separate representation of the same
-/// output. Returns the check's outcome; a check that exceeds [`CHECK_TIMEOUT`]
-/// or cannot be captured is [`Status::Error`].
+/// output. Returns the effect's outcome; an effect that exceeds
+/// [`CHECK_TIMEOUT`] or cannot be captured is [`Status::Error`].
///
/// ## Requirements
///
@@ -840,7 +848,7 @@
let pair = match native_pty_system().openpty(CHECK_PTY_SIZE) {
Ok(pair) => pair,
Err(e) => {
- eprintln!("checks: ERROR {name} (could not allocate a pty: {e})");
+ eprintln!("effects: ERROR {name} (could not allocate a pty: {e})");
return finish(Status::Error, start, None, live);
}
};
@@ -851,17 +859,17 @@
let mut child = match pair.slave.spawn_command(cmd) {
Ok(child) => child,
Err(e) => {
- eprintln!("checks: ERROR {name} (could not run: {e})");
+ eprintln!("effects: ERROR {name} (could not run: {e})");
return finish(Status::Error, start, None, live);
}
};
// The child holds the slave now; drop ours so the master sees EOF when the
- // check process actually exits rather than when this scope happens to end.
+ // effect process actually exits rather than when this scope happens to end.
drop(pair.slave);
let master = pair.master;
let Ok(mut reader) = master.try_clone_reader() else {
- eprintln!("checks: ERROR {name} (could not read the pty)");
+ eprintln!("effects: ERROR {name} (could not read the pty)");
let _killed = child.kill();
return finish(Status::Error, start, None, live);
};
@@ -903,7 +911,7 @@
drop(master);
if timed_out {
- eprintln!("checks: ERROR {name} (timed out after {CHECK_TIMEOUT:?})");
+ eprintln!("effects: ERROR {name} (timed out after {CHECK_TIMEOUT:?})");
let _killed = child.kill();
return finish(Status::Error, start, None, live);
}
@@ -911,23 +919,23 @@
let status = match child.wait() {
Ok(status) => status,
Err(e) => {
- eprintln!("checks: ERROR {name} (could not wait on the sprite CLI: {e})");
+ eprintln!("effects: ERROR {name} (could not wait on the sprite CLI: {e})");
return finish(Status::Error, start, None, live);
}
};
let exit_code = Some(i32::try_from(status.exit_code()).unwrap_or(i32::MAX));
if status.success() {
- eprintln!("checks: PASS {name}");
+ eprintln!("effects: PASS {name}");
finish(Status::Pass, start, exit_code, live)
} else {
- eprintln!("checks: FAIL {name} ({command})");
+ eprintln!("effects: FAIL {name} ({command})");
finish(Status::Fail, start, exit_code, live)
}
}
/// Assemble a [`RunResult`] from `live`'s accumulated recording — used on
-/// every exit path, including the failure ones, so a check that errors out
+/// every exit path, including the failure ones, so an effect that errors out
/// still keeps whatever terminal output it produced before that happened.
fn finish(
status: Status,
@@ -944,7 +952,7 @@
}
/// The asciicast v2 header line naming the terminal's fixed [`CHECK_PTY_SIZE`]
-/// — the first line of every check recording, live or finished (see
+/// — the first line of every effect recording, live or finished (see
/// <https://docs.asciinema.org/manual/asciicast/v2/>).
fn asciicast_header() -> String {
format!(
@@ -994,12 +1002,12 @@
#[test]
fn parse_updates_keeps_content_branches_only() {
let new = "1111111111111111111111111111111111111111";
+ let zero = "0".repeat(40);
let input = format!(
"{zero} {new} refs/heads/main\n\
{new} {zero} refs/heads/old\n\
- {new} {new} refs/meta/checks\n\
+ {new} {new} refs/meta/effects/fmt\n\
{new} {new} refs/heads/feature\n",
- zero = git_ents_core::ZERO_OID,
);
let updates = parse_updates(&input);
let refs: Vec<&str> = updates.iter().map(|u| u.ref_name).collect();
crates/git-effect/src/definition.rs
@@ -1,0 +1,456 @@
+//! The configured effects, sourced from `refs/meta/effects/<name>` — one ref
+//! per effect.
+//!
+//! An effect is anything a server runs against a push — CI, CD, linting,
+//! versioning gates, and so on. Decomposed one ref per effect (rather than a
+//! single aggregated map, as the prior "checks" naming used) so an effect can
+//! be added or removed as an independent, separately-history'd ref, and so the
+//! admin-only write rule can be stated as a single refname glob
+//! (`refs/meta/effects/*`) instead of gating one shared ref. The document is
+//! read and written through [`git_store`], so an effect is a typed value that
+//! lives in git — versioned, auditable, and itself pushable. Keeping it on a
+//! meta ref rather than in the worktree means an untrusted branch cannot
+//! rewrite the effects that gate it.
+//!
+//! # Migration note
+//!
+//! Effects were checks: `refs/meta/checks` (one ref, a scalar-keyed map of
+//! `checks/<name>` subtrees) decomposed to `refs/meta/effects/<name>` (one ref
+//! per effect), and `Check`/`CheckBody` renamed to [`Effect`]/`EffectBody`.
+//! Incompatible with data written in the prior layout — acceptable pre-1.0
+//! (see the format compatibility rules in `git_store`'s module docs).
+
+use std::path::Path;
+
+use facet::Facet;
+
+use git_store::component;
+
+/// The ref namespace holding the configured effects, one
+/// `refs/meta/effects/<name>` ref per effect.
+pub const EFFECTS_NS: &str = "refs/meta/effects";
+
+/// The ref holding the effect named `name`.
+#[must_use]
+pub fn effect_ref(name: &str) -> String {
+ format!("{EFFECTS_NS}/{name}")
+}
+
+/// A configured effect's on-disk body. The ref's last segment (its name) is
+/// the effect's identity, so it is not duplicated inside the body.
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+struct EffectBody {
+ /// The shell command run for the effect (e.g. `cargo fmt --check`), or
+ /// `None` for a composite effect that only aggregates its `depends`.
+ command: Option<String>,
+ /// The sandbox image the command runs in; `None` uses the default.
+ image: Option<String>,
+ /// Names of sibling effects that must pass before this one runs. Stored
+ /// as `None` when empty so an independent effect stays a minimal tree.
+ depends: Option<Vec<String>>,
+ /// Names of toolchains (`git-toolchain`, `refs/meta/toolchains/<name>`)
+ /// activated on `PATH` before the command runs. Stored as `None` when
+ /// empty, like `depends`.
+ toolchains: Option<Vec<String>>,
+}
+
+impl component::Collection for EffectBody {
+ const NS: &'static str = EFFECTS_NS;
+}
+
+/// One configured effect, assembled from its ref name and [`EffectBody`] at
+/// load.
+///
+/// ## Requirements
+///
+/// @relation(checks.definition)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Effect {
+ /// The name it is stored under (`refs/meta/effects/<name>`).
+ pub name: String,
+ /// The shell command run for the effect (e.g. `cargo fmt --check`), or
+ /// `None` for a composite effect that only aggregates its dependencies.
+ pub command: Option<String>,
+ /// The sandbox image the command runs in; `None` uses the default.
+ pub image: Option<String>,
+ /// Names of sibling effects that must pass before this one runs.
+ pub depends: Vec<String>,
+ /// Names of toolchains activated on `PATH` before the command runs.
+ pub toolchains: Vec<String>,
+}
+
+impl component::Component for Effect {
+ const NOUN: &'static str = "effect";
+ const PLURAL: &'static str = "effects";
+}
+
+fn compose(name: String, body: EffectBody) -> Effect {
+ Effect {
+ name,
+ command: body.command,
+ image: body.image,
+ depends: body.depends.unwrap_or_default(),
+ toolchains: body.toolchains.unwrap_or_default(),
+ }
+}
+
+fn decompose(effect: &Effect) -> EffectBody {
+ EffectBody {
+ command: effect.command.clone(),
+ image: effect.image.clone(),
+ depends: if effect.depends.is_empty() {
+ None
+ } else {
+ Some(effect.depends.clone())
+ },
+ toolchains: if effect.toolchains.is_empty() {
+ None
+ } else {
+ Some(effect.toolchains.clone())
+ },
+ }
+}
+
+/// Load the effect named `name` at [`effect_ref`] in `repo`, or `None` when
+/// it is not configured.
+pub fn load(repo: &Path, name: &str) -> Result<Option<Effect>, git_store::Error> {
+ let store = git_store::Store::open(repo)?;
+ Ok(
+ component::load_item::<EffectBody>(&store, name)?
+ .map(|body| compose(name.to_owned(), body)),
+ )
+}
+
+/// Load every configured effect in `repo`. An absent [`EFFECTS_NS`] yields an
+/// empty set, as on a server whose effects have not been pushed yet.
+pub fn load_all(repo: &Path) -> Result<Vec<Effect>, git_store::Error> {
+ let store = git_store::Store::open(repo)?;
+ Ok(component::list::<EffectBody>(&store)?
+ .into_iter()
+ .map(|(name, body)| compose(name, body))
+ .collect())
+}
+
+/// Write `effect` to its own [`effect_ref`] in `repo`, replacing any existing
+/// value as a new commit.
+pub fn store(repo: &Path, effect: &Effect) -> Result<(), git_store::Error> {
+ let store = git_store::Store::open(repo)?;
+ component::store_item::<EffectBody>(&store, &effect.name, &decompose(effect), "Update effect")
+}
+
+/// Validate `effects` as a static dependency graph and return them in an
+/// order that runs every effect after its dependencies — Kahn's topological
+/// sort, with ties broken by name so the order is deterministic.
+///
+/// Rejected here, at write time, so the worker only ever walks a fixed order:
+/// a `depends` entry naming no configured effect, a duplicate or self edge, an
+/// effect with neither a command nor dependencies, any dependency cycle
+/// (reported with its member names), and a `toolchains` entry that is not a
+/// valid ref-path segment. An effect that sets an `image` is also rejected
+/// until the Sprite sandbox can honor one — the field exists in the format
+/// now so supporting it later is not a data migration. Whether a named
+/// toolchain actually exists is checked server-side at job time, not here —
+/// unlike `depends`, `toolchains` cross-references a different ref
+/// namespace this function has no set of configured names to check against.
+///
+/// ## Requirements
+///
+/// @relation(checks.definition, checks.toolchains)
+pub fn order(effects: &[Effect]) -> Result<Vec<&Effect>, String> {
+ let mut by_name: std::collections::BTreeMap<&str, &Effect> = std::collections::BTreeMap::new();
+ for effect in effects {
+ if by_name.insert(effect.name.as_str(), effect).is_some() {
+ return Err(format!("effect {} is defined twice", effect.name));
+ }
+ }
+ let mut blocking: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
+ for effect in effects {
+ if effect.command.is_none() && effect.depends.is_empty() {
+ return Err(format!(
+ "effect {} has neither a command nor dependencies",
+ effect.name
+ ));
+ }
+ if effect.image.is_some() {
+ return Err(format!(
+ "effect {} sets an image, which the effects sandbox does not support yet",
+ effect.name
+ ));
+ }
+ for toolchain in &effect.toolchains {
+ if !git_store::ref_segment_ok(toolchain) {
+ return Err(format!(
+ "effect {} names an invalid toolchain {toolchain:?}",
+ effect.name
+ ));
+ }
+ }
+ let mut seen = std::collections::BTreeSet::new();
+ for dep in &effect.depends {
+ if !by_name.contains_key(dep.as_str()) {
+ return Err(format!(
+ "effect {} depends on unknown effect {dep}",
+ effect.name
+ ));
+ }
+ if dep == &effect.name {
+ return Err(format!("effect {} depends on itself", effect.name));
+ }
+ if !seen.insert(dep.as_str()) {
+ return Err(format!(
+ "effect {} lists dependency {dep} twice",
+ effect.name
+ ));
+ }
+ }
+ blocking.insert(effect.name.as_str(), effect.depends.len());
+ }
+
+ let mut ordered = Vec::with_capacity(effects.len());
+ while ordered.len() < effects.len() {
+ let ready: Vec<&str> = blocking
+ .iter()
+ .filter_map(|(name, blockers)| (*blockers == 0).then_some(*name))
+ .collect();
+ if ready.is_empty() {
+ let cycle: Vec<&str> = blocking.keys().copied().collect();
+ return Err(format!(
+ "effect dependencies form a cycle: {}",
+ cycle.join(", ")
+ ));
+ }
+ for name in ready {
+ let _ready = blocking.remove(name);
+ if let Some(effect) = by_name.get(name) {
+ ordered.push(*effect);
+ }
+ for (blocked, blockers) in blocking.iter_mut() {
+ if let Some(effect) = by_name.get(blocked)
+ && effect.depends.iter().any(|dep| dep == name)
+ {
+ *blockers = blockers.saturating_sub(1);
+ }
+ }
+ }
+ }
+ Ok(ordered)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::unwrap_used,
+ clippy::indexing_slicing,
+ clippy::let_underscore_must_use,
+ reason = "unit test"
+ )]
+
+ use super::*;
+ use crate::testutil::{unique_repo as new_repo, write_effect_doc};
+
+ fn unique_repo() -> std::path::PathBuf {
+ new_repo("effect")
+ }
+
+ fn effect(name: &str, command: &str) -> Effect {
+ Effect {
+ name: name.to_owned(),
+ command: Some(command.to_owned()),
+ image: None,
+ depends: Vec::new(),
+ toolchains: Vec::new(),
+ }
+ }
+
+ fn composite(name: &str, depends: &[&str]) -> Effect {
+ Effect {
+ name: name.to_owned(),
+ command: None,
+ image: None,
+ depends: depends.iter().map(|dep| (*dep).to_owned()).collect(),
+ toolchains: Vec::new(),
+ }
+ }
+
+ fn dependent(name: &str, command: &str, depends: &[&str]) -> Effect {
+ Effect {
+ depends: depends.iter().map(|dep| (*dep).to_owned()).collect(),
+ ..effect(name, command)
+ }
+ }
+
+ fn toolchained(name: &str, command: &str, toolchains: &[&str]) -> Effect {
+ Effect {
+ toolchains: toolchains.iter().map(|t| (*t).to_owned()).collect(),
+ ..effect(name, command)
+ }
+ }
+
+ // @relation(checks.definition, role=Verifies)
+ #[test]
+ fn store_then_load_round_trips_an_effect() {
+ let repo = unique_repo();
+ let written = effect("fmt", "cargo fmt --check");
+ store(&repo, &written).unwrap();
+ assert_eq!(load(&repo, "fmt").unwrap(), Some(written));
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn store_then_load_all_round_trips_the_effect_set() {
+ let repo = unique_repo();
+ let written = vec![
+ effect("fmt", "cargo fmt --check"),
+ effect("test", "cargo nextest run"),
+ ];
+ for item in &written {
+ store(&repo, item).unwrap();
+ }
+ let mut loaded = load_all(&repo).unwrap();
+ loaded.sort_by(|a, b| a.name.cmp(&b.name));
+ assert_eq!(loaded, written);
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn empty_when_no_effects_are_configured() {
+ let repo = unique_repo();
+ assert!(load_all(&repo).unwrap().is_empty());
+ assert!(load(&repo, "fmt").unwrap().is_none());
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn loads_the_on_disk_effect_format() {
+ // A fixture written as the real `command/some` subtree layout (the
+ // `Option`-wrapped command, with `image`/`depends`/`toolchains`
+ // omitted entirely) must keep loading, with the missing optional
+ // fields unset — guarding the effect document's shape against an
+ // incompatible change to data already on a ref.
+ let repo = unique_repo();
+ write_effect_doc(&repo, "fmt", "cargo fmt --check");
+ assert_eq!(
+ load(&repo, "fmt").unwrap(),
+ Some(effect("fmt", "cargo fmt --check"))
+ );
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ // @relation(checks.definition, role=Verifies)
+ #[test]
+ fn store_then_load_round_trips_image_and_depends() {
+ let repo = unique_repo();
+ let written = vec![
+ Effect {
+ image: Some("rust:1.88".to_owned()),
+ ..effect("fmt", "cargo fmt --check")
+ },
+ dependent("test", "cargo nextest run", &["fmt"]),
+ composite("ci", &["fmt", "test"]),
+ ];
+ for item in &written {
+ store(&repo, item).unwrap();
+ }
+ let mut loaded = load_all(&repo).unwrap();
+ loaded.sort_by(|a, b| a.name.cmp(&b.name));
+ let mut expected = written;
+ expected.sort_by(|a, b| a.name.cmp(&b.name));
+ assert_eq!(loaded, expected);
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ // @relation(checks.definition, role=Verifies)
+ #[test]
+ fn order_runs_dependencies_first() {
+ let effects = vec![
+ composite("ci", &["test", "fmt"]),
+ dependent("test", "cargo nextest run", &["fmt"]),
+ effect("fmt", "cargo fmt --check"),
+ ];
+ let names: Vec<&str> = order(&effects)
+ .unwrap()
+ .iter()
+ .map(|c| c.name.as_str())
+ .collect();
+ assert_eq!(names, vec!["fmt", "test", "ci"]);
+ }
+
+ // @relation(checks.definition, role=Verifies)
+ #[test]
+ fn order_rejects_a_cycle() {
+ let effects = vec![
+ dependent("a", "true", &["b"]),
+ dependent("b", "true", &["a"]),
+ effect("fmt", "cargo fmt --check"),
+ ];
+ let err = order(&effects).unwrap_err();
+ assert!(err.contains("cycle"), "unexpected error: {err}");
+ assert!(err.contains('a') && err.contains('b'));
+ }
+
+ // @relation(checks.definition, role=Verifies)
+ #[test]
+ fn order_rejects_an_unknown_dependency() {
+ let effects = vec![dependent("test", "cargo nextest run", &["fmt"])];
+ let err = order(&effects).unwrap_err();
+ assert!(
+ err.contains("unknown effect fmt"),
+ "unexpected error: {err}"
+ );
+ }
+
+ // @relation(checks.definition, role=Verifies)
+ #[test]
+ fn order_rejects_self_and_duplicate_edges() {
+ let selfish = vec![dependent("a", "true", &["a"])];
+ assert!(order(&selfish).unwrap_err().contains("itself"));
+ let doubled = vec![
+ effect("fmt", "true"),
+ dependent("a", "true", &["fmt", "fmt"]),
+ ];
+ assert!(order(&doubled).unwrap_err().contains("twice"));
+ }
+
+ // @relation(checks.definition, role=Verifies)
+ #[test]
+ fn order_rejects_an_empty_effect() {
+ let effects = vec![composite("hollow", &[])];
+ let err = order(&effects).unwrap_err();
+ assert!(
+ err.contains("neither a command nor dependencies"),
+ "unexpected error: {err}"
+ );
+ }
+
+ // @relation(checks.toolchains, role=Verifies)
+ #[test]
+ fn order_accepts_a_valid_toolchain_name() {
+ let effects = vec![toolchained("build", "make", &["gcc-12"])];
+ assert_eq!(
+ order(&effects)
+ .unwrap()
+ .iter()
+ .map(|c| c.name.as_str())
+ .collect::<Vec<_>>(),
+ vec!["build"]
+ );
+ }
+
+ // @relation(checks.toolchains, role=Verifies)
+ #[test]
+ fn order_rejects_an_invalid_toolchain_name() {
+ let effects = vec![toolchained("build", "make", &["not/valid"])];
+ let err = order(&effects).unwrap_err();
+ assert!(err.contains("invalid toolchain"), "unexpected error: {err}");
+ }
+
+ // @relation(checks.toolchains, role=Verifies)
+ #[test]
+ fn store_then_load_round_trips_toolchains() {
+ let repo = unique_repo();
+ let written = toolchained("build", "make", &["gcc-12", "cmake"]);
+ store(&repo, &written).unwrap();
+ assert_eq!(load(&repo, "build").unwrap(), Some(written));
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+}
crates/git-effect/src/lib.rs
@@ -1,0 +1,22 @@
+//! The Effect abstraction: anything a server runs against a push (CI, CD,
+//! linting, versioning gates), decomposed into two pieces.
+//!
+//! [`definition`] holds an effect's static shape — its command, dependencies,
+//! and toolchains, one ref per effect at `refs/meta/effects/<name>`.
+//! [`results`] holds what running an effect against a commit produced, one
+//! ref per effect per commit at `refs/meta/results/<effect>/<short-oid>`.
+//! [`engine`] runs the effects a `post-receive` hook queues, in a Sprite
+//! sandbox, and records their outcomes through `results`.
+//!
+//! This crate used to be `checks` (definitions in `git-ents-core`, execution
+//! in `git-ents-server`) — see each module's migration note for the storage
+//! rename that came with the split into its own crate.
+
+pub mod definition;
+pub mod engine;
+pub mod results;
+#[cfg(test)]
+mod testutil;
+
+pub use definition::{EFFECTS_NS, Effect, effect_ref, load, load_all, order, store};
+pub use results::{CommitRuns, RESULTS_NS, Run, RunOutcome, Status, record, runs, update_run};
crates/git-effect/src/results.rs
@@ -1,0 +1,395 @@
+//! Recorded effect runs, sourced from `refs/meta/results/<effect>/<short-oid>`
+//! — one ref per effect, per checked commit.
+//!
+//! # Migration note
+//!
+//! Results were runs: `refs/meta/runs/<commit>` (one ref per commit, a
+//! scalar-keyed map of every check's outcome) decomposed to
+//! `refs/meta/results/<effect>/<short-oid>` (one ref per effect per commit),
+//! matching [`crate::definition`]'s checks→effects decomposition. The public
+//! [`CommitRuns`]/[`Run`]/[`RunOutcome`] shape stays the aggregate view a
+//! caller wants — every effect's outcome against a commit, grouped by the
+//! moment they were recorded — reassembled in [`runs`] from the decomposed
+//! refs rather than read directly off one ref. Incompatible with data written
+//! in the prior layout — acceptable pre-1.0 (see the format compatibility
+//! rules in `git_store`'s module docs).
+
+use std::path::Path;
+
+use facet::Facet;
+use gix_hash::ObjectId;
+
+/// The ref namespace under which effect runs are recorded: one ref,
+/// `refs/meta/results/<effect>/<short-oid>`, per effect per checked commit,
+/// holding the *log* of every run of that effect against that commit.
+/// Definitions live under [`crate::definition::EFFECTS_NS`]; this is their
+/// history.
+pub const RESULTS_NS: &str = "refs/meta/results";
+
+/// How many hex characters of the checked commit's id the ref's last segment
+/// carries. The full id is also stored in the document body (see
+/// [`ResultBody::commit`]), so truncation here is purely a naming
+/// convenience, not a loss of precision.
+const SHORT_LEN: usize = 12;
+
+/// An effect run's status, progressing `Queued` → `Running` → a terminal
+/// outcome. Closed set — the only values a run legitimately takes, in place
+/// of a `String` that every caller had to trust held one of five values.
+///
+/// ## Requirements
+///
+/// @relation(checks.outcomes)
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
+#[repr(u8)]
+pub enum Status {
+ /// Enqueued by `post-receive`, not yet picked up by the worker.
+ Queued,
+ /// The worker has started this run.
+ Running,
+ /// The effect exited successfully.
+ Pass,
+ /// The effect exited with a failure.
+ Fail,
+ /// An infrastructure failure (an unreachable sandbox, a timeout) kept the
+ /// effect from completing.
+ Error,
+ /// The effect never ran because a dependency did not pass.
+ Skipped,
+}
+
+impl std::fmt::Display for Status {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Self::Queued => "queued",
+ Self::Running => "running",
+ Self::Pass => "pass",
+ Self::Fail => "fail",
+ Self::Error => "error",
+ Self::Skipped => "skipped",
+ })
+ }
+}
+
+/// One effect run's on-disk body, at `refs/meta/results/<effect>/<short-oid>`.
+/// The checked commit's full id is carried here (not just abbreviated in the
+/// ref name) so [`runs`] can recover it exactly regardless of [`SHORT_LEN`].
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+struct ResultBody {
+ /// The checked commit's full hex id.
+ commit: String,
+ /// `queued`, `running`, then `pass`, `fail`, or `error`.
+ status: Status,
+ /// How long the effect took to run, when known.
+ duration_secs: Option<u64>,
+ /// The effect's terminal session, captured as asciicast v2 (JSONL) text,
+ /// when the runner recorded one.
+ recording: Option<String>,
+ /// The command's process exit code, when the effect ran to completion
+ /// rather than erroring out before or during execution (an unreachable
+ /// sandbox, a timeout).
+ exit_code: Option<i32>,
+}
+
+/// One effect's outcome, independent of which commit or moment it was
+/// recorded for.
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct RunOutcome {
+ /// The effect's name (its `refs/meta/effects/<name>`).
+ pub name: String,
+ /// The outcome recorded for it as a run progresses.
+ pub status: Status,
+ /// How long the effect took to run, when known.
+ pub duration_secs: Option<u64>,
+ /// The effect's terminal session, captured as asciicast v2 (JSONL) text,
+ /// when the runner recorded one.
+ pub recording: Option<String>,
+ /// The command's process exit code, when the effect ran to completion
+ /// rather than erroring out before or during execution (an unreachable
+ /// sandbox, a timeout).
+ pub exit_code: Option<i32>,
+}
+
+/// One recorded execution of the effect set against a commit — every effect's
+/// outcome recorded at the same moment, reassembled from their independent
+/// per-effect refs (see the module's migration note).
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Run {
+ /// When the run was recorded, as seconds since the Unix epoch — the
+ /// underlying commits' committer date.
+ pub at: u64,
+ /// Each effect's outcome recorded at `at`, in name order.
+ pub results: Vec<RunOutcome>,
+}
+
+/// The runs recorded for one commit: its object id and every execution
+/// against it, newest first.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CommitRuns {
+ /// The checked commit's object id.
+ pub commit: ObjectId,
+ /// Every run against it, newest first.
+ pub runs: Vec<Run>,
+}
+
+/// The ref holding effect `effect`'s run history against `commit`.
+fn result_ref(effect: &str, commit: ObjectId) -> String {
+ format!(
+ "{RESULTS_NS}/{effect}/{}",
+ commit.to_hex_with_len(SHORT_LEN)
+ )
+}
+
+/// Record a run of `outcomes` against `commit` in `repo`: each effect's
+/// outcome becomes a new commit on its own `result_ref`, parented on that
+/// effect's prior run, so each effect's ref accrues its own history. Not
+/// atomic across effects — an effect's own ref is the unit of consistency
+/// here, the same one-ref-per-entity trade-off [`crate::definition`] makes.
+///
+/// ## Requirements
+///
+/// @relation(checks.outcomes)
+pub fn record(
+ repo: &Path,
+ commit: ObjectId,
+ outcomes: &[RunOutcome],
+) -> Result<(), git_store::Error> {
+ let store = git_store::Store::open(repo)?;
+ for outcome in outcomes {
+ let body = to_body(commit, outcome);
+ store.store(
+ &result_ref(&outcome.name, commit),
+ &body,
+ "Record effect run",
+ )?;
+ }
+ Ok(())
+}
+
+/// Advance the latest run recorded for each of `outcomes`' effects against
+/// `commit`, in place. Unlike [`record`], which appends a new run per effect,
+/// this replaces each effect's run ref tip (re-parented on its prior parents)
+/// so a single run's status can progress — `queued` → `running` → results —
+/// without appending a commit per transition.
+///
+/// When no run has been recorded yet for an effect the update starts one, so
+/// a worker that advances a run is self-healing even if the `queued` record
+/// never landed.
+///
+/// ## Requirements
+///
+/// @relation(checks.outcomes)
+pub fn update_run(
+ repo: &Path,
+ commit: ObjectId,
+ outcomes: &[RunOutcome],
+) -> Result<(), git_store::Error> {
+ let store = git_store::Store::open(repo)?;
+ for outcome in outcomes {
+ let body = to_body(commit, outcome);
+ store.amend(
+ &result_ref(&outcome.name, commit),
+ &body,
+ "Record effect run",
+ )?;
+ }
+ Ok(())
+}
+
+/// List the recorded runs per commit in `repo`, newest commit first. Every
+/// effect's run history under [`RESULTS_NS`] is read and grouped by checked
+/// commit, then by the recorded moment (`at`), so effects updated together
+/// (as the worker always does — see [`update_run`]) reassemble into one
+/// [`Run`] with every effect's outcome, matching the pre-decomposition shape.
+///
+/// A ref whose path does not decompose into `<effect>/<short-oid>`, or whose
+/// commit segment is not a valid hex object id, cannot have been written by
+/// [`record`]/[`update_run`], so it is skipped rather than surfaced as an
+/// error.
+pub fn runs(repo: &Path) -> Result<Vec<CommitRuns>, git_store::Error> {
+ let store = git_store::Store::open(repo)?;
+ let prefix = format!("{RESULTS_NS}/");
+ let mut by_commit: std::collections::BTreeMap<
+ ObjectId,
+ std::collections::BTreeMap<u64, Vec<RunOutcome>>,
+ > = std::collections::BTreeMap::new();
+ for refname in store.list(&prefix)? {
+ let Some(rest) = refname.strip_prefix(&prefix) else {
+ continue;
+ };
+ let Some((effect, _short_oid)) = rest.split_once('/') else {
+ continue;
+ };
+ for (at, body) in store.history::<ResultBody>(&refname)? {
+ let Some(commit) = ObjectId::from_hex(body.commit.as_bytes()).ok() else {
+ continue;
+ };
+ by_commit
+ .entry(commit)
+ .or_default()
+ .entry(at)
+ .or_default()
+ .push(from_body(effect.to_owned(), body));
+ }
+ }
+
+ let mut commits: Vec<CommitRuns> = by_commit
+ .into_iter()
+ .map(|(commit, by_at)| {
+ let mut runs: Vec<Run> = by_at
+ .into_iter()
+ .map(|(at, mut results)| {
+ results.sort_by(|a, b| a.name.cmp(&b.name));
+ Run { at, results }
+ })
+ .collect();
+ runs.sort_by_key(|run| std::cmp::Reverse(run.at));
+ CommitRuns { commit, runs }
+ })
+ .collect();
+ commits.sort_by(|a, b| {
+ let a_at = a.runs.first().map_or(0, |run| run.at);
+ let b_at = b.runs.first().map_or(0, |run| run.at);
+ b_at.cmp(&a_at)
+ });
+ Ok(commits)
+}
+
+/// Build a [`ResultBody`] from a public [`RunOutcome`] for `commit`.
+fn to_body(commit: ObjectId, outcome: &RunOutcome) -> ResultBody {
+ ResultBody {
+ commit: commit.to_string(),
+ status: outcome.status,
+ duration_secs: outcome.duration_secs,
+ recording: outcome.recording.clone(),
+ exit_code: outcome.exit_code,
+ }
+}
+
+/// Assemble a public [`RunOutcome`] named `name` from its on-disk [`ResultBody`].
+fn from_body(name: String, body: ResultBody) -> RunOutcome {
+ RunOutcome {
+ name,
+ status: body.status,
+ duration_secs: body.duration_secs,
+ recording: body.recording,
+ exit_code: body.exit_code,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::unwrap_used,
+ clippy::indexing_slicing,
+ clippy::let_underscore_must_use,
+ reason = "unit test"
+ )]
+
+ use super::*;
+ use crate::testutil::{unique_repo as new_repo, write_result_doc};
+
+ fn unique_repo() -> std::path::PathBuf {
+ new_repo("results")
+ }
+
+ fn outcome(name: &str, status: Status) -> RunOutcome {
+ RunOutcome {
+ name: name.to_owned(),
+ status,
+ duration_secs: None,
+ recording: None,
+ exit_code: None,
+ }
+ }
+
+ // @relation(checks.outcomes, role=Verifies)
+ #[test]
+ fn record_then_runs_round_trips_a_run() {
+ let repo = unique_repo();
+ let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
+ record(
+ &repo,
+ commit,
+ &[outcome("fmt", Status::Pass), outcome("test", Status::Fail)],
+ )
+ .unwrap();
+
+ let commits = runs(&repo).unwrap();
+ assert_eq!(commits.len(), 1);
+ assert_eq!(commits[0].commit, commit);
+ assert_eq!(commits[0].runs.len(), 1);
+ assert_eq!(
+ commits[0].runs[0].results,
+ vec![outcome("fmt", Status::Pass), outcome("test", Status::Fail)]
+ );
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ // @relation(checks.outcomes, role=Verifies)
+ #[test]
+ fn update_run_advances_in_place_rather_than_appending() {
+ let repo = unique_repo();
+ let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
+ record(&repo, commit, &[outcome("fmt", Status::Queued)]).unwrap();
+ update_run(&repo, commit, &[outcome("fmt", Status::Running)]).unwrap();
+ update_run(&repo, commit, &[outcome("fmt", Status::Pass)]).unwrap();
+ let commits = runs(&repo).unwrap();
+ assert_eq!(commits[0].runs.len(), 1);
+ assert_eq!(
+ commits[0].runs[0].results,
+ vec![outcome("fmt", Status::Pass)]
+ );
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn empty_when_no_runs_recorded() {
+ let repo = unique_repo();
+ assert!(runs(&repo).unwrap().is_empty());
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ // @relation(checks.outcomes, role=Verifies)
+ #[test]
+ fn round_trips_an_outcomes_duration_and_recording() {
+ let repo = unique_repo();
+ let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
+ let rich = outcome("fmt", Status::Pass);
+ let rich = RunOutcome {
+ duration_secs: Some(12),
+ recording: Some("{\"version\": 2}\n[0.5, \"o\", \"hi\\r\\n\"]\n".to_owned()),
+ exit_code: Some(0),
+ ..rich
+ };
+ record(&repo, commit, std::slice::from_ref(&rich)).unwrap();
+ let commits = runs(&repo).unwrap();
+ assert_eq!(commits[0].runs[0].results, vec![rich]);
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ // @relation(checks.outcomes, role=Verifies)
+ #[test]
+ fn displays_lowercase_status_words() {
+ assert_eq!(Status::Queued.to_string(), "queued");
+ assert_eq!(Status::Pass.to_string(), "pass");
+ assert_eq!(Status::Skipped.to_string(), "skipped");
+ }
+
+ #[test]
+ fn loads_the_on_disk_result_format() {
+ // A fixture written as the real `status/<Variant>` subtree layout,
+ // with `duration_secs`/`recording` omitted, must keep loading, with
+ // the missing optional fields unset.
+ let repo = unique_repo();
+ let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
+ write_result_doc(&repo, "fmt", commit, "Pass");
+ let commits = runs(&repo).unwrap();
+ assert_eq!(commits.len(), 1);
+ assert_eq!(commits[0].commit, commit);
+ assert_eq!(
+ commits[0].runs[0].results,
+ vec![outcome("fmt", Status::Pass)]
+ );
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+}
crates/git-effect/src/testutil.rs
@@ -1,0 +1,116 @@
+//! Shared test helpers: a throwaway git repository and builders that lay an
+//! on-disk `refs/meta/*` document out with raw git plumbing.
+//!
+//! Building the tree directly — rather than through [`git_store::Store`] —
+//! pins the *on-disk* layout each document type promises: a load test against
+//! a fixture written this way fails the moment an incompatible change to a
+//! document's [`facet::Facet`] shape stops reading data already in the wild,
+//! the failure mode that broke every push once before.
+
+#![allow(
+ clippy::unwrap_used,
+ clippy::let_underscore_must_use,
+ reason = "test support"
+)]
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+use gix_hash::ObjectId;
+
+use crate::definition::effect_ref;
+use crate::results::RESULTS_NS;
+
+/// A freshly initialized, uniquely named git repository under the temp dir.
+#[must_use]
+pub(crate) fn unique_repo(label: &str) -> PathBuf {
+ static COUNTER: AtomicUsize = AtomicUsize::new(0);
+ let n = COUNTER.fetch_add(1, Ordering::SeqCst);
+ let dir = std::env::temp_dir().join(format!("git-effect-{label}-{}-{n}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(&dir)
+ .args(["init", "-q"])
+ .status()
+ .unwrap();
+ assert!(status.success());
+ for (key, value) in [("user.email", "test@example.com"), ("user.name", "Test")] {
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(&dir)
+ .args(["config", key, value])
+ .status()
+ .unwrap();
+ assert!(status.success());
+ }
+ dir
+}
+
+/// Lay an effect document out at [`effect_ref`]`(name)` as the real on-disk
+/// format: a bare `command/some` blob (the `Option`-wrapped command), with
+/// the optional `image`/`depends`/`toolchains` fields omitted entirely.
+/// Asserts the loader fills a missing optional field as unset, independent
+/// of the writer.
+pub(crate) fn write_effect_doc(repo: &Path, name: &str, command: &str) {
+ let command_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], command);
+ let some_tree = git_with_stdin(
+ repo,
+ &["mktree"],
+ &format!("100644 blob {command_blob}\tsome\n"),
+ );
+ let root = git_with_stdin(
+ repo,
+ &["mktree"],
+ &format!("040000 tree {some_tree}\tcommand\n"),
+ );
+ let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(["update-ref", &effect_ref(name), &commit])
+ .status()
+ .unwrap();
+ assert!(status.success());
+}
+
+/// Lay a result document out at `refs/meta/results/<effect>/<commit>` as the
+/// real on-disk format: a `commit` blob (the checked commit's full hex id)
+/// and a `status/<variant>` subtree (the `Status` enum's unit variant
+/// resolving to an empty tree, exactly like `Member`'s `provenance`), with
+/// `duration_secs`/`recording`/`exit_code` omitted entirely — asserting the
+/// loader fills a result's missing optional fields as unset, independent of
+/// the writer. `variant` is the `Status` variant's name (`"Pass"`, `"Fail"`,
+/// …).
+pub(crate) fn write_result_doc(repo: &Path, effect: &str, commit: ObjectId, variant: &str) {
+ let commit_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], &commit.to_string());
+ let empty_tree = git_with_stdin(repo, &["mktree"], "");
+ let variant_tree = git_with_stdin(
+ repo,
+ &["mktree"],
+ &format!("040000 tree {empty_tree}\t{variant}\n"),
+ );
+ let root = git_with_stdin(
+ repo,
+ &["mktree"],
+ &format!(
+ "100644 blob {commit_blob}\tcommit\n\
+ 040000 tree {variant_tree}\tstatus\n"
+ ),
+ );
+ let refname = format!("{RESULTS_NS}/{effect}/{}", commit.to_hex_with_len(12));
+ let tree_commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(["update-ref", &refname, &tree_commit])
+ .status()
+ .unwrap();
+ assert!(status.success());
+}
+
+/// Run git in `repo` with `input` on stdin, returning its trimmed stdout.
+fn git_with_stdin(repo: &Path, args: &[&str], input: &str) -> String {
+ git_store::test_support::git_with_stdin(repo, args, input)
+}
crates/git-ents-core/src/checks.rs
@@ -1,780 +1,0 @@
-//! The configured checks, sourced from the `refs/meta/checks` ref.
-//!
-//! A check is anything a server runs against a push — CI, CD, linting,
-//! versioning gates, and so on. Their definitions live in exactly one place:
-//! the `refs/meta/checks` ref, whose tree is a scalar-keyed map from each
-//! check name to the [`CheckBody`] that runs it. The document is read and
-//! written through [`git_store`], so the check set is a typed value that
-//! lives in git — versioned, auditable, and itself pushable. Keeping it on a
-//! meta ref rather than in the worktree means an untrusted branch cannot
-//! rewrite the checks that gate it.
-//!
-//! # Migration note
-//!
-//! `checks/<name>` and `results/<name>` moved from bare blobs to subtrees
-//! (`CheckBody`/[`Outcome`]) so a run's outcome can carry more than one field
-//! (a duration, a log URL), and a run's [`Status`] moved from a bare string to
-//! a closed enum. [`CheckBody::command`] then moved from a required blob to an
-//! `Option` subtree when checks gained `image` and `depends`, so a composite
-//! check can exist without a command. Each is an incompatible format change:
-//! data written in a prior layout no longer loads and must be re-recorded.
-//! Acceptable pre-1.0 (see the format compatibility rules in `git_store`'s
-//! module docs).
-
-use std::path::Path;
-
-use facet::Facet;
-use gix::ObjectId;
-
-use git_store::component;
-
-/// The ref whose tree holds the configured check set.
-pub const CHECKS_REF: &str = "refs/meta/checks";
-
-/// A configured check's on-disk body. The map key (its name) is the check's
-/// identity, so it is not duplicated inside the body. `pub` only because it
-/// is [`component::MapDocument::Body`] for [`Check`]; nothing outside this
-/// module constructs one directly.
-///
-/// ## Requirements
-///
-/// @relation(checks.definition)
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct CheckBody {
- /// The shell command run for the check (e.g. `cargo fmt --check`), or
- /// `None` for a composite check that only aggregates its `depends`.
- command: Option<String>,
- /// The sandbox image the command runs in; `None` uses the default.
- image: Option<String>,
- /// Names of sibling checks that must pass before this one runs. Stored as
- /// `None` when empty so an independent check stays a minimal tree.
- depends: Option<Vec<String>>,
- /// Names of toolchains (`git-toolchain`, `refs/meta/toolchains/<name>`)
- /// activated on `PATH` before the command runs. Stored as `None` when
- /// empty, like `depends`.
- toolchains: Option<Vec<String>>,
-}
-
-/// One configured check, assembled from its map key and [`CheckBody`] at load.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Check {
- /// The name it is stored under.
- pub name: String,
- /// The shell command run for the check (e.g. `cargo fmt --check`), or
- /// `None` for a composite check that only aggregates its dependencies.
- pub command: Option<String>,
- /// The sandbox image the command runs in; `None` uses the default.
- pub image: Option<String>,
- /// Names of sibling checks that must pass before this one runs.
- pub depends: Vec<String>,
- /// Names of toolchains activated on `PATH` before the command runs.
- pub toolchains: Vec<String>,
-}
-
-impl component::MapDocument for Check {
- const REF: &'static str = CHECKS_REF;
- type Body = CheckBody;
-
- fn compose(name: String, body: CheckBody) -> Self {
- Check {
- name,
- command: body.command,
- image: body.image,
- depends: body.depends.unwrap_or_default(),
- toolchains: body.toolchains.unwrap_or_default(),
- }
- }
-
- fn decompose(&self) -> (&str, CheckBody) {
- (
- &self.name,
- CheckBody {
- command: self.command.clone(),
- image: self.image.clone(),
- depends: if self.depends.is_empty() {
- None
- } else {
- Some(self.depends.clone())
- },
- toolchains: if self.toolchains.is_empty() {
- None
- } else {
- Some(self.toolchains.clone())
- },
- },
- )
- }
-}
-
-impl component::Component for Check {
- const NOUN: &'static str = "check";
- const PLURAL: &'static str = "checks";
-}
-
-/// Load the configured checks recorded at [`CHECKS_REF`] in `repo`.
-///
-/// An absent ref yields an empty set, as on a server whose check set has not
-/// been pushed yet. A present but unreadable ref is an error so callers can
-/// distinguish corruption from "no checks configured".
-pub fn load(repo: &Path) -> Result<Vec<Check>, git_store::Error> {
- component::load_map(&git_store::Store::open(repo)?)
-}
-
-/// Write `checks` to [`CHECKS_REF`] in `repo`, replacing any existing set as a
-/// new commit.
-pub fn store(repo: &Path, checks: &[Check]) -> Result<(), git_store::Error> {
- component::store_map(&git_store::Store::open(repo)?, checks, "Update checks")
-}
-
-/// Validate `checks` as a static dependency graph and return them in an order
-/// that runs every check after its dependencies — Kahn's topological sort,
-/// with ties broken by name so the order is deterministic.
-///
-/// Rejected here, at write time, so the worker only ever walks a fixed order:
-/// a `depends` entry naming no configured check, a duplicate or self edge, a
-/// check with neither a command nor dependencies, any dependency cycle
-/// (reported with its member names), and a `toolchains` entry that is not a
-/// valid ref-path segment. A check that sets an `image` is also rejected
-/// until the Sprite sandbox can honor one — the field exists in the format
-/// now so supporting it later is not a data migration. Whether a named
-/// toolchain actually exists is checked server-side at job time, not here —
-/// unlike `depends`, `toolchains` cross-references a different ref
-/// namespace this function has no set of configured names to check against.
-///
-/// ## Requirements
-///
-/// @relation(checks.definition, checks.toolchains)
-pub fn order(checks: &[Check]) -> Result<Vec<&Check>, String> {
- let mut by_name: std::collections::BTreeMap<&str, &Check> = std::collections::BTreeMap::new();
- for check in checks {
- if by_name.insert(check.name.as_str(), check).is_some() {
- return Err(format!("check {} is defined twice", check.name));
- }
- }
- let mut blocking: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
- for check in checks {
- if check.command.is_none() && check.depends.is_empty() {
- return Err(format!(
- "check {} has neither a command nor dependencies",
- check.name
- ));
- }
- if check.image.is_some() {
- return Err(format!(
- "check {} sets an image, which the checks sandbox does not support yet",
- check.name
- ));
- }
- for toolchain in &check.toolchains {
- if !git_store::ref_segment_ok(toolchain) {
- return Err(format!(
- "check {} names an invalid toolchain {toolchain:?}",
- check.name
- ));
- }
- }
- let mut seen = std::collections::BTreeSet::new();
- for dep in &check.depends {
- if !by_name.contains_key(dep.as_str()) {
- return Err(format!(
- "check {} depends on unknown check {dep}",
- check.name
- ));
- }
- if dep == &check.name {
- return Err(format!("check {} depends on itself", check.name));
- }
- if !seen.insert(dep.as_str()) {
- return Err(format!("check {} lists dependency {dep} twice", check.name));
- }
- }
- blocking.insert(check.name.as_str(), check.depends.len());
- }
-
- let mut ordered = Vec::with_capacity(checks.len());
- while ordered.len() < checks.len() {
- let ready: Vec<&str> = blocking
- .iter()
- .filter_map(|(name, blockers)| (*blockers == 0).then_some(*name))
- .collect();
- if ready.is_empty() {
- let cycle: Vec<&str> = blocking.keys().copied().collect();
- return Err(format!(
- "check dependencies form a cycle: {}",
- cycle.join(", ")
- ));
- }
- for name in ready {
- let _ready = blocking.remove(name);
- if let Some(check) = by_name.get(name) {
- ordered.push(*check);
- }
- for (blocked, blockers) in blocking.iter_mut() {
- if let Some(check) = by_name.get(blocked)
- && check.depends.iter().any(|dep| dep == name)
- {
- *blockers = blockers.saturating_sub(1);
- }
- }
- }
- }
- Ok(ordered)
-}
-
-/// The namespace under which a commit's check runs are recorded: one ref,
-/// `refs/meta/runs/<commit>`, per checked commit, holding the *log* of every
-/// run against it. Definitions live on [`CHECKS_REF`]; this is their history.
-pub const RUNS_NS: &str = "refs/meta/runs";
-
-/// A check run's status, progressing `Queued` → `Running` → a terminal
-/// outcome. Closed set — the only values a run legitimately takes, in place
-/// of a `String` that every caller had to trust held one of five values.
-///
-/// ## Requirements
-///
-/// @relation(checks.outcomes)
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
-#[repr(u8)]
-pub enum Status {
- /// Enqueued by `post-receive`, not yet picked up by the worker.
- Queued,
- /// The worker has started this run.
- Running,
- /// The check exited successfully.
- Pass,
- /// The check exited with a failure.
- Fail,
- /// An infrastructure failure (an unreachable sandbox, a timeout) kept the
- /// check from completing.
- Error,
- /// The check never ran because a dependency did not pass.
- Skipped,
-}
-
-impl std::fmt::Display for Status {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(match self {
- Self::Queued => "queued",
- Self::Running => "running",
- Self::Pass => "pass",
- Self::Fail => "fail",
- Self::Error => "error",
- Self::Skipped => "skipped",
- })
- }
-}
-
-/// One check's on-disk outcome. The map key (the check's name) is not
-/// duplicated inside it. Optional fields absent from an older record load as
-/// unset, so a run recorded before a field existed still loads.
-///
-/// ## Requirements
-///
-/// @relation(checks.outcomes)
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-struct Outcome {
- /// `queued`, `running`, then `pass`, `fail`, or `error`.
- status: Status,
- /// How long the check took to run, when known.
- duration_secs: Option<u64>,
- /// The check's terminal session, captured as asciicast v2 (JSONL) text,
- /// when the runner recorded one.
- recording: Option<String>,
- /// The command's process exit code, when the check ran to completion
- /// rather than erroring out before or during execution (an unreachable
- /// sandbox, a timeout).
- exit_code: Option<i32>,
-}
-
-/// One check's outcome within a [`Run`], assembled from its map key and
-/// [`Outcome`] at load.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct RunOutcome {
- /// The check's name (its `checks/<name>` in [`CHECKS_REF`]).
- pub name: String,
- /// The outcome recorded for it as a run progresses.
- pub status: Status,
- /// How long the check took to run, when known.
- pub duration_secs: Option<u64>,
- /// The check's terminal session, captured as asciicast v2 (JSONL) text,
- /// when the runner recorded one.
- pub recording: Option<String>,
- /// The command's process exit code, when the check ran to completion
- /// rather than erroring out before or during execution (an unreachable
- /// sandbox, a timeout).
- pub exit_code: Option<i32>,
-}
-
-/// One recorded execution of the check set against a commit.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Run {
- /// When the run was recorded, as seconds since the Unix epoch — the run
- /// commit's committer date.
- pub at: u64,
- /// Each check's outcome, in name order.
- pub results: Vec<RunOutcome>,
-}
-
-/// The runs recorded for one commit: its object id and every execution against
-/// it, newest first.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CommitRuns {
- /// The checked commit's object id.
- pub commit: ObjectId,
- /// Every run against it, newest first.
- pub runs: Vec<Run>,
-}
-
-/// Record a run of `outcomes` for `commit` in `repo`, as a new commit on
-/// `refs/meta/runs/<commit>`, parented on the prior run so the ref's commit
-/// chain is the run history. The commit's date is the run time.
-///
-/// ## Requirements
-///
-/// @relation(checks.outcomes)
-pub fn record(
- repo: &Path,
- commit: ObjectId,
- outcomes: &[RunOutcome],
-) -> Result<(), git_store::Error> {
- let store = git_store::Store::open(repo)?;
- store.store_map(
- &format!("{RUNS_NS}/{commit}"),
- outcomes,
- outcome_split,
- "Record check run",
- )
-}
-
-/// Advance the latest run recorded for `commit` to `outcomes`, in place, in
-/// `repo`. Unlike [`record`], which appends a new run, this replaces the run
-/// ref's tip commit (re-parented on the prior run) so a single run's status can
-/// progress — `queued` → `running` → results — without appending a commit per
-/// transition.
-///
-/// When no run has been recorded yet the update starts one, so a worker that
-/// advances a run is self-healing even if the `queued` record never landed.
-///
-/// ## Requirements
-///
-/// @relation(checks.outcomes)
-pub fn update_run(
- repo: &Path,
- commit: ObjectId,
- outcomes: &[RunOutcome],
-) -> Result<(), git_store::Error> {
- let refname = format!("{RUNS_NS}/{commit}");
- let doc: std::collections::BTreeMap<String, Outcome> =
- outcomes.iter().map(outcome_split).collect();
- git_store::Store::open(repo)?.amend(&refname, &doc, "Record check run")
-}
-
-/// List the recorded runs per commit in `repo`, newest commit first. Each
-/// commit's runs are the ref's commit chain, newest first, with the run time
-/// taken from each commit's date.
-///
-/// A ref whose last segment is not a valid hex object id cannot have been
-/// written by [`record`]/[`update_run`], so it is skipped rather than
-/// surfaced as an error — the same tolerance [`runs`] already gives a foreign
-/// ref under [`RUNS_NS`].
-pub fn runs(repo: &Path) -> Result<Vec<CommitRuns>, git_store::Error> {
- let store = git_store::Store::open(repo)?;
- let prefix = format!("{RUNS_NS}/");
- let mut commits = Vec::new();
- for refname in store.list(&prefix)? {
- let Some(commit) = refname
- .strip_prefix(&prefix)
- .and_then(|hex| ObjectId::from_hex(hex.as_bytes()).ok())
- else {
- continue;
- };
- let runs = store
- .history::<std::collections::BTreeMap<String, Outcome>>(&refname)?
- .into_iter()
- .map(|(at, doc)| Run {
- at,
- results: doc
- .into_iter()
- .map(|(name, outcome)| assemble_outcome(name, outcome))
- .collect(),
- })
- .collect();
- commits.push(CommitRuns { commit, runs });
- }
- Ok(commits)
-}
-
-/// Split a public [`RunOutcome`] into its map key and on-disk [`Outcome`].
-fn outcome_split(outcome: &RunOutcome) -> (String, Outcome) {
- (
- outcome.name.clone(),
- Outcome {
- status: outcome.status,
- duration_secs: outcome.duration_secs,
- recording: outcome.recording.clone(),
- exit_code: outcome.exit_code,
- },
- )
-}
-
-/// Assemble a public [`RunOutcome`] from its map key and on-disk [`Outcome`].
-fn assemble_outcome(name: String, outcome: Outcome) -> RunOutcome {
- RunOutcome {
- name,
- status: outcome.status,
- duration_secs: outcome.duration_secs,
- recording: outcome.recording,
- exit_code: outcome.exit_code,
- }
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::indexing_slicing,
- clippy::let_underscore_must_use,
- reason = "unit test"
- )]
-
- use super::*;
- use crate::testutil::{unique_repo as new_repo, write_checks_doc, write_runs_doc};
-
- fn unique_repo() -> std::path::PathBuf {
- new_repo("checks")
- }
-
- fn check(name: &str, command: &str) -> Check {
- Check {
- name: name.to_owned(),
- command: Some(command.to_owned()),
- image: None,
- depends: Vec::new(),
- toolchains: Vec::new(),
- }
- }
-
- fn composite(name: &str, depends: &[&str]) -> Check {
- Check {
- name: name.to_owned(),
- command: None,
- image: None,
- depends: depends.iter().map(|dep| (*dep).to_owned()).collect(),
- toolchains: Vec::new(),
- }
- }
-
- fn dependent(name: &str, command: &str, depends: &[&str]) -> Check {
- Check {
- depends: depends.iter().map(|dep| (*dep).to_owned()).collect(),
- ..check(name, command)
- }
- }
-
- fn toolchained(name: &str, command: &str, toolchains: &[&str]) -> Check {
- Check {
- toolchains: toolchains.iter().map(|t| (*t).to_owned()).collect(),
- ..check(name, command)
- }
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn store_then_load_round_trips_the_check_set() {
- let repo = unique_repo();
- let written = vec![
- check("fmt", "cargo fmt --check"),
- check("test", "cargo nextest run"),
- ];
- store(&repo, &written).unwrap();
-
- let mut loaded = load(&repo).unwrap();
- loaded.sort_by(|a, b| a.name.cmp(&b.name));
- assert_eq!(loaded, written);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn store_replaces_the_previous_set() {
- let repo = unique_repo();
- store(&repo, &[check("fmt", "cargo fmt --check")]).unwrap();
- store(&repo, &[check("test", "cargo nextest run")]).unwrap();
- assert_eq!(
- load(&repo).unwrap(),
- vec![check("test", "cargo nextest run")]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn empty_when_the_checks_ref_is_absent() {
- let repo = unique_repo();
- assert!(load(&repo).unwrap().is_empty());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn loads_the_on_disk_checks_format() {
- // A fixture written as the real `checks/<name>/command/some` subtree
- // layout (the `Option`-wrapped command, with `image`/`depends`/
- // `toolchains` omitted entirely) must keep loading, with the missing
- // optional fields unset — guarding the checks document's shape
- // against an incompatible change to data already on a ref.
- let repo = unique_repo();
- write_checks_doc(
- &repo,
- &[("fmt", "cargo fmt --check"), ("test", "cargo nextest run")],
- );
- let mut loaded = load(&repo).unwrap();
- loaded.sort_by(|a, b| a.name.cmp(&b.name));
- assert_eq!(
- loaded,
- vec![
- check("fmt", "cargo fmt --check"),
- check("test", "cargo nextest run")
- ]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn loads_the_on_disk_runs_format() {
- // A fixture written as the real `results/<name>/status/<Variant>`
- // subtree layout, with `duration_secs`/`recording` omitted, must keep
- // loading, with the missing optional fields unset.
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- write_runs_doc(
- &repo,
- &format!("{RUNS_NS}/{commit}"),
- &[("fmt", "Pass"), ("test", "Fail")],
- );
- let commits = runs(&repo).unwrap();
- assert_eq!(commits.len(), 1);
- assert_eq!(commits[0].commit, commit);
- assert_eq!(commits[0].runs.len(), 1);
- assert_eq!(
- commits[0].runs[0].results,
- vec![outcome("fmt", Status::Pass), outcome("test", Status::Fail)]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- fn outcome(name: &str, status: Status) -> RunOutcome {
- RunOutcome {
- name: name.to_owned(),
- status,
- duration_secs: None,
- recording: None,
- exit_code: None,
- }
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn record_then_runs_round_trips_a_run() {
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- record(
- &repo,
- commit,
- &[outcome("fmt", Status::Pass), outcome("test", Status::Fail)],
- )
- .unwrap();
-
- let commits = runs(&repo).unwrap();
- assert_eq!(commits.len(), 1);
- assert_eq!(commits[0].commit, commit);
- assert_eq!(commits[0].runs.len(), 1);
- assert_eq!(
- commits[0].runs[0].results,
- vec![outcome("fmt", Status::Pass), outcome("test", Status::Fail)]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn recording_a_commit_again_appends_a_run() {
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- record(&repo, commit, &[outcome("fmt", Status::Fail)]).unwrap();
- record(&repo, commit, &[outcome("fmt", Status::Pass)]).unwrap();
- let commits = runs(&repo).unwrap();
- assert_eq!(commits.len(), 1);
- assert_eq!(commits[0].runs.len(), 2);
- // Newest first: the second run (pass) leads, the first (fail) follows.
- assert_eq!(
- commits[0].runs[0].results,
- vec![outcome("fmt", Status::Pass)]
- );
- assert_eq!(
- commits[0].runs[1].results,
- vec![outcome("fmt", Status::Fail)]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn empty_when_no_runs_recorded() {
- let repo = unique_repo();
- assert!(runs(&repo).unwrap().is_empty());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn round_trips_an_outcomes_duration_and_recording() {
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- let rich = RunOutcome {
- name: "fmt".to_owned(),
- status: Status::Pass,
- duration_secs: Some(12),
- recording: Some("{\"version\": 2}\n[0.5, \"o\", \"hi\\r\\n\"]\n".to_owned()),
- exit_code: Some(0),
- };
- record(&repo, commit, std::slice::from_ref(&rich)).unwrap();
- let commits = runs(&repo).unwrap();
- assert_eq!(commits[0].runs[0].results, vec![rich]);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn update_run_advances_in_place_rather_than_appending() {
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- record(&repo, commit, &[outcome("fmt", Status::Queued)]).unwrap();
- update_run(&repo, commit, &[outcome("fmt", Status::Running)]).unwrap();
- update_run(&repo, commit, &[outcome("fmt", Status::Pass)]).unwrap();
- let commits = runs(&repo).unwrap();
- assert_eq!(commits[0].runs.len(), 1);
- assert_eq!(
- commits[0].runs[0].results,
- vec![outcome("fmt", Status::Pass)]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn displays_lowercase_status_words() {
- assert_eq!(Status::Queued.to_string(), "queued");
- assert_eq!(Status::Pass.to_string(), "pass");
- assert_eq!(Status::Skipped.to_string(), "skipped");
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn store_then_load_round_trips_image_and_depends() {
- let repo = unique_repo();
- let written = vec![
- Check {
- image: Some("rust:1.88".to_owned()),
- ..check("fmt", "cargo fmt --check")
- },
- dependent("test", "cargo nextest run", &["fmt"]),
- composite("ci", &["fmt", "test"]),
- ];
- store(&repo, &written).unwrap();
- let mut loaded = load(&repo).unwrap();
- loaded.sort_by(|a, b| a.name.cmp(&b.name));
- let mut expected = written;
- expected.sort_by(|a, b| a.name.cmp(&b.name));
- assert_eq!(loaded, expected);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_runs_dependencies_first() {
- let checks = vec![
- composite("ci", &["test", "fmt"]),
- dependent("test", "cargo nextest run", &["fmt"]),
- check("fmt", "cargo fmt --check"),
- ];
- let names: Vec<&str> = order(&checks)
- .unwrap()
- .iter()
- .map(|c| c.name.as_str())
- .collect();
- assert_eq!(names, vec!["fmt", "test", "ci"]);
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_rejects_a_cycle() {
- let checks = vec![
- dependent("a", "true", &["b"]),
- dependent("b", "true", &["a"]),
- check("fmt", "cargo fmt --check"),
- ];
- let err = order(&checks).unwrap_err();
- assert!(err.contains("cycle"), "unexpected error: {err}");
- assert!(err.contains('a') && err.contains('b'));
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_rejects_an_unknown_dependency() {
- let checks = vec![dependent("test", "cargo nextest run", &["fmt"])];
- let err = order(&checks).unwrap_err();
- assert!(err.contains("unknown check fmt"), "unexpected error: {err}");
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_rejects_self_and_duplicate_edges() {
- let selfish = vec![dependent("a", "true", &["a"])];
- assert!(order(&selfish).unwrap_err().contains("itself"));
- let doubled = vec![
- check("fmt", "true"),
- dependent("a", "true", &["fmt", "fmt"]),
- ];
- assert!(order(&doubled).unwrap_err().contains("twice"));
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_rejects_an_empty_check() {
- let checks = vec![composite("hollow", &[])];
- let err = order(&checks).unwrap_err();
- assert!(
- err.contains("neither a command nor dependencies"),
- "unexpected error: {err}"
- );
- }
-
- // @relation(checks.toolchains, role=Verifies)
- #[test]
- fn order_accepts_a_valid_toolchain_name() {
- let checks = vec![toolchained("build", "make", &["gcc-12"])];
- assert_eq!(
- order(&checks)
- .unwrap()
- .iter()
- .map(|c| c.name.as_str())
- .collect::<Vec<_>>(),
- vec!["build"]
- );
- }
-
- // @relation(checks.toolchains, role=Verifies)
- #[test]
- fn order_rejects_an_invalid_toolchain_name() {
- let checks = vec![toolchained("build", "make", &["not/valid"])];
- let err = order(&checks).unwrap_err();
- assert!(err.contains("invalid toolchain"), "unexpected error: {err}");
- }
-
- // @relation(checks.toolchains, role=Verifies)
- #[test]
- fn store_then_load_round_trips_toolchains() {
- let repo = unique_repo();
- let written = vec![toolchained("build", "make", &["gcc-12", "cmake"])];
- store(&repo, &written).unwrap();
- assert_eq!(load(&repo).unwrap(), written);
- let _ = std::fs::remove_dir_all(&repo);
- }
-}