Skip to main content

git_policy_cli/
worktree_copy_rollback.rs

1//! What:
2//!  Removal of the paths one failed installation created,
3//!  child first, and only where the filesystem still proves they are cli-git's own.
4//! Why:
5//!  A failed installation must not leave a partial worktree, but it also must never delete a path
6//!  the user or another process put there after cli-git created it.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! const retained = await rollbackCreated({ snapshot, destinationRoot, created });
11//! ```
12
13use super::worktree_copy_compare::{entry_matches, lstat_or_absent};
14use super::worktree_copy_error::WorktreeCopyError;
15use super::worktree_copy_install_entry::InstalledPath;
16use super::worktree_copy_manifest::{EntryKind, WorktreeCopyEntry};
17use super::worktree_copy_selection::filesystem_path;
18use std::collections::BTreeMap;
19use std::os::unix::fs::MetadataExt;
20use std::path::Path;
21
22/// What:
23///  Where a recorded path stands relative to the identity cli-git recorded for it.
24/// Why:
25///   Only an exact match may be removed; an absent path has nothing to remove and a changed one is someone else's.
26///
27/// In TS you'd write (pseudocode):
28/// ```ts
29/// type IdentityState = 'absent' | 'changed' | 'match';
30/// ```
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32enum IdentityState {
33    /// Nothing exists at the path.
34    Absent,
35    /// Something exists with a different device or inode.
36    Changed,
37    /// The object cli-git created is still there.
38    Match,
39}
40
41/// What:
42///  The current identity state of one recorded path, read without following links.
43/// Why:
44///   Checked immediately before each removal, so a path replaced during rollback is never removed.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// async function installedIdentityState({ path, installed }): Promise<'absent' | 'changed' | 'match'>;
49/// ```
50fn identity_state(
51    path: &Path,
52    installed: &InstalledPath,
53) -> Result<IdentityState, WorktreeCopyError> {
54    return match lstat_or_absent(path)? {
55        None => Ok(IdentityState::Absent),
56        Some(metadata) => {
57            if metadata.dev() == installed.device && metadata.ino() == installed.inode {
58                Ok(IdentityState::Match)
59            } else {
60                Ok(IdentityState::Changed)
61            }
62        }
63    };
64}
65
66/// What:
67///  The one removal of a proven path: a directory only when empty, a file or link by unlinking it.
68/// Why:
69///   A directory is removed with `rmdir`, which refuses a non-empty one, so user files inside it survive.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// await ((expected?.kind === 'directory') || (!installed.selected) ? rmdir(path) : unlink(path));
74/// ```
75fn remove_proven(path: &Path, is_directory: bool) -> std::io::Result<()> {
76    if is_directory {
77        return std::fs::remove_dir(path);
78    }
79    return std::fs::remove_file(path);
80}
81
82/// What:
83///  Removes every path the failed installation created that still carries cli-git's identity and content,
84///  and returns a diagnostic for each path it had to keep.
85/// Why:
86///   Returning the kept paths lets the user see exactly what remains, instead of a silent partial removal.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// async function rollbackCreated({ snapshot, destinationRoot, created }): Promise<readonly string[]>;
91/// ```
92pub fn rollback_created(
93    stage_root: &Path,
94    destination_root: &Path,
95    entries: &[WorktreeCopyEntry],
96    created: &[InstalledPath],
97) -> Result<Vec<String>, WorktreeCopyError> {
98    let selected_by_path: BTreeMap<&str, &WorktreeCopyEntry> = entries
99        .iter()
100        .map(|entry| return (entry.relative_path.as_str(), entry))
101        .collect();
102    let mut retained: Vec<String> = Vec::new();
103    for installed in created.iter().rev() {
104        let destination_path: std::path::PathBuf =
105            filesystem_path(destination_root, installed.relative_path.as_str())?;
106        let expected: Option<&&WorktreeCopyEntry> =
107            selected_by_path.get(installed.relative_path.as_str());
108        let outcome: Result<Option<String>, WorktreeCopyError> = rollback_one(
109            stage_root,
110            destination_root,
111            destination_path.as_path(),
112            installed,
113            expected.copied(),
114        );
115        match outcome {
116            Ok(None) => {}
117            Ok(Some(reason)) => retained.push(reason),
118            Err(error) => retained.push(format!("{}: {}", installed.relative_path, error.0)),
119        }
120    }
121    return Ok(retained);
122}
123
124/// What:
125///  The rollback decision for one created path.
126/// Why:
127///   Each proof is checked in order, and the result names the first one that failed.
128///
129/// In TS you'd write (pseudocode):
130/// ```ts
131/// // one iteration of the rollbackCreated loop
132/// ```
133fn rollback_one(
134    stage_root: &Path,
135    destination_root: &Path,
136    destination_path: &Path,
137    installed: &InstalledPath,
138    expected: Option<&WorktreeCopyEntry>,
139) -> Result<Option<String>, WorktreeCopyError> {
140    let initial: IdentityState = identity_state(destination_path, installed)?;
141    // `match` on the first identity state decides whether the path is ours to examine.
142    match initial {
143        IdentityState::Absent => return Ok(None),
144        IdentityState::Changed => {
145            return Ok(Some(format!(
146                "{}: filesystem identity changed",
147                installed.relative_path
148            )));
149        }
150        IdentityState::Match => {}
151    }
152    // A directory keeps its private installation mode until modes are applied, and `rmdir` removes it only when empty,
153    // so its identity is the whole proof; a file or link must still equal the stage.
154    let content_differs: bool = match expected {
155        Some(entry) if entry.kind != EntryKind::Directory => {
156            !entry_matches(stage_root, destination_root, entry)?
157        }
158        _ => false,
159    };
160    if content_differs {
161        return Ok(Some(installed.relative_path.clone()));
162    }
163    // The identity is read again immediately before removal, to close the race with a replacement.
164    let final_state: IdentityState = identity_state(destination_path, installed)?;
165    // `match` on the recheck: only an exact match proceeds; a changed path is named, an absent one is done.
166    match final_state {
167        IdentityState::Match => {}
168        IdentityState::Absent => return Ok(None),
169        IdentityState::Changed => {
170            return Ok(Some(format!(
171                "{}: filesystem identity changed",
172                installed.relative_path
173            )));
174        }
175    }
176    let is_directory: bool = expected
177        .is_some_and(|entry| return entry.kind == EntryKind::Directory)
178        || !installed.selected;
179    // The removal's own text is kept as the reason, as the incumbent reports it.
180    remove_proven(destination_path, is_directory)
181        .map_err(|error| return WorktreeCopyError(error.to_string()))?;
182    return Ok(None);
183}
184
185/// Rollback controls stay out of the release executable.
186#[cfg(test)]
187#[path = "worktree_copy_rollback_tests.rs"]
188mod tests;