OXYGENHUB
# 02e691881a4e oxygen / crates / oxy-core / src / repo / bookmarks.rs
12.6 KiB · Rust · last changed Merges move clean working copies; checkouts say what blocks them 1 month ago source blame history raw
1
//! Bookmarks (spec §10.1): named states, any number per repository, with a
2
//! current bookmark per workspace. Merging is the union of two bookmarks'
3
//! heads: no merge change, conflicts reified.
4
5
use super::*;
6
7
/// One bookmark as `oxy bookmark list` shows it.
8
#[derive(Clone, Debug)]
9
pub struct BookmarkInfo {
10
    pub name: String,
11
    pub heads: Vec<ChangeHash>,
12
    /// The workspace's current bookmark.
13
    pub current: bool,
14
    /// Changes ahead of / behind `origin`'s copy, when tracked.
15
    pub ahead: Option<usize>,
16
    pub behind: Option<usize>,
17
}
18
19
#[derive(Clone, Debug)]
20
pub struct MergeReport {
21
    pub heads: Vec<ChangeHash>,
22
    pub changed: bool,
23
    pub checked_out: bool,
24
}
25
26
/// Bookmark and tag names (spec §10.1): UTF-8, 1–255 bytes, no control
27
/// characters, no leading `-`, and none of `..`, `//`, `@{`.
28
pub fn valid_reference_name(name: &str) -> bool {
29
    !name.is_empty()
30
        && name.len() <= 255
31
        && !name.starts_with('-')
32
        && !name.chars().any(|c| c.is_control())
33
        && !name.contains("..")
34
        && !name.contains("//")
35
        && !name.contains("@{")
36
        && name != "policy"
37
}
38
39
impl Repo {
40
    fn workspace_bookmark_path(&self) -> PathBuf {
41
        self.oxy
42
            .join("workspaces")
43
            .join(self.workspace.to_hex())
44
            .join("bookmark")
45
    }
46
47
    /// Every bookmark with its heads and remote-tracking difference.
48
    pub fn bookmarks(&self) -> Result<Vec<BookmarkInfo>> {
49
        let view = self.view()?;
50
        let mut out = Vec::new();
51
        for (name, st) in &view.bookmarks {
52
            let heads = self.store.get_state(st)?.heads;
53
            let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(name)) {
54
                Some(rs) => {
55
                    let remote_heads = self.store.get_state(rs)?.heads;
56
                    (
57
                        self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(),
58
                        self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok(),
59
                    )
60
                }
61
                None => (None, None),
62
            };
63
            out.push(BookmarkInfo {
64
                name: name.clone(),
65
                heads,
66
                current: *name == self.config.bookmark,
67
                ahead,
68
                behind,
69
            });
70
        }
71
        Ok(out)
72
    }
73
74
    /// Create `name` at `heads` (default: the working copy's base heads).
75
    pub fn bookmark_create(
76
        &self,
77
        name: &str,
78
        heads: Option<&[ChangeHash]>,
79
    ) -> Result<Vec<ChangeHash>> {
80
        if !valid_reference_name(name) {
81
            return fail(format!("{name:?} is not a valid bookmark name"));
82
        }
83
        let (op_ref, op) = self.head_op()?;
84
        if op.view.bookmarks.contains_key(name) {
85
            return fail(format!("bookmark {name} already exists"));
86
        }
87
        let heads = match heads {
88
            Some(h) => self.load_state(h)?.maximal(h),
89
            None => {
90
                let (_, wc) = self.working_copy()?;
91
                self.load_state(&wc.deps)?.maximal(&wc.deps)
92
            }
93
        };
94
        let mut view = op.view.clone();
95
        view.bookmarks.insert(
96
            name.to_string(),
97
            self.store.put_state(&StateObject::new(heads.clone()))?,
98
        );
99
        self.record_op(&[op_ref], &format!("oxy bookmark create {name}"), view)?;
100
        Ok(heads)
101
    }
102
103
    /// Point an existing bookmark at `heads`.
104
    pub fn bookmark_set(&self, name: &str, heads: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
105
        let (op_ref, op) = self.head_op()?;
106
        if !op.view.bookmarks.contains_key(name) {
107
            return fail(format!(
108
                "no bookmark {name}; create it with `oxy bookmark create`"
109
            ));
110
        }
111
        let heads = self.load_state(heads)?.maximal(heads);
112
        let mut view = op.view.clone();
113
        view.bookmarks.insert(
114
            name.to_string(),
115
            self.store.put_state(&StateObject::new(heads.clone()))?,
116
        );
117
        self.record_op(&[op_ref], &format!("oxy bookmark set {name}"), view)?;
118
        if name == self.config.bookmark {
119
            self.drop_manifest();
120
        }
121
        Ok(heads)
122
    }
