crates/cli/git-ents/src/commands/effect.rs
effect.rshistorycomment on this file
| 1 | //! `git ents effect`: define, list, show, run, and log effects |
| 2 | //! (`model.effect-definition`, `effect.local-run`). |
| 3 | |
| 4 | use ents_effect::run::{run_effect, short_oid}; |
| 5 | use ents_model::{Effect, ResultRecord, Status, namespace}; |
| 6 | use ents_receive::{Identity, propose_entity}; |
| 7 | use gix_ref_store::RefStoreRead; |
| 8 | |
| 9 | use super::{actor, signer}; |
| 10 | use crate::error::{Error, Result}; |
| 11 | use crate::mutate::outcome_to_result; |
| 12 | use crate::root::LocalRoot; |
| 13 | |
| 14 | /// `git ents effect list`: every effect currently defined. |
| 15 | /// |
| 16 | /// # Errors |
| 17 | /// |
| 18 | /// Propagates a ref-store or object read failure. |
| 19 | pub fn list(root: &LocalRoot) -> Result<Vec<(String, Effect)>> { |
| 20 | let mut out = Vec::new(); |
| 21 | for entry in root.refs.iter_prefix("refs/meta/effects/")? { |
| 22 | let (name, tip) = entry?; |
| 23 | let path = name.as_bstr().to_string(); |
| 24 | let Some(short) = path.strip_prefix("refs/meta/effects/") else { |
| 25 | continue; |
| 26 | }; |
| 27 | if short.is_empty() || short.contains('/') { |
| 28 | continue; |
| 29 | } |
| 30 | let tree = super::commit_tree(&root.objects, tip)?; |
| 31 | if let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, &root.objects) { |
| 32 | out.push((short.to_owned(), effect)); |
| 33 | } |
| 34 | } |
| 35 | Ok(out) |
| 36 | } |
| 37 | |
| 38 | /// `git ents effect add`: define (or replace) `name`. |
| 39 | /// |
| 40 | /// # Errors |
| 41 | /// |
| 42 | /// See [`crate::mutate::outcome_to_result`]. |
| 43 | pub fn add( |
| 44 | root: &LocalRoot, |
| 45 | name: &str, |
| 46 | on: String, |
| 47 | run: String, |
| 48 | toolchains: Vec<String>, |
| 49 | key: Option<std::path::PathBuf>, |
| 50 | ) -> Result<()> { |
| 51 | // Validate the trigger parses before it is ever written — a malformed |
| 52 | // trigger would otherwise be silently skipped by every future |
| 53 | // reconciliation scan (`ents_receive::reconcile`'s own tolerance rule). |
| 54 | let _: ents_query::Query = on |
| 55 | .parse() |
| 56 | .map_err(|_source| Error::InvalidArgument(format!("unparsable trigger: {on}")))?; |
| 57 | |
| 58 | let signer = signer(root, key)?; |
| 59 | let effect = Effect { |
| 60 | name: name.to_owned(), |
| 61 | trigger: on, |
| 62 | toolchains, |
| 63 | run, |
| 64 | }; |
| 65 | let ref_name = namespace::effect_ref(name)?; |
| 66 | let identity = Identity { |
| 67 | actor: actor(&signer), |
| 68 | author: None, |
| 69 | sign: &|payload| signer.sign(payload), |
| 70 | }; |
| 71 | let outcome = propose_entity( |
| 72 | &root.refs, |
| 73 | &root.objects, |
| 74 | &root.events, |
| 75 | ref_name, |
| 76 | &effect, |
| 77 | &identity, |
| 78 | &format!("Define effect {name}"), |
| 79 | root.mode(), |
| 80 | )?; |
| 81 | outcome_to_result(outcome, None)?; |
| 82 | Ok(()) |
| 83 | } |
| 84 | |
| 85 | /// `git ents effect show`: the definition, plus its result at `at` when |
| 86 | /// given. |
| 87 | /// |
| 88 | /// # Errors |
| 89 | /// |
| 90 | /// [`Error::NotFound`] if `name` has no effect definition. |
| 91 | pub fn show(root: &LocalRoot, name: &str, at: Option<String>) -> Result<(Effect, Option<Status>)> { |
| 92 | let ref_name = namespace::effect_ref(name)?; |
| 93 | let Some(tip) = root.refs.get(ref_name.as_ref())? else { |
| 94 | return Err(Error::NotFound { |
| 95 | what: format!("effect {name}"), |
| 96 | }); |
| 97 | }; |
| 98 | let tree = super::commit_tree(&root.objects, tip)?; |
| 99 | let effect = facet_git_tree::deserialize::<Effect>(&tree, &root.objects)?; |
| 100 | |
| 101 | let status = match at { |
| 102 | None => None, |
| 103 | Some(commit) => { |
| 104 | let oid = resolve_commit(root, &commit)?; |
| 105 | let results_ref = namespace::result_ref(name, &short_oid(oid))?; |
| 106 | match root.refs.get(results_ref.as_ref())? { |
| 107 | None => None, |
| 108 | Some(result_tip) => { |
| 109 | let tree = super::commit_tree(&root.objects, result_tip)?; |
| 110 | facet_git_tree::deserialize::<ResultRecord>(&tree, &root.objects) |
| 111 | .ok() |
| 112 | .map(|record| record.status) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | }; |
| 117 | Ok((effect, status)) |
| 118 | } |
| 119 | |
| 120 | /// `git ents effect run`: run `name` locally against every outstanding |
| 121 | /// commit, or a single `at` — no queue, identical materialization and |
| 122 | /// sandbox path to a hosted worker (`effect.local-run`). |
| 123 | /// |
| 124 | /// # Errors |
| 125 | /// |
| 126 | /// Propagates any failure `ents_effect::run::run_effect` reports. |
| 127 | #[expect( |
| 128 | clippy::result_large_err, |
| 129 | reason = "the closure passed to run_effect below is typed against ents_effect::Error, that \ |
| 130 | crate's own Result shape, not this crate's to box" |
| 131 | )] |
| 132 | pub fn run( |
| 133 | root: &LocalRoot, |
| 134 | name: &str, |
| 135 | at: Option<String>, |
| 136 | key: Option<std::path::PathBuf>, |
| 137 | executor: &dyn ents_effect::Executor, |
| 138 | ) -> Result<Vec<(gix_hash::ObjectId, ents_receive::Outcome)>> { |
| 139 | let ref_name = namespace::effect_ref(name)?; |
| 140 | let Some(tip) = root.refs.get(ref_name.as_ref())? else { |
| 141 | return Err(Error::NotFound { |
| 142 | what: format!("effect {name}"), |
| 143 | }); |
| 144 | }; |
| 145 | let tree = super::commit_tree(&root.objects, tip)?; |
| 146 | let effect = facet_git_tree::deserialize::<Effect>(&tree, &root.objects)?; |
| 147 | |
| 148 | let signer = signer(root, key)?; |
| 149 | let at_oid = at.map(|rev| resolve_commit(root, &rev)).transpose()?; |
| 150 | |
| 151 | let scratch = tempfile::tempdir().map_err(|source| Error::Io { |
| 152 | path: root.path.clone(), |
| 153 | source, |
| 154 | })?; |
| 155 | let cache = tempfile::tempdir().map_err(|source| Error::Io { |
| 156 | path: root.path.clone(), |
| 157 | source, |
| 158 | })?; |
| 159 | |
| 160 | // `run_effect` no longer resolves toolchain names itself: resolve and |
| 161 | // materialize each of the effect's declared toolchains here, before |
| 162 | // handing the run loop an already-materialized slice. |
| 163 | let mut toolchains = Vec::with_capacity(effect.toolchains.len()); |
| 164 | for toolchain_name in &effect.toolchains { |
| 165 | let (_, recipe) = ents_kiln::toolchain::resolve(&root.refs, &root.objects, toolchain_name)?; |
| 166 | let bin = ents_kiln::toolchain::materialize(&recipe, &root.objects, cache.path())?; |
| 167 | toolchains.push((toolchain_name.clone(), bin)); |
| 168 | } |
| 169 | |
| 170 | let author = actor(&signer); |
| 171 | let outcomes = run_effect( |
| 172 | &root.refs, |
| 173 | &root.objects, |
| 174 | &root.events, |
| 175 | executor, |
| 176 | scratch.path(), |
| 177 | &toolchains, |
| 178 | name, |
| 179 | &effect, |
| 180 | at_oid, |
| 181 | |short| canonical_result_ref(name, short), |
| 182 | &author, |
| 183 | &|payload| signer.sign(payload), |
| 184 | root.mode(), |
| 185 | )?; |
| 186 | Ok(outcomes) |
| 187 | } |
| 188 | |
| 189 | /// Build the canonical results refname for one run, in the shape |
| 190 | /// `run_effect`'s own `results_ref` parameter expects. |
| 191 | /// |
| 192 | /// # Errors |
| 193 | /// |
| 194 | /// Never in practice: `name` is an already-defined effect and `short` is |
| 195 | /// always a hex oid slice ([`ents_effect::run::short_oid`]'s own shape), so |
| 196 | /// both always compose into a well-formed refname; kept fallible only |
| 197 | /// because `ents_effect::run::run_effect`'s own signature requires it. |
| 198 | #[expect( |
| 199 | clippy::result_large_err, |
| 200 | reason = "the Result shape is ents_effect::run_effect's own signature, not this crate's to box" |
| 201 | )] |
| 202 | fn canonical_result_ref(name: &str, short: &str) -> ents_effect::Result<gix::refs::FullName> { |
| 203 | #[expect( |
| 204 | clippy::expect_used, |
| 205 | clippy::unwrap_in_result, |
| 206 | reason = "see this function's own doc: always well-formed in practice" |
| 207 | )] |
| 208 | Ok(namespace::result_ref(name, short).expect("well-formed refname segments")) |
| 209 | } |
| 210 | |
| 211 | /// `git ents effect log`: every recorded result for `name`, keyed by the |
| 212 | /// full oid of the judged commit (the identity `model.result-identity` |
| 213 | /// binds, and what `results(...)` queries match on). |
| 214 | /// |
| 215 | /// # Errors |
| 216 | /// |
| 217 | /// Propagates a ref-store or object read failure. |
| 218 | pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<(gix_hash::ObjectId, ResultRecord)>> { |
| 219 | let prefix = format!("refs/meta/results/{name}/"); |
| 220 | let mut out = Vec::new(); |
| 221 | for entry in root.refs.iter_prefix(&prefix)? { |
| 222 | let (_, tip) = entry?; |
| 223 | let tree = super::commit_tree(&root.objects, tip)?; |
| 224 | if let Ok(record) = facet_git_tree::deserialize::<ResultRecord>(&tree, &root.objects) { |
| 225 | out.push((record.target(), record)); |
| 226 | } |
| 227 | } |
| 228 | Ok(out) |
| 229 | } |
| 230 | |
| 231 | fn resolve_commit(root: &LocalRoot, rev: &str) -> Result<gix_hash::ObjectId> { |
| 232 | let repo = gix::open(&root.path)?; |
| 233 | let id = repo |
| 234 | .rev_parse_single(rev) |
| 235 | .map_err(|source| Error::InvalidArgument(format!("cannot resolve {rev}: {source}")))?; |
| 236 | Ok(id.detach()) |
| 237 | } |