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