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;