use std::path::Path;
use gix_error::{ClassificationMarker, ErrorExt, ResultExt, bail, message};
use crate::{Result, bstr::BString};
use gix_path::realpath::MAX_SYMLINKS;
use gix_utils::progress::{Count, NestedProgress, Progress};
pub use gix_worktree::remove::Options;
#[derive(Default, Debug, Copy, Clone, Eq, PartialEq)]
pub enum Force {
#[default]
Never,
DiscardChanges,
OverrideLock,
}
impl Force {
fn discards_changes(self) -> bool {
!matches!(self, Force::Never)
}
}
pub struct Target<'repo> {
proxy: crate::worktree::Proxy<'repo>,
base: std::path::PathBuf,
options: Options,
}
impl Target<'_> {
pub fn base(&self) -> &Path {
&self.base
}
pub fn repository(&self) -> Result<crate::Repository> {
self.proxy.clone().into_repo_with_possibly_inaccessible_worktree()
}
pub fn options(mut self, options: Options) -> Self {
self.options = options;
self
}
pub fn remove<P>(self, force: Force, mut progress: P) -> Result<()>
where
P: NestedProgress,
P::SubProgress: NestedProgress + 'static,
{
let Target {
proxy,
base: work_dir,
options,
} = self;
let work_dir = proxy.parent.current_dir().join(work_dir);
let mut validation = progress.add_child("validate");
validation.init(Some(1), gix_utils::progress::count("worktree"));
let git_dir = proxy.parent.current_dir().join(proxy.git_dir());
let ignore_case = proxy.parent.config.ignore_case;
if force != Force::OverrideLock && proxy.is_locked() {
bail!(Error::Locked {
path: work_dir,
reason: proxy.lock_reason(),
});
}
match std::fs::symlink_metadata(&work_dir) {
Err(err) if gix_fs::io_err::is_not_found(err.kind(), err.raw_os_error()) => {}
Err(err) => {
bail!(err.and_raise(message("Could not read the location of a linked worktree")));
}
Ok(_) => {
validate_backlink(&work_dir, &git_dir, ignore_case)?;
if !force.discards_changes() {
let linked_repo = proxy
.into_repo_with_possibly_inaccessible_worktree()
.or_raise(|| message("Could not open the linked worktree repository"))?;
if has_populated_submodule(&linked_repo)? {
bail!(Error::ContainsSubmodule { path: work_dir });
}
let dirwalk_options = linked_repo
.dirwalk_options()
.or_raise(|| message("Could not prepare the linked worktree directory walk"))?;
let status = validation.add_child("status");
let mut iter = linked_repo
.status(status)
.or_raise(|| message("Could not prepare status for the linked worktree"))?
.index_worktree_options_mut(|opts| {
opts.dirwalk_options = Some(dirwalk_options);
})
.untracked_files(crate::status::UntrackedFiles::Files)
.index_worktree_submodules(crate::status::Submodule::AsConfigured { check_dirty: true })
.into_iter(None)
.or_raise(|| message("Could not inspect the linked worktree status"))?;
if iter
.next()
.transpose()
.or_raise(|| message("Could not inspect a linked worktree status item"))?
.is_some()
{
bail!(Error::Dirty { path: work_dir });
}
}
}
}
validation.inc();
drop(validation);
gix_worktree::remove(work_dir, git_dir, progress, options)
.or_raise(|| message("Could not remove the linked worktree"))?;
Ok(())
}
}
impl crate::worktree::Proxy<'_> {
pub fn remove<P>(self, force: Force, progress: P) -> Result<()>
where
P: NestedProgress,
P::SubProgress: NestedProgress + 'static,
{
let base = self
.base()
.or_raise(|| message("Could not read the linked worktree checkout location"))?;
Target {
proxy: self,
base,
options: Options::default(),
}
.remove(force, progress)
}
}
#[derive(Debug)]
#[expect(missing_docs)]
pub enum Error {
EmptyTarget,
NotFound {
target: std::path::PathBuf,
},
Ambiguous {
target: std::path::PathBuf,
candidates: Vec<std::path::PathBuf>,
},
MainWorktree {
path: std::path::PathBuf,
},
Locked {
path: std::path::PathBuf,
reason: Option<BString>,
},
BacklinkMismatch {
path: std::path::PathBuf,
expected: std::path::PathBuf,
actual: std::path::PathBuf,
},
ContainsSubmodule {
path: std::path::PathBuf,
},
Dirty {
path: std::path::PathBuf,
},
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fn display_reason(reason: Option<&BString>) -> String {
reason.map_or_else(String::new, |reason| format!(": {reason}"))
}
match self {
Error::EmptyTarget => f.write_str("The worktree name or path cannot be empty"),
Error::NotFound { target } => write!(f, "\"{}\" is not a registered worktree", target.display()),
Error::Ambiguous { target, candidates } => {
write!(f, "\"{}\" is ambiguous and matches {candidates:?}", target.display())
}
Error::MainWorktree { path } => write!(f, "The main worktree \"{}\" cannot be removed", path.display()),
Error::Locked { path, reason } => write!(
f,
"The worktree \"{}\" is locked{}",
path.display(),
display_reason(reason.as_ref())
),
Error::BacklinkMismatch { path, expected, actual } => write!(
f,
"The .git file at \"{}\" points to \"{}\", not \"{}\"",
path.display(),
actual.display(),
expected.display()
),
Error::ContainsSubmodule { path } => {
write!(f, "The worktree '{}' contains an initialized submodule", path.display())
}
Error::Dirty { path } => write!(
f,
"The worktree \"{}\" contains modified or untracked files",
path.display()
),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(match self {
Error::NotFound { .. } => const { &ClassificationMarker::NOT_FOUND },
Error::BacklinkMismatch { .. } => const { &ClassificationMarker::CORRUPTION },
Error::EmptyTarget | Error::Ambiguous { .. } | Error::MainWorktree { .. } => {
const { &ClassificationMarker::VALIDATION }
}
Error::Locked { .. } | Error::ContainsSubmodule { .. } | Error::Dirty { .. } => {
const { &ClassificationMarker::CONFLICT }
}
})
}
}
impl crate::Repository {
pub fn prepare_remove_worktree(&self, target: impl AsRef<Path>) -> Result<Target<'_>> {
resolve(self, target.as_ref())
}
pub fn remove_worktree<P>(&self, target: impl AsRef<Path>, force: Force, progress: P) -> Result<()>
where
P: NestedProgress,
P::SubProgress: NestedProgress + 'static,
{
self.prepare_remove_worktree(target)?.remove(force, progress)
}
}
fn resolve<'repo>(repo: &'repo crate::Repository, target: &Path) -> Result<Target<'repo>> {
enum Match<'repo> {
Main(std::path::PathBuf),
Linked(Target<'repo>),
}
if target.as_os_str().is_empty() {
bail!(Error::EmptyTarget);
}
let main = repo
.main_repo()
.or_raise(|| message("Could not open the linked worktree repository"))?
.workdir()
.map(Path::to_owned);
let ignore_case = repo.config.ignore_case;
let mut linked = Vec::new();
for proxy in repo
.worktrees()
.or_raise(|| message("Could not list linked worktrees"))?
{
let Ok(base) = proxy.base() else { continue };
linked.push(Target {
proxy,
base,
options: Options::default(),
});
}
let mut suffix_matches = Vec::new();
if let Some(path) = main.as_ref()
&& path_ends_with(path, target, ignore_case)
{
suffix_matches.push(Match::Main(path.clone()));
}
for candidate in &linked {
if path_ends_with(&candidate.base, target, ignore_case) {
suffix_matches.push(Match::Linked(Target {
proxy: candidate.proxy.clone(),
base: candidate.base.clone(),
options: candidate.options,
}));
}
}
if suffix_matches.len() == 1 {
return match suffix_matches.pop().expect("exactly one match") {
Match::Main(path) => Err(Error::MainWorktree { path }.raise()),
Match::Linked(candidate) => Ok(candidate),
};
}
let resolved_target =
gix_path::realpath(target).or_raise(|| message!("Could not resolve worktree path \"{}\"", target.display()))?;
let mut exact_matches = Vec::new();
if let Some(path) = main {
let resolved = gix_path::realpath_opts(&path, repo.current_dir(), MAX_SYMLINKS)
.or_raise(|| message!("Could not resolve worktree path \"{}\"", path.display()))?;
if path_eq(&resolved, &resolved_target, ignore_case) {
exact_matches.push(Match::Main(path));
}
}
for candidate in linked {
let resolved = gix_path::realpath_opts(&candidate.base, repo.current_dir(), MAX_SYMLINKS)
.or_raise(|| message!("Could not resolve worktree path \"{}\"", candidate.base.display()))?;
if path_eq(&resolved, &resolved_target, ignore_case) {
exact_matches.push(Match::Linked(candidate));
}
}
match exact_matches.len() {
0 if suffix_matches.is_empty() => Err(Error::NotFound {
target: target.to_owned(),
}
.raise()),
0 => Err(Error::Ambiguous {
target: target.to_owned(),
candidates: suffix_matches
.into_iter()
.map(|candidate| match candidate {
Match::Main(path) => path,
Match::Linked(candidate) => candidate.base,
})
.collect(),
}
.raise()),
1 => match exact_matches.pop().expect("exactly one match") {
Match::Main(path) => Err(Error::MainWorktree { path }.raise()),
Match::Linked(candidate) => Ok(candidate),
},
_ => Err(Error::Ambiguous {
target: target.to_owned(),
candidates: exact_matches
.into_iter()
.map(|candidate| match candidate {
Match::Main(path) => path,
Match::Linked(candidate) => candidate.base,
})
.collect(),
}
.raise()),
}
}
fn validate_backlink(work_dir: &Path, git_dir: &Path, ignore_case: bool) -> Result<()> {
let path = work_dir.join(gix_discover::DOT_GIT_DIR);
let actual = gix_discover::path::from_gitdir_file(&path)
.or_raise(|| message!("Could not read the .git file at \"{}\"", path.display()))?;
let actual = gix_path::realpath(&actual)
.or_raise(|| message!("Could not resolve the private Git directory \"{}\"", actual.display()))?;
let expected = gix_path::realpath(git_dir)
.or_raise(|| message!("Could not resolve the private Git directory \"{}\"", git_dir.display()))?;
if !path_eq(&actual, &expected, ignore_case) {
bail!(Error::BacklinkMismatch { path, expected, actual });
}
Ok(())
}
fn path_eq(left: &Path, right: &Path, ignore_case: bool) -> bool {
if left.components().count() == right.components().count() && path_ends_with(left, right, ignore_case) {
return true;
}
#[cfg(windows)]
if let (Ok(left), Ok(right)) = (left.canonicalize(), right.canonicalize()) {
return left == right;
}
false
}
fn path_ends_with(path: &Path, suffix: &Path, ignore_case: bool) -> bool {
if !ignore_case {
return path.ends_with(suffix);
}
let mut path = path.components().rev();
suffix.components().rev().all(|suffix| {
path.next().is_some_and(|path| {
path.as_os_str()
.as_encoded_bytes()
.eq_ignore_ascii_case(suffix.as_os_str().as_encoded_bytes())
})
})
}
fn has_populated_submodule(repo: &crate::Repository) -> Result<bool> {
if repo.git_dir().join("modules").is_dir() {
return Ok(true);
}
let index = repo
.index_or_empty()
.or_raise(|| message("Could not open the linked worktree index"))?;
if let Some(work_dir) = repo.workdir() {
for entry in index
.entries()
.iter()
.filter(|entry| entry.mode == gix_index::entry::Mode::COMMIT)
{
if work_dir
.join(gix_path::from_bstr(entry.path(&index))?)
.join(".git")
.exists()
{
return Ok(true);
}
}
}
Ok(false)
}