use super::diagnostic_log::debug;
use super::fs_kind::is_subvolume_root;
use super::iso_time::now_iso;
use super::random_id::random_uuid;
use super::service_client::{Activation, COMMIT_WAIT, ClientConfig, START_WAIT, wake};
use super::service_route::{Route, route_from_environment};
use super::service_work::{Verdict, work_directory};
use super::subvolume_remove::remove_tree;
use super::transaction_git::{GitContext, GitRequest, run_git};
use super::work_item::{PENDING, WorkItem, WorkKind, enqueue, state_directory};
use super::worktree_clean::stored_stats;
use super::worktree_copy_prune::Prune;
use super::worktree_snapshot_source::SnapshotSource;
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
const TAG: &str = "worktreeTrash";
pub const TRASH_PARENT: &str = ".claude/cli-git-trash";
pub const MAIN_SNAPSHOT_NOTE: &str = "cli-git: made the worktree as a snapshot of the main checkout; nested worktrees, caches and run records were left out.\n";
const TRASH_REFERENCE: &str = "refs/cli-git/trash";
pub fn trash_location_is_ignored(context: &GitContext, root: &Path) -> bool {
let request: GitRequest = GitRequest {
cwd: root.to_path_buf(),
arguments: ["check-ignore", "--quiet", "--no-index", "--"].iter().map(std::ffi::OsString::from).chain(std::iter::once(std::ffi::OsString::from(format!("{TRASH_PARENT}/probe/x")))).collect(),
without_prefix: true,
..GitRequest::default()
};
return run_git(context, &request).is_ok_and(|output| { return output.code == Some(0); });
}
fn new_trash_name() -> Result<String, String> {
let first: String = random_uuid().map_err(|error| { return format!("a name for the trash could not be made: {error:?}"); })?;
let second: String = random_uuid().map_err(|error| { return format!("a name for the trash could not be made: {error:?}"); })?;
let digits: String = format!("{first}{second}").chars().filter(char::is_ascii_hexdigit).collect();
return Ok(digits.chars().take(40).collect());
}
fn is_trash_name(name: &str) -> bool {
return name.len() == 40 && name.bytes().all(|byte: u8| { return byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte); });
}
struct Aside {
trash: Option<PathBuf>,
count: usize,
}
impl Aside {
fn put(&mut self, from: &Path) -> Result<(), String> {
match self.trash.as_ref() {
Some(trash) => {
let to: PathBuf = trash.join(format!("d{}", self.count));
std::fs::rename(from, &to).map_err(|error: std::io::Error| { return format!("{} cannot be set aside ({error})", from.display()); })?;
}
None => remove_tree(from).map_err(|error: std::io::Error| { return format!("{} cannot be removed ({error})", from.display()); })?,
}
self.count += 1;
return Ok(());
}
}
fn tracked_directories(source: &SnapshotSource) -> Result<HashSet<Vec<u8>>, String> {
let entries = stored_stats(source.clean.index_bytes.as_slice(), source.clean.hash_length).ok_or_else(|| { return String::from("the index entries have no plain layout"); })?;
let mut directories: HashSet<Vec<u8>> = HashSet::new();
for entry in entries {
let mut end: usize = entry.path.len();
while let Some(at) = entry.path[..end].iter().rposition(|byte: &u8| { return *byte == b'/'; }) {
if !directories.insert(entry.path[..at].to_vec()) {
break;
}
end = at;
}
}
return Ok(directories);
}
pub fn prune_snapshot(stage: &Path, source: &SnapshotSource) -> Result<usize, String> {
if source.git_dir != source.common_dir {
return Ok(0);
}
let prune: Prune = Prune::main_checkout();
let trash: Option<PathBuf> = if source.trash_ignored {
let directory: PathBuf = stage.join(TRASH_PARENT).join(new_trash_name()?);
std::fs::create_dir_all(&directory).map_err(|error: std::io::Error| { return format!("{} cannot be made ({error})", directory.display()); })?;
Some(directory)
} else {
None
};
let mut aside: Aside = Aside { trash: trash.clone(), count: 0 };
for nested in &source.nested {
let Ok(relative) = nested.strip_prefix(source.root.as_path()) else { continue };
if is_subvolume_root(nested.as_path()).unwrap_or(false) {
continue;
}
let copy: PathBuf = stage.join(relative);
if !std::fs::symlink_metadata(©).is_ok_and(|metadata: std::fs::Metadata| { return metadata.is_dir(); }) {
continue;
}
unlink_pointer(copy.as_path())?;
aside.put(copy.as_path())?;
}
let tracked: HashSet<Vec<u8>> = tracked_directories(source)?;
let mut pending: Vec<(PathBuf, String)> = vec![(stage.to_path_buf(), String::new())];
while let Some((directory, relative)) = pending.pop() {
let mut children: Vec<(String, bool)> = Vec::new();
for listed in std::fs::read_dir(&directory).map_err(|error: std::io::Error| { return format!("{} cannot be listed ({error})", directory.display()); })? {
let entry: std::fs::DirEntry = listed.map_err(|error: std::io::Error| { return format!("{} cannot be listed ({error})", directory.display()); })?;
let is_directory: bool = entry.file_type().is_ok_and(|kind: std::fs::FileType| { return kind.is_dir(); });
children.push((entry.file_name().to_string_lossy().into_owned(), is_directory));
}
let names: Vec<String> = children.iter().map(|(name, _is_directory): &(String, bool)| { return name.clone(); }).collect();
if !relative.is_empty() && prune.skips_checkout(names.as_slice()) && !tracked.contains(relative.as_bytes()) {
unlink_pointer(directory.as_path())?;
aside.put(directory.as_path())?;
continue;
}
for (name, is_directory) in children {
if !is_directory {
continue;
}
let child_relative: String = if relative.is_empty() { name.clone() } else { format!("{relative}/{name}") };
if child_relative == ".git" || child_relative == TRASH_PARENT {
continue;
}
if !tracked.contains(child_relative.as_bytes()) && prune.skips_path(child_relative.as_str()) {
aside.put(directory.join(&name).as_path())?;
} else {
pending.push((directory.join(&name), child_relative));
}
}
}
if aside.count == 0
&& let Some(empty) = trash.as_ref()
{
let _ignored: std::io::Result<()> = std::fs::remove_dir(empty);
}
debug(TAG, format!("{} directories set aside in {} ({})", aside.count, stage.display(), if trash.is_some() { "into the trash" } else { "removed in place" }).as_str());
return Ok(aside.count);
}
fn unlink_pointer(directory: &Path) -> Result<(), String> {
let pointer: PathBuf = directory.join(".git");
match std::fs::symlink_metadata(&pointer) {
Ok(metadata) if metadata.is_file() => return std::fs::remove_file(&pointer).map_err(|error: std::io::Error| { return format!("{} cannot be unlinked ({error})", pointer.display()); }),
Ok(_) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(format!("{} cannot be examined ({error})", pointer.display())),
}
}
pub fn dispose_pending_trash(context: &GitContext, destination: &Path, git_dir: &Path, notices: &mut dyn Write) {
dispose_pending_trash_with(context.real_git.as_path(), destination, git_dir, notices, &route_from_environment, START_WAIT);
}
pub type Router<'a> = &'a dyn Fn(&Path, &Path) -> Option<Route>;
pub fn dispose_pending_trash_with(real_git: &Path, destination: &Path, git_dir: &Path, notices: &mut dyn Write, router: Router<'_>, start_wait: std::time::Duration) {
let parent: PathBuf = destination.join(TRASH_PARENT);
let Ok(listing) = std::fs::read_dir(&parent) else { return };
let mut names: Vec<String> = listing.flatten().map(|entry: std::fs::DirEntry| { return entry.file_name().to_string_lossy().into_owned(); }).filter(|name: &String| { return is_trash_name(name.as_str()); }).collect();
names.sort();
for name in names {
let handed: bool = hand_over(real_git, destination, git_dir, name.as_str(), notices, router, start_wait);
if !handed {
debug(TAG, format!("the trash {name} could not be handed to the service; emptying it here").as_str());
if let Err(error) = remove_tree(parent.join(&name).as_path()) {
debug(TAG, format!("the trash {name} could not be emptied here either ({error}); it stays under an ignored path").as_str());
}
}
}
let _ignored: std::io::Result<()> = std::fs::remove_dir(&parent);
}
fn hand_over(real_git: &Path, destination: &Path, git_dir: &Path, name: &str, notices: &mut dyn Write, router: Router<'_>, start_wait: std::time::Duration) -> bool {
let item: WorkItem = WorkItem {
kind: WorkKind::Trash,
repository: destination.display().to_string(),
git_dir: git_dir.display().to_string(),
branch_ref: String::from(TRASH_REFERENCE),
oid: String::from(name),
parent: String::from(name),
created: now_iso(),
tries: 0,
};
if let Err(reason) = enqueue(work_directory(git_dir).as_path(), &item) {
debug(TAG, format!("the work item could not be written: {reason}").as_str());
return false;
}
let reached: bool = router(real_git, git_dir).is_some_and(|Route { address, .. }| {
let config: ClientConfig = ClientConfig { address, activation: Activation::Managed, start_wait, commit_wait: COMMIT_WAIT };
return wake(&config, git_dir, None, notices).is_ok();
});
if !reached {
let record: PathBuf = state_directory(work_directory(git_dir).as_path(), PENDING).join(item.id());
let _ignored: std::io::Result<()> = std::fs::remove_file(record);
}
return reached;
}
pub fn empty_trash(item: &WorkItem) -> Verdict {
if !is_trash_name(item.oid.as_str()) || item.parent != item.oid {
return Verdict::Failed(format!("{} is not the name of a trash directory", item.oid));
}
let repository: &Path = Path::new(item.repository.as_str());
let parent: PathBuf = repository.join(TRASH_PARENT);
let trash: PathBuf = parent.join(item.oid.as_str());
match std::fs::symlink_metadata(&trash) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return Verdict::Failed(format!("{} is not a directory, so it is left alone", trash.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Verdict::Done,
Err(error) => return Verdict::Failed(format!("{} cannot be examined ({error})", trash.display())),
}
if !repository.join(".git").is_file() {
return Verdict::Failed(format!("{} is no longer a linked worktree, so its trash is left alone", repository.display()));
}
if let Err(error) = remove_tree(trash.as_path()) {
return Verdict::Failed(format!("{} cannot be emptied ({error})", trash.display()));
}
let _ignored: std::io::Result<()> = std::fs::remove_dir(&parent);
return Verdict::Done;
}
#[cfg(test)]
#[path = "worktree_trash_tests.rs"]
mod tests;