OXYGENHUB
◆ v0.2.0 oxygen / crates / oxy-core / src / repo / checkout.rs
24.2 KiB · Rust · last changed The operation log says what each operation did; op restore, op revert and --at-op; commands say where they left you (closes #51, closes #52, closes #53) 1 month ago source blame history raw
1
//! Moving the working copy to another state (spec §12.3, §7.7).
2
3
use super::*;
4
5
#[derive(Debug, Default, Clone, PartialEq, Eq)]
6
pub struct CheckoutReport {
7
    pub written: usize,
8
    pub removed: usize,
9
    pub conflicted: bool,
10
    /// Nested repositories that could not be moved, and other remarks.
11
    pub notes: Vec<String>,
12
    /// How many changes of the state left behind the new one lacks.
13
    pub lacks: usize,
14
}
15
16
/// Text files rendered at a time by a checkout: bounds its memory.
17
const CHECKOUT_BATCH: usize = 2048;
18
19
impl Repo {
20
    /// Move the working copy to the state whose heads are `heads` (spec §12.3):
21
    /// the working directory is rewritten to that state and a fresh
22
    /// working-copy change is started on it. The bookmark does not move.
23
    pub fn checkout(
24
        &self,
25
        heads: &[ChangeHash],
26
        discard: bool,
27
        command: &str,
28
    ) -> Result<CheckoutReport> {
29
        self.checkout_keeping(heads, discard, command, &BTreeSet::new())
30
    }
31
32
    /// [`Repo::checkout`], leaving the nested repositories at
33
    /// `keep_subrepos` as they are (their pins are carried work).
34
    pub(crate) fn checkout_keeping(
35
        &self,
36
        heads: &[ChangeHash],
37
        discard: bool,
38
        command: &str,
39
        keep_subrepos: &BTreeSet<String>,
40
    ) -> Result<CheckoutReport> {
41
        let _wc = self.lock_working_copy()?;
42
        let _all = crate::timing::phase("checkout");
43
        // A working copy clean by the manifest needs no snapshot; otherwise a
44
        // status snapshot records what changed, and rewrites the manifest
45
        // with the graph key of every file it proves unchanged. Either way
46
        // the manifest then says, for each file on disk, which graph it
47
        // renders, so the base state itself is not needed (oxygen#35).
48
        let changed_paths: Vec<String> = if self.clean_by_manifest()? {
49
            Vec::new()
50
        } else {
51
            let snap = self.snapshot_status(command)?;
52
            self.refuse_dirty(&snap, discard)?;
53
            snap.statuses.iter().map(|s| s.path.clone()).collect()
54
        };
55
        let (_, wc_now) = self.working_copy()?;
56
        let old_manifest = self.read_manifest()?.map(|(_, _, m)| m).unwrap_or_default();
57
        let changed: HashSet<&str> = changed_paths.iter().map(String::as_str).collect();
58
        // The target's history; its file graphs are built a batch at a time
59
        // for the files that must be written, and dropped after. It becomes
60
        // the working copy's base, which the state index holds.
61
        let mut target = self.load_state_indexed(heads)?;
62
        let heads = target.maximal(heads);
63
        let tv = crate::timing::phase("checkout: target view");
64
        let tview = target.tree().view();
65
        drop(tv);
66
        let mut report = CheckoutReport::default();
67
        // Paths this file system cannot hold side by side (spec §7.7).
68
        report.notes.extend(
69
            self.platform
70
                .problems(tview.entries.keys().map(String::as_str)),
71
        );
72
        report.conflicted = tview.is_conflicted();
73
        report.lacks = self.count_lacking(&wc_now.deps, &target)?;
74
75
        // Remove what the base or the working copy has and the target does
76
        // not. A nested repository is never deleted (spec §7.8).
77
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
78
        let gone: BTreeSet<&str> = old_manifest
79
            .entries
80
            .keys()
81
            .map(String::as_str)
82
            .chain(changed.iter().copied())
83
            .filter(|p| !tview.entries.contains_key(*p))
84
            .collect();
85
        for path in gone {
86
            let dir = self.root.join(path);
87
            if dir.join(OXY_DIR).exists() || dir.join(".git").exists() {
88
                report
89
                    .notes
90
                    .push(format!("{path}: nested repository left in place"));
91
                continue;
92
            }
93
            let full = self.safe_path(path)?;
94
            if fs::symlink_metadata(&full).is_err() {
95
                continue;
96
            }
97
            fs::remove_file(&full)?;
98
            report.removed += 1;
99
            if let Some(parent) = full.parent() {
100
                dirs_to_prune.insert(parent.to_path_buf());
101
            }
102
        }
103
104
        // The manifest after the checkout, built from what is known.
105
        let plan = crate::timing::phase("checkout: plan");
106
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
107
        let hex = |b: &[u8]| *blake3::hash(b).as_bytes();
108
        // Text files to render, attribute files first (the others need them).
109
        let mut texts: Vec<(String, NodeId, Mode)> = Vec::new();
110
        for (path, e) in tview.entries.iter() {
111
            if e.kind == NodeKind::Dir || !self.in_scope(path) {
112
                continue;
113
            }
114
            // The manifest entry of a file the working copy has not changed,
115
            // with the right executable bit.
116
            let exec_ok =
117
                |m: &prompt::ManifestEntry| !self.platform.exec_bits || m.exec == e.mode.exec;
118
            let entry = old_manifest
119
                .entries
120
                .get(path)
121
                .filter(|m| !changed.contains(path.as_str()) && exec_ok(m));
122
            if e.kind == NodeKind::File && !e.mode.opaque {
123
                // The manifest proves the file on disk equal to the rendering
124
                // of a graph whose key is the target's: nothing to read,
125
                // render or write.
126
                let key = target.graph_key(&e.node);
127
                let proven = |m: &&prompt::ManifestEntry| {
128
                    !is_attributes(path) && key.is_some() && m.graph == key
129
                };
130
                match entry.filter(proven) {
131
                    Some(m) => {
132
                        entries.insert(
133
                            path.clone(),
134
                            FsEntry {
135
                                content_hash: Some(m.hash),
136
                                graph_key: m.graph,
137
                                unchanged: true,
138
                                stat: Some((m.size, m.mtime_ns)),
139
                                ..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new())
140
                            },
141
                        );
142
                    }
143
                    None => texts.push((path.clone(), e.node, e.mode)),
144
                }
145
                continue;
146
            }
147
            // Opaque files, symlinks and subrepos: their blobs.
148
            let blobs = target.live_blobs(&e.node);
149
            if blobs.len() > 1 {
150
                report.conflicted = true;
151
            }
152
            let Some(blob) = blobs.first().copied() else {
153
                continue;
154
            };
155
            if e.kind == NodeKind::Subrepo {
156
                if keep_subrepos.contains(path) {
157
                    continue;
158
                }
159
                let pin = crate::subrepo::Pin::decode(&self.read_blob(&blob)?)?;
160
                let sync = self.sync_subrepo(path, &pin, false)?;
161
                if matches!(
162
                    sync,
163
                    SubrepoSync::Updated | SubrepoSync::KeptBookmark { .. }
164
                ) {
165
                    report.written += 1;
166
                }
167
                report.notes.extend(sync.note(path));
168
                continue;
169
            }
170
            if let Some(m) = entry
171
                .filter(|m| e.kind == NodeKind::File && blobs.len() == 1 && m.blob == Some(blob))
172
            {
173
                entries.insert(
174
                    path.clone(),
175
                    FsEntry {
176
                        content_hash: Some(m.hash),
177
                        blob: (e.kind == NodeKind::File).then_some(blob),
178
                        stat: Some((m.size, m.mtime_ns)),
179
                        ..FsEntry::new(e.kind, e.mode.exec, Vec::new())
180
                    },
181
                );
182
                continue;
183
            }
184
            let bytes = self.read_blob(&blob)?;
185
            let full = self.root.join(path);
186
            if e.kind == NodeKind::Symlink {
187
                let same = fs::read_link(&full)
188
                    .is_ok_and(|t| t.to_string_lossy().as_bytes() == bytes.as_slice());
189
                if !same {
190
                    self.write_symlink(path, &bytes)?;
191
                    report.written += 1;
192
                }
193
                entries.insert(
194
                    path.clone(),
195
                    FsEntry {
196
                        content_hash: Some(hex(&bytes)),
197
                        ..FsEntry::new(NodeKind::Symlink, false, Vec::new())
198
                    },
199
                );
200
            } else {
201
                if !self.same_on_disk(&full, &bytes, e.mode.exec) {
202
                    self.write_file(path, &bytes, e.mode.exec)?;
203
                    report.written += 1;
204
                }
205
                entries.insert(
206
                    path.clone(),
207
                    FsEntry {
208
                        content_hash: Some(hex(&bytes)),
209
                        blob: (blobs.len() == 1).then_some(blob),
210
                        ..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new())
211
                    },
212
                );
213
            }
214
            for (i, b) in blobs.iter().enumerate().skip(1) {
215
                self.write_file(
216
                    &format!("{path}.oxy-conflict-{}", i + 1),
217
                    &self.read_blob(b)?,
218
                    e.mode.exec,
219
                )?;
220
            }
221
        }
222
223
        drop(plan);
224
        let _render = crate::timing::phase("checkout: render and write");
225
        // Render and write text files a batch at a time; attribute files
226
        // come first, since the others' line endings and locks follow them.
227
        texts.sort_by_key(|(p, _, _)| !is_attributes(p));
228
        let keys: HashMap<NodeId, [u8; 32]> = texts
229
            .iter()
230
            .filter_map(|(_, n, _)| Some((*n, target.graph_key(n)?)))
231
            .collect();
232
        let mut attrs = crate::attributes::Attributes::default();
233
        let attr_count = texts.iter().filter(|(p, _, _)| is_attributes(p)).count();
234
        let mut done = 0usize;
235
        let mut batches: Vec<&[(String, NodeId, Mode)]> = Vec::new();
236
        if attr_count > 0 {
237
            batches.push(&texts[..attr_count]);
238
        }
239
        batches.extend(texts[attr_count..].chunks(CHECKOUT_BATCH));
240
        for batch in batches {
241
            use rayon::prelude::*;
242
            let nodes: Vec<NodeId> = batch.iter().map(|(_, n, _)| *n).collect();
243
            // A file added once and never edited is its text: no graph.
244
            let single: Vec<Option<crate::objects::TextRef>> =
245
                nodes.iter().map(|n| target.single_text(n)).collect();
246
            let graphs: Vec<NodeId> = nodes
247
                .iter()
248
                .zip(&single)
249
                .filter(|(_, s)| s.is_none())
250
                .map(|(n, _)| *n)
251
                .collect();
252
            let mut texts = {
253
                let _t = crate::timing::phase("checkout: read single texts");
254
                let wanted: Vec<crate::objects::TextRef> =
255
                    single.iter().flatten().copied().collect();
256
                self.single_texts(&wanted)?.into_iter()
257
            };
258
            {
259
                let _t = crate::timing::phase("checkout: build graphs");
260
                self.load_graphs_of(&mut target, &graphs)?;
261
            }
262
            let rendered: Vec<(Vec<u8>, bool)> = {
263
                let _t = crate::timing::phase("checkout: render");
264
                let mut out: Vec<Option<(Vec<u8>, bool)>> = single
265
                    .iter()
266
                    .map(|s| s.as_ref().and_then(|_| texts.next().map(|t| (t, false))))
267
                    .collect();
268
                let from_graphs: Vec<(Vec<u8>, bool)> = graphs
269
                    .par_iter()
270
                    .map(|n| {
271
                        let (bytes, m) = target.render_file(n);
272
                        (bytes, m.is_conflicted())
273
                    })
274
                    .collect();
275
                let mut from_graphs = from_graphs.into_iter();
276
                for o in out.iter_mut().filter(|o| o.is_none()) {
277
                    *o = from_graphs.next();
278
                }
279
                out.into_iter()
280
                    .map(|o| o.expect("every file rendered"))
281
                    .collect()
282
            };
283
            #[cfg(test)]
284
            {
285
                let singles: Vec<NodeId> = nodes
286
                    .iter()
287
                    .zip(&single)
288
                    .filter(|(_, s)| s.is_some())
289
                    .map(|(n, _)| *n)
290
                    .collect();
291
                self.load_graphs_of(&mut target, &singles)?;
292
                for (n, (bytes, _)) in nodes.iter().zip(&rendered) {
293
                    assert_eq!(
294
                        *bytes,
295
                        target.render_file(n).0,
296
                        "a single text is its file's rendering"
297
                    );
298
                }
299
            }
300
            target.drop_graphs(&nodes);
301
            let _t = crate::timing::phase("checkout: write");
302
            if done == 0 && attr_count > 0 {
303
                attrs = crate::attributes::Attributes::from_files(
304
                    batch
305
                        .iter()
306
                        .zip(&rendered)
307
                        .map(|((p, _, _), (b, _))| (p.as_str(), b.as_slice())),
308
                );
309
            }
310
            done += batch.len();
311
            let on_disk: Vec<Vec<u8>> = batch
312
                .iter()
313
                .zip(rendered.iter())
314
                .map(|((path, _, _), (bytes, _))| {
315
                    if attrs.lookup(path).eol == Some(crate::attributes::Eol::Crlf) {
316
                        crate::attributes::to_crlf(bytes)
317
                    } else {
318
                        bytes.clone()
319
                    }
320
                })
321
                .collect();
322
            // Each directory checked (no symlink on the way) and made once,
323
            // then the files compared and written on every core.
324
            let mut dirs: BTreeSet<&str> = BTreeSet::new();
325
            for (path, _, _) in batch {
326
                if let Some((dir, _)) = path.rsplit_once('/') {
327
                    dirs.insert(dir);
328
                }
329
            }
330
            for dir in dirs {
331
                let full = self.safe_path(dir)?;
332
                if !full.is_dir() {
333
                    if fs::symlink_metadata(&full).is_ok() {
334
                        remove_any(&full)?;
335
                    }
336
                    fs::create_dir_all(&full)?;
337
                }
338
            }
339
            for (path, _, _) in batch {
340
                check_path_name(path)?;
341
            }
342
            let (root, exec_bits) = (&self.root, self.platform.exec_bits);
343
            // Compared on every core; new files written on every core too,
344
            // but files that exist rewritten one at a time: on ext4,
345
            // rewriting in parallel was six times slower (each truncation
346
            // starts writing back the old blocks).
347
            let plans: Vec<Disk> = batch
348
                .par_iter()
349
                .zip(on_disk.par_iter())
350
                .map(|((path, _, mode), bytes)| {
351
                    compare_on_disk(&root.join(path), bytes, mode.exec, exec_bits)
352
                })
353
                .collect();
354
            let fresh: Vec<usize> = (0..batch.len())
355
                .filter(|&i| plans[i] == Disk::Absent)
356
                .collect();
357
            fresh
358
                .par_iter()
359
                .map(|&i| write_file_at(&root.join(&batch[i].0), &on_disk[i], batch[i].2.exec))
360
                .collect::<std::io::Result<()>>()?;
361
            for (i, plan) in plans.iter().enumerate() {
362
                if *plan == Disk::Differs {
363
                    write_file_at(&root.join(&batch[i].0), &on_disk[i], batch[i].2.exec)?;
364
                }
365
            }
366
            report.written += plans.iter().filter(|p| **p != Disk::Same).count();
367
            for (((path, node, mode), (_, conflicted)), on_disk) in
368
                batch.iter().zip(rendered).zip(on_disk)
369
            {
370
                let a = attrs.lookup(path);
371
                report.conflicted |= conflicted;
372
                entries.insert(
373
                    path.clone(),
374
                    FsEntry {
375
                        content_hash: Some(hex(&on_disk)),
376
                        graph_key: (!conflicted && a.eol.is_none())
377
                            .then(|| keys.get(node).copied())
378
                            .flatten(),
379
                        ..FsEntry::new(NodeKind::File, mode.exec, Vec::new())
380
                    },
381
                );
382
            }
383
        }
384
        // Lockable files are read-only until locked (spec §18), whether
385
        // written now or left as they were.
386
        for (path, e) in &entries {
387
            if e.kind == NodeKind::File && attrs.lookup(path).lockable && !self.is_locked(path) {
388
                let _ = set_readonly(&self.root.join(path), true);
389
            }
390
        }
391
        // Prune directories left empty, deepest first.
392
        for d in dirs_to_prune.iter().rev() {
393
            let mut d = d.clone();
394
            while d != self.root
395
                && fs::read_dir(&d)
396
                    .map(|mut i| i.next().is_none())
397
                    .unwrap_or(false)
398
            {
399
                fs::remove_dir(&d)?;
400
                let Some(p) = d.parent() else { break };
401
                d = p.to_path_buf();
402
            }
403
        }
404
405
        drop(_render);
406
        let _t = crate::timing::phase("checkout: record and manifest");
407
        let (op_ref, op) = self.head_op()?;
408
        let wc = self.new_working_copy(heads.clone(), "");
409
        let wc_hash = self.store.put_change(&wc)?;
410
        let mut view = op.view.clone();
411
        view.workspaces.insert(self.workspace, wc_hash);
412
        self.record_op(&[op_ref], command, view)?;
413
        self.write_manifest_entries(report.conflicted, false, &entries, &HashMap::new())?;
414
        let _ = self.run_hook(
415
            "post-checkout",
416
            &format!(
417
                "{{\"event\":\"post-checkout\",\"heads\":{},\"written\":{},\"removed\":{}}}",
418
                json_array(heads.iter().map(|h| json_str(&h.to_hex()))),
419
                report.written,
420
                report.removed
421
            ),
422
        );
423
        Ok(report)
424
    }
