use std::{io, path::Path}; use crate::bstr::BString; use gix_features::progress::{Count, NestedProgress, Progress}; /// The amount of validation to bypass when removing a linked worktree. #[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] pub enum Force { /// Refuse to remove a dirty worktree or one containing initialized submodules. #[default] Never, /// Discard changes and initialized submodules, but retain locked worktrees. DiscardChanges, /// Also override a worktree lock. OverrideLock, } impl Force { fn discards_changes(self) -> bool { !matches!(self, Force::Never) } } /// A resolved linked worktree ready for inspection or removal. pub struct Target<'repo> { proxy: crate::worktree::Proxy<'repo>, base: std::path::PathBuf, } impl Target<'_> { /// Return the checkout directory, whether or not it is currently accessible. pub fn base(&self) -> &Path { &self.base } /// Open this worktree's repository, even if its checkout is currently inaccessible. pub fn repository(&self) -> Result { self.proxy.clone().into_repo_with_possibly_inaccessible_worktree() } /// Validate and remove this linked worktree. pub fn remove

(self, force: Force, mut progress: P) -> Result<(), Error> where P: NestedProgress, P::SubProgress: NestedProgress + 'static, { let Target { proxy, base: work_dir } = self; let mut validation = progress.add_child("validate"); validation.init(Some(1), gix_features::progress::count("worktree")); let git_dir = proxy.git_dir().to_owned(); let ignore_case = proxy.parent.config.ignore_case; if proxy.is_locked() && force != Force::OverrideLock { return Err(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) => return Err(Error::WorktreeLocation(err)), Ok(_) => { validate_backlink(&work_dir, &git_dir, ignore_case)?; if !force.discards_changes() { let linked_repo = proxy .into_repo_with_possibly_inaccessible_worktree() .map_err(Error::OpenWorktreeRepo)?; if has_populated_submodule(&linked_repo)? { return Err(Error::ContainsSubmodule { path: work_dir }); } let status = validation.add_child("status"); let mut iter = linked_repo .status(status)? .untracked_files(crate::status::UntrackedFiles::Files) .index_worktree_submodules(crate::status::Submodule::AsConfigured { check_dirty: true }) .into_iter(Vec::new())?; match iter.next() { Some(Ok(_)) => return Err(Error::Dirty { path: work_dir }), Some(Err(err)) => return Err(err.into()), None => {} } } } } validation.inc(); drop(validation); gix_worktree::remove::remove(work_dir, git_dir, progress)?; Ok(()) } } /// The error returned by [`Repository::remove_worktree()`][crate::Repository::remove_worktree()]. #[derive(Debug, thiserror::Error)] #[expect(missing_docs)] pub enum Error { #[error("The worktree name or path cannot be empty")] EmptyTarget, #[error("Could not list linked worktrees")] WorktreeListing(#[source] io::Error), #[error("Could not read the location of a linked worktree")] WorktreeLocation(#[source] io::Error), #[error("Could not resolve worktree path '{path}'")] ResolvePath { path: std::path::PathBuf, #[source] source: gix_path::realpath::Error, }, #[error("'{target}' is not a registered worktree", target = target.display())] NotFound { target: std::path::PathBuf }, #[error("'{target}' is ambiguous and matches {candidates:?}", target = target.display())] Ambiguous { target: std::path::PathBuf, candidates: Vec, }, #[error("The main worktree '{path}' cannot be removed", path = path.display())] MainWorktree { path: std::path::PathBuf }, #[error("The worktree '{path}' is locked{reason}", path = path.display(), reason = display_reason(.reason.as_ref()))] Locked { path: std::path::PathBuf, reason: Option, }, #[error("Could not read the .git file at '{path}'", path = path.display())] ReadBacklink { path: std::path::PathBuf, #[source] source: gix_discover::path::from_gitdir_file::Error, }, #[error("Could not resolve the private Git directory '{path}'", path = path.display())] ResolveGitDir { path: std::path::PathBuf, #[source] source: gix_path::realpath::Error, }, #[error("The .git file at '{path}' points to '{actual}', not '{expected}'", path = path.display(), actual = actual.display(), expected = expected.display())] BacklinkMismatch { path: std::path::PathBuf, expected: std::path::PathBuf, actual: std::path::PathBuf, }, #[error("Could not open the linked worktree repository")] OpenWorktreeRepo(#[source] crate::open::Error), #[error("The worktree '{path}' contains an initialized submodule", path = path.display())] ContainsSubmodule { path: std::path::PathBuf }, #[error("Could not open the linked worktree index")] OpenIndex(#[from] crate::worktree::open_index::Error), #[error("Could not prepare status for the linked worktree")] Status(#[from] crate::status::Error), #[error("Could not inspect the linked worktree status")] StatusIter(#[from] crate::status::into_iter::Error), #[error("Could not inspect a linked worktree status item")] StatusItem(#[from] crate::status::iter::Error), #[error("The worktree '{path}' contains modified or untracked files", path = path.display())] Dirty { path: std::path::PathBuf }, #[error(transparent)] Remove(#[from] gix_worktree::remove::Error), } impl crate::Repository { /// Resolve `target`, which may be a worktree path or a unique trailing path suffix, for later inspection and removal. /// /// The main worktree cannot be prepared for removal. The returned target can be opened with /// [`Target::repository()`] before it is consumed by [`Target::remove()`]. pub fn prepare_remove_worktree(&self, target: impl AsRef) -> Result, Error> { resolve(self, target.as_ref()) } /// Remove the linked worktree matching `target`, which may be its path or a unique trailing path suffix. /// /// The main worktree cannot be removed. By default, modified or untracked files and initialized submodules /// prevent removal. [`Force::DiscardChanges`] permits both, while a locked worktree additionally requires /// [`Force::OverrideLock`]. The worktree's attached branch, if any, is retained. /// /// This method may remove the linked worktree represented by `self`. Callers doing so should leave the /// checkout before removal, particularly on Windows where a process working directory can prevent deletion. pub fn remove_worktree

