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;
enum MergeTreeOutcome {
Clean { tree: String },
Conflict,
}
impl Repository {
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 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_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 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_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 merge_tree_outcome(&self, a: &str, b: &str) -> anyhow::Result<MergeTreeOutcome> {
let output = self.run_command_output(&["merge-tree", "--write-tree", a, b])?;
if output.status.code() == Some(1) {
return Ok(MergeTreeOutcome::Conflict);
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git merge-tree failed for {a} {b}: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let tree = stdout.lines().next().unwrap_or("").trim().to_string();
Ok(MergeTreeOutcome::Clean { tree })
}
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 conflicts = matches!(
self.merge_tree_outcome(base_sha, head_sha)?,
MergeTreeOutcome::Conflict
);
super::sha_cache::put_merge_conflicts(self, cache_base, cache_head, conflicts);
Ok(conflicts)
}
fn would_merge_add_to_target(
&self,
branch: &str,
target: &str,
) -> anyhow::Result<Option<bool>> {
match self.merge_tree_outcome(target, branch)? {
MergeTreeOutcome::Conflict => Ok(None),
MergeTreeOutcome::Clean { tree } => {
let target_tree = self.rev_parse_tree(&format!("{target}^{{tree}}"))?;
Ok(Some(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], None)?;
let Some(branch_pid) = branch_pids.split_whitespace().next() else {
return Ok(false);
};
let target_commits = self.run_command(&["rev-list", &format!("{merge_base}..{target}")])?;
let target_pids = self.patch_ids_from(
&["diff-tree", "--stdin", "-p"],
Some(target_commits.into_bytes()),
)?;
Ok(target_pids
.lines()
.any(|line| line.split_whitespace().next() == Some(branch_pid)))
}
fn patch_ids_from(&self, args: &[&str], stdin: Option<Vec<u8>>) -> anyhow::Result<String> {
let mut source = Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.discovery_path)
.context(self.logging_context());
if let Some(data) = stdin {
source = source.stdin_bytes(data);
}
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_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_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}"
);
}
#[test]
fn detects_squash_merge_under_nondefault_diff_config() {
let test = TestRepo::new();
let repo = &test.repo;
test.run_git(&["config", "diff.context", "25"]);
test.run_git(&["config", "diff.algorithm", "histogram"]);
let path = test.path().join("file");
let base: String = (1..=60).map(|i| format!("l{i}\n")).collect();
std::fs::write(&path, &base).unwrap();
test.run_git(&["add", "file"]);
test.run_git(&["commit", "-m", "base"]);
let changed = base.replace("l30\n", "FEATURE\n");
test.run_git(&["checkout", "-b", "feature"]);
std::fs::write(&path, &changed).unwrap();
test.run_git(&["commit", "-am", "feature change"]);
test.run_git(&["checkout", "main"]);
std::fs::write(&path, &changed).unwrap();
test.run_git(&["commit", "-am", "squash-merge feature"]);
std::fs::write(&path, changed.replace("FEATURE\n", "PADDED\n")).unwrap();
test.run_git(&["commit", "-am", "follow-up on same line"]);
let snapshot = repo.capture_refs().unwrap();
let signals = compute_integration_lazy(repo, &snapshot, "feature", "main").unwrap();
assert_eq!(
check_integration(&signals),
Some(IntegrationReason::PatchIdMatch),
"squash merge must be detected via patch-id regardless of diff.* config"
);
}
}