OXYGENHUB
# e8d38e47e9d7 oxygen / crates / oxy-core / src / repo / checkout.rs
10.1 KiB · Rust · last changed Stat cache for text files: a stat-clean file whose recorded hash is the base's rendering takes the base's bytes instead of being read; edits are always read (spec 12.4) 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
}
13
14
impl Repo {
15
    /// Move the working copy to the state whose heads are `heads` (spec §12.3):
16
    /// the working directory is rewritten to that state and a fresh
17
    /// working-copy change is started on it. The bookmark does not move.
18
    pub fn checkout(
19
        &self,
20
        heads: &[ChangeHash],
21
        discard: bool,
22
        command: &str,
23
    ) -> Result<CheckoutReport> {
24
        let snap = self.snapshot(command)?;
25
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
26
            return fail("the working copy has changes; commit them first or pass --discard");
27
        }
28
        let target_state = self.load_state(heads)?;
29
        let heads = target_state.maximal(heads);
30
        let target = target_state.materialize();
31
        let attrs = Repo::state_attributes(&target);
32
        let current = self.walk_working_copy()?;
33
        let mut report = CheckoutReport::default();
34
        // Paths this file system cannot hold side by side (spec §7.7).
35
        report.notes.extend(
36
            self.platform
37
                .problems(target.files.keys().map(String::as_str)),
38
        );
39
40
        // Remove tracked paths that the target does not have. A nested
41
        // repository is never deleted (spec §7.8); it is left in place.
42
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
43
        for (path, entry) in &current {
44
            if !target.files.contains_key(path) {
45
                if entry.kind == NodeKind::Subrepo {
46
                    report
47
                        .notes
48
                        .push(format!("{path}: nested repository left in place"));
49
                    continue;
50
                }
51
                let full = self.safe_path(path)?;
52
                fs::remove_file(&full)?;
53
                report.removed += 1;
54
                if let Some(parent) = full.parent() {
55
                    dirs_to_prune.insert(parent.to_path_buf());
56
                }
57
            }
58
        }
59
        // Write target files that differ (only inside the sparse filter).
60
        for (path, f) in &target.files {
61
            if !self.in_scope(path) {
62
                continue;
63
            }
64
            match &f.content {
65
                FileContent::Dir => continue,
66
                FileContent::Text { bytes, .. } => {
67
                    let a = attrs.lookup(path);
68
                    let on_disk: std::borrow::Cow<[u8]> =
69
                        if a.eol == Some(crate::attributes::Eol::Crlf) {
70
                            crate::attributes::to_crlf(bytes).into()
71
                        } else {
72
                            bytes.as_slice().into()
73
                        };
74
                    let same = current.get(path).is_some_and(|c| {
75
                        c.kind == NodeKind::File
76
                            && c.content == *on_disk
77
                            && (!self.platform.exec_bits || c.exec == f.mode.exec)
78
                    });
79
                    if !same {
80
                        self.write_file(path, &on_disk, f.mode.exec)?;
81
                        report.written += 1;
82
                    }
83
                    if a.lockable && !self.is_locked(path) {
84
                        let _ = set_readonly(&self.root.join(path), true);
85
                    }
86
                }
87
                FileContent::Opaque { blobs } => {
88
                    let Some(blob) = blobs.first() else { continue };
89
                    let bytes = self.read_blob(blob)?;
90
                    if f.kind == NodeKind::Subrepo {
91
                        let pin = crate::subrepo::Pin::decode(&bytes)?;
92
                        match self.sync_subrepo(path, &pin, false)? {
93
                            SubrepoSync::Updated => report.written += 1,
94
                            SubrepoSync::UpToDate => {}
95
                            SubrepoSync::Missing => report.notes.push(format!("{path}: nested repository not present; run oxy submodule update --init")),
96
                            SubrepoSync::Dirty => report.notes.push(format!("{path}: nested repository has uncommitted changes; left as is")),
97
                            SubrepoSync::Failed(e) => report.notes.push(format!("{path}: {e}")),
98
                        }
99
                        continue;
100
                    }
101
                    if f.kind == NodeKind::Symlink {
102
                        let same = current
103
                            .get(path)
104
                            .is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
105
                        if !same {
106
                            self.write_symlink(path, &bytes)?;
107
                            report.written += 1;
108
                        }
109
                    } else {
110
                        let same = current.get(path).is_some_and(|c| {
111
                            c.kind == NodeKind::File
112
                                && c.content == bytes
113
                                && (!self.platform.exec_bits || c.exec == f.mode.exec)
114
                        });
115
                        if !same {
116
                            self.write_file(path, &bytes, f.mode.exec)?;
117
                            report.written += 1;
118
                        }
119
                        if attrs.lookup(path).lockable && !self.is_locked(path) {
120
                            let _ = set_readonly(&self.root.join(path), true);
121
                        }
122
                    }
123
                    if blobs.len() > 1 {
124
                        for (i, b) in blobs.iter().enumerate().skip(1) {
125
                            self.write_file(
126
                                &format!("{path}.oxy-conflict-{}", i + 1),
127
                                &self.read_blob(b)?,
128
                                f.mode.exec,
129
                            )?;
130
                        }
131
                    }
132
                }
133
            }
134
        }
135
        // Prune directories left empty, deepest first.
136
        for d in dirs_to_prune.iter().rev() {
137
            let mut d = d.clone();
138
            while d != self.root
139
                && fs::read_dir(&d)
140
                    .map(|mut i| i.next().is_none())
141
                    .unwrap_or(false)
142
            {
143
                fs::remove_dir(&d)?;
144
                let Some(p) = d.parent() else { break };
145
                d = p.to_path_buf();
146
            }
147
        }
148
149
        let (op_ref, op) = self.head_op()?;
150
        let wc = self.new_working_copy(heads.clone(), "");
151
        let wc_hash = self.store.put_change(&wc)?;
152
        let mut view = op.view.clone();
153
        view.workspaces.insert(self.workspace, wc_hash);
154
        self.record_op(&[op_ref], command, view)?;
155
        report.conflicted = target.is_conflicted();
156
        let blobs: HashMap<String, ObjectRef> = target
157
            .files
158
            .iter()
159
            .filter_map(|(p, f)| match &f.content {
160
                FileContent::Opaque { blobs } if f.kind == NodeKind::File && blobs.len() == 1 => {
161
                    Some((p.clone(), blobs[0]))
162
                }
163
                _ => None,
164
            })
165
            .collect();
166
        let entries = self.walk_working_copy_with(None, &Self::tracked_kinds(&target), None)?;
167
        self.write_manifest_entries(report.conflicted, false, &entries, &blobs)?;
168
        let _ = self.run_hook(
169
            "post-checkout",
170
            &format!(
171
                "{{\"event\":\"post-checkout\",\"heads\":{},\"written\":{},\"removed\":{}}}",
172
                json_array(heads.iter().map(|h| json_str(&h.to_hex()))),
173
                report.written,
174
                report.removed
175
            ),
176
        );
177
        Ok(report)
178
    }
