use crate::diagnostic_log::debug;
use crate::fs_beneath::{ObjectId, identity_at, identity_of, make_directory_at, open_directory_at, open_directory_beneath, open_root, plain_components, rename_at};
use crate::fs_kind::is_subvolume_root;
use crate::random_id::random_uuid;
use crate::transaction_git::{GitContext, GitRequest, run_git};
use crate::worktree_clean::stored_stats;
use crate::worktree_copy_prune::Prune;
use crate::worktree_snapshot_source::SnapshotSource;
pub use crate::worktree_trash_dispose::{Reach, Router, TRASH_REFERENCE, delete_trash, dispose_pending_trash, dispose_pending_trash_with, empty_trash};
pub use crate::worktree_trash_manifest::{TrashManifest, entry_name, is_trash_name};
use crate::worktree_trash_remove::remove_tree_beneath;
use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::os::unix::ffi::OsStrExt;
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 DIRECTORY_MODES: [u32; 2] = [0o040_000, 0o160_000];
const TYPE_MASK: u32 = 0o170_000;
pub fn trash_location_is_ignored(context: &GitContext, root: &Path) -> bool {
let request: GitRequest = GitRequest {
cwd: root.to_path_buf(),
arguments: ["check-ignore", "--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::<String>().to_ascii_lowercase());
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Pruned {
pub count: usize,
pub trash: Option<TrashManifest>,
}
struct Trash {
dir: File,
name: String,
id: ObjectId,
parent: File,
}
struct Aside {
root: File,
stage: PathBuf,
trash: Option<Trash>,
entries: Vec<ObjectId>,
count: usize,
}
impl Aside {
fn holder(&self, relative: &Path) -> Result<(File, OsString), String> {
let names: Vec<OsString> = plain_components(relative).map_err(|error: std::io::Error| { return format!("{} is not a plain path inside the snapshot ({error})", relative.display()); })?;
let Some((name, parents)) = names.split_last() else { return Err(format!("{} names the snapshot itself", relative.display())) };
let parent: File = open_directory_beneath(&self.root, parents).map_err(|error: std::io::Error| { return format!("{} is reached through a link or another mount in the snapshot ({error})", self.stage.join(relative).display()); })?;
return Ok((parent, name.clone()));
}
fn put(&mut self, relative: &Path) -> Result<(), String> {
let (parent, name): (File, OsString) = self.holder(relative)?;
let shown: PathBuf = self.stage.join(relative);
match self.trash.as_ref() {
Some(trash) => {
let looked: ObjectId = identity_at(&parent, name.as_os_str()).map_err(|error: std::io::Error| { return format!("{} cannot be examined ({error})", shown.display()); })?;
let to: String = entry_name(self.entries.len());
rename_at(&parent, name.as_os_str(), &trash.dir, OsStr::new(to.as_str()), false).map_err(|error: std::io::Error| { return format!("{} cannot be set aside ({error})", shown.display()); })?;
let moved: ObjectId = identity_at(&trash.dir, OsStr::new(to.as_str())).map_err(|error: std::io::Error| { return format!("{} cannot be examined in the trash ({error})", shown.display()); })?;
if moved != looked {
return Err(format!("{} changed while it was set aside ({} instead of {})", shown.display(), moved.encode(), looked.encode()));
}
self.entries.push(moved);
}
None => {
remove_tree_beneath(shown.parent().unwrap_or(self.stage.as_path()), &parent, name.as_os_str(), None).map_err(|reason: String| { return format!("{} cannot be removed ({reason})", shown.display()); })?;
}
}
self.count += 1;
return Ok(());
}
}
fn make_trash(root: &File) -> Result<Trash, String> {
let names: Vec<OsString> = plain_components(Path::new(TRASH_PARENT)).map_err(|error: std::io::Error| { return format!("the trash parent is not a plain path ({error})"); })?;
let mut parent: File = root.try_clone().map_err(|error: std::io::Error| { return format!("the snapshot cannot be held ({error})"); })?;
for name in &names {
make_directory_at(&parent, name.as_os_str(), 0o755).map_err(|error: std::io::Error| { return format!("{} cannot be made in the snapshot ({error})", name.to_string_lossy()); })?;
parent = open_directory_at(&parent, name.as_os_str()).map_err(|error: std::io::Error| { return format!("{} is not a plain directory in the snapshot ({error})", name.to_string_lossy()); })?;
}
let name: String = new_trash_name()?;
if !make_directory_at(&parent, OsStr::new(name.as_str()), 0o700).map_err(|error: std::io::Error| { return format!("the trash {name} cannot be made ({error})"); })? {
return Err(format!("the trash {name} already exists"));
}
let dir: File = open_directory_at(&parent, OsStr::new(name.as_str())).map_err(|error: std::io::Error| { return format!("the trash {name} cannot be opened ({error})"); })?;
let id: ObjectId = identity_of(&dir).map_err(|error: std::io::Error| { return format!("the trash {name} cannot be examined ({error})"); })?;
return Ok(Trash { dir, name, id, parent });
}
fn tracked_paths(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 {
if DIRECTORY_MODES.contains(&(entry.mode & TYPE_MASK)) {
directories.insert(entry.path.clone());
}
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);
}
fn tracked_below(source: &SnapshotSource, directory: &[u8]) -> Option<String> {
let entries = stored_stats(source.clean.index_bytes.as_slice(), source.clean.hash_length)?;
return entries
.into_iter()
.find(|entry| { return entry.path == directory || (entry.path.starts_with(directory) && entry.path.get(directory.len()) == Some(&b'/')); })
.map(|entry| { return String::from_utf8_lossy(entry.path.as_slice()).into_owned(); });
}
pub fn prune_snapshot(stage: &Path, source: &SnapshotSource) -> Result<Pruned, String> {
if source.git_dir != source.common_dir {
return Ok(Pruned { count: 0, trash: None });
}
let prune: Prune = Prune::main_checkout();
let tracked: HashSet<Vec<u8>> = tracked_paths(source)?;
let mut nested_copies: Vec<PathBuf> = Vec::new();
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;
}
if !std::fs::symlink_metadata(stage.join(relative)).is_ok_and(|metadata: std::fs::Metadata| { return metadata.is_dir(); }) {
continue;
}
if tracked.contains(relative.as_os_str().as_bytes()) {
let inside: String = tracked_below(source, relative.as_os_str().as_bytes()).unwrap_or_else(|| { return relative.display().to_string(); });
return Err(format!("{} is a registered worktree, but the main checkout tracks {inside} in it, so the snapshot cannot leave it out", relative.display()));
}
nested_copies.push(relative.to_path_buf());
}
let root: File = open_root(stage).map_err(|error: std::io::Error| { return format!("the snapshot {} cannot be opened ({error})", stage.display()); })?;
let trash: Option<Trash> = if source.trash_ignored {
match make_trash(&root) {
Ok(made) => Some(made),
Err(reason) => {
debug(TAG, format!("{reason}; the left-out directories are removed in place instead").as_str());
None
}
}
} else {
None
};
let mut aside: Aside = Aside { root, stage: stage.to_path_buf(), trash, entries: Vec::new(), count: 0 };
for relative in &nested_copies {
unlink_pointer(stage.join(relative).as_path())?;
aside.put(relative.as_path())?;
}
let mut pending: Vec<(PathBuf, Vec<u8>)> = vec![(stage.to_path_buf(), Vec::new())];
while let Some((directory, relative)) = pending.pop() {
let mut children: Vec<(OsString, 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(), is_directory));
}
let names: Vec<String> = children.iter().map(|(name, _is_directory): &(OsString, bool)| { return name.to_string_lossy().into_owned(); }).collect();
if !relative.is_empty() && prune.skips_checkout(names.as_slice()) && !tracked.contains(relative.as_slice()) {
unlink_pointer(directory.as_path())?;
aside.put(Path::new(OsStr::from_bytes(relative.as_slice())))?;
continue;
}
for (name, is_directory) in children {
if !is_directory {
continue;
}
let child_relative: Vec<u8> = if relative.is_empty() { name.as_bytes().to_vec() } else { [relative.as_slice(), b"/", name.as_bytes()].concat() };
if child_relative == b".git" || child_relative == TRASH_PARENT.as_bytes() {
continue;
}
if !tracked.contains(child_relative.as_slice()) && prune.skips_path(String::from_utf8_lossy(child_relative.as_slice()).as_ref()) {
aside.put(Path::new(OsStr::from_bytes(child_relative.as_slice())))?;
} else {
pending.push((directory.join(&name), child_relative));
}
}
}
let count: usize = aside.count;
let manifest: Option<TrashManifest> = match aside.trash {
Some(made) if count > 0 => Some(TrashManifest { name: made.name, trash: made.id, entries: aside.entries }),
Some(empty) => {
if let Err(error) = crate::fs_beneath::remove_at(&empty.parent, OsStr::new(empty.name.as_str()), true) {
debug(TAG, format!("the empty trash {} could not be removed ({error})", empty.name).as_str());
}
None
}
None => None,
};
debug(TAG, format!("{count} directories set aside in {} ({})", stage.display(), if manifest.is_some() { "into the trash" } else { "removed in place" }).as_str());
return Ok(Pruned { count, trash: manifest });
}
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())),
}
}
#[cfg(test)]
#[path = "worktree_trash_tests.rs"]
mod tests;