OXYGENHUB
# 6e2b12dfe6ec oxygen / crates / oxy-core / src / repo / bookmarks.rs
15.7 KiB · Rust · last changed Never move a subrepo's bookmark backwards from its parent 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
/// What one state lacks of another: see [`Repo::state_gap`].
40
#[derive(Debug, Clone, PartialEq, Eq)]
41
pub struct StateGap {
42
    /// Changes of the first state recorded before the latest head of the
43
    /// second but not in it, oldest first: what Git's ancestry would have
44
    /// included.
45
    pub earlier: Vec<ChangeHash>,
46
    /// How many changes of the first state the second lacks altogether.
47
    pub lacks: usize,
48
    /// The heads of the second state together with `earlier`: everything
49
    /// of the first state up to the second's latest head.
50
    pub upto: Vec<ChangeHash>,
51
}
52
53
impl Repo {
54
    fn workspace_bookmark_path(&self) -> PathBuf {
55
        self.oxy
56
            .join("workspaces")
57
            .join(self.workspace.to_hex())
58
            .join("bookmark")
59
    }
60
61
    /// Every bookmark with its heads and remote-tracking difference.
62
    pub fn bookmarks(&self) -> Result<Vec<BookmarkInfo>> {
63
        let view = self.view()?;
64
        let mut out = Vec::new();
65
        for (name, st) in &view.bookmarks {
66
            let heads = self.store.get_state(st)?.heads;
67
            let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(name)) {
68
                Some(rs) => {
69
                    let remote_heads = self.store.get_state(rs)?.heads;
70
                    (
71
                        self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(),
72
                        self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok(),
73
                    )
74
                }
75
                None => (None, None),
76
            };
77
            out.push(BookmarkInfo {
78
                name: name.clone(),
79
                heads,
80
                current: *name == self.config.bookmark,
81
                ahead,
82
                behind,
83
            });
84
        }
85
        Ok(out)
86
    }
87
88
    /// Create `name` at `heads` (default: the working copy's base heads).
89
    pub fn bookmark_create(
90
        &self,
91
        name: &str,
92
        heads: Option<&[ChangeHash]>,
93
    ) -> Result<Vec<ChangeHash>> {
94
        if !valid_reference_name(name) {
95
            return fail(format!("{name:?} is not a valid bookmark name"));
96
        }
97
        let (op_ref, op) = self.head_op()?;
98
        if op.view.bookmarks.contains_key(name) {
99
            return fail(format!("bookmark {name} already exists"));
100
        }
101
        let heads = match heads {
102
            Some(h) => self.load_state(h)?.maximal(h),
103
            None => {
104
                let (_, wc) = self.working_copy()?;
105
                self.load_state(&wc.deps)?.maximal(&wc.deps)
106
            }
107
        };
108
        let mut view = op.view.clone();
109
        view.bookmarks.insert(
110
            name.to_string(),
111
            self.store.put_state(&StateObject::new(heads.clone()))?,
112
        );
113
        self.record_op(&[op_ref], &format!("oxy bookmark create {name}"), view)?;
114
        Ok(heads)
115
    }
116
117
    /// What a state at `at` lacks of the state at `from` (spec §10.1). A
118
    /// state is its heads and what they depend on, not everything recorded
119
    /// before them, so a bookmark created at one change of a state with
120
    /// several heads holds much less than someone used to Git expects.
121
    pub fn state_gap(&self, from: &[ChangeHash], at: &[ChangeHash]) -> Result<StateGap> {
122
        let mut all = from.to_vec();
123
        all.extend_from_slice(at);
124
        let st = self.load_state_history(&all)?;
125
        let with_closure = |hs: &[ChangeHash]| {
126
            let mut s: HashSet<ChangeHash> = hs.iter().copied().collect();
127
            for h in hs {
128
                s.extend(st.closure(h).iter().copied());
129
            }
130
            s
131
        };
132
        let from_set = with_closure(from);
133
        let at_set = with_closure(at);
134
        let key = |h: &ChangeHash| st.get(h).map(|m| (m.hlc, *h));
135
        let last = at.iter().filter_map(key).max();
136
        let missing: Vec<ChangeHash> = from_set
137
            .iter()
138
            .filter(|h| !at_set.contains(h))
139
            .copied()
140
            .collect();
141
        let mut earlier: Vec<ChangeHash> = missing
142
            .iter()
143
            .filter(|h| matches!((key(h), last), (Some(k), Some(l)) if k < l))
144
            .copied()
145
            .collect();
146
        earlier.sort_by_key(key);
147
        let mut candidates = at.to_vec();
148
        candidates.extend_from_slice(&earlier);
149
        Ok(StateGap {
150
            lacks: missing.len(),
151
            upto: st.maximal(&candidates),
152
            earlier,
153
        })
154
    }
155
156
    /// Point an existing bookmark at `heads`.
