monochromatic-git-policy-cli 0.5.33

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  The leave-out step of a snapshot of the main checkout: set aside the nested worktrees that are ordinary directories,
//!  the other checkouts, the caches and the run records that the main-checkout rule leaves out, into a trash directory inside
//!  the new subvolume, and write down what was made and moved; and, once the worktree is in place, hand the trash to the
//!  commit service as a work item that empties it (`worktree_trash_dispose.rs`).
//! Why:
//!  A snapshot carries everything, so the leave-out is a removal. Unlinking about 495 thousand entries takes 14 s, which the
//!  person should not wait for; renaming 480 directories into one trash directory takes 1.6 s, 1.2 s of which is the walk.
//!  The rename keeps the new worktree final at once. The `.git` pointer of a nested worktree that was copied names another
//!  worktree's administrative directory, so it is unlinked before anything else and before the add returns. Nothing the
//!  main checkout tracks is ever moved, and every move and removal names one plain entry below a held directory handle.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const { count, trash } = await pruneSnapshot({ stage, source }); // before the swap
//! await disposePendingTrash({ context, destination, gitDir, trash, notices }); // after it
//! ```

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};

/// Tag used for diagnostics from this module.
const TAG: &str = "worktreeTrash";

/// The directory, relative to a worktree root, inside which each snapshot's trash directory is made.
/// It sits below `.claude/`, which the repository ignores, so a trash that has not been emptied yet is no untracked file.
pub const TRASH_PARENT: &str = ".claude/cli-git-trash";

/// The line a snapshot of the main checkout adds to what Git prints, unless `--quiet` was given.
/// Why: a snapshot carries the whole ignored state itself, so no copy summary appears; this says what happened instead.
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";

/// The mode bits that say an index entry is a directory (a tree) or a gitlink.
const DIRECTORY_MODES: [u32; 2] = [0o040_000, 0o160_000];

/// The mode bits that carry the type of an index entry.
const TYPE_MASK: u32 = 0o170_000;

/// What:
///  Whether Git ignores the place the trash would be made at in this worktree.
/// Why:
///   A trash directory Git does not ignore would list as untracked until emptied, so the leave-out removes in the foreground
///  instead when the answer is no or unknown.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runGit(['check-ignore', '--no-index', '--', `${TRASH_PARENT}/probe/x`]).exitCode === 0
/// ```
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); });
}

/// A name for one trash directory: forty hexadecimal digits, which is also the object name the work item carries.
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());
}

/// What the leave-out made and moved, and how many directories it set aside.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Pruned {
    /// How many directories were set aside, moved into the trash or removed in place.
    pub count: usize,
    /// The trash directory with the identity of every directory moved into it; absent when nothing was moved into a trash.
    pub trash: Option<TrashManifest>,
}

/// The trash directory the leave-out made and holds open.
struct Trash {
    /// The trash directory, held open so every move names an entry of this object.
    dir: File,
    /// Its name below the trash parent.
    name: String,
    /// Its identity, taken when it was made.
    id: ObjectId,
    /// The directory that holds it, for removing it again when nothing is moved into it.
    parent: File,
}

/// Where left-out directories go: into one trash directory when there is one, else they are removed in place.
struct Aside {
    /// The snapshot, held open; every directory moved or removed is reached from it by plain names.
    root: File,
    /// The path of the snapshot, for messages.
    stage: PathBuf,
    /// The trash directory, absent when the leave-out removes in the foreground.
    trash: Option<Trash>,
    /// The identity of each directory moved into the trash, in the order moved.
    entries: Vec<ObjectId>,
    /// How many directories were set aside.
    count: usize,
}

impl Aside {
    /// The held directory that contains `relative`, and the entry's name in it.
    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()));
    }

    /// Set one directory aside by renaming it into the trash, or remove it when there is no trash; `relative` is its path
    ///  below the snapshot.
    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(());
    }
}

/// Make the trash directory below the trash parent in the snapshot, one plain name at a time; the reason is the error.
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 });
}

/// What:
///  The paths of the snapshot that are never left out, as repository paths: every directory above a tracked file, and every
///  tracked entry that is itself a directory (a gitlink).
/// Why:
///   Whatever the name of such a path, it holds or is tracked content. A gitlink is a directory in the snapshot with no
///  tracked file below it, so only its own path protects it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const keep = trackedPaths(source); // has 'src', 'src/.cache' and the gitlink 'vendor/lib'
/// ```
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);
}

/// A tracked path at or below `directory`, to name in a refusal.
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(); });
}

/// What:
///  Set aside, inside a fresh snapshot of the main checkout, everything the main-checkout rule leaves out; return how many
///  directories were set aside and what was made and moved.
/// Why:
///   The order matters: what the index tracks is worked out first, and a registered nested worktree that holds or is tracked
///  content refuses the whole leave-out before anything is touched; then the `.git` pointer of every copied nested worktree
///  and of every other checkout goes, because a Git command run inside such a copy would read and write another worktree's
///  administrative directory; then the directories move into the trash. A directory above a tracked file, or a tracked
///  gitlink, is never moved, whatever its name. Names are kept as bytes, so a name that is not text is never taken for another.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const { count, trash } = await pruneSnapshot({ stage, source }); // throws the reason; the caller destroys the stage
/// ```
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));
        }
        // The rules compare names with ASCII names only, so the text of a name that is not UTF-8 (with the replacement
        //  character in it) can never equal one; the bytes alone decide every move and every protection.
        let names: Vec<String> = children.iter().map(|(name, _is_directory): &(OsString, bool)| { return name.to_string_lossy().into_owned(); }).collect();
        // A directory that is another checkout is set aside whole, its pointer first.
        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) => {
            // Nothing was moved, so the trash made for it goes again; a refusal only leaves an empty directory.
            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 });
}

/// Unlink the `.git` file of a copied directory, when it is a file; a `.git` directory is a repository of its own and stays.
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())),
    }
}

/// Trash controls stay out of the release executable.
#[cfg(test)]
#[path = "worktree_trash_tests.rs"]
mod tests;