Skip to main content

ephemeris_files/
apply.rs

1use std::io::Write as _;
2use std::path::Path;
3use std::{fs, process};
4
5use super::entry::FileEntry;
6use super::error::IoResultExt as _;
7use super::path::{RepositoryPath, ensure_no_symlink_parent, set_mode};
8use super::planner::{DirtyFiles, Operation, Plan, PlanOrigin, Precondition};
9use super::repository::Repository;
10use crate::{FilesError, PathError};
11
12impl Plan {
13    /// Apply this plan without touching the Git index.
14    ///
15    /// With [`DirtyFiles::Refuse`], a plan that touches a dirty path fails
16    /// before the first write.
17    ///
18    /// Every source is checked against its snapshot first, so a stale plan
19    /// fails before any write. Then all operations run in order. The plan is
20    /// not atomic: when an operation fails, the earlier operations stay
21    /// applied and the error names the failed path.
22    ///
23    /// # Errors
24    ///
25    /// Returns an error when foreign files are present, a dirty path is
26    /// refused, a precondition no longer holds, or a filesystem operation
27    /// cannot be completed safely.
28    pub fn apply(&self, dirty: DirtyFiles) -> Result<(), FilesError> {
29        if self.origin != PlanOrigin::Repository {
30            return Err(FilesError::SnapshotPlan);
31        }
32        if !self.foreign_paths().is_empty() {
33            return Err(FilesError::Foreign {
34                paths: self.foreign_paths().to_vec(),
35            });
36        }
37        if dirty == DirtyFiles::Refuse
38            && let Some(path) = self.dirty_paths().first()
39        {
40            return Err(FilesError::Unsafe { path: path.clone() });
41        }
42        let root = &self.root;
43        for precondition in &self.preconditions {
44            match precondition {
45                Precondition::Missing(relative) => {
46                    let path = root.join(relative.as_path());
47                    ensure_no_symlink_parent(root, &path)?;
48                    if path.symlink_metadata().is_ok() {
49                        return Err(FilesError::Stale {
50                            path: relative.clone(),
51                        });
52                    }
53                }
54                Precondition::Existing {
55                    path: relative,
56                    entry,
57                } => {
58                    if Repository::current_entry(root, relative)?.as_ref() != Some(entry) {
59                        return Err(FilesError::Stale {
60                            path: relative.clone(),
61                        });
62                    }
63                }
64            }
65        }
66        for operation in self.operations() {
67            operation.apply(root)?;
68        }
69        Ok(())
70    }
71
72    /// Stage every created or replaced file and unstage every deleted file.
73    ///
74    /// Call this after [`Plan::apply`] succeeded.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error when the repository cannot be opened or the index
79    /// cannot be updated.
80    pub fn stage(&self) -> Result<(), FilesError> {
81        Repository::open(&self.root)?.stage(self.operations())
82    }
83}
84impl Operation {
85    /// The repository-relative path this operation changes.
86    #[must_use]
87    pub const fn path(&self) -> &RepositoryPath {
88        match self {
89            Self::Create { path, entry: _ }
90            | Self::Replace { path, entry: _ }
91            | Self::Delete { path } => path,
92        }
93    }
94
95    fn apply(&self, root: &Path) -> Result<(), FilesError> {
96        let path = root.join(self.path().as_path());
97        ensure_no_symlink_parent(root, &path)?;
98        match self {
99            Self::Create { entry, path: _ } => {
100                if let Some(parent) = path.parent() {
101                    fs::create_dir_all(parent).at_path(parent)?;
102                }
103                entry.write_new(&path)
104            }
105            Self::Replace { entry, path: _ } => {
106                let parent = path
107                    .parent()
108                    .ok_or_else(|| PathError::Outside(path.clone()))?;
109                let name = path
110                    .file_name()
111                    .ok_or_else(|| PathError::Outside(path.clone()))?
112                    .to_string_lossy();
113                let temp = parent.join(format!(".{name}.ephemeris-{}", process::id()));
114                let result = (|| {
115                    entry.write_new(&temp)?;
116                    fs::rename(&temp, &path).at_path(&path)
117                })();
118                if result.is_err() {
119                    drop(fs::remove_file(&temp));
120                }
121                result
122            }
123            Self::Delete { path: _ } => fs::remove_file(&path).at_path(path),
124        }
125    }
126}
127
128impl FileEntry {
129    /// Write a new file at `path` with this content and mode.
130    fn write_new(&self, path: &Path) -> Result<(), FilesError> {
131        let mut file = fs::OpenOptions::new()
132            .write(true)
133            .create_new(true)
134            .open(path)
135            .at_path(path)?;
136        file.write_all(&self.content).at_path(path)?;
137        set_mode(path, self.mode)
138    }
139}
140
141#[cfg(test)]
142mod tests;