425
426
    /// How many changes of the state at `from` the state `to` lacks: a walk
427
    /// down from `from` that stops at what `to` holds.
428
    fn count_lacking(&self, from: &[ChangeHash], to: &State) -> Result<usize> {
429
        let mut seen: HashSet<ChangeHash> = HashSet::new();
430
        let mut stack: Vec<ChangeHash> = from.to_vec();
431
        while let Some(h) = stack.pop() {
432
            if to.contains(&h) || !seen.insert(h) {
433
                continue;
434
            }
435
            stack.extend(self.store.get_change_lazy(&h)?.0.deps);
436
        }
437
        Ok(seen.len())
438
    }
439
440
    /// Refuse to leave uncommitted work behind unless `discard`.
441
    pub(crate) fn refuse_dirty(&self, snap: &SnapshotResult, discard: bool) -> Result<()> {
442
        if discard || (snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty()) {
443
            return Ok(());
444
        }
445
        let list: Vec<String> = snap
446
            .statuses
447
            .iter()
448
            .take(8)
449
            .map(|s| format!("{} {}", status_word(s.status), s.path))
450
            .collect();
451
        fail(format!(
452
            "the working copy has changes ({}{}); commit them, take them along with --carry, or drop them with --discard",
453
            list.join(", "),
454
            if snap.statuses.len() > 8 { ", …" } else { "" }
455
        ))
456
    }
