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