use std::sync::OnceLock;
use super::driver::{Git, Landing};
use super::plan::{PrView, WorktreeEntry};
use super::{CleanupRequest, FALLBACK_BASE, owned};
pub(super) struct Merge {
branch: String,
reference: String,
head: String,
fetched: OnceLock<Result<(), String>>,
}
impl Merge {
pub(super) fn new(view: &PrView) -> Self {
let branch = match view.base_ref_name.trim() {
"" => FALLBACK_BASE,
b => b,
};
Self {
branch: branch.to_string(),
reference: format!("origin/{branch}"),
head: view.head_ref_oid.trim().to_string(),
fetched: OnceLock::new(),
}
}
pub(super) fn resolve<T: Git>(&self, git: &T, req: &CleanupRequest) -> Result<&str, String> {
let outcome = self.fetched.get_or_init(|| {
match git.run(&req.repo_root, &owned(&["fetch", "origin", &self.branch])) {
Ok(out) if out.success => Ok(()),
Ok(out) => Err(format!(
"`git fetch origin {}` failed: {}",
self.branch,
out.stderr.trim()
)),
Err(e) => Err(format!(
"`git fetch origin {}` could not be run: {e:#}",
self.branch
)),
}
});
match outcome {
Ok(()) => Ok(&self.reference),
Err(why) => Err(why.clone()),
}
}
}
pub(super) fn unlanded<T: Git>(
git: &T,
req: &CleanupRequest,
merge: &Merge,
tip: &str,
) -> Option<String> {
let head_oid = merge.head.as_str();
if !head_oid.is_empty()
&& let Ok(out) = git.run(
&req.repo_root,
&owned(&["merge-base", "--is-ancestor", tip, head_oid]),
)
&& out.success
{
return None;
}
let base_ref = match merge.resolve(git, req) {
Ok(r) => r.to_string(),
Err(why) => {
return Some(format!(
"cannot test whether the merge carried `{tip}`: {why} — refusing rather than \
comparing against a base ref that may predate the merge (#7275)"
));
}
};
let merged = match git.run(
&req.repo_root,
&owned(&["merge-tree", "--write-tree", &base_ref, tip]),
) {
Ok(out) if out.success => out.stdout,
Ok(out) => {
return Some(format!(
"`{tip}` does not merge cleanly into {base_ref}, so the merge cannot have \
carried it: {}",
out.stderr.trim()
));
}
Err(e) => {
return Some(format!(
"cannot merge-test `{tip}` against {base_ref}: {e:#}"
));
}
};
let Some(tree) = merged
.lines()
.next()
.map(str::trim)
.filter(|t| !t.is_empty())
else {
return Some(format!(
"`git merge-tree --write-tree {base_ref} {tip}` named no tree"
));
};
match git.run(
&req.repo_root,
&owned(&["diff", "--name-only", &base_ref, tree]),
) {
Ok(out) if out.success && out.stdout.trim().is_empty() => None,
Ok(out) if out.success => Some(format!(
"`{tip}` is this PR's round sibling, but merging it into {base_ref} would still \
change {}: {} — it holds work the merge did not carry, so cleanup leaves it alone \
(#7275)",
file_count(&out.stdout),
out.stdout.split_whitespace().collect::<Vec<_>>().join(", ")
)),
Ok(out) => Some(format!(
"cannot compare the merge of `{tip}` against {base_ref}: {}",
out.stderr.trim()
)),
Err(e) => Some(format!(
"cannot compare the merge of `{tip}` against {base_ref}: {e:#}"
)),
}
}
pub(super) fn file_count(listing: &str) -> String {
let n = listing.split_whitespace().count();
if n == 1 {
"1 file".to_string()
} else {
format!("{n} files")
}
}
pub(super) fn landed<T: Git>(
git: &T,
landing: &dyn Landing,
req: &CleanupRequest,
merge: &Merge,
entry: &WorktreeEntry,
) -> Result<u64, String> {
let branch = entry
.branch
.as_deref()
.map(str::trim)
.filter(|b| !b.is_empty());
let head = entry.head.trim();
let oid = merge.head.as_str();
let exact = (!oid.is_empty() && head.eq_ignore_ascii_case(oid)).then_some(req.pr);
let Some(pr) = landing.merged_pr(&req.repo_root, &entry.path, branch, exact) else {
return Err(format!(
"no MERGED pull request matches {} by branch name, round stem or head commit, so \
the commits it is ahead by are not provably landed (#7275)",
branch.unwrap_or("its detached HEAD")
));
};
let Some(tip) = branch.or(if head.is_empty() { None } else { Some(head) }) else {
return Err(
"this worktree names neither a branch nor a HEAD commit, so whether the merge \
carried its content cannot be tested (#7275)"
.to_string(),
);
};
match unlanded(git, req, merge, tip) {
None => Ok(pr),
Some(reason) => Err(reason),
}
}
pub(super) fn ahead_refusal(shown: &str, dirt_reason: &str, why: &str) -> String {
format!(
"{shown} is ahead of its upstream ({dirt_reason}) and the merge does not account for \
it — refusing to remove it: {why}"
)
}
pub(super) fn ahead_only(dirt: &crate::session_manager::DirtyWorktree) -> bool {
dirt.dirty_files == 0 && dirt.unpushed_commits > 0
}