1use crate::diagnostic_log::debug;
20use crate::fs_beneath::{ObjectId, identity_at, identity_of, make_directory_at, open_directory_at, open_directory_beneath, open_root, plain_components, rename_at};
21use crate::fs_kind::is_subvolume_root;
22use crate::random_id::random_uuid;
23use crate::transaction_git::{GitContext, GitRequest, run_git};
24use crate::worktree_clean::stored_stats;
25use crate::worktree_copy_prune::Prune;
26use crate::worktree_snapshot_source::SnapshotSource;
27pub use crate::worktree_trash_dispose::{Reach, Router, TRASH_REFERENCE, delete_trash, dispose_pending_trash, dispose_pending_trash_with, empty_trash};
28pub use crate::worktree_trash_manifest::{TrashManifest, entry_name, is_trash_name};
29use crate::worktree_trash_remove::remove_tree_beneath;
30use std::collections::HashSet;
31use std::ffi::{OsStr, OsString};
32use std::fs::File;
33use std::os::unix::ffi::OsStrExt;
34use std::path::{Path, PathBuf};
35
36const TAG: &str = "worktreeTrash";
38
39pub const TRASH_PARENT: &str = ".claude/cli-git-trash";
42
43pub 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";
46
47const DIRECTORY_MODES: [u32; 2] = [0o040_000, 0o160_000];
49
50const TYPE_MASK: u32 = 0o170_000;
52
53pub fn trash_location_is_ignored(context: &GitContext, root: &Path) -> bool {
64 let request: GitRequest = GitRequest {
65 cwd: root.to_path_buf(),
66 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(),
67 without_prefix: true,
68 ..GitRequest::default()
69 };
70 return run_git(context, &request).is_ok_and(|output| { return output.code == Some(0); });
71}
72
73fn new_trash_name() -> Result<String, String> {
75 let first: String = random_uuid().map_err(|error| { return format!("a name for the trash could not be made: {error:?}"); })?;
76 let second: String = random_uuid().map_err(|error| { return format!("a name for the trash could not be made: {error:?}"); })?;
77 let digits: String = format!("{first}{second}").chars().filter(char::is_ascii_hexdigit).collect();
78 return Ok(digits.chars().take(40).collect::<String>().to_ascii_lowercase());
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
83pub struct Pruned {
84 pub count: usize,
86 pub trash: Option<TrashManifest>,
88}
89
90struct Trash {
92 dir: File,
94 name: String,
96 id: ObjectId,
98 parent: File,
100}
101
102struct Aside {
104 root: File,
106 stage: PathBuf,
108 trash: Option<Trash>,
110 entries: Vec<ObjectId>,
112 count: usize,
114}
115
116impl Aside {
117 fn holder(&self, relative: &Path) -> Result<(File, OsString), String> {
119 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()); })?;
120 let Some((name, parents)) = names.split_last() else { return Err(format!("{} names the snapshot itself", relative.display())) };
121 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()); })?;
122 return Ok((parent, name.clone()));
123 }
124
125 fn put(&mut self, relative: &Path) -> Result<(), String> {
128 let (parent, name): (File, OsString) = self.holder(relative)?;
129 let shown: PathBuf = self.stage.join(relative);
130 match self.trash.as_ref() {
131 Some(trash) => {
132 let looked: ObjectId = identity_at(&parent, name.as_os_str()).map_err(|error: std::io::Error| { return format!("{} cannot be examined ({error})", shown.display()); })?;
133 let to: String = entry_name(self.entries.len());
134 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()); })?;
135 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()); })?;
136 if moved != looked {
137 return Err(format!("{} changed while it was set aside ({} instead of {})", shown.display(), moved.encode(), looked.encode()));
138 }
139 self.entries.push(moved);
140 }
141 None => {
142 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()); })?;
143 }
144 }
145 self.count += 1;
146 return Ok(());
147 }
148}
149
150fn make_trash(root: &File) -> Result<Trash, String> {
152 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})"); })?;
153 let mut parent: File = root.try_clone().map_err(|error: std::io::Error| { return format!("the snapshot cannot be held ({error})"); })?;
154 for name in &names {
155 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()); })?;
156 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()); })?;
157 }
158 let name: String = new_trash_name()?;
159 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})"); })? {
160 return Err(format!("the trash {name} already exists"));
161 }
162 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})"); })?;
163 let id: ObjectId = identity_of(&dir).map_err(|error: std::io::Error| { return format!("the trash {name} cannot be examined ({error})"); })?;
164 return Ok(Trash { dir, name, id, parent });
165}
166
167fn tracked_paths(source: &SnapshotSource) -> Result<HashSet<Vec<u8>>, String> {
179 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"); })?;
180 let mut directories: HashSet<Vec<u8>> = HashSet::new();
181 for entry in entries {
182 if DIRECTORY_MODES.contains(&(entry.mode & TYPE_MASK)) {
183 directories.insert(entry.path.clone());
184 }
185 let mut end: usize = entry.path.len();
186 while let Some(at) = entry.path[..end].iter().rposition(|byte: &u8| { return *byte == b'/'; }) {
187 if !directories.insert(entry.path[..at].to_vec()) {
188 break;
189 }
190 end = at;
191 }
192 }
193 return Ok(directories);
194}
195
196fn tracked_below(source: &SnapshotSource, directory: &[u8]) -> Option<String> {
198 let entries = stored_stats(source.clean.index_bytes.as_slice(), source.clean.hash_length)?;
199 return entries
200 .into_iter()
201 .find(|entry| { return entry.path == directory || (entry.path.starts_with(directory) && entry.path.get(directory.len()) == Some(&b'/')); })
202 .map(|entry| { return String::from_utf8_lossy(entry.path.as_slice()).into_owned(); });
203}
204
205pub fn prune_snapshot(stage: &Path, source: &SnapshotSource) -> Result<Pruned, String> {
220 if source.git_dir != source.common_dir {
221 return Ok(Pruned { count: 0, trash: None });
222 }
223 let prune: Prune = Prune::main_checkout();
224 let tracked: HashSet<Vec<u8>> = tracked_paths(source)?;
225 let mut nested_copies: Vec<PathBuf> = Vec::new();
226 for nested in &source.nested {
227 let Ok(relative) = nested.strip_prefix(source.root.as_path()) else { continue };
228 if is_subvolume_root(nested.as_path()).unwrap_or(false) {
229 continue;
230 }
231 if !std::fs::symlink_metadata(stage.join(relative)).is_ok_and(|metadata: std::fs::Metadata| { return metadata.is_dir(); }) {
232 continue;
233 }
234 if tracked.contains(relative.as_os_str().as_bytes()) {
235 let inside: String = tracked_below(source, relative.as_os_str().as_bytes()).unwrap_or_else(|| { return relative.display().to_string(); });
236 return Err(format!("{} is a registered worktree, but the main checkout tracks {inside} in it, so the snapshot cannot leave it out", relative.display()));
237 }
238 nested_copies.push(relative.to_path_buf());
239 }
240 let root: File = open_root(stage).map_err(|error: std::io::Error| { return format!("the snapshot {} cannot be opened ({error})", stage.display()); })?;
241 let trash: Option<Trash> = if source.trash_ignored {
242 match make_trash(&root) {
243 Ok(made) => Some(made),
244 Err(reason) => {
245 debug(TAG, format!("{reason}; the left-out directories are removed in place instead").as_str());
246 None
247 }
248 }
249 } else {
250 None
251 };
252 let mut aside: Aside = Aside { root, stage: stage.to_path_buf(), trash, entries: Vec::new(), count: 0 };
253 for relative in &nested_copies {
254 unlink_pointer(stage.join(relative).as_path())?;
255 aside.put(relative.as_path())?;
256 }
257 let mut pending: Vec<(PathBuf, Vec<u8>)> = vec![(stage.to_path_buf(), Vec::new())];
258 while let Some((directory, relative)) = pending.pop() {
259 let mut children: Vec<(OsString, bool)> = Vec::new();
260 for listed in std::fs::read_dir(&directory).map_err(|error: std::io::Error| { return format!("{} cannot be listed ({error})", directory.display()); })? {
261 let entry: std::fs::DirEntry = listed.map_err(|error: std::io::Error| { return format!("{} cannot be listed ({error})", directory.display()); })?;
262 let is_directory: bool = entry.file_type().is_ok_and(|kind: std::fs::FileType| { return kind.is_dir(); });
263 children.push((entry.file_name(), is_directory));
264 }
265 let names: Vec<String> = children.iter().map(|(name, _is_directory): &(OsString, bool)| { return name.to_string_lossy().into_owned(); }).collect();
268 if !relative.is_empty() && prune.skips_checkout(names.as_slice()) && !tracked.contains(relative.as_slice()) {
270 unlink_pointer(directory.as_path())?;
271 aside.put(Path::new(OsStr::from_bytes(relative.as_slice())))?;
272 continue;
273 }
274 for (name, is_directory) in children {
275 if !is_directory {
276 continue;
277 }
278 let child_relative: Vec<u8> = if relative.is_empty() { name.as_bytes().to_vec() } else { [relative.as_slice(), b"/", name.as_bytes()].concat() };
279 if child_relative == b".git" || child_relative == TRASH_PARENT.as_bytes() {
280 continue;
281 }
282 if !tracked.contains(child_relative.as_slice()) && prune.skips_path(String::from_utf8_lossy(child_relative.as_slice()).as_ref()) {
283 aside.put(Path::new(OsStr::from_bytes(child_relative.as_slice())))?;
284 } else {
285 pending.push((directory.join(&name), child_relative));
286 }
287 }
288 }
289 let count: usize = aside.count;
290 let manifest: Option<TrashManifest> = match aside.trash {
291 Some(made) if count > 0 => Some(TrashManifest { name: made.name, trash: made.id, entries: aside.entries }),
292 Some(empty) => {
293 if let Err(error) = crate::fs_beneath::remove_at(&empty.parent, OsStr::new(empty.name.as_str()), true) {
295 debug(TAG, format!("the empty trash {} could not be removed ({error})", empty.name).as_str());
296 }
297 None
298 }
299 None => None,
300 };
301 debug(TAG, format!("{count} directories set aside in {} ({})", stage.display(), if manifest.is_some() { "into the trash" } else { "removed in place" }).as_str());
302 return Ok(Pruned { count, trash: manifest });
303}
304
305fn unlink_pointer(directory: &Path) -> Result<(), String> {
307 let pointer: PathBuf = directory.join(".git");
308 match std::fs::symlink_metadata(&pointer) {
309 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()); }),
310 Ok(_) => return Ok(()),
311 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
312 Err(error) => return Err(format!("{} cannot be examined ({error})", pointer.display())),
313 }
314}
315
316#[cfg(test)]
318#[path = "worktree_trash_tests.rs"]
319mod tests;