(&self, target: impl AsRef, force: Force, progress: P) -> Result<(), Error> where P: NestedProgress, P::SubProgress: NestedProgress + 'static, { self.prepare_remove_worktree(target)?.remove(force, progress) } } enum Match<'repo> { Main(std::path::PathBuf), Linked(Target<'repo>), } fn resolve<'repo>(repo: &'repo crate::Repository, target: &Path) -> Result, Error> { if target.as_os_str().is_empty() { return Err(Error::EmptyTarget); } let main = repo .main_repo() .map_err(Error::OpenWorktreeRepo)? .workdir() .map(Path::to_owned); let ignore_case = repo.config.ignore_case; let mut linked = Vec::new(); for proxy in repo.worktrees().map_err(Error::WorktreeListing)? { let Ok(base) = proxy.base() else { continue }; linked.push(Target { proxy, base }); } 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(), })); } } if suffix_matches.len() == 1 { return match suffix_matches.pop().expect("exactly one match") { Match::Main(path) => Err(Error::MainWorktree { path }), Match::Linked(candidate) => Ok(candidate), }; } let resolved_target = gix_path::realpath(target).map_err(|source| Error::ResolvePath { path: target.to_owned(), source, })?; let mut exact_matches = Vec::new(); if let Some(path) = main { let resolved = gix_path::realpath(&path).map_err(|source| Error::ResolvePath { path: path.clone(), source, })?; if path_eq(&resolved, &resolved_target, ignore_case) { exact_matches.push(Match::Main(path)); } } for candidate in linked { let resolved = gix_path::realpath(&candidate.base).map_err(|source| Error::ResolvePath { path: candidate.base.clone(), source, })?; 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(), }), 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(), }), 1 => match exact_matches.pop().expect("exactly one match") { Match::Main(path) => Err(Error::MainWorktree { path }), 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(), }), } } fn validate_backlink(work_dir: &Path, git_dir: &Path, ignore_case: bool) -> Result<(), Error> { let path = work_dir.join(gix_discover::DOT_GIT_DIR); let actual = gix_discover::path::from_gitdir_file(&path).map_err(|source| Error::ReadBacklink { path: path.clone(), source, })?; let actual = gix_path::realpath(&actual).map_err(|source| Error::ResolveGitDir { path: actual.clone(), source, })?; let expected = gix_path::realpath(git_dir).map_err(|source| Error::ResolveGitDir { path: git_dir.to_owned(), source, })?; if !path_eq(&actual, &expected, ignore_case) { return Err(Error::BacklinkMismatch { path, expected, actual }); } Ok(()) } fn path_eq(left: &Path, right: &Path, ignore_case: bool) -> bool { left.components().count() == right.components().count() && path_ends_with(left, right, ignore_case) } 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 { if repo.git_dir().join("modules").is_dir() { return Ok(true); } let index = repo.index_or_empty()?; Ok(index.entries().iter().any(|entry| { entry.mode == gix_index::entry::Mode::COMMIT && repo.workdir().is_some_and(|work_dir| { work_dir .join(gix_path::from_bstr(entry.path(&index))) .join(".git") .exists() }) })) } fn display_reason(reason: Option<&BString>) -> String { reason.map_or_else(String::new, |reason| format!(": {reason}")) }