1use 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::path::RelPath;
15use crate::plan::{Action, Plan, Step};
16use crate::resolve::Resolved;
17use crate::rollback::{Location, Undo};
18use crate::target::{
19 ANSWERS, BASE, CONFIG, CONFLICTS, LOCK, Lock, Locked, PENDING, Record, STATE, State, Target,
20 read_optional,
21};
22use crate::tree::Tree;
23
24const LOCKFILE: &str = ".lock";
26
27const GITIGNORE: &str = "/conflicts/\n/.lock\n";
31
32const GITATTRIBUTES: &str = "base/** -diff -text\n";
34
35pub fn commit(plan: Plan, target: &Target) -> Result<Vec<Step>> {
49 let Plan { resolved, steps, merged, gone, held } = plan;
50 let writes = Writes::decide(&resolved, &steps, &merged, &gone)?;
51 let _guard = lock(target)?;
52 writes.perform(target, &steps, held)?;
53 Ok(steps)
54}
55
56struct Writes<'a> {
58 files: Vec<(&'a RelPath, &'a [u8])>,
60 removals: Vec<&'a RelPath>,
62 executable: BTreeSet<&'a RelPath>,
64 blobs: Vec<(Digest, &'a [u8])>,
66 sidecars: Vec<(&'a RelPath, &'a [u8])>,
68 lock: String,
70 state: String,
72 answers: Option<String>,
74 live: HashSet<Digest>,
76}
77
78impl<'a> Writes<'a> {
79 fn decide(
81 resolved: &'a Resolved, steps: &'a [Step], merged: &'a Tree, gone: &'a BTreeSet<RelPath>,
82 ) -> Result<Self> {
83 let mut records = BTreeMap::new();
84 let (mut files, mut blobs, mut sidecars) = (Vec::new(), Vec::new(), Vec::new());
85 let mut conflicted = BTreeSet::new();
86 for step in steps {
87 let (entry, path) = (&step.entry, &step.entry.path);
88 let scope = entry.scope();
89 match (step.action, entry.want) {
90 (Action::Untrack | Action::Remove, _) => {},
91 (Action::Write | Action::Record | Action::Merge, Some(want)) => {
92 let payload = resolved.payload(entry)?;
93 if step.action == Action::Write && entry.part.is_none() {
95 files.push((path, payload));
96 }
97 blobs.push((want.fingerprint.exact, payload));
98 let (fingerprint, policy) = (want.fingerprint, want.policy);
99 records.insert(entry.slot(), Record { scope, fingerprint, policy });
100 },
101 (action, _) => {
102 if action == Action::Conflict {
103 conflicted.insert(path);
104 }
105 let policy = entry.want.map(|want| want.policy).or(entry.recorded);
106 let record = entry.record.map(|fingerprint| Record {
107 scope,
108 fingerprint,
109 policy: policy.unwrap_or_default(),
110 });
111 records.extend(record.map(|record| (entry.slot(), record)));
112 },
113 }
114 }
115 for (path, bytes) in merged.iter() {
116 if conflicted.contains(path) {
117 sidecars.push((path, bytes));
118 } else {
119 files.push((path, bytes));
120 }
121 }
122 let locked = resolved.layers().iter().map(|layer| Locked {
123 name: Some(layer.meta().name.clone()),
124 rev: layer.rev().cloned(),
125 digest: layer.digest(),
126 source: layer.source().clone(),
127 });
128 let lock = to_toml(&Lock::new(locked.collect()))?;
129 let answers = resolved.answers();
130 let answers = if answers.is_empty() { None } else { Some(to_toml(answers)?) };
131 let live = records.values().map(|record| record.fingerprint.exact).collect();
132 let state = to_toml(&State::new(records))?;
133 let removals = gone.iter().collect();
134 let executable = steps
135 .iter()
136 .filter(|step| step.entry.want.is_some_and(|want| want.executable))
137 .map(|step| &step.entry.path)
138 .collect();
139 Ok(Self { files, removals, executable, blobs, sidecars, lock, state, answers, live })
140 }
141
142 fn perform(&self, target: &Target, steps: &[Step], held: bool) -> Result<()> {
144 let dir = target.dir();
145 let unfinished = !self.sidecars.is_empty();
146 if unfinished {
147 self.remember(target, steps, held)?;
148 }
149 for &(path, bytes) in &self.sidecars {
150 write(&target.sidecar(path), bytes)?;
151 }
152 write_if_changed(&dir.join(".gitignore"), GITIGNORE.as_bytes())?;
153 match &self.answers {
154 Some(answers) => write_if_changed(&dir.join(ANSWERS), answers.as_bytes())?,
155 None => remove_if_present(&dir.join(ANSWERS))?,
156 }
157 if held {
158 return write(&target.unfinished().join(PENDING), self.lock.as_bytes());
159 }
160 for &(path, bytes) in &self.files {
161 let file = path.under(target.root());
162 write(&file, bytes)?;
163 if self.executable.contains(path) {
164 make_executable(&file)?;
165 }
166 }
167 for &path in &self.removals {
168 remove_with_empty_parents(target.root(), path)?;
169 }
170 for &(digest, bytes) in &self.blobs {
171 let blob = dir.join(BASE).join(digest.to_string());
172 if !blob.is_file() {
173 write(&blob, bytes)?;
174 }
175 }
176 write_if_changed(&dir.join(".gitattributes"), GITATTRIBUTES.as_bytes())?;
177 write_if_changed(&dir.join(CONFIG), target.config_text().as_bytes())?;
178 write_if_changed(&dir.join(LOCK), self.lock.as_bytes())?;
179 write_if_changed(&dir.join(STATE), self.state.as_bytes())?;
180
181 if unfinished {
182 remove_if_present(&target.unfinished().join(PENDING))?;
183 for step in steps.iter().filter(|s| s.entry.conflict && s.action != Action::Conflict) {
184 remove_if_present(&target.sidecar(&step.entry.path))?;
185 }
186 Ok(())
187 } else {
188 remove_if_present(&dir.join(CONFLICTS))?;
189 prune(target, &self.live)
190 }
191 }
192
193 fn remember(&self, target: &Target, steps: &[Step], held: bool) -> Result<()> {
195 let mut undo = Undo::load(target)?;
196 for &(path, bytes) in &self.sidecars {
197 undo.record(target, &Location::Sidecar(path.clone()), Some(bytes))?;
198 }
199 for step in steps.iter().filter(|s| s.entry.conflict && s.action != Action::Conflict) {
200 undo.record(target, &Location::Sidecar(step.entry.path.clone()), None)?;
201 }
202 let answers = self.answers.as_deref().map(str::as_bytes);
203 undo.record(target, &Location::Record(ANSWERS), answers)?;
204 if !held {
205 for &(path, bytes) in &self.files {
206 undo.record(target, &Location::File(path.clone()), Some(bytes))?;
207 }
208 for &path in &self.removals {
209 undo.record(target, &Location::File(path.clone()), None)?;
210 }
211 let config = Some(target.config_text().as_bytes());
212 undo.record(target, &Location::Record(CONFIG), config)?;
213 undo.record(target, &Location::Record(LOCK), Some(self.lock.as_bytes()))?;
214 undo.record(target, &Location::Record(STATE), Some(self.state.as_bytes()))?;
215 }
216 undo.save(target)
217 }
218}
219
220pub(crate) fn prune(target: &Target, live: &HashSet<Digest>) -> Result<()> {
222 let base = target.dir().join(BASE);
223 if !base.is_dir() {
224 return Ok(());
225 }
226 for blob in fs_err::read_dir(&base)? {
227 let blob = blob?;
228 let digest = blob.file_name().to_str().and_then(|name| name.parse::<Digest>().ok());
229 if digest.is_some_and(|digest| !live.contains(&digest)) {
230 fs_err::remove_file(blob.path())?;
231 }
232 }
233 Ok(())
234}
235
236pub(crate) fn lock(target: &Target) -> Result<fs_err::File> {
238 let dir = target.dir();
239 fs_err::create_dir_all(dir.join(BASE))?;
240 let guard = fs_err::File::create(dir.join(LOCKFILE))?;
241 match guard.file().try_lock() {
242 Ok(()) => {},
243 Err(TryLockError::WouldBlock) => return Err(TargetError::Busy.into()),
244 Err(TryLockError::Error(e)) => return Err(e.into()),
245 }
246 if read_optional(&dir.join(STATE))?.as_deref().map(Digest::of) != target.state_digest() {
247 return Err(TargetError::Concurrent.into());
248 }
249 Ok(guard)
250}
251
252pub(crate) fn to_toml<T: Serialize + ?Sized>(value: &T) -> Result<String> {
254 Ok(toml::to_string(value).map_err(io::Error::other)?)
255}
256
257pub(crate) fn write(path: &Utf8Path, bytes: &[u8]) -> Result<()> {
260 let dir =
261 path.parent().filter(|dir| !dir.as_str().is_empty()).unwrap_or_else(|| Utf8Path::new("."));
262 fs_err::create_dir_all(dir)?;
263 let with_path = |e: io::Error| io::Error::new(e.kind(), format!("{path}: {e}"));
264 let kept = fs_err::metadata(path).ok().map(|metadata| metadata.permissions());
265 let mut file = new_file(dir).map_err(with_path)?;
266 if let Some(permissions) = kept {
267 file.as_file().set_permissions(permissions).map_err(with_path)?;
268 }
269 file.write_all(bytes).map_err(with_path)?;
270 file.as_file().sync_all().map_err(with_path)?;
271 file.persist(path).map_err(|e| with_path(e.error))?;
272 sync_dir(dir)
273}
274
275#[cfg(unix)]
277fn new_file(dir: &Utf8Path) -> io::Result<camino_tempfile::NamedUtf8TempFile> {
278 use std::fs::Permissions;
279 use std::os::unix::fs::PermissionsExt as _;
280 Builder::new().permissions(Permissions::from_mode(0o666)).tempfile_in(dir)
281}
282
283#[cfg(not(unix))]
285fn new_file(dir: &Utf8Path) -> io::Result<camino_tempfile::NamedUtf8TempFile> {
286 Builder::new().tempfile_in(dir)
287}
288
289#[cfg(unix)]
291fn sync_dir(dir: &Utf8Path) -> Result<()> {
292 Ok(fs_err::File::open(dir)?.sync_all()?)
293}
294
295#[cfg(not(unix))]
297fn sync_dir(_: &Utf8Path) -> Result<()> {
298 Ok(())
299}
300
301#[cfg(unix)]
303fn make_executable(path: &Utf8Path) -> Result<()> {
304 use std::fs::Permissions;
305 use std::os::unix::fs::PermissionsExt as _;
306 Ok(fs_err::set_permissions(path, Permissions::from_mode(0o755))?)
307}
308
309#[cfg(not(unix))]
311fn make_executable(_: &Utf8Path) -> Result<()> {
312 Ok(())
313}
314
315fn write_if_changed(path: &Utf8Path, bytes: &[u8]) -> Result<()> {
317 if read_optional(path)?.as_deref() == Some(bytes) { Ok(()) } else { write(path, bytes) }
318}
319
320fn remove_with_empty_parents(root: &Utf8Path, path: &RelPath) -> Result<()> {
322 let file = path.under(root);
323 remove_if_present(&file)?;
324 let mut dir = file.parent();
325 while let Some(parent) = dir.filter(|parent| *parent != root && parent.starts_with(root)) {
326 if fs_err::read_dir(parent).map_or(true, |mut entries| entries.next().is_some()) {
327 break;
328 }
329 fs_err::remove_dir(parent)?;
330 dir = parent.parent();
331 }
332 Ok(())
333}
334
335pub(crate) fn remove_if_present(path: &Utf8Path) -> Result<()> {
337 let removed =
338 if path.is_dir() { fs_err::remove_dir_all(path) } else { fs_err::remove_file(path) };
339 match removed {
340 Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e.into()),
341 Ok(()) | Err(_) => Ok(()),
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::write;
348
349 #[test]
350 fn write_replaces_a_file_and_leaves_nothing_beside_it() {
351 let dir = camino_tempfile::tempdir().expect("a directory");
352 let path = dir.path().join("a/b.txt");
353 write(&path, b"one").expect("writes a new file, and its directory");
354 write(&path, b"two").expect("replaces it");
355 assert_eq!(fs_err::read(&path).expect("reads it"), b"two", "the new bytes");
356 let names: Vec<_> = fs_err::read_dir(dir.path().join("a"))
357 .expect("lists the directory")
358 .map(|entry| entry.expect("an entry").file_name())
359 .collect();
360 assert_eq!(names, ["b.txt"], "no temporary file is left");
361 }
362
363 #[cfg(unix)]
364 #[test]
365 fn write_keeps_the_mode_a_file_has() {
366 use std::fs::Permissions;
367 use std::os::unix::fs::PermissionsExt as _;
368
369 let dir = camino_tempfile::tempdir().expect("a directory");
370 let path = dir.path().join("run.sh");
371 write(&path, b"one").expect("writes a new file");
372 let mode =
373 |path| fs_err::metadata(path).expect("its metadata").permissions().mode() & 0o777;
374 assert_eq!(mode(&path) & 0o600, 0o600, "a new file is its owner's to read and write");
375 fs_err::set_permissions(&path, Permissions::from_mode(0o751)).expect("sets a mode");
376 write(&path, b"two").expect("replaces it");
377 assert_eq!(mode(&path), 0o751, "a replaced file keeps its mode");
378 }
379}