457
458
    /// Whether the file at `full` already holds `bytes` with this executable
459
    /// bit, so writing it again can be skipped.
460
    fn same_on_disk(&self, full: &Path, bytes: &[u8], exec: bool) -> bool {
461
        match fs::symlink_metadata(full) {
462
            Ok(m) if m.is_file() && m.len() == bytes.len() as u64 => {
463
                (!self.platform.exec_bits || is_executable(&m) == exec)
464
                    && fs::read(full).is_ok_and(|b| b == bytes)
465
            }
466
            _ => false,
467
        }
468
    }
469
470
    /// The absolute path for a repository path, refusing anything that would
471
    /// escape the workspace or write through a symlink (spec §7.7).
472
    fn safe_path(&self, rel: &str) -> Result<PathBuf> {
473
        let mut full = self.root.clone();
474
        let comps: Vec<&str> = rel.split('/').collect();
475
        for (i, comp) in comps.iter().enumerate() {
476
            if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') {
477
                return fail(format!("refusing unsafe path {rel:?}"));
478
            }
479
            if i == 0 && *comp == OXY_DIR {
480
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
481
            }
482
            if i + 1 < comps.len() {
483
                full.push(comp);
484
                if let Ok(m) = fs::symlink_metadata(&full) {
485
                    if m.file_type().is_symlink() {
486
                        return fail(format!(
487
                            "refusing to write through symlink {}",
488
                            full.display()
489
                        ));
490
                    }
491
                }
492
            } else {
493
                full.push(comp);
494
            }
495
        }
