1use 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 static PUSH_WARNING: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
24}
25
26#[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 static LAYER_HOME: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
40 static LAYER_SEAT: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
43}
44
45#[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#[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
67pub 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#[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
140pub 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 owners: Vec<String>,
242 identity: Option<Person>,
243 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
282fn 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 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 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 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
513fn 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
542fn 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
560fn 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
585fn 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
607fn 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
654fn 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
717fn 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
926pub(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
956fn 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
966fn 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#[derive(Debug, Clone, PartialEq, Eq)]
982pub struct ConsensusPush {
983 pub quorum: usize,
984 pub branches: Vec<String>,
985}
986
987pub 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#[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
1790fn 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
2007fn 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
2047fn 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 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 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
2363fn 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 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 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 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 #[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 #[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 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 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 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}