123
124
    /// Remove a bookmark; its changes stay until unreachable (spec §21).
125
    pub fn bookmark_delete(&self, name: &str) -> Result<()> {
126
        if name == self.config.bookmark {
127
            return fail(format!(
128
                "{name} is this workspace's current bookmark; switch to another first"
129
            ));
130
        }
131
        for w in self.workspaces()? {
132
            if !w.current && self.workspace_bookmark_of(&w.id).as_deref() == Some(name) {
133
                return fail(format!(
134
                    "{name} is the current bookmark of workspace {} at {}",
135
                    w.id.short(8),
136
                    w.path.display()
137
                ));
138
            }
139
        }
140
        let (op_ref, op) = self.head_op()?;
141
        let mut view = op.view.clone();
142
        if view.bookmarks.remove(name).is_none() {
143
            return fail(format!("no bookmark {name}"));
144
        }
145
        self.record_op(&[op_ref], &format!("oxy bookmark delete {name}"), view)?;
146
        Ok(())
147
    }
148
149
    fn workspace_bookmark_of(&self, id: &WorkspaceId) -> Option<String> {
150
        fs::read_to_string(
151
            self.oxy
152
                .join("workspaces")
153
                .join(id.to_hex())
154
                .join("bookmark"),
155
        )
156
        .ok()
157
        .map(|s| s.trim().to_string())
158
        .filter(|s| !s.is_empty())
159
    }
160
161
    /// Make `name` this workspace's current bookmark and check it out.
162
    pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result<CheckoutReport> {
163
        let view = self.view()?;
164
        let Some(st) = view.bookmarks.get(name) else {
165
            return fail(format!("no bookmark {name}"));
166
        };
167
        let heads = self.store.get_state(st)?.heads;
168
        let report = self.checkout(&heads, discard, &format!("oxy bookmark switch {name}"))?;
169
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
170
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
171
        self.config.bookmark = name.to_string();
172
        Ok(report)
173
    }
174
175
    /// Switch to `name` taking uncommitted work along (spec §10.1), as `git
176
    /// switch` does: see [`Repo::checkout_carry`]. Returns the checkout
177
    /// report and the carried paths.
178
    pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> {
179
        let view = self.view()?;
180
        let Some(st) = view.bookmarks.get(name) else {
181
            return fail(format!("no bookmark {name}"));
182
        };
183
        let heads = self.store.get_state(st)?.heads;
184
        let out =
185
            self.checkout_carry(&heads, name, &format!("oxy bookmark switch {name} --carry"))?;
186
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
187
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
188
        self.config.bookmark = name.to_string();
189
        self.snapshot(&format!("oxy bookmark switch {name} --carry"))?;
190
        Ok(out)
191
    }
192
193
    /// Check out `heads` taking uncommitted work along: every path the
194
    /// working copy changes must be the same in the target state as in the
195
    /// working copy's base, so carrying the files over applies exactly the
196
    /// same edits there. Otherwise nothing happens and the error names the
197
    /// paths that differ (`target` names the state in it). Returns the
198
    /// checkout report and the carried paths.
