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 {
let Some(ask) = landed_content else {
return ReclaimVerdict::blocked(
ReclaimGate::PrState,
"no pull request found for this branch",
);
};
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!(
"no pull request found for this branch, 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_content_with(path, &|p| {
landing_admission(p, FETCH_TIMEOUT, &GhLandingProbe)
})
}
pub(super) fn reclaim_landed_content_with(
path: &Path,
admit: &dyn Fn(&Path) -> LandingAdmission,
) -> LandingAdmission {
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 admission;
}
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 => admission,
(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 matches!(pr_now, BranchPrState::NoPr | BranchPrState::Unknown)
&& 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())
}
_ => 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;