Skip to main content

devset_core/
commit.rs

1//! Carrying out a [`Plan`]: every file, blob and record written atomically, state last.
2
3use alloc::collections::{BTreeMap, BTreeSet};
4use std::collections::HashSet;
5use std::fs::TryLockError;
6use std::io::{self, Write};
7
8use camino::Utf8Path;
9use camino_tempfile::Builder;
10use serde::Serialize;
11
12use crate::digest::Digest;
13use crate::errors::{Result, TargetError};
14use crate::name::ScaffoldId;
15use crate::path::RelPath;
16use crate::plan::{Action, Plan, Step};
17use crate::resolve::Resolved;
18use crate::rollback::{Location, Undo};
19use crate::target::{
20    ANSWERS, BASE, CONFIG, CONFLICTS, LOCK, Lock, Locked, PENDING, Record, STATE, Scaffold, State,
21    Target, read_optional,
22};
23use crate::tree::Tree;
24
25/// Held in `.devset/` while committing.
26const LOCKFILE: &str = ".lock";
27
28/// `.devset/.gitignore`: local work in progress.
29///
30/// Anchored, so it matches devset's own entries and nothing else.
31const GITIGNORE: &str = "/conflicts/\n/.lock\n";
32
33/// `.devset/.gitattributes`: keep blobs out of diffs, and away from end-of-line translation.
34const GITATTRIBUTES: &str = "base/** -diff -text\n";
35
36/// Carries out `plan` in `target`, returning its steps.
37///
38/// Everything is decided before anything is written; then files, blobs and `.devset/` metadata
39/// are replaced atomically, with `state.toml` last, so an interruption leaves a target that the
40/// next run repairs. A held plan writes only its conflicts, and the lock it waits to apply.
41///
42/// A commit that leaves conflicts first saves what it is about to change, and keeps every base,
43/// so the unfinished run can be taken back with [`Rollback`](crate::Rollback).
44///
45/// # Errors
46/// - [`TargetError::Busy`] or [`TargetError::Concurrent`], another devset is at work; nothing is
47///   written.
48/// - [`Error::Io`](crate::Error::Io), a write fails; the next run repairs what it left.
49pub fn commit(plan: Plan, target: &Target) -> Result<Vec<Step>> {
50    let Plan { resolved, scaffolds, steps, merged, gone, held } = plan;
51    let writes = Writes::decide(&resolved, scaffolds, &steps, &merged, &gone)?;
52    let _guard = lock(target)?;
53    writes.perform(target, &steps, held)?;
54    Ok(steps)
55}
56
57/// Everything a commit writes, decided before any of it is.
58struct Writes<'a> {
59    /// Target files and their new bytes.
60    files: Vec<(&'a RelPath, &'a [u8])>,
61    /// Target files to delete.
62    removals: Vec<&'a RelPath>,
63    /// Target files written executable.
64    executable: BTreeSet<&'a RelPath>,
65    /// Bases to store.
66    blobs: Vec<(Digest, &'a [u8])>,
67    /// Conflicted merges, for `.devset/conflicts/`.
68    sidecars: Vec<(&'a RelPath, &'a [u8])>,
69    /// `lock.toml`.
70    lock: String,
71    /// `state.toml`.
72    state: String,
73    /// `answers.toml`; `None` when nothing is declared.
74    answers: Option<String>,
75    /// Bases `state.toml` references.
76    live: HashSet<Digest>,
77}
78
79impl<'a> Writes<'a> {
80    /// What committing `steps` writes.
81    fn decide(
82        resolved: &'a Resolved, scaffolds: BTreeMap<ScaffoldId, Scaffold>, steps: &'a [Step],
83        merged: &'a Tree, gone: &'a BTreeSet<RelPath>,
84    ) -> Result<Self> {
85        let mut records = BTreeMap::new();
86        let (mut files, mut blobs, mut sidecars) = (Vec::new(), Vec::new(), Vec::new());
87        let mut conflicted = BTreeSet::new();
88        for step in steps {
89            let (entry, path) = (&step.entry, &step.entry.path);
90            let scope = entry.scope();
91            match (step.action, entry.want) {
92                (Action::Untrack | Action::Remove, _) => {},
93                (Action::Write | Action::Record | Action::Merge, Some(want)) => {
94                    let payload = resolved.payload(entry)?;
95                    // A written part, or a starter written with its parts, is in its composed
96                    // file, among the merged.
97                    if step.action == Action::Write
98                        && entry.part.is_none()
99                        && !merged.contains(path)
100                    {
101                        files.push((path, payload));
102                    }
103                    blobs.push((want.fingerprint.exact, payload));
104                    let (fingerprint, policy) = (want.fingerprint, want.policy);
105                    records.insert(entry.slot(), Record { scope, fingerprint, policy });
106                },
107                (action, _) => {
108                    if action == Action::Conflict {
109                        conflicted.insert(path);
110                    }
111                    let policy = entry.want.map(|want| want.policy).or(entry.recorded);
112                    let record = entry.record.map(|fingerprint| Record {
113                        scope,
114                        fingerprint,
115                        policy: policy.unwrap_or_default(),
116                    });
117                    records.extend(record.map(|record| (entry.slot(), record)));
118                },
119            }
120        }
121        for (path, bytes) in merged.iter() {
122            if conflicted.contains(path) {
123                sidecars.push((path, bytes));
124            } else {
125                files.push((path, bytes));
126            }
127        }
128        let locked = resolved.layers().iter().map(|layer| Locked {
129            name: layer.name().clone(),
130            rev: layer.rev().cloned(),
131            digest: layer.digest(),
132            source: layer.source().clone(),
133            features: Locked::features(layer.features()),
134        });
135        let lock = to_toml(&Lock::new(locked.collect()))?;
136        let answers = resolved.answers();
137        let answers = if answers.is_empty() { None } else { Some(to_toml(answers)?) };
138        let live = records.values().map(|record| record.fingerprint.exact).collect();
139        let state = to_toml(&State::new(records, scaffolds))?;
140        let removals = gone.iter().collect();
141        let executable = steps
142            .iter()
143            .filter(|step| step.entry.want.is_some_and(|want| want.executable))
144            .map(|step| &step.entry.path)
145            .collect();
146        Ok(Self { files, removals, executable, blobs, sidecars, lock, state, answers, live })
147    }
148
149    /// Writes everything, `state.toml` last; only conflicts and the pending lock if `held`.
150    fn perform(&self, target: &Target, steps: &[Step], held: bool) -> Result<()> {
151        let dir = target.dir();
152        let unfinished = !self.sidecars.is_empty();
153        if unfinished {
154            self.remember(target, steps, held)?;
155        }
156        for &(path, bytes) in &self.sidecars {
157            write(&target.sidecar(path), bytes)?;
158        }
159        write_if_changed(&dir.join(".gitignore"), GITIGNORE.as_bytes())?;
160        match &self.answers {
161            Some(answers) => write_if_changed(&dir.join(ANSWERS), answers.as_bytes())?,
162            None => remove_if_present(&dir.join(ANSWERS))?,
163        }
164        // The layers a command added or removed are the target's, withheld update or not.
165        write_if_changed(&dir.join(CONFIG), target.config_text().as_bytes())?;
166        if held {
167            return write(&target.unfinished().join(PENDING), self.lock.as_bytes());
168        }
169        for &(path, bytes) in &self.files {
170            let file = path.under(target.root());
171            write(&file, bytes)?;
172            if self.executable.contains(path) {
173                make_executable(&file)?;
174            }
175        }
176        for &path in &self.removals {
177            remove_with_empty_parents(target.root(), path)?;
178        }
179        for &(digest, bytes) in &self.blobs {
180            let blob = dir.join(BASE).join(digest.to_string());
181            if !blob.is_file() {
182                write(&blob, bytes)?;
183            }
184        }
185        write_if_changed(&dir.join(".gitattributes"), GITATTRIBUTES.as_bytes())?;
186        write_if_changed(&dir.join(LOCK), self.lock.as_bytes())?;
187        write_if_changed(&dir.join(STATE), self.state.as_bytes())?;
188
189        if unfinished {
190            remove_if_present(&target.unfinished().join(PENDING))?;
191            for step in steps.iter().filter(|s| s.entry.conflict && s.action != Action::Conflict) {
192                remove_if_present(&target.sidecar(&step.entry.path))?;
193            }
194            Ok(())
195        } else {
196            remove_if_present(&dir.join(CONFLICTS))?;
197            prune(target, &self.live)
198        }
199    }
200
201    /// Saves what `perform` changes before it writes, so a conflicted commit can be taken back.
202    fn remember(&self, target: &Target, steps: &[Step], held: bool) -> Result<()> {
203        let mut undo = Undo::load(target)?;
204        for &(path, bytes) in &self.sidecars {
205            undo.record(target, &Location::Sidecar(path.clone()), Some(bytes))?;
206        }
207        for step in steps.iter().filter(|s| s.entry.conflict && s.action != Action::Conflict) {
208            undo.record(target, &Location::Sidecar(step.entry.path.clone()), None)?;
209        }
210        let answers = self.answers.as_deref().map(str::as_bytes);
211        undo.record(target, &Location::Record(ANSWERS), answers)?;
212        let config = Some(target.config_text().as_bytes());
213        undo.record(target, &Location::Record(CONFIG), config)?;
214        if !held {
215            for &(path, bytes) in &self.files {
216                undo.record(target, &Location::File(path.clone()), Some(bytes))?;
217            }
218            for &path in &self.removals {
219                undo.record(target, &Location::File(path.clone()), None)?;
220            }
221            undo.record(target, &Location::Record(LOCK), Some(self.lock.as_bytes()))?;
222            undo.record(target, &Location::Record(STATE), Some(self.state.as_bytes()))?;
223        }
224        undo.save(target)
225    }
226}
227
228/// Removes every base in `.devset/base/` but those in `live`.
229pub(crate) fn prune(target: &Target, live: &HashSet<Digest>) -> Result<()> {
230    let base = target.dir().join(BASE);
231    if !base.is_dir() {
232        return Ok(());
233    }
234    for blob in fs_err::read_dir(&base)? {
235        let blob = blob?;
236        let digest = blob.file_name().to_str().and_then(|name| name.parse::<Digest>().ok());
237        if digest.is_some_and(|digest| !live.contains(&digest)) {
238            fs_err::remove_file(blob.path())?;
239        }
240    }
241    Ok(())
242}
243
244/// Takes `target`'s lock, and checks no other devset wrote `state.toml` since it was read.
245pub(crate) fn lock(target: &Target) -> Result<fs_err::File> {
246    let dir = target.dir();
247    fs_err::create_dir_all(dir.join(BASE))?;
248    let guard = fs_err::File::create(dir.join(LOCKFILE))?;
249    match guard.file().try_lock() {
250        Ok(()) => {},
251        Err(TryLockError::WouldBlock) => return Err(TargetError::Busy.into()),
252        Err(TryLockError::Error(e)) => return Err(e.into()),
253    }
254    if read_optional(&dir.join(STATE))?.as_deref().map(Digest::of) != target.state_digest() {
255        return Err(TargetError::Concurrent.into());
256    }
257    Ok(guard)
258}
259
260/// `value` as TOML.
261pub(crate) fn to_toml<T: Serialize + ?Sized>(value: &T) -> Result<String> {
262    Ok(toml::to_string(value).map_err(io::Error::other)?)
263}
264
265/// Replaces `path` with `bytes` atomically, creating parent directories: the bytes reach the disk
266/// in a file beside it, which takes its place in one rename. A file already there keeps its mode.
267pub(crate) fn write(path: &Utf8Path, bytes: &[u8]) -> Result<()> {
268    let dir =
269        path.parent().filter(|dir| !dir.as_str().is_empty()).unwrap_or_else(|| Utf8Path::new("."));
270    fs_err::create_dir_all(dir)?;
271    let with_path = |e: io::Error| io::Error::new(e.kind(), format!("{path}: {e}"));
272    let kept = fs_err::metadata(path).ok().map(|metadata| metadata.permissions());
273    let mut file = new_file(dir).map_err(with_path)?;
274    if let Some(permissions) = kept {
275        file.as_file().set_permissions(permissions).map_err(with_path)?;
276    }
277    file.write_all(bytes).map_err(with_path)?;
278    file.as_file().sync_all().map_err(with_path)?;
279    file.persist(path).map_err(|e| with_path(e.error))?;
280    sync_dir(dir)
281}
282
283/// A temporary file in `dir`, as readable as a new file the umask allows.
284#[cfg(unix)]
285fn new_file(dir: &Utf8Path) -> io::Result<camino_tempfile::NamedUtf8TempFile> {
286    use std::fs::Permissions;
287    use std::os::unix::fs::PermissionsExt as _;
288    Builder::new().permissions(Permissions::from_mode(0o666)).tempfile_in(dir)
289}
290
291/// A temporary file in `dir`.
292#[cfg(not(unix))]
293fn new_file(dir: &Utf8Path) -> io::Result<camino_tempfile::NamedUtf8TempFile> {
294    Builder::new().tempfile_in(dir)
295}
296
297/// Makes a rename in `dir` durable.
298#[cfg(unix)]
299fn sync_dir(dir: &Utf8Path) -> Result<()> {
300    Ok(fs_err::File::open(dir)?.sync_all()?)
301}
302
303/// A rename is durable once it returns here.
304#[cfg(not(unix))]
305fn sync_dir(_: &Utf8Path) -> Result<()> {
306    Ok(())
307}
308
309/// Gives the file at `path` mode 755.
310#[cfg(unix)]
311fn make_executable(path: &Utf8Path) -> Result<()> {
312    use std::fs::Permissions;
313    use std::os::unix::fs::PermissionsExt as _;
314    Ok(fs_err::set_permissions(path, Permissions::from_mode(0o755))?)
315}
316
317/// Files have no mode here.
318#[cfg(not(unix))]
319fn make_executable(_: &Utf8Path) -> Result<()> {
320    Ok(())
321}
322
323/// [`write`](write()), unless `path` already holds `bytes`.
324fn write_if_changed(path: &Utf8Path, bytes: &[u8]) -> Result<()> {
325    if read_optional(path)?.as_deref() == Some(bytes) { Ok(()) } else { write(path, bytes) }
326}
327
328/// Removes the file at `path` under `root`, then each directory it leaves empty, up to `root`.
329fn remove_with_empty_parents(root: &Utf8Path, path: &RelPath) -> Result<()> {
330    let file = path.under(root);
331    remove_if_present(&file)?;
332    let mut dir = file.parent();
333    while let Some(parent) = dir.filter(|parent| *parent != root && parent.starts_with(root)) {
334        if fs_err::read_dir(parent).map_or(true, |mut entries| entries.next().is_some()) {
335            break;
336        }
337        fs_err::remove_dir(parent)?;
338        dir = parent.parent();
339    }
340    Ok(())
341}
342
343/// Removes the file or directory tree at `path`, if there is one.
344pub(crate) fn remove_if_present(path: &Utf8Path) -> Result<()> {
345    let removed =
346        if path.is_dir() { fs_err::remove_dir_all(path) } else { fs_err::remove_file(path) };
347    match removed {
348        Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e.into()),
349        Ok(()) | Err(_) => Ok(()),
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::write;
356
357    #[test]
358    fn write_replaces_a_file_and_leaves_nothing_beside_it() {
359        let dir = camino_tempfile::tempdir().expect("a directory");
360        let path = dir.path().join("a/b.txt");
361        write(&path, b"one").expect("writes a new file, and its directory");
362        write(&path, b"two").expect("replaces it");
363        assert_eq!(fs_err::read(&path).expect("reads it"), b"two", "the new bytes");
364        let names: Vec<_> = fs_err::read_dir(dir.path().join("a"))
365            .expect("lists the directory")
366            .map(|entry| entry.expect("an entry").file_name())
367            .collect();
368        assert_eq!(names, ["b.txt"], "no temporary file is left");
369    }
370
371    #[cfg(unix)]
372    #[test]
373    fn write_keeps_the_mode_a_file_has() {
374        use std::fs::Permissions;
375        use std::os::unix::fs::PermissionsExt as _;
376
377        let dir = camino_tempfile::tempdir().expect("a directory");
378        let path = dir.path().join("run.sh");
379        write(&path, b"one").expect("writes a new file");
380        let mode =
381            |path| fs_err::metadata(path).expect("its metadata").permissions().mode() & 0o777;
382        assert_eq!(mode(&path) & 0o600, 0o600, "a new file is its owner's to read and write");
383        fs_err::set_permissions(&path, Permissions::from_mode(0o751)).expect("sets a mode");
384        write(&path, b"two").expect("replaces it");
385        assert_eq!(mode(&path), 0o751, "a replaced file keeps its mode");
386    }
387}