git_policy_cli/direct_fix_install.rs
1//! What:
2//! Install the corrections a converged `git cli-git fix` settled on into the
3//! worktree,
4//! all of them or none,
5//! and never touch the index.
6//! Why:
7//! The installed wrapper's `direct-fix-install.ts` decides this protocol:
8//! every
9//! corrected file must still hold the bytes the fix read,
10//! each replacement is written
11//! beside its file and renamed over it,
12//! a copy of the old file is kept until the end,
13//! and the real index must hold the same bytes afterwards.
14//! Any failure restores every
15//! file already replaced.
16//! Failure messages name no file,
17//! because a pathname can hold
18//! the text a scan exists to keep out of output.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! // await installDirectFix({ scope, changedPaths, finalCandidates, originals });
23//! ```
24
25/// Import the candidate's mode,
26/// which decides the replacement's permissions.
27use super::candidate_object::CandidateMode;
28/// Import the unique name part shared with the private index directories.
29use super::candidate_private_index::unique_suffix;
30/// Import the conversion of Git pathname bytes to a native path.
31use super::git_metadata::path_from_git_bytes;
32/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
33use std::path::{Path, PathBuf};
34/// `Rc<T>` is a shared,
35/// read-only handle.
36use std::rc::Rc;
37
38/// The name prefix of the files a fix writes beside each corrected file.
39pub const INSTALL_SIBLING_PREFIX: &str = ".cli-git-direct-fix-";
40
41/// The start of every installation failure message.
42const INSTALL_FAILED: &str = "cli-git fix could not install its corrections";
43
44/// What:
45/// One file to correct:
46/// its pathname,
47/// mode,
48/// the bytes the fix read and the bytes to
49/// write.
50/// `#[derive(...)]` generates cloning,
51/// debug printing and `==`.
52/// Why:
53/// The read bytes prove nobody changed the file while the fix ran.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// type InstallChange = { path: Uint8Array; mode: CandidateMode; original: Uint8Array; replacement: Uint8Array };
58/// ```
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct InstallChange {
61 /// The pathname,
62 /// relative to the repository's top level,
63 /// as Git's raw bytes.
64 pub path: Vec<u8>,
65 /// The file's mode;
66 /// executable files stay executable.
67 pub mode: CandidateMode,
68 /// The bytes the fix read;
69 /// the worktree file must still hold them.
70 pub original: Rc<[u8]>,
71 /// The corrected bytes.
72 pub replacement: Rc<[u8]>,
73}
74
75/// What:
76/// One prepared replacement:
77/// the file,
78/// the new bytes beside it,
79/// and a copy of the
80/// old file beside it.
81/// Why:
82/// Renaming the new file over the old one replaces it in one step,
83/// and the copy
84/// restores it if a later step fails.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// type PreparedReplacement = { destination: string; prepared: string; backup: string };
89/// ```
90#[derive(Debug)]
91pub struct Replacement {
92 /// The worktree file.
93 destination: PathBuf,
94 /// The corrected bytes,
95 /// written beside it.
96 prepared: PathBuf,
97 /// A copy of the file as it was,
98 /// beside it.
99 backup: PathBuf,
100}
101
102/// What:
103/// An installation failure message:
104/// the shared start,
105/// then what happened.
106/// Why:
107/// Every failure reads the same way and names no file.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// const installFailure = (detail: string) => `cli-git fix could not install its corrections: ${detail}`;
112/// ```
113fn install_failure(detail: &str) -> String {
114 return format!("{INSTALL_FAILED}: {detail}");
115}
116
117/// What:
118/// The real index's bytes,
119/// or nothing when it does not exist.
120/// `std::io::Result<Option<Vec<u8>>>` is "bytes,
121/// absent,
122/// or a read failure".
123/// Why:
124/// A repository without an index file has an empty index,
125/// and must still have none
126/// after the fix.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// async function readIndex(path: string): Promise<Uint8Array | typeof INDEX_ABSENT>;
131/// ```
132pub fn read_index(path: &Path) -> std::io::Result<Option<Vec<u8>>> {
133 match std::fs::read(path) {
134 Ok(bytes) => return Ok(Some(bytes)),
135 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
136 Err(error) => return Err(error),
137 }
138}
139
140/// What:
141/// Remove the prepared and backup files of each replacement,
142/// ignoring files that
143/// were never written.
144/// Why:
145/// Neither may stay behind after the fix ends,
146/// whichever way it ended.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// async function cleanup(replacements): Promise<void>;
151/// ```
152fn cleanup(replacements: &[Replacement]) {
153 for replacement in replacements {
154 // What: `let _ = ...` discards each removal's `Result` on purpose.
155 // Why: A file that was never written cannot be removed, and that is fine.
156 //
157 // In TS you'd write (pseudocode):
158 // ```ts
159 // await rm(prepared, { force: true });
160 // ```
161 let _ = std::fs::remove_file(replacement.prepared.as_path());
162 let _ = std::fs::remove_file(replacement.backup.as_path());
163 }
164}
165
166/// What:
167/// Rename each installed file's backup over it,
168/// last installed first.
169/// `Result<(), String>` is success or the first restore that failed.
170/// Why:
171/// The worktree returns to the bytes it held before the fix.
172/// A backup that cannot
173/// be restored is left in place,
174/// so its bytes are not lost.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// async function rollback(installed): Promise<void>;
179/// ```
180fn rollback(installed: &[Replacement]) -> Result<(), String> {
181 // `.iter().rev()` visits the installed files in reverse order.
182 for replacement in installed.iter().rev() {
183 if let Err(error) = std::fs::rename(
184 replacement.backup.as_path(),
185 replacement.destination.as_path(),
186 ) {
187 return Err(format!(
188 "restoring a corrected file failed ({error}); its original bytes remain beside it in a file whose name starts with {INSTALL_SIBLING_PREFIX}"
189 ));
190 }
191 }
192 return Ok(());
193}
194
195/// What:
196/// Write `bytes` to a new file at `path` with permissions for `mode`.
197/// Why:
198/// The file must not exist yet,
199/// so no other file is overwritten;
200/// executable
201/// files get `0755` and others `0644`,
202/// before the umask,
203/// as the installed wrapper
204/// writes them.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// await writeFile(path, bytes, { mode: mode === 'executable' ? 0o755 : 0o644, flag: 'wx' });
209/// ```
210fn write_new(path: &Path, bytes: &[u8], mode: CandidateMode) -> std::io::Result<()> {
211 /// `Write` adds `write_all` to the open file.
212 use std::io::Write;
213 let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
214 options.write(true).create_new(true);
215 #[cfg(unix)]
216 {
217 /// `OpenOptionsExt` adds `mode`,
218 /// the permissions a new file is created with.
219 use std::os::unix::fs::OpenOptionsExt;
220 let permissions: u32 = if mode == CandidateMode::Executable {
221 0o755
222 } else {
223 0o644
224 };
225 options.mode(permissions);
226 }
227 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
228 #[cfg(not(unix))]
229 {
230 // Only Unix records an executable bit; the mode decides nothing elsewhere.
231 let _ = mode;
232 }
233 return options.open(path)?.write_all(bytes);
234}
235
236/// What:
237/// Prepare every replacement:
238/// check the file still holds the bytes the fix read,
239/// copy it beside itself,
240/// and write the corrected bytes beside it.
241/// Why:
242/// Nothing in the worktree changes until every file is prepared,
243/// so a file that
244/// changed under the fix stops it before any file is replaced.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// async function prepareReplacements(root, changes): Promise<PreparedReplacement[]>;
249/// ```
250pub fn prepare_replacements(
251 root: &Path,
252 changes: &[InstallChange],
253) -> Result<Vec<Replacement>, String> {
254 let mut replacements: Vec<Replacement> = Vec::new();
255 for change in changes {
256 let Some(relative) = path_from_git_bytes(change.path.as_slice()) else {
257 cleanup(replacements.as_slice());
258 return Err(install_failure(
259 "Git named a corrected file this system cannot represent, so no file was changed.",
260 ));
261 };
262 let destination: PathBuf = root.join(relative);
263 let current: Vec<u8> = match std::fs::read(destination.as_path()) {
264 Ok(bytes) => bytes,
265 Err(error) => {
266 cleanup(replacements.as_slice());
267 return Err(install_failure(
268 format!("reading a corrected file failed ({error}), so no file was changed.")
269 .as_str(),
270 ));
271 }
272 };
273 if current.as_slice() != &*change.original {
274 cleanup(replacements.as_slice());
275 return Err(install_failure(
276 "a selected file changed while cli-git fix ran, so no file was changed. Run cli-git fix again.",
277 ));
278 }
279 let parent: PathBuf = match destination.parent() {
280 Some(found) => found.to_path_buf(),
281 None => root.to_path_buf(),
282 };
283 let prefix: String = format!("{INSTALL_SIBLING_PREFIX}{}", unique_suffix());
284 let replacement: Replacement = Replacement {
285 prepared: parent.join(format!("{prefix}.new")),
286 backup: parent.join(format!("{prefix}.old")),
287 destination,
288 };
289 let written: std::io::Result<()> = match std::fs::copy(
290 replacement.destination.as_path(),
291 replacement.backup.as_path(),
292 ) {
293 Ok(_) => write_new(
294 replacement.prepared.as_path(),
295 &change.replacement,
296 change.mode,
297 ),
298 Err(error) => Err(error),
299 };
300 // Recorded before the failure check, so a failure still removes what was written.
301 replacements.push(replacement);
302 if let Err(error) = written {
303 cleanup(replacements.as_slice());
304 return Err(install_failure(
305 format!("writing a corrected file beside its original failed ({error}), so no file was changed.").as_str(),
306 ));
307 }
308 }
309 return Ok(replacements);
310}
311
312/// What:
313/// Rename every prepared file over its destination,
314/// then prove the real index still
315/// holds `index_before`.
316/// `&Option<Vec<u8>>` borrows the bytes read before,
317/// or absence.
318/// Why:
319/// The fix changes the worktree only.
320/// A rename that fails,
321/// an index that changed,
322/// or an index that cannot be read restores every file already replaced.
323///
324/// In TS you'd write (pseudocode):
325/// ```ts
326/// async function installPrepared(replacements, realIndexPath, indexBefore): Promise<void>;
327/// ```
328pub fn install_prepared(
329 replacements: &[Replacement],
330 real_index: &Path,
331 index_before: &Option<Vec<u8>>,
332) -> Result<(), String> {
333 let mut installed: usize = 0;
334 let mut failure: Option<String> = None;
335 for replacement in replacements {
336 if let Err(error) = std::fs::rename(
337 replacement.prepared.as_path(),
338 replacement.destination.as_path(),
339 ) {
340 failure = Some(format!("replacing a corrected file failed ({error})"));
341 break;
342 }
343 installed += 1;
344 }
345 if failure.is_none() {
346 failure = match read_index(real_index) {
347 Ok(after) if after == *index_before => None,
348 Ok(_) => Some(String::from("the index changed while cli-git fix ran")),
349 Err(error) => Some(format!("reading the index again failed ({error})")),
350 };
351 }
352 let Some(cause) = failure else {
353 cleanup(replacements);
354 return Ok(());
355 };
356 // `..installed` is the files already replaced, which the rollback restores.
357 let restored: Result<(), String> = rollback(&replacements[..installed]);
358 match restored {
359 Ok(()) => {
360 cleanup(replacements);
361 return Err(install_failure(
362 format!("{cause}, so every corrected file was restored. Run cli-git fix again.")
363 .as_str(),
364 ));
365 }
366 Err(restore_failure) => {
367 // Prepared files are removed; backups stay, because one of them is needed.
368 for replacement in replacements {
369 let _ = std::fs::remove_file(replacement.prepared.as_path());
370 }
371 return Err(install_failure(
372 format!("{cause}, and {restore_failure}.").as_str(),
373 ));
374 }
375 }
376}
377
378/// What:
379/// Install every change,
380/// or none:
381/// read the real index,
382/// prepare,
383/// then rename and
384/// verify.
385/// `Result<(), String>` is success or the failure message to report.
386/// Why:
387/// The whole protocol of the installed wrapper's `installDirectFix`,
388/// apart from the
389/// landing lock,
390/// which the native wrapper does not take.
391///
392/// In TS you'd write (pseudocode):
393/// ```ts
394/// async function installCorrections(root, realIndexPath, changes): Promise<void>;
395/// ```
396pub fn install_corrections(
397 root: &Path,
398 real_index: &Path,
399 changes: &[InstallChange],
400) -> Result<(), String> {
401 let index_before: Option<Vec<u8>> = match read_index(real_index) {
402 Ok(found) => found,
403 Err(error) => {
404 return Err(install_failure(
405 format!("reading the index failed ({error}), so no file was changed.").as_str(),
406 ));
407 }
408 };
409 let replacements: Vec<Replacement> = prepare_replacements(root, changes)?;
410 return install_prepared(replacements.as_slice(), real_index, &index_before);
411}
412
413/// Installation,
414/// rollback and cleanup controls stay out of the release executable.
415#[cfg(test)]
416#[path = "direct_fix_install_tests.rs"]
417mod tests;