Skip to main content

agents_tools/
filesystem.rs

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/// The kind of filesystem change made by [`apply_diff`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum FileChangeKind {
19    /// A previously absent path now contains a file.
20    Added,
21    /// An existing file's contents changed without changing its path.
22    Updated,
23    /// A previously existing file was removed.
24    Deleted,
25    /// A file was installed at a new path and removed from its previous path.
26    Moved,
27}
28
29/// A committed file change.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct AppliedFile {
32    /// The path after the change, relative to the supplied root.
33    pub path: PathBuf,
34    /// The previous path when the file was moved.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub previous_path: Option<PathBuf>,
37    /// The observable kind of committed change.
38    pub kind: FileChangeKind,
39}
40
41/// A logical file transaction that could not be applied.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct FailedFile {
44    /// The primary path named by the failed operation.
45    pub path: PathBuf,
46    /// Every path participating in the same atomic file transaction.
47    pub related_paths: Vec<PathBuf>,
48    /// A contextual description of why the logical file did not commit.
49    pub message: String,
50}
51
52/// The result of applying a multi-file patch.
53///
54/// Invalid patch envelopes return an [`ApplyDiffError`]. Failures involving an
55/// individual file are collected here so unrelated files can still commit.
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
57pub struct ApplyDiffReport {
58    /// Filesystem changes that committed successfully.
59    pub applied: Vec<AppliedFile>,
60    /// Logical files that remained unchanged because planning or commit failed.
61    pub failed: Vec<FailedFile>,
62}
63
64impl ApplyDiffReport {
65    /// Returns `true` when no logical file failed.
66    ///
67    /// A successful report can still have an empty [`applied`](Self::applied)
68    /// list when every requested update was a no-op.
69    pub fn is_success(&self) -> bool {
70        self.failed.is_empty()
71    }
72}
73
74/// Applies a complete patch to files beneath `root`.
75///
76/// The patch must use `*** Begin Patch` and `*** End Patch`, with one or more
77/// `*** Add File:`, `*** Update File:`, or `*** Delete File:` blocks. Update
78/// blocks may include `*** Move to:` immediately after their file header.
79///
80/// Each logical file is independent: failures do not prevent unrelated files
81/// from committing. All blocks connected through the same paths are first
82/// applied in memory and then committed together, so a failed file is never
83/// left partially edited.
84///
85/// Paths must be non-empty and relative, may not contain `.` or `..`, and may
86/// not traverse symbolic links. Existing files must be regular UTF-8 files.
87/// Missing parent directories for added or moved files are created as needed.
88///
89/// Updates are installed with atomic replacement on Linux, macOS, and Windows.
90/// Overlapping paths are serialized within this process. Before each commit,
91/// the current file is compared with the snapshot used for planning; an
92/// observed external modification becomes a [`FailedFile`] conflict.
93///
94/// # Errors
95///
96/// Returns [`ApplyDiffError`] before applying file operations when `root` is not
97/// an accessible directory, the patch envelope is malformed, an operation path
98/// is unsafe, or the envelope contains no operations. File-specific errors are
99/// returned in [`ApplyDiffReport::failed`] instead.
100///
101/// # Example
102///
103/// ```no_run
104/// use std::path::Path;
105/// use agents_tools::apply_diff;
106///
107/// # fn update_workspace(root: &Path) -> Result<(), agents_tools::ApplyDiffError> {
108/// let report = apply_diff(
109///     root,
110///     "*** Begin Patch\n*** Update File: settings.txt\n-old\n+new\n*** End Patch",
111/// )?;
112///
113/// for change in &report.applied {
114///     println!("{:?}: {}", change.kind, change.path.display());
115/// }
116/// for failure in &report.failed {
117///     eprintln!("{}: {}", failure.path.display(), failure.message);
118/// }
119/// # Ok(())
120/// # }
121/// ```
122pub 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, &current);
414    commit_group(root, &initial, &current)?;
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(&current) {
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 = &current_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(&current[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(&current) {
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(&current)
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    // A commit error can be reported after the replacement became visible
704    // (for example while synchronizing metadata). Mark it first so rollback
705    // inspects the actual state and restores only when our final bytes won.
706    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}