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// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
143#[cfg(not(unix))]
144pub fn retained_mode(_metadata: &std::fs::Metadata) -> u32 {
145    return 0;
146}
147
148/// What:
149///  The complete manifest of a set of selected roots, parent before child, in JavaScript sort order.
150/// Why:
151///   A deterministic order makes the stage, the comparison and the journal reproducible.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// async function collectEntryManifest({ root, selectedRoots, excludedRoots }): Promise<readonly WorktreeCopyEntry[]>;
156/// ```
157pub fn collect_entry_manifest(
158    root: &Path,
159    selected_roots: &[String],
160    excluded_roots: &[PathBuf],
161) -> Result<Vec<WorktreeCopyEntry>, WorktreeCopyError> {
162    // The stack is filled in reverse so the lexically first root is popped first.
163    let mut pending: Vec<WalkItem> = Vec::new();
164    for relative_path in selected_roots.iter().rev() {
165        pending.push(WalkItem {
166            filesystem_path: filesystem_path(root, relative_path.as_str())?,
167            relative_path: relative_path.clone(),
168        });
169    }
170    let mut entries: Vec<WorktreeCopyEntry> = Vec::new();
171    while let Some(item) = pending.pop() {
172        if excluded_roots
173            .iter()
174            .any(|excluded| return item.filesystem_path.starts_with(excluded.as_path()))
175        {
176            continue;
177        }
178        let metadata: std::fs::Metadata = std::fs::symlink_metadata(item.filesystem_path.as_path())
179            .map_err(|error| return raw_failure(&error))?;
180        let kind: EntryKind = entry_kind(metadata.file_type(), item.filesystem_path.as_path())?;
181        entries.push(WorktreeCopyEntry {
182            kind,
183            mode: retained_mode(&metadata),
184            relative_path: item.relative_path.clone(),
185        });
186        if kind != EntryKind::Directory {
187            continue;
188        }
189        let mut names: Vec<String> = Vec::new();
190        for listed in std::fs::read_dir(item.filesystem_path.as_path())
191            .map_err(|error| return raw_failure(&error))?
192        {
193            let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
194            names.push(child.file_name().to_string_lossy().into_owned());
195        }
196        names.sort_by(|left, right| return js_order(left, right));
197        for name in names.iter().rev() {
198            let child_relative: String = format!("{}/{name}", item.relative_path);
199            pending.push(WalkItem {
200                filesystem_path: filesystem_path(root, child_relative.as_str())?,
201                relative_path: child_relative,
202            });
203        }
204    }
205    return Ok(entries);
206}
207
208/// What:
209///  Applies each staged entry's retained mode, files first and then directories deepest first.
210/// Why:
211///   A restrictive directory mode would block changes to its children, so directories change last.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// async function applyEntryModes({ root, entries }): Promise<void>;
216/// ```
217#[cfg(unix)]
218pub fn apply_entry_modes(
219    root: &Path,
220    entries: &[WorktreeCopyEntry],
221) -> Result<(), WorktreeCopyError> {
222    use std::os::unix::fs::PermissionsExt;
223    let files = entries
224        .iter()
225        .filter(|entry| return entry.kind == EntryKind::File);
226    let directories = entries
227        .iter()
228        .rev()
229        .filter(|entry| return entry.kind == EntryKind::Directory);
230    for entry in files.chain(directories) {
231        let path: PathBuf = filesystem_path(root, entry.relative_path.as_str())?;
232        std::fs::set_permissions(path.as_path(), std::fs::Permissions::from_mode(entry.mode))
233            .map_err(|error| return raw_failure(&error))?;
234    }
235    return Ok(());
236}
237
238/// What:
239///  Applies modes on a platform without Unix mode bits, which leaves them untouched.
240/// Why:
241///   Keeps the call sites identical on every platform.
242///
243/// In TS you'd write (pseudocode):
244/// ```ts
245/// async function applyEntryModes({ root, entries }): Promise<void>;
246/// ```
247// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
248#[cfg(not(unix))]
249pub fn apply_entry_modes(
250    _root: &Path,
251    _entries: &[WorktreeCopyEntry],
252) -> Result<(), WorktreeCopyError> {
253    return Ok(());
254}
255
256/// Manifest controls stay out of the release executable.
257#[cfg(test)]
258#[path = "worktree_copy_manifest_tests.rs"]
259mod tests;