Skip to main content

ljos_cli/
gates.rs

1//! Commit, push, and merge gates for the hook and `ljos_guard_check`.
2//!
3//! The checks are mechanical. A line that is not a commit, a push, or a
4//! merge is left to the seat guard and the pack's rules. A denial names
5//! the command that makes the same line pass. A push is never refused for
6//! a missing test record: it runs, and the hook passes the warning on.
7
8use std::path::{Path, PathBuf};
9use std::process::{Command, Output, Stdio};
10use std::time::Duration;
11
12use anyhow::{bail, Context, Result};
13use serde::Deserialize;
14use serde_json::Value;
15
16thread_local! {
17    static GH_BIN: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
18}
19
20thread_local! {
21    /// What the last judged push had to say without refusing it: its tip
22    /// has no passing test record, or the gate settings did not parse.
23    static PUSH_WARNING: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
24}
25
26/// The warning the last judged push left, once. The push ran either way.
27#[must_use]
28pub fn take_push_warning() -> Option<String> {
29    PUSH_WARNING.with(|slot| slot.borrow_mut().take())
30}
31
32fn warn_push(text: String) {
33    PUSH_WARNING.with(|slot| *slot.borrow_mut() = Some(text));
34}
35
36#[cfg(test)]
37thread_local! {
38    /// User and seat config for this thread, instead of [`super::config_base`].
39    static LAYER_HOME: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
40    /// `LJOS_SEAT` for this thread, so a test can name a seat file without
41    /// changing the process environment.
42    static LAYER_SEAT: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
43}
44
45/// The commit, push, and merge gates alone. `None` means the line is not
46/// one of those, or it already passes. A denial names the command that
47/// makes the same line pass. The static guard calls this; it does not
48/// call back into the static guard or policyd.
49#[must_use]
50pub fn command_gate(line: &str, cwd: Option<&Path>) -> Option<super::Rule> {
51    PUSH_WARNING.with(|slot| *slot.borrow_mut() = None);
52    let found = std::env::current_dir().ok();
53    let base = cwd.or(found.as_deref());
54    enforce(line, base, 0).err()
55}
56
57/// One git call a shell line runs, its aliases expanded: the checkout it
58/// runs in, the subcommand, its arguments, and each `-c KEY=VALUE`.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct GitCall {
61    pub cwd: PathBuf,
62    pub sub: String,
63    pub args: Vec<String>,
64    pub config: Vec<(String, String)>,
65}
66
67/// Every git call `line` runs from `cwd`, the process directory when
68/// absent. `cd DIR`, `git -C DIR`, `sh -c`, `$(...)` and a `!` alias's
69/// shell are followed the way [`command_gate`] follows them.
70///
71/// # Errors
72///
73/// The aliases nest too deep to read, so what the line runs is not known.
74pub fn git_calls(line: &str, cwd: Option<&Path>) -> Result<Vec<GitCall>, String> {
75    let mut out = Vec::new();
76    if !line.to_ascii_lowercase().contains("git") {
77        return Ok(out);
78    }
79    let found = std::env::current_dir().ok();
80    let base = cwd.or(found.as_deref()).unwrap_or_else(|| Path::new("."));
81    let mut queue = invocations(line, base, 0);
82    let mut i = 0;
83    while i < queue.len() {
84        let inv = queue[i].clone();
85        i += 1;
86        if inv.depth > 3 {
87            return Err("its git aliases nest too deep to read".into());
88        }
89        let Some(git) = git_of(&inv) else {
90            continue;
91        };
92        match git.shell {
93            Some(shell) => {
94                let extras = invocations(&shell, &git.cwd, inv.depth.saturating_add(1));
95                queue.splice(i..i, extras);
96            }
97            None => out.push(GitCall {
98                cwd: git.cwd,
99                sub: git.sub,
100                args: git.args,
101                config: git.config,
102            }),
103        }
104    }
105    Ok(out)
106}
107
108/// The static guard (seat, user rules, these gates, a dangerous push) and
109/// then the pack's rules, the same composition the hook uses. `None` lets
110/// the line run. A plain push runs. When the checkout sets `test` and the
111/// tip has no passing record, [`take_push_warning`] says so. A force,
112/// mirror or delete push asks, and so does a push onto a protected branch
113/// of a repository whose owner is not in `owners`. A merge whose head sha
114/// has no recorded checks asks too: the reason names that sha, and a local
115/// evidence record when the checkout has one for it. A fine-grained token
116/// with no Checks permission reads the REST fallback when the rollup is
117/// denied, and the reason names which source it used.
118#[must_use]
119pub fn guard_verdict(line: &str, cwd: Option<&Path>, rules: &[super::Rule]) -> Option<super::Rule> {
120    let fast = super::static_guard_in(line, cwd);
121    let here = cwd.and_then(Path::to_str).map(str::to_string).or_else(|| {
122        std::env::current_dir()
123            .ok()
124            .and_then(|dir| dir.into_os_string().into_string().ok())
125    });
126    let gated = super::redirect_seat_verb(
127        super::gate_push(
128            super::ruled_verdict(rules, line).as_ref(),
129            line,
130            here.as_deref(),
131        ),
132        line,
133    );
134    if gated.as_ref().is_some_and(|rule| rule.verdict == "deny") {
135        return gated;
136    }
137    fast.or(gated)
138}
139
140/// Run the repo's configured test command and store a passing record for
141/// the current tip. The push gate accepts only that record.
142///
143/// # Errors
144///
145/// The working directory is not a repository, `.ljos/gates.toml` has no
146/// matching `test` command, the command fails, or HEAD moves while it runs.
147pub fn record_evidence(cmd: &[String]) -> Result<String> {
148    let cwd = std::env::current_dir().context(
149        "evidence: no working directory. Run `ljos evidence record -- <test command>` from the repository checkout",
150    )?;
151    record_in(&cwd, cmd)
152}
153
154fn record_in(cwd: &Path, cmd: &[String]) -> Result<String> {
155    if cmd.is_empty() || cmd.iter().all(|c| c == "--") {
156        bail!(
157            "evidence: pass the test command after `--`. Example: `ljos evidence record -- cargo test --workspace`"
158        );
159    }
160    let root = repo_root(cwd).context(
161        "evidence: run this inside a git repository. `ljos evidence record -- <test command>` stores the tip sha, and the command is the `test` line in `.ljos/gates.toml`",
162    )?;
163    let cfg = load_config(&root).map_err(|e| anyhow::anyhow!("evidence: {e}"))?;
164    let want = cfg.test.as_deref().context(
165        "evidence: `.ljos/gates.toml` has no `test` command. Add `test = \"cargo test --workspace\"` (or this repo's suite, such as `meson test --suite eon`) at the repository root, then run `ljos evidence record -- <that command>`",
166    )?;
167    let got = norm_cmd(&cmd.join(" "));
168    if got != want {
169        bail!(
170            "evidence: `{got}` is not the test command in `.ljos/gates.toml`. Run `ljos evidence record -- {want}`"
171        );
172    }
173    let before = head_sha(&root).with_context(|| {
174        format!(
175            "evidence: this repository has no commit yet. Commit first, then run `ljos evidence record -- {want}`"
176        )
177    })?;
178    let status = Command::new(&cmd[0])
179        .args(&cmd[1..])
180        .current_dir(&root)
181        .status()
182        .with_context(|| {
183            format!(
184                "evidence: `{want}` did not start. Run `ljos evidence record -- {want}` once that command is on PATH"
185            )
186        })?;
187    let after = head_sha(&root).unwrap_or_default();
188    if after != before {
189        bail!(
190            "evidence: HEAD moved from {before} to {after} while `{want}` ran, so the run is not evidence for one commit. Commit first, then run `ljos evidence record -- {want}`"
191        );
192    }
193    let code = status.code().unwrap_or(1);
194    if !status.success() {
195        bail!(
196            "evidence: `{want}` exited {code}, so nothing was stored for {before}. Fix the failure, then run `ljos evidence record -- {want}`"
197        );
198    }
199    let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
200    write_evidence(&root, &before, want, &ts)?;
201    Ok(format!("evidence: {before} passed `{want}` at {ts}"))
202}
203
204#[derive(Debug, Default)]
205struct Walk {
206    branch: Option<String>,
207    branch_set: bool,
208    fresh: bool,
209}
210
211#[derive(Debug, Clone)]
212struct Inv {
213    cwd: PathBuf,
214    env: Vec<(String, String)>,
215    words: Vec<String>,
216    depth: u8,
217}
218
219#[derive(Debug, Clone)]
220struct GitCmd {
221    cwd: PathBuf,
222    config: Vec<(String, String)>,
223    sub: String,
224    args: Vec<String>,
225    shell: Option<String>,
226}
227
228#[derive(Debug, Clone)]
229struct Person {
230    name: String,
231    email: String,
232}
233
234#[derive(Debug, Clone)]
235struct GateConfig {
236    test: Option<String>,
237    signoff: bool,
238    protected: Vec<String>,
239    /// The accounts and organisations whose repositories are the person's
240    /// own. Empty: every repository is.
241    owners: Vec<String>,
242    identity: Option<Person>,
243    /// The file that set [`GateConfig::identity`].
244    identity_from: Option<PathBuf>,
245}
246
247#[derive(Debug, Deserialize)]
248struct Stored {
249    command: String,
250    exit: i32,
251    sha: String,
252    ts: String,
253}
254
255struct CommitFields {
256    messages: Vec<String>,
257    files: Vec<String>,
258    reuse: Vec<String>,
259    trailers: Vec<String>,
260    author: Option<String>,
261    signoff: bool,
262    no_edit: bool,
263    amend: bool,
264    dry: bool,
265    help: bool,
266    hidden: bool,
267}
268
269struct ApiMerge {
270    repo: Option<String>,
271    spec: String,
272}
273
274fn deny(pattern: &str, token: &str, detail: &str) -> super::Rule {
275    super::Rule {
276        pattern: pattern.into(),
277        verdict: "deny".into(),
278        reason: join_sentences(&super::policyd_reason(token), detail),
279    }
280}
281
282/// `base` and `detail` as two sentences. When `base` already ends with
283/// `.`, `!` or `?`, that stop stays. Otherwise a full stop is added
284/// before `detail`.
285fn join_sentences(base: &str, detail: &str) -> String {
286    let detail = detail.trim();
287    if detail.is_empty() {
288        return base.to_string();
289    }
290    let base = base.trim_end();
291    if base.ends_with(['.', '!', '?']) {
292        format!("{base} {detail}")
293    } else {
294        format!("{base}. {detail}")
295    }
296}
297
298fn default_protected() -> Vec<String> {
299    vec!["main".into(), "master".into()]
300}
301
302fn norm_cmd(s: &str) -> String {
303    s.split_whitespace().collect::<Vec<_>>().join(" ")
304}
305
306fn branch_of(name: &str) -> String {
307    name.trim().trim_start_matches("refs/heads/").to_string()
308}
309
310fn may_gate(line: &str) -> bool {
311    let lower = line.to_ascii_lowercase();
312    lower.contains("git")
313        || lower.contains("gh")
314        || lower.contains("curl")
315        || lower.contains("wget")
316        || lower.contains("merge_pull_request")
317        || lower.contains("mergepullrequest")
318}
319
320fn enforce(line: &str, cwd: Option<&Path>, depth: usize) -> Result<(), super::Rule> {
321    if depth > 3 {
322        return Err(deny(
323            "push-gate",
324            "git-push-evidence",
325            "This command expands too many aliases. Run git commit, git push, or the merge directly.",
326        ));
327    }
328    if !may_gate(line) {
329        return Ok(());
330    }
331    if line.contains("ssh") {
332        if let Some(rule) = remote_hidden(line) {
333            return Err(rule);
334        }
335    }
336    let base = cwd.unwrap_or_else(|| Path::new("."));
337    let mut queue = invocations(line, base, 0);
338    let mut walk = Walk::default();
339    let mut i = 0;
340    while i < queue.len() {
341        let inv = queue[i].clone();
342        i += 1;
343        if inv.depth > 3 {
344            return Err(deny(
345                "push-gate",
346                "git-push-evidence",
347                "This command expands too many aliases. Run git commit, git push, or the merge directly.",
348            ));
349        }
350        if let Some(git) = git_of(&inv) {
351            if let Some(shell) = git.shell.clone() {
352                if shell.trim().is_empty() {
353                    continue;
354                }
355                let extras = invocations(&shell, &git.cwd, inv.depth.saturating_add(1));
356                queue.splice(i..i, extras);
357                continue;
358            }
359            handle_git(&git, &inv, &mut walk)?;
360            continue;
361        }
362        if let Some(api) = api_merge(&inv.words) {
363            let action = if api.spec.is_empty() {
364                "merge_pull_request".to_string()
365            } else {
366                format!("merge_pull_request {}", api.spec)
367            };
368            review_pr(&inv.cwd, &api.spec, api.repo.as_deref(), None, &action)?;
369        }
370    }
371    Ok(())
372}
373
374fn handle_git(git: &GitCmd, inv: &Inv, walk: &mut Walk) -> Result<(), super::Rule> {
375    match git.sub.as_str() {
376        "commit" => on_commit(git, inv, walk),
377        "checkout" | "switch" => {
378            if git.args.iter().any(|a| a == "-") {
379                walk.branch = None;
380                walk.branch_set = true;
381            } else if let Some(branch) = switched_branch(&git.args) {
382                walk.branch = Some(branch);
383                walk.branch_set = true;
384            }
385            Ok(())
386        }
387        "push" => on_push(git, walk),
388        _ => Ok(()),
389    }
390}
391
392fn on_commit(git: &GitCmd, inv: &Inv, walk: &mut Walk) -> Result<(), super::Rule> {
393    let fields = commit_fields(&git.args);
394    if fields.help || fields.dry {
395        return Ok(());
396    }
397    let cfg = repo_config(&git.cwd, "git-commit-identity")?;
398    if let Some(person) = &cfg.identity {
399        identity_ok(
400            git,
401            &inv.env,
402            fields.author.as_deref(),
403            person,
404            cfg.identity_from.as_deref(),
405        )?;
406    }
407    if fields.hidden {
408        return Err(hidden_deny());
409    }
410    // `--amend --no-edit` keeps the message the last commit has, so there
411    // is no new text to read for a trailer, and no repository to read it
412    // from does not make it a hidden message.
413    if fields.amend
414        && fields.no_edit
415        && fields.messages.is_empty()
416        && fields.files.is_empty()
417        && fields.reuse.is_empty()
418        && fields.trailers.is_empty()
419    {
420        walk.fresh = true;
421        return Ok(());
422    }
423    let message = if !fields.messages.is_empty() {
424        fields.messages.join("\n\n")
425    } else if !fields.files.is_empty() {
426        read_files(&git.cwd, &fields.files).ok_or_else(hidden_deny)?
427    } else if !fields.reuse.is_empty() {
428        reuse_messages(&git.cwd, &fields.reuse).ok_or_else(hidden_deny)?
429    } else if fields.no_edit {
430        git_text(&git.cwd, &["log", "-1", "--format=%B"]).ok_or_else(hidden_deny)?
431    } else {
432        return Err(hidden_deny());
433    };
434    let mut message = message;
435    for trailer in &fields.trailers {
436        if has_subst(trailer) {
437            return Err(hidden_deny());
438        }
439        if !message.is_empty() {
440            message.push_str("\n\n");
441        }
442        message.push_str(trailer);
443    }
444    if let Some(what) = ai_marker(&message) {
445        return Err(deny(
446            "commit-gate",
447            "git-commit-trailer",
448            &format!(
449                "Remove {what} from the message, then commit again with `git commit -m TEXT`."
450            ),
451        ));
452    }
453    if cfg.signoff && !fields.signoff {
454        let Some(person) = &cfg.identity else {
455            return Err(deny(
456                "commit-gate",
457                "git-commit-signoff",
458                "Set `name` and `email` in the same file as `signoff`, then commit again.",
459            ));
460        };
461        let line = signoff_line(person);
462        if !has_signoff(&message, &line) {
463            return Err(deny(
464                "commit-gate",
465                "git-commit-signoff",
466                &format!("The message has no `{line}` line."),
467            ));
468        }
469    }
470    walk.fresh = true;
471    Ok(())
472}
473
474fn on_push(git: &GitCmd, walk: &Walk) -> Result<(), super::Rule> {
475    if is_dry(&git.args) || is_delete(&git.args) {
476        return Ok(());
477    }
478    // No checkout has no tip and no gate file. A plain push is allowed. A
479    // force push or a delete still asks in the push gate.
480    let Some(root) = repo_root(&git.cwd) else {
481        return Ok(());
482    };
483    let cfg = match load_config(&root) {
484        Ok(cfg) => cfg,
485        Err(e) => {
486            warn_push(format!(
487                "ljos ran this push without reading the gate settings: {e}"
488            ));
489            return Ok(());
490        }
491    };
492    let current = if walk.branch_set {
493        walk.branch.clone()
494    } else {
495        abbrev(&git.cwd)
496    };
497    let dests = push_dests(&git.cwd, &git.args, current.as_deref());
498    if let Some(rule) = foreign_protected(&git.cwd, &git.args, current.as_deref(), &dests, &cfg) {
499        return Err(rule);
500    }
501    // Evidence is a warning. A missing or stale record never holds a push,
502    // and neither does a commit made on the same line.
503    if let Some(test) = cfg.test.as_deref() {
504        if let Some(why) = evidence_gap(&git.cwd, &git.args, current.as_deref(), walk.fresh, test) {
505            warn_push(format!(
506                "This push runs without a passing test record. {why} Run `ljos evidence record -- {test}` to record one."
507            ));
508        }
509    }
510    Ok(())
511}
512
513/// A push onto a protected branch of a repository the person does not
514/// own asks. `owners` in `ljos.toml` or `.ljos/gates.toml` names the
515/// accounts and organisations that are the person's own. With no `owners`
516/// set, or a remote whose owner cannot be read, the push runs.
517fn foreign_protected(
518    cwd: &Path,
519    args: &[String],
520    current: Option<&str>,
521    dests: &[String],
522    cfg: &GateConfig,
523) -> Option<super::Rule> {
524    if cfg.owners.is_empty() {
525        return None;
526    }
527    let branch = dests.iter().find(|d| is_protected(d, &cfg.protected))?;
528    let url = push_url(cwd, args, current)?;
529    let (owner, repo) = super::remote_slug(&url)?;
530    if cfg.owners.iter().any(|o| o.eq_ignore_ascii_case(&owner)) {
531        return None;
532    }
533    Some(super::Rule {
534        pattern: "push-gate".into(),
535        verdict: "ask".into(),
536        reason: format!(
537            "`{branch}` is a protected branch of {owner}/{repo}, and {owner} is not in `owners`; the agent asks you first"
538        ),
539    })
540}
541
542/// The URL a push goes to: a URL named in place of a remote, else the
543/// named remote's push URL, else the current branch's remote, else
544/// `origin`.
545fn push_url(cwd: &Path, args: &[String], current: Option<&str>) -> Option<String> {
546    let pos = positionals(args);
547    let names = remotes(cwd);
548    let remote = match pos.first() {
549        Some(first) if names.iter().any(|r| r == first) => first.clone(),
550        Some(first) if first.contains("://") || (first.contains('@') && first.contains(':')) => {
551            return Some(first.clone());
552        }
553        _ => current
554            .and_then(|c| git_config(cwd, &format!("branch.{c}.remote")))
555            .unwrap_or_else(|| "origin".to_string()),
556    };
557    git_text(cwd, &["remote", "get-url", "--push", &remote]).filter(|u| !u.is_empty())
558}
559
560/// Why the commits a push publishes have no passing record for `test`,
561/// or `None` when each one has.
562fn evidence_gap(
563    cwd: &Path,
564    args: &[String],
565    current: Option<&str>,
566    fresh: bool,
567    test: &str,
568) -> Option<String> {
569    let specs = refspecs(cwd, args);
570    let pushes_head = specs.is_empty()
571        || specs
572            .iter()
573            .filter_map(|spec| src_of(spec))
574            .any(|src| pushing_current(&src, current));
575    if fresh && pushes_head {
576        return Some("The same command makes a new commit, so its tip has no record yet.".into());
577    }
578    let Some(shas) = shas_quietly(cwd, args, current) else {
579        return Some("The commit this push publishes cannot be read.".into());
580    };
581    shas.iter()
582        .find_map(|sha| evidence_problem(cwd, sha, test).map(|why| format!("Tip {sha}: {why}")))
583}
584
585/// The commits a push would publish, or `None` when one of them cannot be
586/// read. An empty refspec is the current HEAD.
587fn shas_quietly(cwd: &Path, args: &[String], current: Option<&str>) -> Option<Vec<String>> {
588    let specs = refspecs(cwd, args);
589    if specs.is_empty() {
590        return head_sha(cwd).map(|sha| vec![sha]);
591    }
592    let mut out = Vec::new();
593    for spec in specs {
594        let Some(src) = src_of(&spec) else {
595            continue;
596        };
597        let sha = if pushing_current(&src, current) {
598            head_sha(cwd)?
599        } else {
600            rev(cwd, &src)?
601        };
602        out.push(sha);
603    }
604    Some(out)
605}
606
607/// What is wrong with the stored record for `sha`, or `None` when it is a
608/// passing run of `test`.
609fn evidence_problem(cwd: &Path, sha: &str, test: &str) -> Option<&'static str> {
610    let Some(path) = evidence_file(cwd, sha) else {
611        return Some("the tip sha cannot be stored as evidence.");
612    };
613    if !path.is_file() {
614        return Some("no evidence file for this tip.");
615    }
616    let Ok(text) = std::fs::read_to_string(&path) else {
617        return Some("the evidence file cannot be read.");
618    };
619    let Ok(rec) = serde_json::from_str::<Stored>(&text) else {
620        return Some("the evidence file is unreadable.");
621    };
622    if rec.sha != sha {
623        Some("the evidence file is for a different commit.")
624    } else if rec.exit != 0 {
625        Some("the stored run did not exit 0.")
626    } else if norm_cmd(&rec.command) != test {
627        Some("the stored command is not the `test` line in `.ljos/gates.toml`.")
628    } else if rec.ts.trim().is_empty() {
629        Some("the evidence file has no timestamp.")
630    } else {
631        None
632    }
633}
634
635fn write_evidence(cwd: &Path, sha: &str, command: &str, ts: &str) -> Result<()> {
636    let path = evidence_file(cwd, sha).context(
637        "evidence: cannot find the git directory. Run `ljos evidence record -- <test command>` inside the repository",
638    )?;
639    if let Some(parent) = path.parent() {
640        std::fs::create_dir_all(parent)?;
641    }
642    let body = serde_json::json!({
643        "command": command,
644        "exit": 0,
645        "sha": sha,
646        "ts": ts,
647    });
648    let tmp = path.with_extension("json.tmp");
649    std::fs::write(&tmp, serde_json::to_vec_pretty(&body)?)?;
650    std::fs::rename(&tmp, &path)?;
651    Ok(())
652}
653
654/// A passing `ljos evidence record` for this exact sha: the file under
655/// `.git/ljos-evidence/<sha>.json`, with that sha and exit 0.
656fn evidence_recorded(cwd: &Path, sha: &str) -> bool {
657    let Some(path) = evidence_file(cwd, sha) else {
658        return false;
659    };
660    let Ok(text) = std::fs::read_to_string(path) else {
661        return false;
662    };
663    serde_json::from_str::<Stored>(&text)
664        .is_ok_and(|rec| rec.sha == sha && rec.exit == 0 && !rec.ts.trim().is_empty())
665}
666
667fn evidence_file(cwd: &Path, sha: &str) -> Option<PathBuf> {
668    if sha.len() < 7 || sha.len() > 64 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
669        return None;
670    }
671    let git_dir = git_text(cwd, &["rev-parse", "--git-dir"])?;
672    let git_dir = PathBuf::from(git_dir.trim());
673    let git_dir = if git_dir.is_absolute() {
674        git_dir
675    } else {
676        cwd.join(git_dir)
677    };
678    Some(git_dir.join("ljos-evidence").join(format!("{sha}.json")))
679}
680
681fn repo_config(cwd: &Path, token: &str) -> Result<GateConfig, super::Rule> {
682    let pattern = match token {
683        "git-merge-unready" => "merge-gate",
684        "git-commit-identity"
685        | "git-commit-message"
686        | "git-commit-trailer"
687        | "git-commit-signoff" => "commit-gate",
688        _ => "push-gate",
689    };
690    let Some(root) = repo_root(cwd) else {
691        return Err(deny(
692            pattern,
693            token,
694            "Run this inside the repository checkout so the gate can read `.ljos/gates.toml` and the tip.",
695        ));
696    };
697    load_config(&root).map_err(|e| deny(pattern, token, &e))
698}
699
700fn load_config(root: &Path) -> Result<GateConfig, String> {
701    let mut cfg = GateConfig {
702        test: None,
703        signoff: false,
704        protected: default_protected(),
705        owners: Vec::new(),
706        identity: None,
707        identity_from: None,
708    };
709    for path in config_layers(root) {
710        if path.is_file() {
711            apply_gate_file(&mut cfg, &path)?;
712        }
713    }
714    Ok(cfg)
715}
716
717/// User `ljos.toml`, then the seat's, then the repository's `ljos.toml`,
718/// then `LJOS_CONFIG`, then `.ljos/gates.toml`. A later file overrides the
719/// keys it sets. Identity is whatever the last of these files names. With
720/// none of them naming a person, there is no identity gate.
721fn config_layers(root: &Path) -> Vec<PathBuf> {
722    let mut paths = person_layers();
723    paths.push(root.join("ljos.toml"));
724    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
725        paths.push(PathBuf::from(path));
726    }
727    paths.push(root.join(".ljos").join("gates.toml"));
728    paths
729}
730
731fn user_config_base() -> Option<PathBuf> {
732    #[cfg(test)]
733    if let Some(path) = LAYER_HOME.with(|slot| slot.borrow().clone()) {
734        return Some(path);
735    }
736    super::config_base()
737}
738
739fn configured_seat() -> Option<String> {
740    #[cfg(test)]
741    if let Some(name) = LAYER_SEAT.with(|slot| slot.borrow().clone()) {
742        return clean_seat(&name);
743    }
744    std::env::var("LJOS_SEAT")
745        .ok()
746        .and_then(|name| clean_seat(&name))
747}
748
749fn clean_seat(name: &str) -> Option<String> {
750    let name = name.trim();
751    if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
752        return None;
753    }
754    Some(name.to_string())
755}
756
757fn read_layer(path: &Path) -> Result<Option<toml::map::Map<String, toml::Value>>, String> {
758    let text = std::fs::read_to_string(path).map_err(|e| {
759        format!(
760            "`{}` cannot be read ({e}). Fix the file, then retry.",
761            path.display()
762        )
763    })?;
764    if text.trim().is_empty() {
765        return Ok(None);
766    }
767    let value: toml::Value = toml::from_str(&text).map_err(|e| {
768        format!(
769            "`{}` does not parse ({e}). Fix the file, then retry.",
770            path.display()
771        )
772    })?;
773    match value {
774        toml::Value::Table(root) => Ok(Some(root)),
775        _ => Err(format!(
776            "`{}` is not a table. Fix the file, then retry.",
777            path.display()
778        )),
779    }
780}
781
782fn apply_gate_file(cfg: &mut GateConfig, path: &Path) -> Result<(), String> {
783    let Some(root) = read_layer(path)? else {
784        return Ok(());
785    };
786    let root = &root;
787    let gates = root.get("gates").and_then(toml::Value::as_table);
788    let src = gates.unwrap_or(root);
789    if src.contains_key("test") {
790        let test = norm_cmd(&req_string(src, "test", path)?);
791        cfg.test = (!test.is_empty()).then_some(test);
792    }
793    if src.contains_key("signoff") {
794        cfg.signoff = req_bool(src, "signoff", path)?;
795    }
796    if src.contains_key("protected_branches") {
797        cfg.protected = req_strings(src, "protected_branches", path)?
798            .into_iter()
799            .map(|name| branch_of(&name))
800            .filter(|name| !name.is_empty())
801            .collect();
802    }
803    if src.contains_key("owners") {
804        cfg.owners = req_strings(src, "owners", path)?
805            .into_iter()
806            .map(|name| name.trim().to_string())
807            .filter(|name| !name.is_empty())
808            .collect();
809    }
810    if let Some(identity) = root.get("identity").and_then(toml::Value::as_table) {
811        set_identity(cfg, identity, path)?;
812    } else if let Some(gates) = gates {
813        if gates.contains_key("name") || gates.contains_key("email") {
814            set_identity(cfg, gates, path)?;
815        }
816    } else if path.file_name().and_then(|name| name.to_str()) == Some("gates.toml")
817        && (src.contains_key("name") || src.contains_key("email"))
818    {
819        set_identity(cfg, src, path)?;
820    }
821    Ok(())
822}
823
824fn set_identity(
825    cfg: &mut GateConfig,
826    table: &toml::map::Map<String, toml::Value>,
827    path: &Path,
828) -> Result<(), String> {
829    let name = opt_string(table, "name", path)?;
830    let email = opt_string(table, "email", path)?;
831    match (name, email) {
832        (None, None) => Ok(()),
833        (Some(name), Some(email)) => {
834            let name = name.trim().to_string();
835            let email = email.trim().to_string();
836            if name.is_empty() || email.is_empty() {
837                return Err(identity_half(path));
838            }
839            cfg.identity = Some(Person { name, email });
840            cfg.identity_from = Some(path.to_path_buf());
841            Ok(())
842        }
843        _ => Err(identity_half(path)),
844    }
845}
846
847fn identity_half(path: &Path) -> String {
848    format!(
849        "`{}` sets an identity with only one of `name` and `email`. Set both, then retry.",
850        path.display()
851    )
852}
853
854fn req_string(
855    table: &toml::map::Map<String, toml::Value>,
856    key: &str,
857    path: &Path,
858) -> Result<String, String> {
859    match table.get(key) {
860        Some(toml::Value::String(text)) => Ok(text.clone()),
861        Some(_) => Err(format!(
862            "`{}` key `{key}` must be a string. Fix the file, then retry.",
863            path.display()
864        )),
865        None => Err(format!(
866            "`{}` is missing `{key}`. Fix the file, then retry.",
867            path.display()
868        )),
869    }
870}
871
872fn opt_string(
873    table: &toml::map::Map<String, toml::Value>,
874    key: &str,
875    path: &Path,
876) -> Result<Option<String>, String> {
877    match table.get(key) {
878        None => Ok(None),
879        Some(_) => req_string(table, key, path).map(Some),
880    }
881}
882
883fn req_bool(
884    table: &toml::map::Map<String, toml::Value>,
885    key: &str,
886    path: &Path,
887) -> Result<bool, String> {
888    match table.get(key) {
889        Some(toml::Value::Boolean(value)) => Ok(*value),
890        _ => Err(format!(
891            "`{}` key `{key}` must be true or false. Fix the file, then retry.",
892            path.display()
893        )),
894    }
895}
896
897fn req_strings(
898    table: &toml::map::Map<String, toml::Value>,
899    key: &str,
900    path: &Path,
901) -> Result<Vec<String>, String> {
902    let Some(items) = table.get(key).and_then(toml::Value::as_array) else {
903        return Err(format!(
904            "`{}` key `{key}` must be a list of strings. Fix the file, then retry.",
905            path.display()
906        ));
907    };
908    let mut out = Vec::new();
909    for item in items {
910        let Some(text) = item.as_str() else {
911            return Err(format!(
912                "`{}` key `{key}` must be a list of strings. Fix the file, then retry.",
913                path.display()
914            ));
915        };
916        out.push(text.to_string());
917    }
918    Ok(out)
919}
920
921fn is_protected(name: &str, protected: &[String]) -> bool {
922    let name = branch_of(name);
923    protected.iter().any(|p| p == &name)
924}
925
926/// The branches a `--force-with-lease` push asks before it replaces:
927/// `main`, `master` and `develop`, and every branch `protected_branches`
928/// names in the config layers [`config_layers`] reads (the user and seat
929/// `ljos.toml`, the repository's `ljos.toml`, `LJOS_CONFIG` and
930/// `.ljos/gates.toml`). Outside a checkout the user and seat files and
931/// `LJOS_CONFIG` still count. `None` when one of them does not parse, so
932/// the caller asks.
933pub(crate) fn lease_protected(cwd: Option<&Path>) -> Option<Vec<String>> {
934    let mut cfg = GateConfig {
935        test: None,
936        signoff: false,
937        protected: Vec::new(),
938        owners: Vec::new(),
939        identity: None,
940        identity_from: None,
941    };
942    let layers = match cwd.and_then(repo_root) {
943        Some(root) => config_layers(&root),
944        None => user_layers(),
945    };
946    for path in layers {
947        if path.is_file() {
948            apply_gate_file(&mut cfg, &path).ok()?;
949        }
950    }
951    let mut out: Vec<String> = ["main", "master", "develop"].map(String::from).to_vec();
952    out.extend(cfg.protected);
953    Some(out)
954}
955
956/// The config layers that do not live in a checkout: the user and seat
957/// `ljos.toml`, then `LJOS_CONFIG`.
958fn user_layers() -> Vec<PathBuf> {
959    let mut paths = person_layers();
960    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
961        paths.push(PathBuf::from(path));
962    }
963    paths
964}
965
966/// The user and seat `ljos.toml`: the person's own files, which the seat
967/// guard keeps a runner from writing.
968fn person_layers() -> Vec<PathBuf> {
969    let mut paths = Vec::new();
970    if let Some(base) = user_config_base() {
971        paths.push(base.join("ljos").join("ljos.toml"));
972        if let Some(seat) = configured_seat() {
973            paths.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
974        }
975    }
976    paths
977}
978
979/// What a runner's push onto one of `branches` waits for: `quorum` seats
980/// agreeing on the ticket it sits on.
981#[derive(Debug, Clone, PartialEq, Eq)]
982pub struct ConsensusPush {
983    pub quorum: usize,
984    pub branches: Vec<String>,
985}
986
987/// The [`ConsensusPush`] settings: `quorum` (2 seats unless set) and
988/// `consensus_branches` (`develop` and `main` unless set), under `[gates]`
989/// or at the top of a file. The user and seat `ljos.toml` set either. The
990/// repository's `ljos.toml`, `LJOS_CONFIG` and `.ljos/gates.toml` only
991/// raise the quorum and add branches, since a runner can write them.
992///
993/// # Errors
994///
995/// A layer that does not parse, or a key of the wrong kind.
996pub fn consensus_push(cwd: Option<&Path>) -> Result<ConsensusPush, String> {
997    let mut out = ConsensusPush {
998        quorum: 2,
999        branches: vec!["develop".into(), "main".into()],
1000    };
1001    for path in person_layers() {
1002        let (quorum, branches) = consensus_keys(&path)?;
1003        out.quorum = quorum.unwrap_or(out.quorum);
1004        out.branches = branches.unwrap_or(out.branches);
1005    }
1006    let root = cwd.and_then(repo_root);
1007    let mut rest: Vec<PathBuf> = root.iter().map(|r| r.join("ljos.toml")).collect();
1008    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
1009        rest.push(PathBuf::from(path));
1010    }
1011    rest.extend(root.iter().map(|r| r.join(".ljos").join("gates.toml")));
1012    for path in rest {
1013        let (quorum, branches) = consensus_keys(&path)?;
1014        out.quorum = out.quorum.max(quorum.unwrap_or(0));
1015        for branch in branches.unwrap_or_default() {
1016            if !out.branches.contains(&branch) {
1017                out.branches.push(branch);
1018            }
1019        }
1020    }
1021    Ok(out)
1022}
1023
1024fn consensus_keys(path: &Path) -> Result<(Option<usize>, Option<Vec<String>>), String> {
1025    if !path.is_file() {
1026        return Ok((None, None));
1027    }
1028    let Some(root) = read_layer(path)? else {
1029        return Ok((None, None));
1030    };
1031    let src = root
1032        .get("gates")
1033        .and_then(toml::Value::as_table)
1034        .unwrap_or(&root);
1035    let quorum = match src.get("quorum") {
1036        None => None,
1037        Some(toml::Value::Integer(n)) if *n >= 1 => Some(usize::try_from(*n).unwrap_or(usize::MAX)),
1038        Some(_) => {
1039            return Err(format!(
1040                "`{}` key `quorum` must be a whole number of seats, 1 or more. Fix the file, then retry.",
1041                path.display()
1042            ))
1043        }
1044    };
1045    let branches = if src.contains_key("consensus_branches") {
1046        let names = req_strings(src, "consensus_branches", path)?;
1047        Some(
1048            names
1049                .iter()
1050                .map(|name| branch_of(name))
1051                .filter(|name| !name.is_empty())
1052                .collect(),
1053        )
1054    } else {
1055        None
1056    };
1057    Ok((quorum, branches))
1058}
1059
1060/// Where `call`, a `git push` on `line`, lands that needs a consensus: a
1061/// branch in `branches`, in backticks, or why the push cannot be told
1062/// from one that does. `None` when each ref it updates is read and none
1063/// is in `branches`. The push is read the way git reads it: the first
1064/// positional is the repository; a bare push lands on the current branch
1065/// and its upstream unless `push.default` says otherwise; a branch named
1066/// alone lands on its upstream under `push.default=upstream`. A tag, or
1067/// any ref outside `refs/heads/`, is not a branch.
1068#[must_use]
1069pub fn protected_push(call: &GitCall, line: &str, branches: &[String]) -> Option<String> {
1070    let unread = |why: String| {
1071        Some(format!(
1072            "a branch the guard cannot tell from a protected one, since {why}"
1073        ))
1074    };
1075    let cwd = call.cwd.as_path();
1076    let args = &call.args;
1077    for word in [
1078        "GIT_CONFIG",
1079        "GIT_DIR",
1080        "--git-dir",
1081        "GIT_NAMESPACE",
1082        "--namespace",
1083    ] {
1084        if line.contains(word) {
1085            return unread(format!("the line sets `{word}`"));
1086        }
1087    }
1088    for (key, _) in &call.config {
1089        let key = key.to_ascii_lowercase();
1090        if ["remote.", "push.", "branch."]
1091            .iter()
1092            .any(|p| key.starts_with(p))
1093        {
1094            return unread(format!("it sets `{key}` for this push"));
1095        }
1096    }
1097    if args.iter().any(|a| has_subst(a)) {
1098        return unread("an argument is filled in when the line runs".into());
1099    }
1100    if let Some(all) = args
1101        .iter()
1102        .find(|a| matches!(a.as_str(), "--all" | "--mirror" | "--branches" | "--prune"))
1103    {
1104        return Some(format!("every branch `{all}` reaches"));
1105    }
1106    if let Some(flag) = args.iter().find(|a| {
1107        a.starts_with("--repo")
1108            || (a.starts_with("--recurse-submodules")
1109                && !a.ends_with("=check")
1110                && !a.ends_with("=no"))
1111    }) {
1112        return unread(format!("`{flag}` moves where it goes"));
1113    }
1114    if git_text(cwd, &["config", "--get-regexp", r"^remote\..*\.push$"]).is_some() {
1115        return unread("a remote in this checkout sets its own push refspecs".into());
1116    }
1117    let mode = git_config(cwd, "push.default")
1118        .unwrap_or_default()
1119        .to_ascii_lowercase();
1120    let upstream = |branch: &str| git_config(cwd, &format!("branch.{branch}.merge"));
1121    let here = || -> Result<Vec<String>, String> {
1122        if ["checkout", "switch", "symbolic-ref"]
1123            .iter()
1124            .any(|w| line.contains(w))
1125        {
1126            return Err("the line may change branch before it pushes".into());
1127        }
1128        let head = git_text(cwd, &["symbolic-ref", "-q", "--short", "HEAD"])
1129            .filter(|head| !head.is_empty())
1130            .ok_or_else(|| "HEAD names no branch".to_string())?;
1131        let up = upstream(&head).filter(|_| mode != "current");
1132        Ok(std::iter::once(head).chain(up).collect())
1133    };
1134    let pos = positionals(args);
1135    let specs = pos.get(1..).unwrap_or_default();
1136    let mut dests = Vec::new();
1137    if specs.is_empty() {
1138        match mode.as_str() {
1139            "nothing" => return None,
1140            "matching" => {
1141                return Some("every branch both sides have, under `push.default=matching`".into())
1142            }
1143            _ => match here() {
1144                Ok(here) => dests.extend(here),
1145                Err(why) => return unread(why),
1146            },
1147        }
1148    }
1149    for spec in specs {
1150        let spec = spec.strip_prefix('+').unwrap_or(spec);
1151        if spec.contains('*') {
1152            let dst = spec.rsplit(':').next().unwrap_or(spec);
1153            if !dst.starts_with("refs/") || dst.starts_with("refs/heads/") {
1154                return Some(format!("the branches `{spec}` matches"));
1155            }
1156            continue;
1157        }
1158        match spec.split_once(':') {
1159            Some((_, "HEAD" | "@")) => {
1160                return unread(format!("`{spec}` lands on what the far side's HEAD names"))
1161            }
1162            Some((src, "")) => dests.push(src.to_string()),
1163            Some((_, dst)) => dests.push(dst.to_string()),
1164            None if matches!(spec, "HEAD" | "@") => match here() {
1165                Ok(here) => dests.extend(here),
1166                Err(why) => return unread(why),
1167            },
1168            None => {
1169                dests.push(spec.to_string());
1170                if matches!(mode.as_str(), "upstream" | "tracking") {
1171                    dests.extend(upstream(spec));
1172                }
1173            }
1174        }
1175    }
1176    dests.iter().find_map(|dest| {
1177        let name = dest
1178            .strip_prefix("refs/heads/")
1179            .or_else(|| dest.strip_prefix("heads/"))
1180            .unwrap_or(dest);
1181        branches
1182            .iter()
1183            .find(|b| b.eq_ignore_ascii_case(name))
1184            .map(|b| format!("`{b}`"))
1185    })
1186}
1187
1188fn repo_root(cwd: &Path) -> Option<PathBuf> {
1189    git_text(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
1190}
1191
1192fn head_sha(cwd: &Path) -> Option<String> {
1193    let sha = git_text(cwd, &["rev-parse", "HEAD"])?;
1194    (sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit())).then_some(sha)
1195}
1196
1197fn rev(cwd: &Path, spec: &str) -> Option<String> {
1198    if has_subst(spec) {
1199        return None;
1200    }
1201    let expr = format!("{spec}^{{commit}}");
1202    let sha = git_text(cwd, &["rev-parse", "--verify", &expr])?;
1203    (sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit())).then_some(sha)
1204}
1205
1206fn abbrev(cwd: &Path) -> Option<String> {
1207    let branch = git_text(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
1208    (branch != "HEAD" && !branch.is_empty()).then_some(branch)
1209}
1210
1211#[cfg(test)]
1212fn second_parent(cwd: &Path) -> Option<String> {
1213    git_text(cwd, &["rev-parse", "--verify", "HEAD^2"])
1214}
1215
1216fn git_config(cwd: &Path, key: &str) -> Option<String> {
1217    let value = git_text(cwd, &["config", "--get", key])?;
1218    (!value.is_empty()).then_some(value)
1219}
1220
1221fn git_text(cwd: &Path, args: &[&str]) -> Option<String> {
1222    let mut cmd = Command::new("git");
1223    cmd.arg("-C")
1224        .arg(cwd)
1225        .args(args)
1226        .stdin(Stdio::null())
1227        .stdout(Stdio::piped())
1228        .stderr(Stdio::null());
1229    crate::apply_git_command_env(&mut cmd);
1230    let out = cmd.output().ok()?;
1231    if !out.status.success() {
1232        return None;
1233    }
1234    Some(
1235        String::from_utf8_lossy(&out.stdout)
1236            .trim_end_matches(['\r', '\n'])
1237            .to_string(),
1238    )
1239}
1240
1241fn identity_ok(
1242    git: &GitCmd,
1243    env: &[(String, String)],
1244    author_flag: Option<&str>,
1245    person: &Person,
1246    from: Option<&Path>,
1247) -> Result<(), super::Rule> {
1248    let cfg = |key: &str| {
1249        git.config
1250            .iter()
1251            .rev()
1252            .find(|(k, _)| k == key)
1253            .map(|(_, v)| v.clone())
1254            .or_else(|| git_config(&git.cwd, key))
1255            .unwrap_or_default()
1256    };
1257    let mut author_n = env_val(env, "GIT_AUTHOR_NAME").unwrap_or_else(|| cfg("user.name"));
1258    let mut author_e = env_val(env, "GIT_AUTHOR_EMAIL").unwrap_or_else(|| cfg("user.email"));
1259    let committer_n = env_val(env, "GIT_COMMITTER_NAME").unwrap_or_else(|| cfg("user.name"));
1260    let committer_e = env_val(env, "GIT_COMMITTER_EMAIL").unwrap_or_else(|| cfg("user.email"));
1261    if let Some(flag) = author_flag {
1262        let (name, email) = parse_author(flag);
1263        if !name.is_empty() {
1264            author_n = name;
1265        }
1266        if !email.is_empty() {
1267            author_e = email;
1268        }
1269    }
1270    let author_ok = same_person(&author_n, &author_e, person);
1271    let committer_ok = same_person(&committer_n, &committer_e, person);
1272    if author_ok && committer_ok {
1273        return Ok(());
1274    }
1275    let where_from = from
1276        .map(|path| path.display().to_string())
1277        .unwrap_or_else(|| "the gate config".to_string());
1278    Err(deny(
1279        "commit-gate",
1280        "git-commit-identity",
1281        &format!(
1282            "Configured identity is {} <{}> in {where_from}. Author is {} <{}>, committer is {} <{}>.",
1283            person.name,
1284            person.email,
1285            shown(&author_n),
1286            shown(&author_e),
1287            shown(&committer_n),
1288            shown(&committer_e)
1289        ),
1290    ))
1291}
1292
1293fn same_person(name: &str, email: &str, person: &Person) -> bool {
1294    name.trim() == person.name && email.trim().eq_ignore_ascii_case(&person.email)
1295}
1296
1297fn signoff_line(person: &Person) -> String {
1298    format!("Signed-off-by: {} <{}>", person.name, person.email)
1299}
1300
1301fn shown(value: &str) -> &str {
1302    let value = value.trim();
1303    if value.is_empty() {
1304        "unset"
1305    } else {
1306        value
1307    }
1308}
1309
1310fn parse_author(s: &str) -> (String, String) {
1311    let s = s.trim();
1312    if let Some((name, rest)) = s.rsplit_once('<') {
1313        let email = rest.trim().trim_end_matches('>').trim();
1314        (name.trim().to_string(), email.to_string())
1315    } else if s.contains('@') {
1316        (String::new(), s.to_string())
1317    } else {
1318        (s.to_string(), String::new())
1319    }
1320}
1321
1322fn has_signoff(message: &str, line: &str) -> bool {
1323    message.lines().any(|row| row.trim() == line)
1324}
1325
1326fn ai_marker(message: &str) -> Option<&'static str> {
1327    let lower = message.to_ascii_lowercase();
1328    if lower.contains("co-authored-by") {
1329        return Some("Co-authored-by");
1330    }
1331    if message.to_ascii_uppercase().contains("CURSOR_AGENT") {
1332        return Some("CURSOR_AGENT");
1333    }
1334    if lower.contains("generated with") {
1335        return Some("Generated with");
1336    }
1337    if lower.contains("open in cursor") {
1338        return Some("Open in Cursor");
1339    }
1340    if lower.contains("generated-by:") || lower.contains("generated by cursor") {
1341        return Some("Generated-by");
1342    }
1343    if lower.contains("assisted-by:") {
1344        return Some("Assisted-by");
1345    }
1346    if lower.contains("made-with:") || lower.contains("made with cursor") {
1347        return Some("Made-with");
1348    }
1349    if lower.contains("helped-by:") {
1350        return Some("Helped-by");
1351    }
1352    if message.contains('🤖') {
1353        return Some("an AI footer");
1354    }
1355    None
1356}
1357
1358fn hidden_deny() -> super::Rule {
1359    deny(
1360        "commit-gate",
1361        "git-commit-message",
1362        "Pass the message with `git commit -m TEXT` or `git commit -F FILE`.",
1363    )
1364}
1365
1366fn has_subst(s: &str) -> bool {
1367    if s.contains('`') {
1368        return true;
1369    }
1370    let bytes = s.as_bytes();
1371    bytes.iter().enumerate().any(|(i, c)| {
1372        if *c != b'$' {
1373            return false;
1374        }
1375        matches!(
1376            bytes.get(i + 1).copied().unwrap_or(0),
1377            b'(' | b'{' | b'_' | b'a'..=b'z' | b'A'..=b'Z'
1378        )
1379    })
1380}
1381
1382fn commit_fields(args: &[String]) -> CommitFields {
1383    let mut fields = CommitFields {
1384        messages: Vec::new(),
1385        files: Vec::new(),
1386        reuse: Vec::new(),
1387        trailers: Vec::new(),
1388        author: None,
1389        signoff: false,
1390        no_edit: false,
1391        amend: false,
1392        dry: false,
1393        help: false,
1394        hidden: false,
1395    };
1396    let mut i = 0;
1397    while i < args.len() {
1398        let a = &args[i];
1399        if a == "--" {
1400            break;
1401        }
1402        if let Some(v) = a.strip_prefix("--message=") {
1403            fields.messages.push(v.to_string());
1404            i += 1;
1405            continue;
1406        }
1407        if let Some(v) = a.strip_prefix("--file=") {
1408            fields.files.push(v.to_string());
1409            i += 1;
1410            continue;
1411        }
1412        if let Some(v) = a.strip_prefix("--author=") {
1413            fields.author = Some(v.to_string());
1414            i += 1;
1415            continue;
1416        }
1417        if let Some(v) = a.strip_prefix("--trailer=") {
1418            fields.trailers.push(v.to_string());
1419            i += 1;
1420            continue;
1421        }
1422        if matches!(
1423            a.as_str(),
1424            "-m" | "--message"
1425                | "-F"
1426                | "--file"
1427                | "--author"
1428                | "--trailer"
1429                | "-C"
1430                | "--reuse-message"
1431                | "-c"
1432                | "--reedit-message"
1433                | "-t"
1434                | "--template"
1435        ) {
1436            let val = args.get(i + 1).cloned().unwrap_or_default();
1437            match a.as_str() {
1438                "-m" | "--message" => fields.messages.push(val),
1439                "-F" | "--file" => fields.files.push(val),
1440                "--author" => fields.author = Some(val),
1441                "--trailer" => fields.trailers.push(val),
1442                "-C" | "--reuse-message" => fields.reuse.push(val),
1443                _ => fields.hidden = true,
1444            }
1445            i += 2;
1446            continue;
1447        }
1448        match a.as_str() {
1449            "--signoff" => fields.signoff = true,
1450            "--no-edit" => fields.no_edit = true,
1451            "--amend" => fields.amend = true,
1452            "--dry-run" => fields.dry = true,
1453            "--help" | "-h" => fields.help = true,
1454            _ if a.starts_with("--") => {}
1455            _ if a.starts_with('-') && a.len() > 1 => {
1456                let chars: Vec<char> = a[1..].chars().collect();
1457                let mut k = 0;
1458                let mut ate = false;
1459                while k < chars.len() {
1460                    let inline: String = chars[k + 1..].iter().collect();
1461                    match chars[k] {
1462                        's' => fields.signoff = true,
1463                        'h' => fields.help = true,
1464                        'm' | 'F' | 'C' | 'c' | 't' => {
1465                            let (val, took) = if inline.is_empty() {
1466                                (args.get(i + 1).cloned().unwrap_or_default(), true)
1467                            } else {
1468                                (inline, false)
1469                            };
1470                            match chars[k] {
1471                                'm' => fields.messages.push(val),
1472                                'F' => fields.files.push(val),
1473                                'C' => fields.reuse.push(val),
1474                                _ => fields.hidden = true,
1475                            }
1476                            ate = took;
1477                            break;
1478                        }
1479                        _ => {}
1480                    }
1481                    k += 1;
1482                }
1483                if ate {
1484                    i += 1;
1485                }
1486            }
1487            _ => {}
1488        }
1489        i += 1;
1490    }
1491    fields
1492}
1493
1494fn read_files(cwd: &Path, files: &[String]) -> Option<String> {
1495    let mut out = String::new();
1496    for file in files {
1497        let text = read_one(cwd, file)?;
1498        if !out.is_empty() {
1499            out.push('\n');
1500        }
1501        out.push_str(&text);
1502    }
1503    Some(out)
1504}
1505
1506fn read_one(cwd: &Path, file: &str) -> Option<String> {
1507    if file == "-" || has_subst(file) {
1508        return None;
1509    }
1510    let path = if Path::new(file).is_absolute() {
1511        PathBuf::from(file)
1512    } else {
1513        cwd.join(file)
1514    };
1515    let meta = std::fs::metadata(&path).ok()?;
1516    if meta.len() > 1_048_576 {
1517        return None;
1518    }
1519    std::fs::read_to_string(path).ok()
1520}
1521
1522fn reuse_messages(cwd: &Path, revs: &[String]) -> Option<String> {
1523    let mut out = String::new();
1524    for rev in revs {
1525        if has_subst(rev) {
1526            return None;
1527        }
1528        let text = git_text(cwd, &["log", "-1", "--format=%B", "--", rev])?;
1529        if !out.is_empty() {
1530            out.push_str("\n\n");
1531        }
1532        out.push_str(&text);
1533    }
1534    Some(out)
1535}
1536
1537pub(crate) fn is_dry(args: &[String]) -> bool {
1538    args.iter().any(|a| {
1539        a == "--dry-run"
1540            || a == "-n"
1541            || (a.starts_with('-') && !a.starts_with("--") && a.chars().skip(1).any(|c| c == 'n'))
1542    })
1543}
1544
1545fn is_delete(args: &[String]) -> bool {
1546    if args.iter().any(|a| a == "--delete" || a == "-d") {
1547        return true;
1548    }
1549    let pos = positionals(args);
1550    !pos.is_empty()
1551        && pos
1552            .iter()
1553            .all(|p| p.strip_prefix('+').unwrap_or(p).starts_with(':'))
1554}
1555
1556fn positionals(args: &[String]) -> Vec<String> {
1557    let mut out = Vec::new();
1558    let mut i = 0;
1559    while i < args.len() {
1560        let a = &args[i];
1561        if a == "--" {
1562            out.extend(args[i + 1..].iter().cloned());
1563            break;
1564        }
1565        if matches!(
1566            a.as_str(),
1567            "-o" | "--push-option" | "--receive-pack" | "--exec" | "--repo"
1568        ) {
1569            i += 2;
1570            continue;
1571        }
1572        if a.starts_with('-') {
1573            i += 1;
1574            continue;
1575        }
1576        out.push(a.clone());
1577        i += 1;
1578    }
1579    out
1580}
1581
1582fn remotes(cwd: &Path) -> Vec<String> {
1583    git_text(cwd, &["remote"])
1584        .unwrap_or_default()
1585        .lines()
1586        .map(str::trim)
1587        .filter(|s| !s.is_empty())
1588        .map(str::to_string)
1589        .collect()
1590}
1591
1592fn refspecs(cwd: &Path, args: &[String]) -> Vec<String> {
1593    let pos = positionals(args);
1594    if pos.is_empty() {
1595        return Vec::new();
1596    }
1597    let names = remotes(cwd);
1598    if names.iter().any(|r| r == &pos[0]) {
1599        return pos[1..].to_vec();
1600    }
1601    pos
1602}
1603
1604fn push_dests(cwd: &Path, args: &[String], current: Option<&str>) -> Vec<String> {
1605    let specs = refspecs(cwd, args);
1606    if specs.is_empty() {
1607        return current.map(|c| vec![branch_of(c)]).unwrap_or_default();
1608    }
1609    specs.iter().filter_map(|s| dest_of(s, current)).collect()
1610}
1611
1612fn dest_of(spec: &str, current: Option<&str>) -> Option<String> {
1613    let spec = spec.strip_prefix('+').unwrap_or(spec);
1614    if spec.is_empty() || spec.starts_with(':') {
1615        return None;
1616    }
1617    let dst = spec.split_once(':').map(|(_, d)| d).unwrap_or(spec);
1618    if dst.is_empty() || dst == "HEAD" {
1619        return current.map(branch_of);
1620    }
1621    Some(branch_of(dst))
1622}
1623
1624fn src_of(spec: &str) -> Option<String> {
1625    let spec = spec.strip_prefix('+').unwrap_or(spec);
1626    if spec.is_empty() || spec.starts_with(':') {
1627        return None;
1628    }
1629    let src = spec.split_once(':').map(|(s, _)| s).unwrap_or(spec);
1630    if src.is_empty() {
1631        None
1632    } else {
1633        Some(src.to_string())
1634    }
1635}
1636
1637fn pushing_current(src: &str, current: Option<&str>) -> bool {
1638    if src == "HEAD" {
1639        return true;
1640    }
1641    current.is_some_and(|c| branch_of(c) == branch_of(src))
1642}
1643
1644fn switched_branch(args: &[String]) -> Option<String> {
1645    if is_help(args) {
1646        return None;
1647    }
1648    let mut i = 0;
1649    while i < args.len() {
1650        let a = &args[i];
1651        if a == "--" {
1652            return None;
1653        }
1654        if matches!(a.as_str(), "-b" | "-B" | "-c" | "-C" | "--orphan") {
1655            return args
1656                .get(i + 1)
1657                .map(|s| branch_of(s))
1658                .filter(|s| !s.is_empty());
1659        }
1660        if let Some(v) = a.strip_prefix("--orphan=") {
1661            return Some(branch_of(v)).filter(|s| !s.is_empty());
1662        }
1663        if matches!(a.as_str(), "--track" | "-t")
1664            && args.get(i + 1).is_some_and(|n| !n.starts_with('-'))
1665        {
1666            i += 2;
1667            continue;
1668        }
1669        if a.starts_with('-') {
1670            i += 1;
1671            continue;
1672        }
1673        if a == "." || a.contains('*') {
1674            return None;
1675        }
1676        return Some(branch_of(a));
1677    }
1678    None
1679}
1680
1681fn is_help(args: &[String]) -> bool {
1682    args.iter().any(|a| a == "--help" || a == "-h")
1683}
1684
1685fn review_pr(
1686    cwd: &Path,
1687    spec: &str,
1688    repo: Option<&str>,
1689    expect: Option<&str>,
1690    action: &str,
1691) -> Result<(), super::Rule> {
1692    let args = pr_view_args(spec, repo, "number,headRefOid,statusCheckRollup,url,state");
1693    match gh_read(cwd, &args) {
1694        Ok(view) => review_rollup(cwd, &view, expect, action),
1695        Err(err) if err.rollup_denied => review_pr_rest(cwd, spec, repo, expect, action),
1696        Err(err) => Err(merge_io(action, &err.text)),
1697    }
1698}
1699
1700fn pr_view_args(spec: &str, repo: Option<&str>, fields: &str) -> Vec<String> {
1701    let mut args = vec!["pr".to_string(), "view".to_string()];
1702    if !spec.is_empty() {
1703        args.push(spec.to_string());
1704    }
1705    args.push("--json".into());
1706    args.push(fields.to_string());
1707    if let Some(repo) = repo {
1708        args.push("--repo".into());
1709        args.push(repo.to_string());
1710    }
1711    args
1712}
1713
1714struct PrHead {
1715    number: u64,
1716    sha: String,
1717    url: String,
1718}
1719
1720fn pr_head(view: &Value, action: &str) -> Result<PrHead, super::Rule> {
1721    let number = view["number"]
1722        .as_u64()
1723        .ok_or_else(|| merge_io(action, "the pull request number was missing"))?;
1724    let sha = view["headRefOid"]
1725        .as_str()
1726        .filter(|s| !s.is_empty())
1727        .ok_or_else(|| merge_io(action, "the head sha was missing"))?
1728        .to_string();
1729    let url = view["url"].as_str().unwrap_or("").to_string();
1730    Ok(PrHead { number, sha, url })
1731}
1732
1733fn same_head(
1734    action: &str,
1735    sha: &str,
1736    number: u64,
1737    expect: Option<&str>,
1738) -> Result<(), super::Rule> {
1739    let Some(expect) = expect else {
1740        return Ok(());
1741    };
1742    if sha == expect {
1743        return Ok(());
1744    }
1745    Err(merge_deny(
1746        action,
1747        expect,
1748        &number.to_string(),
1749        &format!("The pull request head is {sha}, but this merge publishes {expect}."),
1750    ))
1751}
1752
1753fn review_rollup(
1754    cwd: &Path,
1755    view: &Value,
1756    expect: Option<&str>,
1757    action: &str,
1758) -> Result<(), super::Rule> {
1759    let head = pr_head(view, action)?;
1760    same_head(action, &head.sha, head.number, expect)?;
1761    let rollup = &view["statusCheckRollup"];
1762    if let Some(problem) = ci_problem(rollup) {
1763        return Err(merge_deny(
1764            action,
1765            &head.sha,
1766            &head.number.to_string(),
1767            &name_source(&problem, "rollup"),
1768        ));
1769    }
1770    let Some((owner, name)) = owner_repo(&head.url) else {
1771        return Err(merge_io(
1772            action,
1773            "the pull request URL did not name an owner and repository",
1774        ));
1775    };
1776    finish_review(
1777        cwd,
1778        &Reviewed {
1779            action,
1780            number: head.number,
1781            sha: &head.sha,
1782            owner: &owner,
1783            name: &name,
1784            absent: ci_absent(rollup),
1785            source: "rollup",
1786        },
1787    )
1788}
1789
1790/// The rollup read was denied to this token. Workflow runs and the combined
1791/// commit status carry the same CI result. Both empty is the no-CI ask. A
1792/// failed read keeps the refusal that gh could not read the pull request.
1793fn review_pr_rest(
1794    cwd: &Path,
1795    spec: &str,
1796    repo: Option<&str>,
1797    expect: Option<&str>,
1798    action: &str,
1799) -> Result<(), super::Rule> {
1800    let args = pr_view_args(spec, repo, "number,headRefOid,url,state");
1801    let view = gh_json(cwd, &args).map_err(|e| merge_io(action, &e))?;
1802    let head = pr_head(&view, action)?;
1803    same_head(action, &head.sha, head.number, expect)?;
1804    let Some((owner, name)) = owner_repo(&head.url) else {
1805        return Err(merge_io(
1806            action,
1807            "the pull request URL did not name an owner and repository",
1808        ));
1809    };
1810    let runs = workflow_runs(cwd, &owner, &name, &head.sha).map_err(|e| merge_io(action, &e))?;
1811    let status = commit_status(cwd, &owner, &name, &head.sha).map_err(|e| merge_io(action, &e))?;
1812    let problem = rest_problem(&runs, &status).map_err(|e| merge_io(action, &e))?;
1813    if let Some(problem) = problem {
1814        return Err(merge_deny(
1815            action,
1816            &head.sha,
1817            &head.number.to_string(),
1818            &name_source(&problem, "REST fallback"),
1819        ));
1820    }
1821    finish_review(
1822        cwd,
1823        &Reviewed {
1824            action,
1825            number: head.number,
1826            sha: &head.sha,
1827            owner: &owner,
1828            name: &name,
1829            absent: rest_absent(&runs, &status),
1830            source: "REST fallback",
1831        },
1832    )
1833}
1834
1835struct Reviewed<'a> {
1836    action: &'a str,
1837    number: u64,
1838    sha: &'a str,
1839    owner: &'a str,
1840    name: &'a str,
1841    absent: bool,
1842    source: &'a str,
1843}
1844
1845fn finish_review(cwd: &Path, review: &Reviewed<'_>) -> Result<(), super::Rule> {
1846    let threads = bugbot_query(cwd, review.owner, review.name, review.number)
1847        .map_err(|e| merge_io(review.action, &e))?;
1848    if let Some(problem) = bugbot_problem(&threads).map_err(|e| merge_io(review.action, &e))? {
1849        return Err(merge_deny(
1850            review.action,
1851            review.sha,
1852            &review.number.to_string(),
1853            &name_source(&problem, review.source),
1854        ));
1855    }
1856    if review.absent {
1857        return Err(merge_ask(cwd, review.sha, review.source));
1858    }
1859    Ok(())
1860}
1861
1862fn name_source(problem: &str, source: &str) -> String {
1863    format!("{problem}. Source: {source}.")
1864}
1865
1866const RUN_PAGE: usize = 100;
1867
1868fn workflow_runs(cwd: &Path, owner: &str, name: &str, sha: &str) -> Result<Vec<Value>, String> {
1869    let mut all = Vec::new();
1870    let mut page = 1u32;
1871    loop {
1872        let body = gh_json(
1873            cwd,
1874            &[
1875                "api".into(),
1876                format!("repos/{owner}/{name}/actions/runs"),
1877                "-f".into(),
1878                format!("head_sha={sha}"),
1879                "-f".into(),
1880                format!("per_page={RUN_PAGE}"),
1881                "-f".into(),
1882                format!("page={page}"),
1883            ],
1884        )?;
1885        let Some(runs) = body.get("workflow_runs").and_then(Value::as_array) else {
1886            return Err("the workflow run list was missing".into());
1887        };
1888        let total = body.get("total_count").and_then(Value::as_u64);
1889        if runs.is_empty() {
1890            if total.is_some_and(|t| (all.len() as u64) < t) {
1891                return Err("the workflow run list ended early".into());
1892            }
1893            break;
1894        }
1895        all.extend(runs.iter().cloned());
1896        let more = match total {
1897            Some(total) => (all.len() as u64) < total,
1898            None => runs.len() >= RUN_PAGE,
1899        };
1900        if !more {
1901            break;
1902        }
1903        if page == 100 {
1904            return Err("the workflow run list did not end".into());
1905        }
1906        page += 1;
1907    }
1908    Ok(all)
1909}
1910
1911fn commit_status(cwd: &Path, owner: &str, name: &str, sha: &str) -> Result<Value, String> {
1912    gh_json(
1913        cwd,
1914        &[
1915            "api".into(),
1916            format!("repos/{owner}/{name}/commits/{sha}/status"),
1917        ],
1918    )
1919}
1920
1921fn rest_absent(runs: &[Value], status: &Value) -> bool {
1922    runs.is_empty()
1923        && status
1924            .get("statuses")
1925            .and_then(Value::as_array)
1926            .is_some_and(Vec::is_empty)
1927}
1928
1929fn rest_problem(runs: &[Value], status: &Value) -> Result<Option<String>, String> {
1930    let mut bad = Vec::new();
1931    for run in runs {
1932        if let Some(fault) = run_fault(run) {
1933            bad.push(fault);
1934        }
1935    }
1936    if let Some(fault) = status_fault(status)? {
1937        bad.push(fault);
1938    }
1939    if bad.is_empty() {
1940        Ok(None)
1941    } else {
1942        Ok(Some(format!("{} not green", bad.join(", "))))
1943    }
1944}
1945
1946fn run_fault(run: &Value) -> Option<String> {
1947    let name = run["name"]
1948        .as_str()
1949        .filter(|s| !s.is_empty())
1950        .unwrap_or("run");
1951    let status = run["status"]
1952        .as_str()
1953        .unwrap_or("")
1954        .trim()
1955        .to_ascii_lowercase();
1956    let conclusion = run["conclusion"]
1957        .as_str()
1958        .unwrap_or("")
1959        .trim()
1960        .to_ascii_lowercase();
1961    if matches!(
1962        status.as_str(),
1963        "queued" | "in_progress" | "in-progress" | "waiting" | "requested" | "pending"
1964    ) {
1965        return Some(format!("{name}={status} (pending)"));
1966    }
1967    if status == "completed" && matches!(conclusion.as_str(), "success" | "neutral" | "skipped") {
1968        return None;
1969    }
1970    let shown = if !conclusion.is_empty() {
1971        conclusion
1972    } else if !status.is_empty() {
1973        status
1974    } else {
1975        "unknown".to_string()
1976    };
1977    Some(format!("{name}={shown}"))
1978}
1979
1980fn status_fault(status: &Value) -> Result<Option<String>, String> {
1981    let Some(list) = status.get("statuses").and_then(Value::as_array) else {
1982        return Err("the commit status list was missing".into());
1983    };
1984    if list.is_empty() {
1985        return Ok(None);
1986    }
1987    let state = status["state"]
1988        .as_str()
1989        .unwrap_or("")
1990        .trim()
1991        .to_ascii_lowercase();
1992    if state == "success" {
1993        return Ok(None);
1994    }
1995    let shown = if state.is_empty() {
1996        "unknown".to_string()
1997    } else {
1998        state
1999    };
2000    if shown == "pending" {
2001        Ok(Some(format!("commit status={shown} (pending)")))
2002    } else {
2003        Ok(Some(format!("commit status={shown}")))
2004    }
2005}
2006
2007/// An ask for a head sha with no CI checks. A passing local record for
2008/// that exact sha is named. The merge still asks. `source` is `rollup` or
2009/// `REST fallback`.
2010fn merge_ask(cwd: &Path, sha: &str, source: &str) -> super::Rule {
2011    let mut detail = format!("no CI ran on head sha {sha}. Source: {source}.");
2012    if evidence_recorded(cwd, sha) {
2013        detail.push_str(&format!(" A local evidence record exists for {sha}."));
2014    }
2015    super::Rule {
2016        pattern: "merge-gate".into(),
2017        verdict: "ask".into(),
2018        reason: format!("git-merge-unready: {detail}"),
2019    }
2020}
2021
2022fn merge_deny(action: &str, sha: &str, pr: &str, problem: &str) -> super::Rule {
2023    let checks = if pr.is_empty() {
2024        "gh pr checks".to_string()
2025    } else {
2026        format!("gh pr checks {pr}")
2027    };
2028    deny(
2029        "merge-gate",
2030        "git-merge-unready",
2031        &format!(
2032            "Refusing `{action}`. {problem} Head sha {sha}. Wait until `{checks}` is green and every Cursor Bugbot thread is resolved or has a reply, then run the merge again."
2033        ),
2034    )
2035}
2036
2037fn merge_io(action: &str, err: &str) -> super::Rule {
2038    deny(
2039        "merge-gate",
2040        "git-merge-unready",
2041        &format!(
2042            "Refusing `{action}`. {err}. Sign in with `gh auth login` if gh could not read the pull request, wait until CI is green on the head sha and every Cursor Bugbot thread is resolved or has a reply, then run the merge again."
2043        ),
2044    )
2045}
2046
2047/// No checks on the head sha: a null rollup or an empty list. That is an
2048/// ask in [`review_pr`], not a denial.
2049fn ci_absent(rollup: &Value) -> bool {
2050    rollup.is_null() || rollup.as_array().is_some_and(Vec::is_empty)
2051}
2052
2053fn ci_problem(rollup: &Value) -> Option<String> {
2054    // No checks at all is an ask, decided in [`review_pr`]. A red or
2055    // pending check is a denial.
2056    if ci_absent(rollup) {
2057        return None;
2058    }
2059    let arr = rollup.as_array()?;
2060    let mut bad = Vec::new();
2061    for check in arr {
2062        let name = check["name"]
2063            .as_str()
2064            .or_else(|| check["context"].as_str())
2065            .unwrap_or("check");
2066        let status = check["status"].as_str().unwrap_or("").to_ascii_uppercase();
2067        let conclusion = check["conclusion"]
2068            .as_str()
2069            .unwrap_or("")
2070            .to_ascii_uppercase();
2071        let state = check["state"].as_str().unwrap_or("").to_ascii_uppercase();
2072        let shown = if !conclusion.is_empty() && (status.is_empty() || status == "COMPLETED") {
2073            conclusion
2074        } else if !status.is_empty() && status != "COMPLETED" {
2075            status
2076        } else if !state.is_empty() {
2077            state
2078        } else {
2079            "UNKNOWN".into()
2080        };
2081        if !matches!(shown.as_str(), "SUCCESS" | "NEUTRAL" | "SKIPPED") {
2082            bad.push(format!("{name}={shown}"));
2083        }
2084    }
2085    if bad.is_empty() {
2086        None
2087    } else {
2088        Some(format!("{} not green", bad.join(", ")))
2089    }
2090}
2091
2092fn bugbot_query(cwd: &Path, owner: &str, name: &str, number: u64) -> Result<Value, String> {
2093    let query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){pageInfo{hasNextPage}nodes{isResolved comments(first:50){nodes{author{login}body}}}}}}}";
2094    gh_json(
2095        cwd,
2096        &[
2097            "api".into(),
2098            "graphql".into(),
2099            "-f".into(),
2100            format!("query={query}"),
2101            "-f".into(),
2102            format!("owner={owner}"),
2103            "-f".into(),
2104            format!("name={name}"),
2105            "-F".into(),
2106            format!("number={number}"),
2107        ],
2108    )
2109}
2110
2111fn bugbot_problem(body: &Value) -> Result<Option<String>, String> {
2112    if let Some(errors) = body.get("errors") {
2113        if errors.as_array().is_some_and(|a| !a.is_empty()) {
2114            return Err(brief(&errors.to_string()));
2115        }
2116    }
2117    let Some(nodes) = body
2118        .pointer("/data/repository/pullRequest/reviewThreads/nodes")
2119        .and_then(Value::as_array)
2120    else {
2121        return Err("the Bugbot thread list was missing".into());
2122    };
2123    if body
2124        .pointer("/data/repository/pullRequest/reviewThreads/pageInfo/hasNextPage")
2125        .and_then(Value::as_bool)
2126        == Some(true)
2127    {
2128        return Err("more than 100 review threads came back".into());
2129    }
2130    let mut open = Vec::new();
2131    for node in nodes {
2132        let Some(comments) = node.pointer("/comments/nodes").and_then(Value::as_array) else {
2133            continue;
2134        };
2135        let Some(first) = comments.first() else {
2136            continue;
2137        };
2138        let login = first
2139            .pointer("/author/login")
2140            .and_then(Value::as_str)
2141            .unwrap_or("");
2142        let text = first["body"].as_str().unwrap_or("");
2143        if !is_bugbot(login, text) {
2144            continue;
2145        }
2146        if node["isResolved"].as_bool() == Some(true) {
2147            continue;
2148        }
2149        let reply = comments.iter().skip(1).any(|c| {
2150            let login = c
2151                .pointer("/author/login")
2152                .and_then(Value::as_str)
2153                .unwrap_or("");
2154            !bugbot_login(login)
2155        });
2156        if reply {
2157            continue;
2158        }
2159        let snippet: String = text.chars().take(80).collect();
2160        open.push(snippet);
2161    }
2162    if open.is_empty() {
2163        Ok(None)
2164    } else {
2165        Ok(Some(format!(
2166            "{} open Cursor Bugbot thread(s) with no reply ({})",
2167            open.len(),
2168            open.join(" | ")
2169        )))
2170    }
2171}
2172
2173fn bugbot_login(login: &str) -> bool {
2174    let login = login.to_ascii_lowercase();
2175    login.contains("bugbot") || login == "cursor[bot]" || login.starts_with("cursor[")
2176}
2177
2178fn is_bugbot(login: &str, body: &str) -> bool {
2179    bugbot_login(login) || body.contains("Bugbot") || body.contains("CURSOR_BUGBOT")
2180}
2181
2182fn owner_repo(url: &str) -> Option<(String, String)> {
2183    let rest = url.split("://").nth(1).unwrap_or(url);
2184    let mut parts = rest.split('/').filter(|p| !p.is_empty());
2185    let _host = parts.next()?;
2186    let owner = parts.next()?.to_string();
2187    let repo = parts.next()?.trim_end_matches(".git").to_string();
2188    (!owner.is_empty() && !repo.is_empty()).then_some((owner, repo))
2189}
2190
2191fn api_merge(words: &[String]) -> Option<ApiMerge> {
2192    if words.is_empty() {
2193        return None;
2194    }
2195    let base = super::base_name(&words[0]);
2196    match base {
2197        "gh" => gh_merge(words),
2198        "curl" | "wget" => words
2199            .iter()
2200            .find_map(|w| pulls_merge(w))
2201            .map(|(repo, num)| ApiMerge {
2202                repo: Some(repo),
2203                spec: num,
2204            }),
2205        "python" | "python3" | "node" | "ruby" | "perl" | "bun" => {
2206            if words.iter().any(|w| {
2207                w.contains("mergePullRequest")
2208                    || w.contains("merge_pull_request")
2209                    || pulls_merge(w).is_some()
2210            }) {
2211                if let Some((repo, num)) = words.iter().find_map(|w| pulls_merge(w)) {
2212                    Some(ApiMerge {
2213                        repo: Some(repo),
2214                        spec: num,
2215                    })
2216                } else {
2217                    Some(ApiMerge {
2218                        repo: None,
2219                        spec: String::new(),
2220                    })
2221                }
2222            } else {
2223                None
2224            }
2225        }
2226        _ => None,
2227    }
2228}
2229
2230fn gh_merge(words: &[String]) -> Option<ApiMerge> {
2231    if words.get(1).map(String::as_str) == Some("pr")
2232        && words.get(2).map(String::as_str) == Some("merge")
2233    {
2234        let (repo, spec) = gh_merge_args(words);
2235        return Some(ApiMerge { repo, spec });
2236    }
2237    if let Some((repo, num)) = words.iter().find_map(|w| pulls_merge(w)) {
2238        return Some(ApiMerge {
2239            repo: Some(repo),
2240            spec: num,
2241        });
2242    }
2243    if words
2244        .iter()
2245        .any(|w| w.contains("mergePullRequest") || w.contains("merge_pull_request"))
2246    {
2247        return Some(ApiMerge {
2248            repo: None,
2249            spec: String::new(),
2250        });
2251    }
2252    None
2253}
2254
2255fn gh_merge_args(words: &[String]) -> (Option<String>, String) {
2256    let mut repo = None;
2257    let mut spec = String::new();
2258    let mut i = 3;
2259    while i < words.len() {
2260        let w = &words[i];
2261        if w == "--repo" || w == "-R" {
2262            repo = words.get(i + 1).cloned();
2263            i += 2;
2264            continue;
2265        }
2266        if let Some(v) = w.strip_prefix("--repo=") {
2267            repo = Some(v.to_string());
2268            i += 1;
2269            continue;
2270        }
2271        if w.starts_with('-') {
2272            i += 1;
2273            continue;
2274        }
2275        if spec.is_empty() {
2276            spec = w.clone();
2277        }
2278        i += 1;
2279    }
2280    (repo, spec)
2281}
2282
2283fn pulls_merge(text: &str) -> Option<(String, String)> {
2284    let mut search = 0;
2285    while let Some(rel) = text[search..].find("/pulls/") {
2286        let at = search + rel;
2287        let rest = &text[at + "/pulls/".len()..];
2288        let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
2289        if num.is_empty() {
2290            search = at + "/pulls/".len();
2291            continue;
2292        }
2293        let after = &rest[num.len()..];
2294        if !after.starts_with("/merge") {
2295            search = at + "/pulls/".len();
2296            continue;
2297        }
2298        let before = &text[..at];
2299        let segs: Vec<&str> = before
2300            .rsplit('/')
2301            .filter(|s| !s.is_empty())
2302            .take(3)
2303            .collect();
2304        if segs.len() < 2 {
2305            return None;
2306        }
2307        let repo = segs[0].trim();
2308        let owner = segs[1].trim();
2309        if owner.is_empty() || repo.is_empty() || owner == "repos" {
2310            search = at + "/pulls/".len();
2311            continue;
2312        }
2313        return Some((format!("{owner}/{repo}"), num));
2314    }
2315    None
2316}
2317
2318fn gh_program() -> PathBuf {
2319    GH_BIN
2320        .with(|g| g.borrow().clone())
2321        .unwrap_or_else(|| PathBuf::from("gh"))
2322}
2323
2324struct GhFail {
2325    text: String,
2326    /// The rollup field was refused with the fine-grained token phrase.
2327    /// Any other failure leaves this false.
2328    rollup_denied: bool,
2329}
2330
2331fn gh_json(cwd: &Path, args: &[String]) -> Result<Value, String> {
2332    gh_read(cwd, args).map_err(|err| err.text)
2333}
2334
2335fn gh_read(cwd: &Path, args: &[String]) -> Result<Value, GhFail> {
2336    let mut cmd = Command::new(gh_program());
2337    cmd.current_dir(cwd).args(args);
2338    let out = run_out(&mut cmd, super::guard_timeout()).map_err(|text| GhFail {
2339        text,
2340        rollup_denied: false,
2341    })?;
2342    if !out.status.success() {
2343        let err = String::from_utf8_lossy(&out.stderr);
2344        let raw_out = String::from_utf8_lossy(&out.stdout);
2345        let rollup_denied = rollup_permission_denied(&err) || rollup_permission_denied(&raw_out);
2346        let text = brief(&err);
2347        let text = if text.is_empty() {
2348            format!("gh exited {}", out.status.code().unwrap_or(1))
2349        } else {
2350            text
2351        };
2352        return Err(GhFail {
2353            text,
2354            rollup_denied,
2355        });
2356    }
2357    serde_json::from_slice(&out.stdout).map_err(|e| GhFail {
2358        text: format!("gh did not return JSON ({e})"),
2359        rollup_denied: false,
2360    })
2361}
2362
2363/// Fine-grained tokens have no Checks permission. `gh pr view` then fails
2364/// with this phrase when the json fields include `statusCheckRollup`.
2365/// A different failure is not this case.
2366fn rollup_permission_denied(err: &str) -> bool {
2367    let flat = err.split_whitespace().collect::<Vec<_>>().join(" ");
2368    flat.to_ascii_lowercase()
2369        .contains("resource not accessible by personal access token")
2370}
2371
2372fn brief(s: &str) -> String {
2373    let one = s.split_whitespace().collect::<Vec<_>>().join(" ");
2374    let mut text: String = one.chars().take(240).collect();
2375    if one.chars().count() > 240 {
2376        text.push_str("...");
2377    }
2378    text
2379}
2380
2381fn run_out(cmd: &mut Command, deadline: Duration) -> Result<Output, String> {
2382    let mut child = cmd
2383        .stdin(Stdio::null())
2384        .stdout(Stdio::piped())
2385        .stderr(Stdio::piped())
2386        .spawn()
2387        .map_err(|e| e.to_string())?;
2388    let mut stdout = child.stdout.take().ok_or("gh returned no output")?;
2389    let mut stderr = child.stderr.take().ok_or("gh returned no errors")?;
2390    let out_thread = std::thread::spawn(move || {
2391        let mut buf = Vec::new();
2392        let _ = std::io::Read::read_to_end(&mut stdout, &mut buf);
2393        buf
2394    });
2395    let err_thread = std::thread::spawn(move || {
2396        let mut buf = Vec::new();
2397        let _ = std::io::Read::read_to_end(&mut stderr, &mut buf);
2398        buf
2399    });
2400    let start = std::time::Instant::now();
2401    let status = loop {
2402        match child.try_wait() {
2403            Ok(Some(status)) => break status,
2404            Ok(None) if start.elapsed() < deadline => {
2405                std::thread::sleep(Duration::from_millis(10));
2406            }
2407            Ok(None) => {
2408                let _ = child.kill();
2409                let _ = child.wait();
2410                let _ = out_thread.join();
2411                let _ = err_thread.join();
2412                return Err("did not answer in time".into());
2413            }
2414            Err(e) => return Err(e.to_string()),
2415        }
2416    };
2417    let stdout = out_thread.join().unwrap_or_default();
2418    let stderr = err_thread.join().unwrap_or_default();
2419    Ok(Output {
2420        status,
2421        stdout,
2422        stderr,
2423    })
2424}
2425
2426fn remote_hidden(line: &str) -> Option<super::Rule> {
2427    remote_hidden_at(line, 0)
2428}
2429
2430fn remote_hidden_at(line: &str, depth: usize) -> Option<super::Rule> {
2431    if depth > 4 {
2432        return Some(deny(
2433            "remote-gate",
2434            "git-remote-hidden",
2435            "Run the git command in this checkout.",
2436        ));
2437    }
2438    for sub in super::nested_lines(line, 0) {
2439        for seg in super::raw_segments(&sub) {
2440            let words = super::shell_words(&seg);
2441            let (at, _) = super::prefix_end(&words);
2442            if at >= words.len() {
2443                continue;
2444            }
2445            let cmd = &words[at..];
2446            if super::base_name(&cmd[0]) != "ssh" {
2447                continue;
2448            }
2449            let refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
2450            let Some(remote) = super::ssh_remote_command(&refs) else {
2451                continue;
2452            };
2453            if gate_command(&remote) || remote_hidden_at(&remote, depth + 1).is_some() {
2454                return Some(deny(
2455                    "remote-gate",
2456                    "git-remote-hidden",
2457                    "Run the commit or the merge in this checkout, where the gate can read it.",
2458                ));
2459            }
2460        }
2461    }
2462    None
2463}
2464
2465fn gate_command(line: &str) -> bool {
2466    for inv in invocations(line, Path::new("."), 0) {
2467        if let Some(git) = git_of(&inv) {
2468            if git.shell.as_deref().is_some_and(gate_command) {
2469                return true;
2470            }
2471            // A push and a local merge are not held, so running them
2472            // elsewhere hides nothing. A commit's message and a forge
2473            // merge are.
2474            if git.sub == "commit" {
2475                return true;
2476            }
2477        }
2478        if api_merge(&inv.words).is_some() {
2479            return true;
2480        }
2481    }
2482    false
2483}
2484
2485fn invocations(line: &str, base: &Path, depth: u8) -> Vec<Inv> {
2486    let mut cwd = base.to_path_buf();
2487    let mut out = Vec::new();
2488    for sub in super::nested_lines(line, 0) {
2489        for seg in super::raw_segments(&sub) {
2490            let words = super::shell_words(&seg);
2491            if words.is_empty() {
2492                continue;
2493            }
2494            let (at, _) = super::prefix_end(&words);
2495            if at >= words.len() {
2496                continue;
2497            }
2498            let env = assignments(&words[..at]);
2499            let cmd = words[at..].to_vec();
2500            if cmd.is_empty() {
2501                continue;
2502            }
2503            if super::base_name(&cmd[0]) == "cd" {
2504                if let Some(d) = cmd.get(1) {
2505                    if d != "-" && !d.starts_with('-') {
2506                        cwd = join_dir(&cwd, d);
2507                    }
2508                }
2509                continue;
2510            }
2511            out.push(Inv {
2512                cwd: cwd.clone(),
2513                env,
2514                words: cmd,
2515                depth,
2516            });
2517        }
2518    }
2519    out
2520}
2521
2522fn assignments(words: &[String]) -> Vec<(String, String)> {
2523    words
2524        .iter()
2525        .filter_map(|w| {
2526            let (k, v) = w.split_once('=')?;
2527            if k.is_empty()
2528                || k.starts_with('-')
2529                || !k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
2530            {
2531                return None;
2532            }
2533            Some((k.to_string(), v.to_string()))
2534        })
2535        .collect()
2536}
2537
2538fn env_val(env: &[(String, String)], key: &str) -> Option<String> {
2539    env.iter()
2540        .rev()
2541        .find(|(k, _)| k == key)
2542        .map(|(_, v)| v.clone())
2543        .or_else(|| std::env::var(key).ok())
2544}
2545
2546fn join_dir(base: &Path, dir: &str) -> PathBuf {
2547    let path = Path::new(dir);
2548    if path.is_absolute() {
2549        path.to_path_buf()
2550    } else {
2551        base.join(path)
2552    }
2553}
2554
2555fn parse_git(inv: &Inv) -> Option<GitCmd> {
2556    if super::base_name(&inv.words[0]) != "git" {
2557        return None;
2558    }
2559    let mut i = 1;
2560    let mut cwd = inv.cwd.clone();
2561    let mut config = Vec::new();
2562    while i < inv.words.len() {
2563        let w = inv.words[i].as_str();
2564        if w == "--" {
2565            i += 1;
2566            break;
2567        }
2568        if w == "-C" {
2569            if let Some(d) = inv.words.get(i + 1) {
2570                cwd = join_dir(&cwd, d);
2571            }
2572            i += 2;
2573            continue;
2574        }
2575        if w == "-c" {
2576            if let Some((k, v)) = inv.words.get(i + 1).and_then(|kv| kv.split_once('=')) {
2577                config.push((k.to_string(), v.to_string()));
2578            }
2579            i += 2;
2580            continue;
2581        }
2582        if let Some((k, v)) = w
2583            .strip_prefix("-c")
2584            .filter(|s| !s.is_empty() && s.contains('='))
2585            .and_then(|kv| kv.split_once('='))
2586        {
2587            config.push((k.to_string(), v.to_string()));
2588            i += 1;
2589            continue;
2590        }
2591        if matches!(
2592            w,
2593            "--git-dir"
2594                | "--work-tree"
2595                | "--namespace"
2596                | "--config-env"
2597                | "--exec-path"
2598                | "--super-prefix"
2599        ) {
2600            i += 2;
2601            continue;
2602        }
2603        if w.starts_with("--git-dir=")
2604            || w.starts_with("--work-tree=")
2605            || w.starts_with("--namespace=")
2606            || w.starts_with("--config-env=")
2607            || w.starts_with("--exec-path=")
2608            || w.starts_with("--super-prefix=")
2609        {
2610            i += 1;
2611            continue;
2612        }
2613        if w.starts_with('-') {
2614            i += 1;
2615            continue;
2616        }
2617        break;
2618    }
2619    let sub = inv.words.get(i)?.clone();
2620    let args = inv.words.get(i + 1..).unwrap_or(&[]).to_vec();
2621    Some(GitCmd {
2622        cwd,
2623        config,
2624        sub,
2625        args,
2626        shell: None,
2627    })
2628}
2629
2630fn git_of(inv: &Inv) -> Option<GitCmd> {
2631    let mut git = parse_git(inv)?;
2632    let mut seen = std::collections::HashSet::new();
2633    while let Some(value) = alias_of(&git) {
2634        if !seen.insert(git.sub.clone()) {
2635            break;
2636        }
2637        if let Some(shell) = value.strip_prefix('!') {
2638            git.shell = Some(shell.trim().to_string());
2639            break;
2640        }
2641        let mut parts: Vec<String> = value.split_whitespace().map(str::to_string).collect();
2642        if parts.is_empty() {
2643            break;
2644        }
2645        git.sub = parts.remove(0);
2646        parts.append(&mut git.args);
2647        git.args = parts;
2648    }
2649    Some(git)
2650}
2651
2652fn alias_of(git: &GitCmd) -> Option<String> {
2653    if matches!(
2654        git.sub.as_str(),
2655        "commit"
2656            | "push"
2657            | "merge"
2658            | "checkout"
2659            | "switch"
2660            | "status"
2661            | "diff"
2662            | "add"
2663            | "log"
2664            | "show"
2665            | "fetch"
2666            | "pull"
2667            | "rebase"
2668            | "stash"
2669            | "branch"
2670            | "tag"
2671            | "remote"
2672            | "init"
2673            | "clone"
2674            | "restore"
2675            | "reset"
2676            | "clean"
2677            | "rev-parse"
2678            | "config"
2679            | "help"
2680            | "version"
2681            | "blame"
2682            | "grep"
2683            | "mv"
2684            | "rm"
2685            | "apply"
2686            | "cherry-pick"
2687            | "revert"
2688            | "bisect"
2689            | "reflog"
2690    ) {
2691        return None;
2692    }
2693    let key = format!("alias.{}", git.sub);
2694    if let Some((_, value)) = git.config.iter().rev().find(|(k, _)| k == &key) {
2695        return Some(value.clone());
2696    }
2697    git_config(&git.cwd, &key)
2698}
2699
2700#[cfg(test)]
2701mod tests {
2702    use super::*;
2703
2704    const AUTHOR_NAME: &str = "Ada Lovelace";
2705    const AUTHOR_EMAIL: &str = "ada@example.com";
2706
2707    fn git(dir: &Path, args: &[&str]) {
2708        let output = Command::new("git")
2709            .arg("-C")
2710            .arg(dir)
2711            .args(args)
2712            .env("GIT_AUTHOR_NAME", AUTHOR_NAME)
2713            .env("GIT_AUTHOR_EMAIL", AUTHOR_EMAIL)
2714            .env("GIT_COMMITTER_NAME", AUTHOR_NAME)
2715            .env("GIT_COMMITTER_EMAIL", AUTHOR_EMAIL)
2716            .output()
2717            .unwrap();
2718        assert!(
2719            output.status.success(),
2720            "git {args:?}: {}",
2721            String::from_utf8_lossy(&output.stderr)
2722        );
2723    }
2724
2725    /// The checkout, and the environment lock. Gate tests read the process
2726    /// home: a temp home suspends the guard. Another test sets that home
2727    /// for its own case, so this lock is held until the checkout is dropped.
2728    struct Held {
2729        _env: crate::EnvLock,
2730        dir: tempfile::TempDir,
2731    }
2732
2733    impl std::ops::Deref for Held {
2734        type Target = tempfile::TempDir;
2735
2736        fn deref(&self) -> &Self::Target {
2737            &self.dir
2738        }
2739    }
2740
2741    fn repo() -> Held {
2742        let env = crate::test_env_lock();
2743        let dir = tempfile::tempdir().unwrap();
2744        git(dir.path(), &["init", "-q", "-b", "main"]);
2745        git(dir.path(), &["config", "user.name", AUTHOR_NAME]);
2746        git(dir.path(), &["config", "user.email", AUTHOR_EMAIL]);
2747        git(dir.path(), &["config", "commit.gpgsign", "false"]);
2748        git(
2749            dir.path(),
2750            &[
2751                "remote",
2752                "add",
2753                "origin",
2754                "https://example.com/acme/widgets.git",
2755            ],
2756        );
2757        Held { _env: env, dir }
2758    }
2759
2760    fn seed(dir: &Path) {
2761        std::fs::write(dir.join("f.txt"), "one\n").unwrap();
2762        git(dir, &["add", "f.txt"]);
2763        git(dir, &["commit", "-q", "-m", "note the seat"]);
2764    }
2765
2766    fn gates(dir: &Path, body: &str) {
2767        std::fs::create_dir_all(dir.join(".ljos")).unwrap();
2768        std::fs::write(dir.join(".ljos").join("gates.toml"), body).unwrap();
2769    }
2770
2771    fn tip(dir: &Path) -> String {
2772        head_sha(dir).unwrap()
2773    }
2774
2775    #[test]
2776    fn git_calls_follow_dirs_shells_and_aliases() {
2777        let dir = repo();
2778        let root = dir.path();
2779        std::fs::create_dir_all(root.join("sub")).unwrap();
2780        git(root, &["config", "alias.ci", "commit -q"]);
2781        git(
2782            root,
2783            &["config", "alias.ship", "!git add . && git push origin HEAD"],
2784        );
2785        let calls = git_calls(
2786            "cd sub && git -C .. ci -m x; sh -c 'git ship'; echo $(git cherry-pick abc)",
2787            Some(root),
2788        )
2789        .unwrap();
2790        let subs: Vec<&str> = calls.iter().map(|c| c.sub.as_str()).collect();
2791        assert_eq!(subs, ["commit", "add", "push", "cherry-pick"], "{calls:?}");
2792        assert_eq!(calls[0].args, ["-q", "-m", "x"]);
2793        assert_eq!(calls[0].cwd, root.join("sub").join(".."));
2794        assert_eq!(calls[2].args, ["origin", "HEAD"]);
2795        assert!(git_calls("ls -la", Some(root)).unwrap().is_empty());
2796
2797        for (name, next) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")] {
2798            git(
2799                root,
2800                &["config", &format!("alias.{name}"), &format!("!git {next}")],
2801            );
2802        }
2803        let deep = git_calls("git a", Some(root)).unwrap_err();
2804        assert!(deep.contains("too deep"), "{deep}");
2805    }
2806
2807    struct GhHold {
2808        _dir: tempfile::TempDir,
2809    }
2810
2811    impl Drop for GhHold {
2812        fn drop(&mut self) {
2813            GH_BIN.with(|g| *g.borrow_mut() = None);
2814        }
2815    }
2816
2817    fn stub_gh(view: &str, threads: &str, list: &str) -> GhHold {
2818        stub_gh_at(view, threads, list, false)
2819    }
2820
2821    fn stub_gh_unreadable() -> GhHold {
2822        stub_gh_at("", "", "", true)
2823    }
2824
2825    fn stub_gh_at(view: &str, threads: &str, list: &str, fail: bool) -> GhHold {
2826        let dir = tempfile::tempdir().unwrap();
2827        std::fs::write(dir.path().join("view.json"), view).unwrap();
2828        std::fs::write(dir.path().join("threads.json"), threads).unwrap();
2829        std::fs::write(dir.path().join("list.json"), list).unwrap();
2830        if fail {
2831            std::fs::write(dir.path().join("fail"), "1").unwrap();
2832        }
2833        hold_gh(dir)
2834    }
2835
2836    fn hold_gh(dir: tempfile::TempDir) -> GhHold {
2837        let script = r#"#!/bin/sh
2838here=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
2839if [ -f "$here/fail" ]; then
2840  echo "HTTP 401: Bad credentials" >&2
2841  exit 1
2842fi
2843has_rollup=0
2844for a in "$@"; do
2845  case "$a" in
2846    *statusCheckRollup*) has_rollup=1 ;;
2847  esac
2848done
2849if [ "$has_rollup" -eq 1 ] && [ -f "$here/rollup-error" ]; then
2850  cat "$here/rollup-error" >&2
2851  exit 1
2852fi
2853if [ "$has_rollup" -eq 1 ] && [ -f "$here/rollup-error-out" ]; then
2854  cat "$here/rollup-error-out"
2855  exit 1
2856fi
2857case "$*" in
2858  *graphql*) cat "$here/threads.json" ;;
2859  *"pr list"*) cat "$here/list.json" ;;
2860  *"pr view"*)
2861    if [ "$has_rollup" -eq 0 ] && [ -f "$here/meta-error" ]; then
2862      cat "$here/meta-error" >&2
2863      exit 1
2864    fi
2865    cat "$here/view.json"
2866    ;;
2867  *actions/runs*)
2868    if [ -f "$here/runs-error" ]; then
2869      cat "$here/runs-error" >&2
2870      exit 1
2871    fi
2872    case "$*" in
2873      *repos/acme/widgets/actions/runs*) ;;
2874      *) echo "workflow runs were not read for the pull request repository" >&2; exit 1 ;;
2875    esac
2876    if [ -f "$here/want-sha" ]; then
2877      want=$(cat "$here/want-sha")
2878      found=0
2879      for a in "$@"; do
2880        case "$a" in
2881          "head_sha=$want") found=1 ;;
2882        esac
2883      done
2884      if [ "$found" -eq 0 ]; then
2885        echo "workflow runs were not read for the head sha" >&2
2886        exit 1
2887      fi
2888    fi
2889    page=1
2890    for a in "$@"; do
2891      case "$a" in
2892        page=*) page=${a#page=} ;;
2893      esac
2894    done
2895    if [ -f "$here/runs-$page.json" ]; then
2896      cat "$here/runs-$page.json"
2897    else
2898      echo "workflow run page $page failed" >&2
2899      exit 1
2900    fi
2901    ;;
2902  *commits/*/status*)
2903    if [ -f "$here/status-error" ]; then
2904      cat "$here/status-error" >&2
2905      exit 1
2906    fi
2907    if [ -f "$here/want-sha" ]; then
2908      want=$(cat "$here/want-sha")
2909      case "$*" in
2910        *"commits/$want/status"*) ;;
2911        *) echo "commit status was not read for the head sha" >&2; exit 1 ;;
2912      esac
2913    fi
2914    case "$*" in
2915      *repos/acme/widgets/commits/*) ;;
2916      *) echo "commit status was not read for the pull request repository" >&2; exit 1 ;;
2917    esac
2918    cat "$here/status.json"
2919    ;;
2920  *) echo "stub gh: $*" >&2; exit 1 ;;
2921esac
2922"#;
2923        let bin = dir.path().join("gh");
2924        std::fs::write(&bin, script).unwrap();
2925        use std::os::unix::fs::PermissionsExt;
2926        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
2927        GH_BIN.with(|g| *g.borrow_mut() = Some(bin));
2928        GhHold { _dir: dir }
2929    }
2930
2931    fn green_view(sha: &str) -> String {
2932        serde_json::json!({
2933            "number": 7,
2934            "headRefOid": sha,
2935            "state": "OPEN",
2936            "url": "https://github.com/acme/widgets/pull/7",
2937            "statusCheckRollup": [{
2938                "name": "ci",
2939                "status": "COMPLETED",
2940                "conclusion": "SUCCESS"
2941            }]
2942        })
2943        .to_string()
2944    }
2945
2946    fn red_view(sha: &str) -> String {
2947        serde_json::json!({
2948            "number": 7,
2949            "headRefOid": sha,
2950            "state": "OPEN",
2951            "url": "https://github.com/acme/widgets/pull/7",
2952            "statusCheckRollup": [{
2953                "name": "ci",
2954                "status": "COMPLETED",
2955                "conclusion": "FAILURE"
2956            }]
2957        })
2958        .to_string()
2959    }
2960
2961    fn rollup_view(sha: &str, rollup: serde_json::Value) -> String {
2962        serde_json::json!({
2963            "number": 7,
2964            "headRefOid": sha,
2965            "state": "OPEN",
2966            "url": "https://github.com/acme/widgets/pull/7",
2967            "statusCheckRollup": rollup,
2968        })
2969        .to_string()
2970    }
2971
2972    fn pending_view(sha: &str) -> String {
2973        rollup_view(
2974            sha,
2975            serde_json::json!([{
2976                "name": "ci",
2977                "status": "IN_PROGRESS",
2978                "conclusion": null
2979            }]),
2980        )
2981    }
2982
2983    const ROLLUP_DENIED: &str = "GraphQL: Resource not accessible by personal access token (repository.pullRequest.statusCheckRollup)\n";
2984
2985    struct RestGh {
2986        sha: String,
2987        threads: String,
2988        list: String,
2989        pages: Vec<String>,
2990        status: String,
2991        rollup_error: Option<String>,
2992        rollup_stdout: Option<String>,
2993        meta_error: Option<String>,
2994        runs_error: Option<String>,
2995        status_error: Option<String>,
2996        view: Option<String>,
2997    }
2998
2999    fn rest_gh(sha: &str, pages: Vec<String>, status: String) -> RestGh {
3000        RestGh {
3001            sha: sha.to_string(),
3002            threads: quiet_threads(),
3003            list: "[]".to_string(),
3004            pages,
3005            status,
3006            rollup_error: Some(ROLLUP_DENIED.to_string()),
3007            rollup_stdout: None,
3008            meta_error: None,
3009            runs_error: None,
3010            status_error: None,
3011            view: None,
3012        }
3013    }
3014
3015    fn stub_rest(spec: &RestGh) -> GhHold {
3016        let dir = tempfile::tempdir().unwrap();
3017        let view = spec.view.clone().unwrap_or_else(|| meta_view(&spec.sha));
3018        let root = dir.path();
3019        std::fs::write(root.join("view.json"), view).unwrap();
3020        std::fs::write(root.join("threads.json"), &spec.threads).unwrap();
3021        std::fs::write(root.join("list.json"), &spec.list).unwrap();
3022        std::fs::write(root.join("status.json"), &spec.status).unwrap();
3023        std::fs::write(root.join("want-sha"), &spec.sha).unwrap();
3024        for (i, page) in spec.pages.iter().enumerate() {
3025            std::fs::write(root.join(format!("runs-{}.json", i + 1)), page).unwrap();
3026        }
3027        if let Some(text) = &spec.rollup_error {
3028            std::fs::write(root.join("rollup-error"), text).unwrap();
3029        }
3030        if let Some(text) = &spec.rollup_stdout {
3031            std::fs::write(root.join("rollup-error-out"), text).unwrap();
3032        }
3033        if let Some(text) = &spec.meta_error {
3034            std::fs::write(root.join("meta-error"), text).unwrap();
3035        }
3036        if let Some(text) = &spec.runs_error {
3037            std::fs::write(root.join("runs-error"), text).unwrap();
3038        }
3039        if let Some(text) = &spec.status_error {
3040            std::fs::write(root.join("status-error"), text).unwrap();
3041        }
3042        hold_gh(dir)
3043    }
3044
3045    fn meta_view(sha: &str) -> String {
3046        serde_json::json!({
3047            "number": 7,
3048            "headRefOid": sha,
3049            "state": "OPEN",
3050            "url": "https://github.com/acme/widgets/pull/7",
3051        })
3052        .to_string()
3053    }
3054
3055    fn workflow(name: &str, status: &str, conclusion: Option<&str>) -> serde_json::Value {
3056        serde_json::json!({
3057            "name": name,
3058            "status": status,
3059            "conclusion": conclusion,
3060        })
3061    }
3062
3063    fn run_page(runs: serde_json::Value) -> String {
3064        let n = runs.as_array().map(|a| a.len()).unwrap_or(0);
3065        run_page_total(n as u64, runs)
3066    }
3067
3068    fn run_page_total(total: u64, runs: serde_json::Value) -> String {
3069        serde_json::json!({
3070            "total_count": total,
3071            "workflow_runs": runs,
3072        })
3073        .to_string()
3074    }
3075
3076    fn no_runs() -> String {
3077        run_page(serde_json::json!([]))
3078    }
3079
3080    fn commit_status_body(state: &str, statuses: serde_json::Value) -> String {
3081        let n = statuses.as_array().map(|a| a.len()).unwrap_or(0);
3082        serde_json::json!({
3083            "state": state,
3084            "statuses": statuses,
3085            "total_count": n,
3086        })
3087        .to_string()
3088    }
3089
3090    fn no_statuses() -> String {
3091        commit_status_body("pending", serde_json::json!([]))
3092    }
3093
3094    fn success_status() -> String {
3095        commit_status_body(
3096            "success",
3097            serde_json::json!([{"context": "ci", "state": "success"}]),
3098        )
3099    }
3100
3101    fn status_state(state: &str) -> String {
3102        commit_status_body(
3103            state,
3104            serde_json::json!([{"context": "ci", "state": state}]),
3105        )
3106    }
3107
3108    fn green_runs() -> String {
3109        run_page(serde_json::json!([
3110            workflow("unit", "completed", Some("success")),
3111            workflow("docs", "completed", Some("neutral")),
3112            workflow("nightly", "completed", Some("skipped")),
3113        ]))
3114    }
3115
3116    fn merge_rule(dir: &Path) -> super::super::Rule {
3117        guard_verdict("gh pr merge 7", Some(dir), &[]).expect("the merge gate spoke")
3118    }
3119
3120    fn assert_rest_deny(rule: &super::super::Rule, needle: &str) {
3121        assert_eq!(rule.verdict, "deny", "{}", rule.reason);
3122        assert_eq!(rule.pattern, "merge-gate");
3123        assert!(rule.reason.contains(needle), "{}", rule.reason);
3124        assert!(
3125            rule.reason.contains("Source: REST fallback"),
3126            "{}",
3127            rule.reason
3128        );
3129        assert!(!rule.reason.contains("no CI"), "{}", rule.reason);
3130        assert!(
3131            !rule.reason.contains("could not read the pull request"),
3132            "{}",
3133            rule.reason
3134        );
3135    }
3136
3137    fn assert_unreadable(rule: &super::super::Rule) {
3138        assert_eq!(rule.verdict, "deny", "{}", rule.reason);
3139        assert_eq!(rule.pattern, "merge-gate");
3140        assert!(
3141            rule.reason.contains("could not read the pull request"),
3142            "{}",
3143            rule.reason
3144        );
3145        assert!(rule.reason.contains("gh auth login"), "{}", rule.reason);
3146        assert!(!rule.reason.contains("no CI"), "{}", rule.reason);
3147        assert!(!rule.reason.contains("CI is missing"), "{}", rule.reason);
3148        assert!(
3149            !rule.reason.contains("Source: REST fallback"),
3150            "{}",
3151            rule.reason
3152        );
3153        assert!(!rule.reason.contains("Source: rollup"), "{}", rule.reason);
3154    }
3155
3156    fn assert_rest_ask(rule: &super::super::Rule, sha: &str) {
3157        assert_eq!(rule.verdict, "ask", "{}", rule.reason);
3158        assert_eq!(rule.pattern, "merge-gate");
3159        assert!(rule.reason.contains("no CI ran"), "{}", rule.reason);
3160        assert!(rule.reason.contains(sha), "{}", rule.reason);
3161        assert!(
3162            rule.reason.contains("Source: REST fallback"),
3163            "{}",
3164            rule.reason
3165        );
3166    }
3167
3168    fn quiet_threads() -> String {
3169        serde_json::json!({
3170            "data": {
3171                "repository": {
3172                    "pullRequest": {
3173                        "reviewThreads": {
3174                            "pageInfo": {"hasNextPage": false},
3175                            "nodes": []
3176                        }
3177                    }
3178                }
3179            }
3180        })
3181        .to_string()
3182    }
3183
3184    fn human_thread() -> String {
3185        serde_json::json!({
3186            "data": {
3187                "repository": {
3188                    "pullRequest": {
3189                        "reviewThreads": {
3190                            "pageInfo": {"hasNextPage": false},
3191                            "nodes": [{
3192                                "isResolved": false,
3193                                "comments": {"nodes": [{
3194                                    "author": {"login": "octocat"},
3195                                    "body": "please rename this"
3196                                }]}
3197                            }]
3198                        }
3199                    }
3200                }
3201            }
3202        })
3203        .to_string()
3204    }
3205
3206    fn threads(resolved: bool, reply: bool) -> String {
3207        let mut comments = vec![serde_json::json!({
3208            "author": {"login": "cursor[bot]"},
3209            "body": "Bugbot: check the null"
3210        })];
3211        if reply {
3212            comments.push(serde_json::json!({
3213                "author": {"login": "rgoswami"},
3214                "body": "fixed"
3215            }));
3216        }
3217        serde_json::json!({
3218            "data": {
3219                "repository": {
3220                    "pullRequest": {
3221                        "reviewThreads": {
3222                            "pageInfo": {"hasNextPage": false},
3223                            "nodes": [{
3224                                "isResolved": resolved,
3225                                "comments": {"nodes": comments}
3226                            }]
3227                        }
3228                    }
3229                }
3230            }
3231        })
3232        .to_string()
3233    }
3234
3235    fn payloads(command: &str) -> Vec<(&'static str, String)> {
3236        vec![
3237            (
3238                "claude",
3239                serde_json::json!({
3240                    "hook_event_name": "PreToolUse",
3241                    "tool_name": "Bash",
3242                    "tool_input": {"command": command}
3243                })
3244                .to_string(),
3245            ),
3246            (
3247                "codex",
3248                serde_json::json!({
3249                    "hook_event_name": "PreToolUse",
3250                    "session_id": "c-1",
3251                    "turn_id": "t-1",
3252                    "tool_name": "Bash",
3253                    "tool_input": {"command": command}
3254                })
3255                .to_string(),
3256            ),
3257            (
3258                "opencode",
3259                serde_json::json!({
3260                    "hook_event_name": "pre_tool_call",
3261                    "tool_name": "bash",
3262                    "tool_input": {"command": command},
3263                    "session_id": "h-1",
3264                    "extra": {}
3265                })
3266                .to_string(),
3267            ),
3268            (
3269                "agy",
3270                serde_json::json!({
3271                    "conversationId": "agy-1",
3272                    "toolCall": {"name": "run_command", "args": {"CommandLine": command}}
3273                })
3274                .to_string(),
3275            ),
3276            (
3277                "cursor-shell",
3278                serde_json::json!({
3279                    "cursor_version": "2.4.0",
3280                    "conversation_id": "c-1",
3281                    "hook_event_name": "beforeShellExecution",
3282                    "command": command
3283                })
3284                .to_string(),
3285            ),
3286            (
3287                "cursor-tool",
3288                serde_json::json!({
3289                    "cursor_version": "2.4.0",
3290                    "conversation_id": "c-1",
3291                    "hook_event_name": "preToolUse",
3292                    "tool_name": "Shell",
3293                    "tool_input": {"command": command}
3294                })
3295                .to_string(),
3296            ),
3297        ]
3298    }
3299
3300    fn answers(command: &str, cwd: &Path, rules: &[super::super::Rule]) -> Vec<(String, String)> {
3301        payloads(command)
3302            .into_iter()
3303            .map(|(name, input)| {
3304                let call = super::super::hook_call(&input);
3305                assert_eq!(call.event, "PreToolUse", "{name}");
3306                assert_eq!(call.cue, command, "{name} cue {}", call.cue);
3307                let verdict = guard_verdict(&call.cue, Some(cwd), rules);
3308                let answer = super::super::hook_output_ruled(&call, "", verdict.as_ref());
3309                (name.to_string(), answer)
3310            })
3311            .collect()
3312    }
3313
3314    fn refuses(answer: &str) -> bool {
3315        if super::super::answer_denies(answer) {
3316            return true;
3317        }
3318        // opencode's only refusal is `decision: block`.
3319        serde_json::from_str::<serde_json::Value>(answer.trim())
3320            .ok()
3321            .is_some_and(|v| v.get("decision").and_then(serde_json::Value::as_str) == Some("block"))
3322    }
3323
3324    fn assert_denies(rows: &[(String, String)], needle: &str) {
3325        for (name, answer) in rows {
3326            assert!(refuses(answer), "{name} allowed: {answer}");
3327            assert!(answer.contains(needle), "{name} missing {needle}: {answer}");
3328        }
3329    }
3330
3331    fn assert_allows(rows: &[(String, String)]) {
3332        for (name, answer) in rows {
3333            assert!(
3334                !super::super::answer_denies(answer),
3335                "{name} refused: {answer}"
3336            );
3337        }
3338    }
3339
3340    #[test]
3341    fn ordinary_commands_are_not_blocked() {
3342        let dir = repo();
3343        for line in ["ls", "cargo test --workspace", "git status", "git diff"] {
3344            let rows = answers(line, dir.path(), &[]);
3345            assert_allows(&rows);
3346            assert!(
3347                guard_verdict(line, Some(dir.path()), &[]).is_none(),
3348                "{line}"
3349            );
3350        }
3351    }
3352
3353    #[test]
3354    fn a_clean_commit_is_allowed_and_a_bad_one_is_refused_on_every_harness() {
3355        let dir = repo();
3356        let allow = answers("git commit -m \"note the seat\"", dir.path(), &[]);
3357        assert_allows(&allow);
3358        let refuse = answers(
3359            "git commit -m \"Co-authored-by: Cursor <cursoragent@cursor.com>\"",
3360            dir.path(),
3361            &[],
3362        );
3363        assert_denies(&refuse, "Co-authored-by");
3364        assert_denies(&refuse, "git commit -m TEXT");
3365    }
3366
3367    /// The detail a gate adds after the rule's reason is its own sentence,
3368    /// in the verdict and in every harness's answer.
3369    #[test]
3370    fn a_refusal_puts_a_full_stop_before_its_detail() {
3371        let dir = repo();
3372        let line = "git commit -m \"Co-authored-by: Cursor <cursoragent@cursor.com>\"";
3373        let rule = guard_verdict(line, Some(dir.path()), &[]).expect("a trailer is refused");
3374        assert!(
3375            rule.reason
3376                .contains("`git commit -m TEXT`. Remove Co-authored-by from the message"),
3377            "{}",
3378            rule.reason
3379        );
3380        assert_denies(
3381            &answers(line, dir.path(), &[]),
3382            "`git commit -m TEXT`. Remove Co-authored-by",
3383        );
3384    }
3385
3386    /// `git commit --amend --no-edit` keeps the last message, so it runs in
3387    /// a stock seat: in a repository with no commit yet (git refuses it
3388    /// there itself) and after one. New text still goes through the gate:
3389    /// `--amend -m`, `--amend --no-edit --trailer` and a bare `--amend`.
3390    #[test]
3391    fn an_amend_that_keeps_the_message_is_allowed() {
3392        let dir = repo();
3393        let cwd = dir.path();
3394        assert!(
3395            guard_verdict("git commit --amend --no-edit", Some(cwd), &[]).is_none(),
3396            "no commit yet"
3397        );
3398        seed(cwd);
3399        for line in [
3400            "git commit --amend --no-edit",
3401            "git commit --no-edit --amend",
3402            "git commit -q --amend --no-edit",
3403        ] {
3404            assert!(guard_verdict(line, Some(cwd), &[]).is_none(), "{line}");
3405        }
3406        let worded = guard_verdict(
3407            "git commit --amend -m \"Co-authored-by: Cursor <c@c>\"",
3408            Some(cwd),
3409            &[],
3410        )
3411        .expect("a new message is read");
3412        assert!(
3413            worded.reason.contains("Co-authored-by"),
3414            "{}",
3415            worded.reason
3416        );
3417        let trailer = guard_verdict(
3418            "git commit --amend --no-edit --trailer \"Co-authored-by: Cursor <c@c>\"",
3419            Some(cwd),
3420            &[],
3421        )
3422        .expect("a new trailer is read");
3423        assert!(
3424            trailer.reason.contains("Co-authored-by"),
3425            "{}",
3426            trailer.reason
3427        );
3428        let bare = guard_verdict("git commit --amend", Some(cwd), &[]).expect("an editor");
3429        assert!(
3430            bare.reason.contains("git commit -m TEXT"),
3431            "{}",
3432            bare.reason
3433        );
3434    }
3435
3436    #[test]
3437    fn commit_identity_trailers_signoff_and_wrappers_say_what_passes() {
3438        let dir = repo();
3439        let cwd = dir.path();
3440        // A runner that exports a git identity would stand in for the
3441        // checkout's own, so the cases below read only what they name. The
3442        // lock `repo` holds puts the environment back when it drops.
3443        for key in [
3444            "GIT_AUTHOR_NAME",
3445            "GIT_AUTHOR_EMAIL",
3446            "GIT_COMMITTER_NAME",
3447            "GIT_COMMITTER_EMAIL",
3448        ] {
3449            crate::with_env_mutation(|| unsafe { std::env::remove_var(key) });
3450        }
3451        let foreign = "GIT_AUTHOR_NAME=rig GIT_AUTHOR_EMAIL=rig@example.invalid GIT_COMMITTER_NAME=rig GIT_COMMITTER_EMAIL=rig@example.invalid git commit -m \"note the seat\"";
3452        assert!(
3453            guard_verdict(foreign, Some(cwd), &[]).is_none(),
3454            "no identity configured, so a foreign author is allowed"
3455        );
3456        let generated =
3457            guard_verdict("git commit -m \"Generated with Cursor\"", Some(cwd), &[]).unwrap();
3458        assert!(
3459            generated.reason.contains("Generated with"),
3460            "{}",
3461            generated.reason
3462        );
3463        let footer = guard_verdict("git commit -m \"Open in Cursor\"", Some(cwd), &[]).unwrap();
3464        assert!(
3465            footer.reason.contains("Open in Cursor"),
3466            "{}",
3467            footer.reason
3468        );
3469        let agent = guard_verdict("git commit -m \"see CURSOR_AGENT\"", Some(cwd), &[]).unwrap();
3470        assert!(agent.reason.contains("CURSOR_AGENT"), "{}", agent.reason);
3471        let trailer = guard_verdict(
3472            "git commit -m \"note the seat\" --trailer \"Co-authored-by: Cursor <c@c>\"",
3473            Some(cwd),
3474            &[],
3475        )
3476        .unwrap();
3477        assert!(
3478            trailer.reason.contains("Co-authored-by"),
3479            "{}",
3480            trailer.reason
3481        );
3482        let hidden = guard_verdict("git commit", Some(cwd), &[]).unwrap();
3483        assert!(
3484            hidden.reason.contains("git commit -m TEXT"),
3485            "{}",
3486            hidden.reason
3487        );
3488        let wrapped = guard_verdict(
3489            "sh -c \"git commit -m 'Co-authored-by: Cursor <c@c>'\"",
3490            Some(cwd),
3491            &[],
3492        )
3493        .unwrap();
3494        assert!(
3495            wrapped.reason.contains("Co-authored-by"),
3496            "{}",
3497            wrapped.reason
3498        );
3499        let alias = guard_verdict(
3500            "git -c alias.ci=commit ci -m \"note the seat\"",
3501            Some(cwd),
3502            &[],
3503        );
3504        assert!(alias.is_none(), "{alias:?}");
3505        let line = format!("Signed-off-by: {AUTHOR_NAME} <{AUTHOR_EMAIL}>");
3506        gates(
3507            cwd,
3508            &format!("name = \"{AUTHOR_NAME}\"\nemail = \"{AUTHOR_EMAIL}\"\nsignoff = true\n"),
3509        );
3510        let unsigned = guard_verdict("git commit -m \"note the seat\"", Some(cwd), &[]).unwrap();
3511        assert!(unsigned.reason.contains(&line), "{}", unsigned.reason);
3512        assert!(
3513            !unsigned.reason.contains("git config"),
3514            "{}",
3515            unsigned.reason
3516        );
3517        assert!(guard_verdict("git commit -s -m \"note the seat\"", Some(cwd), &[]).is_none());
3518        let signed = format!("git commit -m \"note the seat\n\n{line}\"");
3519        assert!(guard_verdict(&signed, Some(cwd), &[]).is_none(), "{signed}");
3520        let wrong = guard_verdict(foreign, Some(cwd), &[]).unwrap();
3521        assert!(
3522            wrong.reason.contains("rig@example.invalid"),
3523            "{}",
3524            wrong.reason
3525        );
3526        assert!(wrong.reason.contains("Author is rig"), "{}", wrong.reason);
3527        assert!(
3528            wrong.reason.contains(".ljos/gates.toml"),
3529            "{}",
3530            wrong.reason
3531        );
3532        assert!(!wrong.reason.contains("git config"), "{}", wrong.reason);
3533        assert!(!wrong.reason.contains("user.name"), "{}", wrong.reason);
3534        assert!(!wrong.reason.contains("GIT_AUTHOR"), "{}", wrong.reason);
3535    }
3536
3537    #[test]
3538    fn evidence_is_stored_only_for_the_configured_command_and_a_passing_run() {
3539        let dir = repo();
3540        seed(dir.path());
3541        gates(dir.path(), "test = \"true\"\n");
3542        let mismatch = record_in(dir.path(), &["false".into()]).unwrap_err();
3543        assert!(
3544            mismatch
3545                .to_string()
3546                .contains("ljos evidence record -- true"),
3547            "{mismatch}"
3548        );
3549        gates(dir.path(), "test = \"false\"\n");
3550        let failed = record_in(dir.path(), &["false".into()]).unwrap_err();
3551        assert!(failed.to_string().contains("exited"), "{failed}");
3552        assert!(evidence_file(dir.path(), &tip(dir.path())).is_none_or(|p| !p.is_file()));
3553        gates(dir.path(), "test = \"true\"\n");
3554        let said = record_in(dir.path(), &["true".into()]).unwrap();
3555        let sha = tip(dir.path());
3556        assert!(said.contains(&sha), "{said}");
3557        let path = evidence_file(dir.path(), &sha).unwrap();
3558        let body: Stored = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
3559        assert_eq!(body.command, "true");
3560        assert_eq!(body.exit, 0);
3561        assert_eq!(body.sha, sha);
3562        assert!(!body.ts.is_empty());
3563    }
3564
3565    #[test]
3566    fn a_plain_push_without_evidence_runs_and_says_so() {
3567        let dir = repo();
3568        seed(dir.path());
3569        gates(dir.path(), "test = \"true\"\n");
3570        let rules = vec![super::super::Rule {
3571            pattern: "git push*".into(),
3572            verdict: "ask".into(),
3573            reason: "A push needs consent.".into(),
3574        }];
3575        let _ = take_push_warning();
3576        let unrecorded = answers("git push origin main", dir.path(), &rules);
3577        assert_allows(&unrecorded);
3578        let warned = take_push_warning().expect("a missing record is a warning");
3579        assert!(warned.contains("ljos evidence record -- true"), "{warned}");
3580        let sha = tip(dir.path());
3581        assert!(
3582            warned.starts_with(&format!(
3583                "This push runs without a passing test record. Tip {sha}:"
3584            )),
3585            "the warning names the missing record, then the tip: {warned}"
3586        );
3587        assert!(
3588            !warned.contains("could not read this command"),
3589            "the gate read this push: {warned}"
3590        );
3591        record_in(dir.path(), &["true".into()]).unwrap();
3592        let allowed = answers("git push origin main", dir.path(), &rules);
3593        assert_allows(&allowed);
3594        assert!(guard_verdict("git push origin main", Some(dir.path()), &rules).is_none());
3595        assert!(
3596            take_push_warning().is_none(),
3597            "a recorded tip has nothing to say"
3598        );
3599        let forced = guard_verdict("git push --force origin main", Some(dir.path()), &rules)
3600            .expect("force still asks");
3601        assert_eq!(forced.verdict, "ask");
3602        let claude = answers("git push --force origin main", dir.path(), &rules);
3603        let shell = claude.iter().find(|(n, _)| n == "cursor-shell").unwrap();
3604        assert!(shell.1.contains("\"permission\":\"ask\""), "{}", shell.1);
3605        std::fs::write(dir.path().join("g.txt"), "two\n").unwrap();
3606        git(dir.path(), &["add", "g.txt"]);
3607        let fresh = guard_verdict(
3608            "git commit -m \"note the seat\" && git push origin main",
3609            Some(dir.path()),
3610            &[],
3611        );
3612        assert!(
3613            fresh.is_none(),
3614            "commit and push on one line runs: {fresh:?}"
3615        );
3616        let said = take_push_warning().expect("the new commit has no record");
3617        assert!(said.contains("new commit"), "{said}");
3618        let remote = guard_verdict("ssh host 'git push origin main'", Some(dir.path()), &[]);
3619        assert!(
3620            remote.is_none(),
3621            "a push run elsewhere is a push: {remote:?}"
3622        );
3623        let commit = guard_verdict(
3624            "ssh host 'git commit -m \"Co-authored-by: Cursor <c@c>\"'",
3625            Some(dir.path()),
3626            &[],
3627        )
3628        .expect("a commit run elsewhere hides its message");
3629        assert!(
3630            commit.reason.contains("git-remote-hidden"),
3631            "{}",
3632            commit.reason
3633        );
3634    }
3635
3636    #[test]
3637    fn a_broken_gate_file_warns_and_the_push_runs() {
3638        let dir = repo();
3639        seed(dir.path());
3640        gates(dir.path(), "test = [\n");
3641        let _ = take_push_warning();
3642        assert!(guard_verdict("git push origin main", Some(dir.path()), &[]).is_none());
3643        let said = take_push_warning().expect("the parse failure is a warning");
3644        assert!(said.contains("gates.toml"), "{said}");
3645    }
3646
3647    #[test]
3648    fn a_merge_is_allowed_only_when_ci_is_green_and_bugbot_is_answered() {
3649        let dir = repo();
3650        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3651        let _hold = stub_gh(&green_view(sha), &threads(true, false), "[]");
3652        let allowed = answers("gh pr merge 7", dir.path(), &[]);
3653        assert_allows(&allowed);
3654        drop(_hold);
3655
3656        let _hold = stub_gh(&red_view(sha), &threads(true, false), "[]");
3657        let refused = answers("gh pr merge 7", dir.path(), &[]);
3658        assert_denies(&refused, "ci=FAILURE");
3659        assert_denies(&refused, "gh pr checks 7");
3660        drop(_hold);
3661
3662        let _hold = stub_gh(&green_view(sha), &threads(false, false), "[]");
3663        let open = guard_verdict("gh pr merge 7", Some(dir.path()), &[]).unwrap();
3664        assert_eq!(open.verdict, "deny");
3665        assert!(open.reason.contains("Bugbot"), "{}", open.reason);
3666        drop(_hold);
3667
3668        let _hold = stub_gh(&green_view(sha), &threads(false, true), "[]");
3669        assert!(guard_verdict("gh pr merge 7", Some(dir.path()), &[]).is_none());
3670        let api = "curl -X PUT https://api.github.com/repos/acme/widgets/pulls/7/merge";
3671        assert!(guard_verdict(api, Some(dir.path()), &[]).is_none(), "{api}");
3672    }
3673
3674    /// A stub `gh` that fails every call: a push that reached for the
3675    /// forge would be refused with its error.
3676    fn silent_gh() -> GhHold {
3677        stub_gh("not json", "not json", "not json")
3678    }
3679
3680    #[test]
3681    fn a_pending_check_on_a_merge_is_refused() {
3682        let dir = repo();
3683        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3684        let _hold = stub_gh(&pending_view(sha), &quiet_threads(), "[]");
3685        let pending = guard_verdict("gh pr merge 7", Some(dir.path()), &[]).unwrap();
3686        assert_eq!(pending.verdict, "deny");
3687        assert!(
3688            pending.reason.contains("ci=IN_PROGRESS"),
3689            "{}",
3690            pending.reason
3691        );
3692        assert!(
3693            pending.reason.contains("git-merge-unready"),
3694            "{}",
3695            pending.reason
3696        );
3697        assert_denies(&answers("gh pr merge 7", dir.path(), &[]), "ci=IN_PROGRESS");
3698        drop(_hold);
3699
3700        let state = rollup_view(
3701            sha,
3702            serde_json::json!([{
3703                "context": "build",
3704                "state": "PENDING"
3705            }]),
3706        );
3707        let _hold = stub_gh(&state, &quiet_threads(), "[]");
3708        let held = guard_verdict("gh pr merge 7", Some(dir.path()), &[]).unwrap();
3709        assert_eq!(held.verdict, "deny");
3710        assert!(held.reason.contains("build=PENDING"), "{}", held.reason);
3711    }
3712
3713    #[test]
3714    fn a_head_sha_with_no_ci_asks_and_names_local_evidence() {
3715        let dir = repo();
3716        seed(dir.path());
3717        let sha = tip(dir.path());
3718        for rollup in [serde_json::Value::Null, serde_json::json!([])] {
3719            let _hold = stub_gh(&rollup_view(&sha, rollup), &quiet_threads(), "[]");
3720            let asked =
3721                guard_verdict("gh pr merge 7", Some(dir.path()), &[]).expect("no checks asks");
3722            assert_eq!(asked.verdict, "ask", "{}", asked.reason);
3723            assert_eq!(asked.pattern, "merge-gate");
3724            assert!(asked.reason.contains("no CI ran"), "{}", asked.reason);
3725            assert!(asked.reason.contains(&sha), "{}", asked.reason);
3726            assert!(!asked.reason.contains("evidence"), "{}", asked.reason);
3727            let rows = answers("gh pr merge 7", dir.path(), &[]);
3728            let shell = rows
3729                .iter()
3730                .find(|(name, _)| name == "cursor-shell")
3731                .unwrap();
3732            assert!(shell.1.contains("\"permission\":\"ask\""), "{}", shell.1);
3733            assert!(shell.1.contains("no CI ran"), "{}", shell.1);
3734            assert!(shell.1.contains(&sha), "{}", shell.1);
3735        }
3736
3737        let bad = evidence_file(dir.path(), &sha).unwrap();
3738        if let Some(parent) = bad.parent() {
3739            std::fs::create_dir_all(parent).unwrap();
3740        }
3741        std::fs::write(
3742            &bad,
3743            serde_json::json!({
3744                "command": "true",
3745                "exit": 1,
3746                "sha": sha,
3747                "ts": "2026-10-20T00:00:00Z"
3748            })
3749            .to_string(),
3750        )
3751        .unwrap();
3752        let _hold = stub_gh(
3753            &rollup_view(&sha, serde_json::Value::Null),
3754            &quiet_threads(),
3755            "[]",
3756        );
3757        let failed = guard_verdict("gh pr merge 7", Some(dir.path()), &[]).unwrap();
3758        assert_eq!(failed.verdict, "ask");
3759        assert!(!failed.reason.contains("evidence"), "{}", failed.reason);
3760        drop(_hold);
3761
3762        gates(dir.path(), "test = \"true\"\n");
3763        record_in(dir.path(), &["true".into()]).unwrap();
3764        let _hold = stub_gh(
3765            &rollup_view(&sha, serde_json::json!([])),
3766            &quiet_threads(),
3767            "[]",
3768        );
3769        let recorded = guard_verdict("gh pr merge 7", Some(dir.path()), &[]).unwrap();
3770        assert_eq!(recorded.verdict, "ask", "{}", recorded.reason);
3771        assert!(recorded.reason.contains("no CI ran"), "{}", recorded.reason);
3772        assert!(recorded.reason.contains(&sha), "{}", recorded.reason);
3773        assert!(
3774            recorded.reason.contains("local evidence record"),
3775            "{}",
3776            recorded.reason
3777        );
3778        assert!(
3779            recorded.reason.contains(&format!("exists for {sha}")),
3780            "{}",
3781            recorded.reason
3782        );
3783        drop(_hold);
3784
3785        let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
3786        let _hold = stub_gh(
3787            &rollup_view(other, serde_json::Value::Null),
3788            &quiet_threads(),
3789            "[]",
3790        );
3791        let elsewhere = guard_verdict("gh pr merge 7", Some(dir.path()), &[]).unwrap();
3792        assert_eq!(elsewhere.verdict, "ask");
3793        assert!(elsewhere.reason.contains(other), "{}", elsewhere.reason);
3794        assert!(
3795            !elsewhere.reason.contains("evidence"),
3796            "{}",
3797            elsewhere.reason
3798        );
3799        drop(_hold);
3800
3801        let _hold = stub_gh(&red_view(&sha), &quiet_threads(), "[]");
3802        let red = guard_verdict("gh pr merge 7", Some(dir.path()), &[]).unwrap();
3803        assert_eq!(red.verdict, "deny");
3804        assert!(red.reason.contains("ci=FAILURE"), "{}", red.reason);
3805        drop(_hold);
3806
3807        let _hold = stub_gh(
3808            &rollup_view(&sha, serde_json::Value::Null),
3809            &threads(false, false),
3810            "[]",
3811        );
3812        let open = guard_verdict("gh pr merge 7", Some(dir.path()), &[]).unwrap();
3813        assert_eq!(open.verdict, "deny");
3814        assert!(open.reason.contains("Bugbot"), "{}", open.reason);
3815        assert!(!open.reason.contains("no CI ran"), "{}", open.reason);
3816    }
3817
3818    #[test]
3819    fn an_unreadable_pull_request_is_refused_without_saying_ci_is_missing() {
3820        let dir = repo();
3821        let _hold = stub_gh_unreadable();
3822        let denied =
3823            guard_verdict("gh pr merge 7", Some(dir.path()), &[]).expect("unreadable pull request");
3824        assert_eq!(denied.verdict, "deny");
3825        assert_eq!(denied.pattern, "merge-gate");
3826        assert!(denied.reason.contains("gh auth login"), "{}", denied.reason);
3827        assert!(!denied.reason.contains("no CI"), "{}", denied.reason);
3828        assert!(
3829            !denied.reason.contains("CI is missing"),
3830            "{}",
3831            denied.reason
3832        );
3833        assert_denies(&answers("gh pr merge 7", dir.path(), &[]), "gh auth login");
3834    }
3835
3836    #[test]
3837    fn a_denied_check_rollup_allows_a_green_rest_fallback() {
3838        let dir = repo();
3839        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3840        let allow = |spec: &RestGh| {
3841            let _hold = stub_rest(spec);
3842            assert!(
3843                guard_verdict("gh pr merge 7", Some(dir.path()), &[]).is_none(),
3844                "green REST CI allows the merge"
3845            );
3846            let api = "curl -X PUT https://api.github.com/repos/acme/widgets/pulls/7/merge";
3847            assert!(guard_verdict(api, Some(dir.path()), &[]).is_none(), "{api}");
3848        };
3849
3850        allow(&rest_gh(sha, vec![green_runs()], no_statuses()));
3851        allow(&rest_gh(sha, vec![green_runs()], success_status()));
3852        allow(&rest_gh(sha, vec![no_runs()], success_status()));
3853        allow(&rest_gh(
3854            sha,
3855            vec![run_page(serde_json::json!([workflow(
3856                "nightly",
3857                "completed",
3858                Some("skipped"),
3859            )]))],
3860            no_statuses(),
3861        ));
3862        allow(&rest_gh(
3863            sha,
3864            vec![
3865                run_page_total(
3866                    2,
3867                    serde_json::json!([workflow("unit", "completed", Some("success"))]),
3868                ),
3869                run_page_total(
3870                    2,
3871                    serde_json::json!([workflow("docs", "completed", Some("neutral"))]),
3872                ),
3873            ],
3874            success_status(),
3875        ));
3876
3877        let mut wrapped = rest_gh(sha, vec![green_runs()], no_statuses());
3878        wrapped.rollup_error = Some(
3879            "GraphQL: Resource not accessible by\npersonal access token (repository.pullRequest.statusCheckRollup)\n"
3880                .into(),
3881        );
3882        allow(&wrapped);
3883
3884        let mut stdout = rest_gh(sha, vec![green_runs()], no_statuses());
3885        stdout.rollup_error = None;
3886        stdout.rollup_stdout = Some(ROLLUP_DENIED.into());
3887        allow(&stdout);
3888
3889        let mut bugbot = rest_gh(sha, vec![green_runs()], success_status());
3890        bugbot.threads = threads(false, false);
3891        let _hold = stub_rest(&bugbot);
3892        let open = merge_rule(dir.path());
3893        assert_eq!(open.verdict, "deny");
3894        assert!(open.reason.contains("Bugbot"), "{}", open.reason);
3895        assert!(
3896            open.reason.contains("Source: REST fallback"),
3897            "{}",
3898            open.reason
3899        );
3900        assert!(!open.reason.contains("no CI"), "{}", open.reason);
3901        drop(_hold);
3902
3903        let mut human = rest_gh(sha, vec![green_runs()], success_status());
3904        human.threads = human_thread();
3905        let _hold = stub_rest(&human);
3906        assert!(
3907            guard_verdict("gh pr merge 7", Some(dir.path()), &[]).is_none(),
3908            "a thread that is not Bugbot does not refuse the merge"
3909        );
3910    }
3911
3912    #[test]
3913    fn a_denied_check_rollup_refuses_a_bad_workflow_run() {
3914        let dir = repo();
3915        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3916        for conclusion in [
3917            "failure",
3918            "cancelled",
3919            "timed_out",
3920            "action_required",
3921            "stale",
3922        ] {
3923            let page = run_page(serde_json::json!([workflow(
3924                "ci",
3925                "completed",
3926                Some(conclusion),
3927            )]));
3928            let _hold = stub_rest(&rest_gh(sha, vec![page], success_status()));
3929            let denied = merge_rule(dir.path());
3930            assert_rest_deny(&denied, &format!("ci={conclusion}"));
3931            assert!(!denied.reason.contains("(pending)"), "{}", denied.reason);
3932        }
3933
3934        let page = run_page(serde_json::json!([workflow("ci", "completed", None)]));
3935        let _hold = stub_rest(&rest_gh(sha, vec![page], success_status()));
3936        let denied = merge_rule(dir.path());
3937        assert_rest_deny(&denied, "ci=completed");
3938        drop(_hold);
3939
3940        for status in ["queued", "in_progress", "in-progress", "waiting"] {
3941            let page = run_page(serde_json::json!([workflow("ci", status, None)]));
3942            let _hold = stub_rest(&rest_gh(sha, vec![page], success_status()));
3943            let denied = merge_rule(dir.path());
3944            assert_rest_deny(&denied, &format!("ci={status} (pending)"));
3945        }
3946
3947        let page = run_page(serde_json::json!([
3948            workflow("unit", "completed", Some("failure")),
3949            workflow("lint", "queued", None),
3950        ]));
3951        let _hold = stub_rest(&rest_gh(sha, vec![page], success_status()));
3952        let denied = merge_rule(dir.path());
3953        assert_rest_deny(&denied, "unit=failure");
3954        assert!(
3955            denied.reason.contains("lint=queued (pending)"),
3956            "{}",
3957            denied.reason
3958        );
3959        drop(_hold);
3960
3961        let pages = vec![
3962            run_page_total(
3963                2,
3964                serde_json::json!([workflow("unit", "completed", Some("success"))]),
3965            ),
3966            run_page_total(
3967                2,
3968                serde_json::json!([workflow("lint", "completed", Some("failure"))]),
3969            ),
3970        ];
3971        let _hold = stub_rest(&rest_gh(sha, pages, success_status()));
3972        let denied = merge_rule(dir.path());
3973        assert_rest_deny(&denied, "lint=failure");
3974        assert!(!denied.reason.contains("unit="), "{}", denied.reason);
3975        drop(_hold);
3976
3977        let page = run_page(serde_json::json!([workflow(
3978            "ci",
3979            "completed",
3980            Some("failure"),
3981        )]));
3982        let _hold = stub_rest(&rest_gh(sha, vec![page], success_status()));
3983        assert_denies(
3984            &answers("gh pr merge 7", dir.path(), &[]),
3985            "Source: REST fallback",
3986        );
3987        assert_denies(&answers("gh pr merge 7", dir.path(), &[]), "ci=failure");
3988    }
3989
3990    #[test]
3991    fn a_denied_check_rollup_refuses_a_bad_commit_status() {
3992        let dir = repo();
3993        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3994        for state in ["failure", "error"] {
3995            let _hold = stub_rest(&rest_gh(sha, vec![green_runs()], status_state(state)));
3996            let denied = merge_rule(dir.path());
3997            assert_rest_deny(&denied, &format!("commit status={state}"));
3998            assert!(!denied.reason.contains("(pending)"), "{}", denied.reason);
3999        }
4000        let _hold = stub_rest(&rest_gh(sha, vec![green_runs()], status_state("pending")));
4001        let denied = merge_rule(dir.path());
4002        assert_rest_deny(&denied, "commit status=pending (pending)");
4003        drop(_hold);
4004
4005        let _hold = stub_rest(&rest_gh(sha, vec![no_runs()], status_state("failure")));
4006        let denied = merge_rule(dir.path());
4007        assert_rest_deny(&denied, "commit status=failure");
4008        drop(_hold);
4009
4010        let mut missing = rest_gh(sha, vec![green_runs()], success_status());
4011        missing.status = serde_json::json!({
4012            "statuses": [{"context": "ci", "state": "success"}],
4013            "total_count": 1,
4014        })
4015        .to_string();
4016        let _hold = stub_rest(&missing);
4017        let denied = merge_rule(dir.path());
4018        assert_rest_deny(&denied, "commit status=unknown");
4019        drop(_hold);
4020
4021        let page = run_page(serde_json::json!([workflow(
4022            "unit",
4023            "completed",
4024            Some("cancelled"),
4025        )]));
4026        let _hold = stub_rest(&rest_gh(sha, vec![page], status_state("error")));
4027        let denied = merge_rule(dir.path());
4028        assert_rest_deny(&denied, "unit=cancelled");
4029        assert!(
4030            denied.reason.contains("commit status=error"),
4031            "{}",
4032            denied.reason
4033        );
4034    }
4035
4036    #[test]
4037    fn a_denied_check_rollup_with_no_runs_and_no_statuses_asks() {
4038        let dir = repo();
4039        seed(dir.path());
4040        let sha = tip(dir.path());
4041        let _hold = stub_rest(&rest_gh(&sha, vec![no_runs()], no_statuses()));
4042        let asked = merge_rule(dir.path());
4043        assert_rest_ask(&asked, &sha);
4044        assert!(!asked.reason.contains("evidence"), "{}", asked.reason);
4045        let rows = answers("gh pr merge 7", dir.path(), &[]);
4046        let shell = rows
4047            .iter()
4048            .find(|(name, _)| name == "cursor-shell")
4049            .unwrap();
4050        assert!(shell.1.contains("\"permission\":\"ask\""), "{}", shell.1);
4051        assert!(shell.1.contains("REST fallback"), "{}", shell.1);
4052        assert!(shell.1.contains(&sha), "{}", shell.1);
4053        drop(_hold);
4054
4055        let bad = evidence_file(dir.path(), &sha).unwrap();
4056        if let Some(parent) = bad.parent() {
4057            std::fs::create_dir_all(parent).unwrap();
4058        }
4059        std::fs::write(
4060            &bad,
4061            serde_json::json!({
4062                "command": "true",
4063                "exit": 1,
4064                "sha": sha,
4065                "ts": "2026-10-20T00:00:00Z"
4066            })
4067            .to_string(),
4068        )
4069        .unwrap();
4070        let _hold = stub_rest(&rest_gh(&sha, vec![no_runs()], no_statuses()));
4071        let failed = merge_rule(dir.path());
4072        assert_rest_ask(&failed, &sha);
4073        assert!(!failed.reason.contains("evidence"), "{}", failed.reason);
4074        drop(_hold);
4075
4076        gates(dir.path(), "test = \"true\"\n");
4077        record_in(dir.path(), &["true".into()]).unwrap();
4078        let _hold = stub_rest(&rest_gh(&sha, vec![no_runs()], no_statuses()));
4079        let recorded = merge_rule(dir.path());
4080        assert_rest_ask(&recorded, &sha);
4081        assert!(
4082            recorded.reason.contains("local evidence record"),
4083            "{}",
4084            recorded.reason
4085        );
4086        assert!(
4087            recorded.reason.contains(&format!("exists for {sha}")),
4088            "{}",
4089            recorded.reason
4090        );
4091        drop(_hold);
4092
4093        let page = run_page(serde_json::json!([workflow(
4094            "ci",
4095            "completed",
4096            Some("failure"),
4097        )]));
4098        let _hold = stub_rest(&rest_gh(&sha, vec![page], no_statuses()));
4099        let red = merge_rule(dir.path());
4100        assert_rest_deny(&red, "ci=failure");
4101        assert!(!red.reason.contains("evidence"), "{}", red.reason);
4102        drop(_hold);
4103
4104        let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
4105        let _hold = stub_rest(&rest_gh(other, vec![no_runs()], no_statuses()));
4106        let elsewhere = merge_rule(dir.path());
4107        assert_rest_ask(&elsewhere, other);
4108        assert!(
4109            !elsewhere.reason.contains("evidence"),
4110            "{}",
4111            elsewhere.reason
4112        );
4113        drop(_hold);
4114
4115        let mut spec = rest_gh(&sha, vec![no_runs()], no_statuses());
4116        spec.threads = threads(false, false);
4117        let _hold = stub_rest(&spec);
4118        let open = merge_rule(dir.path());
4119        assert_eq!(open.verdict, "deny");
4120        assert!(open.reason.contains("Bugbot"), "{}", open.reason);
4121        assert!(
4122            open.reason.contains("Source: REST fallback"),
4123            "{}",
4124            open.reason
4125        );
4126        assert!(!open.reason.contains("no CI ran"), "{}", open.reason);
4127    }
4128
4129    #[test]
4130    fn a_failed_rest_read_does_not_say_ci_is_missing() {
4131        let dir = repo();
4132        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4133        let unreadable = |spec: &RestGh| {
4134            let _hold = stub_rest(spec);
4135            let denied = merge_rule(dir.path());
4136            assert_unreadable(&denied);
4137            denied.reason
4138        };
4139
4140        let mut runs_fail = rest_gh(sha, vec![green_runs()], success_status());
4141        runs_fail.runs_error = Some("HTTP 502\n".into());
4142        unreadable(&runs_fail);
4143
4144        let mut status_fail = rest_gh(sha, vec![green_runs()], success_status());
4145        status_fail.status_error = Some("HTTP 502\n".into());
4146        unreadable(&status_fail);
4147
4148        let mut empty_status_fail = rest_gh(sha, vec![no_runs()], no_statuses());
4149        empty_status_fail.status_error = Some("HTTP 502\n".into());
4150        let reason = unreadable(&empty_status_fail);
4151        assert!(!reason.contains("no CI ran"), "{reason}");
4152
4153        let mut meta_fail = rest_gh(sha, vec![green_runs()], success_status());
4154        meta_fail.meta_error = Some("HTTP 401: Bad credentials\n".into());
4155        let reason = unreadable(&meta_fail);
4156        assert!(reason.contains("Bad credentials"), "{reason}");
4157
4158        let mut no_url = rest_gh(sha, vec![no_runs()], no_statuses());
4159        no_url.view = Some(
4160            serde_json::json!({
4161                "number": 7,
4162                "headRefOid": sha,
4163                "state": "OPEN",
4164            })
4165            .to_string(),
4166        );
4167        unreadable(&no_url);
4168
4169        let bad_runs = rest_gh(sha, vec!["{}".into()], no_statuses());
4170        let reason = unreadable(&bad_runs);
4171        assert!(
4172            reason.contains("the workflow run list was missing"),
4173            "{reason}"
4174        );
4175
4176        let mut bad_status = rest_gh(sha, vec![green_runs()], success_status());
4177        bad_status.status = r#"{"state":"success"}"#.into();
4178        let reason = unreadable(&bad_status);
4179        assert!(
4180            reason.contains("the commit status list was missing"),
4181            "{reason}"
4182        );
4183
4184        let short = rest_gh(
4185            sha,
4186            vec![run_page_total(
4187                2,
4188                serde_json::json!([workflow("unit", "completed", Some("success"))]),
4189            )],
4190            success_status(),
4191        );
4192        let reason = unreadable(&short);
4193        assert!(reason.contains("workflow run page 2 failed"), "{reason}");
4194
4195        for message in [
4196            "HTTP 401: Bad credentials\n",
4197            "Resource not accessible by integration\n",
4198            "HTTP 403: personal access token lacks a scope\n",
4199        ] {
4200            let mut spec = rest_gh(sha, vec![green_runs()], success_status());
4201            spec.rollup_error = Some(message.into());
4202            let reason = unreadable(&spec);
4203            assert!(reason.contains(message.trim()), "{reason}");
4204        }
4205    }
4206
4207    #[test]
4208    fn a_push_of_a_merge_ignores_a_different_pull_request_head() {
4209        let dir = repo();
4210        seed(dir.path());
4211        git(dir.path(), &["checkout", "-q", "-b", "feature"]);
4212        std::fs::write(dir.path().join("g.txt"), "two\n").unwrap();
4213        git(dir.path(), &["add", "g.txt"]);
4214        git(dir.path(), &["commit", "-q", "-m", "note the feature"]);
4215        git(dir.path(), &["checkout", "-q", "main"]);
4216        git(dir.path(), &["merge", "--no-ff", "--no-edit", "feature"]);
4217        let head = tip(dir.path());
4218        let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
4219        let mut spec = rest_gh(
4220            other,
4221            vec![run_page(serde_json::json!([workflow(
4222                "unit",
4223                "completed",
4224                Some("success"),
4225            )]))],
4226            success_status(),
4227        );
4228        spec.list = format!(r#"[{{"number":7,"headRefOid":"{head}","state":"OPEN"}}]"#);
4229        let _hold = stub_rest(&spec);
4230        // The forge head is `other` and this merge publishes `head`. A push
4231        // does not read the pull request, so the difference does not refuse it.
4232        assert!(
4233            guard_verdict("git push origin main", Some(dir.path()), &[]).is_none(),
4234            "a push does not read the pull request"
4235        );
4236    }
4237
4238    #[test]
4239    fn a_check_rollup_message_names_the_rollup() {
4240        let dir = repo();
4241        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4242        let _hold = stub_gh(&red_view(sha), &quiet_threads(), "[]");
4243        let red = merge_rule(dir.path());
4244        assert_eq!(red.verdict, "deny");
4245        assert!(red.reason.contains("ci=FAILURE"), "{}", red.reason);
4246        assert!(red.reason.contains("Source: rollup"), "{}", red.reason);
4247        assert!(!red.reason.contains("REST fallback"), "{}", red.reason);
4248        drop(_hold);
4249
4250        let _hold = stub_gh(&pending_view(sha), &quiet_threads(), "[]");
4251        let pending = merge_rule(dir.path());
4252        assert!(
4253            pending.reason.contains("ci=IN_PROGRESS"),
4254            "{}",
4255            pending.reason
4256        );
4257        assert!(
4258            pending.reason.contains("Source: rollup"),
4259            "{}",
4260            pending.reason
4261        );
4262        drop(_hold);
4263
4264        for rollup in [serde_json::Value::Null, serde_json::json!([])] {
4265            let _hold = stub_gh(&rollup_view(sha, rollup), &quiet_threads(), "[]");
4266            let asked = merge_rule(dir.path());
4267            assert_eq!(asked.verdict, "ask", "{}", asked.reason);
4268            assert!(asked.reason.contains("no CI ran"), "{}", asked.reason);
4269            assert!(asked.reason.contains("Source: rollup"), "{}", asked.reason);
4270            assert!(!asked.reason.contains("REST fallback"), "{}", asked.reason);
4271        }
4272
4273        let _hold = stub_gh(&green_view(sha), &threads(false, false), "[]");
4274        let open = merge_rule(dir.path());
4275        assert!(open.reason.contains("Bugbot"), "{}", open.reason);
4276        assert!(open.reason.contains("Source: rollup"), "{}", open.reason);
4277    }
4278
4279    #[test]
4280    fn no_bugbot_threads_lets_a_green_merge_through() {
4281        let dir = repo();
4282        let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4283        let _hold = stub_gh(&green_view(sha), &quiet_threads(), "[]");
4284        assert!(guard_verdict("gh pr merge 7", Some(dir.path()), &[]).is_none());
4285        assert_allows(&answers("gh pr merge 7", dir.path(), &[]));
4286        drop(_hold);
4287
4288        let _hold = stub_gh(&green_view(sha), &human_thread(), "[]");
4289        assert!(
4290            guard_verdict("gh pr merge 7", Some(dir.path()), &[]).is_none(),
4291            "a thread that is not Bugbot does not refuse the merge"
4292        );
4293    }
4294
4295    #[test]
4296    fn a_local_merge_pushed_to_the_persons_own_main_runs() {
4297        let dir = repo();
4298        seed(dir.path());
4299        git(dir.path(), &["checkout", "-q", "-b", "feature"]);
4300        std::fs::write(dir.path().join("g.txt"), "two\n").unwrap();
4301        git(dir.path(), &["add", "g.txt"]);
4302        git(dir.path(), &["commit", "-q", "-m", "note the feature"]);
4303        gates(dir.path(), "test = \"true\"\n");
4304        record_in(dir.path(), &["true".into()]).unwrap();
4305        git(dir.path(), &["checkout", "-q", "main"]);
4306        let _hold = silent_gh();
4307        let line = "git merge feature && git push origin main";
4308        assert_allows(&answers(line, dir.path(), &[]));
4309
4310        std::fs::write(dir.path().join("h.txt"), "three\n").unwrap();
4311        git(dir.path(), &["add", "h.txt"]);
4312        git(dir.path(), &["commit", "-q", "-m", "note main"]);
4313        let diverged = guard_verdict(line, Some(dir.path()), &[]);
4314        assert!(
4315            diverged.is_none(),
4316            "a merge commit onto the person's main runs: {diverged:?}"
4317        );
4318    }
4319
4320    #[test]
4321    fn pushing_a_merge_commit_does_not_wait_on_the_forge() {
4322        let dir = repo();
4323        seed(dir.path());
4324        git(dir.path(), &["checkout", "-q", "-b", "feature"]);
4325        std::fs::write(dir.path().join("g.txt"), "two\n").unwrap();
4326        git(dir.path(), &["add", "g.txt"]);
4327        git(dir.path(), &["commit", "-q", "-m", "note the feature"]);
4328        git(dir.path(), &["checkout", "-q", "main"]);
4329        git(dir.path(), &["merge", "--no-ff", "--no-edit", "feature"]);
4330        let head = tip(dir.path());
4331        assert!(second_parent(dir.path()).is_some(), "{head}");
4332        let list = format!(r#"[{{"number":7,"headRefOid":"{head}","state":"OPEN"}}]"#);
4333        let _hold = stub_gh(&red_view(&head), &threads(false, false), &list);
4334        assert_allows(&answers("git push origin main", dir.path(), &[]));
4335        assert!(guard_verdict("git push origin main", Some(dir.path()), &[]).is_none());
4336    }
4337
4338    #[test]
4339    fn a_protected_branch_of_someone_elses_repository_asks() {
4340        let dir = repo();
4341        seed(dir.path());
4342        let _hold = silent_gh();
4343        gates(dir.path(), "owners = [\"rgoswami\", \"leidarljos\"]\n");
4344        let asked = guard_verdict("git push origin main", Some(dir.path()), &[])
4345            .expect("acme is not one of the owners");
4346        assert_eq!(asked.verdict, "ask");
4347        assert_eq!(asked.pattern, "push-gate");
4348        assert!(asked.reason.contains("acme/widgets"), "{}", asked.reason);
4349        assert!(
4350            guard_verdict("git push origin HEAD:feature", Some(dir.path()), &[]).is_none(),
4351            "a feature branch of someone else's repository is an ordinary push"
4352        );
4353        gates(dir.path(), "owners = [\"ACME\"]\n");
4354        assert!(
4355            guard_verdict("git push origin main", Some(dir.path()), &[]).is_none(),
4356            "owners match without case"
4357        );
4358        gates(dir.path(), "signoff = false\n");
4359        assert!(
4360            guard_verdict("git push origin main", Some(dir.path()), &[]).is_none(),
4361            "no owners set: every repository is the person's"
4362        );
4363    }
4364
4365    #[test]
4366    fn a_force_push_with_a_test_command_asks_and_is_not_an_evidence_refusal() {
4367        let dir = repo();
4368        seed(dir.path());
4369        git(dir.path(), &["checkout", "-q", "-b", "feature"]);
4370        std::fs::write(dir.path().join("g.txt"), "two\n").unwrap();
4371        git(dir.path(), &["add", "g.txt"]);
4372        git(dir.path(), &["commit", "-q", "-m", "note the feature"]);
4373        git(dir.path(), &["checkout", "-q", "main"]);
4374        git(dir.path(), &["merge", "--no-ff", "--no-edit", "feature"]);
4375        git(dir.path(), &["branch", "-q", "merged"]);
4376        git(dir.path(), &["reset", "-q", "--hard", "HEAD^1"]);
4377        git(dir.path(), &["checkout", "-q", "merged"]);
4378        assert!(second_parent(dir.path()).is_some());
4379        gates(dir.path(), "test = \"true\"\n");
4380        let asked = guard_verdict("git push --force origin main", Some(dir.path()), &[]).unwrap();
4381        assert_eq!(asked.verdict, "ask", "{}", asked.reason);
4382        assert!(
4383            !asked.reason.contains("git-push-evidence"),
4384            "{}",
4385            asked.reason
4386        );
4387        assert!(
4388            !asked.reason.contains("git-merge-unready"),
4389            "{}",
4390            asked.reason
4391        );
4392    }
4393
4394    struct Layers;
4395
4396    impl Drop for Layers {
4397        fn drop(&mut self) {
4398            LAYER_HOME.with(|slot| *slot.borrow_mut() = None);
4399            LAYER_SEAT.with(|slot| *slot.borrow_mut() = None);
4400        }
4401    }
4402
4403    fn layers(home: &Path, seat: Option<&str>) -> Layers {
4404        LAYER_HOME.with(|slot| *slot.borrow_mut() = Some(home.to_path_buf()));
4405        LAYER_SEAT.with(|slot| *slot.borrow_mut() = seat.map(str::to_string));
4406        Layers
4407    }
4408
4409    fn write_toml(path: &Path, body: &str) {
4410        if let Some(parent) = path.parent() {
4411            std::fs::create_dir_all(parent).unwrap();
4412        }
4413        std::fs::write(path, body).unwrap();
4414    }
4415
4416    #[test]
4417    fn a_push_outside_a_checkout_is_not_an_evidence_denial() {
4418        let _env = crate::test_env_lock();
4419        let dir = tempfile::tempdir().unwrap();
4420        let plain = guard_verdict("git push origin main", Some(dir.path()), &[]);
4421        assert!(plain.is_none(), "{plain:?}");
4422        let forced = guard_verdict("git push --force origin main", Some(dir.path()), &[])
4423            .expect("a force push asks");
4424        assert_eq!(forced.verdict, "ask", "{}", forced.reason);
4425        assert_eq!(forced.pattern, "git-force-push");
4426        assert!(
4427            forced.reason.starts_with("git-force-push:"),
4428            "{}",
4429            forced.reason
4430        );
4431        assert!(
4432            !forced.reason.contains("git-push-evidence"),
4433            "{}",
4434            forced.reason
4435        );
4436    }
4437
4438    #[test]
4439    fn a_plain_push_without_a_test_command_is_allowed_and_a_force_push_asks() {
4440        let dir = repo();
4441        seed(dir.path());
4442        let plain = guard_verdict("git push origin main", Some(dir.path()), &[]);
4443        assert!(plain.is_none(), "{plain:?}");
4444        let forced = guard_verdict("git push --force origin main", Some(dir.path()), &[])
4445            .expect("force still asks");
4446        assert_eq!(forced.verdict, "ask");
4447        assert_eq!(forced.pattern, "git-force-push");
4448        let leased = guard_verdict(
4449            "git push --force-with-lease origin HEAD:main",
4450            Some(dir.path()),
4451            &[],
4452        )
4453        .expect("a lease push asks");
4454        assert_eq!(leased.verdict, "ask");
4455        assert_eq!(leased.pattern, "git-force-push");
4456        assert!(
4457            guard_verdict("git push -u origin feature", Some(dir.path()), &[]).is_none(),
4458            "a push that sets the upstream is an ordinary push"
4459        );
4460        assert!(
4461            guard_verdict(
4462                "git push --force-if-includes origin main",
4463                Some(dir.path()),
4464                &[]
4465            )
4466            .is_none(),
4467            "force-if-includes is an ordinary push"
4468        );
4469        assert!(
4470            !forced.reason.contains("git-push-evidence"),
4471            "{}",
4472            forced.reason
4473        );
4474        gates(dir.path(), "signoff = false\n");
4475        assert!(
4476            guard_verdict("git push origin main", Some(dir.path()), &[]).is_none(),
4477            "a gates file with no test command does not ask for evidence"
4478        );
4479        gates(dir.path(), "test = \"true\"\n");
4480        let _ = take_push_warning();
4481        assert!(
4482            guard_verdict("git push origin main", Some(dir.path()), &[]).is_none(),
4483            "a test command with no record warns and the push runs"
4484        );
4485        let said = take_push_warning().expect("a warning");
4486        assert!(said.contains("ljos evidence record -- true"), "{said}");
4487        let sha = tip(dir.path());
4488        let path = evidence_file(dir.path(), &sha).unwrap();
4489        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4490        std::fs::write(
4491            &path,
4492            format!(r#"{{"command":"true","exit":1,"sha":"{sha}","ts":"2020-01-01T00:00:00Z"}}"#),
4493        )
4494        .unwrap();
4495        let _ = take_push_warning();
4496        assert!(
4497            guard_verdict("git push origin main", Some(dir.path()), &[]).is_none(),
4498            "a stale record warns and the push runs"
4499        );
4500        let stale = take_push_warning().expect("a failing record is a warning");
4501        assert!(stale.contains("did not exit 0"), "{stale}");
4502    }
4503
4504    #[test]
4505    fn the_person_sets_the_consensus_push_and_a_checkout_only_tightens_it() {
4506        let dir = repo();
4507        let cwd = dir.path();
4508        let home = tempfile::tempdir().unwrap();
4509        let _layers = layers(home.path(), Some("box"));
4510        let read = || consensus_push(Some(cwd)).unwrap();
4511        let names = |all: &[&str]| all.iter().map(|b| (*b).to_string()).collect::<Vec<_>>();
4512        assert_eq!(read().quorum, 2);
4513        assert_eq!(read().branches, names(&["develop", "main"]));
4514        write_toml(
4515            &home.path().join("ljos/ljos.toml"),
4516            "[gates]\nquorum = 3\nconsensus_branches = [\"main\", \"release\"]\n",
4517        );
4518        assert_eq!(read().quorum, 3);
4519        assert_eq!(read().branches, names(&["main", "release"]));
4520        write_toml(
4521            &home.path().join("ljos/seats/box/ljos.toml"),
4522            "quorum = 1\n",
4523        );
4524        assert_eq!(read().quorum, 1, "the seat file is the person's too");
4525        write_toml(
4526            &cwd.join("ljos.toml"),
4527            "quorum = 1\nconsensus_branches = []\n",
4528        );
4529        gates(
4530            cwd,
4531            "quorum = 2\nconsensus_branches = [\"refs/heads/stable\"]\n",
4532        );
4533        assert_eq!(read().quorum, 2, "a checkout raises the quorum");
4534        assert_eq!(read().branches, names(&["main", "release", "stable"]));
4535        write_toml(&cwd.join("ljos.toml"), "quorum = 0\n");
4536        let zero = consensus_push(Some(cwd)).unwrap_err();
4537        assert!(zero.contains("`quorum`"), "{zero}");
4538        write_toml(&cwd.join("ljos.toml"), "quorum = [\n");
4539        let broken = consensus_push(Some(cwd)).unwrap_err();
4540        assert!(broken.contains("does not parse"), "{broken}");
4541    }
4542
4543    #[test]
4544    fn a_push_lands_where_git_would_put_it_and_an_unread_one_is_protected() {
4545        let dir = repo();
4546        let cwd = dir.path();
4547        seed(cwd);
4548        git(cwd, &["checkout", "-q", "-b", "topic"]);
4549        let branches = ["develop".to_string(), "main".to_string()];
4550        let lands = |line: &str| {
4551            let calls = git_calls(line, Some(cwd)).unwrap();
4552            let push = calls.iter().find(|c| c.sub == "push").unwrap();
4553            protected_push(push, line, &branches)
4554        };
4555        let unread = |line: &str| {
4556            lands(line).is_some_and(|d| d.starts_with("a branch the guard cannot tell"))
4557        };
4558        for line in [
4559            "git push",
4560            "git push origin",
4561            "git push origin topic",
4562            "git push -u origin HEAD",
4563            "git push origin topic:refs/heads/topic",
4564            "git push origin v1:refs/tags/develop",
4565            "git push origin 'refs/tags/*:refs/tags/*'",
4566            "git push develop topic",
4567        ] {
4568            assert_eq!(lands(line), None, "{line}");
4569        }
4570        for (line, dest) in [
4571            ("git push origin HEAD:develop", "`develop`"),
4572            ("git push origin topic:refs/heads/develop", "`develop`"),
4573            ("git push origin +topic:heads/develop", "`develop`"),
4574            ("git push origin :develop", "`develop`"),
4575            ("git push --delete origin develop", "`develop`"),
4576            ("git push origin topic HEAD:Main", "`main`"),
4577            (
4578                "git push https://example.com/acme/widgets.git main",
4579                "`main`",
4580            ),
4581            ("git push --all origin", "every branch `--all` reaches"),
4582            (
4583                "git push origin 'refs/heads/*:refs/heads/*'",
4584                "the branches `refs/heads/*:refs/heads/*` matches",
4585            ),
4586        ] {
4587            assert_eq!(lands(line).as_deref(), Some(dest), "{line}");
4588        }
4589        for line in [
4590            "git checkout develop && git push",
4591            "GIT_DIR=../other git push origin topic",
4592            "git -c remote.origin.push=refs/heads/topic:refs/heads/develop push origin topic",
4593            "git push origin $BRANCH",
4594            "git push --repo=origin topic",
4595            "git push origin topic:HEAD",
4596        ] {
4597            assert!(unread(line), "{line}: {:?}", lands(line));
4598        }
4599
4600        git(cwd, &["config", "branch.topic.merge", "refs/heads/develop"]);
4601        assert_eq!(lands("git push").as_deref(), Some("`develop`"));
4602        assert_eq!(lands("git push origin HEAD").as_deref(), Some("`develop`"));
4603        assert_eq!(
4604            lands("git push origin topic"),
4605            None,
4606            "simple names the branch"
4607        );
4608        git(cwd, &["config", "push.default", "upstream"]);
4609        assert_eq!(lands("git push origin topic").as_deref(), Some("`develop`"));
4610        git(cwd, &["config", "push.default", "current"]);
4611        assert_eq!(lands("git push"), None);
4612        git(cwd, &["config", "push.default", "nothing"]);
4613        assert_eq!(lands("git push"), None);
4614        git(cwd, &["config", "push.default", "matching"]);
4615        assert!(lands("git push")
4616            .unwrap()
4617            .starts_with("every branch both sides have"));
4618        git(cwd, &["config", "--unset", "push.default"]);
4619        git(
4620            cwd,
4621            &[
4622                "config",
4623                "remote.origin.push",
4624                "refs/heads/topic:refs/heads/develop",
4625            ],
4626        );
4627        assert!(unread("git push origin topic"));
4628        git(cwd, &["config", "--unset", "remote.origin.push"]);
4629        git(cwd, &["checkout", "-q", "--detach"]);
4630        assert!(unread("git push origin HEAD"));
4631        assert_eq!(lands("git push origin HEAD:refs/heads/topic"), None);
4632    }
4633
4634    #[test]
4635    fn identity_comes_from_config_layers_and_the_example_is_not_the_default() {
4636        let dir = repo();
4637        let cwd = dir.path();
4638        let home = tempfile::tempdir().unwrap();
4639        let _layers = layers(home.path(), Some("box"));
4640        let foreign = "GIT_AUTHOR_NAME=rig GIT_AUTHOR_EMAIL=rig@example.invalid \
4641                       GIT_COMMITTER_NAME=rig GIT_COMMITTER_EMAIL=rig@example.invalid \
4642                       git commit -m \"note the seat\"";
4643        assert!(
4644            guard_verdict(foreign, Some(cwd), &[]).is_none(),
4645            "an empty seat config is not an identity gate"
4646        );
4647        write_toml(
4648            &home.path().join("ljos/ljos.toml"),
4649            "[identity]\nname = \"User Person\"\nemail = \"user@example.com\"\n",
4650        );
4651        let user = guard_verdict(foreign, Some(cwd), &[]).unwrap();
4652        assert!(user.reason.contains("user@example.com"), "{}", user.reason);
4653        assert!(user.reason.contains("ljos.toml"), "{}", user.reason);
4654        assert!(!user.reason.contains("git config"), "{}", user.reason);
4655        write_toml(
4656            &home.path().join("ljos/seats/box/ljos.toml"),
4657            "[identity]\nname = \"Seat Person\"\nemail = \"seat@example.com\"\n",
4658        );
4659        let seat = guard_verdict(foreign, Some(cwd), &[]).unwrap();
4660        assert!(seat.reason.contains("seat@example.com"), "{}", seat.reason);
4661        write_toml(
4662            &cwd.join("ljos.toml"),
4663            "[identity]\nname = \"Repo Person\"\nemail = \"repo@example.com\"\n",
4664        );
4665        let repo_id = guard_verdict(foreign, Some(cwd), &[]).unwrap();
4666        assert!(
4667            repo_id.reason.contains("repo@example.com"),
4668            "{}",
4669            repo_id.reason
4670        );
4671        gates(
4672            cwd,
4673            "name = \"Gate Person\"\nemail = \"gate@example.com\"\n",
4674        );
4675        let gate = guard_verdict(foreign, Some(cwd), &[]).unwrap();
4676        assert!(gate.reason.contains("gate@example.com"), "{}", gate.reason);
4677        assert!(gate.reason.contains(".ljos/gates.toml"), "{}", gate.reason);
4678        let example = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/ljos.toml");
4679        let text = std::fs::read_to_string(&example).unwrap();
4680        assert!(text.contains("Rohit Goswami"), "{text}");
4681        assert!(text.contains("rgoswami@ieee.org"), "{text}");
4682        write_toml(&home.path().join("ljos/ljos.toml"), &text);
4683        std::fs::remove_file(home.path().join("ljos/seats/box/ljos.toml")).unwrap();
4684        std::fs::remove_file(cwd.join("ljos.toml")).unwrap();
4685        std::fs::remove_file(cwd.join(".ljos/gates.toml")).unwrap();
4686        let from_example = guard_verdict(foreign, Some(cwd), &[]).unwrap();
4687        assert!(
4688            from_example.reason.contains("rgoswami@ieee.org"),
4689            "{}",
4690            from_example.reason
4691        );
4692        assert!(
4693            !from_example.reason.contains("git config"),
4694            "{}",
4695            from_example.reason
4696        );
4697        let rohit = "GIT_AUTHOR_NAME='Rohit Goswami' GIT_AUTHOR_EMAIL=rgoswami@ieee.org \
4698                     GIT_COMMITTER_NAME='Rohit Goswami' GIT_COMMITTER_EMAIL=rgoswami@ieee.org \
4699                     git commit -m \"note the seat\"";
4700        assert!(
4701            guard_verdict(rohit, Some(cwd), &[]).is_none(),
4702            "the configured person may commit"
4703        );
4704    }
4705}