157
    pub fn bookmark_set(&self, name: &str, heads: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
158
        let (op_ref, op) = self.head_op()?;
159
        if !op.view.bookmarks.contains_key(name) {
160
            return fail(format!(
161
                "no bookmark {name}; create it with `oxy bookmark create`"
162
            ));
163
        }
164
        let heads = self.load_state(heads)?.maximal(heads);
165
        let mut view = op.view.clone();
166
        view.bookmarks.insert(
167
            name.to_string(),
168
            self.store.put_state(&StateObject::new(heads.clone()))?,
169
        );
170
        self.record_op(&[op_ref], &format!("oxy bookmark set {name}"), view)?;
171
        if name == self.config.bookmark {
172
            self.drop_manifest();
173
        }
174
        Ok(heads)
175
    }
176
177
    /// Remove a bookmark; its changes stay until unreachable (spec §21).
178
    pub fn bookmark_delete(&self, name: &str) -> Result<()> {
179
        if name == self.config.bookmark {
180
            return fail(format!(
181
                "{name} is this workspace's current bookmark; switch to another first"
182
            ));
183
        }
184
        for w in self.workspaces()? {
185
            if !w.current && self.workspace_bookmark_of(&w.id).as_deref() == Some(name) {
186
                return fail(format!(
187
                    "{name} is the current bookmark of workspace {} at {}",
188
                    w.id.short(8),
189
                    w.path.display()
190
                ));
191
            }
192
        }
193
        let (op_ref, op) = self.head_op()?;
194
        let mut view = op.view.clone();
195
        if view.bookmarks.remove(name).is_none() {
196
            return fail(format!("no bookmark {name}"));
197
        }
198
        self.record_op(&[op_ref], &format!("oxy bookmark delete {name}"), view)?;
199
        Ok(())
200
    }
201
202
    fn workspace_bookmark_of(&self, id: &WorkspaceId) -> Option<String> {
203
        fs::read_to_string(
204
            self.oxy
205
                .join("workspaces")
206
                .join(id.to_hex())
207
                .join("bookmark"),
208
        )
209
        .ok()
210
        .map(|s| s.trim().to_string())
211
        .filter(|s| !s.is_empty())
212
    }
213
214
    /// Make `name` this workspace's current bookmark and check it out.
215
    pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result<CheckoutReport> {
216
        let _wc = self.lock_working_copy()?;
217
        let view = self.view()?;
218
        let Some(st) = view.bookmarks.get(name) else {
219
            return fail(format!("no bookmark {name}"));
220
        };
221
        let heads = self.store.get_state(st)?.heads;
222
        let report = self.checkout(&heads, discard, &format!("oxy bookmark switch {name}"))?;
223
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
224
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
225
        self.config.bookmark = name.to_string();
226
        Ok(report)
227
    }
228
229
    /// Switch to `name` taking uncommitted work along (spec §10.1), as `git
230
    /// switch` does: see [`Repo::checkout_carry`]. Returns the checkout
231
    /// report and the carried paths.
232
    pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> {
233
        let _wc = self.lock_working_copy()?;
234
        let view = self.view()?;
235
        let Some(st) = view.bookmarks.get(name) else {
236
            return fail(format!("no bookmark {name}"));
237
        };
238
        let heads = self.store.get_state(st)?.heads;
239
        let out =
240
            self.checkout_carry(&heads, name, &format!("oxy bookmark switch {name} --carry"))?;
241
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
242
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
243
        self.config.bookmark = name.to_string();
244
        self.snapshot(&format!("oxy bookmark switch {name} --carry"))?;
245
        Ok(out)
246
    }
247
248
    /// Check out `heads` taking uncommitted work along: every path the
249
    /// working copy changes must be the same in the target state as in the
250
    /// working copy's base, so carrying the files over applies exactly the
251
    /// same edits there. Otherwise nothing happens and the error names the
252
    /// paths that differ (`target` names the state in it). Returns the
253
    /// checkout report and the carried paths.
