build_helper/
git.rs

1use std::path::{Path, PathBuf};
2use std::process::{Command, Stdio};
3
4use crate::ci::CiEnv;
5
6pub struct GitConfig<'a> {
7    pub git_repository: &'a str,
8    pub nightly_branch: &'a str,
9    pub git_merge_commit_email: &'a str,
10}
11
12/// Runs a command and returns the output
13pub fn output_result(cmd: &mut Command) -> Result<String, String> {
14    let output = match cmd.stderr(Stdio::inherit()).output() {
15        Ok(status) => status,
16        Err(e) => return Err(format!("failed to run command: {:?}: {}", cmd, e)),
17    };
18    if !output.status.success() {
19        return Err(format!(
20            "command did not execute successfully: {:?}\n\
21             expected success, got: {}\n{}",
22            cmd,
23            output.status,
24            String::from_utf8(output.stderr).map_err(|err| format!("{err:?}"))?
25        ));
26    }
27    String::from_utf8(output.stdout).map_err(|err| format!("{err:?}"))
28}
29
30/// Finds the remote for rust-lang/rust.
31/// For example for these remotes it will return `upstream`.
32/// ```text
33/// origin  https://github.com/pietroalbani/rust.git (fetch)
34/// origin  https://github.com/pietroalbani/rust.git (push)
35/// upstream        https://github.com/rust-lang/rust (fetch)
36/// upstream        https://github.com/rust-lang/rust (push)
37/// ```
38pub fn get_rust_lang_rust_remote(
39    config: &GitConfig<'_>,
40    git_dir: Option<&Path>,
41) -> Result<String, String> {
42    let mut git = Command::new("git");
43    if let Some(git_dir) = git_dir {
44        git.current_dir(git_dir);
45    }
46    git.args(["config", "--local", "--get-regex", "remote\\..*\\.url"]);
47    let stdout = output_result(&mut git)?;
48
49    let rust_lang_remote = stdout
50        .lines()
51        .find(|remote| remote.contains(config.git_repository))
52        .ok_or_else(|| format!("{} remote not found", config.git_repository))?;
53
54    let remote_name =
55        rust_lang_remote.split('.').nth(1).ok_or_else(|| "remote name not found".to_owned())?;
56    Ok(remote_name.into())
57}
58
59pub fn rev_exists(rev: &str, git_dir: Option<&Path>) -> Result<bool, String> {
60    let mut git = Command::new("git");
61    if let Some(git_dir) = git_dir {
62        git.current_dir(git_dir);
63    }
64    git.args(["rev-parse", rev]);
65    let output = git.output().map_err(|err| format!("{err:?}"))?;
66
67    match output.status.code() {
68        Some(0) => Ok(true),
69        Some(128) => Ok(false),
70        None => Err(format!(
71            "git didn't exit properly: {}",
72            String::from_utf8(output.stderr).map_err(|err| format!("{err:?}"))?
73        )),
74        Some(code) => Err(format!(
75            "git command exited with status code: {code}: {}",
76            String::from_utf8(output.stderr).map_err(|err| format!("{err:?}"))?
77        )),
78    }
79}
80
81/// Returns the master branch from which we can take diffs to see changes.
82/// This will usually be rust-lang/rust master, but sometimes this might not exist.
83/// This could be because the user is updating their forked master branch using the GitHub UI
84/// and therefore doesn't need an upstream master branch checked out.
85/// We will then fall back to origin/master in the hope that at least this exists.
86pub fn updated_master_branch(
87    config: &GitConfig<'_>,
88    git_dir: Option<&Path>,
89) -> Result<String, String> {
90    let upstream_remote = get_rust_lang_rust_remote(config, git_dir)?;
91    let branch = config.nightly_branch;
92    for upstream_master in [format!("{upstream_remote}/{branch}"), format!("origin/{branch}")] {
93        if rev_exists(&upstream_master, git_dir)? {
94            return Ok(upstream_master);
95        }
96    }
97
98    Err("Cannot find any suitable upstream master branch".to_owned())
99}
100
101/// Finds the nearest merge commit by comparing the local `HEAD` with the upstream branch's state.
102/// To work correctly, the upstream remote must be properly configured using `git remote add <name> <url>`.
103/// In most cases `get_closest_merge_commit` is the function you are looking for as it doesn't require remote
104/// to be configured.
105fn git_upstream_merge_base(
106    config: &GitConfig<'_>,
107    git_dir: Option<&Path>,
108) -> Result<String, String> {
109    let updated_master = updated_master_branch(config, git_dir)?;
110    let mut git = Command::new("git");
111    if let Some(git_dir) = git_dir {
112        git.current_dir(git_dir);
113    }
114    Ok(output_result(git.arg("merge-base").arg(&updated_master).arg("HEAD"))?.trim().to_owned())
115}
116
117/// Searches for the nearest merge commit in the repository that also exists upstream.
118///
119/// It looks for the most recent commit made by the merge bot by matching the author's email
120/// address with the merge bot's email.
121pub fn get_closest_merge_commit(
122    git_dir: Option<&Path>,
123    config: &GitConfig<'_>,
124    target_paths: &[PathBuf],
125) -> Result<String, String> {
126    let mut git = Command::new("git");
127
128    if let Some(git_dir) = git_dir {
129        git.current_dir(git_dir);
130    }
131
132    let channel = include_str!("../../ci/channel");
133
134    let merge_base = {
135        if CiEnv::is_ci() &&
136            // FIXME: When running on rust-lang managed CI and it's not a nightly build,
137            // `git_upstream_merge_base` fails with an error message similar to this:
138            // ```
139            //    called `Result::unwrap()` on an `Err` value: "command did not execute successfully:
140            //    cd \"/checkout\" && \"git\" \"merge-base\" \"origin/master\" \"HEAD\"\nexpected success, got: exit status: 1\n"
141            // ```
142            // Investigate and resolve this issue instead of skipping it like this.
143            (channel == "nightly" || !CiEnv::is_rust_lang_managed_ci_job())
144        {
145            git_upstream_merge_base(config, git_dir).unwrap()
146        } else {
147            // For non-CI environments, ignore rust-lang/rust upstream as it usually gets
148            // outdated very quickly.
149            "HEAD".to_string()
150        }
151    };
152
153    git.args([
154        "rev-list",
155        &format!("--author={}", config.git_merge_commit_email),
156        "-n1",
157        "--first-parent",
158        &merge_base,
159    ]);
160
161    if !target_paths.is_empty() {
162        git.arg("--").args(target_paths);
163    }
164
165    Ok(output_result(&mut git)?.trim().to_owned())
166}
167
168/// Returns the files that have been modified in the current branch compared to the master branch.
169/// The `extensions` parameter can be used to filter the files by their extension.
170/// Does not include removed files.
171/// If `extensions` is empty, all files will be returned.
172pub fn get_git_modified_files(
173    config: &GitConfig<'_>,
174    git_dir: Option<&Path>,
175    extensions: &[&str],
176) -> Result<Option<Vec<String>>, String> {
177    let merge_base = get_closest_merge_commit(git_dir, config, &[])?;
178
179    let mut git = Command::new("git");
180    if let Some(git_dir) = git_dir {
181        git.current_dir(git_dir);
182    }
183    let files = output_result(git.args(["diff-index", "--name-status", merge_base.trim()]))?
184        .lines()
185        .filter_map(|f| {
186            let (status, name) = f.trim().split_once(char::is_whitespace).unwrap();
187            if status == "D" {
188                None
189            } else if Path::new(name).extension().map_or(false, |ext| {
190                extensions.is_empty() || extensions.contains(&ext.to_str().unwrap())
191            }) {
192                Some(name.to_owned())
193            } else {
194                None
195            }
196        })
197        .collect();
198    Ok(Some(files))
199}
200
201/// Returns the files that haven't been added to git yet.
202pub fn get_git_untracked_files(
203    config: &GitConfig<'_>,
204    git_dir: Option<&Path>,
205) -> Result<Option<Vec<String>>, String> {
206    let Ok(_updated_master) = updated_master_branch(config, git_dir) else {
207        return Ok(None);
208    };
209    let mut git = Command::new("git");
210    if let Some(git_dir) = git_dir {
211        git.current_dir(git_dir);
212    }
213
214    let files = output_result(git.arg("ls-files").arg("--others").arg("--exclude-standard"))?
215        .lines()
216        .map(|s| s.trim().to_owned())
217        .collect();
218    Ok(Some(files))
219}