diff --git a/scripts/axbuild/src/support/git.rs b/scripts/axbuild/src/support/git.rs index 15e1ba5ca0..0c46fe7dab 100644 --- a/scripts/axbuild/src/support/git.rs +++ b/scripts/axbuild/src/support/git.rs @@ -30,36 +30,40 @@ pub(crate) fn select_incremental_packages( workspace_packages: &[Package], since: &str, ) -> anyhow::Result { - let changed_paths = match changed_paths_since(workspace_root, since) { - Ok(paths) => paths, - Err(err) => { - return Ok(IncrementalPackageSelection::Full { - reason: format!("failed to diff against `{since}`: {err:#}"), - }); - } - }; - let root_manifest_change = if changed_paths - .iter() - .any(|path| path == Path::new(ROOT_MANIFEST)) - { - Some(root_manifest_change_since(workspace_root, since)?) - } else { - None - }; + match changed_paths_since_with_base(workspace_root, since) { + Ok((paths, diff_base)) => { + let root_manifest_change = if paths.iter().any(|path| path == Path::new(ROOT_MANIFEST)) + { + Some(root_manifest_change_since(workspace_root, &diff_base)?) + } else { + None + }; - select_incremental_packages_for_paths_with_root_manifest_change( - workspace_root, - metadata, - workspace_packages, - changed_paths, - root_manifest_change, - ) + select_incremental_packages_for_paths_with_root_manifest_change( + workspace_root, + metadata, + workspace_packages, + paths, + root_manifest_change, + ) + } + Err(err) => Ok(IncrementalPackageSelection::Full { + reason: format!("failed to diff against `{since}`: {err:#}"), + }), + } } pub(crate) fn changed_paths_since( workspace_root: &Path, since: &str, ) -> anyhow::Result> { + changed_paths_since_with_base(workspace_root, since).map(|(paths, _)| paths) +} + +fn changed_paths_since_with_base( + workspace_root: &Path, + since: &str, +) -> anyhow::Result<(Vec, String)> { ensure_git_work_tree(workspace_root)?; let diff_base = resolve_since_diff_base(workspace_root, since)?; @@ -90,17 +94,26 @@ pub(crate) fn changed_paths_since( ); } - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(PathBuf::from) - .collect()) + Ok(( + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect(), + diff_base, + )) } fn resolve_since_diff_base(workspace_root: &Path, since: &str) -> anyhow::Result { - if since.is_empty() || since == "0000000000000000000000000000000000000000" { - bail!("since ref is empty or zero"); + if since.is_empty() { + bail!("since ref is empty"); + } + if since == "0000000000000000000000000000000000000000" { + let diff_base = infer_zero_since_diff_base(workspace_root) + .context("failed to infer diff base for zero since ref")?; + println!("input ref `{since}` is zero; inferred `{diff_base}` as incremental diff base"); + return Ok(diff_base); } let since_commit = git_commit_for_ref(workspace_root, since) @@ -119,6 +132,95 @@ fn resolve_since_diff_base(workspace_root: &Path, since: &str) -> anyhow::Result Ok(merge_base) } +fn infer_zero_since_diff_base(workspace_root: &Path) -> anyhow::Result { + let head_commit = git_commit_for_ref(workspace_root, "HEAD") + .context("failed to resolve HEAD for zero since inference")?; + let remote_refs = git_remote_refs_not_at_commit(workspace_root, &head_commit) + .context("failed to list remote refs for zero since inference")?; + if remote_refs.is_empty() { + bail!("no remote refs remain after excluding refs at HEAD"); + } + + let mut args = vec!["rev-list", "--reverse", "--parents", "HEAD", "--not"]; + args.extend(remote_refs.iter().map(String::as_str)); + let output = Command::new("git") + .args(git_safe_directory_args(workspace_root)) + .arg("-C") + .arg(workspace_root) + .args(args) + .output() + .context("failed to run git rev-list for zero since inference")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + bail!( + "git rev-list exited with status {}{}", + output.status, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ); + } + + let first_unique = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) + .context("HEAD has no commits outside remote refs not at HEAD")?; + let mut parts = first_unique.split_whitespace(); + let commit = parts + .next() + .context("git rev-list returned an empty line")?; + let parent = parts + .next() + .with_context(|| format!("first unique commit `{commit}` has no parent"))?; + + git_commit_for_ref(workspace_root, parent) + .with_context(|| format!("failed to resolve inferred parent `{parent}` to a commit")) +} + +fn git_remote_refs_not_at_commit( + workspace_root: &Path, + excluded_commit: &str, +) -> anyhow::Result> { + let output = Command::new("git") + .args(git_safe_directory_args(workspace_root)) + .arg("-C") + .arg(workspace_root) + .args([ + "for-each-ref", + "--format=%(refname) %(objectname)", + "refs/remotes", + ]) + .output() + .context("failed to run git for-each-ref for remote refs")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + bail!( + "git for-each-ref exited with status {}{}", + output.status, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ); + } + + let refs = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.split_whitespace(); + let git_ref = parts.next()?; + let commit = parts.next()?; + (commit != excluded_commit).then(|| git_ref.to_string()) + }) + .collect(); + Ok(refs) +} + fn git_commit_for_ref(workspace_root: &Path, git_ref: &str) -> anyhow::Result { let commit_ref = format!("{git_ref}^{{commit}}"); let output = Command::new("git") @@ -942,6 +1044,155 @@ mod tests { ); } + #[test] + fn zero_since_on_new_branch_resolves_to_first_unique_parent() { + let root = tempfile::tempdir().unwrap(); + run_git(root.path(), &["init"]); + run_git(root.path(), &["config", "user.email", "test@example.com"]); + run_git(root.path(), &["config", "user.name", "Test User"]); + + std::fs::write(root.path().join("file.txt"), "base\n").unwrap(); + run_git(root.path(), &["add", "file.txt"]); + run_git(root.path(), &["commit", "-m", "base"]); + let base = git_stdout(root.path(), &["rev-parse", "HEAD"]); + run_git( + root.path(), + &["update-ref", "refs/remotes/origin/dev", &base], + ); + + run_git(root.path(), &["checkout", "-b", "feature"]); + std::fs::write(root.path().join("feature.txt"), "feature 1\n").unwrap(); + run_git(root.path(), &["add", "feature.txt"]); + run_git(root.path(), &["commit", "-m", "feature 1"]); + std::fs::write(root.path().join("feature.txt"), "feature 2\n").unwrap(); + run_git(root.path(), &["commit", "-am", "feature 2"]); + let head = git_stdout(root.path(), &["rev-parse", "HEAD"]); + run_git( + root.path(), + &["update-ref", "refs/remotes/origin/feature", &head], + ); + + assert_eq!( + resolve_since_diff_base(root.path(), "0000000000000000000000000000000000000000") + .unwrap(), + base + ); + } + + #[test] + fn zero_since_ignores_default_branch_tip_after_branch_fork() { + let root = tempfile::tempdir().unwrap(); + run_git(root.path(), &["init"]); + run_git(root.path(), &["config", "user.email", "test@example.com"]); + run_git(root.path(), &["config", "user.name", "Test User"]); + + std::fs::write(root.path().join("file.txt"), "base\n").unwrap(); + run_git(root.path(), &["add", "file.txt"]); + run_git(root.path(), &["commit", "-m", "base"]); + let fork_point = git_stdout(root.path(), &["rev-parse", "HEAD"]); + + run_git(root.path(), &["checkout", "-b", "feature"]); + std::fs::write(root.path().join("feature.txt"), "feature\n").unwrap(); + run_git(root.path(), &["add", "feature.txt"]); + run_git(root.path(), &["commit", "-m", "feature"]); + let feature_head = git_stdout(root.path(), &["rev-parse", "HEAD"]); + + run_git(root.path(), &["checkout", "-b", "dev", &fork_point]); + std::fs::write(root.path().join("file.txt"), "dev 1\n").unwrap(); + run_git(root.path(), &["commit", "-am", "dev 1"]); + std::fs::write(root.path().join("file.txt"), "dev 2\n").unwrap(); + run_git(root.path(), &["commit", "-am", "dev 2"]); + let dev_head = git_stdout(root.path(), &["rev-parse", "HEAD"]); + assert_ne!(dev_head, fork_point); + run_git( + root.path(), + &["update-ref", "refs/remotes/origin/dev", &dev_head], + ); + + run_git(root.path(), &["checkout", "feature"]); + run_git( + root.path(), + &["update-ref", "refs/remotes/origin/feature", &feature_head], + ); + + assert_eq!( + resolve_since_diff_base(root.path(), "0000000000000000000000000000000000000000") + .unwrap(), + fork_point + ); + } + + #[test] + fn zero_since_root_manifest_change_uses_inferred_base() { + let (root, metadata, workspace_packages) = test_workspace(); + run_git(root.path(), &["init"]); + run_git(root.path(), &["config", "user.email", "test.com"]); + run_git(root.path(), &["config", "user.name", "Test User"]); + std::fs::write( + root.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/alpha\", \"crates/beta\", \ + \"crates/gamma\"]\n\n[workspace.dependencies]\nalpha = { path = \"crates/alpha\" }\n", + ) + .unwrap(); + run_git(root.path(), &["add", "."]); + run_git(root.path(), &["commit", "-m", "base"]); + let base = git_stdout(root.path(), &["rev-parse", "HEAD"]); + run_git( + root.path(), + &["update-ref", "refs/remotes/origin/dev", &base], + ); + + run_git(root.path(), &["checkout", "-b", "feature"]); + std::fs::write( + root.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/alpha\", \"crates/beta\", \ + \"crates/gamma\"]\n\n[workspace.dependencies]\nalpha = { path = \"crates/alpha\", \ + package = \"alpha\" }\n", + ) + .unwrap(); + run_git(root.path(), &["commit", "-am", "feature"]); + let head = git_stdout(root.path(), &["rev-parse", "HEAD"]); + run_git( + root.path(), + &["update-ref", "refs/remotes/origin/feature", &head], + ); + + let selected = select_incremental_packages( + root.path(), + &metadata, + &workspace_packages, + "0000000000000000000000000000000000000000", + ) + .unwrap(); + + assert_eq!( + selected, + IncrementalPackageSelection::Packages { + changed: vec!["alpha".into()], + affected: vec!["alpha".into(), "beta".into(), "gamma".into()], + } + ); + } + + #[test] + fn zero_since_without_remote_refs_returns_error() { + let root = tempfile::tempdir().unwrap(); + run_git(root.path(), &["init"]); + run_git(root.path(), &["config", "user.email", "test@example.com"]); + run_git(root.path(), &["config", "user.name", "Test User"]); + std::fs::write(root.path().join("file.txt"), "base\n").unwrap(); + run_git(root.path(), &["add", "file.txt"]); + run_git(root.path(), &["commit", "-m", "base"]); + + let err = resolve_since_diff_base(root.path(), "0000000000000000000000000000000000000000") + .unwrap_err(); + + assert!( + format!("{err:#}").contains("no remote refs remain"), + "unexpected error: {err:#}" + ); + } + #[test] fn changed_top_level_crate_affected_set_is_only_itself() { let (root, metadata, workspace_packages) = test_workspace();