Skip to main content

git_policy_cli/worktree/
worktree_trash.rs

1//! What:
2//!  The leave-out step of a snapshot of the main checkout: set aside the nested worktrees that are ordinary directories,
3//!  the other checkouts, the caches and the run records that the main-checkout rule leaves out, into a trash directory inside
4//!  the new subvolume, and write down what was made and moved; and, once the worktree is in place, hand the trash to the
5//!  commit service as a work item that empties it (`worktree_trash_dispose.rs`).
6//! Why:
7//!  A snapshot carries everything, so the leave-out is a removal. Unlinking about 495 thousand entries takes 14 s, which the
8//!  person should not wait for; renaming 480 directories into one trash directory takes 1.6 s, 1.2 s of which is the walk.
9//!  The rename keeps the new worktree final at once. The `.git` pointer of a nested worktree that was copied names another
10//!  worktree's administrative directory, so it is unlinked before anything else and before the add returns. Nothing the
11//!  main checkout tracks is ever moved, and every move and removal names one plain entry below a held directory handle.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const { count, trash } = await pruneSnapshot({ stage, source }); // before the swap
16//! await disposePendingTrash({ context, destination, gitDir, trash, notices }); // after it
17//! ```
18
19use 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
36/// Tag used for diagnostics from this module.
37const TAG: &str = "worktreeTrash";
38
39/// The directory, relative to a worktree root, inside which each snapshot's trash directory is made.
40/// It sits below `.claude/`, which the repository ignores, so a trash that has not been emptied yet is no untracked file.
41pub const TRASH_PARENT: &str = ".claude/cli-git-trash";
42
43/// The line a snapshot of the main checkout adds to what Git prints, unless `--quiet` was given.
44/// Why: a snapshot carries the whole ignored state itself, so no copy summary appears; this says what happened instead.
45pub 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
47/// The mode bits that say an index entry is a directory (a tree) or a gitlink.
48const DIRECTORY_MODES: [u32; 2] = [0o040_000, 0o160_000];
49
50/// The mode bits that carry the type of an index entry.
51const TYPE_MASK: u32 = 0o170_000;
52
53/// What:
54///  Whether Git ignores the place the trash would be made at in this worktree.
55/// Why:
56///   A trash directory Git does not ignore would list as untracked until emptied, so the leave-out removes in the foreground
57///  instead when the answer is no or unknown.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// await runGit(['check-ignore', '--no-index', '--', `${TRASH_PARENT}/probe/x`]).exitCode === 0
62/// ```
63pub 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
73/// A name for one trash directory: forty hexadecimal digits, which is also the object name the work item carries.
74fn 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/// What the leave-out made and moved, and how many directories it set aside.
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub struct Pruned {
84    /// How many directories were set aside, moved into the trash or removed in place.
85    pub count: usize,
86    /// The trash directory with the identity of every directory moved into it; absent when nothing was moved into a trash.
87    pub trash: Option<TrashManifest>,
88}
89
90/// The trash directory the leave-out made and holds open.
91struct Trash {
92    /// The trash directory, held open so every move names an entry of this object.
93    dir: File,
94    /// Its name below the trash parent.
95    name: String,
96    /// Its identity, taken when it was made.
97    id: ObjectId,
98    /// The directory that holds it, for removing it again when nothing is moved into it.
99    parent: File,
100}
101
102/// Where left-out directories go: into one trash directory when there is one, else they are removed in place.
103struct Aside {
104    /// The snapshot, held open; every directory moved or removed is reached from it by plain names.
105    root: File,
106    /// The path of the snapshot, for messages.
107    stage: PathBuf,
108    /// The trash directory, absent when the leave-out removes in the foreground.
109    trash: Option<Trash>,
110    /// The identity of each directory moved into the trash, in the order moved.
111    entries: Vec<ObjectId>,
112    /// How many directories were set aside.
113    count: usize,
114}
115
116impl Aside {
117    /// The held directory that contains `relative`, and the entry's name in it.
118    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    /// Set one directory aside by renaming it into the trash, or remove it when there is no trash; `relative` is its path
126    ///  below the snapshot.
127    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
150/// Make the trash directory below the trash parent in the snapshot, one plain name at a time; the reason is the error.
151fn 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
167/// What:
168///  The paths of the snapshot that are never left out, as repository paths: every directory above a tracked file, and every
169///  tracked entry that is itself a directory (a gitlink).
170/// Why:
171///   Whatever the name of such a path, it holds or is tracked content. A gitlink is a directory in the snapshot with no
172///  tracked file below it, so only its own path protects it.
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// const keep = trackedPaths(source); // has 'src', 'src/.cache' and the gitlink 'vendor/lib'
177/// ```
178fn 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
196/// A tracked path at or below `directory`, to name in a refusal.
197fn 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
205/// What:
206///  Set aside, inside a fresh snapshot of the main checkout, everything the main-checkout rule leaves out; return how many
207///  directories were set aside and what was made and moved.
208/// Why:
209///   The order matters: what the index tracks is worked out first, and a registered nested worktree that holds or is tracked
210///  content refuses the whole leave-out before anything is touched; then the `.git` pointer of every copied nested worktree
211///  and of every other checkout goes, because a Git command run inside such a copy would read and write another worktree's
212///  administrative directory; then the directories move into the trash. A directory above a tracked file, or a tracked
213///  gitlink, is never moved, whatever its name. Names are kept as bytes, so a name that is not text is never taken for another.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// const { count, trash } = await pruneSnapshot({ stage, source }); // throws the reason; the caller destroys the stage
218/// ```
219pub 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        // The rules compare names with ASCII names only, so the text of a name that is not UTF-8 (with the replacement
266        //  character in it) can never equal one; the bytes alone decide every move and every protection.
267        let names: Vec<String> = children.iter().map(|(name, _is_directory): &(OsString, bool)| { return name.to_string_lossy().into_owned(); }).collect();
268        // A directory that is another checkout is set aside whole, its pointer first.
269        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            // Nothing was moved, so the trash made for it goes again; a refusal only leaves an empty directory.
294            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
305/// Unlink the `.git` file of a copied directory, when it is a file; a `.git` directory is a repository of its own and stays.
306fn 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/// Trash controls stay out of the release executable.
317#[cfg(test)]
318#[path = "worktree_trash_tests.rs"]
319mod tests;