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