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 #[cfg(not(unix))]
228 {
229 // Only Unix records an executable bit; the mode decides nothing elsewhere.
230 let _ = mode;
231 }
232 return options.open(path)?.write_all(bytes);
233}
234
235/// What:
236/// Prepare every replacement:
237/// check the file still holds the bytes the fix read,
238/// copy it beside itself,
239/// and write the corrected bytes beside it.
240/// Why:
241/// Nothing in the worktree changes until every file is prepared,
242/// so a file that
243/// changed under the fix stops it before any file is replaced.
244///
245/// In TS you'd write (pseudocode):
246/// ```ts
247/// async function prepareReplacements(root, changes): Promise<PreparedReplacement[]>;
248/// ```
249pub fn prepare_replacements(
250 root: &Path,
251 changes: &[InstallChange],
252) -> Result<Vec<Replacement>, String> {
253 let mut replacements: Vec<Replacement> = Vec::new();
254 for change in changes {
255 let Some(relative) = path_from_git_bytes(change.path.as_slice()) else {
256 cleanup(replacements.as_slice());
257 return Err(install_failure(
258 "Git named a corrected file this system cannot represent, so no file was changed.",
259 ));
260 };
261 let destination: PathBuf = root.join(relative);
262 let current: Vec<u8> = match std::fs::read(destination.as_path()) {
263 Ok(bytes) => bytes,
264 Err(error) => {
265 cleanup(replacements.as_slice());
266 return Err(install_failure(
267 format!("reading a corrected file failed ({error}), so no file was changed.")
268 .as_str(),
269 ));
270 }
271 };
272 if current.as_slice() != &*change.original {
273 cleanup(replacements.as_slice());
274 return Err(install_failure(
275 "a selected file changed while cli-git fix ran, so no file was changed. Run cli-git fix again.",
276 ));
277 }
278 let parent: PathBuf = match destination.parent() {
279 Some(found) => found.to_path_buf(),
280 None => root.to_path_buf(),
281 };
282 let prefix: String = format!("{INSTALL_SIBLING_PREFIX}{}", unique_suffix());
283 let replacement: Replacement = Replacement {
284 prepared: parent.join(format!("{prefix}.new")),
285 backup: parent.join(format!("{prefix}.old")),
286 destination,
287 };
288 let written: std::io::Result<()> = match std::fs::copy(
289 replacement.destination.as_path(),
290 replacement.backup.as_path(),
291 ) {
292 Ok(_) => write_new(
293 replacement.prepared.as_path(),
294 &change.replacement,
295 change.mode,
296 ),
297 Err(error) => Err(error),
298 };
299 // Recorded before the failure check, so a failure still removes what was written.
300 replacements.push(replacement);
301 if let Err(error) = written {
302 cleanup(replacements.as_slice());
303 return Err(install_failure(
304 format!("writing a corrected file beside its original failed ({error}), so no file was changed.").as_str(),
305 ));
306 }
307 }
308 return Ok(replacements);
309}
310
311/// What:
312/// Rename every prepared file over its destination,
313/// then prove the real index still
314/// holds `index_before`.
315/// `&Option<Vec<u8>>` borrows the bytes read before,
316/// or absence.
317/// Why:
318/// The fix changes the worktree only.
319/// A rename that fails,
320/// an index that changed,
321/// or an index that cannot be read restores every file already replaced.
322///
323/// In TS you'd write (pseudocode):
324/// ```ts
325/// async function installPrepared(replacements, realIndexPath, indexBefore): Promise<void>;
326/// ```
327pub fn install_prepared(
328 replacements: &[Replacement],
329 real_index: &Path,
330 index_before: &Option<Vec<u8>>,
331) -> Result<(), String> {
332 let mut installed: usize = 0;
333 let mut failure: Option<String> = None;
334 for replacement in replacements {
335 if let Err(error) = std::fs::rename(
336 replacement.prepared.as_path(),
337 replacement.destination.as_path(),
338 ) {
339 failure = Some(format!("replacing a corrected file failed ({error})"));
340 break;
341 }
342 installed += 1;
343 }
344 if failure.is_none() {
345 failure = match read_index(real_index) {
346 Ok(after) if after == *index_before => None,
347 Ok(_) => Some(String::from("the index changed while cli-git fix ran")),
348 Err(error) => Some(format!("reading the index again failed ({error})")),
349 };
350 }
351 let Some(cause) = failure else {
352 cleanup(replacements);
353 return Ok(());
354 };
355 // `..installed` is the files already replaced, which the rollback restores.
356 let restored: Result<(), String> = rollback(&replacements[..installed]);
357 match restored {
358 Ok(()) => {
359 cleanup(replacements);
360 return Err(install_failure(
361 format!("{cause}, so every corrected file was restored. Run cli-git fix again.")
362 .as_str(),
363 ));
364 }
365 Err(restore_failure) => {
366 // Prepared files are removed; backups stay, because one of them is needed.
367 for replacement in replacements {
368 let _ = std::fs::remove_file(replacement.prepared.as_path());
369 }
370 return Err(install_failure(
371 format!("{cause}, and {restore_failure}.").as_str(),
372 ));
373 }
374 }
375}
376
377/// What:
378/// Install every change,
379/// or none:
380/// read the real index,
381/// prepare,
382/// then rename and
383/// verify.
384/// `Result<(), String>` is success or the failure message to report.
385/// Why:
386/// The whole protocol of the installed wrapper's `installDirectFix`,
387/// apart from the
388/// landing lock,
389/// which the native wrapper does not take.
390///
391/// In TS you'd write (pseudocode):
392/// ```ts
393/// async function installCorrections(root, realIndexPath, changes): Promise<void>;
394/// ```
395pub fn install_corrections(
396 root: &Path,
397 real_index: &Path,
398 changes: &[InstallChange],
399) -> Result<(), String> {
400 let index_before: Option<Vec<u8>> = match read_index(real_index) {
401 Ok(found) => found,
402 Err(error) => {
403 return Err(install_failure(
404 format!("reading the index failed ({error}), so no file was changed.").as_str(),
405 ));
406 }
407 };
408 let replacements: Vec<Replacement> = prepare_replacements(root, changes)?;
409 return install_prepared(replacements.as_slice(), real_index, &index_before);
410}
411
412/// Installation,
413/// rollback and cleanup controls stay out of the release executable.
414#[cfg(test)]
415#[path = "direct_fix_install_tests.rs"]
416mod tests;