use std::path::Path;
use crate::core::worktree_landed_content::{LandedContent, LandingAdmission, landing_admission};
use super::worktree_landing_refresh::FETCH_TIMEOUT;
use super::worktree_reclaim::BranchPrState;
use super::worktree_reclaim_pr_match::GhLandingProbe;
use super::worktree_reclaim_verdict::{ReclaimGate, ReclaimVerdict};
use super::worktree_safety::{
DirtyWorktree, count_dirty_files, count_session_branch_unpushed, git_stdout, inspect_dirt,
};
pub(crate) type LandedContentProbe<'a> = Option<&'a dyn Fn(&Path) -> LandingAdmission>;
pub(super) fn no_pr_verdict(
path: &Path,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
landed_content: LandedContentProbe<'_>,
) -> ReclaimVerdict {
admit_or_refuse(
path,
probe_dirt,
landed_content,
"no pull request found for this branch",
)
}
pub(super) const UNKNOWN_PR_STATE: &str = "pull-request state could not be determined — a \
detached HEAD no merged pull request's commit search matched, or a branch past the \
index's page limit";
const DETACHED_PR_STATE: &str = "pull-request state could not be determined — a detached \
HEAD no merged pull request's commit search matched";
pub(super) fn unknown_pr_verdict(
path: &Path,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
landed_content: LandedContentProbe<'_>,
) -> ReclaimVerdict {
if head_is_detached(path) {
return admit_or_refuse(path, probe_dirt, landed_content, DETACHED_PR_STATE);
}
admit_or_refuse(path, probe_dirt, None, UNKNOWN_PR_STATE)
}
fn head_is_detached(path: &Path) -> bool {
git_stdout(path, &["rev-parse", "--abbrev-ref", "HEAD"]).is_ok_and(|name| name.trim() == "HEAD")
}
pub(super) fn published_route_applies(path: &Path, pr: &BranchPrState) -> bool {
match pr {
BranchPrState::NoPr | BranchPrState::Unknown => true,
BranchPrState::LookupFailed { .. } => head_is_detached(path),
_ => false,
}
}
fn admit_or_refuse(
path: &Path,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
landed_content: LandedContentProbe<'_>,
refusal: &str,
) -> ReclaimVerdict {
let Some(ask) = landed_content else {
return ReclaimVerdict::blocked(ReclaimGate::PrState, refusal);
};
if let Some(dirt) = probe_dirt(path).filter(|d| !commits_only(d)) {
return ReclaimVerdict::blocked(
ReclaimGate::UnsavedWork,
format!("holds unsaved work: {}", dirt.reason),
);
}
let admission = ask(path);
if let LandedContent::Landed { base, .. } = &admission.content {
return ReclaimVerdict::ReclaimableLandedContent { base: base.clone() };
}
match admission.carried_pr() {
Some(pr) => ReclaimVerdict::Reclaimable { pr },
None => ReclaimVerdict::blocked(
ReclaimGate::PrState,
format!("{refusal}, and {}", admission.note()),
),
}
}
pub(super) fn merged_pr_verdict(
path: &Path,
merged_pr: u64,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
landed_content: LandedContentProbe<'_>,
) -> ReclaimVerdict {
let Some(dirt) = probe_dirt(path) else {
return ReclaimVerdict::Reclaimable { pr: merged_pr };
};
let refusal = format!("holds unsaved work: {}", dirt.reason);
let Some(ask) = landed_content.filter(|_| commits_only(&dirt)) else {
return ReclaimVerdict::blocked(ReclaimGate::UnsavedWork, refusal);
};
let admission = ask(path);
if admission.admits() {
return ReclaimVerdict::Reclaimable { pr: merged_pr };
}
ReclaimVerdict::blocked(
ReclaimGate::UnsavedWork,
format!("{refusal}, and {}", admission.note()),
)
}
pub(super) const PUBLISHED_BASE: &str = "refs/remotes/origin/*";
pub(super) fn published_verdict(
path: &Path,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
) -> Option<ReclaimVerdict> {
(published(path) && probe_dirt(path).is_none()).then(|| {
ReclaimVerdict::ReclaimableLandedContent {
base: PUBLISHED_BASE.to_string(),
}
})
}
fn published(path: &Path) -> bool {
let args = crate::core::worktree_removal_facts::LOCAL_ONLY_COMMITS_ARGS;
git_stdout(path, args).is_ok_and(|out| out.trim() == "0")
}
fn commits_only(dirt: &DirtyWorktree) -> bool {
dirt.dirty_files == 0 && dirt.unpushed_commits > 0
}
pub(crate) fn reclaim_landed_content(path: &Path) -> LandingAdmission {
reclaim_landed_proof(path).admission
}
pub(crate) fn reclaim_landed_proof(path: &Path) -> ReclaimProof {
reclaim_landed_content_with(path, &|p| {
landing_admission(p, FETCH_TIMEOUT, &GhLandingProbe)
})
}
#[derive(Debug, Clone)]
pub(crate) struct ReclaimProof {
pub admission: LandingAdmission,
pub head: Option<String>,
}
impl From<LandedContent> for ReclaimProof {
fn from(content: LandedContent) -> Self {
Self {
admission: content.into(),
head: None,
}
}
}
pub(super) fn reclaim_landed_content_with(
path: &Path,
admit: &dyn Fn(&Path) -> LandingAdmission,
) -> ReclaimProof {
if let Some(dirt) = dirt_outside_head(path) {
return LandedContent::unavailable(dirt).into();
}
let head_before = git_stdout(path, &["rev-parse", "HEAD"]).map(|h| h.trim().to_string());
let admission = admit(path);
if !admission.admits() {
return ReclaimProof {
admission,
head: None,
};
}
if let Some(dirt) = dirt_outside_head(path) {
return LandedContent::unavailable(format!(
"work appeared while the admission ran: {dirt}"
))
.into();
}
let head_after = git_stdout(path, &["rev-parse", "HEAD"]).map(|h| h.trim().to_string());
match (head_before, head_after) {
(Ok(before), Ok(after)) if before == after => ReclaimProof {
admission,
head: Some(before),
},
(before, after) => LandedContent::unavailable(format!(
"HEAD moved or could not be read while the admission ran ({before:?} → {after:?})"
))
.into(),
}
}
fn dirt_outside_head(path: &Path) -> Option<String> {
match count_dirty_files(path) {
Ok(0) => {}
Ok(n) => {
return Some(format!(
"the tree holds {n} uncommitted/untracked file(s), which no comparison of HEAD \
can vouch for"
));
}
Err(e) => return Some(format!("the dirty-check failed: {e}")),
}
match count_session_branch_unpushed(path) {
Ok(0) => {}
Ok(n) => {
let (branch, sha) = first_unreachable_session_commit(path)
.unwrap_or_else(|| ("session branch".to_string(), "unknown".to_string()));
return Some(format!(
"{n} unpushed commit(s) on `{branch}` are not reachable from HEAD (first: \
`{sha}`), so no comparison of HEAD can vouch for them, and removal deletes \
that branch"
));
}
Err(e) => return Some(format!("the session-branch check failed: {e}")),
}
super::worktree_nested::nested_dirt(path).map(|nested| {
format!(
"a nested repository holds work outside HEAD: {}",
nested.reason
)
})
}
fn first_unreachable_session_commit(path: &Path) -> Option<(String, String)> {
let leaf = path.file_name()?.to_str()?;
[format!("session/{leaf}"), leaf.to_string()]
.into_iter()
.find_map(|branch| {
let tip = format!("refs/heads/{branch}");
let args = [
"rev-list",
"--max-count=1",
&tip,
"--not",
"--remotes",
"HEAD",
];
let sha = git_stdout(path, &args).ok()?.trim().to_string();
(!sha.is_empty()).then_some((branch, sha))
})
}
pub(super) fn landing_recheck(
path: &Path,
pr_now: &BranchPrState,
landed_content: LandedContentProbe<'_>,
) -> Option<String> {
let ask_or = |refusal: String| match landed_content {
Some(ask) => {
let admission = ask(path);
(!admission.admits()).then(|| format!("{refusal}, and {}", admission.note()))
}
None => Some(refusal),
};
if published_route_applies(path, pr_now) && published_verdict(path, &inspect_dirt).is_some() {
return None;
}
match pr_now {
BranchPrState::Merged { .. } => match inspect_dirt(path) {
None => None,
Some(dirt) if commits_only(&dirt) => {
ask_or(format!("holds unsaved work: {}", dirt.reason))
}
Some(dirt) => Some(format!("holds unsaved work: {}", dirt.reason)),
},
BranchPrState::NoPr if landed_content.is_some() => {
ask_or("no pull request carries this branch".to_string())
}
BranchPrState::Unknown if landed_content.is_some() && head_is_detached(path) => {
ask_or(DETACHED_PR_STATE.to_string())
}
_ => Some(format!(
"pull-request state is no longer a merge ({pr_now:?})"
)),
}
}
#[cfg(test)]
#[path = "worktree_reclaim_landed_tests.rs"]
mod worktree_reclaim_landed_tests;