From 58a5544d44c48e8940519b987ac5d79522444daf Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 10:52:52 -0700 Subject: [PATCH 1/6] feat: heph-tui --version reports version + build SHA heph-tui was the one daily-driver binary that did not answer --version. Add the same clap `version = heph_core::VERSION` attribute that heph and hephd already carry, so all three report `X.Y.Z (sha)` consistently. Addresses the heph-tui half of the cross-binary --version task. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-tui/src/main.rs | 6 +++++- docs/changelog.d/heph-tui-version.feature.md | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/heph-tui-version.feature.md diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 3f2d834..27be96e 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -21,7 +21,11 @@ enum Action { } #[derive(Parser)] -#[command(name = "heph-tui", about = "Hephaestus task agenda / triage TUI")] +#[command( + name = "heph-tui", + version = heph_core::VERSION, + about = "Hephaestus task agenda / triage TUI" +)] struct Cli { /// Path to the hephd unix socket. Falls back to $HEPH_SOCKET, then the /// standard runtime path. diff --git a/docs/changelog.d/heph-tui-version.feature.md b/docs/changelog.d/heph-tui-version.feature.md new file mode 100644 index 0000000..17832b1 --- /dev/null +++ b/docs/changelog.d/heph-tui-version.feature.md @@ -0,0 +1 @@ +`heph-tui --version` now reports the version plus build commit (e.g. `1.0.0 (ab6701d12)`), matching `heph` and `hephd`. All three daily-driver binaries answer `--version` consistently. From fc25f6ac512d31aadb2dd9ad8115bf6373aaa6a9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 10:57:37 -0700 Subject: [PATCH 2/6] feat: --project arg is case-insensitive / prefix-fuzzy when unambiguous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `--project ` argument matched titles case-sensitively and exactly, so `--project hephaestus` or `--project heph` failed against a `Hephaestus` project. Make project-name resolution forgiving but deterministic, via a tiered match in `resolve_project_id`: 1. exact (case-sensitive) — the historical behavior; always wins 2. case-insensitive exact — only when unambiguous 3. case-insensitive prefix — only when unambiguous Ambiguous fuzzy matches resolve to None (callers report "no project named X") rather than silently picking one. This single resolver already backed `heph list --project` (via project_scope); route the CLI's task/edit/promote/parent path through it too with a new `project.resolve` RPC + `Store::resolve_project`, so every `--project` surface behaves the same. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/sqlite/links.rs | 65 ++++++++++++++++--- crates/heph-core/src/sqlite/mod.rs | 54 +++++++++++++++ crates/heph-core/src/store.rs | 7 ++ crates/heph/src/main.rs | 12 ++-- crates/hephd/src/remote.rs | 4 ++ crates/hephd/src/rpc.rs | 4 ++ docs/changelog.d/project-arg-fuzzy.feature.md | 1 + 7 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 docs/changelog.d/project-arg-fuzzy.feature.md diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 0783e16..f3e7e10 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -210,21 +210,66 @@ pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result /// Resolve a project **name** to its node id, restricted to `project`-kind /// nodes (so a like-named task/doc never wins). `None` if no such project. -/// Used by filter-view scope/exclude resolution (tech-spec §8.2). +/// Used by filter-view scope/exclude resolution (tech-spec §8.2) and the +/// `--project` CLI argument across `task`/`edit`/`promote`/`list`. +/// +/// Matching is **case-insensitive and prefix-fuzzy, but only when +/// unambiguous** — a forgiving `--project hephaestus` / `--project heph` +/// without sacrificing determinism: +/// +/// 1. **Exact** (case-sensitive) — the historical behavior; an exact title +/// always wins outright, so adding the fuzzy tiers can never change a name +/// that already resolved. +/// 2. **Case-insensitive exact** — accepted only if exactly one project +/// matches (`Work` vs `work` is ambiguous → no match). +/// 3. **Case-insensitive prefix** — accepted only if exactly one project's +/// title starts with the name. +/// +/// Ambiguous fuzzy matches resolve to `None` (treated as "no such project" by +/// callers) rather than silently picking one. pub(super) fn resolve_project_id( conn: &Connection, owner: &str, name: &str, ) -> Result> { - Ok(conn - .query_row( - "SELECT id FROM nodes - WHERE title = ?1 AND owner_id = ?2 AND kind = 'project' AND tombstoned = 0 - ORDER BY created_at, id LIMIT 1", - (name, owner), - |r| r.get(0), - ) - .optional()?) + // Live projects for this owner, in the deterministic (created_at, id) order + // the exact path has always used as its tie-break. + let mut stmt = conn.prepare( + "SELECT id, title FROM nodes + WHERE owner_id = ?1 AND kind = 'project' AND tombstoned = 0 + ORDER BY created_at, id", + )?; + let projects: Vec<(String, String)> = stmt + .query_map((owner,), |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>>()?; + + // 1. Exact, case-sensitive. + if let Some((id, _)) = projects.iter().find(|(_, title)| title == name) { + return Ok(Some(id.clone())); + } + // 2. Case-insensitive exact — only when unambiguous. + let ci: Vec<&String> = projects + .iter() + .filter(|(_, title)| title.eq_ignore_ascii_case(name)) + .map(|(id, _)| id) + .collect(); + if let [only] = ci.as_slice() { + return Ok(Some((*only).clone())); + } + if ci.len() > 1 { + return Ok(None); + } + // 3. Case-insensitive prefix — only when unambiguous. + let needle = name.to_ascii_lowercase(); + let pre: Vec<&String> = projects + .iter() + .filter(|(_, title)| title.to_ascii_lowercase().starts_with(&needle)) + .map(|(id, _)| id) + .collect(); + if let [only] = pre.as_slice() { + return Ok(Some((*only).clone())); + } + Ok(None) } /// Every project node id in the subtree rooted at `root` (inclusive): `root` diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index ebd01af..28d73b5 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -286,6 +286,13 @@ impl Store for LocalStore { tasks::project_scope(&self.conn, &self.owner_id, name) } + fn resolve_project(&self, name: &str) -> Result> { + match links::resolve_project_id(&self.conn, &self.owner_id, name)? { + Some(id) => nodes::get(&self.conn, &id), + None => Ok(None), + } + } + fn health(&self) -> Result { tasks::health(&self.conn, &self.owner_id) } @@ -491,6 +498,53 @@ mod tests { assert!(store.project_scope("Nope").is_err()); } + #[test] + fn resolve_project_is_fuzzy_only_when_unambiguous() { + use crate::model::{NewNode, NodeKind}; + let mut store = store_at(1); + let mk = |store: &mut LocalStore, title: &str| { + store + .create_node(NewNode { + kind: NodeKind::Project, + title: title.into(), + body: None, + }) + .unwrap() + .id + }; + let hephaestus = mk(&mut store, "Hephaestus"); + let garden = mk(&mut store, "Garden"); + + let id = |o: Option| o.map(|n| n.id); + + // Exact (case-sensitive) wins. + assert_eq!( + id(store.resolve_project("Hephaestus").unwrap()), + Some(hephaestus.clone()) + ); + // Case-insensitive exact. + assert_eq!( + id(store.resolve_project("hephaestus").unwrap()), + Some(hephaestus.clone()) + ); + // Unambiguous prefix. + assert_eq!( + id(store.resolve_project("heph").unwrap()), + Some(hephaestus.clone()) + ); + assert_eq!(id(store.resolve_project("gar").unwrap()), Some(garden)); + // No match at all. + assert_eq!(id(store.resolve_project("nope").unwrap()), None); + + // An exact (case-sensitive) title still wins even when it is a prefix of + // another project — exactness beats fuzziness, never ambiguous. + let work = mk(&mut store, "Work"); + let _workshop = mk(&mut store, "Workshop"); + assert_eq!(id(store.resolve_project("Work").unwrap()), Some(work)); + // …but an ambiguous prefix with no exact match resolves to nothing. + assert_eq!(id(store.resolve_project("Wor").unwrap()), None); + } + #[test] fn delete_project_unfiles_its_tasks_then_tombstones_it() { use crate::filter::ListFilter; diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 3fd8275..72e13d3 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -132,6 +132,13 @@ pub trait Store { /// `heph list --project `. Errors if the name names no project. fn project_scope(&self, name: &str) -> Result>; + /// Resolve a project NAME to its node, restricted to `project`-kind nodes + /// (so a like-named task/doc never wins). Matching is case-insensitive and + /// prefix-fuzzy when unambiguous (an exact title always wins outright). + /// `None` if no project matches. Backs the `--project` CLI argument on + /// `task`/`edit`/`promote` and project-parent resolution. + fn resolve_project(&self, name: &str) -> Result>; + /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result; diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 735bfe1..d7c123b 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -787,20 +787,16 @@ fn recurrence_value(recur: Option<&str>, rrule: Option<&str>) -> Result) -> Result> { let Some(name) = name else { return Ok(None) }; - let result = client.call("node.resolve", json!({ "title": name }))?; + let result = client.call("project.resolve", json!({ "name": name }))?; if result.is_null() { bail!("no project named {name:?} (create it with: heph project add {name:?})"); } let node: Node = serde_json::from_value(result)?; - if node.kind.as_str() != "project" { - bail!( - "{name:?} resolves to a {} node, not a project", - node.kind.as_str() - ); - } Ok(Some(node.id)) } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index c486055..43f8ad4 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -213,6 +213,10 @@ impl Store for RemoteStore { self.call_as("project.scope", json!({ "name": name })) } + fn resolve_project(&self, name: &str) -> Result> { + self.call_as("project.resolve", json!({ "name": name })) + } + fn health(&self) -> Result { self.call_as("health", json!({})) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 5f9a59c..5a46cd8 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -413,6 +413,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: ViewParams = parse(params)?; + json!(store.resolve_project(&p.name)?) + } "health" => json!(store.health()?), "search" => { let p: SearchParams = parse(params)?; diff --git a/docs/changelog.d/project-arg-fuzzy.feature.md b/docs/changelog.d/project-arg-fuzzy.feature.md new file mode 100644 index 0000000..76b1a4f --- /dev/null +++ b/docs/changelog.d/project-arg-fuzzy.feature.md @@ -0,0 +1 @@ +`--project ` is now case-insensitive and prefix-fuzzy when unambiguous, across `heph task`, `heph edit`, `heph promote`, `heph list`, and project-parent resolution. `--project heph` or `--project hephaestus` both resolve `Hephaestus`. An exact (case-sensitive) title always wins outright, and an ambiguous prefix (e.g. `Wor` matching both `Work` and `Workshop`) resolves to nothing rather than silently picking one. A new `project.resolve` RPC backs the shared resolver. From babdb21c0adcbbbf5f1c60399ebcabd2f9eeb5a7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 11:09:53 -0700 Subject: [PATCH 3/6] feat: heph context reads/edits a task's context doc by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a task's canonical-context doc body previously meant looking up its `canonical_context_id` (e.g. via `heph list --json`) and then `heph node update --body`. Add a `heph context ` command that resolves the canonical-context doc from the task's outgoing links and: * prints the body with no flag, * `--body ` replaces it (`-` reads stdin, matching `node update`), * `--append ` adds a blank-line-separated paragraph. Errors clearly when the id has no canonical-context doc (e.g. a plain doc node rather than a task). Purely a client-side CLI convenience — no new RPC. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph/src/main.rs | 67 +++++++++++++++++++ .../heph-context-command.feature.md | 1 + 2 files changed, 68 insertions(+) create mode 100644 docs/changelog.d/heph-context-command.feature.md diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index d7c123b..c327f1d 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -160,6 +160,18 @@ enum Command { #[arg(short = 'n', long, default_value_t = 10)] n: usize, }, + /// Read or edit a task's canonical-context doc body **by task id** — no + /// manual `canonical_context_id` lookup. With neither flag, prints the body. + Context { + /// Task node id. + id: String, + /// Replace the body with this text (`-` reads from stdin). + #[arg(long)] + body: Option, + /// Append this text to the body (separated by a blank line). + #[arg(long)] + append: Option, + }, /// Working-set health — the §6.2 tensions (orange vs 6, active vs ~30, …). Health, /// Create a document node. @@ -577,6 +589,35 @@ fn main() -> Result<()> { println!("Logged to {id}"); } } + Command::Context { id, body, append } => { + let doc_id = canonical_context_id(&mut client, &id)?; + match (body, append) { + (Some(_), Some(_)) => bail!("pass only one of --body / --append"), + (Some(body), None) => { + let body = read_body_arg(Some(body))?; + client.call("node.update", json!({ "id": doc_id, "body": body }))?; + println!("Set context of {id}"); + } + (None, Some(text)) => { + let current = context_body(&mut client, &doc_id)?; + let combined = if current.trim().is_empty() { + text + } else { + format!("{}\n\n{text}", current.trim_end()) + }; + client.call("node.update", json!({ "id": doc_id, "body": combined }))?; + println!("Appended to context of {id}"); + } + (None, None) => { + let current = context_body(&mut client, &doc_id)?; + if current.trim().is_empty() { + println!("(empty context)"); + } else { + println!("{}", current.trim_end()); + } + } + } + } Command::Health => { let h = client.call("health", json!({}))?; println!( @@ -800,6 +841,32 @@ fn resolve_project(client: &mut Client, name: Option<&str>) -> Result Result { + let links = client.call("links.outgoing", json!({ "id": task_id }))?; + let dst = links + .as_array() + .into_iter() + .flatten() + .find(|l| l.get("link_type").and_then(Value::as_str) == Some("canonical-context")) + .and_then(|l| l.get("dst_id").and_then(Value::as_str)); + match dst { + Some(id) => Ok(id.to_string()), + None => bail!("{task_id} has no canonical-context doc (is it a task?)"), + } +} + +/// Fetch a context doc's current body (empty string when unset). +fn context_body(client: &mut Client, doc_id: &str) -> Result { + let node = client.call("node.get", json!({ "id": doc_id }))?; + Ok(node + .get("body") + .and_then(Value::as_str) + .unwrap_or("") + .to_string()) +} + /// `--body -` reads the body from stdin; otherwise pass it through. fn read_body_arg(body: Option) -> Result> { match body.as_deref() { diff --git a/docs/changelog.d/heph-context-command.feature.md b/docs/changelog.d/heph-context-command.feature.md new file mode 100644 index 0000000..146a99c --- /dev/null +++ b/docs/changelog.d/heph-context-command.feature.md @@ -0,0 +1 @@ +New `heph context ` command reads or edits a task's canonical-context doc body **by task id**, with no manual `canonical_context_id` lookup. With no flag it prints the body; `--body ` replaces it (`-` reads stdin, like `node update`); `--append ` adds a blank-line-separated paragraph. Errors clearly on a node that has no canonical-context doc (e.g. a plain doc, not a task). From 7914232ec47b8a2dc74483a8bd59809652eb7ee2 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 11:13:47 -0700 Subject: [PATCH 4/6] feat: add a `version` RPC returning the daemon build version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPC clients (the hephaestus.nvim plugin, for its `:Heph version` command) had no way to learn which hephd they are talking to — `health` returns counts, not a version. Add a tiny `version` method returning `{ version: heph_core::VERSION }`, the same `X.Y.Z (sha)` string the binaries print for --version. No store access needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hephd/src/rpc.rs | 1 + crates/hephd/tests/rpc_socket.rs | 31 +++++++++++++++++++++++++ docs/changelog.d/version-rpc.feature.md | 1 + 3 files changed, 33 insertions(+) create mode 100644 docs/changelog.d/version-rpc.feature.md diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 5a46cd8..bc29eca 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -417,6 +417,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result json!({ "version": heph_core::VERSION }), "health" => json!(store.health()?), "search" => { let p: SearchParams = parse(params)?; diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 9debf4a..a9a2ba1 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -109,6 +109,37 @@ fn node_resolve_is_exact_not_fuzzy_over_socket() { assert_eq!(missing, Value::Null); } +#[test] +fn version_rpc_returns_build_version() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let got = c.call("version", json!({})).unwrap(); + assert_eq!(got["version"], heph_core::VERSION); +} + +#[test] +fn project_resolve_is_case_insensitive_and_prefix_fuzzy_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let proj = c + .call("node.create", json!({ "kind": "project", "title": "Hephaestus" })) + .unwrap(); + let pid = proj["id"].as_str().unwrap().to_string(); + + // Case-insensitive exact and unambiguous prefix both resolve to the project. + for name in ["Hephaestus", "hephaestus", "heph"] { + let got = c.call("project.resolve", json!({ "name": name })).unwrap(); + assert_eq!(got["id"], pid, "resolving {name:?}"); + } + // An unknown name is JSON null, not an error. + let missing = c + .call("project.resolve", json!({ "name": "nope" })) + .unwrap(); + assert_eq!(missing, Value::Null); +} + #[test] fn task_create_appears_in_next_with_context_link() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/version-rpc.feature.md b/docs/changelog.d/version-rpc.feature.md new file mode 100644 index 0000000..36171fd --- /dev/null +++ b/docs/changelog.d/version-rpc.feature.md @@ -0,0 +1 @@ +New `version` RPC returns the daemon's build version (`heph_core::VERSION`, e.g. `1.0.0 (ab6701d12)`), so RPC clients — notably the `hephaestus.nvim` plugin's `:Heph version` command — can report which `hephd` they are talking to without shelling out to the binary. From 8d802087264ffd5e69ad6be150bbbfd0f621d446 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 12:31:01 -0700 Subject: [PATCH 5/6] style: rustfmt the new rpc_socket tests The project.resolve socket test had a node.create call over rustfmt's line limit; CI's `cargo fmt --all --check` flagged it. Wrap it. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hephd/tests/rpc_socket.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index a9a2ba1..a0c95bc 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -124,7 +124,10 @@ fn project_resolve_is_case_insensitive_and_prefix_fuzzy_over_socket() { let mut c = client(&socket); let proj = c - .call("node.create", json!({ "kind": "project", "title": "Hephaestus" })) + .call( + "node.create", + json!({ "kind": "project", "title": "Hephaestus" }), + ) .unwrap(); let pid = proj["id"].as_str().unwrap().to_string(); From 7f48a2a1c53ceeb5cee8fa25f1ae9f3ef1d6ac32 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 12:36:12 -0700 Subject: [PATCH 6/6] infra: add cargo-fmt-check pre-push prek hook (mirror CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cargo-fmt failure on this PR slipped to CI because the pre-commit prek hooks were never installed in the working clone. The existing cargo-fmt hook reformats in place but only when it runs. Add a pre-push cargo-fmt-check hook (`cargo fmt --all --check`) that mirrors CI's Dagger `check` step exactly, so an unformatted commit is blocked locally before it can reach the runner — even if the pre-commit hook was skipped or not installed. Filtered to .rs pushes so Rust-free pushes pay nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/changelog.d/prek-fmt-prepush.infra.md | 1 + prek.toml | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 docs/changelog.d/prek-fmt-prepush.infra.md diff --git a/docs/changelog.d/prek-fmt-prepush.infra.md b/docs/changelog.d/prek-fmt-prepush.infra.md new file mode 100644 index 0000000..26cf000 --- /dev/null +++ b/docs/changelog.d/prek-fmt-prepush.infra.md @@ -0,0 +1 @@ +Added a `cargo-fmt-check` pre-push prek hook that runs `cargo fmt --all --check` (mirroring CI's Dagger `check` step) whenever a push touches a `.rs` file. The pre-commit `cargo-fmt` hook reformats in place, but only fires when installed and run; the pre-push check is a last-line guard so an unformatted commit can't reach the runner. Run `prek install --hook-type pre-push` to activate it. diff --git a/prek.toml b/prek.toml index 82ff957..e60082d 100644 --- a/prek.toml +++ b/prek.toml @@ -102,6 +102,22 @@ language = "system" files = '\.rs$' pass_filenames = false +# Pre-push safety net. The pre-commit cargo-fmt hook above reformats in place, +# but only when it is installed and actually runs — a commit made before +# `prek install`, or via a tool that skips hooks, can still carry unformatted +# Rust to CI (where the Dagger `check` step runs `cargo fmt --all --check` and +# fails). This hook re-checks at push time, mirroring CI byte-for-byte, so an +# unformatted commit is blocked locally before it can reach the runner. It runs +# only when the push range touches a .rs file, so Rust-free pushes pay nothing. +[[repos.hooks]] +id = "cargo-fmt-check" +name = "cargo-fmt-check" +entry = "cargo fmt --all --check" +language = "system" +files = '\.rs$' +pass_filenames = false +stages = ["pre-push"] + # GitHub/Forgejo Actions workflow linting [[repos]] repo = "https://github.com/rhysd/actionlint"