Skip to main content

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;