use std::path::Path;
use serde::Deserialize;
use super::git_ceiling::bounded_git_output;
use super::worktree_reclaim::{BranchPrState, PrIndex, pr_state_for_branch};
use super::worktree_reclaim_gh::{
GH_TIMEOUT, gh_pr_list_command, resolve_daemon_gh_env, run_with_timeout,
};
use super::worktree_reclaim_gh_gate;
use super::worktree_repo_slug::repo_slug_for;
use super::worktree_safety::{git_command, git_stdout};
use crate::core::pr_cleanup::plan::strip_round_suffix;
const COMMIT_SEARCH_LIMIT: usize = 20;
const COMMIT_SEARCH_JSON_FIELDS: &str = "number,headRefOid,isCrossRepository";
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct MergedPrHead {
pub number: u64,
#[serde(default, rename = "headRefOid")]
pub head_ref_oid: String,
#[serde(default, rename = "isCrossRepository")]
pub is_cross_repository: bool,
}
pub(crate) trait LandingProbe {
fn state_for_head(&self, registry_root: &Path, branch: &str) -> BranchPrState;
fn head_commit(&self, worktree: &Path) -> Result<String, String>;
fn merged_prs_containing(
&self,
registry_root: &Path,
sha: &str,
) -> Result<Vec<MergedPrHead>, String>;
fn is_ancestor(
&self,
worktree: &Path,
ancestor: &str,
descendant: &str,
) -> Result<bool, String>;
}
pub(crate) fn resolve_landing(
worktree: &Path,
registry_root: &Path,
branch: Option<&str>,
exact: BranchPrState,
probe: &dyn LandingProbe,
) -> BranchPrState {
resolve_landing_parts(worktree, registry_root, branch, exact, probe).landing
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PrResolution {
pub by_name: BranchPrState,
pub landing: BranchPrState,
}
fn resolve_landing_parts(
worktree: &Path,
registry_root: &Path,
branch: Option<&str>,
exact: BranchPrState,
probe: &dyn LandingProbe,
) -> PrResolution {
let settled = |state: BranchPrState| PrResolution {
by_name: state.clone(),
landing: state,
};
if !matches!(exact, BranchPrState::NoPr | BranchPrState::Unknown) {
return settled(exact);
}
if let Some(pr) = merged_round_sibling(registry_root, branch, probe) {
return settled(pr);
}
let landing = match merged_by_head_commit(worktree, registry_root, probe) {
Ok(Some(merged)) => merged,
Err(reason) if branch.is_none() => BranchPrState::LookupFailed { reason },
Ok(None) | Err(_) => exact.clone(),
};
PrResolution {
by_name: exact,
landing,
}
}
fn merged_round_sibling(
registry_root: &Path,
branch: Option<&str>,
probe: &dyn LandingProbe,
) -> Option<BranchPrState> {
let branch = branch?;
let stem = strip_round_suffix(branch);
if stem == branch {
return None;
}
match probe.state_for_head(registry_root, stem) {
BranchPrState::Merged { pr } => Some(BranchPrState::Merged { pr }),
other => {
tracing::debug!(
branch = %branch,
stem = %stem,
state = ?other,
"worktree-reclaim: the round-stem sibling carries no merged pull \
request either (#7267)"
);
None
}
}
}
fn merged_by_head_commit(
worktree: &Path,
registry_root: &Path,
probe: &dyn LandingProbe,
) -> Result<Option<BranchPrState>, String> {
let head = match probe.head_commit(worktree) {
Ok(head) if !head.trim().is_empty() => head.trim().to_string(),
Ok(_) => {
tracing::debug!(
path = %worktree.display(),
"worktree-reclaim: this worktree named no HEAD commit, so the \
commit search cannot run (#7267)"
);
return Err("`git rev-parse HEAD` named no commit to search for".to_string());
}
Err(e) => {
tracing::warn!(
path = %worktree.display(),
"worktree-reclaim: the HEAD commit could not be read, so the merged \
pull request carrying this tree cannot be searched for — the \
branch-name answer stands (#7267): {e}"
);
return Err(format!("the HEAD commit could not be read: {e}"));
}
};
let rows = match probe.merged_prs_containing(registry_root, &head) {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(
path = %worktree.display(),
head = %head,
"worktree-reclaim: the merged-pull-request commit search did not \
answer — the branch-name answer stands (#7267): {e}"
);
return Err(format!(
"the merged-pull-request commit search for `{head}` did not answer: {e}"
));
}
};
for row in rows {
if row.is_cross_repository {
continue;
}
let oid = row.head_ref_oid.trim();
if oid.is_empty() {
continue;
}
if vouches_for_head(worktree, oid, &head, row.number, probe) {
tracing::info!(
path = %worktree.display(),
pr = row.number,
head = %head,
"worktree-reclaim: this worktree's HEAD is carried by a merged pull \
request opened from a different branch name (#7267)"
);
return Ok(Some(BranchPrState::Merged { pr: row.number }));
}
}
Ok(None)
}
fn vouches_for_head(
worktree: &Path,
oid: &str,
head: &str,
pr: u64,
probe: &dyn LandingProbe,
) -> bool {
if oid.eq_ignore_ascii_case(head) {
return true;
}
for (ancestor, descendant) in [(oid, head), (head, oid)] {
match probe.is_ancestor(worktree, ancestor, descendant) {
Ok(true) => return true,
Ok(false) => {}
Err(e) => {
tracing::warn!(
path = %worktree.display(),
pr = pr,
"worktree-reclaim: whether `{ancestor}` is an ancestor of \
`{descendant}` could not be established, so this pull request \
vouches for nothing (#7267): {e}"
);
return false;
}
}
}
false
}
pub(crate) fn resolve_with_index(
worktree: &Path,
registry_root: &Path,
branch: Option<&str>,
index: &PrIndex,
per_branch_fallback: bool,
probe: &dyn LandingProbe,
) -> PrResolution {
let mut pr = index.state_for(branch);
if !per_branch_fallback {
return PrResolution {
by_name: pr.clone(),
landing: pr,
};
}
if matches!(
pr,
BranchPrState::Unknown | BranchPrState::LookupFailed { .. }
) && !index.is_complete()
&& let Some(branch) = branch
{
pr = probe.state_for_head(registry_root, branch);
}
let probe = IndexFirst {
index,
inner: probe,
};
resolve_landing_parts(worktree, registry_root, branch, pr, &probe)
}
struct IndexFirst<'a> {
index: &'a PrIndex,
inner: &'a dyn LandingProbe,
}
impl LandingProbe for IndexFirst<'_> {
fn state_for_head(&self, registry_root: &Path, branch: &str) -> BranchPrState {
if self.index.is_complete() {
return self.index.state_for(Some(branch));
}
self.inner.state_for_head(registry_root, branch)
}
fn head_commit(&self, worktree: &Path) -> Result<String, String> {
self.inner.head_commit(worktree)
}
fn merged_prs_containing(
&self,
registry_root: &Path,
sha: &str,
) -> Result<Vec<MergedPrHead>, String> {
self.inner.merged_prs_containing(registry_root, sha)
}
fn is_ancestor(
&self,
worktree: &Path,
ancestor: &str,
descendant: &str,
) -> Result<bool, String> {
self.inner.is_ancestor(worktree, ancestor, descendant)
}
}
pub(crate) fn merged_prs_containing_in(
registry_root: &Path,
repo: &str,
sha: &str,
) -> Result<Vec<MergedPrHead>, String> {
let stdout = worktree_reclaim_gh_gate::shared()
.poll(registry_root, &format!("merged-sha:{repo}:{sha}"), || {
let gh_env = resolve_daemon_gh_env(registry_root, repo)?;
let mut cmd = gh_pr_list_command(registry_root, &gh_env, repo);
cmd.args(["--state", "merged", "--search", sha, "--limit"])
.arg(COMMIT_SEARCH_LIMIT.to_string())
.args(["--json", COMMIT_SEARCH_JSON_FIELDS]);
run_with_timeout(cmd, GH_TIMEOUT)
})
.map_err(|f| format!("{f} (repository searched: {repo})"))?;
serde_json::from_str(&stdout)
.map_err(|e| format!("`gh pr list --repo {repo} --search {sha}` JSON did not parse: {e}"))
}
pub(crate) struct GhLandingProbe;
impl LandingProbe for GhLandingProbe {
fn state_for_head(&self, registry_root: &Path, branch: &str) -> BranchPrState {
pr_state_for_branch(registry_root, branch)
}
fn head_commit(&self, worktree: &Path) -> Result<String, String> {
git_stdout(worktree, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string())
}
fn merged_prs_containing(
&self,
registry_root: &Path,
sha: &str,
) -> Result<Vec<MergedPrHead>, String> {
let repo = repo_slug_for(registry_root)?;
merged_prs_containing_in(registry_root, &repo, sha)
}
fn is_ancestor(
&self,
worktree: &Path,
ancestor: &str,
descendant: &str,
) -> Result<bool, String> {
let out = bounded_git_output(git_command(
worktree,
&["merge-base", "--is-ancestor", ancestor, descendant],
))
.map_err(|e| format!("`git merge-base --is-ancestor` could not be run: {e}"))?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(format!(
"`git merge-base --is-ancestor {ancestor} {descendant}` failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
)),
}
}
}
#[cfg(test)]
#[path = "worktree_reclaim_pr_match_tests.rs"]
pub(crate) mod worktree_reclaim_pr_match_tests;