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