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;