Skip to main content

git_policy_cli/worktree/
worktree_snapshot.rs

1//! What:
2//!  The mechanics of rung 1 of the cheap-worktree ladder: a snapshot of the source subvolume taken at a stage name beside
3//!  the destination, made into a worktree there (its own `.git` pointer, no stubs of nested worktrees, the source's index
4//!  with refreshed stat fields), and swapped with the destination in one atomic step.
5//! Why:
6//!  The snapshot shares every data block with the source and carries its ignored state at no cost
7//!  (`doc/planning/cli-git-btrfs-worktree.md`). It is made at a name Git never hands out, so the destination is
8//!  complete or untouched at every instant: `RENAME_EXCHANGE` puts the finished subvolume at the destination and the
9//!  empty directory real Git made at the stage name, which is then removed. A source that changed while the snapshot was
10//!  taken is caught by comparing the snapshot's own files with the index entries copied from the source.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! await takeSnapshot({ source, plan, stage, admin }); // throws a reason; the caller destroys the stage
15//! await putInPlace({ stage, destination, mode });
16//! ```
17
18use crate::add_timing::step as timed;
19use crate::fs_beneath::{ObjectId, exchange_at, identity_at, open_directory_at, open_directory_beneath, open_file_at, open_root, plain_components, remove_at, rename_at};
20use crate::fs_btrfs_ioctl::snapshot_create_at;
21use crate::subvolume_index_files::install_index_in;
22use crate::subvolume_remove::remove_tree;
23use crate::worktree_add_plan::AddPlan;
24use crate::subvolume_index::StatFields;
25use crate::worktree_clean::{CleanRefusal, StatCursor, StatEntry, stored_stats, tree_fields_beneath};
26use crate::worktree_snapshot_source::SnapshotSource;
27use crate::worktree_trash::{Pruned, TrashManifest, prune_snapshot};
28use std::ffi::{OsStr, OsString};
29use std::fs::File;
30use std::os::unix::fs::PermissionsExt;
31use std::path::Path;
32
33/// What:
34///  Remove one stub from the snapshot below the held handle of its root: the directory `path` names (a path inside the
35///  source), after its `.git` pointer when `pointer` is set.
36/// Why:
37///   Every directory on the way is opened without following a link and on the snapshot's own device, so a registered
38///  path that reaches the snapshot through a link (a link left where a directory was moved) is an error and never an
39///  unlink in the tree the link points at. A nested subvolume's stub is removed only as an empty directory; only the
40///  destination's own directory, which Git made, holds a pointer file.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// await removeStub(root, sourceRoot, path, pointer);
45/// ```
46fn remove_stub(root: &File, source_root: &Path, path: &Path, pointer: bool) -> Result<(), String> {
47    let Ok(relative) = path.strip_prefix(source_root) else { return Ok(()) };
48    let shown: String = path.display().to_string();
49    let names: Vec<OsString> = plain_components(relative).map_err(|error| { return format!("{shown} is not a plain path inside the source ({error})"); })?;
50    let Some((name, parents)) = names.split_last() else { return Ok(()) };
51    let gone = |error: &std::io::Error| { return error.kind() == std::io::ErrorKind::NotFound; };
52    let parent: File = match open_directory_beneath(root, parents) {
53        Ok(found) => found,
54        Err(error) if gone(&error) => return Ok(()),
55        Err(error) => return Err(format!("{shown} is reached through a link or another mount in the snapshot ({error})")),
56    };
57    if pointer {
58        match open_directory_at(&parent, name.as_os_str()) {
59            Ok(stub) => match remove_at(&stub, OsStr::new(".git"), false) {
60                Ok(()) => {}
61                Err(error) if gone(&error) => {}
62                Err(error) => return Err(format!("{shown}/.git cannot be removed from the snapshot ({error})")),
63            },
64            Err(error) if gone(&error) => return Ok(()),
65            Err(error) => return Err(format!("{shown} cannot be entered in the snapshot ({error})")),
66        }
67    }
68    return match remove_at(&parent, name.as_os_str(), true) {
69        Ok(()) => Ok(()),
70        Err(error) if gone(&error) => Ok(()),
71        Err(error) => Err(format!("{shown} cannot be removed from the snapshot as an empty directory ({error})")),
72    };
73}
74
75/// What:
76///  Remove the stub that each nested worktree leaves in the snapshot, and the destination's own directory when it lies
77///  inside the source.
78/// Why:
79///   A nested worktree that is a subvolume is an empty directory in the snapshot; the destination was made by Git before
80///  the snapshot, so a source that contains it holds a directory with Git's `.git` file there. Neither belongs in the new
81///  worktree.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// await removeStubs({ stage, sourceRoot, nested, destination });
86/// ```
87fn remove_stubs(stage: &Path, source: &SnapshotSource, plan: &AddPlan) -> Result<(), String> {
88    let root: File = open_root(stage).map_err(|error| { return format!("the stage cannot be opened ({error})"); })?;
89    for nested in &source.nested {
90        remove_stub(&root, source.root.as_path(), nested.as_path(), false)?;
91    }
92    return remove_stub(&root, source.root.as_path(), plan.destination.as_path(), true);
93}
94
95/// What:
96///  Put the new worktree's `.git` pointer into the snapshot in place of the source's `.git` (a directory for a main
97///  checkout, a pointer file for a linked worktree).
98/// Why:
99///   The bytes are the ones real Git wrote for the new worktree, so the pointer is exactly Git's.
100///
101/// In TS you'd write (pseudocode):
102/// ```ts
103/// await replaceGitPointer(stage, gitFileOfNewWorktree);
104/// ```
105fn replace_git_pointer(stage: &Path, pointer: &[u8]) -> Result<(), String> {
106    let path = stage.join(".git");
107    remove_tree(path.as_path()).map_err(|error| { return format!("the source's .git cannot be removed from the snapshot ({error})"); })?;
108    return std::fs::write(path.as_path(), pointer).map_err(|error| { return format!("the .git pointer cannot be written ({error})"); });
109}
110
111/// What:
112///  Check the snapshot against the source's index, then install that index in the new worktree's administrative
113///  directory with stat fields refreshed and the accelerator extensions of the source dropped.
114/// Why:
115///   Every tracked file of the snapshot must equal its entry, which proves the snapshot and the index describe the same
116///  state whatever the source did meanwhile. The extensions (`UNTR`, `FSMN`) name the source's worktree.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// await installIndex({ source, stage, admin });
121/// ```
122fn install_index(source: &SnapshotSource, stage: &Path, admin: &File, own_index: Option<&[u8]>) -> Result<(), String> {
123    let root: File = open_root(stage).map_err(|error| { return format!("the stage cannot be opened ({error})"); })?;
124    // A clean source proved its index equal to its files, so it is compared with the snapshot. A source with changes has no such
125    //  proof: the snapshot's own index, one instant with its files, is installed as it is and the restore settles the rest.
126    let bytes: &[u8] = own_index.unwrap_or(source.clean.index_bytes.as_slice());
127    let entries: Vec<StatEntry> = if own_index.is_none() { stored_stats(bytes, source.clean.hash_length).ok_or_else(|| { return String::from("the source's index changed shape"); })? } else { Vec::new() };
128    // One walk of the snapshot proves it equal to the source's index and returns the stat fields the new index takes: every
129    //  file is looked at once, and the fields are the ones the verdict was given on.
130    let walked: Vec<StatFields> = timed("walkSnapshot", || { return tree_fields_beneath(&root, entries.as_slice(), source.clean.index_seconds); })
131        .map_err(|refusal: CleanRefusal| { return format!("the source changed while it was copied ({})", refusal.reason); })?;
132    let known: std::collections::HashMap<&[u8], StatFields> = entries.iter().map(|entry: &StatEntry| { return entry.path.as_slice(); }).zip(walked).collect();
133    // An entry the walk did not cover (a source with changes has no walk) is looked at through a cursor that keeps its directory.
134    let mut cursor: StatCursor<'_> = StatCursor::new(&root);
135    let report: String = timed("installIndexFile", || { return install_index_in(admin, bytes, &mut |path: &[u8]| { return known.get(path).copied().or_else(|| { return cursor.fields(path); }); }, source.clean.hash_length); })?;
136    crate::diagnostic_log::debug("takeSnapshot", report.as_str());
137    return Ok(());
138}
139
140/// What:
141///  The bytes of the index inside the snapshot's own `.git`, read below handles that follow no link.
142/// Why:
143///   A source with uncommitted changes has no index that was proven equal to its files; the copy's index and the copy's files
144///  are one instant, whatever the source does next.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// const bytes = await readOwnIndex(stage); // throws a reason
149/// ```
150fn read_own_index(stage: &Path) -> Result<Vec<u8>, String> {
151    let root: File = open_root(stage).map_err(|error| { return format!("the stage cannot be opened ({error})"); })?;
152    let git_dir: File = open_directory_at(&root, OsStr::new(".git")).map_err(|error| { return format!("the snapshot has no .git directory ({error})"); })?;
153    let mut index: File = open_file_at(&git_dir, OsStr::new("index")).map_err(|error| { return format!("the snapshot has no index ({error})"); })?;
154    let mut bytes: Vec<u8> = Vec::new();
155    std::io::Read::read_to_end(&mut index, &mut bytes).map_err(|error| { return format!("the snapshot's index is unreadable ({error})"); })?;
156    return Ok(bytes);
157}
158
159/// What:
160///  Take the snapshot at `stage` and make it the new worktree there; the caller destroys `stage` on an error.
161/// Why:
162///   Each step is one the later steps depend on; the first failure ends the rung with its reason.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// await takeSnapshot({ source, plan, stage, admin, pointer });
167/// ```
168pub fn take_snapshot(source: &SnapshotSource, plan: &AddPlan, stage: &Path, admin: &File, pointer: &[u8]) -> Result<Snapshotted, SnapshotFailure> {
169    timed("snapshotIoctl", || { return snapshot_create_at(source.root.as_path(), stage); }).map_err(|error| { return SnapshotFailure { reason: format!("the snapshot was refused ({error})"), stage: None }; })?;
170    // From here the stage exists and this process made it; its identity is what a later removal checks.
171    let made: Option<ObjectId> = open_root(stage.parent().unwrap_or(stage)).ok().and_then(|parent: File| { return identity_at(&parent, stage.file_name()?).ok(); });
172    let failed = |reason: String| -> SnapshotFailure { return SnapshotFailure { reason, stage: made }; };
173    std::fs::set_permissions(stage, std::fs::Permissions::from_mode(0o700)).map_err(|error| { return failed(format!("the stage cannot be made private ({error})")); })?;
174    // The snapshot's own index is read before its `.git` goes: it is the index of the same instant as the files.
175    let own_index: Option<Vec<u8>> = if source.dirty.is_some() { Some(timed("readOwnIndex", || { return read_own_index(stage); }).map_err(failed)?) } else { None };
176    let pruned: Pruned = timed("prune", || { return prune_snapshot(stage, source); }).map_err(failed)?;
177    timed("removeStubs", || { return remove_stubs(stage, source, plan); }).map_err(failed)?;
178    timed("replacePointer", || { return replace_git_pointer(stage, pointer); }).map_err(failed)?;
179    timed("installIndex", || { return install_index(source, stage, admin, own_index.as_deref()); }).map_err(failed)?;
180    let stage_id: ObjectId = made.ok_or_else(|| { return failed(String::from("the stage cannot be examined")); })?;
181    return Ok(Snapshotted { stage: stage_id, trash: pruned.trash });
182}
183
184/// What a snapshot that was taken left for the steps after it.
185#[derive(Debug, Eq, PartialEq)]
186pub struct Snapshotted {
187    /// The identity of the stage this process made.
188    pub stage: ObjectId,
189    /// The trash the leave-out made in the stage, with the identity of everything it moved there; the disposal after the swap
190    ///  empties exactly that.
191    pub trash: Option<TrashManifest>,
192}
193
194/// Why a snapshot could not be taken, and the identity of the stage it left, if it left one.
195#[derive(Debug, Eq, PartialEq)]
196pub struct SnapshotFailure {
197    /// The reason, in words.
198    pub reason: String,
199    /// The identity of the stage this process made before it failed.
200    pub stage: Option<ObjectId>,
201}
202
203/// Why a swap of the stage with the destination did not complete, and whether the stage name still holds the snapshot.
204#[derive(Debug, Eq, PartialEq)]
205pub struct PlaceError {
206    /// The reason, in words.
207    pub reason: String,
208    /// Whether the object at the stage name is the snapshot this process made. When it is not, nothing may be removed there:
209    ///  the stage name holds somebody else's directory.
210    pub stage_is_ours: bool,
211}
212
213/// The moments of a swap at which the controls move the tree.
214pub struct PlaceSeams<'a> {
215    /// Called after the destination was checked and before the exchange.
216    pub after_check: &'a mut dyn FnMut(),
217    /// Called after an exchange that found another directory at the stage name and before the swap back.
218    pub before_swap_back: &'a mut dyn FnMut(),
219}
220
221/// What:
222///  Swap the finished stage with the destination, after giving the stage the mode of the source's root.
223/// Why:
224///   `RENAME_EXCHANGE` leaves no instant at which the destination is missing or incomplete; the old destination, a
225///  directory with Git's `.git` file, ends up at the stage name for `settle_displaced`. The names are looked up in the held
226///  parent; the destination must be the directory Git made, and a directory that is not is swapped back. When the swap back
227///  fails, the error says the stage name no longer holds the snapshot, so the caller does not remove what is there.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// await putInPlace({ stage, destination, sourceMode, expected }); // expected: the identity of the directory Git made
232/// ```
233pub fn put_in_place(stage: &Path, destination: &Path, source_mode: u32, expected: ObjectId) -> Result<(), PlaceError> {
234    return put_in_place_with(stage, destination, source_mode, expected, &mut PlaceSeams { after_check: &mut || {}, before_swap_back: &mut || {} });
235}
236
237/// `put_in_place` with the moments at which a control may move the tree.
238pub fn put_in_place_with(stage: &Path, destination: &Path, source_mode: u32, expected: ObjectId, seams: &mut PlaceSeams<'_>) -> Result<(), PlaceError> {
239    let ours = |reason: String| -> PlaceError { return PlaceError { reason, stage_is_ours: true }; };
240    let (Some(parent_path), Some(stage_name), Some(destination_name)) = (stage.parent(), stage.file_name(), destination.file_name()) else {
241        return Err(ours(String::from("the stage and the destination have no parent")));
242    };
243    if destination.parent() != Some(parent_path) {
244        return Err(ours(String::from("the stage is not beside the destination")));
245    }
246    let parent: File = open_root(parent_path).map_err(|error| { return ours(format!("the parent of the destination cannot be opened ({error})")); })?;
247    let snapshot: ObjectId = identity_at(&parent, stage_name).map_err(|error| { return ours(format!("the stage cannot be examined ({error})")); })?;
248    let is_directory = |name: &OsStr| -> bool { return crate::fs_beneath::entry_metadata_at(&parent, name).is_ok_and(|found: std::fs::Metadata| { return found.is_dir(); }); };
249    if !is_directory(destination_name) || identity_at(&parent, destination_name).ok() != Some(expected) {
250        return Err(ours(format!("{} is not the directory Git made", destination.display())));
251    }
252    std::fs::set_permissions(stage, std::fs::Permissions::from_mode(source_mode)).map_err(|error| { return ours(format!("the stage cannot take the source's mode ({error})")); })?;
253    (seams.after_check)();
254    exchange_at(&parent, stage_name, destination_name).map_err(|error| { return ours(format!("the destination cannot be swapped with the stage ({error})")); })?;
255    // The directory displaced to the stage name must be the one that was checked; another means the destination was replaced.
256    if identity_at(&parent, stage_name).ok() == Some(expected) {
257        return Ok(());
258    }
259    (seams.before_swap_back)();
260    if let Err(error) = exchange_at(&parent, stage_name, destination_name) {
261        return Err(PlaceError { reason: format!("{} changed while it was swapped and the swap could not be undone ({error})", destination.display()), stage_is_ours: false });
262    }
263    // The undo put the stage name back to what it held before only if that is the snapshot.
264    let restored: bool = identity_at(&parent, stage_name).ok() == Some(snapshot);
265    return Err(PlaceError { reason: format!("{} changed while it was swapped; the swap was undone", destination.display()), stage_is_ours: restored });
266}
267
268/// What:
269///  Settle the directory real Git made, now at the stage name: move what was added to it meanwhile into the new worktree,
270///  remove Git's pointer when it is the one that was written, remove the directory when it is empty, and return what stays.
271/// Why:
272///   The destination may have gained files between the look and the exchange, and a recursive removal would delete them.
273///  Only the pointer and an empty directory are removed; every other entry is moved without replacing, or stays with a note.
274///
275/// In TS you'd write (pseudocode):
276/// ```ts
277/// const notes = await settleDisplaced({ stage, destination, expected, pointer });
278/// ```
279pub fn settle_displaced(stage: &Path, destination: &Path, expected: ObjectId, pointer: &[u8]) -> Vec<String> {
280    let (Some(parent_path), Some(stage_name), Some(destination_name)) = (stage.parent(), stage.file_name(), destination.file_name()) else {
281        return vec![format!("{} has no parent", stage.display())];
282    };
283    let mut notes: Vec<String> = Vec::new();
284    let opened = (|| -> std::io::Result<(File, File, File)> {
285        let parent: File = open_root(parent_path)?;
286        let displaced: File = open_directory_at(&parent, stage_name)?;
287        let placed: File = open_directory_at(&parent, destination_name)?;
288        return Ok((parent, displaced, placed));
289    })();
290    let Ok((parent, displaced, placed)) = opened else { return vec![format!("{} could not be opened to be settled", stage.display())] };
291    if crate::fs_beneath::identity_of(&displaced).ok() != Some(expected) {
292        return vec![format!("{} is not the directory Git made and stays", stage.display())];
293    }
294    let listing: Vec<OsString> = match std::fs::read_dir(crate::fs_beneath::handle_path(&displaced)) {
295        Ok(found) => found.flatten().map(|entry| { return entry.file_name(); }).collect(),
296        Err(error) => return vec![format!("{} cannot be listed ({error})", stage.display())],
297    };
298    for name in listing {
299        if name == ".git" {
300            let same: bool = open_file_at(&displaced, name.as_os_str()).ok().is_some_and(|mut file: File| {
301                let mut found: Vec<u8> = Vec::new();
302                return std::io::Read::read_to_end(&mut file, &mut found).is_ok() && found == pointer;
303            });
304            if same {
305                if let Err(error) = remove_at(&displaced, name.as_os_str(), false) {
306                    notes.push(format!("Git's pointer in {} cannot be removed ({error})", stage.display()));
307                }
308            } else {
309                notes.push(format!(".git in {} is not the pointer Git wrote and stays", stage.display()));
310            }
311            continue;
312        }
313        if let Err(error) = rename_at(&displaced, name.as_os_str(), &placed, name.as_os_str(), false) {
314            notes.push(format!("{} was added to {} while the worktree was made and stays in {} ({error})", name.to_string_lossy(), destination.display(), stage.display()));
315        }
316    }
317    if let Err(error) = remove_at(&parent, stage_name, true) {
318        notes.push(format!("{} is not removed ({error})", stage.display()));
319    }
320    return notes;
321}
322
323/// Snapshot mechanics controls stay out of the release executable.
324#[cfg(test)]
325#[path = "worktree_snapshot_tests.rs"]
326mod tests;