Skip to main content

git_policy_cli/worktree_copying/
worktree_copy_manifest.rs

1//! What:
2//!  The deterministic, parent-first list of entries a copy transfers,
3//!  read without following symbolic links,
4//!  and the application of the retained permission bits to a staged copy.
5//! Why:
6//!  The stage and the source are compared entry by entry,
7//!  so both sides must enumerate in the same order and classify the same way.
8//!  Sockets, FIFOs and device nodes stop the walk before any of them is opened.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const entries = await collectEntryManifest({ root, selectedRoots, excludedRoots });
13//! await applyEntryModes({ root: stageRoot, entries });
14//! ```
15
16use crate::worktree_copy_error::raw_failure;
17use crate::worktree_copy_error::{WorktreeCopyError, failure_with_cause};
18use crate::worktree_copy_prune::Prune;
19use crate::worktree_copy_selection::{filesystem_path, js_order};
20use crate::json_record::quote;
21use std::path::{Path, PathBuf};
22
23/// What:
24///  The permission and special-mode bits the copy contract retains.
25/// Why:
26///   Only these bits are compared and applied; file-type bits are never copied.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// const PERMISSION_BITS = 0o7777;
31/// ```
32#[cfg(unix)]
33const PERMISSION_BITS: u32 = 0o7777;
34
35/// What:
36///  The kind of one copied entry.
37/// Why:
38///   Each kind is copied, compared and modified differently.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type WorktreeCopyEntryKind = 'directory' | 'file' | 'symlink';
43/// ```
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum EntryKind {
46    /// A directory, recreated rather than copied.
47    Directory,
48    /// A regular file, copied byte for byte.
49    File,
50    /// A symbolic link, recreated with its exact target text.
51    Symlink,
52}
53
54/// What:
55///  One entry of a manifest: its kind, its retained mode and its repository path.
56/// Why:
57///   A manifest is compared with another manifest entry by entry.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// type WorktreeCopyEntry = { kind: WorktreeCopyEntryKind; mode: number; relativePath: string };
62/// ```
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct WorktreeCopyEntry {
65    /// Copy kind of the entry.
66    pub kind: EntryKind,
67    /// Retained permission bits;
68    ///  zero where the platform has no such bits.
69    pub mode: u32,
70    /// Slash-separated repository path.
71    pub relative_path: String,
72}
73
74/// What:
75///  One pending walk item: its native path and its repository path.
76/// Why:
77///   The walk keeps both, because exclusion works on native paths and output on repository paths.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// type WalkItem = { filesystemPath: string; relativePath: string };
82/// ```
83struct WalkItem {
84    /// Native path under the walked root.
85    filesystem_path: PathBuf,
86    /// Repository path retained in the manifest.
87    relative_path: String,
88}
89
90/// What:
91///  The copy kind of one no-follow filesystem entry,
92///  or the failure for a socket, FIFO or device node.
93/// Why:
94///   Only three kinds can be copied; anything else must fail before it is opened.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// function entryKind({ stats, path }): WorktreeCopyEntryKind;
99/// ```
100fn entry_kind(file_type: std::fs::FileType, path: &Path) -> Result<EntryKind, WorktreeCopyError> {
101    // `if` chains keep each supported kind next to its name; the last branch refuses the rest.
102    if file_type.is_dir() {
103        return Ok(EntryKind::Directory);
104    }
105    if file_type.is_file() {
106        return Ok(EntryKind::File);
107    }
108    if file_type.is_symlink() {
109        return Ok(EntryKind::Symlink);
110    }
111    let message: String = format!(
112        "cli-git: ignored path {} has unsupported filesystem type.",
113        quote(path.to_string_lossy().as_ref())
114    );
115    return Err(failure_with_cause(message.as_str(), None));
116}
117
118/// What:
119///  The permission bits of one no-follow entry, as the copy contract retains them.
120/// Why:
121///   The mode is read from the entry itself, never from what a symbolic link points to.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// stats.mode & 0o7777
126/// ```
127#[cfg(unix)]
128pub fn retained_mode(metadata: &std::fs::Metadata) -> u32 {
129    use std::os::unix::fs::MetadataExt;
130    return metadata.mode() & PERMISSION_BITS;
131}
132
133/// What:
134///  The permission bits of one entry on a platform without Unix mode bits.
135/// Why:
136///   The copy is Unix-only in this lane; the zero mode keeps non-Unix builds compiling
137///  without claiming the mode parity the incumbent provides there.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// stats.mode & 0o7777
142/// ```
143// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
144#[cfg(not(unix))]
145pub fn retained_mode(_metadata: &std::fs::Metadata) -> u32 {
146    return 0;
147}
148
149/// What:
150///  Whether the running user can read an entry that the walk found, as a file or as a directory to list.
151/// Why:
152///   A file or directory the user cannot read (a root-owned log in a dependency tree) cannot be part of a copy a user
153///  makes, so the walk leaves it out and says so instead of failing the whole copy.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// await access(path, directory ? R_OK | X_OK : R_OK).then(() => true, () => false)
158/// ```
159#[cfg(unix)]
160fn readable_by_user(path: &Path, kind: EntryKind) -> bool {
161    use std::os::unix::ffi::OsStrExt;
162    if kind == EntryKind::Symlink {
163        return true;
164    }
165    let Ok(name) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
166        return false;
167    };
168    let mode: libc::c_int = if kind == EntryKind::Directory { libc::R_OK | libc::X_OK } else { libc::R_OK };
169    // SAFETY: `name` is a NUL-terminated string that outlives the call, and `access` only reads it.
170    return unsafe { libc::access(name.as_ptr(), mode) } == 0;
171}
172
173/// What:
174///  Every entry is readable on a platform without Unix permission checks in this lane.
175/// Why:
176///   Keeps the walk identical on every platform; a refused open then fails the copy as before.
177///
178/// In TS you'd write (pseudocode):
179/// ```ts
180/// true
181/// ```
182// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
183#[cfg(not(unix))]
184fn readable_by_user(_path: &Path, _kind: EntryKind) -> bool {
185    return true;
186}
187
188/// What:
189///  The complete manifest of a set of selected roots, parent before child, in JavaScript sort order.
190/// Why:
191///   A deterministic order makes the stage, the comparison and the journal reproducible.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// async function collectEntryManifest({ root, selectedRoots, excludedRoots }): Promise<readonly WorktreeCopyEntry[]>;
196/// ```
197pub fn collect_entry_manifest(
198    root: &Path,
199    selected_roots: &[String],
200    excluded_roots: &[PathBuf],
201) -> Result<Vec<WorktreeCopyEntry>, WorktreeCopyError> {
202    let mut skipped: Vec<String> = Vec::new();
203    return collect_pruned_manifest(root, selected_roots, excluded_roots, Prune::Never, &mut skipped);
204}
205
206/// What:
207///  The manifest of a set of selected roots under a leave-out rule, with the unreadable entries it left out.
208/// Why:
209///   A copy from a main checkout leaves out caches and other checkouts, and never fails over an entry the user cannot
210///  read; the caller reports those so the person sees what the new worktree lacks.
211///
212/// In TS you'd write (pseudocode):
213/// ```ts
214/// async function collectPrunedManifest({ root, selectedRoots, excludedRoots, prune, skipped }): Promise<readonly WorktreeCopyEntry[]>;
215/// ```
216pub fn collect_pruned_manifest(
217    root: &Path,
218    selected_roots: &[String],
219    excluded_roots: &[PathBuf],
220    prune: Prune,
221    unreadable: &mut Vec<String>,
222) -> Result<Vec<WorktreeCopyEntry>, WorktreeCopyError> {
223    // The stack is filled in reverse so the lexically first root is popped first.
224    let mut pending: Vec<WalkItem> = Vec::new();
225    for relative_path in selected_roots.iter().rev() {
226        pending.push(WalkItem {
227            filesystem_path: filesystem_path(root, relative_path)?,
228            relative_path: relative_path.clone(),
229        });
230    }
231    let mut entries: Vec<WorktreeCopyEntry> = Vec::new();
232    while let Some(item) = pending.pop() {
233        if excluded_roots
234            .iter()
235            .any(|excluded| return item.filesystem_path.starts_with(excluded.as_path()))
236            || prune.skips_path(item.relative_path.as_str())
237        {
238            continue;
239        }
240        let metadata: std::fs::Metadata = std::fs::symlink_metadata(item.filesystem_path.as_path())
241            .map_err(|error| return raw_failure(&error))?;
242        let kind: EntryKind = entry_kind(metadata.file_type(), item.filesystem_path.as_path())?;
243        if !readable_by_user(item.filesystem_path.as_path(), kind) {
244            unreadable.push(item.relative_path.clone());
245            continue;
246        }
247        if kind != EntryKind::Directory {
248            entries.push(WorktreeCopyEntry {
249                kind,
250                mode: retained_mode(&metadata),
251                relative_path: item.relative_path.clone(),
252            });
253            continue;
254        }
255        let mut names: Vec<String> = Vec::new();
256        for listed in std::fs::read_dir(item.filesystem_path.as_path())
257            .map_err(|error| return raw_failure(&error))?
258        {
259            let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
260            names.push(child.file_name().to_string_lossy().into_owned());
261        }
262        // A directory that is a checkout of its own is left out whole, with no entry of its own.
263        if prune.skips_checkout(names.as_slice()) {
264            continue;
265        }
266        entries.push(WorktreeCopyEntry {
267            kind,
268            mode: retained_mode(&metadata),
269            relative_path: item.relative_path.clone(),
270        });
271        names.sort_by(|left, right| return js_order(left, right));
272        for name in names.iter().rev() {
273            let child_relative: String = format!("{}/{name}", item.relative_path);
274            pending.push(WalkItem {
275                filesystem_path: filesystem_path(root, child_relative.as_str())?,
276                relative_path: child_relative,
277            });
278        }
279    }
280    return Ok(entries);
281}
282
283/// What:
284///  The identity of a regular file's content as the filesystem reports it: size, modification time, change time and inode.
285/// Why:
286///   A cloned file shares its data blocks with the source, so equal stamps before and after the clone and again at the
287///  end of the copy prove the source did not change in between without reading a byte.
288///
289/// In TS you'd write (pseudocode):
290/// ```ts
291/// type FileStamp = { size: number; mtimeNs: bigint; ctimeNs: bigint; inode: bigint };
292/// ```
293#[derive(Clone, Copy, Debug, Eq, PartialEq)]
294pub struct FileStamp {
295    /// Size in bytes.
296    pub size: u64,
297    /// Modification time, seconds and nanoseconds.
298    pub modified: (i64, i64),
299    /// Change time, seconds and nanoseconds; no program can set it, so a write always moves it.
300    pub changed: (i64, i64),
301    /// Inode number, so a replacement by rename is a difference.
302    pub inode: u64,
303}
304
305/// What:
306///  The stamp of one no-follow entry, or nothing on a platform that reports no change time.
307/// Why:
308///   Without a stamp the caller compares bytes, which is always correct.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// stampOf(await lstat(path))
313/// ```
314#[cfg(unix)]
315pub fn stamp_of(metadata: &std::fs::Metadata) -> Option<FileStamp> {
316    use std::os::unix::fs::MetadataExt;
317    return Some(FileStamp {
318        size: metadata.len(),
319        modified: (metadata.mtime(), metadata.mtime_nsec()),
320        changed: (metadata.ctime(), metadata.ctime_nsec()),
321        inode: metadata.ino(),
322    });
323}
324
325/// What:
326///  No stamp on a platform without a change time, so every cloned file is compared byte for byte.
327/// Why:
328///   Keeps the call sites identical on every platform.
329///
330/// In TS you'd write (pseudocode):
331/// ```ts
332/// undefined
333/// ```
334// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
335#[cfg(not(unix))]
336pub fn stamp_of(_metadata: &std::fs::Metadata) -> Option<FileStamp> {
337    return None;
338}
339
340/// What:
341///  Applies each staged entry's retained mode, files first and then directories deepest first.
342/// Why:
343///   A restrictive directory mode would block changes to its children, so directories change last.
344///
345/// In TS you'd write (pseudocode):
346/// ```ts
347/// async function applyEntryModes({ root, entries }): Promise<void>;
348/// ```
349#[cfg(unix)]
350pub fn apply_entry_modes(
351    root: &Path,
352    entries: &[WorktreeCopyEntry],
353) -> Result<(), WorktreeCopyError> {
354    use std::os::unix::fs::PermissionsExt;
355    let files = entries
356        .iter()
357        .filter(|entry| return entry.kind == EntryKind::File);
358    let directories = entries
359        .iter()
360        .rev()
361        .filter(|entry| return entry.kind == EntryKind::Directory);
362    for entry in files.chain(directories) {
363        let path: PathBuf = filesystem_path(root, entry.relative_path.as_str())?;
364        std::fs::set_permissions(path.as_path(), std::fs::Permissions::from_mode(entry.mode))
365            .map_err(|error| return raw_failure(&error))?;
366    }
367    return Ok(());
368}
369
370/// What:
371///  Applies modes on a platform without Unix mode bits, which leaves them untouched.
372/// Why:
373///   Keeps the call sites identical on every platform.
374///
375/// In TS you'd write (pseudocode):
376/// ```ts
377/// async function applyEntryModes({ root, entries }): Promise<void>;
378/// ```
379// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
380#[cfg(not(unix))]
381pub fn apply_entry_modes(
382    _root: &Path,
383    _entries: &[WorktreeCopyEntry],
384) -> Result<(), WorktreeCopyError> {
385    return Ok(());
386}
387
388/// Manifest controls stay out of the release executable.
389#[cfg(test)]
390#[path = "worktree_copy_manifest_tests.rs"]
391mod tests;