Skip to content

Commit 9f6e07f

Browse files
committed
bootstrap: handle fork shallow upstream detection
1 parent 2cd8aeb commit 9f6e07f

3 files changed

Lines changed: 152 additions & 35 deletions

File tree

src/bootstrap/src/core/config/tests.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,56 @@ fn test_auto_ci_changed_in_pr() {
723723
});
724724
}
725725

726+
#[test]
727+
fn test_push_ci_unchanged_anywhere_uses_nightly_ref() {
728+
git_test(|ctx| {
729+
let sha = ctx.create_upstream_merge(&["a"]);
730+
ctx.set_origin_nightly_ref(&sha);
731+
732+
ctx.create_branch("feature");
733+
ctx.modify("b");
734+
ctx.commit();
735+
736+
let src = ctx.check_modifications(&["c"], CiEnv::GitHubActions);
737+
assert_eq!(src, PathFreshness::LastModifiedUpstream { upstream: sha });
738+
});
739+
}
740+
741+
#[test]
742+
fn test_push_ci_changed_in_branch_uses_nightly_ref() {
743+
git_test(|ctx| {
744+
let sha = ctx.create_upstream_merge(&["a"]);
745+
ctx.set_origin_nightly_ref(&sha);
746+
747+
ctx.create_branch("feature");
748+
ctx.modify("b");
749+
ctx.commit();
750+
751+
let src = ctx.check_modifications(&["b"], CiEnv::GitHubActions);
752+
assert_eq!(src, modified(sha, &["b"]));
753+
});
754+
}
755+
756+
#[test]
757+
fn test_ci_merge_without_upstream_parent_falls_back_to_nightly_ref() {
758+
git_test(|ctx| {
759+
let sha = ctx.create_upstream_merge(&["a"]);
760+
ctx.set_origin_nightly_ref(&sha);
761+
762+
ctx.create_branch("feature");
763+
ctx.modify("b");
764+
ctx.commit();
765+
ctx.create_branch("nested");
766+
ctx.modify("c");
767+
ctx.commit();
768+
ctx.switch_to_branch("feature");
769+
ctx.merge("nested", "Tester <tester@rust-lang.org>");
770+
771+
let src = ctx.check_modifications(&["d"], CiEnv::GitHubActions);
772+
assert_eq!(src, PathFreshness::LastModifiedUpstream { upstream: sha });
773+
});
774+
}
775+
726776
#[test]
727777
fn test_local_uncommitted_modifications() {
728778
git_test(|ctx| {

src/bootstrap/src/utils/tests/git.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ impl GitCtx {
7474
self.run_git(&["rev-parse", "--abbrev-ref", "HEAD"])
7575
}
7676

77+
pub fn set_origin_branch_ref(&self, branch: &str, commit: &str) {
78+
let refname = format!("refs/remotes/origin/{branch}");
79+
self.run_git(&["update-ref", &refname, commit]);
80+
}
81+
82+
pub fn set_origin_nightly_ref(&self, commit: &str) {
83+
self.set_origin_branch_ref(&self.nightly_branch, commit);
84+
}
85+
7786
pub fn merge(&self, branch: &str, author: &str) {
7887
self.run_git(&["merge", "--no-commit", "--no-ff", branch]);
7988
self.run_git(&[

src/build_helper/src/git.rs

Lines changed: 93 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,11 @@ pub enum PathFreshness {
7070
/// were not modified upstream in the meantime. In that case we would be redownloading CI
7171
/// artifacts unnecessarily.
7272
///
73-
/// - In CI, we use a shallow clone of depth 2, i.e., we fetch only a single parent commit
74-
/// (which will be the most recent bors merge commit) and do not have access
75-
/// to the full git history. Luckily, we only need to distinguish between two situations:
76-
/// 1) The current PR made modifications to `target_paths`.
77-
/// In that case, a build is typically necessary.
78-
/// 2) The current PR did not make modifications to `target_paths`.
79-
/// In that case we simply take the latest upstream commit, because on CI there is no need to avoid
80-
/// redownloading.
73+
/// - In CI, we prefer a shallow merge-parent fast path when `HEAD` is a CI-generated merge
74+
/// commit. However, fork push workflows can also run in shallow clones where `HEAD` is just the
75+
/// branch tip, so blindly using `HEAD^1` there would pick a fork commit instead of the upstream
76+
/// base. In those cases we fall back to the fetched nightly branch ref, and only then to the
77+
/// normal upstream search logic.
8178
pub fn check_path_modifications(
8279
git_dir: &Path,
8380
config: &GitConfig<'_>,
@@ -90,24 +87,9 @@ pub fn check_path_modifications(
9087
}
9188

9289
let upstream_sha = if matches!(ci_env, CiEnv::GitHubActions) {
93-
// Here the situation is different for PR CI and try/auto CI.
94-
// For PR CI, we have the following history:
95-
// <merge commit made by GitHub>
96-
// 1-N PR commits
97-
// upstream merge commit made by bors
98-
//
99-
// For try/auto CI, we have the following history:
100-
// <**non-upstream** merge commit made by bors>
101-
// 1-N PR commits
102-
// upstream merge commit made by bors
103-
//
104-
// But on both cases, HEAD should be a merge commit.
105-
// So if HEAD contains modifications of `target_paths`, our PR has modified
106-
// them. If not, we can use the only available upstream commit for downloading
107-
// artifacts.
108-
109-
// Do not include HEAD, as it is never an upstream commit
110-
// If we do not find an upstream commit in CI, something is seriously wrong.
90+
// CI may be running on a synthetic merge ref or a shallow fork push ref.
91+
// `get_closest_upstream_commit` handles the trusted merge-parent fast path and falls back
92+
// to the fetched nightly branch ref when the merge-parent assumption is not valid.
11193
Some(
11294
get_closest_upstream_commit(Some(git_dir), config, ci_env)?
11395
.expect("No upstream commit was found on CI"),
@@ -224,23 +206,45 @@ fn get_latest_upstream_commit_that_modified_files(
224206
/// Returns the most recent (ordered chronologically) commit found in the local history that
225207
/// should exist upstream. We identify upstream commits by the e-mail of the commit
226208
/// author.
227-
///
228-
/// If we are in CI, we simply return our first parent.
229209
pub fn get_closest_upstream_commit(
230210
git_dir: Option<&Path>,
231211
config: &GitConfig<'_>,
232212
env: CiEnv,
233213
) -> Result<Option<String>, String> {
234-
let base = match env {
235-
CiEnv::None => "HEAD",
214+
match env {
215+
CiEnv::None => get_closest_upstream_commit_from_ref(git_dir, config, "HEAD"),
236216
CiEnv::GitHubActions => {
237-
// On CI, we should always have a non-upstream merge commit at the tip,
238-
// and our first parent should be the most recently merged upstream commit.
239-
// We thus simply return our first parent.
240-
return resolve_commit_sha(git_dir, "HEAD^1").map(Some);
217+
// CI-generated PR and auto-merge refs put a synthetic merge commit at HEAD, so the
218+
// first parent is usually the most recent upstream merge commit. Fork push workflows
219+
// do not have that shape, though, and in shallow clones `HEAD^1` can just be the
220+
// previous fork commit. Only trust the fast path when it points at an actual upstream
221+
// merge-bot commit, otherwise fall back to the fetched nightly branch.
222+
if is_merge_commit(git_dir, "HEAD")? {
223+
let parent = resolve_commit_sha(git_dir, "HEAD^1")?;
224+
if is_upstream_merge_commit(git_dir, &parent, config)? {
225+
return Ok(Some(parent));
226+
}
227+
}
228+
229+
let nightly_ref = format!("refs/remotes/origin/{}", config.nightly_branch);
230+
if git_ref_exists(git_dir, &nightly_ref)? {
231+
if let Some(upstream) =
232+
get_closest_upstream_commit_from_ref(git_dir, config, &nightly_ref)?
233+
{
234+
return Ok(Some(upstream));
235+
}
236+
}
237+
238+
get_closest_upstream_commit_from_ref(git_dir, config, "HEAD")
241239
}
242-
};
240+
}
241+
}
243242

243+
fn get_closest_upstream_commit_from_ref(
244+
git_dir: Option<&Path>,
245+
config: &GitConfig<'_>,
246+
base: &str,
247+
) -> Result<Option<String>, String> {
244248
let mut git = Command::new("git");
245249

246250
if let Some(git_dir) = git_dir {
@@ -272,6 +276,60 @@ pub fn get_closest_upstream_commit(
272276
if output.is_empty() { Ok(None) } else { Ok(Some(output)) }
273277
}
274278

279+
fn is_merge_commit(git_dir: Option<&Path>, commit_ref: &str) -> Result<bool, String> {
280+
let mut git = Command::new("git");
281+
if let Some(git_dir) = git_dir {
282+
git.current_dir(git_dir);
283+
}
284+
285+
let output = git
286+
.args(["rev-parse", "--verify", "--quiet", &format!("{commit_ref}^2")])
287+
.stderr(Stdio::null())
288+
.stdout(Stdio::null())
289+
.status()
290+
.map_err(|e| format!("failed to run command: {git:?}: {e}"))?;
291+
Ok(output.success())
292+
}
293+
294+
fn git_ref_exists(git_dir: Option<&Path>, refname: &str) -> Result<bool, String> {
295+
let mut git = Command::new("git");
296+
if let Some(git_dir) = git_dir {
297+
git.current_dir(git_dir);
298+
}
299+
300+
let output = git
301+
.args(["rev-parse", "--verify", "--quiet", refname])
302+
.stderr(Stdio::null())
303+
.stdout(Stdio::null())
304+
.status()
305+
.map_err(|e| format!("failed to run command: {git:?}: {e}"))?;
306+
Ok(output.success())
307+
}
308+
309+
fn is_upstream_merge_commit(
310+
git_dir: Option<&Path>,
311+
commit_ref: &str,
312+
config: &GitConfig<'_>,
313+
) -> Result<bool, String> {
314+
let mut git = Command::new("git");
315+
if let Some(git_dir) = git_dir {
316+
git.current_dir(git_dir);
317+
}
318+
319+
git.args(["show", "-s", "--format=%ae", commit_ref]);
320+
let author_email = output_result(&mut git)?.trim().to_owned();
321+
let merge_bot_email = extract_author_email(config.git_merge_commit_email);
322+
Ok(author_email == merge_bot_email || author_email == TEMPORARY_BORS_EMAIL)
323+
}
324+
325+
fn extract_author_email(author: &str) -> &str {
326+
author
327+
.split_once('<')
328+
.and_then(|(_, email)| email.trim().strip_suffix('>'))
329+
.map(str::trim)
330+
.unwrap_or_else(|| author.trim())
331+
}
332+
275333
/// Resolve the commit SHA of `commit_ref`.
276334
fn resolve_commit_sha(git_dir: Option<&Path>, commit_ref: &str) -> Result<String, String> {
277335
let mut git = Command::new("git");

0 commit comments

Comments
 (0)