OXYGENHUB
# 61e3bce7ea5b oxygen / crates / oxy-core / src / repo / bookmarks.rs
12.1 KiB · Rust · last changed bookmark switch --carry takes uncommitted work along 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: every path the working copy changes must be the same
177
    /// in `name`'s state as in the working copy's base, so carrying the
178
    /// files over applies exactly the same edits there. Otherwise nothing
179
    /// happens and the error names the paths that differ. Returns the
180
    /// checkout report and the carried paths.
181
    pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> {
182
        let view = self.view()?;
183
        let Some(st) = view.bookmarks.get(name) else {
184
            return fail(format!("no bookmark {name}"));
185
        };
186
        let heads = self.store.get_state(st)?.heads;
187
        let snap = self.snapshot(&format!("oxy bookmark switch {name} --carry"))?;
188
        // Every path the work touches: changed ones, and base files it
189
        // removed or renamed away (a rename reports only its new path).
190
        let mut touched: BTreeSet<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
191
        for (p, f) in &snap.base_snapshot.files {
192
            if f.kind != NodeKind::Dir
193
                && f.kind != NodeKind::Subrepo
194
                && fs::symlink_metadata(self.root.join(p)).is_err()
195
            {
196
                touched.insert(p.clone());
197
            }
198
        }
199
        let target = self.load_state(&heads)?.materialize();
200
        let same = |p: &String| {
201
            let a = snap.base_snapshot.files.get(p);
202
            let b = target.files.get(p);
203
            match (a, b) {
204
                (None, None) => true,
205
                (Some(a), Some(b)) => {
206
                    a.kind == b.kind && a.mode == b.mode && a.content == b.content
207
                }
208
                _ => false,
209
            }
210
        };
211
        let differ: Vec<&String> = touched.iter().filter(|p| !same(p)).collect();
212
        if !differ.is_empty() {
213
            let list: Vec<&str> = differ.iter().take(10).map(|p| p.as_str()).collect();
214
            return fail(format!(
215
                "{name} has other versions of {} you changed ({}{}); commit first, or switch with --discard",
216
                if differ.len() == 1 { "a file" } else { "files" },
217
                list.join(", "),
218
                if differ.len() > 10 { ", …" } else { "" }
219
            ));
220
        }
221
        // The work as it is on disk: content and mode, or absent.
222
        let mut carried: Vec<(String, Carried)> = Vec::new();
223
        for p in &touched {
224
            let full = self.root.join(p);
225
            let item = match fs::symlink_metadata(&full) {
226
                Ok(m) if m.file_type().is_symlink() => {
227
                    Carried::Symlink(fs::read_link(&full)?.to_string_lossy().as_bytes().to_vec())
228
                }
229
                Ok(m) if m.is_file() => Carried::File(is_executable(&m), fs::read(&full)?),
230
                Ok(_) => continue,
231
                Err(_) => Carried::Absent,
232
            };
233
            carried.push((p.clone(), item));
234
        }
235
        let report = self.checkout(&heads, true, &format!("oxy bookmark switch {name} --carry"))?;
236
        for (p, item) in &carried {
237
            match item {
238
                Carried::Symlink(target) => self.write_symlink(p, target)?,
239
                Carried::File(exec, bytes) => self.write_file(p, bytes, *exec)?,
240
                Carried::Absent => {
241
                    let _ = fs::remove_file(self.root.join(p));
242
                }
243
            }
244
        }
245
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
246
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
247
        self.config.bookmark = name.to_string();
248
        self.drop_manifest();
249
        self.snapshot(&format!("oxy bookmark switch {name} --carry"))?;
250
        Ok((report, carried.into_iter().map(|(p, _)| p).collect()))
251
    }
252
253
    /// Record `name` as the current bookmark of a new workspace.
254
    pub(crate) fn set_workspace_bookmark(&self, id: &WorkspaceId, name: &str) -> Result<()> {
255
        let dir = self.oxy.join("workspaces").join(id.to_hex());
256
        fs::create_dir_all(&dir)?;
257
        write_atomic(&dir.join("bookmark"), name.as_bytes())
258
    }
259
260
    /// Union the heads of bookmark `name` into the current bookmark (spec
261
    /// §10.1). The working copy follows when it was clean on the old heads.
262
    pub fn merge(&self, name: &str) -> Result<MergeReport> {
263
        let (op_ref, op) = self.head_op()?;
264
        let current = self.config.bookmark.clone();
265
        if name == current {
266
            return fail(format!("{name} is the current bookmark"));
267
        }
268
        let Some(st) = op.view.bookmarks.get(name) else {
269
            return fail(format!("no bookmark {name}"));
270
        };
271
        let other = self.store.get_state(st)?.heads;
272
        let local = self.heads_of(&op.view, &current)?;
273
        let mut all = local.clone();
274
        all.extend(other);
275
        let mut heads = self.load_state(&all)?.maximal(&all);
276
        heads.sort();
277
        let mut sorted_local = local.clone();
278
        sorted_local.sort();
279
        if heads == sorted_local {
280
            return Ok(MergeReport {
281
                heads,
282
                changed: false,
283
                checked_out: false,
284
            });
285
        }
286
        let mut view = op.view.clone();
287
        view.bookmarks.insert(
288
            current,
289
            self.store.put_state(&StateObject::new(heads.clone()))?,
290
        );
291
        self.record_op(&[op_ref], &format!("oxy merge {name}"), view)?;
292
        let snap = self.snapshot("oxy merge")?;
293
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
294
        let mut base = snap.base.maximal(&snap.wc.deps);
295
        base.sort();
296
        let mut checked_out = false;
297
        if clean && base == sorted_local {
298
            self.checkout(&heads, false, "oxy merge (checkout)")?;
299
            checked_out = true;
300
        }
301
        Ok(MergeReport {
302
            heads,
303
            changed: true,
304
            checked_out,
305
        })
306
    }
307
}
308
309
/// A path's state on disk, taken along by `bookmark switch --carry`.
310
enum Carried {
311
    Symlink(Vec<u8>),
312
    /// Executable bit and content.
313
    File(bool, Vec<u8>),
314
    Absent,
315
}