254
    pub fn checkout_carry(
255
        &self,
256
        heads: &[ChangeHash],
257
        target: &str,
258
        command: &str,
259
    ) -> Result<(CheckoutReport, Vec<String>)> {
260
        let _wc = self.lock_working_copy()?;
261
        let snap = self.snapshot(command)?;
262
        // Every path the work touches: changed ones, and base files it
263
        // removed or renamed away (a rename reports only its new path).
264
        let mut touched: BTreeSet<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
265
        for (p, f) in &snap.base_snapshot.files {
266
            if f.kind != NodeKind::Dir
267
                && f.kind != NodeKind::Subrepo
268
                && fs::symlink_metadata(self.root.join(p)).is_err()
269
            {
270
                touched.insert(p.clone());
271
            }
272
        }
273
        let target_state = self.load_state(heads)?.materialize();
274
        let same = |p: &String| {
275
            let a = snap.base_snapshot.files.get(p);
276
            let b = target_state.files.get(p);
277
            match (a, b) {
278
                (None, None) => true,
279
                (Some(a), Some(b)) => {
280
                    a.kind == b.kind && a.mode == b.mode && a.content == b.content
281
                }
282
                _ => false,
283
            }
284
        };
285
        let differ: Vec<&String> = touched.iter().filter(|p| !same(p)).collect();
286
        if !differ.is_empty() {
287
            let list: Vec<&str> = differ.iter().take(10).map(|p| p.as_str()).collect();
288
            return fail(format!(
289
                "{target} has other versions of {} you changed ({}{}); commit first, or drop the changes with --discard",
290
                if differ.len() == 1 { "a file" } else { "files" },
291
                list.join(", "),
292
                if differ.len() > 10 { ", …" } else { "" }
293
            ));
294
        }
295
        // A subrepo whose pin is carried work keeps its own state: checking
296
        // it out at the target's pin would roll it back (oxygen#38).
297
        let keep: BTreeSet<String> = touched
298
            .iter()
299
            .filter(|p| {
300
                snap.base_snapshot
301
                    .files
302
                    .get(*p)
303
                    .or_else(|| target_state.files.get(*p))
304
                    .is_some_and(|f| f.kind == NodeKind::Subrepo)
305
            })
306
            .cloned()
307
            .collect();
308
        // The work as it is on disk: content and mode, or absent.
309
        let mut carried: Vec<(String, Carried)> = Vec::new();
310
        for p in &touched {
311
            if keep.contains(p) {
312
                carried.push((p.clone(), Carried::Subrepo));
313
                continue;
314
            }
315
            let full = self.root.join(p);
316
            let item = match fs::symlink_metadata(&full) {
317
                Ok(m) if m.file_type().is_symlink() => {
318
                    Carried::Symlink(fs::read_link(&full)?.to_string_lossy().as_bytes().to_vec())
319
                }
320
                Ok(m) if m.is_file() => Carried::File(is_executable(&m), fs::read(&full)?),
321
                Ok(_) => continue,
322
                Err(_) => Carried::Absent,
323
            };
324
            carried.push((p.clone(), item));
325
        }
326
        let report = self.checkout_keeping(heads, true, command, &keep)?;
327
        for (p, item) in &carried {
328
            match item {
329
                Carried::Symlink(target) => self.write_symlink(p, target)?,
330
                Carried::File(exec, bytes) => self.write_file(p, bytes, *exec)?,
331
                Carried::Absent => {
332
                    let _ = fs::remove_file(self.root.join(p));
333
                }
334
                Carried::Subrepo => {}
335
            }
336
        }
337
        self.drop_manifest();
338
        self.snapshot(command)?;
339
        Ok((report, carried.into_iter().map(|(p, _)| p).collect()))
340
    }
341
342
    /// Record `name` as the current bookmark of a new workspace.
343
    pub(crate) fn set_workspace_bookmark(&self, id: &WorkspaceId, name: &str) -> Result<()> {
344
        let dir = self.oxy.join("workspaces").join(id.to_hex());
345
        fs::create_dir_all(&dir)?;
346
        write_atomic(&dir.join("bookmark"), name.as_bytes())
347
    }
348
349
    /// Union the heads of bookmark `name` into the current bookmark (spec
350
    /// §10.1). A working copy without changes follows.
351
    pub fn merge(&self, name: &str) -> Result<MergeReport> {
352
        let _wc = self.lock_working_copy()?;
353
        let (op_ref, op) = self.head_op()?;
354
        let current = self.config.bookmark.clone();
355
        if name == current {
356
            return fail(format!("{name} is the current bookmark"));
357
        }
358
        let Some(st) = op.view.bookmarks.get(name) else {
359
            return fail(format!("no bookmark {name}"));
360
        };
361
        let other = self.store.get_state(st)?.heads;
362
        let local = self.heads_of(&op.view, &current)?;
363
        let mut all = local.clone();
364
        all.extend(other);
365
        let mut heads = self.load_state(&all)?.maximal(&all);
366
        heads.sort();
367
        let mut sorted_local = local.clone();
368
        sorted_local.sort();
369
        if heads == sorted_local {
370
            return Ok(MergeReport {
371
                heads,
372
                changed: false,
373
                checked_out: false,
374
            });
375
        }
376
        let mut view = op.view.clone();
377
        view.bookmarks.insert(
378
            current,
379
            self.store.put_state(&StateObject::new(heads.clone()))?,
380
        );
381
        self.record_op(&[op_ref], &format!("oxy merge {name}"), view)?;
382
        let snap = self.snapshot("oxy merge")?;
383
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
384
        let mut base = snap.base.maximal(&snap.wc.deps);
385
        base.sort();
386
        let mut checked_out = false;
387
        // A working copy without changes follows the merge, whichever state
388
        // it sat on: nothing of the user's can be lost.
389
        if clean {
390
            self.checkout(&heads, false, "oxy merge (checkout)")?;
391
            checked_out = true;
392
        }
393
        Ok(MergeReport {
394
            heads,
395
            changed: true,
396
            checked_out,
397
        })
398
    }
399
}
400
401
/// A path's state on disk, taken along by `bookmark switch --carry`.
402
enum Carried {
403
    /// A subrepo whose pin is carried: left as it is.
404
    Subrepo,
405
    Symlink(Vec<u8>),
406
    /// Executable bit and content.
407
    File(bool, Vec<u8>),
408
    Absent,
409
}