use std::path::{Path, PathBuf};
use std::process::Command;
use crate::engine::git::{
acquire_worktree_lease, delete_local_branch, get_default_branch, is_clean, ref_exists,
rev_parse, worktree_remove_owned,
};
use crate::engine::worktrees::{list_worktrees_local, main_repo_root, sibling_worktree_name};
use crate::ops::{OpsError, OpsResult, Progress};
#[derive(Debug)]
pub(crate) struct BranchDeletion {
pub repo: PathBuf,
pub branch: String,
worktree: Option<PathBuf>,
remote_head: Option<String>,
local_head: Option<String>,
force: bool,
}
fn git(repo: &Path, args: &[&str]) -> OpsResult<String> {
let output = Command::new("git").args(args).current_dir(repo).output()?;
if !output.status.success() {
return Err(OpsError::Message(format!(
"git {}: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn prepare_delete(
repo: &Path,
selector: &str,
force: bool,
) -> OpsResult<BranchDeletion> {
let repo = main_repo_root(repo)?;
let matches = list_worktrees_local(&repo)?
.1
.into_iter()
.filter(|wt| {
wt.branch.as_deref() == Some(selector)
|| wt.path.to_string_lossy() == selector
|| sibling_worktree_name(&wt.path).as_deref() == Some(selector)
|| wt.path.file_name().is_some_and(|name| name == selector)
})
.collect::<Vec<_>>();
if matches.len() > 1 {
return Err(OpsError::Message(format!(
"multiple worktrees match {selector:?}; use the full branch"
)));
}
let worktree = matches.into_iter().next();
let branch = match &worktree {
Some(wt) => wt
.branch
.clone()
.ok_or_else(|| OpsError::Message("detached checkout has no branch to delete".into()))?,
None => selector.to_string(),
};
git(
&repo,
&["check-ref-format", &format!("refs/heads/{branch}")],
)?;
if branch == get_default_branch(&repo)? || worktree.as_ref().is_some_and(|wt| wt.path == repo) {
return Err(OpsError::Message(
"cannot delete the primary checkout or default branch".into(),
));
}
if let Some(wt) = &worktree {
if wt.path.try_exists()? && !force && !is_clean(&wt.path)? {
return Err(OpsError::Message(
"worktree has uncommitted changes; use --force to discard them".into(),
));
}
}
let remotes = git(&repo, &["remote"])?;
let remote_head = if remotes.lines().any(|remote| remote == "origin") {
let refs = git(
&repo,
&[
"ls-remote",
"--heads",
"origin",
&format!("refs/heads/{branch}"),
],
)?;
refs.split_whitespace().next().map(str::to_string)
} else {
None
};
Ok(BranchDeletion {
local_head: ref_exists(&repo, &format!("refs/heads/{branch}"))?
.then(|| rev_parse(&repo, &format!("refs/heads/{branch}")))
.transpose()?,
repo,
branch,
worktree: worktree.map(|wt| wt.path),
remote_head,
force,
})
}
pub(crate) fn prepare_landed_delete(
repo: &Path,
branch: &str,
merged_head: &str,
) -> OpsResult<BranchDeletion> {
let deletion = prepare_delete(repo, branch, false)?;
if [&deletion.local_head, &deletion.remote_head]
.into_iter()
.flatten()
.any(|head| head != merged_head)
{
return Err(OpsError::Message(format!(
"{branch} has work beyond its merged head; retained checkout and branches"
)));
}
Ok(deletion)
}
pub(crate) fn apply_delete(deletion: BranchDeletion, progress: &impl Progress) -> OpsResult<()> {
let BranchDeletion {
repo,
branch,
worktree,
remote_head,
local_head,
force,
} = deletion;
let lease = worktree
.as_ref()
.map(|path| acquire_worktree_lease(&repo, path, "branch deletion"))
.transpose()?;
let current_head = ref_exists(&repo, &format!("refs/heads/{branch}"))?
.then(|| rev_parse(&repo, &format!("refs/heads/{branch}")))
.transpose()?;
if current_head != local_head {
return Err(OpsError::Message(
"local branch changed during deletion; retained its branches".into(),
));
}
if let Some(path) = &worktree {
if path.try_exists()? && !force && !is_clean(path)? {
return Err(OpsError::Message(
"worktree changed during deletion; retained its branches".into(),
));
}
}
if let Some(head) = remote_head {
progress.status("Deleting remote branch...");
git(
&repo,
&[
"push",
&format!("--force-with-lease=refs/heads/{branch}:{head}"),
"origin",
&format!(":refs/heads/{branch}"),
],
)?;
}
if let Some(path) = worktree {
progress.status("Removing worktree...");
worktree_remove_owned(
&repo,
&path,
lease.as_ref().expect("worktree has a deletion lease"),
)?;
}
if ref_exists(&repo, &format!("refs/heads/{branch}"))? {
delete_local_branch(&repo, &branch)?;
}
Ok(())
}
pub fn delete_worktree(
repo: &Path,
selector: &str,
force: bool,
progress: &impl Progress,
) -> OpsResult<()> {
let deletion = prepare_delete(repo, selector, force)?;
super::task::notice_retained_task(&deletion.repo, &deletion.branch, progress)?;
progress.status(
"PR and Task outcomes are unchanged; use pr abandon or task abandon to close them.",
);
apply_delete(deletion, progress)
}
#[cfg(test)]
mod tests {
use super::{apply_delete, git, prepare_delete};
use crate::ops::NullProgress;
use loopflow_test_support::TestRepo;
#[test]
fn deletion_removes_checkout_and_both_branches_and_retries_without_a_checkout() {
let fixture = TestRepo::new();
let directory = tempfile::tempdir().unwrap();
let checkout = directory.path().join("work");
git(
fixture.path(),
&[
"worktree",
"add",
"-b",
"cancel-me",
checkout.to_str().unwrap(),
],
)
.unwrap();
git(fixture.path(), &["push", "origin", "cancel-me"]).unwrap();
let deletion = prepare_delete(&checkout, "cancel-me", false).unwrap();
apply_delete(deletion, &NullProgress).unwrap();
assert!(!checkout.exists());
assert!(git(fixture.path(), &["branch", "--list", "cancel-me"])
.unwrap()
.is_empty());
assert!(git(
fixture.path(),
&["ls-remote", "--heads", "origin", "refs/heads/cancel-me"]
)
.unwrap()
.is_empty());
apply_delete(
prepare_delete(fixture.path(), "cancel-me", false).unwrap(),
&NullProgress,
)
.unwrap();
}
#[test]
fn deletion_preserves_dirty_primary_and_changed_remote_work() {
let fixture = TestRepo::new();
let directory = tempfile::tempdir().unwrap();
let checkout = directory.path().join("work");
git(
fixture.path(),
&[
"worktree",
"add",
"-b",
"cancel-me",
checkout.to_str().unwrap(),
],
)
.unwrap();
git(fixture.path(), &["push", "origin", "cancel-me"]).unwrap();
std::fs::write(checkout.join("draft"), "keep me").unwrap();
assert!(prepare_delete(fixture.path(), "cancel-me", false).is_err());
assert!(prepare_delete(fixture.path(), "main", true).is_err());
assert_eq!(
std::fs::read_to_string(checkout.join("draft")).unwrap(),
"keep me"
);
let deletion = prepare_delete(fixture.path(), "cancel-me", true).unwrap();
git(&checkout, &["add", "draft"]).unwrap();
git(&checkout, &["commit", "-m", "new work"]).unwrap();
git(&checkout, &["push", "origin", "cancel-me"]).unwrap();
assert!(apply_delete(deletion, &NullProgress).is_err());
assert!(checkout.join("draft").exists());
assert!(!git(
fixture.path(),
&["ls-remote", "--heads", "origin", "refs/heads/cancel-me"]
)
.unwrap()
.is_empty());
}
#[test]
fn deletion_recovers_a_missing_directory_with_retained_git_registration() {
let fixture = TestRepo::new();
let directory = tempfile::tempdir().unwrap();
let checkout = directory.path().join("work");
git(
fixture.path(),
&[
"worktree",
"add",
"-b",
"missing-checkout",
checkout.to_str().unwrap(),
],
)
.unwrap();
git(fixture.path(), &["push", "origin", "missing-checkout"]).unwrap();
std::fs::remove_dir_all(&checkout).unwrap();
apply_delete(
prepare_delete(fixture.path(), "missing-checkout", false).unwrap(),
&NullProgress,
)
.unwrap();
assert!(
git(fixture.path(), &["branch", "--list", "missing-checkout"])
.unwrap()
.is_empty()
);
assert!(git(
fixture.path(),
&[
"ls-remote",
"--heads",
"origin",
"refs/heads/missing-checkout"
]
)
.unwrap()
.is_empty());
assert!(!git(fixture.path(), &["worktree", "list", "--porcelain"])
.unwrap()
.contains("missing-checkout"));
}
}