Skip to content
245 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Cache hits are counted, not only their bytes1//! Runs commands against one commit and reports how each went: the merge
2//! queue's runner of commands, and `MODE=checks`, which ran commands written
3//! on issues before a pull request's checks were its workflows. g1t no
4//! longer starts that mode; it stays for a sandbox already under way.
5//!
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.6//! The sandbox holds nothing but that commit, so a passing result says
7//! something about the code and not about what was left lying around.
Cache hits are counted, not only their bytes8//!
9//! Configuration comes from the environment:
10//!
11//! - `G1T_API`, `CHECK_RUN`, `CHECK_TOKEN`: where and how to report.
12//! - `GIT_REMOTE`, `GIT_COMMIT`: what to check out.
13//! - `G1T_USER`, `G1T_TOKEN`: to read the repository, if it is private.
14//! - `CHECKS`: the commands, as a JSON array.
15
16use std::path::Path;
17use std::process::{Command, Stdio};
18use std::time::Instant;
19
20use anyhow::{Context, Result, bail};
21use serde::Serialize;
22
23use crate::{WORKDIR, auth_option, env, git};
24
25/// The longest one command may run.
26const COMMAND_TIMEOUT_SECONDS: u32 = 10 * 60;
27/// How much of a command's output is kept: the end, where failures are.
28const MAX_OUTPUT_CHARS: usize = 12_000;
29/// What `timeout` exits with when it had to stop the command.
30const TIMED_OUT: i32 = 124;
31const KILLED: i32 = 137;
32
33#[derive(Debug, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub(crate) struct CheckResult {
36 pub(crate) command: String,
37 pub(crate) passed: bool,
38 exit_code: Option<i32>,
39 output: String,
40 duration_ms: u64,
41}
42
43impl CheckResult {
44 pub(crate) fn output_text(&self) -> &str {
45 &self.output
46 }
47}
48
49/// The last `limit` characters of `text`, saying so if any were dropped.
50fn tail(text: &str, limit: usize) -> String {
51 let length = text.chars().count();
52 if length <= limit {
53 return text.to_owned();
54 }
55 let kept: String = text.chars().skip(length - limit).collect();
56 format!("… (earlier output not shown)\n{kept}")
57}
58
59pub(crate) fn redact(text: &str, secrets: &[String]) -> String {
60 secrets.iter().fold(text.to_owned(), |text, secret| {
61 text.replace(secret, "[redacted]")
62 })
63}
64
65/// Runs one command in the checkout, without this process's credentials.
66pub(crate) fn run_command(command: &str, workdir: &Path, secrets: &[String]) -> CheckResult {
67 crate::abuse::touch();
68 // Mining is never a check's or a build's job (abuse.rs).
69 if let Some(miner) = crate::abuse::miner_in(command) {
70 return CheckResult {
71 command: command.to_owned(),
72 passed: false,
73 exit_code: None,
74 output: format!("g1t does not run cryptocurrency miners ({miner}). This command was not run."),
75 duration_ms: 0,
76 };
77 }
78 let started = Instant::now();
79 let output = Command::new("timeout")
80 .args([
81 "--signal=KILL",
82 &COMMAND_TIMEOUT_SECONDS.to_string(),
83 "sh",
84 "-c",
85 // One stream, in the order it was written.
86 &format!("( {command}\n) 2>&1"),
87 ])
88 .current_dir(workdir)
89 .env_remove("G1T_TOKEN")
90 .env_remove("CHECK_TOKEN")
91 .env_remove("DEPLOY_TOKEN")
92 .stdin(Stdio::null())
93 .output();
94 let duration_ms = started.elapsed().as_millis() as u64;
95 match output {
96 Ok(output) => {
97 let code = output.status.code();
98 let timed_out = matches!(code, Some(TIMED_OUT | KILLED) | None);
99 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
100 if timed_out {
101 text.push_str(&format!(
102 "\nStopped after {} minutes.",
103 COMMAND_TIMEOUT_SECONDS / 60
104 ));
105 }
106 CheckResult {
107 command: command.to_owned(),
108 passed: output.status.success(),
109 exit_code: code.filter(|_| !timed_out),
110 output: redact(&tail(text.trim_end(), MAX_OUTPUT_CHARS), secrets),
111 duration_ms,
112 }
113 }
114 Err(error) => CheckResult {
115 command: command.to_owned(),
116 passed: false,
117 exit_code: None,
118 output: format!("Could not start the command: {error}"),
119 duration_ms,
120 },
121 }
122}
123
124struct Reporter {
125 url: String,
126 token: String,
127}
128
129impl Reporter {
130 fn send(&self, mut body: serde_json::Value) -> Result<()> {
131 body["token"] = self.token.clone().into();
132 ureq::post(&self.url)
133 .send_json(body)
134 .context("could not report the check run")?;
135 Ok(())
136 }
137}
138
139fn check_out(secrets: &[String]) -> Result<()> {
140 let remote = env("GIT_REMOTE")?;
141 let commit = env("GIT_COMMIT")?;
142 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
143 std::fs::create_dir_all("/work")?;
144 let workdir = Path::new(WORKDIR);
145 let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR)
146 .and_then(|_| git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"]))
147 .and_then(|branch| crate::clone::ensure(workdir, &auth, "origin", &branch, &commit))
148 .and_then(|_| {
149 git(
150 Path::new(WORKDIR),
151 &[
152 "-c",
153 "advice.detachedHead=false",
154 "checkout",
155 "--quiet",
156 &commit,
157 ],
158 )
159 });
160 if let Err(error) = cloned {
161 bail!("{}", redact(&format!("{error:#}"), secrets));
162 }
163 Ok(())
164}
165
166pub fn main() -> i32 {
167 let reporter = match (env("G1T_API"), env("CHECK_RUN"), env("CHECK_TOKEN")) {
168 (Ok(api), Ok(run), Ok(token)) => Reporter {
169 url: format!("{api}/checks/{run}"),
170 token,
171 },
172 _ => {
173 eprintln!("g1t-runner: G1T_API, CHECK_RUN and CHECK_TOKEN must be set");
174 return 2;
175 }
176 };
177 let secrets: Vec<String> = ["G1T_TOKEN", "CHECK_TOKEN"]
178 .iter()
179 .filter_map(|name| std::env::var(name).ok())
180 .filter(|secret| !secret.is_empty())
181 .collect();
182 let commands: Vec<String> = std::env::var("CHECKS")
183 .ok()
184 .and_then(|json| serde_json::from_str(&json).ok())
185 .unwrap_or_default();
186
187 // Says the run has started.
188 if let Err(error) = reporter.send(serde_json::json!({})) {
189 eprintln!("g1t-runner: {error:#}");
190 return 1;
191 }
192 let report = match check_out(&secrets) {
193 Err(error) => serde_json::json!({
194 "error": format!("The commit could not be checked out: {error:#}"),
195 }),
196 Ok(()) => {
197 let results: Vec<CheckResult> = commands
198 .iter()
199 .map(|command| run_command(command, Path::new(WORKDIR), &secrets))
200 .collect();
201 serde_json::json!({ "results": results })
202 }
203 };
204 match reporter.send(report) {
205 Ok(()) => 0,
206 Err(error) => {
207 eprintln!("g1t-runner: {error:#}");
208 1
209 }
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn long_output_keeps_its_end() {
219 let text = format!("{}END", "x".repeat(50));
220 let kept = tail(&text, 10);
221 assert!(kept.ends_with("xxxxxxxEND"));
222 assert!(kept.starts_with("… (earlier output not shown)"));
223 assert_eq!(tail("short", 10), "short");
224 }
225
226 #[test]
227 fn secrets_do_not_reach_a_report() {
228 let secrets = vec!["g1t_secret".to_owned()];
229 assert_eq!(redact("token=g1t_secret", &secrets), "token=[redacted]");
230 }
231
232 #[cfg(unix)]
233 #[test]
234 fn a_command_passes_or_fails_by_its_exit_code() {
235 let here = std::env::temp_dir();
236 let passed = run_command("echo out; echo err >&2", &here, &[]);
237 assert!(passed.passed);
238 assert_eq!(passed.exit_code, Some(0));
239 assert_eq!(passed.output, "out\nerr");
240 let failed = run_command("echo nope; exit 3", &here, &[]);
241 assert!(!failed.passed);
242 assert_eq!(failed.exit_code, Some(3));
243 assert_eq!(failed.output, "nope");
244 }
245}

This file's history is long; its oldest lines are credited to the oldest commit read.