git-ents.gitmain
⌘K
foforge
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

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1457,6 +1457,20 @@ "git-store", ] +[[package]] +name = "git-effect" +version = "0.0.0" +dependencies = [ + "facet", + "git-store", + "git-toolchain", + "gix-hash", + "portable-pty", + "tempfile", + "tokio", + "uuid", +] + [[package]] name = "git-ents" version = "0.0.0" @@ -1469,6 +1483,7 @@ "futures-util", "git-anchor", "git-comment", + "git-effect", "git-ents-core", "git-ents-server", "git-member", @@ -1509,6 +1524,7 @@ "getrandom 0.4.3", "git-anchor", "git-comment", + "git-effect", "git-ents-core", "git-member", "git-signed-push",
Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/git-anchor", "crates/git-comment", + "crates/git-effect", "crates/git-ents", "crates/git-ents-core", "crates/git-ents-server", @@ -55,6 +56,7 @@ form_urlencoded = "1" git-anchor = { path = "crates/git-anchor" } git-comment = { path = "crates/git-comment" } +git-effect = { path = "crates/git-effect" } git-ents-core = { path = "crates/git-ents-core" } git-ents-server = { path = "crates/git-ents-server" } git-member = { path = "crates/git-member" }
crates/git-ents-server/Cargo.toml @@ -20,6 +20,7 @@ getrandom = { workspace = true } git-anchor = { workspace = true } git-comment = { workspace = true } +git-effect = { workspace = true } git-member = { workspace = true } git-signed-push = { workspace = true } git-store = { workspace = true }
crates/git-ents/Cargo.toml @@ -14,6 +14,7 @@ futures-util = { version = "0.3.32", default-features = false, features = ["sink", "std"] } git-anchor = { workspace = true } git-comment = { workspace = true } +git-effect = { workspace = true } git-ents-core = { workspace = true } git-ents-server = { workspace = true } git-member = { workspace = true }
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/Cargo.toml @@ -1,0 +1,22 @@ +[package] +name = "git-effect" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +facet = { workspace = true } +git-store = { workspace = true } +git-toolchain = { workspace = true } +gix-hash = { workspace = true } +portable-pty = "0.9.0" +tokio = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +git-store = { workspace = true, features = ["test-support"] } +tempfile = { workspace = true } + +[lints] +workspace = true
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); - } -}