Skip to main content

git_policy_cli/worktree/
worktree_trash_dispose.rs

1//! What:
2//!  The disposal of the trash a snapshot's leave-out made: hand it to the commit service as a work item, or empty it here when
3//!  the service cannot be reached, and the service's handler. Both delete only the directory the leave-out recorded and only
4//!  the entries it recorded, found by walking down from the worktree's handle without following a link.
5//! Why:
6//!  A name of forty hexadecimal digits proves nothing about who made a directory, and a path string is resolved again by every
7//!  call that takes it. The manifest says which objects the leave-out made and moved; the disposal opens each directory on the
8//!  way by one plain name below the handle of the one above, compares identities, and leaves anything else alone with a
9//!  reason. A record stays until the trash is gone or the service has taken it, so a failure is never a silent success.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await disposePendingTrash({ context, destination, gitDir, trash, notices }); // throws the reason the trash stays
14//! ```
15
16use crate::diagnostic_log::debug;
17use crate::fs_beneath::{device_of, handle_path, identity_of, open_directory_at, open_file_at, open_root, plain_components, remove_at};
18use crate::iso_time::now_iso;
19use crate::service_client::{Activation, ClientConfig, START_WAIT, manager_watch_for, wake};
20use crate::service_protocol::HEARTBEAT_SILENCE;
21use crate::service_messages::{ClientFailure, render};
22use crate::service_provision::{ProvisionHost, provision};
23use crate::service_route::{Route, route_from_environment};
24use crate::service_work::{Verdict, work_directory};
25use crate::transaction_git::GitContext;
26use crate::work_item::{PENDING, WorkItem, WorkKind, enqueue, state_directory};
27use crate::worktree_trash::TRASH_PARENT;
28use crate::worktree_trash_manifest::TrashManifest;
29use crate::parallel_budget::HostResources;
30use crate::worktree_trash_entries::{EntryRemoval, remove_entries};
31use crate::worktree_trash_remove::remove_tree_beneath;
32use std::fs::File;
33use std::io::Write;
34use std::path::{Path, PathBuf};
35
36/// Tag used for diagnostics from this module.
37const TAG: &str = "worktreeTrashDispose";
38
39/// The reference every trash work item carries; the service accepts only a reference under `refs/`.
40pub const TRASH_REFERENCE: &str = "refs/cli-git/trash";
41
42/// The most bytes of a worktree's `.git` file that are read.
43const POINTER_LIMIT: u64 = 64 * 1024;
44
45/// How the service's address is found for a Git directory; injected so a control never reaches the real service.
46pub type Router<'a> = &'a dyn Fn(&Path, &Path) -> Option<Route>;
47
48/// Sets up what the service needs before it can be reached, as a commit does for its first message; injected so a control
49///  never reaches the session bus.
50pub type Provisioner<'a> = &'a dyn Fn(&ProvisionHost) -> Result<(), ClientFailure>;
51
52/// How the service is reached: where its address is found, how it is set up, and how long its start is waited for.
53pub struct Reach<'a> {
54    /// Finds the service's address for a Git directory.
55    pub router: Router<'a>,
56    /// Sets up the worktree's socket activation, which a new worktree does not have yet.
57    pub provisioner: Provisioner<'a>,
58    /// The longest wait for the service to start.
59    pub start_wait: std::time::Duration,
60}
61
62/// Where the trash went.
63enum Handed {
64    /// The service was reached with a record that names the trash, and owns the emptying now.
65    Service,
66    /// The service was not reached; the trash is emptied here. `record` is the work item that was written for it, if any, and
67    ///  `why` says in words why the service was not reached.
68    Here {
69        /// The pending work item, which stays until the trash is gone.
70        record: Option<PathBuf>,
71        /// Why the service was not reached.
72        why: String,
73    },
74}
75
76/// What:
77///  Dispose of the trash a snapshot made in the new worktree `destination`, when it made one.
78/// Why:
79///   The wrapper never starts the service itself; writing the record and connecting to its socket does. A trash that no one
80///  empties is only disk, but the foreground fallback means the worktree never keeps one past the command that made it, and a
81///  trash that cannot be emptied is reported with its path and the reason.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// await disposePendingTrash({ context, destination, gitDir, trash, notices }); // throws the reason the trash stays
86/// ```
87pub fn dispose_pending_trash(context: &GitContext, destination: &Path, git_dir: &Path, trash: Option<&TrashManifest>, notices: &mut dyn Write) -> Result<(), String> {
88    let Some(manifest) = trash else { return Ok(()) };
89    return dispose_pending_trash_with(context.real_git.as_path(), destination, git_dir, manifest, notices, &Reach { router: &route_from_environment, provisioner: &provision, start_wait: START_WAIT });
90}
91
92/// `dispose_pending_trash` for a known trash, with the route finder and the wait for a current service injected, so a control
93///  can use a fixture service and a short wait.
94pub fn dispose_pending_trash_with(real_git: &Path, destination: &Path, git_dir: &Path, manifest: &TrashManifest, notices: &mut dyn Write, reach: &Reach<'_>) -> Result<(), String> {
95    let (record, why): (Option<PathBuf>, String) = match hand_over(real_git, destination, git_dir, manifest, notices, reach) {
96        Handed::Service => return Ok(()),
97        Handed::Here { record, why } => (record, why),
98    };
99    debug(TAG, format!("the trash {} could not be handed to the service ({why}); emptying it here", manifest.name).as_str());
100    // The person waits for this, so they are told what is being done and why, once.
101    let _told = writeln!(notices, "cli-git: emptying the directories left out of the new worktree {} now, because the commit service could not take them over ({why}); this can take a while.", destination.display());
102    if let Err(reason) = delete_trash(destination, git_dir, manifest) {
103        let stays: String = match record {
104            Some(path) => format!("the work item {} stays, so the commit service empties it when it next runs for this worktree", path.display()),
105            None => String::from("no work item could be written for it, so it stays until it is removed by hand"),
106        };
107        return Err(format!("the directories left out of the new worktree {} could not be emptied ({reason}); {stays}", destination.display()));
108    }
109    // The trash is gone, so the record must not outlive it as work for a service to find.
110    if let Some(path) = record
111        && let Err(error) = std::fs::remove_file(&path)
112        && error.kind() != std::io::ErrorKind::NotFound
113    {
114        debug(TAG, format!("the work item {} could not be withdrawn ({error}); the service finds the trash gone", path.display()).as_str());
115    }
116    return Ok(());
117}
118
119/// Write the work item, set up the worktree's socket activation, and connect to the service.
120///
121/// Why: a new worktree has a commit service instance of its own whose socket does not exist until something sets it up; a
122/// commit does that before its first message, so the hand-over does it too, instead of waiting out the start for a socket
123/// that nothing will ever create.
124fn hand_over(real_git: &Path, destination: &Path, git_dir: &Path, manifest: &TrashManifest, notices: &mut dyn Write, reach: &Reach<'_>) -> Handed {
125    // A record spells a path as text. A path that is not text cannot be spelled, and a lossy spelling would name another place.
126    let (Some(repository), Some(git_dir_text)) = (destination.to_str(), git_dir.to_str()) else {
127        debug(TAG, format!("{} or its administrative directory is not valid UTF-8, so no work item can name it", destination.display()).as_str());
128        return Handed::Here { record: None, why: String::from("the path of the worktree is not valid UTF-8, so a work item cannot name it") };
129    };
130    let item: WorkItem = WorkItem {
131        kind: WorkKind::Trash,
132        repository: repository.to_string(),
133        git_dir: git_dir_text.to_string(),
134        branch_ref: String::from(TRASH_REFERENCE),
135        oid: manifest.name.clone(),
136        parent: manifest.encode(),
137        created: now_iso(),
138        tries: 0,
139    };
140    if let Err(reason) = enqueue(work_directory(git_dir).as_path(), &item) {
141        debug(TAG, format!("the work item could not be written: {reason}").as_str());
142        return Handed::Here { record: None, why: format!("the work item could not be written: {reason}") };
143    }
144    let record: PathBuf = state_directory(work_directory(git_dir).as_path(), PENDING).join(item.id());
145    let Some(Route { address, provision: host }) = (reach.router)(real_git, git_dir) else {
146        return Handed::Here { record: Some(record), why: String::from("no commit service can be started from this session") };
147    };
148    if let Some(setup) = host.as_ref()
149        && let Err(failure) = (reach.provisioner)(setup)
150    {
151        return Handed::Here { record: Some(record), why: render(&failure) };
152    }
153    let config: ClientConfig = ClientConfig {
154        address,
155        activation: Activation::Managed,
156        start_wait: reach.start_wait,
157        heartbeat_silence: HEARTBEAT_SILENCE,
158        start_watch: host.as_ref().map(manager_watch_for),
159        keep_fresh: None, answer_ceiling: crate::wait_ceilings::ANSWER_CEILING, withdraw_ceiling: crate::wait_ceilings::WITHDRAW_CEILING, start_ceiling: crate::wait_ceilings::START_CEILING,
160    };
161    return match wake(&config, git_dir, None, notices) {
162        Ok(()) => Handed::Service,
163        Err(failure) => Handed::Here { record: Some(record), why: render(&failure) },
164    };
165}
166
167/// Whether an error says a directory is not empty.
168fn is_not_empty(error: &std::io::Error) -> bool {
169    return matches!(error.raw_os_error(), Some(libc::ENOTEMPTY | libc::EEXIST));
170}
171
172/// Check that the worktree at `repository` is the one whose administrative directory is `git_dir`: its `.git` file, read
173///  below the held root, points at that directory.
174fn verify_association(root: &File, repository: &Path, git_dir: &Path) -> Result<(), String> {
175    let pointer: File = open_file_at(root, std::ffi::OsStr::new(".git")).map_err(|error: std::io::Error| { return format!("{} has no readable .git file ({error}), so it is no linked worktree and its trash is left alone", repository.display()); })?;
176    if !pointer.metadata().is_ok_and(|found: std::fs::Metadata| { return found.is_file(); }) {
177        return Err(format!("{}/.git is not a file, so its trash is left alone", repository.display()));
178    }
179    let mut text: String = String::new();
180    std::io::Read::read_to_string(&mut std::io::Read::take(&pointer, POINTER_LIMIT), &mut text).map_err(|error: std::io::Error| { return format!("{}/.git cannot be read ({error})", repository.display()); })?;
181    let Some(recorded) = text.strip_prefix("gitdir:").map(|rest: &str| { return rest.trim(); }) else {
182        return Err(format!("{}/.git does not point at an administrative directory, so its trash is left alone", repository.display()));
183    };
184    let pointed: PathBuf = if Path::new(recorded).is_absolute() { PathBuf::from(recorded) } else { repository.join(recorded) };
185    let same: bool = pointed == git_dir || std::fs::canonicalize(&pointed).ok().zip(std::fs::canonicalize(git_dir).ok()).is_some_and(|(left, right): (PathBuf, PathBuf)| { return left == right; });
186    if !same {
187        return Err(format!("{}/.git points at {} and not at {}, so its trash is left alone", repository.display(), pointed.display(), git_dir.display()));
188    }
189    return Ok(());
190}
191
192/// What:
193///  Empty the trash directory the manifest describes, in the worktree `repository` whose administrative directory is
194///  `git_dir`; a trash that is already gone is done.
195/// Why:
196///   Every directory from the worktree down is opened by one plain name below the handle of the one above, so a link at any
197///  level is an error and never followed. The trash is the directory with the recorded identity, each entry in it is removed
198///  only when it is the object that was moved there, and anything else in the trash stays with a reason.
199///
200/// In TS you'd write (pseudocode):
201/// ```ts
202/// await deleteTrash({ repository, gitDir, manifest }); // throws the reason something stays
203/// ```
204pub fn delete_trash(repository: &Path, git_dir: &Path, manifest: &TrashManifest) -> Result<(), String> {
205    let root: File = open_root(repository).map_err(|error: std::io::Error| { return format!("{} cannot be opened as the worktree ({error})", repository.display()); })?;
206    verify_association(&root, repository, git_dir)?;
207    let device: u64 = device_of(&root).map_err(|error: std::io::Error| { return format!("{} cannot be examined ({error})", repository.display()); })?;
208    let names = plain_components(Path::new(TRASH_PARENT)).map_err(|error: std::io::Error| { return format!("the trash parent is not a plain path ({error})"); })?;
209    let mut shown: PathBuf = repository.to_path_buf();
210    let mut holders: Vec<File> = vec![root];
211    for name in &names {
212        shown.push(name);
213        let Some(held) = holders.last() else { return Err(format!("{} cannot be held", shown.display())) };
214        let next: File = match open_directory_at(held, name.as_os_str()) {
215            Ok(opened) => opened,
216            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
217            Err(error) => return Err(format!("{} is not a plain directory ({error}), so the trash below it is left alone", shown.display())),
218        };
219        if device_of(&next).ok() != Some(device) {
220            return Err(format!("{} is on another device, so the trash below it is left alone", shown.display()));
221        }
222        holders.push(next);
223    }
224    let Some(parent) = holders.last() else { return Ok(()) };
225    let trash: File = match open_directory_at(parent, std::ffi::OsStr::new(manifest.name.as_str())) {
226        Ok(opened) => opened,
227        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
228        Err(error) => return Err(format!("{} is not a plain directory ({error}), so it is left alone", shown.join(&manifest.name).display())),
229    };
230    let found = identity_of(&trash).map_err(|error: std::io::Error| { return format!("{} cannot be examined ({error})", shown.join(&manifest.name).display()); })?;
231    if found != manifest.trash {
232        return Err(format!("{} is not the directory the snapshot made ({} instead of {}), so it is left alone", shown.join(&manifest.name).display(), found.encode(), manifest.trash.encode()));
233    }
234    let inside: PathBuf = shown.join(&manifest.name);
235    let left: Vec<String> = remove_entries(&EntryRemoval { inside: inside.as_path(), trash: &trash, manifest, host: &HostResources::read(), remove: &remove_tree_beneath });
236    if !left.is_empty() {
237        return Err(left.join("; "));
238    }
239    let strangers: Vec<String> = std::fs::read_dir(handle_path(&trash))
240        .map_err(|error: std::io::Error| { return format!("{} cannot be listed ({error})", inside.display()); })?
241        .flatten()
242        .map(|entry: std::fs::DirEntry| { return entry.file_name().to_string_lossy().into_owned(); })
243        .collect();
244    if !strangers.is_empty() {
245        return Err(format!("{} also holds {}, which the snapshot did not put there, so the trash directory is left alone", inside.display(), strangers.join(", ")));
246    }
247    drop(trash);
248    remove_at(parent, std::ffi::OsStr::new(manifest.name.as_str()), true).map_err(|error: std::io::Error| { return format!("{} cannot be removed ({error})", inside.display()); })?;
249    // The parent goes when nothing else is in it; a refusal only means another trash directory is still there.
250    if let [.., above, _] = holders.as_slice()
251        && let Some(last) = names.last()
252        && let Err(error) = remove_at(above, last.as_os_str(), true)
253        && !is_not_empty(&error)
254    {
255        debug(TAG, format!("{} cannot be removed ({error}); it stays", shown.display()).as_str());
256    }
257    return Ok(());
258}
259
260/// What:
261///  The service's handler: empty the trash directory a work item names, after checking that it is the one the snapshot made.
262/// Why:
263///   The record is a file in the Git directory; the handler trusts nothing in it beyond what it can verify: the worktree's
264///  `.git` file points at the record's Git directory, every directory down to the trash is a real one, the trash has the
265///  recorded identity, and each entry removed is the recorded object.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// emptyTrash(item) // 'done' | { failed: reason }
270/// ```
271pub fn empty_trash(item: &WorkItem) -> Verdict {
272    let manifest: TrashManifest = match TrashManifest::decode(item.oid.as_str(), item.parent.as_str()) {
273        Ok(found) => found,
274        Err(reason) => return Verdict::Failed(reason),
275    };
276    return match delete_trash(Path::new(item.repository.as_str()), Path::new(item.git_dir.as_str()), &manifest) {
277        Ok(()) => Verdict::Done,
278        Err(reason) => Verdict::Failed(reason),
279    };
280}