From 3951acc78632bc02ac91cc087859eef98c5ae5c8 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:08:55 -0300 Subject: [PATCH 1/6] Judge commits and pushes from Git hooks: check --staged and --pre-push `check --pre-push` judges what a push sends: the refs Git passes a pre-push hook, or the one pre-commit and prek pass as PRE_COMMIT_TO_REF, each compared with the last commit on its first-parent line that a remote has, or with the commits a remote has that the push merges in, merged as Git merges them. `check --staged` judges what a commit records: the index against HEAD, never untracked files, and a merge commit for its resolution. The files the change touched are read from Git, and so are the source recheck and the allow comments. A Git hook's check that cannot finish lets the change through and says so (`on_incomplete`, pass by default for the hooks, fail elsewhere), stops asking after 60 seconds (`max_seconds`) or a dollar budget (`max_cost`), waits out a provider failure for five minutes as the agent hook does, and can be skipped with Enter on a terminal. A stopped commit or push tells a coding agent to fix the findings and never to bypass the hook. `jevgate init --git-hook pre-push|pre-commit` writes the hook, and the pre-commit hooks run the new checks. --- .pre-commit-hooks.yaml | 45 ++- CHANGELOG.md | 17 + jevgate.schema.json | 30 ++ src/check.rs | 143 +++++++- src/command.rs | 76 +++-- src/config.rs | 22 +- src/config_schema.rs | 9 +- src/evaluate.rs | 20 +- src/gate.rs | 2 +- src/git_hooks/install.rs | 197 ++++++++++++ src/git_hooks/mod.rs | 134 ++++++++ src/hook/mod.rs | 4 +- src/hook/outage.rs | 29 +- src/hook/review.rs | 25 +- src/hook/text.rs | 2 +- src/inventory/documents.rs | 9 +- src/inventory/mod.rs | 73 +++-- src/inventory/texts.rs | 2 +- src/main.rs | 1 + src/options/commands.rs | 32 +- src/options/mod.rs | 173 +++++++++- src/output/mod.rs | 21 +- src/requests.rs | 99 +++++- src/revision.rs | 276 ++++++++++++---- src/revision/push.rs | 211 ++++++++++++ src/revision/recorded.rs | 133 ++++++++ src/revision/tests/mod.rs | 5 +- src/revision/tests/push.rs | 268 +++++++++++++++ src/revision/tests/recorded.rs | 188 +++++++++++ src/revision/tests/snapshots.rs | 10 +- src/schema/report.rs | 27 ++ src/setup/mod.rs | 24 +- src/suppress.rs | 22 +- src/tests/mod.rs | 1 + src/transport/mod.rs | 66 +++- src/transport/tests.rs | 19 +- tests/cli/changes.rs | 2 +- tests/cli/commit.rs | 555 ++++++++++++++++++++++++++++++++ tests/cli/main.rs | 1 + tests/support/git.rs | 21 +- 40 files changed, 2733 insertions(+), 261 deletions(-) create mode 100644 src/git_hooks/install.rs create mode 100644 src/git_hooks/mod.rs create mode 100644 src/revision/push.rs create mode 100644 src/revision/recorded.rs create mode 100644 src/revision/tests/push.rs create mode 100644 src/revision/tests/recorded.rs create mode 100644 tests/cli/commit.rs diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 8d504ae..58c2905 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,16 +1,49 @@ -# pre-commit (https://pre-commit.com) hooks. Both review the staged changes -# (`--base HEAD`) and need TYPESAFE_API_KEY or a key saved by `jevgate auth login`. +# pre-commit (https://pre-commit.com) and prek hooks. They need +# TYPESAFE_API_KEY (or OPENROUTER_API_KEY, AI_GATEWAY_API_KEY) or a key saved by +# `jevgate auth login`. When JevGate cannot finish (no key, an outage, 60 +# seconds gone), the commit or push goes ahead and it says so; `verbose` makes +# pre-commit print that even though the hook passed. +# +# The push hooks judge what each push sends; install that hook type too, with +# `pre-commit install --hook-type pre-push` or +# `default_install_hook_types: [pre-commit, pre-push]` in your config. - id: jevgate name: JevGate - description: Review staged changes with JevGate, built from source by pre-commit (needs a Rust toolchain) - entry: jevgate check --base HEAD + description: Judge what is staged before each commit, with jevgate built from source by pre-commit (needs a Rust toolchain) + entry: jevgate check --staged language: rust pass_filenames: false require_serial: true + verbose: true + stages: [pre-commit] + minimum_pre_commit_version: "3.2.0" - id: jevgate-system name: JevGate - description: Review staged changes with the jevgate already on PATH (Homebrew, install.sh or cargo) - entry: jevgate check --base HEAD + description: Judge what is staged before each commit, with the jevgate already on PATH (Homebrew, install.sh, npm or cargo) + entry: jevgate check --staged language: system pass_filenames: false require_serial: true + verbose: true + stages: [pre-commit] + minimum_pre_commit_version: "3.2.0" +- id: jevgate-push + name: JevGate + description: Judge what each push sends, with jevgate built from source by pre-commit (needs a Rust toolchain) + entry: jevgate check --pre-push + language: rust + pass_filenames: false + require_serial: true + verbose: true + stages: [pre-push] + minimum_pre_commit_version: "3.2.0" +- id: jevgate-push-system + name: JevGate + description: Judge what each push sends, with the jevgate already on PATH (Homebrew, install.sh, npm or cargo) + entry: jevgate check --pre-push + language: system + pass_filenames: false + require_serial: true + verbose: true + stages: [pre-push] + minimum_pre_commit_version: "3.2.0" diff --git a/CHANGELOG.md b/CHANGELOG.md index 18aab0a..1c5a979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +### The commit moment + +A Git hook runs the gate before each push or commit, on what the push sends or the commit records, read from Git and not from the working tree. A check that cannot finish lets the change through and says so, where it held a commit until its retries ran out, and `jevgate init --git-hook` writes the hook. On the last commits of 83 corpus projects, a check like the push of that commit took 1.0 s at the median, 5.6 s at the 95th percentile and 8.4 s at most, uncached, for $0.097 in all; the default gate stopped 12 of them, all on function-simplification reviews, 8 in the maintainer's own repositories. pre-commit, prek, lefthook, husky and the hook `init` writes each ran end to end in a scratch repository: a review stopped the commit and the push, and an HTTP 402 let both through with the notice. + +- `check --pre-push` judges what a push sends. It reads the refs Git passes a pre-push hook on stdin, the ref pre-commit and prek pass as `PRE_COMMIT_TO_REF`, or on a terminal the current branch, and compares each pushed commit with the last commit on its first-parent line that a remote already has, as Qlty's pre-push check compares (qltysh/qlty#2867): a branch pushed before with its last push, a new branch with where it leaves the remote's history, and a rebased branch with where it leaves that history now. A push that merges in commits a remote has is compared with them merged as Git merges them, conflict markers and all: after `git merge origin/main`, the first-parent comparison judged main's changes as the push's own, and this one judges the push's commits and how it resolved the conflicts. Files are read as committed, not as the working tree holds them. Deleted refs are skipped, one check runs for each distinct change, and a branch none of whose history is on a remote is not checked, with a line saying so. +- `check --staged` judges what a commit records: the index against HEAD, never untracked files. `--base HEAD`, which the pre-commit hooks ran until now, judged the working tree and untracked files instead, so a file staged in part with `git add -p` was judged with lines the commit did not hold, and an untracked file could stop a commit it was not part of. The files the change touched are read from the index Git commits (`GIT_INDEX_FILE`, a temporary index for `commit -a` or `git commit PATHS`), and so are the recheck that a request's source has not changed and the `jevgate: allow` comments, which read the working tree and would have stopped or misplaced a partly staged file's findings. A commit that concludes a merge is judged for how the conflicts were resolved; before the first commit, every staged file is new. +- A run that cannot finish lets a commit or a push through, loudly. `on_incomplete = "pass" | "fail"` in `jevgate.toml`, or `--on-incomplete`, decides: `pass` by default with `--staged` and `--pre-push`, `fail` for every other check, so CI still exits 2 on partial evidence. For want of a key, after an HTTP 402, with a provider that stopped answering, at a budget, on a configuration that does not load or a Git failure, the check exits 0 and its last lines on stderr say the commit or push was not checked, and why. Interrupting it still stops the commit. +- Budgets: `--max-seconds` or `max_seconds` stops the asking at a deadline, 60 seconds by default for the two hooks: no request starts and no retry waits past it, and an attempt under way gets only the time left, where a provider that failed every attempt held a run of 100 requests for 8 minutes. The 60 seconds are 7 times the slowest of the 83 last commits above. `--max-cost` or `max_cost` stops it before the estimated spend passes a number of dollars: each request is priced from its size before it is first sent, a retry is not priced again, and stderr says when 75% and 90% are spent. Either leaves the run incomplete, with the answers received cached. After a provider failure that passes with time, the hooks' checks of the next five minutes ask nothing and use only cached answers, as the agent hook does, so an outage holds one commit or push, not each one. +- On a terminal, a hook's check that has run for a second offers to skip itself: Enter lets the change through, saying it was not checked. It is offered only when a person reads stderr as it comes, never through an agent's pipe, where the terminal belongs to whoever runs the agent. +- A stopped commit or push ends with what to do next, for the person and for a coding agent that ran `git commit` or `git push`: fix the findings; a person who judges one acceptable can allow it or baseline it; an agent never bypasses the hook with `--no-verify`, an allow comment or the baseline. +- `jevgate init --git-hook pre-push|pre-commit` writes `.git/hooks/pre-push` or `pre-commit`, never over a hook it did not write, and `--remove` takes out its own. Where pre-commit, prek or lefthook wrote the hook, or `core.hooksPath` (husky's) holds the hooks, it says what to add there instead. The hook lets the change through, saying so, when `jevgate` is not on the PATH. +- pre-commit hooks: `jevgate` and `jevgate-system` run `check --staged` before each commit only, where they ran `check --base HEAD` at every stage installed; the new `jevgate-push` and `jevgate-push-system` run `check --pre-push`. All four are `verbose`, so pre-commit prints the notice of a check that passed without finishing, and need pre-commit 3.2 or later. +- The report says what a hook's check judged: `"staged": true` for `--staged`, and the pushed commit in `pushed_revision` for `--pre-push`; the headline reads `staged lines since 1a2b3c4` or `changed lines from 1a2b3c4 to 9f8e7d6`. +- [Git hooks](https://tech-byte-frontier.github.io/jevgate/git-hooks.html) is a new page, with recipes for pre-commit, prek, lefthook and husky. + +### Releases + - Releases stage the npm package, `@tech-byte-frontier/jevgate`, from the release workflow through npm's trusted publishing, with no token and with provenance; each version goes live when the maintainer approves it on npmjs.com. 0.30.0's was published by hand. ## [0.30.0] - 2026-09-28 diff --git a/jevgate.schema.json b/jevgate.schema.json index 939899d..c890cf8 100644 --- a/jevgate.schema.json +++ b/jevgate.schema.json @@ -31,6 +31,21 @@ } ] }, + "OnIncomplete": { + "description": "What a run that could not finish exits with.", + "oneOf": [ + { + "const": "pass", + "description": "Exit 0, saying on stderr that the change was not checked, and why", + "type": "string" + }, + { + "const": "fail", + "description": "Exit 2", + "type": "string" + } + ] + }, "Question": { "additionalProperties": false, "description": "A question as a configuration writes it.", @@ -485,6 +500,11 @@ "minimum": 1, "type": "integer" }, + "max_cost": { + "description": "Ceiling on a check's estimated spend in dollars; what is left unasked leaves the run incomplete. Flags can only lower it. Default: unlimited.", + "exclusiveMinimum": 0, + "type": "number" + }, "max_file_bytes": { "description": "Files larger than this are reported as needs-context, never truncated. Default: 262144.", "minimum": 1, @@ -495,10 +515,20 @@ "minimum": 1, "type": "integer" }, + "max_seconds": { + "description": "Ceiling on the seconds a check asks for; what is left unasked leaves the run incomplete. Flags can only lower it. Default: 60 with `--staged` and `--pre-push`, else unlimited.", + "maximum": 86400, + "minimum": 1, + "type": "integer" + }, "model": { "description": "Model, as the key's provider names it; a pinned version keeps results repeatable. `--model` overrides it. Default: `jev-1.13.0` with a TypeSafe key, `typesafe/jev-1.13` with an OpenRouter key, `typesafe-ai/jev` with a Vercel AI Gateway key.", "type": "string" }, + "on_incomplete": { + "$ref": "#/$defs/OnIncomplete", + "description": "What a run that cannot finish exits with, like `--on-incomplete`: `pass` (exit 0, saying so on stderr) or `fail` (exit 2). Default: `pass` with `--staged` and `--pre-push`, else `fail`." + }, "question": { "description": "Custom questions: a yes/no question per convention, asked of each unit it names, whose yes is a finding. `.jevgate/questions/` holds one per file, named by its id.", "items": { diff --git a/src/check.rs b/src/check.rs index 690e427..2a73192 100644 --- a/src/check.rs +++ b/src/check.rs @@ -3,13 +3,24 @@ use crate::{ cancellation, changes, config::ConfigContext, - evaluate, gate, html_report, inventory, + evaluate, gate, git_hooks, + hook::outage, + html_report, inventory, options::{CheckArgs, Format}, - output, schema, storage, token_budget, transport, watch, + output, + revision::{self, Now, Recorded, push}, + schema, storage, token_budget, transport, watch, +}; +use anyhow::{Context, Result}; +use std::{ + collections::BTreeSet, + io::{IsTerminal, Read}, + path::Path, }; -use anyhow::Result; -fn validate(args: &CheckArgs) -> Result<()> { +/// Flags that cannot run together: a usage error, which exits 2 even when +/// a run that cannot finish would pass. +pub(crate) fn validate(args: &CheckArgs) -> Result<()> { anyhow::ensure!( !args.show_requests || args.output_format() == Format::Json, "--show-requests uses JSON output; omit --format or use --format json" @@ -18,6 +29,10 @@ fn validate(args: &CheckArgs) -> Result<()> { !(args.watch && args.dry_run), "--watch cannot be combined with --dry-run" ); + anyhow::ensure!( + !(args.watch && args.moment().is_some()), + "--watch judges the working tree as it changes; it cannot be combined with --staged or --pre-push" + ); anyhow::ensure!( !(args.watch && matches!( @@ -108,6 +123,7 @@ pub fn session<'a>( budget: token_budget::TokenBudget::load(&context.root), observed: (0, 0), answered: Default::default(), + spend: args.max_cost.map(crate::requests::Spend::new), } } @@ -128,6 +144,17 @@ pub fn judge( session.publish(report) } +/// After a Git hook's check that asked the provider: a failure that passes +/// with time is waited out by the next hooks' checks for a few minutes, and +/// a check that finished clears the one waited out. +fn remember_outage(root: &Path, report: &schema::Report, watch: &outage::Watch) { + match watch.failure() { + Some(failure) if !report.complete => outage::record(root, &failure), + _ if report.complete => outage::clear(root), + _ => {} + } +} + /// Say which selected custom questions this run cannot ask, and what they /// need: a question about changed hunks needs `--base`, and one about tests /// needs them judged. @@ -142,8 +169,87 @@ fn unasked(args: &CheckArgs) { } } +/// Resolve the change a check judges: `--base` to its fork point with HEAD, +/// the working tree's change from it; `--staged` to HEAD, the index's +/// change from it, with the files it touched read from Git. +pub(crate) fn resolve_change(args: &mut CheckArgs, root: &Path) -> Result<()> { + if args.staged { + let base = revision::staged_base(root)?; + args.recorded = Some(Recorded::load(root, &base, &Now::Index)?); + args.now = Now::Index; + args.base = Some(base); + } else if let Some(base) = &args.base { + args.base = Some(revision::resolve(root, base)?); + } + Ok(()) +} + +/// Bytes of pre-push input read: a line per pushed ref, about 200 bytes. +const PUSH_INPUT_BYTES: u64 = 4 * 1024 * 1024; + +/// `check --pre-push`: judge what each pushed ref sends, as committed, with +/// one check per distinct change; the highest exit code of them. +pub(crate) fn pre_push(args: &mut CheckArgs, context: &ConfigContext) -> Result { + let root = &context.root; + let mut judged = BTreeSet::new(); + let mut code = 0; + for update in pushed_refs()? { + match push::sent(root, &update)? { + push::Sent::Commits { base, commit } => { + if !judged.insert((base.clone(), commit.clone())) { + continue; + } + let now = Now::Commit(commit); + args.recorded = Some(Recorded::load(root, &base, &now)?); + args.now = now; + args.base = Some(base); + code = code.max(run(args, context)?); + } + push::Sent::Nothing => {} + push::Sent::Unrooted => { + let why = format!( + "none of the commits of {} is on a remote yet, so there is nothing to compare them with; `jevgate check` judges the whole repository", + update.name + ); + if args.passes_incomplete() { + git_hooks::not_checked(args, &why); + } else { + note!("jevgate: this push cannot be checked: {why}."); + code = code.max(2); + } + } + } + } + if judged.is_empty() { + note!("jevgate: nothing to check: this push sends no commit a remote lacks."); + } + Ok(code) +} + +/// The refs a pre-push check judges: the one pre-commit or prek names, else +/// those Git passes the hook on stdin, else, on a terminal, the current +/// branch. +fn pushed_refs() -> Result> { + let var = |name: &str| std::env::var(name).ok().filter(|value| !value.is_empty()); + if let Some(update) = push::from_pre_commit(var) { + return Ok(vec![update]); + } + let stdin = std::io::stdin(); + if stdin.is_terminal() { + return Ok(vec![push::Update::head()]); + } + let mut input = String::new(); + stdin + .lock() + .take(PUSH_INPUT_BYTES) + .read_to_string(&mut input) + .context("Cannot read the refs Git passed the pre-push hook")?; + push::updates(&input) +} + /// `check`: judge the selected files, apply the gate and report. pub fn run(args: &CheckArgs, context: &ConfigContext) -> Result { + let started = std::time::Instant::now(); validate(args)?; cancellation::install()?; unasked(args); @@ -166,8 +272,24 @@ pub fn run(args: &CheckArgs, context: &ConfigContext) -> Result { args.env_file.is_some(), args.provider, )?; - let mut session = session(args, context, &store, &mut client); + if let Some(seconds) = args.max_seconds { + client = client.until(started + std::time::Duration::from_secs(seconds)); + } + // A Git hook's check waits out a provider failure as the agent hook does. + let waiting = args.moment().and_then(|_| outage::current(&context.root)); + let watch = outage::Watch::default(); + let mut evaluator = outage::Watched { + inner: match &waiting { + Some(outage) => Box::new(outage::Waiting(crate::hook::text::waiting(outage))), + None => Box::new(client), + }, + watch: &watch, + }; + let mut session = session(args, context, &store, &mut evaluator); judge(&mut session, &inputs, previous.as_ref(), &mut report)?; + if args.moment().is_some() && waiting.is_none() { + remember_outage(&context.root, &report, &watch); + } if args.report { html_report::open(&context.root); } @@ -178,5 +300,14 @@ pub fn run(args: &CheckArgs, context: &ConfigContext) -> Result { watch::run(&mut session, scope, inputs, report)?; return Ok(0); } - Ok(gate::exit_code(&report)) + let ran_out = args + .max_seconds + .is_some_and(|seconds| started.elapsed().as_secs() >= seconds); + let code = git_hooks::exit_code(&report, args, ran_out); + if let Some(moment) = args.moment().filter(|_| code == 1) + && args.output_format() == Format::Agent + { + say!("{}", git_hooks::stopped(moment)); + } + Ok(code) } diff --git a/src/command.rs b/src/command.rs index e4f2131..1a2d6a1 100644 --- a/src/command.rs +++ b/src/command.rs @@ -5,9 +5,9 @@ use crate::{ auth, baseline, cancellation, catalog, config, config::ConfigContext, - hook, init, manual, mcp, - options::{self, JevCommand}, - output, revision, server, setup, + git_hooks, hook, init, manual, mcp, + options::{self, CheckArgs, JevCommand}, + output, server, setup, }; use anyhow::Result; @@ -16,10 +16,14 @@ pub fn run(command: JevCommand) -> Result { JevCommand::Auth { command } => auth::run(command), JevCommand::Completions { shell } => manual::completions(shell).map(|()| 0), JevCommand::Man { command } => manual::man(command.as_deref()).map(|()| 0), - JevCommand::Init { setup, .. } if !setup.agents.is_empty() => setup::run(&setup), - JevCommand::Init { force, .. } => init(force), + JevCommand::Init { force, setup } => match setup.git_hook { + Some(hook) => git_hooks::install::run(hook, setup.remove, setup.dry_run), + None if !setup.agents.is_empty() => setup::run(&setup), + None => init(force), + }, JevCommand::Mcp => mcp::run().map(|()| 0), JevCommand::Hook(args) => hook::run(&args), + JevCommand::Check(args) => check(*args), JevCommand::Rules { action: Some(options::RulesAction::Add { names, force }), .. @@ -50,10 +54,39 @@ fn init(force: bool) -> Result { Ok(0) } +/// `check`. When a run that cannot finish passes (`--on-incomplete`, and +/// by default for `--staged` and `--pre-push`), so does one that fails +/// before it can judge anything, such as on a configuration that does not +/// load, saying loudly that the change was not checked. +fn check(mut args: CheckArgs) -> Result { + crate::check::validate(&args)?; + // One offer for the whole invocation, which checks each pushed ref. + let _skip = args + .moment() + .filter(|_| !args.dry_run) + .and_then(git_hooks::offer_skip); + let result = (|| { + let context = + ConfigContext::discover(args.config.as_deref(), args.question_directory.as_deref())?; + crate::check::configure(&mut args, &context)?; + if args.pre_push { + return crate::check::pre_push(&mut args, &context); + } + crate::check::resolve_change(&mut args, &context.root)?; + crate::check::run(&args, &context) + })(); + match result { + Err(error) if args.passes_incomplete() && cancellation::signal().is_none() => { + git_hooks::not_checked(&args, &format!("{error:#}")); + Ok(0) + } + other => other, + } +} + /// The commands that read the repository's configuration. fn configured(command: JevCommand) -> Result { let (file, questions) = match &command { - JevCommand::Check(args) => (args.config.clone(), args.question_directory.clone()), JevCommand::Rules { action: Some(options::RulesAction::Test(args)), .. @@ -68,19 +101,13 @@ fn configured(command: JevCommand) -> Result { | JevCommand::Man { .. } | JevCommand::Mcp | JevCommand::Hook(_) + | JevCommand::Check(_) | JevCommand::Rules { action: Some(options::RulesAction::Add { .. }), .. } => { unreachable!("handled before repository configuration") } - JevCommand::Check(mut args) => { - crate::check::configure(&mut args, &context)?; - if let Some(base) = &args.base { - args.base = Some(revision::resolve(&context.root, base)?); - } - crate::check::run(&args, &context) - } JevCommand::Baseline { action: Some(action), .. @@ -105,16 +132,7 @@ fn configured(command: JevCommand) -> Result { JevCommand::Rules { format, action: None, - } => { - match format { - options::RulesFormat::Json => say!( - "{}", - serde_json::to_string_pretty(&catalog::describe(context.questions))? - ), - options::RulesFormat::Table => say!("{}", catalog::table(context.questions)), - } - Ok(0) - } + } => rules(&context, format), JevCommand::Serve { port } => { cancellation::install()?; server::run(&context.root, port)?; @@ -123,6 +141,18 @@ fn configured(command: JevCommand) -> Result { } } +/// `rules`: every rule and custom question, as a table or JSON. +fn rules(context: &ConfigContext, format: options::RulesFormat) -> Result { + match format { + options::RulesFormat::Json => say!( + "{}", + serde_json::to_string_pretty(&catalog::describe(context.questions))? + ), + options::RulesFormat::Table => say!("{}", catalog::table(context.questions)), + } + Ok(0) +} + /// `baseline`: accept the last check's findings. fn accept( context: &ConfigContext, diff --git a/src/config.rs b/src/config.rs index 9addf18..145326d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,12 @@ pub struct Config { pub rules: Rules, /// Ceiling on API attempts per invocation; flags can only lower it. Default: unlimited. pub max_requests: Option, + /// Ceiling on the seconds a check asks for; what is left unasked leaves the run incomplete. Flags can only lower it. Default: 60 with `--staged` and `--pre-push`, else unlimited. + pub max_seconds: Option, + /// Ceiling on a check's estimated spend in dollars; what is left unasked leaves the run incomplete. Flags can only lower it. Default: unlimited. + pub max_cost: Option, + /// What a run that cannot finish exits with, like `--on-incomplete`: `pass` (exit 0, saying so on stderr) or `fail` (exit 2). Default: `pass` with `--staged` and `--pre-push`, else `fail`. + pub on_incomplete: Option, /// Most simultaneous requests; flags can only lower it. JevGate sends at most 6 at once, so a higher value means 6. Default: 6 with a TypeSafe key, 3 with an OpenRouter or Vercel AI Gateway key. pub concurrency: Option, /// Files larger than this are reported as needs-context, never truncated. Default: 262144. @@ -373,8 +379,22 @@ impl ConfigContext { if let Some(n) = self.config.max_context_bytes { args.max_context_bytes = args.max_context_bytes.min(n); } + if let Some(n) = self.config.max_seconds { + args.max_seconds = Some(args.max_seconds.map_or(n, |limit| limit.min(n))); + } + if args.moment().is_some() { + args.max_seconds = args.max_seconds.or(Some(crate::options::HOOK_SECONDS)); + } + if let Some(usd) = self.config.max_cost { + args.max_cost = Some(args.max_cost.map_or(usd, |limit| limit.min(usd))); + } + args.on_incomplete = args.on_incomplete.or(self.config.on_incomplete); ensure!( - args.max_requests != Some(0) && args.max_file_bytes > 0 && args.max_context_bytes > 0, + args.max_requests != Some(0) + && args.max_file_bytes > 0 + && args.max_context_bytes > 0 + && args.max_seconds != Some(0) + && args.max_cost.is_none_or(|usd| usd.is_finite() && usd > 0.0), "Budgets must be positive" ); Ok(()) diff --git a/src/config_schema.rs b/src/config_schema.rs index ad4b542..c3912d0 100644 --- a/src/config_schema.rs +++ b/src/config_schema.rs @@ -35,9 +35,16 @@ pub fn schema() -> Value { fn bound_budgets(properties: &mut Value) { properties["concurrency"]["minimum"] = json!(1); properties["concurrency"]["maximum"] = json!(MAX_CONCURRENCY); - for budget in ["max_requests", "max_file_bytes", "max_context_bytes"] { + for budget in [ + "max_requests", + "max_file_bytes", + "max_context_bytes", + "max_seconds", + ] { properties[budget]["minimum"] = json!(1); } + properties["max_seconds"]["maximum"] = json!(86_400); + properties["max_cost"]["exclusiveMinimum"] = json!(0); } /// The gate levels `fail_on` and `[[scope]]` accept, and with `off` the diff --git a/src/evaluate.rs b/src/evaluate.rs index a9c2d1e..d3aae3f 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -24,6 +24,8 @@ pub struct Session<'a> { pub observed: (u64, u64), /// The questions this invocation answered, by state. pub answered: crate::requests::Answered, + /// With `--max-cost`, what the requests sent are estimated to cost. + pub spend: Option, } pub struct SnapshotContext<'a> { @@ -85,6 +87,11 @@ fn empty_report(args: &CheckArgs, current: &SnapshotContext<'_>, files: Vec Some(commit.clone()), + _ => None, + }, deleted_files: Vec::new(), scope: if args.changed_lines() { schema::Scope::ChangedLines @@ -540,12 +547,13 @@ impl Session<'_> { hashes .entry(path.clone()) .or_insert_with(|| { - super::inventory::read_source( - &self.context.root.join(path), - self.args.max_context_bytes.max(self.args.max_file_bytes), - ) - .ok() - .map(|s| schema::hash(s.as_bytes())) + self.args + .read( + &self.context.root.join(path), + self.args.max_context_bytes.max(self.args.max_file_bytes), + ) + .ok() + .map(|s| schema::hash(s.as_bytes())) }) .as_deref() == Some(expected) diff --git a/src/gate.rs b/src/gate.rs index 8451122..d08c80b 100644 --- a/src/gate.rs +++ b/src/gate.rs @@ -156,7 +156,7 @@ pub fn settle(root: &Path, report: &mut Report, args: &CheckArgs) -> Result<()> Some(_) => crate::guards::added_allows(&report.guards), None => Default::default(), }; - crate::suppress::apply(root, report, &ignored); + crate::suppress::apply(root, report, &ignored, args.recorded.as_ref()); crate::baseline::apply(root, report, turn_start)?; evaluate(report, args); Ok(()) diff --git a/src/git_hooks/install.rs b/src/git_hooks/install.rs new file mode 100644 index 0000000..4ac9827 --- /dev/null +++ b/src/git_hooks/install.rs @@ -0,0 +1,197 @@ +//! `jevgate init --git-hook pre-push|pre-commit`: write the Git hook that +//! runs `jevgate check --pre-push` or `--staged`. It writes only where Git +//! reads hooks itself, and never over a hook it did not write: when another +//! tool manages the repository's hooks (husky through `core.hooksPath`, +//! pre-commit or lefthook through scripts of their own), it says what to +//! add there instead. +use anyhow::{Context, Result, bail}; +use clap::ValueEnum; +use std::path::{Path, PathBuf}; + +/// The Git hooks JevGate writes. +#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)] +pub enum GitHook { + /// Before each push, judge what it sends: `jevgate check --pre-push` + PrePush, + /// Before each commit, judge what is staged: `jevgate check --staged` + PreCommit, +} + +impl GitHook { + /// Git's name for the hook, and its file's. + pub fn name(self) -> &'static str { + match self { + Self::PrePush => "pre-push", + Self::PreCommit => "pre-commit", + } + } + + /// The command the hook runs. + pub fn command(self) -> &'static str { + match self { + Self::PrePush => "jevgate check --pre-push", + Self::PreCommit => "jevgate check --staged", + } + } + + fn noun(self) -> &'static str { + match self { + Self::PrePush => "push", + Self::PreCommit => "commit", + } + } +} + +/// The line that marks a hook as JevGate's, so it rewrites or removes only +/// its own. +const MARKER: &str = "# Written by `jevgate init --git-hook`"; + +/// Where the recipes for other hook managers are. +const RECIPES: &str = "https://tech-byte-frontier.github.io/jevgate/git-hooks.html"; + +/// The hook's script: POSIX shell, which Git for Windows runs too. A +/// missing `jevgate` lets the commit or push through with a line saying so, +/// as a run that cannot finish does: a Git client started from a desktop +/// may not have the PATH a terminal has. +fn script(hook: GitHook) -> String { + let (name, noun) = (hook.name(), hook.noun()); + format!( + "#!/bin/sh\n\ + {MARKER} {name}.\n\ + # It stops a {noun} while findings fail JevGate's gate, and lets the {noun}\n\ + # through, saying so, when JevGate cannot finish or is not installed.\n\ + # `jevgate init --git-hook {name} --remove` takes it out.\n\ + if ! command -v jevgate >/dev/null 2>&1; then\n\ + \x20 echo \"jevgate: not found on PATH; this {noun} was not checked.\" >&2\n\ + \x20 exit 0\n\ + fi\n\ + exec {}\n", + hook.command() + ) +} + +/// What `init --git-hook` does to the hook file. +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + Wrote(PathBuf), + Unchanged(PathBuf), + Removed(PathBuf), + Absent(PathBuf), +} + +/// Write `hook` in the repository around `cwd`, or with `remove` take out +/// the one JevGate wrote; with `dry_run`, only say what would change. +pub fn install(cwd: &Path, hook: GitHook, remove: bool, dry_run: bool) -> Result { + let command = hook.command(); + if let Some(managed) = hooks_path(cwd)? { + bail!( + "Git runs this repository's hooks from {managed} (core.hooksPath), which a hook manager such as husky keeps; add `{command}` to its {} hook there instead ({RECIPES})", + hook.name() + ); + } + let path = hooks_directory(cwd)?.join(hook.name()); + let written = script(hook); + let outcome = match std::fs::read_to_string(&path) { + Ok(text) if text.contains(MARKER) && remove => Outcome::Removed(path), + Ok(text) if text == written => Outcome::Unchanged(path), + Ok(text) if text.contains(MARKER) => Outcome::Wrote(path), + Ok(text) => bail!("{}", foreign(&path, &text, hook)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound && remove => { + Outcome::Absent(path) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Outcome::Wrote(path), + Err(error) => { + return Err(error).with_context(|| format!("Cannot read {}", path.display())); + } + }; + if dry_run { + return Ok(outcome); + } + match &outcome { + Outcome::Wrote(path) => write(path, &written)?, + Outcome::Removed(path) => std::fs::remove_file(path) + .with_context(|| format!("Cannot remove {}", path.display()))?, + Outcome::Unchanged(_) | Outcome::Absent(_) => {} + } + Ok(outcome) +} + +/// `jevgate init --git-hook`: install or remove the hook, and say what +/// changed and what it needs. +pub fn run(hook: GitHook, remove: bool, dry_run: bool) -> Result { + let cwd = std::env::current_dir()?.canonicalize()?; + let outcome = install(&cwd, hook, remove, dry_run)?; + let (verb, path) = match &outcome { + Outcome::Wrote(path) if dry_run => ("Would write", path), + Outcome::Wrote(path) => ("Wrote", path), + Outcome::Unchanged(path) => ("Unchanged:", path), + Outcome::Removed(path) if dry_run => ("Would remove", path), + Outcome::Removed(path) => ("Removed", path), + Outcome::Absent(path) => ("No JevGate hook to remove at", path), + }; + say!("{verb} {}", path.display()); + if matches!(outcome, Outcome::Wrote(_) | Outcome::Unchanged(_)) { + say!( + "Before each {}, it runs `{}`. It needs an API key (jevgate auth login); when JevGate cannot finish, the {} goes ahead and it says so.", + hook.noun(), + hook.command(), + hook.noun() + ); + } + Ok(0) +} + +/// Why a hook JevGate did not write stays, and what to add to it. +fn foreign(path: &Path, text: &str, hook: GitHook) -> String { + let command = hook.command(); + let shown = path.display(); + if text.contains("pre-commit.com") || text.contains("prek") { + return format!( + "pre-commit or prek wrote {shown}; add JevGate to .pre-commit-config.yaml instead, as the hook `jevgate-push-system` (before a push) or `jevgate-system` (before a commit) ({RECIPES})" + ); + } + if text.contains("lefthook") { + return format!( + "lefthook wrote {shown}; add `{command}` to lefthook.yml instead ({RECIPES})" + ); + } + format!( + "{shown} is a hook JevGate did not write; add `{command}` to it, or move it away and run this again" + ) +} + +/// The directory `core.hooksPath` names, as Git reads it; none when unset. +fn hooks_path(cwd: &Path) -> Result> { + let output = crate::revision::git_in(cwd) + .args(["config", "--get", "core.hooksPath"]) + .output() + .context("Cannot run Git")?; + // Exit 1: the key is not set. + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok((output.status.success() && !value.is_empty()).then_some(value)) +} + +/// The directory Git reads the repository's hooks from: `.git/hooks`, or +/// the main work tree's from a linked one. +fn hooks_directory(cwd: &Path) -> Result { + let bytes = crate::revision::git(cwd, &["rev-parse", "--git-path", "hooks"]) + .context("jevgate init --git-hook writes a Git hook; run it inside a Git work tree")?; + let relative = String::from_utf8(bytes)?.trim().to_string(); + Ok(cwd.join(relative)) +} + +/// Write the hook, executable where the file system says so. +fn write(path: &Path, text: &str) -> Result<()> { + if let Some(directory) = path.parent() { + std::fs::create_dir_all(directory) + .with_context(|| format!("Cannot create {}", directory.display()))?; + } + std::fs::write(path, text).with_context(|| format!("Cannot write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Cannot make {} executable", path.display()))?; + } + Ok(()) +} diff --git a/src/git_hooks/mod.rs b/src/git_hooks/mod.rs new file mode 100644 index 0000000..87e92b8 --- /dev/null +++ b/src/git_hooks/mod.rs @@ -0,0 +1,134 @@ +//! Checks run from Git hooks, before a commit (`--staged`) or a push +//! (`--pre-push`), as Qlty's slop-one runs before a push: a run that cannot +//! finish lets the change through and says so loudly, a person watching can +//! skip the check with Enter, and a blocked commit or push tells a coding +//! agent what to do instead of `--no-verify`. Writing the hooks is in +//! `install`. +use crate::{ + options::{CheckArgs, Moment}, + schema::Report, +}; +use std::{ + io::{BufRead, IsTerminal}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +pub mod install; + +/// Say on stderr that a run which could not finish let the change through: +/// `why` it could not, and how to make such a run stop the change instead. +/// It is the last thing printed, in bold on a terminal, since a hook's +/// output is often read only when something goes wrong. +pub fn not_checked(args: &CheckArgs, why: &str) { + let why = why.trim_end_matches('.'); + let (first, second) = match args.moment() { + Some(moment) => ( + format!( + "jevgate: this {} was not checked: {why}.", + moment.noun() + ), + "jevgate: it goes ahead unchecked, as on_incomplete is \"pass\"; set on_incomplete = \"fail\" in jevgate.toml to stop it instead.".to_string(), + ), + None => ( + format!("jevgate: the check did not finish: {why}."), + "jevgate: it exits 0, as on_incomplete is \"pass\"; what it reported is not the whole review.".to_string(), + ), + }; + let bold = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + for line in [first, second] { + if bold { + note!("\x1b[1;33m{line}\x1b[0m"); + } else { + note!("{line}"); + } + } +} + +/// What a person reads after a report whose gate stopped a commit or a +/// push, and a coding agent that ran `git commit` or `git push` reads as +/// its next step: fix the findings. Accepting one stays with people, as in +/// the agent hook, and the hook is never bypassed. +pub fn stopped(moment: Moment) -> String { + let noun = moment.noun(); + format!( + "\nJevGate stopped this {noun}: fix the findings above and {noun} again. A person who judges a finding acceptable can add a `jevgate: allow(RULE) reason` comment on its line, or run `jevgate baseline --merge` and commit jevgate-baseline.json. Coding agents: fix the findings; never bypass this check with --no-verify, an allow comment or the baseline, and if a finding looks wrong, tell the person." + ) +} + +/// How long a check runs before a person is offered the skip: one answered +/// from the cache ends first, and prints nothing more. +const OFFER_AFTER: Duration = Duration::from_secs(1); + +/// The terminal a person types on, read for Enter. +#[cfg(windows)] +const TERMINAL: &str = "CONIN$"; +#[cfg(not(windows))] +const TERMINAL: &str = "/dev/tty"; + +/// While it is alive, Enter skips the check; dropped when the check ends. +pub struct Skip { + ended: Arc, +} + +impl Drop for Skip { + fn drop(&mut self) { + self.ended.store(true, Ordering::SeqCst); + } +} + +/// Offer a person the skip once the check has run a second: Enter then +/// exits 0, so Git goes on with the commit or push, and a line says it was +/// not checked. Only when stderr is a terminal outside CI, so a person +/// reads the offer: a coding agent's shell reads stderr through a pipe, +/// and the terminal device there belongs to whoever runs the agent, whose +/// keys must not be taken. The answers already received stay cached, +/// since the cache is written file by file. +pub fn offer_skip(moment: Moment) -> Option { + if !std::io::stderr().is_terminal() || std::env::var_os("CI").is_some() { + return None; + } + let terminal = std::fs::File::open(TERMINAL).ok()?; + let ended = Arc::new(AtomicBool::new(false)); + let watching = Arc::clone(&ended); + std::thread::spawn(move || { + std::thread::sleep(OFFER_AFTER); + if watching.load(Ordering::SeqCst) { + return; + } + let noun = moment.noun(); + note!("jevgate: press Enter to skip the check; the {noun} then goes ahead unchecked."); + let mut lines = std::io::BufReader::new(terminal).lines(); + while let Some(Ok(line)) = lines.next() { + if line.trim().is_empty() && !watching.load(Ordering::SeqCst) { + note!("jevgate: skipped with Enter; this {noun} was not checked."); + std::process::exit(0); + } + } + }); + Some(Skip { ended }) +} + +/// The exit code of a finished check: an incomplete run passes when +/// `args` lets it, saying so, and otherwise the gate decides. `ran_out`: +/// the run used up its `--max-seconds`. +pub fn exit_code(report: &Report, args: &CheckArgs, ran_out: bool) -> u8 { + let code = crate::gate::exit_code(report); + // An interrupted run stops the commit, whatever lets it through. + let interrupted = crate::cancellation::signal().is_some(); + if code == 2 && args.passes_incomplete() && !report.dry_run && !interrupted { + let why = match args.max_seconds.filter(|_| ran_out) { + Some(seconds) => format!( + "it used its {} (max_seconds) before every answer came back; the answers it received are cached, so the next run asks only for the rest", + crate::output::count(seconds as usize, "second") + ), + None => report.incomplete_reason(), + }; + not_checked(args, &why); + return 0; + } + code +} diff --git a/src/hook/mod.rs b/src/hook/mod.rs index 4d668db..c3f931a 100644 --- a/src/hook/mod.rs +++ b/src/hook/mod.rs @@ -8,11 +8,11 @@ //! always says so. What each event does is in `events`. mod agents; mod events; -mod outage; +pub(crate) mod outage; mod review; #[cfg(test)] mod tests; -mod text; +pub(crate) mod text; mod turn; pub use agents::Agent; diff --git a/src/hook/outage.rs b/src/hook/outage.rs index b62a798..5c32166 100644 --- a/src/hook/outage.rs +++ b/src/hook/outage.rs @@ -1,10 +1,11 @@ -//! A provider that stops answering must not hold the agent at every event. -//! A hook's check watches what the provider does, and when it fails in a way -//! that passes with time (a timeout, a refused or dropped connection, a rate -//! limit or a server error), the checks of the next few minutes use only -//! the answers already cached: measured against a provider that stopped -//! answering, every edit otherwise waited 29.8 s and every stop 41 to 50 s, -//! the hook's whole budget, for as long as the outage lasted. +//! A provider that stops answering must not hold the agent at every event, +//! nor every commit or push. A hook's check, and a Git hook's (`check +//! --staged` or `--pre-push`), watches what the provider does, and when it +//! fails in a way that passes with time (a timeout, a refused or dropped +//! connection, a rate limit or a server error), the checks of the next few +//! minutes use only the answers already cached: measured against a provider +//! that stopped answering, every edit otherwise waited 29.8 s and every stop +//! 41 to 50 s, the hook's whole budget, for as long as the outage lasted. use super::turn; use crate::{ schema, @@ -31,7 +32,7 @@ const FILE: &str = "outage.json"; /// What the provider did during one check: requests sent and answered, and /// the last failure worth waiting out. #[derive(Default)] -pub(super) struct Watch { +pub(crate) struct Watch { sent: AtomicUsize, answered: AtomicUsize, failure: Mutex>, @@ -63,7 +64,7 @@ impl Watch { } /// An evaluator whose answers `watch` sees. -pub(super) struct Watched<'a> { +pub(crate) struct Watched<'a> { pub inner: Box, pub watch: &'a Watch, } @@ -102,7 +103,7 @@ impl Evaluator for Watched<'_> { /// While the hook waits a failure out: no request is sent, so only cached /// answers count, and each other request fails at once with `0`. -pub(super) struct Waiting(pub String); +pub(crate) struct Waiting(pub String); impl Evaluator for Waiting { fn evaluate(&mut self, _: &Value) -> Result { @@ -112,7 +113,7 @@ impl Evaluator for Waiting { /// A provider failure the hook's checks wait out, in `.jevgate/turns/`. #[derive(Serialize, Deserialize)] -pub(super) struct Outage { +pub(crate) struct Outage { /// When it was met, in seconds since the Unix epoch. pub at: u64, pub reason: String, @@ -133,14 +134,14 @@ fn file(root: &Path) -> PathBuf { } /// The failure the hook still waits out in the repository at `root`. -pub(super) fn current(root: &Path) -> Option { +pub(crate) fn current(root: &Path) -> Option { let text = crate::inventory::read_source(&file(root), turn::MAX_BYTES).ok()?; let outage: Outage = serde_json::from_str(&text).ok()?; (schema::now() < outage.at + WAIT_SECS).then_some(outage) } /// Wait out `reason`, a failure met now. -pub(super) fn record(root: &Path, reason: &str) { +pub(crate) fn record(root: &Path, reason: &str) { let outage = Outage { at: schema::now(), reason: reason.to_string(), @@ -155,6 +156,6 @@ pub(super) fn record(root: &Path, reason: &str) { } /// The provider answered: nothing is waited out. -pub(super) fn clear(root: &Path) { +pub(crate) fn clear(root: &Path) { let _ = std::fs::remove_file(file(root)); } diff --git a/src/hook/review.rs b/src/hook/review.rs index ed46b44..8031a92 100644 --- a/src/hook/review.rs +++ b/src/hook/review.rs @@ -333,7 +333,7 @@ fn run( let mut session = check::session(&args, &context, &store, &mut evaluator); check::judge(&mut session, &inputs, previous.as_ref(), &mut report)?; if !report.complete { - bail!(incomplete(&report)); + bail!(report.incomplete_reason()); } let accepted_now = match args.turn_start() { Some(_) => accepted_now(&context.root, &report), @@ -363,7 +363,7 @@ fn configuration_at(root: &Path, start: &str) -> Result { /// person. fn accepted_now(root: &Path, report: &Report) -> BTreeSet { let mut now = report.clone(); - crate::suppress::apply(root, &mut now, &BTreeSet::new()); + crate::suppress::apply(root, &mut now, &BTreeSet::new(), None); let _ = crate::baseline::apply(root, &mut now, None); let then = report.files.iter().flat_map(|f| &f.findings); now.files @@ -384,7 +384,7 @@ fn arguments(context: &ConfigContext, scope: Scope) -> Result { args.paths = scope.paths; if let Some((base, now)) = scope.trees { args.base = Some(base); - args.worktree_snapshot = Some(now); + args.now = crate::revision::Now::Snapshot(now); } Ok(args) } @@ -405,25 +405,6 @@ fn open_store(root: &Path, until: Instant) -> Result { } } -/// Why an incomplete check could not judge everything: the run's first -/// error, else the first failed file's and how many failed alike. -fn incomplete(report: &Report) -> String { - if let Some(error) = report.errors.first() { - return error.clone(); - } - let failed: Vec<&str> = report - .files - .iter() - .filter(|f| f.status == Status::Error) - .filter_map(|f| f.error.as_deref()) - .collect(); - match failed.first() { - Some(first) if failed.len() > 1 => format!("{first} ({} files)", failed.len()), - Some(first) => (*first).to_string(), - None => "the check did not finish".into(), - } -} - /// The files of code in `report` that the check skipped or could not send /// whole, with the reason the report gives. fn unreviewed(report: &Report, args: &CheckArgs) -> Vec { diff --git a/src/hook/text.rs b/src/hook/text.rs index ee69211..36e2b54 100644 --- a/src/hook/text.rs +++ b/src/hook/text.rs @@ -424,7 +424,7 @@ pub(super) fn failed_user(what: &str, why: &str) -> String { /// Why a check the hook ran while waiting out a provider failure could not /// finish: it asked nothing, and the cache did not hold every answer. -pub(super) fn waiting(outage: &super::outage::Outage) -> String { +pub(crate) fn waiting(outage: &super::outage::Outage) -> String { let minutes = outage.minutes_left(); format!( "the provider failed a few minutes ago ({}), so JevGate asks it again in {} and uses only cached answers until then, which did not cover this", diff --git a/src/inventory/documents.rs b/src/inventory/documents.rs index e9f610f..acf7d52 100644 --- a/src/inventory/documents.rs +++ b/src/inventory/documents.rs @@ -90,10 +90,13 @@ pub(super) fn load_document( path: &std::path::Path, ) -> Result { let mut result = pending_result(relative, role, args, &[]); - if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.len() > args.max_file_bytes) { - return over_read_cap(result, relative, path, args.max_file_bytes); + if args + .size(path) + .is_some_and(|size| size > args.max_file_bytes) + { + return over_read_cap(result, relative, path, args); } - Ok(match read_source(path, args.max_file_bytes) { + Ok(match args.read(path, args.max_file_bytes) { Ok(source) => { result.source_hash = hash(source.as_bytes()); result.content_identity = result.source_hash.clone(); diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index 1fbdc38..38b43d2 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -186,17 +186,13 @@ fn source_paths( ) -> Result> { let classifier = discovery::Classifier::new(&context.config)?; let mut paths = Vec::new(); - for entry in walker(&context.root) { - let entry = entry.context("Failed while discovering Jev scope")?; - let path = entry.path(); - if !entry.file_type().is_some_and(|t| t.is_file()) { - continue; - } + let mut consider = |path: &Path| -> Result<()> { let relative = &discovery::relative(path, &context.root)?; // A server template counts only for its inline scripts and the code // that reads client data. let template = crate::components::server_template(relative) - && std::fs::read_to_string(path) + && args + .read(path, LOCAL_PARSE_MAX) .is_ok_and(|text| crate::components::judged(relative, &text)); if (discovery::source(relative, &args.source_extension) || template) && selected(relative) @@ -205,11 +201,41 @@ fn source_paths( { paths.push((path.to_path_buf(), classifier.role(relative).to_string())); } + Ok(()) + }; + for entry in walker(&context.root) { + let entry = entry.context("Failed while discovering Jev scope")?; + if entry.file_type().is_some_and(|t| t.is_file()) { + consider(entry.path())?; + } + } + for path in only_in_git(args, context) { + consider(&path)?; } paths.sort_by(|a, b| a.0.cmp(&b.0)); Ok(paths) } +/// Files the change touched that Git holds and the working tree no longer +/// does, which the walk cannot find: a file staged and then deleted, or +/// deleted since the pushed commit. Paths inside the directories a check +/// never reads stay out, as the walk leaves them. +fn only_in_git(args: &CheckArgs, context: &ConfigContext) -> Vec { + let Some(recorded) = &args.recorded else { + return Vec::new(); + }; + recorded + .paths() + .filter(|relative| { + !relative.components().any(|part| { + discovery::SKIPPED_DIRS.contains(&part.as_os_str().to_str().unwrap_or_default()) + }) + }) + .map(|relative| context.root.join(relative)) + .filter(|path| std::fs::symlink_metadata(path).is_err()) + .collect() +} + /// SQL files for the access-control rule and workflows for the workflow rule. /// Unchanged SQL is kept as context: earlier migrations decide the final /// state of changed ones. @@ -302,17 +328,21 @@ fn load( return Ok(recast(result, "vendored", relative)); } let copied = |source: &str| copied_now((&path, relative), &role, source, args, context); - if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.len() > args.max_file_bytes) { + if args + .size(&path) + .is_some_and(|size| size > args.max_file_bytes) + { // A copied library or build output is excluded whatever its size. - let copied = read_source(&path, LOCAL_PARSE_MAX) + let copied = args + .read(&path, LOCAL_PARSE_MAX) .ok() .and_then(|source| copied(&source)); return Ok(match copied { Some(kind) => recast(result, kind, relative), - None => over_read_cap(result, relative, &path, args.max_file_bytes)?, + None => over_read_cap(result, relative, &path, args)?, }); } - match read_source(&path, args.max_file_bytes) { + match args.read(&path, args.max_file_bytes) { Ok(source) => Ok(match copied(&source) { Some(kind) => recast(result, kind, relative), None => source_input(result, source, (&path, relative), args, context, extra), @@ -504,13 +534,16 @@ fn error_input(mut result: FileResult, error: impl ToString) -> Input { const LOCAL_PARSE_MAX: u64 = 1_048_576; +/// A file larger than `--max-file-bytes`, as `args` reads it: parsed +/// locally when it is at most 1 MiB, and never sent. fn over_read_cap( mut result: FileResult, relative: &std::path::Path, path: &std::path::Path, - cap: u64, + args: &CheckArgs, ) -> Result { - let parsed = match read_source(path, LOCAL_PARSE_MAX) { + let cap = args.max_file_bytes; + let parsed = match args.read(path, LOCAL_PARSE_MAX) { Ok(source) => Some(source), Err(error) => { let message = error.to_string(); @@ -520,11 +553,10 @@ fn over_read_cap( None } }; - let len = parsed.as_ref().map(String::len).unwrap_or_else(|| { - std::fs::symlink_metadata(path) - .map(|metadata| metadata.len() as usize) - .unwrap_or(0) - }); + let len = parsed + .as_ref() + .map(String::len) + .unwrap_or_else(|| args.size(path).unwrap_or(0) as usize); if let Some(source) = &parsed { result.source_hash = hash(source.as_bytes()); result.content_identity = super::locations::identity(relative, source); @@ -564,6 +596,11 @@ pub(super) fn read_source(path: &std::path::Path, limit: u64) -> Result bytes.len() as u64 <= limit, "File grew beyond --max-file-bytes" ); + text_of(bytes) +} + +/// A source file's bytes as text: never with NUL bytes, and UTF-8. +pub(crate) fn text_of(bytes: Vec) -> Result { ensure!(!bytes.contains(&0), "Source contains binary NUL bytes"); String::from_utf8(bytes).context("Source is not UTF-8") } diff --git a/src/inventory/texts.rs b/src/inventory/texts.rs index 3cfa28f..3660bd8 100644 --- a/src/inventory/texts.rs +++ b/src/inventory/texts.rs @@ -31,7 +31,7 @@ pub(super) fn add_texts( && boundary.permits(&relative) && crate::context::ensure_visible_path(&relative).is_ok() && classifier.role(&relative) != "generated" - && std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()); + && args.regular_file(&path); let existing = inputs.iter().position(|i| i.result.path == relative); if !wanted || existing.is_some_and(|at| !set_aside(&inputs[at])) { continue; diff --git a/src/main.rs b/src/main.rs index f538d60..8faf224 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ mod docs; mod evaluate; mod file_kind; mod gate; +mod git_hooks; mod github; mod gitlab; mod guards; diff --git a/src/options/commands.rs b/src/options/commands.rs index 51438e2..2c37e96 100644 --- a/src/options/commands.rs +++ b/src/options/commands.rs @@ -100,9 +100,9 @@ pub enum JevCommand { #[command(subcommand)] action: Option, }, - /// Write a commented jevgate.toml, or set up a coding agent's hooks (offline) + /// Write a commented jevgate.toml, or set up a coding agent's hooks or a Git hook (offline) /// - /// Without --agent: limits uploads to the detected source and test + /// Without --agent or --git-hook: limits uploads to the detected source and test /// directories and to agent instruction files, denies credential files, /// and lists every rule group with its gate level. Review the file before /// the first paid check. @@ -118,10 +118,15 @@ pub enum JevCommand { /// included) stops it with nothing written. It then runs the `jevgate` on /// your PATH, which the agent will run, and warns when that one cannot /// answer the hooks. + /// + /// With --git-hook: writes `.git/hooks/pre-push` (`jevgate check + /// --pre-push`) or `.git/hooks/pre-commit` (`jevgate check --staged`), + /// never over a hook JevGate did not write. When JevGate cannot finish or + /// is not installed, the hook lets the push or commit through and says so. #[command(after_long_help = INIT_EXAMPLES)] Init { /// Replace an existing jevgate.toml - #[arg(long, conflicts_with = "agents")] + #[arg(long, conflicts_with = "target")] force: bool, #[command(flatten)] setup: crate::setup::AgentSetup, @@ -213,8 +218,9 @@ Workflow: jevgate rules propose Propose custom questions from AGENTS.md and other instruction files jevgate rules add NAME Add a measured custom question from the gallery -For agents and CI: +For agents, Git hooks and CI: jevgate init --agent claude Hooks for Claude Code (also codex, cursor, gemini, opencode) + jevgate init --git-hook pre-push A Git hook that judges what each push sends jevgate check --base origin/main Only what changed since a revision jevgate check --base origin/main --format json The full report, raw probabilities included jevgate check --base origin/main --format github Annotations and a job summary on GitHub @@ -223,9 +229,11 @@ For agents and CI: jevgate rules test Custom questions against their examples Exit codes: - 0 Gate passed, or no supported file changed since --base + 0 Gate passed, or no supported file changed since --base; also a run that could not + finish when --on-incomplete passes it, as it does by default with --staged and + --pre-push, saying so on stderr 1 Gate failed - 2 Run incomplete (no key, provider rejection, request budget reached), invalid + 2 Run incomplete (no key, provider rejection, a budget reached), invalid configuration or invalid usage 128+N Interrupted by signal N `jevgate hook` always exits 0: agents read 2 as a block, so its JSON reply says what happened @@ -258,6 +266,8 @@ Examples: jevgate check src/billing --verbose One directory, with notes and per-file detail jevgate check --base origin/main --format json Only what changed, machine-readable jevgate check --base origin/main --whole-files Every unit of each changed file + jevgate check --staged What a commit records, for a pre-commit hook + jevgate check --pre-push What a push sends, for a pre-push hook jevgate check --rule default --rule security Add the opt-in security group jevgate check --rule documentation Agent instruction files, project docs and code comments jevgate check --rule comments Only code comments: repeated code, filler, narrated edits @@ -267,10 +277,15 @@ Examples: jevgate check --fail-on review --fail-on security=consider jevgate check --dry-run --show-requests Exactly what would be uploaded, offline jevgate check --cache-only Replay cached answers; never contact the provider + jevgate check --max-seconds 30 --max-cost 0.10 Stop asking after 30 s or 10 cents; incomplete then + jevgate check --on-incomplete pass Exit 0 when the run cannot finish, saying so on stderr Reading the JSON report (--format json or .jevgate/latest.json): - complete false when any selected file was not judged; the exit code is then 2 + complete false when any selected file was not judged; the exit code is then 2, + or 0 when --on-incomplete passes it scope whole-files, or changed-lines when --base judged what changed + staged true when --staged judged the index; pushed_revision: the + commit --pre-push judged, from base_revision gate passed, reasons, new_findings, baselined_findings fail_on the gate levels; fail_on_mature says what `mature` stands for files[].status clear, note, consider, review, uncertain, needs-context, @@ -324,6 +339,9 @@ Examples: jevgate init --agent codex,gemini --project This repository's Codex and Gemini CLI hooks jevgate init --agent cursor --dry-run What would change, without writing jevgate init --agent claude --remove Take out what JevGate wrote + jevgate init --git-hook pre-push A Git hook: judge what each push sends + jevgate init --git-hook pre-commit A Git hook: judge what each commit records + jevgate init --git-hook pre-push --remove Take the Git hook out again Files, for your user and with --project: claude ~/.claude/settings.json, rules/jevgate.md .claude/settings.json, .claude/rules/jevgate.md diff --git a/src/options/mod.rs b/src/options/mod.rs index 33f1bf9..bbfd10e 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -37,6 +37,41 @@ pub enum ColorChoice { Never, } +/// What a run that could not finish exits with. +#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq, serde::Deserialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum OnIncomplete { + /// Exit 0, saying on stderr that the change was not checked, and why + Pass, + /// Exit 2 + Fail, +} + +/// The Git hook a check runs for: `--staged` before a commit, `--pre-push` +/// before a push. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Moment { + Commit, + Push, +} + +impl Moment { + /// What the hook holds back while the check runs: "commit", "push". + pub fn noun(self) -> &'static str { + match self { + Self::Commit => "commit", + Self::Push => "push", + } + } +} + +/// Seconds a `--staged` or `--pre-push` check asks for at most, unless +/// `--max-seconds` or `max_seconds` says otherwise: a commit or a push waits +/// on it, and a provider that stops answering held a run of 100 requests +/// for 8 minutes before it ended incomplete. +pub const HOOK_SECONDS: u64 = 60; + /// Results that fail the check. Consider also fails on review findings. #[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)] pub enum FailOn { @@ -130,16 +165,49 @@ pub struct CheckArgs { /// are listed in the report. The revision must exist locally: in CI, /// check out with full history (for example `fetch-depth: 0`). When no /// supported file changed, the run is complete and exits 0. - #[arg(long, value_name = "REVISION", help_heading = SCOPE)] + #[arg(long, value_name = "REVISION", group = "change", help_heading = SCOPE)] pub base: Option, - /// With --base, judge each changed file whole, not only what the change touches - #[arg(long, requires = "base", help_heading = SCOPE)] + /// Review only what is staged: the change a commit records, for a pre-commit hook + /// + /// Compares the index with HEAD, and never reads untracked files; in a + /// hook, the index Git is committing, which for `commit -a` or `git commit + /// PATHS` is a temporary one. The files the change touched are read as + /// staged, so a file staged in part with `git add -p` is judged as it will + /// be committed; other files read as evidence come from disk. A commit + /// that concludes a merge is judged for how the person resolved it, not + /// for the merged branch's changes. Only what the change touches is asked + /// about and reported, as with --base. When + /// the run cannot finish, the commit goes through and JevGate says so + /// (--on-incomplete), within 60 seconds by default (--max-seconds). + #[arg(long, group = "change", help_heading = SCOPE)] + pub staged: bool, + /// Review what a push sends, for a pre-push hook: the refs Git passes it on stdin + /// + /// Each pushed commit is compared with the last commit on its first-parent + /// line that a remote already has: a branch pushed before with its last + /// push, a new or rebased branch with where it leaves the remote's + /// history. Files are read as committed, not as the working tree holds + /// them. Under pre-commit or prek, the ref they pass (PRE_COMMIT_TO_REF) + /// is read instead; run on a terminal, the current branch. A ref none of + /// whose history is on a remote is not checked, and a line says so. When + /// the run cannot finish, the push goes through and JevGate says so + /// (--on-incomplete), within 60 seconds by default (--max-seconds). + #[arg(long, group = "change", help_heading = SCOPE)] + pub pre_push: bool, + /// With --base, --staged or --pre-push, judge each changed file whole, not only what the change touches + #[arg(long, requires = "change", help_heading = SCOPE)] pub whole_files: bool, - /// Set by the agent hook: a snapshot of the working tree (a Git tree). The - /// change is then `base`, the turn's snapshot, to this one, with no merge - /// base: the fork point of a snapshot and HEAD is HEAD itself. + /// What the change runs to from `base`: the working tree with --base, the + /// index with --staged, a pushed commit with --pre-push, or, set by the + /// agent hook, a snapshot of the working tree (a Git tree). A snapshot + /// and a commit are compared with `base` as it is, with no merge base: + /// the fork point of a snapshot and HEAD is HEAD itself. #[arg(skip)] - pub worktree_snapshot: Option, + pub now: crate::revision::Now, + /// With --staged or --pre-push, the files the change touched as Git + /// holds them; they are judged as read here, not from disk. + #[arg(skip)] + pub recorded: Option, /// Also judge tests: test value, redundancy, and shared logic among tests /// /// Without it, test files are judged only for file organization. Test @@ -195,9 +263,20 @@ pub struct CheckArgs { /// group, for example `security=consider`; the most specific target wins. /// Flags replace `fail_on` and `[rules]` levels from jevgate.toml for the /// rules they address. Notes and baselined findings never fail the gate. - /// An incomplete run exits 2 regardless of the gate. + /// An incomplete run exits 2 regardless of the gate, unless --on-incomplete + /// passes it. #[arg(long = "fail-on", value_name = "[TARGET=]LEVEL", value_parser = fail_on_spec, help_heading = RULES)] pub fail_on_specs: Vec, + /// When the run cannot finish: pass (exit 0) or fail (exit 2) [default: pass with --staged and --pre-push, else fail] + /// + /// A run that could not judge everything, for want of a key, after an + /// HTTP 402, with a provider that stopped answering or at a budget, fails + /// with exit 2 by default, so CI never passes on partial evidence. With + /// --staged and --pre-push it passes by default, so an outage never holds + /// a commit or a push; stderr then says the change was not checked, and + /// why. Also set by `on_incomplete` in jevgate.toml. + #[arg(long, value_enum, value_name = "WHEN", help_heading = RULES)] + pub on_incomplete: Option, /// The resolved levels for rules without their own: from --fail-on, else configuration. #[arg(skip)] pub fail_on: Vec, @@ -263,6 +342,22 @@ pub struct CheckArgs { /// ceiling this flag can only lower. #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=1000000), help_heading = BUDGETS)] pub max_requests: Option, + /// Stop asking after this many seconds; what is left unasked leaves the run incomplete [default: 60 with --staged and --pre-push] + /// + /// No request starts, and no retry or pause runs, past it, and an attempt + /// under way gets only the time left. The answers received are kept in + /// the cache, so a rerun asks only for the rest. `max_seconds` in + /// jevgate.toml is a ceiling this flag can only lower. + #[arg(long, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=86400), help_heading = BUDGETS)] + pub max_seconds: Option, + /// Stop asking before the estimated spend passes this many dollars; what is left unasked leaves the run incomplete + /// + /// Each request is priced from its size before it is sent, at the model's + /// price, and stderr says when 75% and 90% of the budget are spent. + /// Answers from the cache cost nothing. `max_cost` in jevgate.toml is a + /// ceiling this flag can only lower. + #[arg(long, value_name = "DOLLARS", value_parser = dollars, help_heading = BUDGETS)] + pub max_cost: Option, /// Maximum simultaneous requests, at most 6; a higher value is lowered to 6 [default: 6, or 3 with a gateway's key] /// /// The default follows the key: 6 with a TypeSafe key, 3 with an @@ -351,6 +446,14 @@ fn concurrency(value: &str) -> Result { } } +/// `--max-cost`: a positive number of dollars. +pub(crate) fn dollars(value: &str) -> Result { + match value.trim_start_matches('$').parse::() { + Ok(usd) if usd.is_finite() && usd > 0.0 => Ok(usd), + _ => Err("Use a positive number of dollars, for example: --max-cost 0.50".into()), + } +} + fn source_extension(value: &str) -> Result { if value.is_empty() || !value.bytes().all(|c| c.is_ascii_alphanumeric()) { return Err("Use an extension without a dot, for example: --source-extension zig".into()); @@ -422,9 +525,59 @@ impl CheckArgs { } /// The snapshot of the working tree an agent's turn began with, in the - /// agent hook's checks of a turn: `base`, when `worktree_snapshot` is set. + /// agent hook's checks of a turn: `base`, when the change runs to a + /// snapshot. pub fn turn_start(&self) -> Option<&str> { - self.worktree_snapshot.as_ref().and(self.base.as_deref()) + matches!(self.now, crate::revision::Now::Snapshot(_)) + .then_some(self.base.as_deref()) + .flatten() + } + + /// The Git hook this check runs for, if any. + pub fn moment(&self) -> Option { + match (self.staged, self.pre_push) { + (true, _) => Some(Moment::Commit), + (_, true) => Some(Moment::Push), + _ => None, + } + } + + /// Whether a run that cannot finish passes: `--on-incomplete`, else + /// `on_incomplete`, else for a commit or a push. + pub fn passes_incomplete(&self) -> bool { + let default = match self.moment() { + Some(_) => OnIncomplete::Pass, + None => OnIncomplete::Fail, + }; + self.on_incomplete.unwrap_or(default) == OnIncomplete::Pass + } + + /// The text of the file at `path` as this check judges it: from Git when + /// the change runs to the index or a pushed commit and touched the file, + /// else from disk, as `inventory::read_source` reads it. + pub fn read(&self, path: &std::path::Path, limit: u64) -> anyhow::Result { + match self.recorded.as_ref().and_then(|r| r.read(path, limit)) { + Some(read) => read, + None => crate::inventory::read_source(path, limit), + } + } + + /// The size of the file at `path` as this check judges it; none when it + /// cannot be read. + pub fn size(&self, path: &std::path::Path) -> Option { + match self.recorded.as_ref().and_then(|r| r.size(path)) { + Some(size) => Some(size), + None => std::fs::symlink_metadata(path).ok().map(|m| m.len()), + } + } + + /// Whether the file at `path`, as this check judges it, is a regular + /// file: not a link, a directory or a submodule. + pub fn regular_file(&self, path: &std::path::Path) -> bool { + match self.recorded.as_ref().and_then(|r| r.regular(path)) { + Some(regular) => regular, + None => std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file()), + } } /// Whether any documentation rule is selected, so instruction files are found. diff --git a/src/output/mod.rs b/src/output/mod.rs index f9ad35e..695165b 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -221,19 +221,24 @@ pub(crate) fn through(provider: crate::provider::Provider) -> String { const SHORT_COMMIT: usize = 7; /// With a base revision, what the check judged since it: ` · changed lines -/// since 1a2b3c4` or ` · whole files changed since 1a2b3c4`. +/// since 1a2b3c4` or ` · whole files changed since 1a2b3c4`; ` · staged +/// lines since 1a2b3c4` for the index, and ` · changed lines from 1a2b3c4 +/// to 9f8e7d6` for a pushed commit. fn since(report: &Report) -> String { let Some(base) = &report.base_revision else { return String::new(); }; - let judged = match report.scope { - Scope::ChangedLines => "changed lines", - Scope::WholeFiles => "whole files changed", + let short = |id: &str| id.get(..SHORT_COMMIT).unwrap_or(id).to_string(); + let judged = match (report.scope, report.staged) { + (Scope::ChangedLines, false) => "changed lines", + (Scope::WholeFiles, false) => "whole files changed", + (Scope::ChangedLines, true) => "staged lines", + (Scope::WholeFiles, true) => "whole files staged", }; - format!( - " · {judged} since {}", - base.get(..SHORT_COMMIT).unwrap_or(base) - ) + match &report.pushed_revision { + Some(pushed) => format!(" · {judged} from {} to {}", short(base), short(pushed)), + None => format!(" · {judged} since {}", short(base)), + } } /// Every finding with its file's path, highest rank first. diff --git a/src/requests.rs b/src/requests.rs index 91c6815..337a309 100644 --- a/src/requests.rs +++ b/src/requests.rs @@ -5,7 +5,7 @@ mod lookup; pub(super) use lookup::{Answered, cached, question_count, unanswered}; -use crate::{evaluate::Session, response, schema}; +use crate::{evaluate::Session, options::CheckArgs, response, schema}; use anyhow::{Result, ensure}; use lookup::Lookup; use serde_json::Value; @@ -179,13 +179,21 @@ impl Session<'_> { /// Upload `pending` through the evaluator, rechecking each source first, /// and record every outcome in its receipt. fn send(&mut self, pending: Vec>, receipts: &mut [Receipt]) { - let root = &self.context.root; - let max_bytes = self.args.max_context_bytes.max(self.args.max_file_bytes); + let (args, root) = (self.args, &self.context.root); + let (spend, budget) = (self.spend.as_ref(), &self.budget); let before = |request: &Value| { crate::cancellation::check()?; // A queued upload must recheck the current bytes even when its // source was already verified while preparing the batch. - require_paths(root, max_bytes, request, &mut SourceHashes::new()) + require_paths(args, root, request, &mut SourceHashes::new())?; + match spend { + Some(spend) => spend.charge(args, request, || { + let tokens = budget.tokens_of(&provider_request(request)) as u64; + crate::model::usd(args.model(), tokens) + .unwrap_or(tokens as f64 * crate::model::INPUT_USD_PER_MILLION / 1e6) + }), + None => Ok(()), + } }; let (sent, mut lookups): (Vec<_>, Vec<_>) = pending .into_iter() @@ -222,6 +230,70 @@ impl Session<'_> { } } +/// A dollar budget, `--max-cost`: each request is priced from its size +/// before it is first sent, at the model's price (an alias's at Jev 1.13's, +/// which the gateways charge too), and none is sent that would pass it. +pub struct Spend { + budget: f64, + spent: std::sync::Mutex, +} + +/// What the requests a check sent are estimated to cost. +#[derive(Default)] +struct Spent { + usd: f64, + /// The requests priced, by address: a retry sends the same request, and + /// is not priced again. + priced: std::collections::BTreeSet, + /// The share of the budget last said to be spent, in percent. + noticed: u8, +} + +/// Shares of the budget stderr says are spent, in percent, highest first. +const SPEND_NOTICES: [u8; 2] = [90, 75]; + +impl Spend { + pub fn new(budget: f64) -> Self { + Self { + budget, + spent: Default::default(), + } + } + + /// Price `request` at `usd` the first time it is sent, or refuse it when + /// that would pass the budget; say on stderr when the spend first + /// passes 75% and 90% of it. + fn charge(&self, args: &CheckArgs, request: &Value, usd: impl FnOnce() -> f64) -> Result<()> { + let mut spent = self.spent.lock().unwrap(); + let address = std::ptr::from_ref(request) as usize; + if spent.priced.contains(&address) { + return Ok(()); + } + let cost = usd(); + ensure!( + spent.usd + cost <= self.budget, + "{} request not sent: it would pass the ${:.2} budget (max_cost)", + args.provider.service().label, + self.budget + ); + spent.usd += cost; + spent.priced.insert(address); + let share = spent.usd / self.budget * 100.0; + if let Some(notice) = SPEND_NOTICES + .into_iter() + .find(|¬ice| share >= f64::from(notice) && spent.noticed < notice) + { + spent.noticed = notice; + note!( + "jevgate: {notice}% of the ${:.2} budget spent (about ${:.4})", + self.budget, + spent.usd + ); + } + Ok(()) + } +} + /// What one answered request was billed: the model that answered, and the /// tokens its response reported. pub(super) struct Billed { @@ -346,22 +418,19 @@ pub(super) fn require_current( request: &Value, hashes: &mut SourceHashes, ) -> Result<()> { - require_paths( - &session.context.root, - session - .args - .max_context_bytes - .max(session.args.max_file_bytes), - request, - hashes, - ) + require_paths(session.args, &session.context.root, request, hashes) } + +/// Stop when a file `request` holds no longer reads as it did when the +/// request was built, as `args` reads it: from disk, or as Git holds a +/// file a `--staged` or `--pre-push` change touched. fn require_paths( + args: &CheckArgs, root: &std::path::Path, - max_bytes: u64, request: &Value, hashes: &mut SourceHashes, ) -> Result<()> { + let max_bytes = args.max_context_bytes.max(args.max_file_bytes); for file in request["jevgate"]["sources"] .as_array() .into_iter() @@ -372,7 +441,7 @@ fn require_paths( hashes .entry(path.into()) .or_insert_with(|| { - crate::inventory::read_source(&root.join(path), max_bytes) + args.read(&root.join(path), max_bytes) .ok() .map(|s| schema::hash(s.as_bytes())) }) diff --git a/src/revision.rs b/src/revision.rs index 84a3234..9cb3cae 100644 --- a/src/revision.rs +++ b/src/revision.rs @@ -1,7 +1,10 @@ //! What a change against a Git revision holds: the changed and deleted -//! files, and the lines of each file the change touched; and snapshots of +//! files, and the lines of each file the change touched, up to the working +//! tree, the index being committed or a commit being pushed; snapshots of //! the working tree for the agent hook's turns, whose changes are read -//! between two snapshots. Git never executes external diff helpers. +//! between two snapshots; the files a change touched as Git holds them +//! (`recorded`); and what a push sends (`push`). Git never executes +//! external diff helpers. use crate::options::CheckArgs; use anyhow::{Context, Result, bail, ensure}; use serde::Serialize; @@ -15,9 +18,8 @@ use std::{ pub struct Changes { pub revision: String, - /// The snapshot the change runs to, from the agent hook; none for the - /// working tree. - now: Option, + /// What the change runs to. + now: Now, /// Current path -> previous path; None denotes a new or untracked file. pub paths: BTreeMap>, pub deleted: Vec, @@ -26,6 +28,35 @@ pub struct Changes { pub lines: BTreeMap, } +/// What a change runs to from its base revision. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Now { + /// The working tree, untracked files included: `check --base`. + #[default] + WorkingTree, + /// A snapshot of the working tree the agent hook took (a Git tree). Its + /// files are read from disk, which held them a moment before. + Snapshot(String), + /// The index a commit records: `check --staged`. In a hook it is the one + /// `GIT_INDEX_FILE` names, a temporary index for `commit -a` or a commit + /// of named paths. + Index, + /// A commit being pushed: `check --pre-push`. + Commit(String), +} + +impl Now { + /// The sides a diff from `base` to this compares: the working tree, the + /// index (`--cached`), or a snapshot or commit. + fn sides<'a>(&'a self, base: &'a str) -> Vec<&'a str> { + match self { + Self::WorkingTree => vec![base], + Self::Index => vec!["--cached", base], + Self::Snapshot(id) | Self::Commit(id) => vec![base, id], + } + } +} + /// The lines of one file a change touched: those it added or modified, and /// the places it removed lines from without adding any. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] @@ -266,43 +297,87 @@ pub(crate) fn git(root: &Path, args: &[&str]) -> Result> { type ChangedPaths = (BTreeMap>, Vec); -/// Changed paths between the diff's `sides` (a revision and the working -/// tree, or two snapshots), each with its previous path (none when added), -/// and deleted paths, relative to the root: a `jevgate.toml` in a -/// subdirectory of a Git repository sees the paths below it, as `ls-files` -/// does. Renames keep their source; conflicts stop the review. -fn tracked_changes(root: &Path, sides: &[&str]) -> Result { +/// One file of a diff between two sides, from its raw entry. +struct DiffEntry { + /// Git's status letter: `A`dded, `M`odified, `D`eleted, `R`enamed, + /// `T`ype changed. + status: u8, + /// Where the file ends up; for a deleted file, where it was. + path: PathBuf, + /// Where it was before a rename; else its path. + previous: PathBuf, + /// Its mode and object on the side the diff runs to. + mode: String, + id: String, +} + +/// The files that differ between the diff's `sides` (a revision and the +/// working tree, the index or a commit, or two snapshots), relative to the +/// root: a `jevgate.toml` in a subdirectory of a Git repository sees the +/// paths below it, as `ls-files` does. Each raw entry is `: `, then the path, and for a +/// rename its new path after the old. Renames keep their source; conflicts +/// stop the review. +fn diff_entries(root: &Path, sides: &[&str]) -> Result> { let mut args = vec![ "diff", "--no-ext-diff", "--no-textconv", "--find-renames", "--relative", - "--name-status", + "--raw", + "--no-abbrev", "-z", ]; args.extend_from_slice(sides); args.push("--"); let bytes = git(root, &args)?; let mut fields = bytes.split(|b| *b == 0).filter(|s| !s.is_empty()); + let mut entries = Vec::new(); + while let Some(header) = fields.next() { + let header = std::str::from_utf8(header)?; + let parts: Vec<&str> = header.trim_start_matches(':').split(' ').collect(); + let [_, mode, _, id, status] = parts[..] else { + bail!("Git printed an unexpected diff entry: {header}"); + }; + let previous = git_path(&mut fields, "Missing Git path")?; + let path = if status.starts_with(['R', 'C']) { + git_path(&mut fields, "Missing Git rename target")? + } else { + previous.clone() + }; + let Some(&status) = status.as_bytes().first() else { + bail!("Git printed an unexpected diff entry: {header}"); + }; + ensure!( + status != b'U', + "Resolve Git conflict in {} before reviewing", + path.display() + ); + entries.push(DiffEntry { + status, + path, + previous, + mode: mode.to_string(), + id: id.to_string(), + }); + } + Ok(entries) +} + +/// Changed paths between the diff's `sides`, each with its previous path +/// (none when added), and deleted paths. +fn tracked_changes(root: &Path, sides: &[&str]) -> Result { let mut paths = BTreeMap::new(); let mut deleted = Vec::new(); - while let Some(status) = fields.next() { - let old = git_path(&mut fields, "Missing Git path")?; - match status.first() { - Some(b'R') => { - let new = git_path(&mut fields, "Missing Git rename target")?; - paths.insert(new, Some(old)); - } - Some(b'D') => deleted.push(old), - Some(b'A') => { - paths.insert(old, None); - } - Some(b'U') => { - anyhow::bail!("Resolve Git conflict in {} before reviewing", old.display()) + for entry in diff_entries(root, sides)? { + match entry.status { + b'D' => deleted.push(entry.path), + b'A' => { + paths.insert(entry.path, None); } _ => { - paths.insert(old.clone(), Some(old)); + paths.insert(entry.path, Some(entry.previous)); } } } @@ -655,16 +730,32 @@ pub(crate) fn blobs( paths: &[&Path], limit: u64, ) -> Result> { - use std::io::Write; // The batch protocol reads one object name per line. let paths: Vec<&Path> = paths .iter() .copied() .filter(|p| !p.to_string_lossy().contains(['\n', '\r'])) .collect(); - let mut texts = BTreeMap::new(); - if paths.is_empty() { - return Ok(texts); + let names: Vec = paths + .iter() + .map(|p| format!("{revision}:./{}", p.to_string_lossy().replace('\\', "/"))) + .collect(); + let answers = cat_batch(root, &names, limit)?; + Ok(paths + .into_iter() + .zip(answers) + .filter_map(|(path, answer)| Some((path.to_path_buf(), answer.text()?))) + .collect()) +} + +/// What `git cat-file --batch` answers for each of `names`, in order: an +/// object ID, or a revision and a path (`:./`). One Git process +/// reads them all, and keeps the bytes of objects of at most `limit` bytes. +fn cat_batch(root: &Path, names: &[String], limit: u64) -> Result> { + use std::io::Write; + let mut answers = Vec::new(); + if names.is_empty() { + return Ok(answers); } let mut child = git_command(root, &["cat-file", "--batch"]) .stdin(Stdio::piped()) @@ -672,38 +763,48 @@ pub(crate) fn blobs( .stderr(Stdio::null()) .spawn() .context("Cannot run Git")?; - let names: String = paths - .iter() - .map(|p| format!("{revision}:./{}\n", p.to_string_lossy().replace('\\', "/"))) - .collect(); + let input: String = names.iter().map(|name| format!("{name}\n")).collect(); let mut stdin = child.stdin.take().context("Git has no stdin")?; // Written on a thread, so Git never waits on a full output pipe. - let writer = std::thread::spawn(move || stdin.write_all(names.as_bytes())); + let writer = std::thread::spawn(move || stdin.write_all(input.as_bytes())); let mut out = BufReader::new(child.stdout.take().context("Git has no stdout")?); - for path in paths { + for _ in names { match batched(&mut out, limit)? { - Batched::Text(text) => { - texts.insert(path.to_path_buf(), text); - } - Batched::Other => {} Batched::End => break, + answer => answers.push(answer), } } let _ = writer.join(); let _ = child.wait(); - Ok(texts) + Ok(answers) } /// One answer of `git cat-file --batch`. enum Batched { - /// A blob of at most the limit's bytes of UTF-8 without NUL bytes. - Text(String), - /// A missing path, or an object read past as too large or not text. - Other, + /// An object of `size` bytes, with its bytes when there are at most the + /// limit's. + Blob { size: u64, bytes: Option> }, + /// A path the revision lacks, or an object the repository lacks. + Missing, /// Git printed nothing more. End, } +impl Batched { + /// The object's text: kept, UTF-8 and without NUL bytes. + fn text(self) -> Option { + let Self::Blob { + bytes: Some(bytes), .. + } = self + else { + return None; + }; + String::from_utf8(bytes) + .ok() + .filter(|text| !text.contains('\0')) + } +} + /// The next answer `out`, the output of `git cat-file --batch`, holds: /// ` `, then the content and a newline; or ` /// missing` alone, for a path the revision lacks. @@ -714,7 +815,7 @@ fn batched(out: &mut impl BufRead, limit: u64) -> Result { } let header = header.trim_end(); if header.ends_with(" missing") { - return Ok(Batched::Other); + return Ok(Batched::Missing); } let size: u64 = header .rsplit(' ') @@ -729,9 +830,9 @@ fn batched(out: &mut impl BufRead, limit: u64) -> Result { content.read_to_end(&mut bytes)?; } out.read_exact(&mut [0])?; - Ok(match String::from_utf8(bytes) { - Ok(text) if size <= limit && !text.contains('\0') => Batched::Text(text), - _ => Batched::Other, + Ok(Batched::Blob { + size, + bytes: (size <= limit).then_some(bytes), }) } @@ -747,12 +848,14 @@ pub fn untracked(root: &Path) -> Result> { impl Changes { /// The changes a check with a base reviews: since the fork point of - /// `--base` and HEAD, or, from the agent hook, between two snapshots. + /// `--base` and HEAD; from HEAD to the index with `--staged`; from the + /// commit a remote has to the one pushed with `--pre-push`; or, from + /// the agent hook, between two snapshots. pub fn of_check(root: &Path, args: &CheckArgs) -> Option> { let base = args.base.as_deref()?; - Some(match args.worktree_snapshot.as_deref() { - Some(now) => Self::between(root, base, now), - None => Self::load(root, base), + Some(match &args.now { + Now::WorkingTree => Self::load(root, base), + now => Self::between(root, base, now.clone()), }) } @@ -764,36 +867,41 @@ impl Changes { } Ok(Self { revision, - now: None, + now: Now::WorkingTree, paths, deleted, lines: BTreeMap::new(), }) } - /// From snapshot `base` to snapshot `now`: a file untracked in both is - /// new only when it did not exist at `base`. - fn between(root: &Path, base: &str, now: &str) -> Result { + /// From `base`, a commit or snapshot, to what `now` holds; untracked + /// files are left out. Between two snapshots, a file untracked in both + /// is new only when it did not exist at `base`. + fn between(root: &Path, base: &str, now: Now) -> Result { + let named = match &now { + Now::Snapshot(id) | Now::Commit(id) => is_object_id(id), + Now::Index | Now::WorkingTree => true, + }; ensure!( - is_object_id(base) && is_object_id(now), - "A working-tree snapshot must be a Git object ID" + is_object_id(base) && named, + "A change's revisions must be Git object IDs" ); - let (paths, deleted) = tracked_changes(root, &[base, now])?; - Ok(Self { + let mut changes = Self { revision: base.to_owned(), - now: Some(now.to_owned()), - paths, - deleted, + now, + paths: BTreeMap::new(), + deleted: Vec::new(), lines: BTreeMap::new(), - }) + }; + (changes.paths, changes.deleted) = tracked_changes(root, &changes.sides())?; + Ok(changes) } - /// The two sides a diff of this change compares: the revision and the - /// working tree, or the turn's two snapshots. + /// The sides a diff of this change compares: the revision and the + /// working tree, the index (`--cached`), the pushed commit, or the + /// turn's second snapshot. pub(crate) fn sides(&self) -> Vec<&str> { - std::iter::once(self.revision.as_str()) - .chain(self.now.as_deref()) - .collect() + self.now.sides(&self.revision) } /// The same changes with the lines each of the `judged` files changed, @@ -872,7 +980,35 @@ impl Changes { } } +/// What `--staged` compares the index with: HEAD, or before the first +/// commit the empty tree, from which every staged file is new. A commit +/// that concludes a merge is compared with HEAD and the merged commit +/// merged as Git merges them, conflict markers and all, so it judges how +/// the person resolved the merge, not the merged branch's commits as the +/// commit's own. +pub fn staged_base(root: &Path) -> Result { + ensure!( + index_file(root).is_some(), + "--staged reads the Git index; run it inside a Git work tree" + ); + let revision = |name: &str| { + git(root, &["rev-parse", "--verify", "--quiet", name]) + .ok() + .and_then(|id| object_id(&id).ok()) + }; + let Some(head) = revision("HEAD^{commit}") else { + // The tree of nothing, in the repository's own hash. + return object_id(&git(root, &["hash-object", "-t", "tree", "--stdin"])?); + }; + let merged = + revision("MERGE_HEAD^{commit}").and_then(|merging| push::merged(root, &head, &merging)); + Ok(merged.unwrap_or(head)) +} + +pub mod push; +mod recorded; mod snapshot; +pub use recorded::Recorded; pub use snapshot::snapshot; #[cfg(test)] diff --git a/src/revision/push.rs b/src/revision/push.rs new file mode 100644 index 0000000..de68858 --- /dev/null +++ b/src/revision/push.rs @@ -0,0 +1,211 @@ +//! What a push sends. Git gives a pre-push hook the refs it pushes on stdin, +//! one a line: ` `. +//! pre-commit and prek read those lines themselves and pass the first ref +//! with something to push as `PRE_COMMIT_TO_REF`. Each pushed commit is +//! compared with the last commit on its first-parent line that a remote +//! already has, as Qlty's pre-push check compares (qltysh/qlty#2867): a +//! branch pushed before is compared with its last push, a new branch with +//! where it left the remote's history, and a rebased one with where it +//! leaves that history now, not with the commits the rebase replaced. +use super::{git, git_command, is_object_id, object_id}; +use anyhow::{Result, bail}; +use std::{collections::BTreeMap, path::Path}; + +/// One ref being pushed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Update { + /// The local ref as Git names it (`refs/heads/main`), else `HEAD`. + pub name: String, + /// The commit pushed, an object ID or a revision; all zeros when the + /// push deletes the remote ref. + pub local: String, + /// The commit the remote ref holds; none when the push creates it. + pub remote: Option, +} + +impl Update { + /// HEAD, as a push of the current branch would send it: `check + /// --pre-push` run by hand, with no pushed refs to read. + pub fn head() -> Self { + Self { + name: "HEAD".into(), + local: "HEAD".into(), + remote: None, + } + } +} + +/// What pushing one ref sends. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Sent { + /// Commits no remote has, up to `commit`, to compare with `base`: a + /// commit, or the tree of two merged. + Commits { base: String, commit: String }, + /// Nothing to judge: every commit is on a remote already, the push + /// deletes the ref, or the ref names no commit. + Nothing, + /// Commits none of which a remote has, down to the first: nothing on a + /// remote to compare them with, as when a repository is first pushed. + Unrooted, +} + +/// The refs a pre-push hook's `input` names. A line that is not four +/// fields, or whose objects are not IDs, stops the check: Git wrote it. +pub fn updates(input: &str) -> Result> { + input + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + let fields: Vec<&str> = line.split_whitespace().collect(); + let [name, local, _, remote] = fields[..] else { + bail!("Git passed an unexpected pre-push line: {line:?}"); + }; + if !is_object_id(local) || !is_object_id(remote) { + bail!("Git passed an unexpected pre-push line: {line:?}"); + } + Ok(Update { + name: name.into(), + local: local.into(), + remote: (!zero(remote)).then(|| remote.into()), + }) + }) + .collect() +} + +/// The ref pre-commit or prek names for a pre-push hook, through `var`: +/// `PRE_COMMIT_TO_REF` is the commit pushed, and `PRE_COMMIT_FROM_REF` what +/// the remote ref holds, else the commit before the first the remote +/// lacks. Their `pre-commit run --from-ref --to-ref` sets the same. +pub fn from_pre_commit(var: impl Fn(&str) -> Option) -> Option { + let local = var("PRE_COMMIT_TO_REF")?; + Some(Update { + name: var("PRE_COMMIT_LOCAL_BRANCH").unwrap_or_else(|| "HEAD".into()), + local, + remote: var("PRE_COMMIT_FROM_REF"), + }) +} + +/// What pushing `update` from the repository at `root` sends: the commits +/// neither a remote-tracking ref nor the remote ref reaches, compared with +/// the commits a remote has that they grow from. That is the last commit on +/// the pushed commit's first-parent line that a remote has, unless the push +/// also merges in commits a remote has: after `git merge origin/main`, a +/// push compared with its branch's last push would carry main's changes as +/// its own, and judge code someone else wrote. The base is then the merge +/// of those commits as Git merges them, so what the push judges is its own +/// commits and how it resolved the merge's conflicts; with more than two, it +/// stays the first-parent one. +pub fn sent(root: &Path, update: &Update) -> Result { + if zero(&update.local) { + return Ok(Sent::Nothing); + } + let Some(pushed) = commit_of(root, &update.local) else { + return Ok(Sent::Nothing); + }; + // A remote object this clone never fetched hides nothing it has. + let remote = update.remote.as_deref().and_then(|r| commit_of(root, r)); + let mut args = vec![ + "rev-list", + "--boundary", + "--parents", + &pushed, + "--not", + "--remotes", + ]; + args.extend(remote.as_deref()); + let listed = String::from_utf8(git(root, &args)?)?; + // ` …` for each commit no remote has, and + // `- …` for each commit a remote has that one of them grows from. + let mut parents = BTreeMap::new(); + let mut boundary = Vec::new(); + for line in listed.lines() { + let mut ids = line.split(' '); + match ids.next() { + Some(id) if id.starts_with('-') => boundary.push(id[1..].to_string()), + Some(id) => { + parents.insert(id.to_string(), ids.next().map(str::to_string)); + } + None => {} + } + } + if parents.is_empty() { + return Ok(Sent::Nothing); + } + // The first-parent line, from the pushed commit to the first commit a + // remote has; none when it reaches the first commit of the history. + let mut at = pushed.as_str(); + let first_parent = loop { + match parents.get(at) { + Some(Some(parent)) => at = parent, + Some(None) => break None, + None => break Some(at.to_string()), + } + }; + let Some(first_parent) = first_parent else { + return Ok(Sent::Unrooted); + }; + let base = match independent(root, &boundary)?.as_slice() { + [one] => one.clone(), + [one, other] => merged(root, one, other).unwrap_or(first_parent), + _ => first_parent, + }; + Ok(Sent::Commits { + base, + commit: pushed, + }) +} + +/// Of `commits`, those no other one descends from: merging in an ancestor +/// changes nothing. +fn independent(root: &Path, commits: &[String]) -> Result> { + if commits.len() < 2 { + return Ok(commits.to_vec()); + } + let mut args = vec!["merge-base", "--independent"]; + args.extend(commits.iter().map(String::as_str)); + let listed = String::from_utf8(git(root, &args)?)?; + Ok(listed.lines().map(str::to_string).collect()) +} + +/// The tree of commits `one` and `other` merged, as `git merge` merges +/// them, conflict markers and all: a conflicted file's resolution then +/// differs from it where the person resolved the conflict, and nowhere +/// else. None with a Git older than 2.38, which cannot merge without a +/// work tree, or for histories that share no commit. +pub(super) fn merged(root: &Path, one: &str, other: &str) -> Option { + let output = git_command( + root, + &["merge-tree", "--write-tree", "--no-messages", one, other], + ) + .output() + .ok()?; + // Exit 1: the merge has conflicts, and the tree holds their markers. + if !matches!(output.status.code(), Some(0 | 1)) { + return None; + } + let tree = String::from_utf8(output.stdout).ok()?; + object_id(tree.lines().next()?.as_bytes()).ok() +} + +/// The commit `revision` names in the repository at `root`, peeling a tag; +/// none when it names no commit this clone has. +fn commit_of(root: &Path, revision: &str) -> Option { + let named = format!("{revision}^{{commit}}"); + git( + root, + &[ + "rev-parse", + "--verify", + "--quiet", + "--end-of-options", + &named, + ], + ) + .ok() + .and_then(|id| object_id(&id).ok()) +} + +/// Whether `object` is Git's all-zero ID: a ref that does not exist. +fn zero(object: &str) -> bool { + object.bytes().all(|b| b == b'0') +} diff --git a/src/revision/recorded.rs b/src/revision/recorded.rs new file mode 100644 index 0000000..03f20c0 --- /dev/null +++ b/src/revision/recorded.rs @@ -0,0 +1,133 @@ +//! The files a change touched as Git holds them, for a check that judges the +//! index being committed or a commit being pushed rather than the working +//! tree: a file staged with `git add -p` holds lines the working tree has +//! changed since, and the changed lines Git reports are the index's. The +//! files it judges are read from here; files read only as evidence (callers, +//! copies elsewhere, context) still come from disk. +use super::{Batched, DiffEntry, Now, cat_batch, diff_entries}; +use anyhow::{Result, bail, ensure}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +/// The largest blob kept: the most any read of a judged file asks for, as +/// `--max-file-bytes` and a local parse stop at 1 MiB. +const KEPT_BYTES: u64 = 1_048_576; + +/// The files a change added, modified or renamed, as the index or a commit +/// holds them, by path relative to the root. +#[derive(Debug)] +pub struct Recorded { + /// The repository root the paths are relative to, as the check names it. + root: PathBuf, + files: BTreeMap, +} + +/// One file as Git holds it. +#[derive(Debug)] +struct Blob { + /// Recorded as a regular file (mode 100644 or 100755), not a symbolic + /// link or a submodule, which a check never uploads. + regular: bool, + size: u64, + /// Its bytes, when there are at most [`KEPT_BYTES`]. + bytes: Option>, +} + +impl Recorded { + /// The files the change from `base` to `now`, the index or a commit, + /// added, modified or renamed, read by two Git processes: the diff's + /// raw entries name each file's mode and object, and one batch reads + /// the objects. + pub fn load(root: &Path, base: &str, now: &Now) -> Result { + if matches!(now, Now::WorkingTree | Now::Snapshot(_)) { + bail!("Only the index or a commit is read from Git"); + } + // A deleted file is not read; the change judges it deleted. + let entries: Vec = diff_entries(root, &now.sides(base))? + .into_iter() + .filter(|entry| entry.status != b'D') + .collect(); + let ids: Vec = entries + .iter() + .filter(|e| regular(e)) + .map(|e| e.id.clone()) + .collect(); + let mut contents = cat_batch(root, &ids, KEPT_BYTES)?.into_iter(); + let mut files = BTreeMap::new(); + for entry in &entries { + let blob = if regular(entry) { + match contents.next() { + Some(Batched::Blob { size, bytes }) => Blob { + regular: true, + size, + bytes, + }, + _ => bail!("Git could not read {} as recorded", entry.path.display()), + } + } else { + Blob { + regular: false, + size: 0, + bytes: None, + } + }; + files.insert(entry.path.clone(), blob); + } + Ok(Self { + root: root.to_path_buf(), + files, + }) + } + + /// The text of the file at `path`, as a read from disk would give it or + /// fail; none when the change did not touch it, so it is read from disk. + pub fn read(&self, path: &Path, limit: u64) -> Option> { + let blob = self.files.get(path.strip_prefix(&self.root).ok()?)?; + Some(blob.read(limit)) + } + + /// The size of the file at `path` as Git holds it; none when the change + /// did not touch it. + pub fn size(&self, path: &Path) -> Option { + let blob = self.files.get(path.strip_prefix(&self.root).ok()?)?; + Some(blob.size) + } + + /// Whether the change touched `path`, and Git holds it as a regular + /// file; none when the change did not touch it. + pub fn regular(&self, path: &Path) -> Option { + let blob = self.files.get(path.strip_prefix(&self.root).ok()?)?; + Some(blob.regular) + } + + /// The paths of the files the change touched, relative to the root. + pub fn paths(&self) -> impl Iterator { + self.files.keys().map(PathBuf::as_path) + } +} + +impl Blob { + /// The text, failing as `inventory::read_source` fails on disk. + fn read(&self, limit: u64) -> Result { + ensure!( + self.regular, + "Source symlinks and special files are not uploaded" + ); + ensure!( + self.size <= limit, + "File exceeds --max-file-bytes; no source was truncated or sent" + ); + match &self.bytes { + Some(bytes) => crate::inventory::text_of(bytes.clone()), + None => bail!("File exceeds --max-file-bytes; no source was truncated or sent"), + } + } +} + +/// Whether Git records the file as a regular one (mode 100644 or 100755), +/// not a symbolic link or a submodule. +fn regular(entry: &DiffEntry) -> bool { + matches!(entry.mode.as_str(), "100644" | "100755") +} diff --git a/src/revision/tests/mod.rs b/src/revision/tests/mod.rs index f3ad50c..66d9d3d 100644 --- a/src/revision/tests/mod.rs +++ b/src/revision/tests/mod.rs @@ -1,9 +1,12 @@ //! Changes against a Git revision: the lines and removals a patch holds, //! the files and lines `Changes` reads from Git, and blobs read from a //! tree. Snapshots of the working tree, and the changes read between two -//! of them, are in `snapshots`. +//! of them, are in `snapshots`; files read from the index or a commit in +//! `recorded`; and what a push sends in `push`. use super::*; use crate::tests::Project; +mod push; +mod recorded; mod snapshots; fn lines(changed: &[(usize, usize)], removed: &[Removal]) -> Lines { diff --git a/src/revision/tests/push.rs b/src/revision/tests/push.rs new file mode 100644 index 0000000..fb7a305 --- /dev/null +++ b/src/revision/tests/push.rs @@ -0,0 +1,268 @@ +//! What a push sends: the refs a pre-push hook reads, and the commits each +//! pushed ref is compared with. +use super::super::push::{Sent, Update, from_pre_commit, sent, updates}; +use crate::tests::Project; +use std::collections::BTreeMap; + +const ZERO: &str = "0000000000000000000000000000000000000000"; + +/// A clone whose `main` a bare remote has, a first commit holding `a.rs`. +struct Clone { + project: Project, + _remote: Project, +} + +impl Clone { + fn new() -> Self { + let remote = Project::new(); + remote.git(&["init", "-q", "--bare"]); + let project = Project::new(); + project.git(&["init", "-q", "-b", "main"]); + let clone = Self { + project, + _remote: remote, + }; + clone.commit("a"); + // Git for Windows reads no remote at a path in the verbatim form. + let url = super::super::for_git(&clone._remote.0); + clone.git(&["remote", "add", "origin", url.to_str().unwrap()]); + clone.git(&["push", "-q", "origin", "main"]); + clone + } + + fn git(&self, args: &[&str]) -> String { + self.project.git(args) + } + + /// Commit `name.rs` on the current branch. + fn commit(&self, name: &str) { + self.project + .write(&format!("{name}.rs"), &format!("fn {name}() {{}}\n")); + self.git(&["add", "-A"]); + self.git(&["commit", "-qm", name]); + } + + /// `feature` pushed with a commit of `f1.rs` holding `feature`, and then + /// a commit on `main` of `a.rs` holding `main` and of `m1.rs`, pushed + /// too; `feature` checked out. + fn diverged(&self) { + self.git(&["checkout", "-qb", "feature"]); + self.commit("f1"); + self.git(&["push", "-q", "origin", "feature"]); + self.git(&["checkout", "-q", "main"]); + self.project.write("a.rs", "fn a() { main() }\n"); + self.commit("m1"); + self.git(&["push", "-q", "origin", "main"]); + self.git(&["checkout", "-q", "feature"]); + } + + fn id(&self, revision: &str) -> String { + self.git(&["rev-parse", revision]).trim().to_string() + } + + /// What pushing `local` sends, over a remote ref at `remote`. + fn sent(&self, local: &str, remote: Option<&str>) -> Sent { + let update = Update { + name: "refs/heads/feature".into(), + local: self.id(local), + remote: remote.map(|r| self.id(r)), + }; + sent(&self.project.0, &update).unwrap() + } + + /// The files that change from the base of what pushing `local` sends to + /// the commit pushed. + fn judged(&self, local: &str, remote: Option<&str>) -> Vec { + let Sent::Commits { base, commit } = self.sent(local, remote) else { + panic!("nothing sent"); + }; + let names = self.git(&["diff", "--name-only", &base, &commit]); + names.lines().map(str::to_string).collect() + } +} + +#[test] +fn the_refs_git_passes_a_pre_push_hook_are_read_and_nothing_else() { + let (local, remote) = ("1".repeat(40), "2".repeat(40)); + let input = format!( + "refs/heads/a {local} refs/heads/a {remote}\n\nrefs/heads/b {local} refs/heads/b {ZERO}\n(delete) {ZERO} refs/heads/c {remote}\n" + ); + let read = updates(&input).unwrap(); + assert_eq!( + read, + [ + Update { + name: "refs/heads/a".into(), + local: local.clone(), + remote: Some(remote.clone()), + }, + Update { + name: "refs/heads/b".into(), + local: local.clone(), + remote: None, + }, + Update { + name: "(delete)".into(), + local: ZERO.into(), + remote: Some(remote.clone()), + }, + ] + ); + assert!(updates("").unwrap().is_empty(), "nothing to push"); + for line in ["refs/heads/a 1111", &format!("a {local} b not-an-id")] { + assert!(updates(line).is_err(), "{line}"); + } +} + +#[test] +fn pre_commit_names_the_pushed_commit_and_what_the_remote_has() { + let variables = BTreeMap::from([ + ("PRE_COMMIT_TO_REF", "HEAD"), + ("PRE_COMMIT_FROM_REF", "origin/main"), + ("PRE_COMMIT_LOCAL_BRANCH", "refs/heads/feature"), + ]); + let var = |name: &str| variables.get(name).map(|v| v.to_string()); + assert_eq!( + from_pre_commit(var), + Some(Update { + name: "refs/heads/feature".into(), + local: "HEAD".into(), + remote: Some("origin/main".into()), + }) + ); + assert_eq!(from_pre_commit(|_| None), None, "not run by pre-commit"); +} + +#[test] +fn a_new_branch_is_compared_with_where_it_leaves_the_remotes_history() { + let clone = Clone::new(); + clone.git(&["checkout", "-qb", "feature"]); + clone.commit("f1"); + clone.commit("f2"); + assert_eq!(clone.judged("feature", None), ["f1.rs", "f2.rs"]); + // Pushed under another name, the same commits are compared alike. + let Sent::Commits { base, .. } = clone.sent("feature", None) else { + panic!("nothing sent"); + }; + assert_eq!(base, clone.id("main")); +} + +#[test] +fn a_branch_pushed_before_is_compared_with_its_last_push() { + let clone = Clone::new(); + clone.git(&["checkout", "-qb", "feature"]); + clone.commit("f1"); + clone.git(&["push", "-q", "origin", "feature"]); + clone.commit("f2"); + assert_eq!(clone.judged("feature", Some("origin/feature")), ["f2.rs"]); + assert_eq!( + clone.sent("feature~1", Some("origin/feature")), + Sent::Nothing, + "every commit is on the remote already" + ); +} + +#[test] +fn a_rebased_branch_is_compared_with_where_it_now_leaves_the_remotes_history() { + let clone = Clone::new(); + clone.diverged(); + clone.git(&["rebase", "-q", "main"]); + // The remote's feature holds the commit the rebase replaced. + assert_eq!( + clone.judged("feature", Some("origin/feature")), + ["f1.rs"], + "main's commit is not the push's" + ); +} + +#[test] +fn a_merge_of_what_a_remote_has_is_not_judged_as_the_pushs_own() { + let clone = Clone::new(); + clone.diverged(); + clone.git(&["merge", "-q", "--no-edit", "main"]); + clone.commit("f2"); + assert_eq!( + clone.judged("feature", Some("origin/feature")), + ["f2.rs"], + "compared with main merged into the last push, as Git merges them" + ); + // A local branch merged in is the push's own work. + clone.git(&["checkout", "-qb", "topic", "main"]); + clone.commit("t1"); + clone.git(&["checkout", "-q", "feature"]); + clone.git(&["merge", "-q", "--no-edit", "topic"]); + assert_eq!( + clone.judged("feature", Some("origin/feature")), + ["f2.rs", "t1.rs"] + ); +} + +#[test] +fn a_deletion_a_tag_and_a_history_no_remote_has() { + let clone = Clone::new(); + let deletion = Update { + name: "(delete)".into(), + local: ZERO.into(), + remote: Some(clone.id("main")), + }; + assert_eq!(sent(&clone.project.0, &deletion).unwrap(), Sent::Nothing); + clone.git(&["checkout", "-qb", "feature"]); + clone.commit("f1"); + clone.git(&["tag", "-a", "-m", "v1", "v1"]); + let tag = Update { + name: "refs/tags/v1".into(), + local: clone.id("v1"), + remote: None, + }; + assert_eq!( + sent(&clone.project.0, &tag).unwrap(), + Sent::Commits { + base: clone.id("main"), + commit: clone.id("feature"), + }, + "an annotated tag is peeled to its commit" + ); + let unknown = Update { + name: "refs/heads/feature".into(), + local: clone.id("feature"), + remote: Some("3".repeat(40)), + }; + assert!( + matches!( + sent(&clone.project.0, &unknown).unwrap(), + Sent::Commits { .. } + ), + "a remote object this clone never fetched hides nothing" + ); + let alone = Project::new(); + alone.write("a.rs", "fn a() {}\n"); + alone.commit_all(); + let first = Update { + name: "refs/heads/main".into(), + local: "HEAD".into(), + remote: None, + }; + assert_eq!(sent(&alone.0, &first).unwrap(), Sent::Unrooted); +} + +#[test] +fn a_pushed_merge_is_judged_for_how_it_resolved_its_conflict() { + let clone = Clone::new(); + clone.diverged(); + clone.project.write("a.rs", "fn a() { feature() }\n"); + clone.git(&["commit", "-qam", "feature a"]); + clone.git(&["push", "-q", "origin", "feature"]); + let merged = crate::tests::git::command(&clone.project.0) + .args(["merge", "-q", "main"]) + .output() + .unwrap(); + assert!(!merged.status.success(), "a conflict"); + clone.project.write("a.rs", "fn a() { both() }\n"); + clone.git(&["add", "a.rs"]); + clone.git(&["commit", "-qm", "merge main"]); + assert_eq!( + clone.judged("feature", Some("origin/feature")), + ["a.rs"], + "the resolution, and not main's m1.rs" + ); +} diff --git a/src/revision/tests/recorded.rs b/src/revision/tests/recorded.rs new file mode 100644 index 0000000..721e0ca --- /dev/null +++ b/src/revision/tests/recorded.rs @@ -0,0 +1,188 @@ +//! The files a change touched as Git holds them: the index for `--staged`, +//! a commit for `--pre-push`. +use super::*; + +/// `lib.rs` committed, then a change staged on its line 2 and another made +/// on disk above it, which shifts its lines; `new.rs` staged new, `gone.rs` +/// staged deleted, and `loose.rs` untracked. +fn staged_in_part() -> Project { + let project = Project::new(); + project.write("lib.rs", LIB); + project.write("gone.rs", "fn gone() {}\n"); + project.commit_all(); + project.write("lib.rs", &LIB.replace("one", "uno")); + project.write("new.rs", "fn fresh() {}\n"); + project.git(&["add", "lib.rs", "new.rs"]); + project.git(&["rm", "-q", "gone.rs"]); + project.write( + "lib.rs", + &format!( + "// unstaged\n{}", + LIB.replace("one", "uno").replace("two", "dos") + ), + ); + project.write("loose.rs", "fn loose() {}\n"); + project +} + +#[test] +fn the_index_is_read_as_staged_and_only_its_changes() { + let project = staged_in_part(); + let root = &project.0; + let base = staged_base(root).unwrap(); + assert_eq!(base, project.git(&["rev-parse", "HEAD"]).trim()); + let recorded = Recorded::load(root, &base, &Now::Index).unwrap(); + let staged = LIB.replace("one", "uno"); + assert_eq!( + recorded.read(&root.join("lib.rs"), 1024).unwrap().unwrap(), + staged, + "the index's text, not the working tree's" + ); + assert_eq!( + recorded.size(&root.join("lib.rs")), + Some(staged.len() as u64) + ); + assert_eq!( + recorded.paths().collect::>(), + ["lib.rs", "new.rs"].map(Path::new), + "neither the deleted nor the untracked file" + ); + assert!( + recorded.read(&root.join("loose.rs"), 1024).is_none(), + "a file the change did not touch is read from disk" + ); + let error = recorded + .read(&root.join("lib.rs"), 10) + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("exceeds"), "{error}"); + let changes = Changes::between(root, &base, Now::Index) + .unwrap() + .with_lines(root, [Path::new("lib.rs")]) + .unwrap(); + assert_eq!( + changes.paths.keys().collect::>(), + ["lib.rs", "new.rs"].map(Path::new) + ); + assert_eq!(changes.deleted, [PathBuf::from("gone.rs")]); + assert_eq!( + changes.lines[Path::new("lib.rs")], + lines(&[(2, 2)], &[]), + "the staged line, where the index holds it" + ); +} + +#[test] +fn before_the_first_commit_every_staged_file_is_new() { + let project = Project::new(); + project.git(&["init", "-q"]); + project.write("lib.rs", LIB); + project.git(&["add", "lib.rs"]); + let root = &project.0; + let base = staged_base(root).unwrap(); + assert!(is_object_id(&base)); + let changes = Changes::between(root, &base, Now::Index).unwrap(); + assert_eq!(changes.paths[Path::new("lib.rs")], None); + let recorded = Recorded::load(root, &base, &Now::Index).unwrap(); + assert_eq!( + recorded.read(&root.join("lib.rs"), 1024).unwrap().unwrap(), + LIB + ); + let outside = crate::tests::Project::new(); + let error = staged_base(&outside.0).unwrap_err(); + assert!(error.to_string().contains("Git work tree"), "{error}"); +} + +#[test] +fn a_commit_is_read_as_committed_and_only_text_in_regular_files() { + let project = Project::new(); + project.write("lib.rs", LIB); + project.commit_all(); + let base = project.git(&["rev-parse", "HEAD"]).trim().to_string(); + project.write("lib.rs", &LIB.replace("one", "uno")); + project.write("data.rs", "a\0b"); + #[cfg(unix)] + std::os::unix::fs::symlink("lib.rs", project.0.join("link.rs")).unwrap(); + project.git(&["add", "-A"]); + project.git(&["commit", "-qm", "change"]); + let commit = project.git(&["rev-parse", "HEAD"]).trim().to_string(); + // The work goes on after the commit. + project.write("lib.rs", "fn later() {}\n"); + let root = &project.0; + let recorded = Recorded::load(root, &base, &Now::Commit(commit)).unwrap(); + assert_eq!( + recorded.read(&root.join("lib.rs"), 1024).unwrap().unwrap(), + LIB.replace("one", "uno") + ); + let binary = recorded + .read(&root.join("data.rs"), 1024) + .unwrap() + .unwrap_err(); + assert!(binary.to_string().contains("NUL bytes"), "{binary}"); + #[cfg(unix)] + { + assert_eq!(recorded.regular(&root.join("link.rs")), Some(false)); + let link = recorded + .read(&root.join("link.rs"), 1024) + .unwrap() + .unwrap_err(); + assert!(link.to_string().contains("symlinks"), "{link}"); + } + assert!(Recorded::load(root, &base, &Now::WorkingTree).is_err()); +} + +#[test] +fn a_root_below_the_git_top_level_reads_its_own_paths() { + let (project, root, _) = below_the_top(&[]); + project.git(&["add", "-A"]); + let base = staged_base(&root).unwrap(); + let recorded = Recorded::load(&root, &base, &Now::Index).unwrap(); + assert_eq!(recorded.paths().collect::>(), [Path::new("lib.rs")]); + assert_eq!( + recorded.read(&root.join("lib.rs"), 1024).unwrap().unwrap(), + LIB.replace("one", "uno") + ); +} + +#[test] +fn a_commit_that_concludes_a_merge_is_judged_for_its_resolution_alone() { + let project = Project::new(); + project.write("lib.rs", LIB); + project.write("other.rs", "fn other() {}\n"); + project.commit_all(); + project.git(&["branch", "-M", "main"]); + project.git(&["checkout", "-qb", "side"]); + project.write("lib.rs", &LIB.replace("two", "side")); + project.write("side.rs", "fn side() {}\n"); + project.git(&["commit", "-qam", "side"]); + project.git(&["add", "side.rs"]); + project.git(&["commit", "-qm", "side file"]); + project.git(&["checkout", "-q", "main"]); + project.write("lib.rs", &LIB.replace("two", "main")); + project.git(&["commit", "-qam", "main"]); + // The merge stops on lib.rs's line 6; the person resolves it. + let merged = crate::tests::git::command(&project.0) + .args(["merge", "-q", "side"]) + .output() + .unwrap(); + assert!(!merged.status.success(), "a conflict"); + project.write("lib.rs", &LIB.replace("two", "both")); + project.git(&["add", "lib.rs"]); + let root = &project.0; + let base = staged_base(root).unwrap(); + assert_ne!(base, project.git(&["rev-parse", "HEAD"]).trim()); + let changes = Changes::between(root, &base, Now::Index) + .unwrap() + .with_lines(root, [Path::new("lib.rs")]) + .unwrap(); + assert_eq!( + changes.paths.keys().collect::>(), + [Path::new("lib.rs")], + "the side branch's file came with the merge, and is not the commit's" + ); + assert_eq!( + changes.lines[Path::new("lib.rs")].changed, + [(6, 6)], + "the resolved line, where the conflict's markers were" + ); +} diff --git a/src/revision/tests/snapshots.rs b/src/revision/tests/snapshots.rs index bfe7394..9d6ec3b 100644 --- a/src/revision/tests/snapshots.rs +++ b/src/revision/tests/snapshots.rs @@ -94,7 +94,7 @@ fn a_file_larger_than_a_snapshot_holds_is_recorded_by_a_stand_in() { assert!(!stored(&project, id.trim()), "Git never copied it"); project.write("dump.sql", &format!("{large}y")); let after = snapshot_of(&project, &project.0); - let changes = Changes::between(&project.0, &before, &after).unwrap(); + let changes = Changes::between(&project.0, &before, Now::Snapshot(after.clone())).unwrap(); assert_eq!( changes.paths.keys().collect::>(), [Path::new("dump.sql")], @@ -187,7 +187,7 @@ fn changes_between_two_snapshots_follow_the_working_tree() { project.write("new.rs", "fn new() {}\n"); project.write("trace.log", "trace\n"); let after = snapshot_of(&project, &project.0); - let changes = Changes::between(&project.0, &before, &after).unwrap(); + let changes = Changes::between(&project.0, &before, Now::Snapshot(after.clone())).unwrap(); let path = |name: &str| PathBuf::from(name); assert_eq!( changes.paths, @@ -201,7 +201,7 @@ fn changes_between_two_snapshots_follow_the_working_tree() { ); assert_eq!(changes.deleted, [path("gone.rs")]); assert_eq!(changes.revision, before); - assert!(Changes::between(&project.0, "HEAD", &after).is_err()); + assert!(Changes::between(&project.0, "HEAD", Now::Snapshot(after.clone())).is_err()); } #[test] @@ -218,7 +218,7 @@ fn lines_between_two_snapshots_are_the_ones_the_turn_changed() { project.write("scratch.rs", &LIB.replace("two", "dos")); let after = snapshot_of(&project, &project.0); let judged = ["src/lib.rs", "scratch.rs"].map(Path::new); - let changes = Changes::between(&project.0, &before, &after) + let changes = Changes::between(&project.0, &before, Now::Snapshot(after.clone())) .unwrap() .with_lines(&project.0, judged) .unwrap(); @@ -246,7 +246,7 @@ fn snapshots_of_a_root_below_the_git_top_level_are_compared_relative_to_it() { project.write("app/new.rs", "fn new() {}\n"); project.write("other.rs", "fn other() { 1 }\n"); let after = snapshot_of(&project, &root); - let changes = Changes::between(&root, &before, &after) + let changes = Changes::between(&root, &before, Now::Snapshot(after.clone())) .unwrap() .with_lines(&root, [Path::new("lib.rs")]) .unwrap(); diff --git a/src/schema/report.rs b/src/schema/report.rs index e47606f..ad7efb6 100644 --- a/src/schema/report.rs +++ b/src/schema/report.rs @@ -314,6 +314,14 @@ pub struct Report { pub quick: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_revision: Option, + /// With `--staged`: the change runs from `base_revision` to the index, + /// not to the working tree. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub staged: bool, + /// With `--pre-push`: the pushed commit the change runs to from + /// `base_revision`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pushed_revision: Option, #[serde(default)] pub deleted_files: Vec, #[serde(default)] @@ -392,6 +400,25 @@ pub struct Report { } impl Report { + /// Why an incomplete run could not judge everything: its first error, + /// else the first failed file's and how many failed alike. + pub fn incomplete_reason(&self) -> String { + if let Some(error) = self.errors.first() { + return error.clone(); + } + let failed: Vec<&str> = self + .files + .iter() + .filter(|f| f.status == Status::Error) + .filter_map(|f| f.error.as_deref()) + .collect(); + match failed.first() { + Some(first) if failed.len() > 1 => format!("{first} ({} files)", failed.len()), + Some(first) => (*first).to_string(), + None => "the check did not finish".into(), + } + } + pub fn update_status(&mut self) { let selected: Vec<&FileResult> = self .files diff --git a/src/setup/mod.rs b/src/setup/mod.rs index d3b1a89..8bd1ab5 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -28,7 +28,7 @@ use std::{ /// A file larger than this is not an agent's settings or instructions. const MAX_BYTES: u64 = 16 * 1024 * 1024; -/// `jevgate init`'s arguments for coding agents. +/// `jevgate init`'s arguments for coding agents and Git hooks. #[derive(clap::Args, Debug, Default)] pub struct AgentSetup { /// Set up a coding agent instead of writing jevgate.toml (repeatable, or comma-separated) @@ -40,20 +40,32 @@ pub struct AgentSetup { long = "agent", value_enum, value_name = "AGENT", - value_delimiter = ',' + value_delimiter = ',', + group = "target" )] pub agents: Vec, + /// Write a Git hook that runs JevGate before each push or commit, instead of jevgate.toml + /// + /// `pre-push` runs `jevgate check --pre-push`, which judges what each push + /// sends; `pre-commit` runs `jevgate check --staged`, which judges what + /// each commit records. A push is the cheaper moment: it runs once for + /// the commits it sends. The hook goes where Git reads hooks + /// (`.git/hooks`), never over one JevGate did not write; when a hook + /// manager keeps the repository's hooks (husky, lefthook, pre-commit), + /// it says what to add there instead. + #[arg(long, value_enum, value_name = "HOOK", group = "target")] + pub git_hook: Option, /// With --agent: write the repository's agent files, for everyone who works in it /// /// They go at the top of the Git work tree: `.claude/`, `.codex/`, /// `.gemini/`, `.cursor/`, `.opencode/`, AGENTS.md and GEMINI.md. #[arg(long, requires = "agents")] pub project: bool, - /// With --agent: take out the hooks and text JevGate wrote, and nothing else - #[arg(long, requires = "agents")] + /// With --agent or --git-hook: take out the hooks and text JevGate wrote, and nothing else + #[arg(long, requires = "target")] pub remove: bool, - /// With --agent: print what would change, and write nothing - #[arg(long, requires = "agents")] + /// With --agent or --git-hook: print what would change, and write nothing + #[arg(long, requires = "target")] pub dry_run: bool, } diff --git a/src/suppress.rs b/src/suppress.rs index 4143e39..4bea035 100644 --- a/src/suppress.rs +++ b/src/suppress.rs @@ -3,13 +3,16 @@ //! accepts that finding as the baseline does. RULE is a rule ID, name, key or group, //! and the reason is required: without one the comment is ignored and the //! finding says so. -use crate::{catalog, schema::Report}; +use crate::{catalog, revision::Recorded, schema::Report}; use std::{ collections::BTreeSet, path::{Path, PathBuf}, }; const MARKER: &str = "jevgate:"; +/// The most of a file Git holds that is read for its comments: as much as +/// a check reads of any file. +const READ_BYTES: u64 = 1_048_576; /// What one `jevgate: allow(…)` comment names and why. #[derive(Debug, PartialEq)] @@ -19,11 +22,20 @@ struct Allow { } /// Mark the findings an allow comment names, but for comments on the -/// `ignored` lines (a file and a 1-based line), which accept nothing. Files -/// that cannot be read keep their findings. -pub fn apply(root: &Path, report: &mut Report, ignored: &BTreeSet<(PathBuf, usize)>) { +/// `ignored` lines (a file and a 1-based line), which accept nothing. Each +/// file is read as the check judged it: from disk, or as Git holds a file +/// a `--staged` or `--pre-push` change touched (`recorded`), whose lines +/// the findings name. Files that cannot be read keep their findings. +pub fn apply( + root: &Path, + report: &mut Report, + ignored: &BTreeSet<(PathBuf, usize)>, + recorded: Option<&Recorded>, +) { for file in report.files.iter_mut().filter(|f| !f.findings.is_empty()) { - let Ok(text) = std::fs::read_to_string(root.join(&file.path)) else { + let path = root.join(&file.path); + let read = recorded.and_then(|r| r.read(&path, READ_BYTES)); + let Ok(text) = read.unwrap_or_else(|| Ok(std::fs::read_to_string(&path)?)) else { continue; }; let lines: Vec<&str> = text.lines().collect(); diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 42f712b..1d6ec13 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -212,6 +212,7 @@ pub(super) fn session<'a>( budget: token_budget::TokenBudget::default(), observed: (0, 0), answered: Default::default(), + spend: options.max_cost.map(crate::requests::Spend::new), } } diff --git a/src/transport/mod.rs b/src/transport/mod.rs index e4733db..1ba6cee 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -187,11 +187,14 @@ impl Client { }) } - /// Give up on a retry, or a pause another request's failure asked for, - /// that would end past `deadline`: the agent hook answers by then, and - /// a provider asking for 30 s would otherwise hold every edit for its - /// whole budget (29.8 s after an edit measured against a 503 asking - /// for `retry-after-ms: 30000`). The failure is then the answer. + /// Send nothing past `deadline`: no request starts, and no retry or + /// pause another request's failure asked for runs, past it, and an + /// attempt under way gets only the time left. The agent hook answers by + /// then, and a provider asking for 30 s would otherwise hold every edit + /// for its whole budget (29.8 s after an edit measured against a 503 + /// asking for `retry-after-ms: 30000`); `check --max-seconds` stops + /// there, as a commit or a push waits on it. The failure is then the + /// answer. pub fn until(mut self, deadline: Instant) -> Self { self.access.deadline = Some(deadline); self @@ -226,7 +229,8 @@ impl Evaluator for Client { let agent = self.agent.clone(); self.credential()?; let key = self.key.as_ref().unwrap().key.expose(); - let result = send(&agent, &self.endpoint, key, request); + let timeout = self.access.attempt_timeout()?; + let result = send(&agent, &self.endpoint, key, request, timeout); self.access.observe(&result); result } @@ -258,12 +262,12 @@ impl Evaluator for Client { } } let key = self.key.as_ref().unwrap().key.expose(); - let endpoint = &self.endpoint; + let (endpoint, access) = (&self.endpoint, &self.access); self.access.evaluate_queue( requests, concurrency, before, - |request| send(&agent, endpoint, key, request), + |request| send(&agent, endpoint, key, request, access.attempt_timeout()?), completed, ); } @@ -408,6 +412,19 @@ impl ProviderAccess { .is_some_and(|deadline| Instant::now() + pause >= deadline) } + /// The time left for the next attempt when the deadline comes before + /// [`ATTEMPT_TIMEOUT`] would; none when the client's own timeout ends + /// first. An error once the deadline has passed, and nothing is sent. + fn attempt_timeout(&self) -> Result> { + let Some(deadline) = self.deadline else { + return Ok(None); + }; + match deadline.checked_duration_since(Instant::now()) { + Some(left) if !left.is_zero() => Ok((left < ATTEMPT_TIMEOUT).then_some(left)), + _ => Err(out_of_time(self.service)), + } + } + /// Take the next start time, `interval` after the one before, and sleep until it. fn pace(&self) { let start = { @@ -506,6 +523,9 @@ impl ProviderAccess { { return Outcome::skipped(error); } + if self.past_deadline(Duration::ZERO) { + return Outcome::skipped(out_of_time(self.service)); + } let started_ms = queue_start.elapsed().as_millis() as u64; let start = std::time::Instant::now(); let (result, retries) = self.send_with_retries(*index, request, before, &send); @@ -518,6 +538,14 @@ impl ProviderAccess { } } +/// The error of a request not sent because the check's time ran out. +fn out_of_time(service: &Service) -> anyhow::Error { + anyhow::anyhow!( + "{} request not sent: the check ran out of time", + service.label + ) +} + /// The pause and the attempt limit for a failure worth retrying: rate limits, /// overload, server and gateway errors, and connections that failed before /// the request was sent. A timeout or dropped connection is retried once, @@ -577,14 +605,24 @@ fn request_body(request: &Value) -> Result> { )?) } -/// Send one request and return the provider's answer, with the provider's -/// request id under `request_id`: TypeSafe's `x-typesafe-request-id` header, -/// else the response's own `id`, which OpenRouter sends. -fn send(agent: &ureq::Agent, endpoint: &Endpoint, key: &str, request: &Value) -> Result { +/// Send one request, giving up at the agent's timeout or after `timeout` +/// when given, and return the provider's answer, with the provider's +/// request id under `request_id`: TypeSafe's `x-typesafe-request-id` +/// header, else the response's own `id`, which OpenRouter sends. +fn send( + agent: &ureq::Agent, + endpoint: &Endpoint, + key: &str, + request: &Value, + timeout: Option, +) -> Result { let service = endpoint.service; let body = request_body(request)?; - let response = agent - .post(endpoint.systemone()) + let mut post = agent.post(endpoint.systemone()); + if let Some(timeout) = timeout { + post = post.config().timeout_global(Some(timeout)).build(); + } + let response = post .header("Authorization", format!("Bearer {key}")) .header( "User-Agent", diff --git a/src/transport/tests.rs b/src/transport/tests.rs index c3c5044..5265f14 100644 --- a/src/transport/tests.rs +++ b/src/transport/tests.rs @@ -498,7 +498,14 @@ fn answered(received: &Received) -> Reply { fn an_exchange_sends_the_bearer_key_and_keeps_only_a_checked_request_id() { let (provider, endpoint) = mock(|received| answered(received).header(REQUEST_ID, "req_01J9-abc")); - let answer = send(&agent(ATTEMPT_TIMEOUT), &endpoint, "test-key", &question()).unwrap(); + let answer = send( + &agent(ATTEMPT_TIMEOUT), + &endpoint, + "test-key", + &question(), + None, + ) + .unwrap(); assert_eq!(answer["request_id"], "req_01J9-abc"); assert!(crate::response::validate(&answer, &question()).is_ok()); let received = &provider.received()[0]; @@ -513,7 +520,7 @@ fn an_exchange_sends_the_bearer_key_and_keeps_only_a_checked_request_id() { body["id"] = json!("gen-dec-1789738314-X5e5"); Reply::json(200, &body) }); - let answer = send(&agent(ATTEMPT_TIMEOUT), &openrouter, "k", &question()).unwrap(); + let answer = send(&agent(ATTEMPT_TIMEOUT), &openrouter, "k", &question(), None).unwrap(); assert_eq!( answer["request_id"], "gen-dec-1789738314-X5e5", "a response's own id stands in" @@ -523,7 +530,7 @@ fn an_exchange_sends_the_bearer_key_and_keeps_only_a_checked_request_id() { body["request_id"] = json!("not an id \u{1b}[31m