1use std::collections::{HashMap, HashSet};
2use std::fs::{self, Permissions};
3use std::io::Write;
4use std::path::{Component, Path, PathBuf};
5use std::sync::{Arc, Mutex, OnceLock, Weak};
6
7use anyhow::{Context, Result as AnyResult, bail};
8use atomic_write_file::AtomicWriteFile;
9use serde::Serialize;
10
11use crate::{ApplyDiffError, ApplyDiffMode, apply_diff_to_text, apply_diff_to_text_with_mode};
12
13static PATH_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum FileChangeKind {
19 Added,
21 Updated,
23 Deleted,
25 Moved,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct AppliedFile {
32 pub path: PathBuf,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub previous_path: Option<PathBuf>,
37 pub kind: FileChangeKind,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct FailedFile {
44 pub path: PathBuf,
46 pub related_paths: Vec<PathBuf>,
48 pub message: String,
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
57pub struct ApplyDiffReport {
58 pub applied: Vec<AppliedFile>,
60 pub failed: Vec<FailedFile>,
62}
63
64impl ApplyDiffReport {
65 pub fn is_success(&self) -> bool {
70 self.failed.is_empty()
71 }
72}
73
74pub fn apply_diff(root: impl AsRef<Path>, patch: &str) -> Result<ApplyDiffReport, ApplyDiffError> {
123 let root = root.as_ref();
124 let metadata = fs::metadata(root).map_err(|error| {
125 ApplyDiffError::new(format!(
126 "Cannot access patch root {}: {error}",
127 root.display()
128 ))
129 })?;
130 if !metadata.is_dir() {
131 return Err(ApplyDiffError::new(format!(
132 "Patch root is not a directory: {}",
133 root.display()
134 )));
135 }
136
137 let root = fs::canonicalize(root).map_err(|error| {
138 ApplyDiffError::new(format!(
139 "Cannot resolve patch root {}: {error}",
140 root.display()
141 ))
142 })?;
143 let operations = parse_patch(patch)?;
144 let groups = group_operations(operations);
145 let mut report = ApplyDiffReport::default();
146
147 for group in groups {
148 let locks = path_locks(&root, &group.paths);
149 let _guards: Vec<_> = locks
150 .iter()
151 .map(|lock| lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()))
152 .collect();
153 match apply_group(&root, &group) {
154 Ok(changes) => report.applied.extend(changes),
155 Err(error) => report.failed.push(FailedFile {
156 path: group.operations[0].source.clone(),
157 related_paths: sorted_paths(&group.paths),
158 message: format!("{error:#}"),
159 }),
160 }
161 }
162
163 Ok(report)
164}
165
166#[derive(Debug, Clone)]
167enum OperationKind {
168 Add,
169 Update { destination: Option<PathBuf> },
170 Delete,
171}
172
173#[derive(Debug, Clone)]
174struct Operation {
175 index: usize,
176 source: PathBuf,
177 body: String,
178 kind: OperationKind,
179}
180
181impl Operation {
182 fn paths(&self) -> Vec<PathBuf> {
183 let mut paths = vec![self.source.clone()];
184 if let OperationKind::Update {
185 destination: Some(destination),
186 } = &self.kind
187 {
188 paths.push(destination.clone());
189 }
190 paths
191 }
192}
193
194#[derive(Debug)]
195struct OperationGroup {
196 operations: Vec<Operation>,
197 paths: HashSet<PathBuf>,
198}
199
200fn parse_patch(patch: &str) -> Result<Vec<Operation>, ApplyDiffError> {
201 let normalized = patch.replace("\r\n", "\n").replace('\r', "\n");
202 let mut lines: Vec<&str> = normalized.lines().collect();
203 while lines.first().is_some_and(|line| line.trim().is_empty()) {
204 lines.remove(0);
205 }
206 while lines.last().is_some_and(|line| line.trim().is_empty()) {
207 lines.pop();
208 }
209
210 if lines.first().copied() != Some("*** Begin Patch") {
211 return Err(ApplyDiffError::new(
212 "Patch must start with `*** Begin Patch`",
213 ));
214 }
215 if lines.last().copied() != Some("*** End Patch") {
216 return Err(ApplyDiffError::new("Patch must end with `*** End Patch`"));
217 }
218
219 let mut operations = Vec::new();
220 let mut cursor = 1;
221 while cursor + 1 < lines.len() {
222 if lines[cursor].trim().is_empty() {
223 cursor += 1;
224 continue;
225 }
226
227 let header = lines[cursor];
228 let (kind, path_text) = if let Some(path) = header.strip_prefix("*** Add File: ") {
229 ("add", path)
230 } else if let Some(path) = header.strip_prefix("*** Update File: ") {
231 ("update", path)
232 } else if let Some(path) = header.strip_prefix("*** Delete File: ") {
233 ("delete", path)
234 } else {
235 return Err(ApplyDiffError::new(format!(
236 "Invalid patch header: {header}"
237 )));
238 };
239
240 let source = validate_relative_path(path_text)?;
241 cursor += 1;
242
243 let destination = if kind == "update" {
244 lines
245 .get(cursor)
246 .and_then(|line| {
247 line.strip_prefix("*** Move to: ")
248 .map(validate_relative_path)
249 })
250 .transpose()?
251 } else {
252 None
253 };
254 if destination.is_some() {
255 cursor += 1;
256 }
257
258 let body_start = cursor;
259 while cursor + 1 < lines.len() && !is_file_header(lines[cursor]) {
260 cursor += 1;
261 }
262 let body = lines[body_start..cursor].join("\n");
263
264 let kind = match kind {
265 "add" => OperationKind::Add,
266 "update" => OperationKind::Update { destination },
267 "delete" => {
268 if !body.trim().is_empty() {
269 return Err(ApplyDiffError::new(format!(
270 "Delete block for {} cannot contain a patch body",
271 source.display()
272 )));
273 }
274 OperationKind::Delete
275 }
276 _ => unreachable!(),
277 };
278
279 operations.push(Operation {
280 index: operations.len(),
281 source,
282 body,
283 kind,
284 });
285 }
286
287 if operations.is_empty() {
288 return Err(ApplyDiffError::new("Patch contains no file operations"));
289 }
290 Ok(operations)
291}
292
293fn is_file_header(line: &str) -> bool {
294 line.starts_with("*** Add File: ")
295 || line.starts_with("*** Update File: ")
296 || line.starts_with("*** Delete File: ")
297 || line == "*** End Patch"
298}
299
300fn validate_relative_path(path: &str) -> Result<PathBuf, ApplyDiffError> {
301 let path = Path::new(path.trim());
302 if path.as_os_str().is_empty() || path.is_absolute() {
303 return Err(ApplyDiffError::new(format!(
304 "Patch path must be a non-empty relative path: {}",
305 path.display()
306 )));
307 }
308 if path
309 .components()
310 .any(|component| !matches!(component, Component::Normal(_)))
311 {
312 return Err(ApplyDiffError::new(format!(
313 "Patch path contains an unsafe component: {}",
314 path.display()
315 )));
316 }
317 Ok(path.to_path_buf())
318}
319
320fn group_operations(operations: Vec<Operation>) -> Vec<OperationGroup> {
321 let mut groups: Vec<OperationGroup> = Vec::new();
322 for operation in operations {
323 let operation_paths: HashSet<PathBuf> = operation.paths().into_iter().collect();
324 let matching: Vec<usize> = groups
325 .iter()
326 .enumerate()
327 .filter_map(|(index, group)| {
328 (!group.paths.is_disjoint(&operation_paths)).then_some(index)
329 })
330 .collect();
331
332 if matching.is_empty() {
333 groups.push(OperationGroup {
334 operations: vec![operation],
335 paths: operation_paths,
336 });
337 continue;
338 }
339
340 let target = matching[0];
341 groups[target].operations.push(operation);
342 groups[target].paths.extend(operation_paths);
343 for index in matching.into_iter().skip(1).rev() {
344 let merged = groups.remove(index);
345 groups[target].operations.extend(merged.operations);
346 groups[target].paths.extend(merged.paths);
347 }
348 groups[target]
349 .operations
350 .sort_by_key(|operation| operation.index);
351 }
352 groups.sort_by_key(|group| group.operations[0].index);
353 groups
354}
355
356#[derive(Clone)]
357struct FileState {
358 text: String,
359 permissions: Option<Permissions>,
360}
361
362fn apply_group(root: &Path, group: &OperationGroup) -> AnyResult<Vec<AppliedFile>> {
363 let mut initial = HashMap::new();
364 for path in &group.paths {
365 initial.insert(path.clone(), read_file(root, path)?);
366 }
367 let mut current = initial.clone();
368
369 for operation in &group.operations {
370 match &operation.kind {
371 OperationKind::Add => {
372 if current.get(&operation.source).is_some_and(Option::is_some) {
373 bail!("File already exists: {}", operation.source.display());
374 }
375 let text = apply_diff_to_text_with_mode("", &operation.body, ApplyDiffMode::Create)
376 .map_err(anyhow::Error::new)?;
377 current.insert(
378 operation.source.clone(),
379 Some(FileState {
380 text,
381 permissions: None,
382 }),
383 );
384 }
385 OperationKind::Update { destination } => {
386 let Some(mut state) = current.get(&operation.source).cloned().flatten() else {
387 bail!("File does not exist: {}", operation.source.display());
388 };
389 state.text =
390 apply_diff_to_text(&state.text, &operation.body).map_err(anyhow::Error::new)?;
391
392 if let Some(destination) = destination {
393 if destination != &operation.source
394 && current.get(destination).is_some_and(Option::is_some)
395 {
396 bail!("Move destination already exists: {}", destination.display());
397 }
398 current.insert(operation.source.clone(), None);
399 current.insert(destination.clone(), Some(state));
400 } else {
401 current.insert(operation.source.clone(), Some(state));
402 }
403 }
404 OperationKind::Delete => {
405 if !current.get(&operation.source).is_some_and(Option::is_some) {
406 bail!("File does not exist: {}", operation.source.display());
407 }
408 current.insert(operation.source.clone(), None);
409 }
410 }
411 }
412
413 let changes = describe_changes(group, &initial, ¤t);
414 commit_group(root, &initial, ¤t)?;
415 Ok(changes)
416}
417
418fn read_file(root: &Path, relative: &Path) -> AnyResult<Option<FileState>> {
419 ensure_safe_components(root, relative)?;
420 let path = root.join(relative);
421 let metadata = match fs::symlink_metadata(&path) {
422 Ok(metadata) => metadata,
423 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
424 Err(error) => {
425 return Err(error).with_context(|| format!("Cannot inspect {}", relative.display()));
426 }
427 };
428 if metadata.file_type().is_symlink() {
429 bail!("Symbolic links are not patchable: {}", relative.display());
430 }
431 if !metadata.is_file() {
432 bail!("Path is not a regular file: {}", relative.display());
433 }
434 let bytes = fs::read(&path).with_context(|| format!("Cannot read {}", relative.display()))?;
435 let text = String::from_utf8(bytes)
436 .with_context(|| format!("File is not valid UTF-8: {}", relative.display()))?;
437 Ok(Some(FileState {
438 text,
439 permissions: Some(metadata.permissions()),
440 }))
441}
442
443fn ensure_safe_components(root: &Path, relative: &Path) -> AnyResult<()> {
444 let mut current = root.to_path_buf();
445 let component_count = relative.components().count();
446 for (index, component) in relative.components().enumerate() {
447 current.push(component.as_os_str());
448 let metadata = match fs::symlink_metadata(¤t) {
449 Ok(metadata) => metadata,
450 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
451 Err(error) => {
452 return Err(error)
453 .with_context(|| format!("Cannot inspect {}", relative.display()));
454 }
455 };
456 if metadata.file_type().is_symlink() {
457 bail!("Symbolic links are not patchable: {}", relative.display());
458 }
459 if index + 1 < component_count && !metadata.is_dir() {
460 bail!("Path component is not a directory: {}", relative.display());
461 }
462 }
463 Ok(())
464}
465
466fn describe_changes(
467 group: &OperationGroup,
468 initial: &HashMap<PathBuf, Option<FileState>>,
469 current: &HashMap<PathBuf, Option<FileState>>,
470) -> Vec<AppliedFile> {
471 let initial_present: Vec<_> = initial
472 .iter()
473 .filter_map(|(path, state)| state.is_some().then_some(path.clone()))
474 .collect();
475 let current_present: Vec<_> = current
476 .iter()
477 .filter_map(|(path, state)| state.is_some().then_some(path.clone()))
478 .collect();
479 let has_move = group.operations.iter().any(|operation| {
480 matches!(
481 operation.kind,
482 OperationKind::Update {
483 destination: Some(_)
484 }
485 )
486 });
487
488 if has_move && initial_present.len() == 1 && current_present.len() == 1 {
489 let previous = &initial_present[0];
490 let path = ¤t_present[0];
491 if previous != path {
492 return vec![AppliedFile {
493 path: path.clone(),
494 previous_path: Some(previous.clone()),
495 kind: FileChangeKind::Moved,
496 }];
497 }
498 }
499
500 let mut paths = sorted_paths(&group.paths);
501 let mut changes = Vec::new();
502 for path in paths.drain(..) {
503 match (
504 initial.get(&path).and_then(Option::as_ref),
505 current.get(&path).and_then(Option::as_ref),
506 ) {
507 (None, Some(_)) => changes.push(AppliedFile {
508 path,
509 previous_path: None,
510 kind: FileChangeKind::Added,
511 }),
512 (Some(_), None) => changes.push(AppliedFile {
513 path,
514 previous_path: None,
515 kind: FileChangeKind::Deleted,
516 }),
517 (Some(before), Some(after)) if !states_equal(before, after) => {
518 changes.push(AppliedFile {
519 path,
520 previous_path: None,
521 kind: FileChangeKind::Updated,
522 })
523 }
524 _ => {}
525 }
526 }
527 changes
528}
529
530fn states_equal(left: &FileState, right: &FileState) -> bool {
531 left.text == right.text && permissions_equal(&left.permissions, &right.permissions)
532}
533
534fn optional_states_equal(left: Option<&FileState>, right: Option<&FileState>) -> bool {
535 match (left, right) {
536 (None, None) => true,
537 (Some(expected), Some(actual)) => {
538 expected.text == actual.text
539 && (expected.permissions.is_none()
540 || permissions_equal(&expected.permissions, &actual.permissions))
541 }
542 _ => false,
543 }
544}
545
546#[cfg(unix)]
547fn permissions_equal(left: &Option<Permissions>, right: &Option<Permissions>) -> bool {
548 use std::os::unix::fs::PermissionsExt;
549 left.as_ref().map(PermissionsExt::mode) == right.as_ref().map(PermissionsExt::mode)
550}
551
552#[cfg(not(unix))]
553fn permissions_equal(left: &Option<Permissions>, right: &Option<Permissions>) -> bool {
554 left.as_ref().map(Permissions::readonly) == right.as_ref().map(Permissions::readonly)
555}
556
557struct PreparedChange {
558 relative: PathBuf,
559 full: PathBuf,
560 initial: Option<FileState>,
561 final_state: Option<FileState>,
562 writer: Option<AtomicWriteFile>,
563 committed: bool,
564}
565
566fn commit_group(
567 root: &Path,
568 initial: &HashMap<PathBuf, Option<FileState>>,
569 current: &HashMap<PathBuf, Option<FileState>>,
570) -> AnyResult<()> {
571 let mut changed_paths: Vec<PathBuf> = initial
572 .keys()
573 .filter(
574 |path| match (initial[*path].as_ref(), current[*path].as_ref()) {
575 (None, None) => false,
576 (Some(before), Some(after)) => !states_equal(before, after),
577 _ => true,
578 },
579 )
580 .cloned()
581 .collect();
582 changed_paths.sort_by(|left, right| {
583 current[left]
584 .is_none()
585 .cmp(¤t[right].is_none())
586 .then_with(|| left.cmp(right))
587 });
588 if changed_paths.is_empty() {
589 return Ok(());
590 }
591
592 let mut created_directories = Vec::new();
593 for relative in &changed_paths {
594 if current[relative].is_some()
595 && let Err(error) = create_parent_directories(root, relative, &mut created_directories)
596 {
597 remove_created_directories(&created_directories);
598 return Err(error);
599 }
600 }
601
602 let mut prepared = Vec::new();
603 for relative in changed_paths {
604 let full = root.join(&relative);
605 let writer = match current[&relative].as_ref() {
606 Some(state) => match prepare_atomic_write(&full, state) {
607 Ok(writer) => Some(writer),
608 Err(error) => {
609 drop(prepared);
610 remove_created_directories(&created_directories);
611 return Err(error)
612 .with_context(|| format!("Cannot prepare {}", relative.display()));
613 }
614 },
615 None => None,
616 };
617 prepared.push(PreparedChange {
618 relative: relative.clone(),
619 full,
620 initial: initial[&relative].clone(),
621 final_state: current[&relative].clone(),
622 writer,
623 committed: false,
624 });
625 }
626
627 for index in 0..prepared.len() {
628 if let Err(error) = verify_unchanged(
629 root,
630 &prepared[index].relative,
631 prepared[index].initial.as_ref(),
632 )
633 .and_then(|()| commit_one(&mut prepared[index]))
634 {
635 let rollback_errors = rollback_prepared(root, &mut prepared, &created_directories);
636 let mut error = error.context(format!(
637 "Cannot commit {}",
638 prepared[index].relative.display()
639 ));
640 if !rollback_errors.is_empty() {
641 error = error.context(format!(
642 "Rollback conflicts: {}",
643 rollback_errors.join("; ")
644 ));
645 }
646 return Err(error);
647 }
648 }
649 Ok(())
650}
651
652fn create_parent_directories(
653 root: &Path,
654 relative: &Path,
655 created: &mut Vec<PathBuf>,
656) -> AnyResult<()> {
657 let Some(parent) = relative.parent() else {
658 return Ok(());
659 };
660 let mut current = root.to_path_buf();
661 for component in parent.components() {
662 current.push(component.as_os_str());
663 match fs::symlink_metadata(¤t) {
664 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
665 Ok(_) => bail!("Unsafe parent directory: {}", relative.display()),
666 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
667 fs::create_dir(¤t)
668 .with_context(|| format!("Cannot create {}", current.display()))?;
669 created.push(current.clone());
670 }
671 Err(error) => {
672 return Err(error).with_context(|| format!("Cannot inspect {}", current.display()));
673 }
674 }
675 }
676 Ok(())
677}
678
679fn prepare_atomic_write(target: &Path, state: &FileState) -> AnyResult<AtomicWriteFile> {
680 let mut file = AtomicWriteFile::open(target)?;
681 file.write_all(state.text.as_bytes())?;
682 if let Some(permissions) = &state.permissions {
683 file.set_permissions(permissions.clone())?;
684 }
685 file.sync_all()?;
686 Ok(file)
687}
688
689fn verify_unchanged(root: &Path, relative: &Path, expected: Option<&FileState>) -> AnyResult<()> {
690 ensure_safe_components(root, relative)?;
691 let actual = read_file(root, relative)?;
692 let unchanged = optional_states_equal(expected, actual.as_ref());
693 if !unchanged {
694 bail!(
695 "File changed concurrently while the patch was being prepared: {}",
696 relative.display()
697 );
698 }
699 Ok(())
700}
701
702fn commit_one(change: &mut PreparedChange) -> AnyResult<()> {
703 change.committed = true;
707 match change.writer.take() {
708 Some(writer) => writer.commit()?,
709 None => remove_file_durably(&change.full)?,
710 }
711 Ok(())
712}
713
714fn remove_file_durably(path: &Path) -> std::io::Result<()> {
715 fs::remove_file(path)?;
716 sync_parent_directory(path)
717}
718
719#[cfg(unix)]
720fn sync_parent_directory(path: &Path) -> std::io::Result<()> {
721 use std::fs::File;
722
723 File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all()
724}
725
726#[cfg(not(unix))]
727fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
728 Ok(())
729}
730
731fn rollback_prepared(
732 root: &Path,
733 prepared: &mut [PreparedChange],
734 created_directories: &[PathBuf],
735) -> Vec<String> {
736 let mut errors = Vec::new();
737 for change in prepared.iter_mut().filter(|change| !change.committed) {
738 drop(change.writer.take());
739 }
740 for change in prepared.iter_mut().rev().filter(|change| change.committed) {
741 let actual = match read_file(root, &change.relative) {
742 Ok(actual) => actual,
743 Err(error) => {
744 errors.push(error.to_string());
745 continue;
746 }
747 };
748 if optional_states_equal(change.initial.as_ref(), actual.as_ref()) {
749 continue;
750 }
751 if !optional_states_equal(change.final_state.as_ref(), actual.as_ref()) {
752 errors.push(format!(
753 "{} changed concurrently during rollback",
754 change.relative.display()
755 ));
756 continue;
757 }
758 let result = match &change.initial {
759 Some(initial) => prepare_atomic_write(&change.full, initial)
760 .and_then(|writer| writer.commit().map_err(anyhow::Error::new)),
761 None => remove_file_durably(&change.full).map_err(anyhow::Error::new),
762 };
763 if let Err(error) = result {
764 errors.push(format!("{}: {error:#}", change.relative.display()));
765 }
766 }
767 remove_created_directories(created_directories);
768 errors
769}
770
771fn remove_created_directories(paths: &[PathBuf]) {
772 for path in paths.iter().rev() {
773 let _ = fs::remove_dir(path);
774 }
775}
776
777fn path_locks(root: &Path, paths: &HashSet<PathBuf>) -> Vec<Arc<Mutex<()>>> {
778 let registry = PATH_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
779 let mut registry = registry
780 .lock()
781 .unwrap_or_else(|poisoned| poisoned.into_inner());
782 registry.retain(|_, lock| lock.strong_count() > 0);
783
784 let mut keys: Vec<PathBuf> = paths.iter().map(|path| root.join(path)).collect();
785 keys.sort();
786 keys.into_iter()
787 .map(|key| {
788 if let Some(lock) = registry.get(&key).and_then(Weak::upgrade) {
789 return lock;
790 }
791 let lock = Arc::new(Mutex::new(()));
792 registry.insert(key, Arc::downgrade(&lock));
793 lock
794 })
795 .collect()
796}
797
798fn sorted_paths(paths: &HashSet<PathBuf>) -> Vec<PathBuf> {
799 let mut paths: Vec<_> = paths.iter().cloned().collect();
800 paths.sort();
801 paths
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 #[test]
809 fn connected_operations_are_grouped_transitively() {
810 let operations = vec![
811 Operation {
812 index: 0,
813 source: PathBuf::from("a"),
814 body: String::new(),
815 kind: OperationKind::Update {
816 destination: Some(PathBuf::from("b")),
817 },
818 },
819 Operation {
820 index: 1,
821 source: PathBuf::from("c"),
822 body: String::new(),
823 kind: OperationKind::Update {
824 destination: Some(PathBuf::from("d")),
825 },
826 },
827 Operation {
828 index: 2,
829 source: PathBuf::from("b"),
830 body: String::new(),
831 kind: OperationKind::Update {
832 destination: Some(PathBuf::from("c")),
833 },
834 },
835 ];
836 let groups = group_operations(operations);
837 assert_eq!(groups.len(), 1);
838 assert_eq!(groups[0].operations.len(), 3);
839 }
840
841 #[test]
842 fn unspecified_creation_permissions_match_the_committed_file() {
843 let expected = FileState {
844 text: "content".to_owned(),
845 permissions: None,
846 };
847 let actual = FileState {
848 text: "content".to_owned(),
849 permissions: Some(fs::metadata(".").unwrap().permissions()),
850 };
851
852 assert!(optional_states_equal(Some(&expected), Some(&actual)));
853 }
854
855 #[test]
856 fn rollback_removes_a_committed_add_with_os_assigned_permissions() {
857 let root = tempfile::tempdir().unwrap();
858 let relative = PathBuf::from("added.txt");
859 let full = root.path().join(&relative);
860 fs::write(&full, "content").unwrap();
861 let mut prepared = vec![PreparedChange {
862 relative,
863 full: full.clone(),
864 initial: None,
865 final_state: Some(FileState {
866 text: "content".to_owned(),
867 permissions: None,
868 }),
869 writer: None,
870 committed: true,
871 }];
872
873 let errors = rollback_prepared(root.path(), &mut prepared, &[]);
874
875 assert!(errors.is_empty(), "{errors:?}");
876 assert!(!full.exists());
877 }
878}