179
180
    /// The absolute path for a repository path, refusing anything that would
181
    /// escape the workspace or write through a symlink (spec §7.7).
182
    fn safe_path(&self, rel: &str) -> Result<PathBuf> {
183
        let mut full = self.root.clone();
184
        let comps: Vec<&str> = rel.split('/').collect();
185
        for (i, comp) in comps.iter().enumerate() {
186
            if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') {
187
                return fail(format!("refusing unsafe path {rel:?}"));
188
            }
189
            if i == 0 && *comp == OXY_DIR {
190
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
191
            }
192
            if i + 1 < comps.len() {
193
                full.push(comp);
194
                if let Ok(m) = fs::symlink_metadata(&full) {
195
                    if m.file_type().is_symlink() {
196
                        return fail(format!(
197
                            "refusing to write through symlink {}",
198
                            full.display()
199
                        ));
200
                    }
201
                }
202
            } else {
203
                full.push(comp);
204
            }
205
        }
206
        Ok(full)
207
    }
208
209
    pub(crate) fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
210
        let full = self.safe_path(rel)?;
211
        if let Some(parent) = full.parent() {
212
            fs::create_dir_all(parent)?;
213
        }
214
        if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) {
215
            remove_any(&full)?;
216
        }
217
        if fs::metadata(&full).is_ok_and(|m| m.permissions().readonly()) {
218
            set_readonly(&full, false)?;
219
        }
220
        fs::write(&full, bytes)?;
221
        set_executable(&full, exec)?;
222
        Ok(())
223
    }
224
225
    pub(crate) fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
226
        let full = self.safe_path(rel)?;
227
        if let Some(parent) = full.parent() {
228
            fs::create_dir_all(parent)?;
229
        }
230
        if fs::symlink_metadata(&full).is_ok() {
231
            remove_any(&full)?;
232
        }
233
        #[cfg(unix)]
234
        {
235
            std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?;
236
            Ok(())
237
        }
238
        #[cfg(not(unix))]
239
        {
240
            // Spec §7.7: without symlink support, write the target as a plain file.
241
            fs::write(&full, target)?;
242
            Ok(())
243
        }
244
    }
245
}