git_policy_cli/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 super::worktree_copy_compare::lstat_or_absent;
18use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
19use super::worktree_copy_manifest::{EntryKind, WorktreeCopyEntry};
20use super::worktree_copy_selection::filesystem_path;
21use super::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 let components: Vec<&str> = entry.relative_path.split('/').collect();
115 let parent_count: usize = components.len().saturating_sub(1);
116 for depth in 1..=parent_count {
117 let current: String = components[..depth].join("/");
118 let destination_path: PathBuf = filesystem_path(destination_root, current.as_str())?;
119 // `match` on the existing entry: a directory is kept, anything else is a refusal.
120 match lstat_or_absent(destination_path.as_path())? {
121 Some(metadata) => {
122 if !metadata.is_dir() {
123 let message: String = format!(
124 "cli-git: ignored-state parent is not a directory: {} in {}.",
125 quote(current.as_str()),
126 quote(destination_root.to_string_lossy().as_ref())
127 );
128 return Err(failure_with_cause(message.as_str(), None));
129 }
130 }
131 None => {
132 std::fs::create_dir(destination_path.as_path())
133 .map_err(|error| return raw_failure(&error))?;
134 created.push(capture_installed_path(
135 destination_path.as_path(),
136 current.as_str(),
137 false,
138 )?);
139 }
140 }
141 }
142 return Ok(());
143}
144
145/// What:
146/// Whether a hard-link failure means the filesystem cannot link the staged file.
147/// Why:
148/// Only a refusal of the link itself falls back to a copy; a missing source or a full disk does not.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// function isLinkUnsupported(error: unknown): boolean;
153/// ```
154fn is_link_unsupported(error: &std::io::Error) -> bool {
155 return error
156 .raw_os_error()
157 .is_some_and(|code| return LINK_UNSUPPORTED_CODES.contains(&code));
158}
159
160/// What:
161/// Installs one staged regular file: a hard link when the filesystem allows it, otherwise an exclusive copy with the entry's mode.
162/// Why:
163/// The link shares the staged bytes and mode in one step; the copy is the fallback the contract requires.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// async function installFile({ stagePath, destinationPath, mode }): Promise<void>;
168/// ```
169fn install_file(
170 stage_path: &Path,
171 destination_path: &Path,
172 mode: u32,
173) -> Result<(), WorktreeCopyError> {
174 // `match` on the link result: success ends here, a refusal copies, and any other failure is reported.
175 match std::fs::hard_link(stage_path, destination_path) {
176 Ok(()) => return Ok(()),
177 Err(error) if is_link_unsupported(&error) => {}
178 Err(error) => return Err(raw_failure(&error)),
179 }
180 copy_file_exclusive(stage_path, destination_path)?;
181 let applied: std::io::Result<()> = std::fs::set_permissions(
182 destination_path,
183 std::os::unix::fs::PermissionsExt::from_mode(mode),
184 );
185 if let Err(error) = applied {
186 // A copy that cannot take its mode is removed, so the next attempt starts from the absent path.
187 let _ignored_removal: std::io::Result<()> = std::fs::remove_file(destination_path);
188 return Err(raw_failure(&error));
189 }
190 return Ok(());
191}
192
193/// What:
194/// Creates one absent selected entry from the private stage, as its kind requires.
195/// Why:
196/// Directories are created private and changed later; links are recreated with their exact target text.
197///
198/// In TS you'd write (pseudocode):
199/// ```ts
200/// async function createSelectedEntry({ snapshot, destinationRoot, entry }): Promise<void>;
201/// ```
202pub fn create_selected_entry(
203 stage_root: &Path,
204 destination_root: &Path,
205 entry: &WorktreeCopyEntry,
206) -> Result<(), WorktreeCopyError> {
207 let stage_path: PathBuf = filesystem_path(stage_root, entry.relative_path.as_str())?;
208 let destination_path: PathBuf =
209 filesystem_path(destination_root, entry.relative_path.as_str())?;
210 // `match` on the kind selects the one creation that applies to it.
211 match entry.kind {
212 EntryKind::Directory => {
213 return std::fs::DirBuilder::new()
214 .mode(PRIVATE_DIRECTORY_MODE)
215 .create(destination_path.as_path())
216 .map_err(|error| return raw_failure(&error));
217 }
218 EntryKind::File => {
219 return install_file(stage_path.as_path(), destination_path.as_path(), entry.mode);
220 }
221 EntryKind::Symlink => {
222 let target: PathBuf = std::fs::read_link(stage_path.as_path())
223 .map_err(|error| return raw_failure(&error))?;
224 return std::os::unix::fs::symlink(target, destination_path.as_path())
225 .map_err(|error| return raw_failure(&error));
226 }
227 }
228}
229
230/// Entry-creation controls stay out of the release executable.
231#[cfg(test)]
232#[path = "worktree_copy_install_entry_tests.rs"]
233mod tests;