OXYGENHUB
# d3cd7ef95da6 oxygen / crates / oxy-core / src / repo / checkout.rs
18.0 KiB · Rust · last changed Switch without a snapshot when the working copy is clean 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, which neither reads nor renders files the
45
        // manifest proves unchanged, and whose base is history only
46
        // (oxygen#35).
47
        let (base, changed_paths) = if self.clean_by_manifest()? {
48
            let (_, wc) = self.working_copy()?;
49
            (self.load_state_history(&wc.deps)?, Vec::new())
50
        } else {
51
            let snap = self.snapshot_status(command)?;
52
            self.refuse_dirty(&snap, discard)?;
53
            let paths = snap.statuses.iter().map(|s| s.path.clone()).collect();
54
            (snap.base, paths)
55
        };
56
        let views = crate::timing::phase("checkout: manifest and base view");
57
        let old_manifest = self.read_manifest()?.map(|(_, _, m)| m).unwrap_or_default();
58
        let bview = base.tree().view();
59
        drop(views);
60
        let changed: HashSet<&str> = changed_paths.iter().map(String::as_str).collect();
61
        // The target's history; its file graphs are built a batch at a time
62
        // for the files that must be written, and dropped after.
63
        let mut target = self.load_state_history(heads)?;
64
        let heads = target.maximal(heads);
65
        let tv = crate::timing::phase("checkout: target view");
66
        let tview = target.tree().view();
67
        drop(tv);
68
        let mut report = CheckoutReport::default();
69
        // Paths this file system cannot hold side by side (spec §7.7).
70
        report.notes.extend(
71
            self.platform
72
                .problems(tview.entries.keys().map(String::as_str)),
73
        );
74
        report.conflicted = tview.is_conflicted();
75
        report.lacks = base.changes().filter(|(h, _)| !target.contains(h)).count();
76
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).
79
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
80
        let gone: BTreeSet<&str> = bview
81
            .entries
82
            .iter()
83
            .filter(|(_, f)| f.kind != NodeKind::Dir)
84
            .map(|(p, _)| p.as_str())
85
            .chain(changed.iter().copied())
86
            .filter(|p| !tview.entries.contains_key(*p))
87
            .collect();
88
        for path in gone {
89
            if bview
90
                .entries
91
                .get(path)
92
                .is_some_and(|f| f.kind == NodeKind::Subrepo)
93
            {
94
                report
95
                    .notes
96
                    .push(format!("{path}: nested repository left in place"));
97
                continue;
98
            }
99
            let full = self.safe_path(path)?;
100
            if fs::symlink_metadata(&full).is_err() {
101
                continue;
102
            }
103
            fs::remove_file(&full)?;
104
            report.removed += 1;
105
            if let Some(parent) = full.parent() {
106
                dirs_to_prune.insert(parent.to_path_buf());
107
            }
108
        }
109
110
        // The manifest after the checkout, built from what is known.
111
        let plan = crate::timing::phase("checkout: plan");
112
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
113
        let hex = |b: &[u8]| blake3::hash(b).to_hex().to_string();
114
        // Text files to render, attribute files first (the others need them).
115
        let mut texts: Vec<(String, NodeId, Mode)> = Vec::new();
