flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/runner/src/mergecheck.rs

150 lines5,496 bytesCodeBlame
1//! Finds out whether a pull request merges cleanly into the branch it
2//! targets, and if not, which files conflict. No agent runs and nothing is
3//! pushed: the two commits are merged in memory with `git merge-tree`, or,
4//! where that is not available, in a throwaway checkout.
5//!
6//! Configuration comes from the environment:
7//!
8//! - `G1T_API`, `MERGECHECK_PULL`, `MERGECHECK_TOKEN`: where and how to report.
9//! - `G1T_USER`, `G1T_TOKEN`: to read the repository and the change.
10//! - `BASE_REMOTE`, `BASE_COMMIT`: the repository and the commit to merge into.
11//! - `HEAD_REMOTE`, `HEAD_BRANCH`, `HEAD_COMMIT`: where the change is.
12
13use std::path::Path;
14use std::process::Command;
15
16use anyhow::{Context, Result, bail};
17
18use crate::checks::redact;
19use crate::{WORKDIR, auth_option, env, git};
20
21/// The paths `git merge-tree --write-tree --name-only` lists as conflicting:
22/// the lines after the tree on the first line, up to the blank line before
23/// any messages.
24pub(crate) fn merge_tree_conflicts(output: &str) -> Vec<String> {
25 let mut paths: Vec<String> = Vec::new();
26 for line in output.lines().skip(1) {
27 if line.trim().is_empty() {
28 break;
29 }
30 let path = line.trim().to_owned();
31 if !paths.contains(&path) {
32 paths.push(path);
33 }
34 }
35 paths
36}
37
38fn short(commit: &str) -> &str {
39 &commit[..commit.len().min(12)]
40}
41
42/// The conflicting files, empty when it merges cleanly.
43fn probe(auth: &str) -> Result<Vec<String>> {
44 let base_remote = env("BASE_REMOTE")?;
45 let base = env("BASE_COMMIT")?;
46 let head_remote = env("HEAD_REMOTE")?;
47 let head_branch = env("HEAD_BRANCH")?;
48 let head = env("HEAD_COMMIT")?;
49 std::fs::create_dir_all("/work")?;
50 let workdir = Path::new(WORKDIR);
51 git(
52 Path::new("/work"),
53 &["-c", auth, "clone", "--quiet", "--no-checkout", &base_remote, WORKDIR],
54 )
55 .context("could not clone the repository")?;
56 git(workdir, &["-c", auth, "fetch", "--quiet", &head_remote, &head_branch])
57 .context("could not fetch the pull request's change")?;
58 for (commit, what) in [(&base, "the target branch's commit"), (&head, "the change's commit")] {
59 let present = Command::new("git")
60 .current_dir(workdir)
61 .args(["cat-file", "-e", &format!("{commit}^{{commit}}")])
62 .status()
63 .is_ok_and(|status| status.success());
64 if !present {
65 bail!("{what} {} is no longer there; it has moved since", short(commit));
66 }
67 }
68
69 // In memory: git 2.38 and later.
70 let merged = Command::new("git")
71 .current_dir(workdir)
72 .args(["merge-tree", "--write-tree", "--name-only", "--no-messages", &base, &head])
73 .output()
74 .context("could not run git")?;
75 match merged.status.code() {
76 Some(0) => return Ok(Vec::new()),
77 Some(1) => return Ok(merge_tree_conflicts(&String::from_utf8_lossy(&merged.stdout))),
78 _ => {}
79 }
80
81 // Otherwise in a throwaway checkout.
82 git(workdir, &["config", "user.name", "g1t merge check"])?;
83 git(workdir, &["config", "user.email", "mergecheck@g1t.sh"])?;
84 git(workdir, &["checkout", "--quiet", "--detach", &base])?;
85 let clean = Command::new("git")
86 .current_dir(workdir)
87 .args(["merge", "--no-commit", "--no-ff", "--quiet", &head])
88 .output()
89 .is_ok_and(|output| output.status.success());
90 if clean {
91 return Ok(Vec::new());
92 }
93 let files: Vec<String> = git(workdir, &["diff", "--name-only", "--diff-filter=U"])?
94 .lines()
95 .map(str::to_owned)
96 .collect();
97 if files.is_empty() {
98 bail!("the merge failed for a reason other than a conflict");
99 }
100 Ok(files)
101}
102
103fn report(api: &str, pull: &str, token: &str, mut body: serde_json::Value) -> Result<()> {
104 body["token"] = token.into();
105 ureq::post(&format!("{api}/mergechecks/{pull}"))
106 .send_json(body)
107 .context("could not report the merge check")?;
108 Ok(())
109}
110
111pub fn main() -> i32 {
112 let (api, pull, token) = match (env("G1T_API"), env("MERGECHECK_PULL"), env("MERGECHECK_TOKEN")) {
113 (Ok(api), Ok(pull), Ok(token)) => (api, pull, token),
114 _ => {
115 eprintln!("g1t-runner: G1T_API, MERGECHECK_PULL and MERGECHECK_TOKEN must be set");
116 return 2;
117 }
118 };
119 let secrets: Vec<String> = ["G1T_TOKEN", "MERGECHECK_TOKEN"]
120 .iter()
121 .filter_map(|name| std::env::var(name).ok())
122 .filter(|secret| !secret.is_empty())
123 .collect();
124 let found = env("G1T_USER")
125 .and_then(|user| Ok(auth_option(&user, &env("G1T_TOKEN")?)))
126 .and_then(|auth| probe(&auth));
127 let body = match found {
128 Ok(conflicts) => serde_json::json!({ "conflicts": conflicts }),
129 Err(error) => serde_json::json!({ "error": redact(&format!("{error:#}"), &secrets) }),
130 };
131 match report(&api, &pull, &token, body) {
132 Ok(()) => 0,
133 Err(error) => {
134 eprintln!("g1t-runner: {error:#}");
135 1
136 }
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn conflicting_paths_are_read_from_merge_tree() {
146 let output = "4b825dc642cb6eb9a060e54bf8d69288fbee4904\nsrc/a.rs\nsrc/b.rs\nsrc/a.rs\n\nAuto-merging src/a.rs\n";
147 assert_eq!(merge_tree_conflicts(output), ["src/a.rs", "src/b.rs"]);
148 assert!(merge_tree_conflicts("4b825dc642cb6eb9a060e54bf8d69288fbee4904\n").is_empty());
149 }
150}