git_policy_cli/worktree_copying/worktree_copy_install_entry.rs
1//! What:
2//! The exclusive creation of one destination entry from the private stage,
3//! the identity captured right after it exists,
4//! and the creation of the unselected parent directories an entry needs.
5//! Why:
6//! A regular file is installed as a hard link to its staged copy,
7//! so it appears with its final bytes and mode in one step;
8//! the identity captured after creation is the only proof later rollback accepts.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await ensureParents({ destinationRoot, entry, created });
13//! await createSelectedEntry({ snapshot, destinationRoot, entry });
14//! created.push(await captureInstalledPath({ destinationPath, relativePath, selected: true }));
15//! ```
16
17use crate::worktree_copy_compare::lstat_or_absent;
18use crate::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
19use crate::worktree_copy_manifest::{EntryKind, WorktreeCopyEntry};
20use crate::worktree_copy_selection::filesystem_path;
21use crate::worktree_copy_stage::copy_file_exclusive;
22use crate::json_record::quote;
23use std::os::unix::fs::{DirBuilderExt, MetadataExt};
24use std::path::{Path, PathBuf};
25
26/// What:
27/// The permission bits of a directory cli-git creates for a selected entry, before modes are applied.
28/// Why:
29/// Restrictive modes can block the children installation still has to create, so modes come last.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// const PRIVATE_DIRECTORY_MODE = 0o700;
34/// ```
35const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
36
37/// What:
38/// The operating-system errors that mean the filesystem cannot hard-link the staged file.
39/// Why:
40/// Only these refusals fall back to a copy; any other failure is reported as it is.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// const LINK_UNSUPPORTED_CODES = new Set(['EMLINK', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EXDEV']);
45/// ```
46const LINK_UNSUPPORTED_CODES: [i32; 6] = [
47 libc::EMLINK,
48 libc::ENOSYS,
49 libc::ENOTSUP,
50 libc::EOPNOTSUPP,
51 libc::EPERM,
52 libc::EXDEV,
53];
54
55/// What:
56/// One path cli-git created, with the identity the filesystem gave it.
57/// Why:
58/// Rollback removes a path only when its device and inode still match this record.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// type InstalledWorktreePath = { device: string; inode: string; relativePath: string; selected: boolean };
63/// ```
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct InstalledPath {
66 /// Device number reported by the filesystem after creation.
67 pub device: u64,
68 /// Inode number reported by the filesystem after creation.
69 pub inode: u64,
70 /// Slash-separated repository path.
71 pub relative_path: String,
72 /// Whether the path came from the selected source manifest rather than being parent scaffolding.
73 pub selected: bool,
74}
75
76/// What:
77/// The no-follow identity of a path, read right after its creation.
78/// Why:
79/// The identity names the exact object cli-git made, not whatever later sits at the same path.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// async function captureInstalledPath({ destinationPath, relativePath, selected }): Promise<InstalledWorktreePath>;
84/// ```
85pub fn capture_installed_path(
86 destination_path: &Path,
87 relative_path: &str,
88 selected: bool,
89) -> Result<InstalledPath, WorktreeCopyError> {
90 let metadata: std::fs::Metadata =
91 std::fs::symlink_metadata(destination_path).map_err(|error| return raw_failure(&error))?;
92 return Ok(InstalledPath {
93 device: metadata.dev(),
94 inode: metadata.ino(),
95 relative_path: String::from(relative_path),
96 selected,
97 });
98}
99
100/// What:
101/// Creates each missing unselected parent directory of one entry, shallow first, recording what it created.
102/// Why:
103/// An entry's parents are scaffolding that rollback must be able to remove again, so each one is recorded.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// async function ensureParents({ destinationRoot, entry, created }): Promise<void>;
108/// ```
109pub fn ensure_parents(
110 destination_root: &Path,
111 entry: &WorktreeCopyEntry,
112 created: &mut Vec<InstalledPath>,
113) -> Result<(), WorktreeCopyError> {
114 return ensure_parents_remembering(destination_root, entry, created, &mut std::collections::HashSet::new());
115}
116
117/// What:
118/// Creates each missing unselected parent directory of one entry, skipping every parent an earlier call already proved.
119/// Why:
120/// Entries arrive parent first, so almost every entry's parent was just proved or created; asking the filesystem about
121/// each ancestor of each of hundreds of thousands of entries costs more than the clones themselves.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// async function ensureParentsRemembering({ destinationRoot, entry, created, known }): Promise<void>;
126/// ```
127pub fn ensure_parents_remembering(
128 destination_root: &Path,
129 entry: &WorktreeCopyEntry,
130 created: &mut Vec<InstalledPath>,
131 known: &mut std::collections::HashSet<String>,
132) -> Result<(), WorktreeCopyError> {
133 let Some((parent, _name)) = entry.relative_path.rsplit_once('/') else {
134 return Ok(());
135 };
136 if known.contains(parent) {
137 return Ok(());
138 }
139 let components: Vec<&str> = entry.relative_path.split('/').collect();
140 let parent_count: usize = components.len().saturating_sub(1);
141 for depth in 1..=parent_count {
142 let current: String = components[..depth].join("/");
143 let destination_path: PathBuf = filesystem_path(destination_root, current.as_str())?;
144 // `match` on the existing entry: a directory is kept, anything else is a refusal.
145 match lstat_or_absent(destination_path.as_path())? {
146 Some(metadata) => {
147 if !metadata.is_dir() {
148 let message: String = format!(
149 "cli-git: ignored-state parent is not a directory: {} in {}.",
150 quote(current.as_str()),
151 quote(destination_root.to_string_lossy().as_ref())
152 );
153 return Err(failure_with_cause(message.as_str(), None));
154 }
155 }
156 None => {
157 std::fs::create_dir(destination_path.as_path())
158 .map_err(|error| return raw_failure(&error))?;
159 created.push(capture_installed_path(
160 destination_path.as_path(),
161 current.as_str(),
162 false,
163 )?);
164 }
165 }
166 }
167 for depth in 1..=parent_count {
168 known.insert(components[..depth].join("/"));
169 }
170 return Ok(());
171}
172
173/// What:
174/// Whether a hard-link failure means the filesystem cannot link the staged file.
175/// Why:
176/// Only a refusal of the link itself falls back to a copy; a missing source or a full disk does not.
177///
178/// In TS you'd write (pseudocode):
179/// ```ts
180/// function isLinkUnsupported(error: unknown): boolean;
181/// ```
182fn is_link_unsupported(error: &std::io::Error) -> bool {
183 return error
184 .raw_os_error()
185 .is_some_and(|code| return LINK_UNSUPPORTED_CODES.contains(&code));
186}
187
188/// What:
189/// Installs one staged regular file: a hard link when the filesystem allows it, otherwise an exclusive copy with the entry's mode.
190/// Why:
191/// The link shares the staged bytes and mode in one step; the copy is the fallback the contract requires.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// async function installFile({ stagePath, destinationPath, mode }): Promise<void>;
196/// ```
197fn install_file(
198 stage_path: &Path,
199 destination_path: &Path,
200 mode: u32,
201) -> Result<(), WorktreeCopyError> {
202 // `match` on the link result: success ends here, a refusal copies, and any other failure is reported.
203 match std::fs::hard_link(stage_path, destination_path) {
204 Ok(()) => return Ok(()),
205 Err(error) if is_link_unsupported(&error) => {}
206 Err(error) => return Err(raw_failure(&error)),
207 }
208 copy_file_exclusive(stage_path, destination_path)?;
209 let applied: std::io::Result<()> = std::fs::set_permissions(
210 destination_path,
211 std::os::unix::fs::PermissionsExt::from_mode(mode),
212 );
213 if let Err(error) = applied {
214 // A copy that cannot take its mode is removed, so the next attempt starts from the absent path.
215 let _ignored_removal: std::io::Result<()> = std::fs::remove_file(destination_path);
216 return Err(raw_failure(&error));
217 }
218 return Ok(());
219}
220
221/// What:
222/// Creates one absent selected entry from the private stage, as its kind requires.
223/// Why:
224/// Directories are created private and changed later; links are recreated with their exact target text.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// async function createSelectedEntry({ snapshot, destinationRoot, entry }): Promise<void>;
229/// ```
230pub fn create_selected_entry(
231 stage_root: &Path,
232 destination_root: &Path,
233 entry: &WorktreeCopyEntry,
234) -> Result<(), WorktreeCopyError> {
235 let stage_path: PathBuf = filesystem_path(stage_root, entry.relative_path.as_str())?;
236 let destination_path: PathBuf =
237 filesystem_path(destination_root, entry.relative_path.as_str())?;
238 // `match` on the kind selects the one creation that applies to it.
239 match entry.kind {
240 EntryKind::Directory => {
241 return std::fs::DirBuilder::new()
242 .mode(PRIVATE_DIRECTORY_MODE)
243 .create(destination_path.as_path())
244 .map_err(|error| return raw_failure(&error));
245 }
246 EntryKind::File => {
247 return install_file(stage_path.as_path(), destination_path.as_path(), entry.mode);
248 }
249 EntryKind::Symlink => {
250 let target: PathBuf = std::fs::read_link(stage_path.as_path())
251 .map_err(|error| return raw_failure(&error))?;
252 return std::os::unix::fs::symlink(target, destination_path.as_path())
253 .map_err(|error| return raw_failure(&error));
254 }
255 }
256}
257
258/// Entry-creation controls stay out of the release executable.
259#[cfg(test)]
260#[path = "worktree_copy_install_entry_tests.rs"]
261mod tests;