From a1e3b439965e5f553f949e6c0e2132e12e1cc0a3 Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Tue, 5 Aug 2025 17:32:49 +0200 Subject: [PATCH 1/3] feat(github): fallback on remote branch for --affected --- crates/turborepo-scm/src/git.rs | 159 ++++++++++++++++++++++++++++---- 1 file changed, 142 insertions(+), 17 deletions(-) diff --git a/crates/turborepo-scm/src/git.rs b/crates/turborepo-scm/src/git.rs index f14ab462cbe2a..054d5eceba3da 100644 --- a/crates/turborepo-scm/src/git.rs +++ b/crates/turborepo-scm/src/git.rs @@ -244,30 +244,46 @@ impl GitRepo { // because at this point we know we're in a GITHUB CI environment // and we should really know by now what the base ref is // so it's better to just error if something went wrong - return match self.execute_git_command(&["rev-parse", &github_base_ref], "") { - Ok(_) => { - eprintln!("Resolved base ref from GitHub Actions event: {github_base_ref}"); - Ok(github_base_ref) - } - Err(e) => { - eprintln!( - "Failed to resolve base ref '{github_base_ref}' from GitHub Actions \ - event: {e}" - ); - Err(Error::UnableToResolveRef) - } - }; + + // Try the github_base_ref first + if let Ok(_) = self.execute_git_command(&["rev-parse", &github_base_ref], "") { + eprintln!("Resolved base ref from GitHub Actions event: {github_base_ref}"); + return Ok(github_base_ref); + } + + // Try the remote version of github_base_ref + let remote_github_base_ref = format!("origin/{}", github_base_ref); + if let Ok(_) = self.execute_git_command(&["rev-parse", &remote_github_base_ref], "") { + eprintln!("Resolved base ref from GitHub Actions event (remote): {remote_github_base_ref}"); + return Ok(remote_github_base_ref); + } + + eprintln!( + "Failed to resolve base ref '{github_base_ref}' or '{remote_github_base_ref}' from GitHub Actions event" + ); + return Err(Error::UnableToResolveRef); } - let main_result = self.execute_git_command(&["rev-parse", "main"], ""); - if main_result.is_ok() { + // Try local main branch + if let Ok(_) = self.execute_git_command(&["rev-parse", "main"], "") { return Ok("main".to_string()); } - let master_result = self.execute_git_command(&["rev-parse", "master"], ""); - if master_result.is_ok() { + // Try remote main branch + if let Ok(_) = self.execute_git_command(&["rev-parse", "origin/main"], "") { + return Ok("origin/main".to_string()); + } + + // Try local master branch + if let Ok(_) = self.execute_git_command(&["rev-parse", "master"], "") { return Ok("master".to_string()); } + + // Try remote master branch + if let Ok(_) = self.execute_git_command(&["rev-parse", "origin/master"], "") { + return Ok("origin/master".to_string()); + } + Err(Error::UnableToResolveRef) } @@ -1049,6 +1065,115 @@ mod tests { Ok(()) } + #[test] + fn test_resolve_base_falls_back_to_remote_main() -> Result<(), Error> { + let (repo_root, repo) = setup_repository(None)?; + let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap(); + + let file = root.join_component("todo.txt"); + file.create_with_contents("1. test remote fallback")?; + let first_commit = commit_file(&repo, Path::new("todo.txt"), None); + + // Delete the local main branch that was created by the commit + repo.find_reference("refs/heads/main").unwrap().delete().unwrap(); + + // Create a remote tracking branch for main + repo.reference( + "refs/remotes/origin/main", + first_commit, + false, + "create remote main", + ).unwrap(); + + let git_repo = GitRepo::find(&root).unwrap(); + let result = git_repo.resolve_base(None, CIEnv::none()).unwrap(); + + assert_eq!(result, "origin/main"); + Ok(()) + } + + #[test] + fn test_resolve_base_falls_back_to_remote_master() -> Result<(), Error> { + let (repo_root, repo) = setup_repository(None)?; + let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap(); + + let file = root.join_component("todo.txt"); + file.create_with_contents("1. test remote fallback")?; + let first_commit = commit_file(&repo, Path::new("todo.txt"), None); + + // Delete the local main branch that was created by the commit + repo.find_reference("refs/heads/main").unwrap().delete().unwrap(); + + // Create a remote tracking branch for master (no main exists) + repo.reference( + "refs/remotes/origin/master", + first_commit, + false, + "create remote master", + ).unwrap(); + + let git_repo = GitRepo::find(&root).unwrap(); + let result = git_repo.resolve_base(None, CIEnv::none()).unwrap(); + + assert_eq!(result, "origin/master"); + Ok(()) + } + + #[test] + fn test_resolve_base_github_actions_falls_back_to_remote() -> Result<(), Error> { + let (repo_root, repo) = setup_repository(None)?; + let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap(); + + let file = root.join_component("todo.txt"); + file.create_with_contents("1. test github remote fallback")?; + let first_commit = commit_file(&repo, Path::new("todo.txt"), None); + + // Delete the local main branch that was created by the commit + repo.find_reference("refs/heads/main").unwrap().delete().unwrap(); + + // Create a remote tracking branch for feature-branch + repo.reference( + "refs/remotes/origin/feature-branch", + first_commit, + false, + "create remote feature-branch", + ).unwrap(); + + let env = CIEnv { + is_github_actions: true, + github_base_ref: Ok("feature-branch".to_string()), + github_event_path: Err(VarError::NotPresent), + }; + + let git_repo = GitRepo::find(&root).unwrap(); + let result = git_repo.resolve_base(None, env).unwrap(); + + assert_eq!(result, "origin/feature-branch"); + Ok(()) + } + + #[test] + fn test_resolve_base_github_actions_errors_when_branch_missing() -> Result<(), Error> { + let (repo_root, repo) = setup_repository(None)?; + let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap(); + + let file = root.join_component("todo.txt"); + file.create_with_contents("1. test error case")?; + let _first_commit = commit_file(&repo, Path::new("todo.txt"), None); + + let env = CIEnv { + is_github_actions: true, + github_base_ref: Ok("non-existent-branch".to_string()), + github_event_path: Err(VarError::NotPresent), + }; + + let git_repo = GitRepo::find(&root).unwrap(); + let result = git_repo.resolve_base(None, env); + + assert_matches!(result, Err(Error::UnableToResolveRef)); + Ok(()) + } + #[test] fn test_error_cases() -> Result<(), Error> { let repo_dir = tempfile::tempdir()?; From a1bbb80881d8094bca1006ac91bcfffa8e472555 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 28 Jul 2026 22:51:44 +0000 Subject: [PATCH 2/3] feat: Resolve remote base refs behind future flag --- .../content/docs/reference/configuration.mdx | 18 +++ crates/turborepo-lib/src/run/builder.rs | 4 +- crates/turborepo-schema-gen/src/main.rs | 8 ++ crates/turborepo-scm/src/git.rs | 130 ++++++++++++++---- crates/turborepo-scm/src/lib.rs | 11 ++ crates/turborepo-scm/src/repo_index.rs | 1 + .../turborepo-turbo-json/src/future_flags.rs | 31 +++-- crates/turborepo-turbo-json/src/lib.rs | 29 ++++ packages/turbo-types/schemas/schema.json | 5 + packages/turbo-types/schemas/schema.v2.json | 5 + packages/turbo-types/src/types/config-v2.ts | 8 ++ 11 files changed, 213 insertions(+), 37 deletions(-) diff --git a/apps/docs/content/docs/reference/configuration.mdx b/apps/docs/content/docs/reference/configuration.mdx index f5b1b9b527b37..4c0a7d522242e 100644 --- a/apps/docs/content/docs/reference/configuration.mdx +++ b/apps/docs/content/docs/reference/configuration.mdx @@ -362,6 +362,24 @@ In this example, if only `README.md` changed in a package, `build` would not run selected, regardless of individual task `inputs`. +#### `resolveRemoteBaseRefs` + +Default: `false` + +Fall back to `origin/` when an inferred base branch is not available as a local ref. This is useful in CI environments that use detached checkouts and only create remote-tracking refs. + +Local refs continue to take precedence. This flag only affects inferred base refs, including the base branch reported by GitHub Actions and the default `main` or `master` branch. Explicit refs provided through `TURBO_SCM_BASE` are used unchanged. + +```jsonc title="./turbo.json" +{ + "futureFlags": { + "resolveRemoteBaseRefs": true, + }, +} +``` + +For example, when GitHub Actions reports `main` as the pull request base but the checkout only contains `origin/main`, Turborepo will use `origin/main` for `--affected` comparisons. + #### `watchUsingTaskInputs` Default: `false` diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index b64cb50dbb383..5209cdbb68ec6 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -479,11 +479,13 @@ impl RunBuilder { let repo_index_task = { let repo_root = self.repo_root.clone(); let git_root = self.opts.git_root.clone(); + let resolve_remote_base_refs = self.opts.future_flags.resolve_remote_base_refs; tokio::task::spawn_blocking(move || { let scm = match git_root { Some(root) => SCM::new_with_git_root(&repo_root, root), None => SCM::new(&repo_root), - }; + } + .with_remote_base_ref_fallback(resolve_remote_base_refs); // The tracked half of the repo index only needs `.git/index`. let tracked_index = { let _span = tracing::info_span!("build_tracked_repo_index_gix").entered(); diff --git a/crates/turborepo-schema-gen/src/main.rs b/crates/turborepo-schema-gen/src/main.rs index 1681cf89ea421..2ac0cb06bd705 100644 --- a/crates/turborepo-schema-gen/src/main.rs +++ b/crates/turborepo-schema-gen/src/main.rs @@ -768,6 +768,14 @@ export interface FutureFlags { * @defaultValue `false` */ affectedUsingTaskInputs?: boolean; + /** + * Fall back to `origin/` when an inferred base branch is not + * available as a local ref. This supports detached checkouts in CI where + * only remote-tracking refs are present. + * + * @defaultValue `false` + */ + resolveRemoteBaseRefs?: boolean; /** * Use task-level `inputs` globs to determine which tasks to re-run when * files change in `turbo watch`. When enabled, only tasks whose declared diff --git a/crates/turborepo-scm/src/git.rs b/crates/turborepo-scm/src/git.rs index 0953b8f2dfbaf..789bdb74bd5f6 100644 --- a/crates/turborepo-scm/src/git.rs +++ b/crates/turborepo-scm/src/git.rs @@ -491,24 +491,41 @@ impl GitRepo { // because at this point we know we're in a GITHUB CI environment // and we should really know by now what the base ref is // so it's better to just error if something went wrong - return match self - .execute_git_command(&["rev-parse", "--end-of-options", &github_base_ref], "") - { + let local_result = + self.execute_git_command(&["rev-parse", "--end-of-options", &github_base_ref], ""); + match local_result { Ok(_) => { eprintln!("Resolved base ref from GitHub Actions event: {github_base_ref}"); - Ok(github_base_ref) + return Ok(github_base_ref); + } + Err(local_error) if self.resolve_remote_base_refs => { + let remote_ref = format!("origin/{github_base_ref}"); + match self + .execute_git_command(&["rev-parse", "--end-of-options", &remote_ref], "") + { + Ok(_) => { + eprintln!("Resolved base ref from GitHub Actions event: {remote_ref}"); + return Ok(remote_ref); + } + Err(remote_error) => eprintln!( + "Failed to resolve base ref '{github_base_ref}' ({local_error}) or \ + '{remote_ref}' ({remote_error}) from GitHub Actions event" + ), + } } - Err(e) => { + Err(error) => { eprintln!( "Failed to resolve base ref '{github_base_ref}' from GitHub Actions \ - event: {e}" + event: {error}" ); - Err(Error::UnableToResolveRef) } - }; + } + return Err(Error::UnableToResolveRef); } - default_base_ref(|branch| self.execute_git_command(&["rev-parse", branch], "").is_ok()) + default_base_ref(self.resolve_remote_base_refs, |branch| { + self.execute_git_command(&["rev-parse", branch], "").is_ok() + }) } fn changed_files( @@ -629,13 +646,18 @@ impl GitRepo { } } -fn default_base_ref(mut branch_exists: impl FnMut(&str) -> bool) -> Result { - if branch_exists("main") { - return Ok("main".to_string()); - } - - if branch_exists("master") { - return Ok("master".to_string()); +fn default_base_ref( + resolve_remote_base_refs: bool, + mut branch_exists: impl FnMut(&str) -> bool, +) -> Result { + for branch in ["main", "master"] { + if branch_exists(branch) { + return Ok(branch.to_string()); + } + let remote_ref = format!("origin/{branch}"); + if resolve_remote_base_refs && branch_exists(&remote_ref) { + return Ok(remote_ref); + } } Err(Error::UnableToResolveRef) @@ -1249,21 +1271,80 @@ mod tests { #[test] fn test_default_base_ref_resolution() { - for (branches, expected) in [ - (vec!["main"], Some("main")), - (vec!["master"], Some("master")), - (vec!["ziltoid"], None), - (vec!["ziltoid", "main"], Some("main")), - (vec!["ziltoid", "master"], Some("master")), - (vec!["ziltoid", "master", "main"], Some("main")), + for (resolve_remote_base_refs, branches, expected) in [ + (false, vec!["main"], Some("main")), + (false, vec!["master"], Some("master")), + (false, vec!["origin/main"], None), + (true, vec!["origin/main"], Some("origin/main")), + (true, vec!["origin/master"], Some("origin/master")), + (true, vec!["master", "origin/main"], Some("origin/main")), + (true, vec!["main", "origin/main"], Some("main")), + (true, vec!["ziltoid"], None), ] { let branches = HashSet::<&str>::from_iter(branches); - let actual = default_base_ref(|branch| branches.contains(branch)).ok(); + let actual = + default_base_ref(resolve_remote_base_refs, |branch| branches.contains(branch)).ok(); assert_eq!(actual.as_deref(), expected); } } + #[test] + fn test_github_base_ref_remote_fallback_respects_flag() -> Result<(), Error> { + let (repo_root, repo_path) = setup_repository(Some("main"))?; + let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap(); + let file = root.join_component("todo.txt"); + file.create_with_contents("test remote base ref fallback")?; + let commit = commit_file(&repo_path, Path::new("todo.txt"), None); + + run_git(&repo_path, &["checkout", "--detach", &commit]); + run_git(&repo_path, &["branch", "-D", "main"]); + run_git( + &repo_path, + &["update-ref", "refs/remotes/origin/main", &commit], + ); + + let github_env = || CIEnv { + is_github_actions: true, + github_base_ref: Ok("main".to_string()), + github_event_path: Err(VarError::NotPresent), + }; + let mut git = GitRepo::find(&root).unwrap(); + + assert_matches!( + git.resolve_base(None, github_env()), + Err(Error::UnableToResolveRef) + ); + + git.resolve_remote_base_refs = true; + assert_eq!( + git.resolve_base(None, github_env())?, + "origin/main".to_string() + ); + + Ok(()) + } + + #[test] + fn test_github_base_ref_remote_fallback_errors_when_refs_are_missing() -> Result<(), Error> { + let (repo_root, repo_path) = setup_repository(Some("main"))?; + let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap(); + let file = root.join_component("todo.txt"); + file.create_with_contents("test missing base refs")?; + commit_file(&repo_path, Path::new("todo.txt"), None); + + let mut git = GitRepo::find(&root).unwrap(); + git.resolve_remote_base_refs = true; + let env = CIEnv { + is_github_actions: true, + github_base_ref: Ok("missing".to_string()), + github_event_path: Err(VarError::NotPresent), + }; + + assert_matches!(git.resolve_base(None, env), Err(Error::UnableToResolveRef)); + Ok(()) + } + #[test] fn test_base_resolution_uses_override_before_default_branches() -> Result<(), Error> { let (repo_root, repo_path) = setup_repository(Some("main"))?; @@ -2351,6 +2432,7 @@ mod tests { root: root.to_owned(), bin, attrs: std::sync::OnceLock::new(), + resolve_remote_base_refs: false, slowest_files: None, } } diff --git a/crates/turborepo-scm/src/lib.rs b/crates/turborepo-scm/src/lib.rs index 78a1ef129a348..3d54eb6326161 100644 --- a/crates/turborepo-scm/src/lib.rs +++ b/crates/turborepo-scm/src/lib.rs @@ -231,6 +231,7 @@ pub struct GitRepo { root: AbsoluteSystemPathBuf, bin: AbsoluteSystemPathBuf, attrs: OnceLock>, + resolve_remote_base_refs: bool, /// Optional recorder for the slowest-to-hash files. Set by long-running /// consumers (the file watcher) so they can diagnose a stalled startup. slowest_files: Option>, @@ -250,6 +251,7 @@ impl Clone for GitRepo { root: self.root.clone(), bin: self.bin.clone(), attrs: OnceLock::new(), + resolve_remote_base_refs: self.resolve_remote_base_refs, slowest_files: self.slowest_files.clone(), } } @@ -284,6 +286,7 @@ impl GitRepo { root, bin, attrs: OnceLock::new(), + resolve_remote_base_refs: false, slowest_files: None, }) } @@ -355,6 +358,7 @@ impl SCM { root: git_root, bin, attrs: OnceLock::new(), + resolve_remote_base_refs: false, slowest_files: None, }), Err(e) => { @@ -367,6 +371,13 @@ impl SCM { } } + pub fn with_remote_base_ref_fallback(mut self, enabled: bool) -> Self { + if let SCM::Git(git) = &mut self { + git.resolve_remote_base_refs = enabled; + } + self + } + /// Attach a recorder that tracks the slowest-to-hash files. Long-running /// consumers (the file watcher) use this to diagnose a stalled startup /// caused by hashing a very large file. No-op for `SCM::Manual`. diff --git a/crates/turborepo-scm/src/repo_index.rs b/crates/turborepo-scm/src/repo_index.rs index 2127a306e487e..e4092735aabd7 100644 --- a/crates/turborepo-scm/src/repo_index.rs +++ b/crates/turborepo-scm/src/repo_index.rs @@ -1691,6 +1691,7 @@ mod tests { root: root.clone(), bin: root, attrs: OnceLock::new(), + resolve_remote_base_refs: false, slowest_files: None, } } diff --git a/crates/turborepo-turbo-json/src/future_flags.rs b/crates/turborepo-turbo-json/src/future_flags.rs index 041a139cd60f7..00977fe3487cf 100644 --- a/crates/turborepo-turbo-json/src/future_flags.rs +++ b/crates/turborepo-turbo-json/src/future_flags.rs @@ -57,6 +57,11 @@ pub struct FutureFlags { /// selecting all tasks in changed packages. #[serde(default)] pub affected_using_task_inputs: bool, + /// Fall back to `origin/` when an inferred base branch is not + /// available as a local ref. This supports detached checkouts in CI where + /// only remote-tracking refs are present. + #[serde(default)] + pub resolve_remote_base_refs: bool, /// Use task-level `inputs` globs to determine which tasks to re-run when /// files change in `turbo watch`. When enabled, only tasks whose declared /// inputs match the changed files are re-executed, rather than re-running @@ -158,18 +163,18 @@ impl TS for FutureFlags { fn inline() -> String { "{ errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, longerSignatureKey?: \ - boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: boolean, \ - pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: boolean, \ - strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ + boolean, affectedUsingTaskInputs?: boolean, resolveRemoteBaseRefs?: boolean, \ + watchUsingTaskInputs?: boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: \ + boolean, strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean }" .to_string() } fn inline_flattened() -> String { "{ errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, longerSignatureKey?: \ - boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: boolean, \ - pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: boolean, \ - strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ + boolean, affectedUsingTaskInputs?: boolean, resolveRemoteBaseRefs?: boolean, \ + watchUsingTaskInputs?: boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: \ + boolean, strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean }" .to_string() } @@ -177,18 +182,20 @@ impl TS for FutureFlags { fn decl() -> String { "type FutureFlags = { errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, \ longerSignatureKey?: boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: \ - boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: boolean, \ - strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ - experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean };" + boolean, resolveRemoteBaseRefs?: boolean, pruneIncludesGlobalFiles?: boolean, \ + filterUsingTasks?: boolean, strictTaskEntrypointSelection?: boolean, \ + globalConfiguration?: boolean, experimentalCargoWorkspaces?: boolean, \ + experimentalTaskCommand?: boolean };" .to_string() } fn decl_concrete() -> String { "type FutureFlags = { errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, \ longerSignatureKey?: boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: \ - boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: boolean, \ - strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ - experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean };" + boolean, resolveRemoteBaseRefs?: boolean, pruneIncludesGlobalFiles?: boolean, \ + filterUsingTasks?: boolean, strictTaskEntrypointSelection?: boolean, \ + globalConfiguration?: boolean, experimentalCargoWorkspaces?: boolean, \ + experimentalTaskCommand?: boolean };" .to_string() } diff --git a/crates/turborepo-turbo-json/src/lib.rs b/crates/turborepo-turbo-json/src/lib.rs index 5a756821e0089..95ec252bed785 100644 --- a/crates/turborepo-turbo-json/src/lib.rs +++ b/crates/turborepo-turbo-json/src/lib.rs @@ -782,6 +782,35 @@ mod tests { assert!(turbo_json.unwrap().future_flags.longer_signature_key); } + #[test] + fn test_deserialize_future_flags_resolve_remote_base_refs() { + let json = r#"{ + "tasks": {}, + "futureFlags": { + "resolveRemoteBaseRefs": true + } + }"#; + + let (deserialized, diagnostics) = deserialize_from_json_str( + json, + JsonParserOptions::default().with_allow_comments(), + "turbo.json", + ); + assert!(diagnostics.is_empty()); + let raw_turbo_json: RawTurboJson = deserialized.unwrap(); + assert!( + raw_turbo_json + .future_flags + .as_ref() + .unwrap() + .as_inner() + .resolve_remote_base_refs + ); + + let turbo_json = TurboJson::try_from(raw_turbo_json).unwrap(); + assert!(turbo_json.future_flags.resolve_remote_base_refs); + } + #[test] fn test_is_root_config_with_root_path() { let turbo_json = TurboJson { diff --git a/packages/turbo-types/schemas/schema.json b/packages/turbo-types/schemas/schema.json index 3e907fba56a12..ae3659c871c36 100644 --- a/packages/turbo-types/schemas/schema.json +++ b/packages/turbo-types/schemas/schema.json @@ -330,6 +330,11 @@ "default": false, "type": "boolean" }, + "resolveRemoteBaseRefs": { + "description": "Fall back to `origin/` when an inferred base branch is not available as a local ref. This supports detached checkouts in CI where only remote-tracking refs are present.", + "default": false, + "type": "boolean" + }, "strictTaskEntrypointSelection": { "description": "Select requested task entrypoints according to whether the task resolves a command in the repository. When any package can run a requested task, packages without a command are not used as entrypoints. Tasks with no command anywhere remain available for graph-only orchestration, and missing tasks reached as dependencies remain in the Task Graph.", "default": false, diff --git a/packages/turbo-types/schemas/schema.v2.json b/packages/turbo-types/schemas/schema.v2.json index ffdd75200c473..4339b802d1733 100644 --- a/packages/turbo-types/schemas/schema.v2.json +++ b/packages/turbo-types/schemas/schema.v2.json @@ -286,6 +286,11 @@ "default": false, "type": "boolean" }, + "resolveRemoteBaseRefs": { + "description": "Fall back to `origin/` when an inferred base branch is not available as a local ref. This supports detached checkouts in CI where only remote-tracking refs are present.", + "default": false, + "type": "boolean" + }, "errorsOnlyShowHash": { "description": "When using `outputLogs: \"errors-only\"`, show task hashes when tasks complete successfully. This provides visibility into which tasks are running without showing full output logs.", "default": false, diff --git a/packages/turbo-types/src/types/config-v2.ts b/packages/turbo-types/src/types/config-v2.ts index eaec1d34facee..6076a2635fac8 100644 --- a/packages/turbo-types/src/types/config-v2.ts +++ b/packages/turbo-types/src/types/config-v2.ts @@ -266,6 +266,14 @@ export interface FutureFlags { * @defaultValue `false` */ affectedUsingTaskInputs?: boolean; + /** + * Fall back to `origin/` when an inferred base branch is not + * available as a local ref. This supports detached checkouts in CI where + * only remote-tracking refs are present. + * + * @defaultValue `false` + */ + resolveRemoteBaseRefs?: boolean; /** * Use task-level `inputs` globs to determine which tasks to re-run when * files change in `turbo watch`. When enabled, only tasks whose declared From fd090c833127efcddd7e93382683f67d2a09335e Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 28 Jul 2026 23:23:39 +0000 Subject: [PATCH 3/3] fix: Scope remote base ref fallback to GitHub Actions --- .../content/docs/reference/configuration.mdx | 10 ++-- crates/turborepo-lib/src/run/builder.rs | 9 ++- crates/turborepo-schema-gen/src/main.rs | 8 +-- crates/turborepo-scm/src/git.rs | 56 +++++++++---------- crates/turborepo-scm/src/lib.rs | 12 ++-- crates/turborepo-scm/src/repo_index.rs | 2 +- .../turborepo-turbo-json/src/future_flags.rs | 20 +++---- crates/turborepo-turbo-json/src/lib.rs | 12 ++-- packages/turbo-types/schemas/schema.json | 4 +- packages/turbo-types/schemas/schema.v2.json | 4 +- packages/turbo-types/src/types/config-v2.ts | 8 +-- 11 files changed, 73 insertions(+), 72 deletions(-) diff --git a/apps/docs/content/docs/reference/configuration.mdx b/apps/docs/content/docs/reference/configuration.mdx index 4c0a7d522242e..1cd98e21a072f 100644 --- a/apps/docs/content/docs/reference/configuration.mdx +++ b/apps/docs/content/docs/reference/configuration.mdx @@ -362,24 +362,22 @@ In this example, if only `README.md` changed in a package, `build` would not run selected, regardless of individual task `inputs`. -#### `resolveRemoteBaseRefs` +#### `githubActionsRemoteBaseRefFallback` Default: `false` -Fall back to `origin/` when an inferred base branch is not available as a local ref. This is useful in CI environments that use detached checkouts and only create remote-tracking refs. +When GitHub Actions reports a pull request's base branch but that branch is not available as a local ref, fall back to `origin/`. This supports detached checkouts created by `actions/checkout`, where the base branch may only exist as a remote-tracking ref. -Local refs continue to take precedence. This flag only affects inferred base refs, including the base branch reported by GitHub Actions and the default `main` or `master` branch. Explicit refs provided through `TURBO_SCM_BASE` are used unchanged. +Local refs continue to take precedence. This flag only affects base refs inferred from GitHub Actions. Explicit refs provided through `TURBO_SCM_BASE` and default `main` or `master` resolution are unchanged. ```jsonc title="./turbo.json" { "futureFlags": { - "resolveRemoteBaseRefs": true, + "githubActionsRemoteBaseRefFallback": true, }, } ``` -For example, when GitHub Actions reports `main` as the pull request base but the checkout only contains `origin/main`, Turborepo will use `origin/main` for `--affected` comparisons. - #### `watchUsingTaskInputs` Default: `false` diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index 5209cdbb68ec6..1421ba40755bd 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -479,13 +479,18 @@ impl RunBuilder { let repo_index_task = { let repo_root = self.repo_root.clone(); let git_root = self.opts.git_root.clone(); - let resolve_remote_base_refs = self.opts.future_flags.resolve_remote_base_refs; + let github_actions_remote_base_ref_fallback = self + .opts + .future_flags + .github_actions_remote_base_ref_fallback; tokio::task::spawn_blocking(move || { let scm = match git_root { Some(root) => SCM::new_with_git_root(&repo_root, root), None => SCM::new(&repo_root), } - .with_remote_base_ref_fallback(resolve_remote_base_refs); + .with_github_actions_remote_base_ref_fallback( + github_actions_remote_base_ref_fallback, + ); // The tracked half of the repo index only needs `.git/index`. let tracked_index = { let _span = tracing::info_span!("build_tracked_repo_index_gix").entered(); diff --git a/crates/turborepo-schema-gen/src/main.rs b/crates/turborepo-schema-gen/src/main.rs index 2ac0cb06bd705..ee8ec66d34a48 100644 --- a/crates/turborepo-schema-gen/src/main.rs +++ b/crates/turborepo-schema-gen/src/main.rs @@ -769,13 +769,13 @@ export interface FutureFlags { */ affectedUsingTaskInputs?: boolean; /** - * Fall back to `origin/` when an inferred base branch is not - * available as a local ref. This supports detached checkouts in CI where - * only remote-tracking refs are present. + * When GitHub Actions reports a base branch that is not available as a + * local ref, fall back to `origin/`. This supports detached + * checkouts where only remote-tracking refs are present. * * @defaultValue `false` */ - resolveRemoteBaseRefs?: boolean; + githubActionsRemoteBaseRefFallback?: boolean; /** * Use task-level `inputs` globs to determine which tasks to re-run when * files change in `turbo watch`. When enabled, only tasks whose declared diff --git a/crates/turborepo-scm/src/git.rs b/crates/turborepo-scm/src/git.rs index 789bdb74bd5f6..fd8fba29def96 100644 --- a/crates/turborepo-scm/src/git.rs +++ b/crates/turborepo-scm/src/git.rs @@ -498,7 +498,7 @@ impl GitRepo { eprintln!("Resolved base ref from GitHub Actions event: {github_base_ref}"); return Ok(github_base_ref); } - Err(local_error) if self.resolve_remote_base_refs => { + Err(local_error) if self.github_actions_remote_base_ref_fallback => { let remote_ref = format!("origin/{github_base_ref}"); match self .execute_git_command(&["rev-parse", "--end-of-options", &remote_ref], "") @@ -523,9 +523,7 @@ impl GitRepo { return Err(Error::UnableToResolveRef); } - default_base_ref(self.resolve_remote_base_refs, |branch| { - self.execute_git_command(&["rev-parse", branch], "").is_ok() - }) + default_base_ref(|branch| self.execute_git_command(&["rev-parse", branch], "").is_ok()) } fn changed_files( @@ -646,18 +644,13 @@ impl GitRepo { } } -fn default_base_ref( - resolve_remote_base_refs: bool, - mut branch_exists: impl FnMut(&str) -> bool, -) -> Result { - for branch in ["main", "master"] { - if branch_exists(branch) { - return Ok(branch.to_string()); - } - let remote_ref = format!("origin/{branch}"); - if resolve_remote_base_refs && branch_exists(&remote_ref) { - return Ok(remote_ref); - } +fn default_base_ref(mut branch_exists: impl FnMut(&str) -> bool) -> Result { + if branch_exists("main") { + return Ok("main".to_string()); + } + + if branch_exists("master") { + return Ok("master".to_string()); } Err(Error::UnableToResolveRef) @@ -1271,19 +1264,17 @@ mod tests { #[test] fn test_default_base_ref_resolution() { - for (resolve_remote_base_refs, branches, expected) in [ - (false, vec!["main"], Some("main")), - (false, vec!["master"], Some("master")), - (false, vec!["origin/main"], None), - (true, vec!["origin/main"], Some("origin/main")), - (true, vec!["origin/master"], Some("origin/master")), - (true, vec!["master", "origin/main"], Some("origin/main")), - (true, vec!["main", "origin/main"], Some("main")), - (true, vec!["ziltoid"], None), + for (branches, expected) in [ + (vec!["main"], Some("main")), + (vec!["master"], Some("master")), + (vec!["origin/main"], None), + (vec!["ziltoid"], None), + (vec!["ziltoid", "main"], Some("main")), + (vec!["ziltoid", "master"], Some("master")), + (vec!["ziltoid", "master", "main"], Some("main")), ] { let branches = HashSet::<&str>::from_iter(branches); - let actual = - default_base_ref(resolve_remote_base_refs, |branch| branches.contains(branch)).ok(); + let actual = default_base_ref(|branch| branches.contains(branch)).ok(); assert_eq!(actual.as_deref(), expected); } @@ -1315,8 +1306,11 @@ mod tests { git.resolve_base(None, github_env()), Err(Error::UnableToResolveRef) ); - - git.resolve_remote_base_refs = true; + git.github_actions_remote_base_ref_fallback = true; + assert_matches!( + git.resolve_base(None, CIEnv::none()), + Err(Error::UnableToResolveRef) + ); assert_eq!( git.resolve_base(None, github_env())?, "origin/main".to_string() @@ -1334,7 +1328,7 @@ mod tests { commit_file(&repo_path, Path::new("todo.txt"), None); let mut git = GitRepo::find(&root).unwrap(); - git.resolve_remote_base_refs = true; + git.github_actions_remote_base_ref_fallback = true; let env = CIEnv { is_github_actions: true, github_base_ref: Ok("missing".to_string()), @@ -2432,7 +2426,7 @@ mod tests { root: root.to_owned(), bin, attrs: std::sync::OnceLock::new(), - resolve_remote_base_refs: false, + github_actions_remote_base_ref_fallback: false, slowest_files: None, } } diff --git a/crates/turborepo-scm/src/lib.rs b/crates/turborepo-scm/src/lib.rs index 3d54eb6326161..5eb4894366a99 100644 --- a/crates/turborepo-scm/src/lib.rs +++ b/crates/turborepo-scm/src/lib.rs @@ -231,7 +231,7 @@ pub struct GitRepo { root: AbsoluteSystemPathBuf, bin: AbsoluteSystemPathBuf, attrs: OnceLock>, - resolve_remote_base_refs: bool, + github_actions_remote_base_ref_fallback: bool, /// Optional recorder for the slowest-to-hash files. Set by long-running /// consumers (the file watcher) so they can diagnose a stalled startup. slowest_files: Option>, @@ -251,7 +251,7 @@ impl Clone for GitRepo { root: self.root.clone(), bin: self.bin.clone(), attrs: OnceLock::new(), - resolve_remote_base_refs: self.resolve_remote_base_refs, + github_actions_remote_base_ref_fallback: self.github_actions_remote_base_ref_fallback, slowest_files: self.slowest_files.clone(), } } @@ -286,7 +286,7 @@ impl GitRepo { root, bin, attrs: OnceLock::new(), - resolve_remote_base_refs: false, + github_actions_remote_base_ref_fallback: false, slowest_files: None, }) } @@ -358,7 +358,7 @@ impl SCM { root: git_root, bin, attrs: OnceLock::new(), - resolve_remote_base_refs: false, + github_actions_remote_base_ref_fallback: false, slowest_files: None, }), Err(e) => { @@ -371,9 +371,9 @@ impl SCM { } } - pub fn with_remote_base_ref_fallback(mut self, enabled: bool) -> Self { + pub fn with_github_actions_remote_base_ref_fallback(mut self, enabled: bool) -> Self { if let SCM::Git(git) = &mut self { - git.resolve_remote_base_refs = enabled; + git.github_actions_remote_base_ref_fallback = enabled; } self } diff --git a/crates/turborepo-scm/src/repo_index.rs b/crates/turborepo-scm/src/repo_index.rs index e4092735aabd7..8c6872f3692be 100644 --- a/crates/turborepo-scm/src/repo_index.rs +++ b/crates/turborepo-scm/src/repo_index.rs @@ -1691,7 +1691,7 @@ mod tests { root: root.clone(), bin: root, attrs: OnceLock::new(), - resolve_remote_base_refs: false, + github_actions_remote_base_ref_fallback: false, slowest_files: None, } } diff --git a/crates/turborepo-turbo-json/src/future_flags.rs b/crates/turborepo-turbo-json/src/future_flags.rs index 00977fe3487cf..4ebd48aa3b589 100644 --- a/crates/turborepo-turbo-json/src/future_flags.rs +++ b/crates/turborepo-turbo-json/src/future_flags.rs @@ -57,11 +57,11 @@ pub struct FutureFlags { /// selecting all tasks in changed packages. #[serde(default)] pub affected_using_task_inputs: bool, - /// Fall back to `origin/` when an inferred base branch is not - /// available as a local ref. This supports detached checkouts in CI where - /// only remote-tracking refs are present. + /// When GitHub Actions reports a base branch that is not available as a + /// local ref, fall back to `origin/`. This supports detached + /// checkouts where only remote-tracking refs are present. #[serde(default)] - pub resolve_remote_base_refs: bool, + pub github_actions_remote_base_ref_fallback: bool, /// Use task-level `inputs` globs to determine which tasks to re-run when /// files change in `turbo watch`. When enabled, only tasks whose declared /// inputs match the changed files are re-executed, rather than re-running @@ -163,7 +163,7 @@ impl TS for FutureFlags { fn inline() -> String { "{ errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, longerSignatureKey?: \ - boolean, affectedUsingTaskInputs?: boolean, resolveRemoteBaseRefs?: boolean, \ + boolean, affectedUsingTaskInputs?: boolean, githubActionsRemoteBaseRefFallback?: boolean, \ watchUsingTaskInputs?: boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: \ boolean, strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean }" @@ -172,7 +172,7 @@ impl TS for FutureFlags { fn inline_flattened() -> String { "{ errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, longerSignatureKey?: \ - boolean, affectedUsingTaskInputs?: boolean, resolveRemoteBaseRefs?: boolean, \ + boolean, affectedUsingTaskInputs?: boolean, githubActionsRemoteBaseRefFallback?: boolean, \ watchUsingTaskInputs?: boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: \ boolean, strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean }" @@ -182,8 +182,8 @@ impl TS for FutureFlags { fn decl() -> String { "type FutureFlags = { errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, \ longerSignatureKey?: boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: \ - boolean, resolveRemoteBaseRefs?: boolean, pruneIncludesGlobalFiles?: boolean, \ - filterUsingTasks?: boolean, strictTaskEntrypointSelection?: boolean, \ + boolean, githubActionsRemoteBaseRefFallback?: boolean, pruneIncludesGlobalFiles?: \ + boolean, filterUsingTasks?: boolean, strictTaskEntrypointSelection?: boolean, \ globalConfiguration?: boolean, experimentalCargoWorkspaces?: boolean, \ experimentalTaskCommand?: boolean };" .to_string() @@ -192,8 +192,8 @@ impl TS for FutureFlags { fn decl_concrete() -> String { "type FutureFlags = { errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, \ longerSignatureKey?: boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: \ - boolean, resolveRemoteBaseRefs?: boolean, pruneIncludesGlobalFiles?: boolean, \ - filterUsingTasks?: boolean, strictTaskEntrypointSelection?: boolean, \ + boolean, githubActionsRemoteBaseRefFallback?: boolean, pruneIncludesGlobalFiles?: \ + boolean, filterUsingTasks?: boolean, strictTaskEntrypointSelection?: boolean, \ globalConfiguration?: boolean, experimentalCargoWorkspaces?: boolean, \ experimentalTaskCommand?: boolean };" .to_string() diff --git a/crates/turborepo-turbo-json/src/lib.rs b/crates/turborepo-turbo-json/src/lib.rs index 95ec252bed785..374c14d4227d9 100644 --- a/crates/turborepo-turbo-json/src/lib.rs +++ b/crates/turborepo-turbo-json/src/lib.rs @@ -783,11 +783,11 @@ mod tests { } #[test] - fn test_deserialize_future_flags_resolve_remote_base_refs() { + fn test_deserialize_future_flags_github_actions_remote_base_ref_fallback() { let json = r#"{ "tasks": {}, "futureFlags": { - "resolveRemoteBaseRefs": true + "githubActionsRemoteBaseRefFallback": true } }"#; @@ -804,11 +804,15 @@ mod tests { .as_ref() .unwrap() .as_inner() - .resolve_remote_base_refs + .github_actions_remote_base_ref_fallback ); let turbo_json = TurboJson::try_from(raw_turbo_json).unwrap(); - assert!(turbo_json.future_flags.resolve_remote_base_refs); + assert!( + turbo_json + .future_flags + .github_actions_remote_base_ref_fallback + ); } #[test] diff --git a/packages/turbo-types/schemas/schema.json b/packages/turbo-types/schemas/schema.json index ae3659c871c36..78b260597ed15 100644 --- a/packages/turbo-types/schemas/schema.json +++ b/packages/turbo-types/schemas/schema.json @@ -330,8 +330,8 @@ "default": false, "type": "boolean" }, - "resolveRemoteBaseRefs": { - "description": "Fall back to `origin/` when an inferred base branch is not available as a local ref. This supports detached checkouts in CI where only remote-tracking refs are present.", + "githubActionsRemoteBaseRefFallback": { + "description": "When GitHub Actions reports a base branch that is not available as a local ref, fall back to `origin/`. This supports detached checkouts where only remote-tracking refs are present.", "default": false, "type": "boolean" }, diff --git a/packages/turbo-types/schemas/schema.v2.json b/packages/turbo-types/schemas/schema.v2.json index 4339b802d1733..ca48ee804c989 100644 --- a/packages/turbo-types/schemas/schema.v2.json +++ b/packages/turbo-types/schemas/schema.v2.json @@ -286,8 +286,8 @@ "default": false, "type": "boolean" }, - "resolveRemoteBaseRefs": { - "description": "Fall back to `origin/` when an inferred base branch is not available as a local ref. This supports detached checkouts in CI where only remote-tracking refs are present.", + "githubActionsRemoteBaseRefFallback": { + "description": "When GitHub Actions reports a base branch that is not available as a local ref, fall back to `origin/`. This supports detached checkouts where only remote-tracking refs are present.", "default": false, "type": "boolean" }, diff --git a/packages/turbo-types/src/types/config-v2.ts b/packages/turbo-types/src/types/config-v2.ts index 6076a2635fac8..b55e8e99b2cb5 100644 --- a/packages/turbo-types/src/types/config-v2.ts +++ b/packages/turbo-types/src/types/config-v2.ts @@ -267,13 +267,13 @@ export interface FutureFlags { */ affectedUsingTaskInputs?: boolean; /** - * Fall back to `origin/` when an inferred base branch is not - * available as a local ref. This supports detached checkouts in CI where - * only remote-tracking refs are present. + * When GitHub Actions reports a base branch that is not available as a + * local ref, fall back to `origin/`. This supports detached + * checkouts where only remote-tracking refs are present. * * @defaultValue `false` */ - resolveRemoteBaseRefs?: boolean; + githubActionsRemoteBaseRefFallback?: boolean; /** * Use task-level `inputs` globs to determine which tasks to re-run when * files change in `turbo watch`. When enabled, only tasks whose declared