git_policy_cli/
conflict_sides.rs1use super::iso_time::format_iso_milliseconds;
21use super::private_storage::{create_private_directory, create_private_directory_all, sync_directory};
22use super::repo_read_index::IndexEntry;
23use std::collections::BTreeSet;
24use std::ffi::{OsStr, OsString};
25use std::io::{Read, Write};
26use std::os::unix::ffi::{OsStrExt, OsStringExt};
27use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
28use std::path::{Path, PathBuf};
29
30pub const SAVED_DIRECTORY: &str = "cli-git/discarded";
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum Operation {
36 Merge,
38 Rebase,
40 CherryPick,
42 Revert,
44}
45
46pub fn operation_in_progress(git_dir: &Path) -> Option<Operation> {
58 if git_dir.join("MERGE_HEAD").exists() {
59 return Some(Operation::Merge);
60 }
61 if git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply/rebasing").exists() {
62 return Some(Operation::Rebase);
63 }
64 if git_dir.join("CHERRY_PICK_HEAD").exists() {
65 return Some(Operation::CherryPick);
66 }
67 if git_dir.join("REVERT_HEAD").exists() {
68 return Some(Operation::Revert);
69 }
70 return None;
71}
72
73pub fn conflicted_paths(entries: &[IndexEntry]) -> BTreeSet<Vec<u8>> {
83 return entries.iter().filter(|entry| { return entry.stage != 0; }).map(|entry| { return entry.path.clone(); }).collect();
84}
85
86pub fn literal_path(prefix: &[u8], root: &Path, token: &OsStr) -> Option<Vec<u8>> {
98 let bytes: &[u8] = token.as_bytes();
99 if bytes.is_empty() || bytes[0] == b':' || bytes.ends_with(b"/") || bytes.iter().any(|byte| { return matches!(byte, b'*' | b'?' | b'[' | b'\\'); }) {
100 return None;
101 }
102 let joined: Vec<u8> = if bytes[0] == b'/' {
103 let below: &Path = Path::new(token).strip_prefix(root).ok()?;
104 below.as_os_str().as_bytes().to_vec()
105 } else {
106 [prefix, bytes].concat()
107 };
108 let mut kept: Vec<&[u8]> = Vec::new();
109 for segment in joined.split(|byte| { return *byte == b'/'; }) {
110 match segment {
111 b"" | b"." => {}
112 b".." => {
113 kept.pop()?;
114 }
115 other => kept.push(other),
116 }
117 }
118 if kept.is_empty() {
119 return None;
120 }
121 return Some(kept.join(&b'/'));
122}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
126pub enum SideDecision {
127 Allow(Vec<Vec<u8>>),
129 Refuse(Vec<OsString>),
131}
132
133pub fn decide_sides(tokens: &[OsString], prefix: &[u8], root: &Path, conflicted: &BTreeSet<Vec<u8>>) -> SideDecision {
144 let mut allowed: Vec<Vec<u8>> = Vec::new();
145 let mut others: Vec<OsString> = Vec::new();
146 for token in tokens {
147 match literal_path(prefix, root, token.as_os_str()) {
148 Some(path) if conflicted.contains(&path) => {
149 if !allowed.contains(&path) {
150 allowed.push(path);
151 }
152 }
153 _ => others.push(token.clone()),
154 }
155 }
156 if others.is_empty() {
157 return SideDecision::Allow(allowed);
158 }
159 return SideDecision::Refuse(others);
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct SaveFailure {
165 pub subject: String,
167 pub reason: String,
169}
170
171#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct SavedCopy {
174 pub path: String,
176 pub saved_to: Option<PathBuf>,
178}
179
180fn stamp(milliseconds: i64) -> String {
182 return format_iso_milliseconds(milliseconds).chars().take(19).map(|each| { return if each == ':' { '-' } else { each }; }).collect();
183}
184
185fn fresh_directory(saved_root: &Path, name: &str) -> std::io::Result<PathBuf> {
188 create_private_directory_all(saved_root)?;
189 for number in 0_u32..1000 {
190 let candidate: PathBuf = if number == 0 { saved_root.join(name) } else { saved_root.join(format!("{name}-{number}")) };
191 match create_private_directory(candidate.as_path()) {
192 Ok(()) => return Ok(candidate),
193 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
194 Err(error) => return Err(error),
195 }
196 }
197 return Err(std::io::Error::other("a thousand copies were saved in this second"));
198}
199
200fn copy_one(source: &Path, destination: &Path) -> std::io::Result<()> {
202 let metadata: std::fs::Metadata = std::fs::symlink_metadata(source)?;
203 if metadata.file_type().is_symlink() {
204 std::os::unix::fs::symlink(std::fs::read_link(source)?, destination)?;
205 return Ok(());
206 }
207 if !metadata.file_type().is_file() {
208 return Err(std::io::Error::other("it is neither a file nor a symbolic link"));
209 }
210 let mut from: std::fs::File = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(source)?;
211 let mut to: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(destination)?;
212 let written: std::io::Result<()> = (|| -> std::io::Result<()> {
213 let mut buffer: Vec<u8> = vec![0; 1 << 16];
214 loop {
215 let count: usize = from.read(buffer.as_mut_slice())?;
216 if count == 0 {
217 break;
218 }
219 to.write_all(&buffer[..count])?;
220 }
221 to.set_permissions(std::fs::Permissions::from_mode_bits(metadata.mode() & 0o777))?;
222 return to.sync_all();
223 })();
224 if let Err(error) = written {
225 let _removed = std::fs::remove_file(destination);
227 return Err(error);
228 }
229 return Ok(());
230}
231
232trait FromModeBits {
234 fn from_mode_bits(bits: u32) -> Self;
236}
237
238impl FromModeBits for std::fs::Permissions {
239 fn from_mode_bits(bits: u32) -> Self {
240 return std::os::unix::fs::PermissionsExt::from_mode(bits);
241 }
242}
243
244pub fn save_copies(root: &Path, common_dir: &Path, paths: &[Vec<u8>], milliseconds: i64) -> Result<Vec<SavedCopy>, SaveFailure> {
256 let saved_root: PathBuf = common_dir.join(SAVED_DIRECTORY);
257 let mut copies: Vec<SavedCopy> = Vec::new();
258 let mut directory: Option<PathBuf> = None;
259 let mut written_directories: BTreeSet<PathBuf> = BTreeSet::new();
260 for path in paths {
261 let shown: String = String::from_utf8_lossy(path).into_owned();
262 let source: PathBuf = root.join(OsString::from_vec(path.clone()));
263 match std::fs::symlink_metadata(source.as_path()) {
264 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
265 copies.push(SavedCopy { path: shown, saved_to: None });
266 continue;
267 }
268 Err(error) => return Err(SaveFailure { subject: shown, reason: error.to_string() }),
269 Ok(_) => {}
270 }
271 let base: PathBuf = match &directory {
272 Some(existing) => existing.clone(),
273 None => {
274 let created: PathBuf = fresh_directory(saved_root.as_path(), stamp(milliseconds).as_str())
275 .map_err(|error| { return SaveFailure { subject: saved_root.display().to_string(), reason: error.to_string() }; })?;
276 directory = Some(created.clone());
277 created
278 }
279 };
280 let destination: PathBuf = base.join(OsString::from_vec(path.clone()));
281 let failed = |error: std::io::Error| -> SaveFailure { return SaveFailure { subject: shown.clone(), reason: error.to_string() }; };
282 if let Some(parent) = destination.parent() {
283 create_private_directory_all(parent).map_err(failed)?;
284 let mut up: Option<&Path> = Some(parent);
285 while let Some(each) = up {
286 written_directories.insert(each.to_path_buf());
287 if each == base.as_path() {
288 break;
289 }
290 up = each.parent();
291 }
292 }
293 copy_one(source.as_path(), destination.as_path()).map_err(failed)?;
294 copies.push(SavedCopy { path: shown, saved_to: Some(destination) });
295 }
296 if directory.is_some() {
297 let mut ordered: Vec<&PathBuf> = written_directories.iter().collect();
299 ordered.sort_by_key(|each| { return std::cmp::Reverse(each.components().count()); });
300 for each in ordered {
301 sync_directory(each.as_path()).map_err(|error| { return SaveFailure { subject: each.display().to_string(), reason: error.to_string() }; })?;
302 }
303 let parent: PathBuf = common_dir.join("cli-git");
304 for each in [saved_root.as_path(), parent.as_path()] {
305 sync_directory(each).map_err(|error| { return SaveFailure { subject: each.display().to_string(), reason: error.to_string() }; })?;
306 }
307 }
308 return Ok(copies);
309}
310
311pub fn saved_notice(copies: &[SavedCopy]) -> String {
321 let mut text: String = String::new();
322 for copy in copies {
323 match ©.saved_to {
324 Some(place) => text.push_str(
325 format!(
326 "cli-git: saved your copy of {} to\n {}\n before this command replaces it ({} was conflicted).\n",
327 copy.path,
328 place.display(),
329 copy.path
330 )
331 .as_str(),
332 ),
333 None => text.push_str(
334 format!(
335 "cli-git: {} was conflicted and is deleted in your worktree, so there is no copy to save; this command brings a file back.\n",
336 copy.path
337 )
338 .as_str(),
339 ),
340 }
341 }
342 return text;
343}
344
345pub fn clean_paths_note(others: &[OsString], operation: Operation) -> String {
355 let named: Vec<String> = others.iter().map(|each| { return format!("`{}`", each.to_string_lossy()); }).collect();
356 let word: &str = match operation {
357 Operation::Merge => "merge",
358 Operation::Rebase => "rebase",
359 Operation::CherryPick => "cherry-pick",
360 Operation::Revert => "revert",
361 };
362 return format!(
363 " This {word} is in progress, and a command that names only conflicted files is let through after your copy is saved, \
364 but these are not conflicted files: {}. Leave them out, or name each conflicted file itself (no directory, wildcard or \
365 pathspec magic).",
366 named.join(", ")
367 );
368}
369
370#[cfg(test)]
372#[path = "conflict_sides_tests.rs"]
373mod tests;