496
        Ok(full)
497
    }
498
499
    pub(crate) fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
500
        let full = self.safe_path(rel)?;
501
        if let Some(parent) = full.parent() {
502
            fs::create_dir_all(parent)?;
503
        }
504
        if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) {
505
            remove_any(&full)?;
506
        }
507
        if fs::metadata(&full).is_ok_and(|m| m.permissions().readonly()) {
508
            set_readonly(&full, false)?;
509
        }
510
        fs::write(&full, bytes)?;
511
        set_executable(&full, exec)?;
512
        Ok(())
513
    }
514
515
    pub(crate) fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
516
        let full = self.safe_path(rel)?;
517
        if let Some(parent) = full.parent() {
518
            fs::create_dir_all(parent)?;
519
        }
520
        if fs::symlink_metadata(&full).is_ok() {
521
            remove_any(&full)?;
522
        }
523
        #[cfg(unix)]
524
        {
525
            std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?;
526
            Ok(())
527
        }
528
        #[cfg(not(unix))]
529
        {
530
            // Spec §7.7: without symlink support, write the target as a plain file.
531
            fs::write(&full, target)?;
532
            Ok(())
533
        }
534
    }
535
}
536
537
/// How a refusal names a path's change: "removed" is a file of the working
538
/// copy's base that is missing on disk.
539
fn status_word(s: ChangeStatus) -> &'static str {
540
    match s {
541
        ChangeStatus::Added => "added",
542
        ChangeStatus::Modified => "modified",
543
        ChangeStatus::Removed => "removed",
544
        ChangeStatus::Renamed => "renamed",
545
        ChangeStatus::ModeChanged => "mode changed",
546
        ChangeStatus::ConflictEdited => "conflict edited",
547
        ChangeStatus::Resolved => "resolved",
548
    }
