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