use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use super::{RefSnapshot, Repository};
use crate::git::{IntegrationReason, check_integration, compute_integration_lazy};
use crate::shell_exec::Cmd;
#[derive(Debug, Clone)]
pub struct IntegrationTargets {
pub primary: String,
pub secondary: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct MergeProbeResult {
pub would_merge_add: bool,
pub is_patch_id_match: bool,
}
const PATCH_ID_SCAN_MAX_COMMITS: usize = 500;
impl Repository {
fn resolve_preferring_branch(&self, r: &str) -> String {
let qualified = format!("refs/heads/{r}");
if self
.run_command(&["rev-parse", "--verify", "-q", &qualified])
.is_ok()
{
qualified
} else {
r.to_string()
}
}
fn resolve_ref(&self, r: &str) -> String {
if r.starts_with("refs/") {
r.to_string()
} else {
self.resolve_preferring_branch(r)
}
}
pub fn is_ancestor(&self, base: &str, head: &str) -> anyhow::Result<bool> {
let base = self.resolve_ref(base);
let head = self.resolve_ref(head);
let base_sha = self.rev_parse_commit(&base)?;
let head_sha = self.rev_parse_commit(&head)?;
self.is_ancestor_by_sha(&base_sha, &head_sha)
}
pub fn is_ancestor_by_sha(&self, base_sha: &str, head_sha: &str) -> anyhow::Result<bool> {
if let Some(cached) = super::sha_cache::is_ancestor(self, base_sha, head_sha) {
return Ok(cached);
}
let result =
self.run_command_check(&["merge-base", "--is-ancestor", base_sha, head_sha])?;
super::sha_cache::put_is_ancestor(self, base_sha, head_sha, result);
Ok(result)
}
pub fn same_commit(&self, ref1: &str, ref2: &str) -> anyhow::Result<bool> {
let ref1 = self.resolve_ref(ref1);
let ref2 = self.resolve_ref(ref2);
let output = self.run_command(&["rev-parse", &ref1, &ref2])?;
let mut lines = output.lines();
let sha1 = lines.next().context("rev-parse returned no output")?.trim();
let sha2 = lines
.next()
.context("rev-parse returned only one line")?
.trim();
Ok(sha1 == sha2)
}
pub fn has_added_changes(&self, branch: &str, target: &str) -> anyhow::Result<bool> {
let branch = self.resolve_ref(branch);
let target = self.resolve_ref(target);
let branch_sha = self.rev_parse_commit(&branch)?;
let target_sha = self.rev_parse_commit(&target)?;
self.has_added_changes_by_sha(&branch_sha, &target_sha)
}
pub fn has_added_changes_by_sha(
&self,
branch_sha: &str,
target_sha: &str,
) -> anyhow::Result<bool> {
if let Some(cached) = super::sha_cache::has_added_changes(self, branch_sha, target_sha) {
return Ok(cached);
}
let Some(merge_base) = self.merge_base(target_sha, branch_sha)? else {
super::sha_cache::put_has_added_changes(self, branch_sha, target_sha, true);
return Ok(true);
};
let range = format!("{merge_base}..{branch_sha}");
let output = self.run_command(&["diff", "--name-only", &range])?;
let result = !output.trim().is_empty();
super::sha_cache::put_has_added_changes(self, branch_sha, target_sha, result);
Ok(result)
}
pub fn trees_match(&self, ref1: &str, ref2: &str) -> anyhow::Result<bool> {
let ref1 = self.resolve_ref(ref1);
let ref2 = self.resolve_ref(ref2);
let output = self.run_command(&[
"rev-parse",
&format!("{ref1}^{{tree}}"),
&format!("{ref2}^{{tree}}"),
])?;
let mut lines = output.lines();
let tree1 = lines.next().context("rev-parse returned no output")?.trim();
let tree2 = lines
.next()
.context("rev-parse returned only one line")?
.trim();
Ok(tree1 == tree2)
}
pub fn trees_match_by_sha(&self, commit_sha1: &str, commit_sha2: &str) -> anyhow::Result<bool> {
let output = self.run_command(&[
"rev-parse",
&format!("{commit_sha1}^{{tree}}"),
&format!("{commit_sha2}^{{tree}}"),
])?;
let mut lines = output.lines();
let tree1 = lines.next().context("rev-parse returned no output")?.trim();
let tree2 = lines
.next()
.context("rev-parse returned only one line")?
.trim();
Ok(tree1 == tree2)
}
pub fn head_tree_matches_branch(&self, branch: &str) -> anyhow::Result<bool> {
self.trees_match("HEAD", branch)
}
pub fn has_merge_conflicts(&self, base: &str, head: &str) -> anyhow::Result<bool> {
let base = self.resolve_ref(base);
let head = self.resolve_ref(head);
let base_sha = self.rev_parse_commit(&base)?;
let head_sha = self.rev_parse_commit(&head)?;
self.has_merge_conflicts_by_sha(&base_sha, &head_sha)
}
pub fn has_merge_conflicts_by_sha(
&self,
base_sha: &str,
head_sha: &str,
) -> anyhow::Result<bool> {
if let Some(cached) = super::sha_cache::merge_conflicts(self, base_sha, head_sha) {
return Ok(cached);
}
self.run_merge_tree(base_sha, head_sha, base_sha, head_sha)
}
pub fn has_merge_conflicts_by_tree(
&self,
base: &str,
branch_head_sha: &str,
tree_sha: &str,
) -> anyhow::Result<bool> {
let base = self.resolve_ref(base);
let base_sha = self.rev_parse_commit(&base)?;
self.has_merge_conflicts_by_tree_with_base_sha(&base_sha, branch_head_sha, tree_sha)
}
pub fn has_merge_conflicts_by_tree_with_base_sha(
&self,
base_sha: &str,
branch_head_sha: &str,
tree_sha: &str,
) -> anyhow::Result<bool> {
let cache_head = format!("{branch_head_sha}+{tree_sha}");
if let Some(cached) = super::sha_cache::merge_conflicts(self, base_sha, &cache_head) {
return Ok(cached);
}
let head_commit =
self.run_command(&["commit-tree", tree_sha, "-p", branch_head_sha, "-m", ""])?;
let head_commit = head_commit.trim();
self.run_merge_tree(base_sha, head_commit, base_sha, &cache_head)
}
fn run_merge_tree(
&self,
base_sha: &str,
head_sha: &str,
cache_base: &str,
cache_head: &str,
) -> anyhow::Result<bool> {
if self.merge_base(base_sha, head_sha)?.is_none() {
super::sha_cache::put_merge_conflicts(self, cache_base, cache_head, true);
return Ok(true);
}
let output =
self.run_command_output(&["merge-tree", "--write-tree", base_sha, head_sha])?;
if output.status.code() == Some(1) {
super::sha_cache::put_merge_conflicts(self, cache_base, cache_head, true);
return Ok(true);
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git merge-tree failed for {base_sha} {head_sha}: {}",
stderr.trim()
);
}
super::sha_cache::put_merge_conflicts(self, cache_base, cache_head, false);
Ok(false)
}
fn would_merge_add_to_target(
&self,
branch: &str,
target: &str,
) -> anyhow::Result<Option<bool>> {
let output = self.run_command_output(&["merge-tree", "--write-tree", target, branch])?;
if output.status.code() == Some(1) {
return Ok(None);
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git merge-tree failed for {target} {branch}: {}",
stderr.trim()
);
}
let merge_tree = String::from_utf8_lossy(&output.stdout);
let merge_tree = merge_tree.lines().next().unwrap_or("").trim();
let target_tree = self.rev_parse_tree(&format!("{target}^{{tree}}"))?;
Ok(Some(merge_tree != target_tree))
}
fn is_squash_merged_via_patch_id(&self, branch: &str, target: &str) -> anyhow::Result<bool> {
let Some(merge_base) = self.merge_base(target, branch)? else {
return Ok(false);
};
let target_commit_count: usize = self
.run_command(&["rev-list", "--count", &format!("{merge_base}..{target}")])?
.trim()
.parse()
.unwrap_or(0);
if target_commit_count > PATCH_ID_SCAN_MAX_COMMITS {
log::debug!(
"skipping patch-id squash-merge check: {target_commit_count} commits in {merge_base}..{target} exceeds cap of {PATCH_ID_SCAN_MAX_COMMITS}"
);
return Ok(false);
}
let branch_pids = self.patch_ids_from(&["diff-tree", "-p", &merge_base, branch])?;
let Some(branch_pid) = branch_pids.split_whitespace().next() else {
return Ok(false);
};
let target_pids =
self.patch_ids_from(&["log", "-p", "--reverse", &format!("{merge_base}..{target}")])?;
Ok(target_pids
.lines()
.any(|line| line.split_whitespace().next() == Some(branch_pid)))
}
fn patch_ids_from(&self, args: &[&str]) -> anyhow::Result<String> {
let source = Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.discovery_path)
.context(self.logging_context());
let sink = Cmd::new("git")
.args(["patch-id", "--verbatim"])
.current_dir(&self.discovery_path)
.context(self.logging_context());
let (source_output, sink_output) = source
.pipe_into(sink)
.context("Failed to compute patch-id")?;
if !source_output.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&source_output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&sink_output.stdout).into_owned())
}
pub fn merge_integration_probe(
&self,
branch: &str,
target: &str,
) -> anyhow::Result<MergeProbeResult> {
let branch = self.resolve_ref(branch);
let target = self.resolve_ref(target);
let branch_sha = self.rev_parse_commit(&branch)?;
let target_sha = self.rev_parse_commit(&target)?;
self.merge_integration_probe_by_sha(&branch_sha, &target_sha)
}
pub fn merge_integration_probe_by_sha(
&self,
branch_sha: &str,
target_sha: &str,
) -> anyhow::Result<MergeProbeResult> {
if let Some(cached) = super::sha_cache::merge_add_probe(self, branch_sha, target_sha) {
return Ok(cached);
}
if self.merge_base(target_sha, branch_sha)?.is_none() {
let result = MergeProbeResult {
would_merge_add: true,
is_patch_id_match: false,
};
super::sha_cache::put_merge_add_probe(self, branch_sha, target_sha, result);
return Ok(result);
}
let merge_result = self.would_merge_add_to_target(branch_sha, target_sha)?;
let result = match merge_result {
Some(would_add) => MergeProbeResult {
would_merge_add: would_add,
is_patch_id_match: false,
},
None => {
let matched = self
.is_squash_merged_via_patch_id(branch_sha, target_sha)
.unwrap_or(false);
MergeProbeResult {
would_merge_add: true,
is_patch_id_match: matched,
}
}
};
super::sha_cache::put_merge_add_probe(self, branch_sha, target_sha, result);
Ok(result)
}
pub fn integration_targets(&self, snapshot: &RefSnapshot) -> Option<IntegrationTargets> {
let target = self.default_branch()?;
let upstream = snapshot.upstream_of(&target).map(str::to_string);
let target_sha = snapshot_resolve(self, snapshot, &target).ok()?;
let (primary, secondary) = match upstream {
None => (target, None),
Some(u) => {
let upstream_sha = snapshot_resolve(self, snapshot, &u).ok()?;
if target_sha == upstream_sha {
(target, None)
} else if self
.is_ancestor_by_sha(&target_sha, &upstream_sha)
.unwrap_or(false)
{
(u, None)
} else if self
.is_ancestor_by_sha(&upstream_sha, &target_sha)
.unwrap_or(false)
{
(target, None)
} else {
(target, Some(u))
}
}
};
Some(IntegrationTargets { primary, secondary })
}
pub(super) fn rev_parse_tree(&self, spec: &str) -> anyhow::Result<String> {
Ok(self
.run_command(&["rev-parse", "--verify", "--end-of-options", spec])?
.trim()
.to_string())
}
pub(super) fn rev_parse_commit(&self, r: &str) -> anyhow::Result<String> {
Ok(self
.run_command(&["rev-parse", "--verify", "--end-of-options", r])?
.trim()
.to_string())
}
pub(super) fn resolve_to_commit_sha(&self, r: &str) -> anyhow::Result<String> {
if is_hex_commit_sha(r) {
return Ok(r.to_string());
}
self.rev_parse_commit(r)
}
pub fn integration_reason(
&self,
snapshot: &RefSnapshot,
branch: &str,
target: &str,
) -> anyhow::Result<(String, Option<IntegrationReason>)> {
self.compute_integration_reason_uncached(snapshot, branch, target)
}
fn compute_integration_reason_uncached(
&self,
snapshot: &RefSnapshot,
branch: &str,
target: &str,
) -> anyhow::Result<(String, Option<IntegrationReason>)> {
let upstream = snapshot
.upstream_of(target)
.map(str::to_string)
.or_else(|| self.branch(target).upstream().ok().flatten());
let target_sha = snapshot_resolve(self, snapshot, target)?;
let (check_local, fallback_upstream) = match upstream {
None => (true, None),
Some(u) => {
let upstream_sha = snapshot_resolve(self, snapshot, &u)?;
if target_sha == upstream_sha {
(true, None)
} else if self.is_ancestor_by_sha(&target_sha, &upstream_sha)? {
let signals = compute_integration_lazy(self, snapshot, branch, &u)?;
return Ok((u, check_integration(&signals)));
} else if self.is_ancestor_by_sha(&upstream_sha, &target_sha)? {
(true, None)
} else {
(true, Some(u))
}
}
};
if check_local
&& let Some(reason) =
check_integration(&compute_integration_lazy(self, snapshot, branch, target)?)
{
return Ok((target.to_string(), Some(reason)));
}
if let Some(upstream) = fallback_upstream
&& let Some(reason) = check_integration(&compute_integration_lazy(
self, snapshot, branch, &upstream,
)?)
{
return Ok((upstream, Some(reason)));
}
Ok((target.to_string(), None))
}
}
fn snapshot_resolve(
repo: &Repository,
snapshot: &RefSnapshot,
name: &str,
) -> anyhow::Result<String> {
if let Some(sha) = snapshot.resolve(name) {
return Ok(sha.to_string());
}
Ok(repo
.run_command(&["rev-parse", "--verify", "--end-of-options", name])?
.trim()
.to_string())
}
fn is_hex_commit_sha(s: &str) -> bool {
s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod snapshot_resolve_tests {
use super::*;
use crate::testing::TestRepo;
#[test]
fn falls_back_to_rev_parse_for_refs_not_in_snapshot() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
let snapshot = repo.capture_refs().unwrap();
let head_sha_via_fallback = snapshot_resolve(&repo, &snapshot, "HEAD").unwrap();
let head_sha_direct = repo.run_command(&["rev-parse", "HEAD"]).unwrap();
assert_eq!(head_sha_via_fallback, head_sha_direct.trim());
assert!(snapshot_resolve(&repo, &snapshot, "no-such-ref").is_err());
}
}
#[cfg(test)]
mod hex_sha_tests {
use super::is_hex_commit_sha;
#[test]
fn detects_full_hex_sha() {
assert!(is_hex_commit_sha(
"273f078bd20a09f1a524aae48fcb1771ceac9b5d"
));
}
#[test]
fn rejects_branch_names() {
assert!(!is_hex_commit_sha("main"));
assert!(!is_hex_commit_sha("feature/foo"));
}
#[test]
fn rejects_short_or_long() {
assert!(!is_hex_commit_sha(
"273f078bd20a09f1a524aae48fcb1771ceac9b5"
));
assert!(!is_hex_commit_sha(
"273f078bd20a09f1a524aae48fcb1771ceac9b5d0"
));
}
#[test]
fn rejects_non_hex_chars() {
assert!(!is_hex_commit_sha(
"z73f078bd20a09f1a524aae48fcb1771ceac9b5d"
));
}
}
#[cfg(test)]
mod patch_id_cap_tests {
use super::*;
use crate::testing::TestRepo;
use std::fmt::Write as _;
fn build(test: &TestRepo, n_padding: usize) {
let mut s = String::new();
s.push_str("blob\nmark :10\ndata 1\nA\n");
s.push_str("blob\nmark :11\ndata 1\nB\n");
writeln!(
s,
"commit refs/heads/base\nmark :1\ncommitter T <t@x> 1700000000 +0000\ndata 0\nM 100644 :10 file\n"
)
.unwrap();
writeln!(
s,
"commit refs/heads/feature\nmark :2\ncommitter T <t@x> 1700000001 +0000\ndata 0\nfrom :1\nM 100644 :11 file\n"
)
.unwrap();
writeln!(
s,
"commit refs/heads/target\nmark :3\ncommitter T <t@x> 1700000002 +0000\ndata 0\nfrom :1\nM 100644 :11 file\n"
)
.unwrap();
for i in 0..n_padding {
let mark = 4 + i;
let parent = if i == 0 { 3 } else { mark - 1 };
writeln!(
s,
"commit refs/heads/target\nmark :{mark}\ncommitter T <t@x> {} +0000\ndata 0\nfrom :{parent}\n",
1_700_000_003 + i
)
.unwrap();
}
let output = test
.git_command()
.args(["fast-import", "--quiet"])
.stdin_bytes(s.into_bytes())
.run()
.expect("fast-import spawn");
assert!(
output.status.success(),
"fast-import failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn rev_parse(repo: &Repository, r: &str) -> String {
repo.run_command(&["rev-parse", r])
.unwrap()
.trim()
.to_string()
}
#[test]
fn detects_squash_when_range_is_under_cap() {
let test = TestRepo::new();
build(&test, 1);
let repo = Repository::at(test.root_path()).unwrap();
let feature = rev_parse(&repo, "refs/heads/feature");
let target = rev_parse(&repo, "refs/heads/target");
assert!(
repo.is_squash_merged_via_patch_id(&feature, &target)
.unwrap(),
"squash commit's patch-id matches feature's; should be detected within the cap"
);
}
#[test]
fn bails_when_range_exceeds_cap() {
let test = TestRepo::new();
build(&test, PATCH_ID_SCAN_MAX_COMMITS);
let repo = Repository::at(test.root_path()).unwrap();
let feature = rev_parse(&repo, "refs/heads/feature");
let target = rev_parse(&repo, "refs/heads/target");
assert!(
!repo
.is_squash_merged_via_patch_id(&feature, &target)
.unwrap(),
"should bail (return false) when base..target exceeds {PATCH_ID_SCAN_MAX_COMMITS}"
);
}
}