549
}
550
551
/// Refuse a path that could escape the workspace (spec §7.7): an empty,
552
/// `.` or `..` component, a backslash, or a first component `.oxy`.
553
fn check_path_name(rel: &str) -> Result<()> {
554
    for (i, comp) in rel.split('/').enumerate() {
555
        if comp.is_empty() || comp == "." || comp == ".." || comp.contains('\\') {
556
            return fail(format!("refusing unsafe path {rel:?}"));
557
        }
558
        if i == 0 && comp == OXY_DIR {
559
            return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
560
        }
561
    }
562
    Ok(())
563
}
564
565
/// A file on disk against what a checkout would write there.
566
#[derive(Clone, Copy, PartialEq, Eq)]
567
enum Disk {
568
    Same,
569
    Differs,
570
    Absent,
571
}
572
573
/// Whether the file at `full` already holds `bytes` with the right
574
/// executable bit. Touches no repository state, so it runs in parallel.
575
fn compare_on_disk(full: &Path, bytes: &[u8], exec: bool, exec_bits: bool) -> Disk {
576
    match fs::symlink_metadata(full) {
577
        Ok(m)
578
            if m.is_file()
579
                && m.len() == bytes.len() as u64
580
                && (!exec_bits || is_executable(&m) == exec)
581
                && fs::read(full).is_ok_and(|b| b == bytes) =>
582
        {
583
            Disk::Same
584
        }
585
        Ok(_) => Disk::Differs,
586
        Err(_) => Disk::Absent,
587
    }
588
}
589
590
/// Write a file whose directory exists, replacing whatever is there.
591
fn write_file_at(full: &Path, bytes: &[u8], exec: bool) -> std::io::Result<()> {
592
    match fs::symlink_metadata(full) {
593
        Ok(m) if m.is_file() => {
594
            if m.permissions().readonly() {
595
                set_readonly(full, false)?;
596
            }
597
        }
598
        Ok(_) => remove_any(full)?,
599
        Err(_) => {}
600
    }
601
    fs::write(full, bytes)?;
602
    set_executable(full, exec)
603
}