g1t/crates/runner/src/mergecheck.rs

152 lines5,972 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 crate::clone::clone(Path::new("/work"), auth, &["--no-checkout"], &base_remote, WORKDIR).context("could not clone the repository")?;
52 let base_branch = git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
53 crate::clone::ensure(workdir, auth, "origin", &base_branch, &base)?;
54 crate::clone::fetch(workdir, auth, &head_remote, &head_branch).context("could not fetch the pull request's change")?;
55 crate::clone::ensure(workdir, auth, &head_remote, &head_branch, &head)?;
56 // Shallow: deep enough for the two to share a commit to merge from.
57 if crate::clone::has(workdir, &base) && crate::clone::has(workdir, &head) {
58 crate::clone::share_history(workdir, auth, &[("origin", base_branch.as_str()), (head_remote.as_str(), head_branch.as_str())], &base, &head)?;
59 }
60 for (commit, what) in [(&base, "the target branch's commit"), (&head, "the change's commit")] {
61 let present = Command::new("git")
62 .current_dir(workdir)
63 .args(["cat-file", "-e", &format!("{commit}^{{commit}}")])
64 .status()
65 .is_ok_and(|status| status.success());
66 if !present {
67 bail!("{what} {} is no longer there; it has moved since", short(commit));
68 }
69 }
70
71 // In memory: git 2.38 and later.
72 let merged = Command::new("git")
73 .current_dir(workdir)
74 .args(["merge-tree", "--write-tree", "--name-only", "--no-messages", &base, &head])
75 .output()
76 .context("could not run git")?;
77 match merged.status.code() {
78 Some(0) => return Ok(Vec::new()),
79 Some(1) => return Ok(merge_tree_conflicts(&String::from_utf8_lossy(&merged.stdout))),
80 _ => {}
81 }
82
83 // Otherwise in a throwaway checkout.
84 git(workdir, &["config", "user.name", "g1t merge check"])?;
85 git(workdir, &["config", "user.email", "mergecheck@g1t.sh"])?;
86 git(workdir, &["checkout", "--quiet", "--detach", &base])?;
87 let clean = Command::new("git")
88 .current_dir(workdir)
89 .args(["merge", "--no-commit", "--no-ff", "--quiet", &head])
90 .output()
91 .is_ok_and(|output| output.status.success());
92 if clean {
93 return Ok(Vec::new());
94 }
95 let files: Vec<String> = git(workdir, &["diff", "--name-only", "--diff-filter=U"])?
96 .lines()
97 .map(str::to_owned)
98 .collect();
99 if files.is_empty() {
100 bail!("the merge failed for a reason other than a conflict");
101 }
102 Ok(files)
103}
104
105fn report(api: &str, pull: &str, token: &str, mut body: serde_json::Value) -> Result<()> {
106 body["token"] = token.into();
107 ureq::post(&format!("{api}/mergechecks/{pull}"))
108 .send_json(body)
109 .context("could not report the merge check")?;
110 Ok(())
111}
112
113pub fn main() -> i32 {
114 let (api, pull, token) = match (env("G1T_API"), env("MERGECHECK_PULL"), env("MERGECHECK_TOKEN")) {
115 (Ok(api), Ok(pull), Ok(token)) => (api, pull, token),
116 _ => {
117 eprintln!("g1t-runner: G1T_API, MERGECHECK_PULL and MERGECHECK_TOKEN must be set");
118 return 2;
119 }
120 };
121 let secrets: Vec<String> = ["G1T_TOKEN", "MERGECHECK_TOKEN"]
122 .iter()
123 .filter_map(|name| std::env::var(name).ok())
124 .filter(|secret| !secret.is_empty())
125 .collect();
126 let found = env("G1T_USER")
127 .and_then(|user| Ok(auth_option(&user, &env("G1T_TOKEN")?)))
128 .and_then(|auth| probe(&auth));
129 let body = match found {
130 Ok(conflicts) => serde_json::json!({ "conflicts": conflicts }),
131 Err(error) => serde_json::json!({ "error": redact(&format!("{error:#}"), &secrets) }),
132 };
133 match report(&api, &pull, &token, body) {
134 Ok(()) => 0,
135 Err(error) => {
136 eprintln!("g1t-runner: {error:#}");
137 1
138 }
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn conflicting_paths_are_read_from_merge_tree() {
148 let output = "4b825dc642cb6eb9a060e54bf8d69288fbee4904\nsrc/a.rs\nsrc/b.rs\nsrc/a.rs\n\nAuto-merging src/a.rs\n";
149 assert_eq!(merge_tree_conflicts(output), ["src/a.rs", "src/b.rs"]);
150 assert!(merge_tree_conflicts("4b825dc642cb6eb9a060e54bf8d69288fbee4904\n").is_empty());
151 }
152}