116
        for (path, e) in &tview.entries {
117
            if e.kind == NodeKind::Dir || !self.in_scope(path) {
118
                continue;
119
            }
120
            let untouched = !changed.contains(path.as_str())
121
                && bview
122
                    .entries
123
                    .get(path)
124
                    .is_some_and(|b| b.node == e.node && b.kind == e.kind && b.mode == e.mode);
125
            if e.kind == NodeKind::File && !e.mode.opaque {
126
                // The manifest proves the file on disk equal to the base's
127
                // rendering (its key is the base graph's, and its stat was
128
                // checked just now), and the graph is the same in both
129
                // states: nothing to read, render or write.
130
                let key = base.graph_key(&e.node);
131
                let same_graph = untouched
132
                    && !is_attributes(path)
133
                    && key.is_some()
134
                    && key == target.graph_key(&e.node);
135
                let proven = |m: &&prompt::ManifestEntry| {
136
                    same_graph
137
                        && m.graph.as_deref() == key.map(|k| ObjectRef(k).to_hex()).as_deref()
138
                };
139
                match old_manifest.entries.get(path).filter(proven) {
140
                    Some(m) => {
141
                        entries.insert(
142
                            path.clone(),
143
                            FsEntry {
144
                                content_hash: Some(m.hash.clone()),
145
                                graph_key: m.graph.clone(),
146
                                unchanged: true,
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
            let same_blob = untouched && base.live_blobs(&e.node) == blobs;
179
            if let Some(m) = old_manifest
180
                .entries
181
                .get(path)
182
                .filter(|_| same_blob && blobs.len() == 1)
183
            {
184
                entries.insert(
185
                    path.clone(),
186
                    FsEntry {
187
                        content_hash: Some(m.hash.clone()),
188
                        blob: (e.kind == NodeKind::File).then_some(blob),
189
                        ..FsEntry::new(e.kind, e.mode.exec, Vec::new())
190
                    },
191
                );
192
                continue;
193
            }
194
            let bytes = self.read_blob(&blob)?;
195
            let full = self.root.join(path);
196
            if e.kind == NodeKind::Symlink {
197
                let same = fs::read_link(&full)
198
                    .is_ok_and(|t| t.to_string_lossy().as_bytes() == bytes.as_slice());
199
                if !same {
200
                    self.write_symlink(path, &bytes)?;
201
                    report.written += 1;
202
                }
203
                entries.insert(
204
                    path.clone(),
205
                    FsEntry {
206
                        content_hash: Some(hex(&bytes)),
207
                        ..FsEntry::new(NodeKind::Symlink, false, Vec::new())
208
                    },
209
                );
210
            } else {
211
                if !self.same_on_disk(&full, &bytes, e.mode.exec) {
212
                    self.write_file(path, &bytes, e.mode.exec)?;
213
                    report.written += 1;
214
                }
215
                entries.insert(
216
                    path.clone(),
217
                    FsEntry {
218
                        content_hash: Some(hex(&bytes)),
219
                        blob: (blobs.len() == 1).then_some(blob),
220
                        ..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new())
221
                    },
222
                );
223
            }
224
            for (i, b) in blobs.iter().enumerate().skip(1) {
225
                self.write_file(
226
                    &format!("{path}.oxy-conflict-{}", i + 1),
227
                    &self.read_blob(b)?,
228
                    e.mode.exec,
229
                )?;
230
            }
231
        }
232
233
        drop(plan);
234
        let _render = crate::timing::phase("checkout: render and write");
235
        // Render and write text files a batch at a time; attribute files
236
        // come first, since the others' line endings and locks follow them.
237
        texts.sort_by_key(|(p, _, _)| !is_attributes(p));
238
        let keys: HashMap<NodeId, [u8; 32]> = texts
239
            .iter()
240
            .filter_map(|(_, n, _)| Some((*n, target.graph_key(n)?)))
241
            .collect();
242
        let mut attrs = crate::attributes::Attributes::default();
243
        let attr_count = texts.iter().filter(|(p, _, _)| is_attributes(p)).count();
244
        let mut done = 0usize;
245
        let mut batches: Vec<&[(String, NodeId, Mode)]> = Vec::new();
246
        if attr_count > 0 {
247
            batches.push(&texts[..attr_count]);
248
        }
249
        batches.extend(texts[attr_count..].chunks(CHECKOUT_BATCH));
250
        for batch in batches {
251
            use rayon::prelude::*;
252
            let nodes: Vec<NodeId> = batch.iter().map(|(_, n, _)| *n).collect();
253
            self.load_graphs_of(&mut target, &nodes)?;
254
            let rendered: Vec<(Vec<u8>, bool)> = nodes
255
                .par_iter()
256
                .map(|n| {
257
                    let (bytes, m) = target.render_file(n);
258
                    (bytes, m.is_conflicted())
259
                })
260
                .collect();
261
            target.drop_graphs(&nodes);
262
            if done == 0 && attr_count > 0 {
263
                attrs = crate::attributes::Attributes::from_files(
264
                    batch
265
                        .iter()
266
                        .zip(&rendered)
267
                        .map(|((p, _, _), (b, _))| (p.as_str(), b.as_slice())),
268
                );
269
            }
270
            done += batch.len();
271
            for ((path, node, mode), (bytes, conflicted)) in batch.iter().zip(rendered) {
272
                let a = attrs.lookup(path);
273
                let crlf = a.eol == Some(crate::attributes::Eol::Crlf);
274
                let on_disk: Vec<u8> = if crlf {
275
                    crate::attributes::to_crlf(&bytes)
276
                } else {
277
                    bytes
278
                };
279
                if !self.same_on_disk(&self.root.join(path), &on_disk, mode.exec) {
280
                    self.write_file(path, &on_disk, mode.exec)?;
281
                    report.written += 1;
282
                }
283
                report.conflicted |= conflicted;
284
                entries.insert(
285
                    path.clone(),
286
                    FsEntry {
287
                        content_hash: Some(hex(&on_disk)),
288
                        graph_key: (!conflicted && a.eol.is_none())
289
                            .then(|| keys.get(node).map(|k| ObjectRef(*k).to_hex()))
290
                            .flatten(),
291
                        ..FsEntry::new(NodeKind::File, mode.exec, Vec::new())
292
                    },
293
                );
294
            }
295
        }
296
        // Lockable files are read-only until locked (spec §18), whether
297
        // written now or left as they were.
298
        for (path, e) in &entries {
299
            if e.kind == NodeKind::File && attrs.lookup(path).lockable && !self.is_locked(path) {
300
                let _ = set_readonly(&self.root.join(path), true);
301
            }
302
        }
303
        // Prune directories left empty, deepest first.
304
        for d in dirs_to_prune.iter().rev() {
305
            let mut d = d.clone();
306
            while d != self.root
307
                && fs::read_dir(&d)
308
                    .map(|mut i| i.next().is_none())
309
                    .unwrap_or(false)
310
            {
311
                fs::remove_dir(&d)?;
312
                let Some(p) = d.parent() else { break };
313
                d = p.to_path_buf();
314
            }
315
        }
316
317
        drop(_render);
318
        let _t = crate::timing::phase("checkout: record and manifest");
319
        let (op_ref, op) = self.head_op()?;
320
        let wc = self.new_working_copy(heads.clone(), "");
321
        let wc_hash = self.store.put_change(&wc)?;
322
        let mut view = op.view.clone();
323
        view.workspaces.insert(self.workspace, wc_hash);
324
        self.record_op(&[op_ref], command, view)?;
325
        self.write_manifest_entries(report.conflicted, false, &entries, &HashMap::new())?;
326
        let _ = self.run_hook(
327
            "post-checkout",
328
            &format!(
329
                "{{\"event\":\"post-checkout\",\"heads\":{},\"written\":{},\"removed\":{}}}",
330
                json_array(heads.iter().map(|h| json_str(&h.to_hex()))),
331
                report.written,
332
                report.removed
333
            ),
334
        );
335
        Ok(report)
336
    }
337
338
    /// Refuse to leave uncommitted work behind unless `discard`.
339
    fn refuse_dirty(&self, snap: &SnapshotResult, discard: bool) -> Result<()> {
340
        if discard || (snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty()) {
341
            return Ok(());
342
        }
343
        let list: Vec<String> = snap
344
            .statuses
345
            .iter()
346
            .take(8)
347
            .map(|s| format!("{} {}", status_word(s.status), s.path))
348
            .collect();
349
        fail(format!(
350
            "the working copy has changes ({}{}); commit them, take them along with --carry, or drop them with --discard",
351
            list.join(", "),
352
            if snap.statuses.len() > 8 { ", …" } else { "" }
353
        ))
354
    }
355
356
    /// Whether the file at `full` already holds `bytes` with this executable
357
    /// bit, so writing it again can be skipped.
358
    fn same_on_disk(&self, full: &Path, bytes: &[u8], exec: bool) -> bool {
359
        match fs::symlink_metadata(full) {
360
            Ok(m) if m.is_file() && m.len() == bytes.len() as u64 => {
361
                (!self.platform.exec_bits || is_executable(&m) == exec)
362
                    && fs::read(full).is_ok_and(|b| b == bytes)
363
            }
364
            _ => false,
365
        }
366
    }
367
368
    /// The absolute path for a repository path, refusing anything that would
369
    /// escape the workspace or write through a symlink (spec §7.7).
370
    fn safe_path(&self, rel: &str) -> Result<PathBuf> {
371
        let mut full = self.root.clone();
372
        let comps: Vec<&str> = rel.split('/').collect();
373
        for (i, comp) in comps.iter().enumerate() {
374
            if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') {
375
                return fail(format!("refusing unsafe path {rel:?}"));
376
            }
377
            if i == 0 && *comp == OXY_DIR {
378
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
379
            }
380
            if i + 1 < comps.len() {
381
                full.push(comp);
382
                if let Ok(m) = fs::symlink_metadata(&full) {
383
                    if m.file_type().is_symlink() {
384
                        return fail(format!(
385
                            "refusing to write through symlink {}",
386
                            full.display()
387
                        ));
388
                    }
389
                }
390
            } else {
391
                full.push(comp);
392
            }
393
        }
394
        Ok(full)
395
    }
396
397
    pub(crate) fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
398
        let full = self.safe_path(rel)?;
399
        if let Some(parent) = full.parent() {
400
            fs::create_dir_all(parent)?;
401
        }
402
        if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) {
403
            remove_any(&full)?;
404
        }
405
        if fs::metadata(&full).is_ok_and(|m| m.permissions().readonly()) {
406
            set_readonly(&full, false)?;
407
        }
408
        fs::write(&full, bytes)?;
409
        set_executable(&full, exec)?;
410
        Ok(())
411
    }
412
413
    pub(crate) fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
414
        let full = self.safe_path(rel)?;
415
        if let Some(parent) = full.parent() {
416
            fs::create_dir_all(parent)?;
417
        }
418
        if fs::symlink_metadata(&full).is_ok() {
419
            remove_any(&full)?;
420
        }
421
        #[cfg(unix)]
422
        {
423
            std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?;
424
            Ok(())
425
        }
426
        #[cfg(not(unix))]
427
        {
428
            // Spec §7.7: without symlink support, write the target as a plain file.
429
            fs::write(&full, target)?;
430
            Ok(())
431
        }
432
    }
433
}
434
435
/// How a refusal names a path's change: "removed" is a file of the working
436
/// copy's base that is missing on disk.
437
fn status_word(s: ChangeStatus) -> &'static str {
438
    match s {
439
        ChangeStatus::Added => "added",
440
        ChangeStatus::Modified => "modified",
441
        ChangeStatus::Removed => "removed",
442
        ChangeStatus::Renamed => "renamed",
443
        ChangeStatus::ModeChanged => "mode changed",
444
        ChangeStatus::ConflictEdited => "conflict edited",
445
        ChangeStatus::Resolved => "resolved",
446
    }
447
}