Skip to main content

git_policy_cli/
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 super::worktree_copy_error::raw_failure;
17use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause};
18use super::worktree_copy_selection::{filesystem_path, js_order};
19use crate::json_record::quote;
20use std::path::{Path, PathBuf};
21
22/// What:
23///  The permission and special-mode bits the copy contract retains.
24/// Why:
25///   Only these bits are compared and applied; file-type bits are never copied.
26///
27/// In TS you'd write (pseudocode):
28/// ```ts
29/// const PERMISSION_BITS = 0o7777;
30/// ```
31#[cfg(unix)]
32const PERMISSION_BITS: u32 = 0o7777;
33
34/// What:
35///  The kind of one copied entry.
36/// Why:
37///   Each kind is copied, compared and modified differently.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// type WorktreeCopyEntryKind = 'directory' | 'file' | 'symlink';
42/// ```
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum EntryKind {
45    /// A directory, recreated rather than copied.
46    Directory,
47    /// A regular file, copied byte for byte.
48    File,
49    /// A symbolic link, recreated with its exact target text.
50    Symlink,
51}
52
53/// What:
54///  One entry of a manifest: its kind, its retained mode and its repository path.
55/// Why:
56///   A manifest is compared with another manifest entry by entry.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// type WorktreeCopyEntry = { kind: WorktreeCopyEntryKind; mode: number; relativePath: string };
61/// ```
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct WorktreeCopyEntry {
64    /// Copy kind of the entry.
65    pub kind: EntryKind,
66    /// Retained permission bits;
67    ///  zero where the platform has no such bits.
68    pub mode: u32,
69    /// Slash-separated repository path.
70    pub relative_path: String,
71}
72
73/// What:
74///  One pending walk item: its native path and its repository path.
75/// Why:
76///   The walk keeps both, because exclusion works on native paths and output on repository paths.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// type WalkItem = { filesystemPath: string; relativePath: string };
81/// ```
82struct WalkItem {
83    /// Native path under the walked root.
84    filesystem_path: PathBuf,
85    /// Repository path retained in the manifest.
86    relative_path: String,
87}
88
89/// What:
90///  The copy kind of one no-follow filesystem entry,
91///  or the failure for a socket, FIFO or device node.
92/// Why:
93///   Only three kinds can be copied; anything else must fail before it is opened.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// function entryKind({ stats, path }): WorktreeCopyEntryKind;
98/// ```
99fn entry_kind(file_type: std::fs::FileType, path: &Path) -> Result<EntryKind, WorktreeCopyError> {
100    // `if` chains keep each supported kind next to its name; the last branch refuses the rest.
101    if file_type.is_dir() {
102        return Ok(EntryKind::Directory);
103    }
104    if file_type.is_file() {
105        return Ok(EntryKind::File);
106    }
107    if file_type.is_symlink() {
108        return Ok(EntryKind::Symlink);
109    }
110    let message: String = format!(
111        "cli-git: ignored path {} has unsupported filesystem type.",
112        quote(path.to_string_lossy().as_ref())
113    );
114    return Err(failure_with_cause(message.as_str(), None));
115}
116
117/// What:
118///  The permission bits of one no-follow entry, as the copy contract retains them.
119/// Why:
120///   The mode is read from the entry itself, never from what a symbolic link points to.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// stats.mode & 0o7777
125/// ```
126#[cfg(unix)]
127pub fn retained_mode(metadata: &std::fs::Metadata) -> u32 {
128    use std::os::unix::fs::MetadataExt;
129    return metadata.mode() & PERMISSION_BITS;
130}
131
132/// What:
133///  The permission bits of one entry on a platform without Unix mode bits.
134/// Why:
135///   The copy is Unix-only in this lane; the zero mode keeps non-Unix builds compiling
136///  without claiming the mode parity the incumbent provides there.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// stats.mode & 0o7777
141/// ```
142#[cfg(not(unix))]
143pub fn retained_mode(_metadata: &std::fs::Metadata) -> u32 {
144    return 0;
145}
146
147/// What:
148///  The complete manifest of a set of selected roots, parent before child, in JavaScript sort order.
149/// Why:
150///   A deterministic order makes the stage, the comparison and the journal reproducible.
151///
152/// In TS you'd write (pseudocode):
153/// ```ts
154/// async function collectEntryManifest({ root, selectedRoots, excludedRoots }): Promise<readonly WorktreeCopyEntry[]>;
155/// ```
156pub fn collect_entry_manifest(
157    root: &Path,
158    selected_roots: &[String],
159    excluded_roots: &[PathBuf],
160) -> Result<Vec<WorktreeCopyEntry>, WorktreeCopyError> {
161    // The stack is filled in reverse so the lexically first root is popped first.
162    let mut pending: Vec<WalkItem> = Vec::new();
163    for relative_path in selected_roots.iter().rev() {
164        pending.push(WalkItem {
165            filesystem_path: filesystem_path(root, relative_path.as_str())?,
166            relative_path: relative_path.clone(),
167        });
168    }
169    let mut entries: Vec<WorktreeCopyEntry> = Vec::new();
170    while let Some(item) = pending.pop() {
171        if excluded_roots
172            .iter()
173            .any(|excluded| return item.filesystem_path.starts_with(excluded.as_path()))
174        {
175            continue;
176        }
177        let metadata: std::fs::Metadata = std::fs::symlink_metadata(item.filesystem_path.as_path())
178            .map_err(|error| return raw_failure(&error))?;
179        let kind: EntryKind = entry_kind(metadata.file_type(), item.filesystem_path.as_path())?;
180        entries.push(WorktreeCopyEntry {
181            kind,
182            mode: retained_mode(&metadata),
183            relative_path: item.relative_path.clone(),
184        });
185        if kind != EntryKind::Directory {
186            continue;
187        }
188        let mut names: Vec<String> = Vec::new();
189        for listed in std::fs::read_dir(item.filesystem_path.as_path())
190            .map_err(|error| return raw_failure(&error))?
191        {
192            let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
193            names.push(child.file_name().to_string_lossy().into_owned());
194        }
195        names.sort_by(|left, right| return js_order(left, right));
196        for name in names.iter().rev() {
197            let child_relative: String = format!("{}/{name}", item.relative_path);
198            pending.push(WalkItem {
199                filesystem_path: filesystem_path(root, child_relative.as_str())?,
200                relative_path: child_relative,
201            });
202        }
203    }
204    return Ok(entries);
205}
206
207/// What:
208///  Applies each staged entry's retained mode, files first and then directories deepest first.
209/// Why:
210///   A restrictive directory mode would block changes to its children, so directories change last.
211///
212/// In TS you'd write (pseudocode):
213/// ```ts
214/// async function applyEntryModes({ root, entries }): Promise<void>;
215/// ```
216#[cfg(unix)]
217pub fn apply_entry_modes(
218    root: &Path,
219    entries: &[WorktreeCopyEntry],
220) -> Result<(), WorktreeCopyError> {
221    use std::os::unix::fs::PermissionsExt;
222    let files = entries
223        .iter()
224        .filter(|entry| return entry.kind == EntryKind::File);
225    let directories = entries
226        .iter()
227        .rev()
228        .filter(|entry| return entry.kind == EntryKind::Directory);
229    for entry in files.chain(directories) {
230        let path: PathBuf = filesystem_path(root, entry.relative_path.as_str())?;
231        std::fs::set_permissions(path.as_path(), std::fs::Permissions::from_mode(entry.mode))
232            .map_err(|error| return raw_failure(&error))?;
233    }
234    return Ok(());
235}
236
237/// What:
238///  Applies modes on a platform without Unix mode bits, which leaves them untouched.
239/// Why:
240///   Keeps the call sites identical on every platform.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// async function applyEntryModes({ root, entries }): Promise<void>;
245/// ```
246#[cfg(not(unix))]
247pub fn apply_entry_modes(
248    _root: &Path,
249    _entries: &[WorktreeCopyEntry],
250) -> Result<(), WorktreeCopyError> {
251    return Ok(());
252}
253
254/// Manifest controls stay out of the release executable.
255#[cfg(test)]
256#[path = "worktree_copy_manifest_tests.rs"]
257mod tests;