199
    pub fn checkout_carry(
200
        &self,
201
        heads: &[ChangeHash],
202
        target: &str,
203
        command: &str,
204
    ) -> Result<(CheckoutReport, Vec<String>)> {
205
        let snap = self.snapshot(command)?;
206
        // Every path the work touches: changed ones, and base files it
207
        // removed or renamed away (a rename reports only its new path).
208
        let mut touched: BTreeSet<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
209
        for (p, f) in &snap.base_snapshot.files {
210
            if f.kind != NodeKind::Dir
211
                && f.kind != NodeKind::Subrepo
212
                && fs::symlink_metadata(self.root.join(p)).is_err()
213
            {
214
                touched.insert(p.clone());
215
            }
216
        }
217
        let target_state = self.load_state(heads)?.materialize();
218
        let same = |p: &String| {
219
            let a = snap.base_snapshot.files.get(p);
220
            let b = target_state.files.get(p);
221
            match (a, b) {
222
                (None, None) => true,
223
                (Some(a), Some(b)) => {
224
                    a.kind == b.kind && a.mode == b.mode && a.content == b.content
225
                }
226
                _ => false,
227
            }
228
        };
229
        let differ: Vec<&String> = touched.iter().filter(|p| !same(p)).collect();
230
        if !differ.is_empty() {
231
            let list: Vec<&str> = differ.iter().take(10).map(|p| p.as_str()).collect();
232
            return fail(format!(
233
                "{target} has other versions of {} you changed ({}{}); commit first, or drop the changes with --discard",
234
                if differ.len() == 1 { "a file" } else { "files" },
235
                list.join(", "),
236
                if differ.len() > 10 { ", …" } else { "" }
237
            ));
238
        }
239
        // The work as it is on disk: content and mode, or absent.
240
        let mut carried: Vec<(String, Carried)> = Vec::new();
241
        for p in &touched {
242
            let full = self.root.join(p);
243
            let item = match fs::symlink_metadata(&full) {
244
                Ok(m) if m.file_type().is_symlink() => {
245
                    Carried::Symlink(fs::read_link(&full)?.to_string_lossy().as_bytes().to_vec())
246
                }
247
                Ok(m) if m.is_file() => Carried::File(is_executable(&m), fs::read(&full)?),
248
                Ok(_) => continue,
249
                Err(_) => Carried::Absent,
250
            };
251
            carried.push((p.clone(), item));
252
        }
253
        let report = self.checkout(heads, true, command)?;
254
        for (p, item) in &carried {
255
            match item {
256
                Carried::Symlink(target) => self.write_symlink(p, target)?,
257
                Carried::File(exec, bytes) => self.write_file(p, bytes, *exec)?,
258
                Carried::Absent => {
259
                    let _ = fs::remove_file(self.root.join(p));
260
                }
261
            }
262
        }
263
        self.drop_manifest();
264
        self.snapshot(command)?;
265
        Ok((report, carried.into_iter().map(|(p, _)| p).collect()))
266
    }
267
268
    /// Record `name` as the current bookmark of a new workspace.
269
    pub(crate) fn set_workspace_bookmark(&self, id: &WorkspaceId, name: &str) -> Result<()> {
270
        let dir = self.oxy.join("workspaces").join(id.to_hex());
271
        fs::create_dir_all(&dir)?;
272
        write_atomic(&dir.join("bookmark"), name.as_bytes())
273
    }
274
275
    /// Union the heads of bookmark `name` into the current bookmark (spec
276
    /// §10.1). A working copy without changes follows.
277
    pub fn merge(&self, name: &str) -> Result<MergeReport> {
278
        let (op_ref, op) = self.head_op()?;
279
        let current = self.config.bookmark.clone();
280
        if name == current {
281
            return fail(format!("{name} is the current bookmark"));
282
        }
283
        let Some(st) = op.view.bookmarks.get(name) else {
284
            return fail(format!("no bookmark {name}"));
285
        };
286
        let other = self.store.get_state(st)?.heads;
287
        let local = self.heads_of(&op.view, &current)?;
288
        let mut all = local.clone();
289
        all.extend(other);
290
        let mut heads = self.load_state(&all)?.maximal(&all);
291
        heads.sort();
292
        let mut sorted_local = local.clone();
293
        sorted_local.sort();
294
        if heads == sorted_local {
295
            return Ok(MergeReport {
296
                heads,
297
                changed: false,
298
                checked_out: false,
299
            });
300
        }
301
        let mut view = op.view.clone();
302
        view.bookmarks.insert(
303
            current,
304
            self.store.put_state(&StateObject::new(heads.clone()))?,
305
        );
306
        self.record_op(&[op_ref], &format!("oxy merge {name}"), view)?;
307
        let snap = self.snapshot("oxy merge")?;
308
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
309
        let mut base = snap.base.maximal(&snap.wc.deps);
310
        base.sort();
311
        let mut checked_out = false;
312
        // A working copy without changes follows the merge, whichever state
313
        // it sat on: nothing of the user's can be lost.
314
        if clean {
315
            self.checkout(&heads, false, "oxy merge (checkout)")?;
316
            checked_out = true;
317
        }
318
        Ok(MergeReport {
319
            heads,
320
            changed: true,
321
            checked_out,
322
        })
323
    }
324
}
325
326
/// A path's state on disk, taken along by `bookmark switch --carry`.
327
enum Carried {
328
    Symlink(Vec<u8>),
329
    /// Executable bit and content.
330
    File(bool, Vec<u8>),
331
    Absent,
332
}