OXYGENHUB
# 79d03f826ecd oxygen / crates / oxy-core / src / repo / checkout.rs
6.2 KiB · Rust · last changed Split repo.rs into repo/{config,snapshot,checkout,identity,sync,tests}; hermetic OXY_HOME in tests; identity recorded in user config 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
}
11
12
impl Repo {
13
    /// Move the working copy to the state whose heads are `heads` (spec §12.3):
14
    /// the working directory is rewritten to that state and a fresh
15
    /// working-copy change is started on it. The bookmark does not move.
16
    pub fn checkout(&self, heads: &[ChangeHash], discard: bool, command: &str) -> Result<CheckoutReport> {
17
        let snap = self.snapshot(command)?;
18
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
19
            return fail("the working copy has changes; commit them first or pass --discard");
20
        }
21
        let target_state = self.load_state(heads)?;
22
        let heads = target_state.maximal(heads);
23
        let target = target_state.materialize();
24
        let current = self.walk_working_copy()?;
25
        let mut report = CheckoutReport::default();
26
27
        // Remove tracked paths that the target does not have.
28
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
29
        for path in current.keys() {
30
            if !target.files.contains_key(path) {
31
                let full = self.safe_path(path)?;
32
                fs::remove_file(&full)?;
33
                report.removed += 1;
34
                if let Some(parent) = full.parent() {
35
                    dirs_to_prune.insert(parent.to_path_buf());
36
                }
37
            }
38
        }
39
        // Write target files that differ.
40
        for (path, f) in &target.files {
41
            match &f.content {
42
                FileContent::Dir => continue,
43
                FileContent::Text { bytes, .. } => {
44
                    let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && &c.content == bytes && c.exec == f.mode.exec);
45
                    if !same {
46
                        self.write_file(path, bytes, f.mode.exec)?;
47
                        report.written += 1;
48
                    }
49
                }
50
                FileContent::Opaque { blobs } => {
51
                    let Some(blob) = blobs.first() else { continue };
52
                    let bytes = self.read_blob(blob)?;
53
                    if f.kind == NodeKind::Symlink {
54
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
55
                        if !same {
56
                            self.write_symlink(path, &bytes)?;
57
                            report.written += 1;
58
                        }
59
                    } else {
60
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && c.content == bytes && c.exec == f.mode.exec);
61
                        if !same {
62
                            self.write_file(path, &bytes, f.mode.exec)?;
63
                            report.written += 1;
64
                        }
65
                    }
66
                    if blobs.len() > 1 {
67
                        for (i, b) in blobs.iter().enumerate().skip(1) {
68
                            self.write_file(&format!("{path}.oxy-conflict-{}", i + 1), &self.read_blob(b)?, f.mode.exec)?;
69
                        }
70
                    }
71
                }
72
            }
73
        }
74
        // Prune directories left empty, deepest first.
75
        for d in dirs_to_prune.iter().rev() {
76
            let mut d = d.clone();
77
            while d != self.root && fs::read_dir(&d).map(|mut i| i.next().is_none()).unwrap_or(false) {
78
                fs::remove_dir(&d)?;
79
                let Some(p) = d.parent() else { break };
80
                d = p.to_path_buf();
81
            }
82
        }
83
84
        let (op_ref, op) = self.head_op()?;
85
        let wc = self.new_working_copy(heads, "");
86
        let wc_hash = self.store.put_change(&wc)?;
87
        let mut view = op.view.clone();
88
        view.workspaces.insert(self.workspace, wc_hash);
89
        self.record_op(&[op_ref], command, view)?;
90
        report.conflicted = target.is_conflicted();
91
        Ok(report)
92
    }
93
94
    /// The absolute path for a repository path, refusing anything that would
95
    /// escape the workspace or write through a symlink (spec §7.7).
96
    fn safe_path(&self, rel: &str) -> Result<PathBuf> {
97
        let mut full = self.root.clone();
98
        let comps: Vec<&str> = rel.split('/').collect();
99
        for (i, comp) in comps.iter().enumerate() {
100
            if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') {
101
                return fail(format!("refusing unsafe path {rel:?}"));
102
            }
103
            if i == 0 && *comp == OXY_DIR {
104
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
105
            }
106
            if i + 1 < comps.len() {
107
                full.push(comp);
108
                if let Ok(m) = fs::symlink_metadata(&full) {
109
                    if m.file_type().is_symlink() {
110
                        return fail(format!("refusing to write through symlink {}", full.display()));
111
                    }
112
                }
113
            } else {
114
                full.push(comp);
115
            }
116
        }
117
        Ok(full)
118
    }
119
120
    fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
121
        let full = self.safe_path(rel)?;
122
        if let Some(parent) = full.parent() {
123
            fs::create_dir_all(parent)?;
124
        }
125
        if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) {
126
            remove_any(&full)?;
127
        }
128
        fs::write(&full, bytes)?;
129
        set_executable(&full, exec)?;
130
        Ok(())
131
    }
132
133
    fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
134
        let full = self.safe_path(rel)?;
135
        if let Some(parent) = full.parent() {
136
            fs::create_dir_all(parent)?;
137
        }
138
        if fs::symlink_metadata(&full).is_ok() {
139
            remove_any(&full)?;
140
        }
141
        #[cfg(unix)]
142
        {
143
            std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?;
144
            Ok(())
145
        }
146
        #[cfg(not(unix))]
147
        {
148
            // Spec §7.7: without symlink support, write the target as a plain file.
149
            fs::write(&full, target)?;
150
            Ok(())
151
        }
152
    }
153
}