OXYGENHUB
◆ v0.4.4 oxygen / crates / oxy-core / src / repo / bookmarks.rs
27.2 KiB · Rust · last changed carry: work the target already has is no conflict 24 days ago source blame history raw
7b9809e6 Andy McCall ↶ 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,
b35df36b Andy McCall ↶ 14
    /// Changes ahead of / behind the default remote's copy, when tracked.
7b9809e6 Andy McCall ↶ 15
    pub ahead: Option<usize>,
16
    pub behind: Option<usize>,
42ccf504 Andy McCall ↶ 17
    /// The remote `ahead` and `behind` are measured against.
18
    pub remote: Option<String>,
7b9809e6 Andy McCall ↶ 19
}
20
21
#[derive(Clone, Debug)]
22
pub struct MergeReport {
23
    pub heads: Vec<ChangeHash>,
24
    pub changed: bool,
25
    pub checked_out: bool,
26
}
27
28
/// Bookmark and tag names (spec §10.1): UTF-8, 1–255 bytes, no control
29
/// characters, no leading `-`, and none of `..`, `//`, `@{`.
30
pub fn valid_reference_name(name: &str) -> bool {
31
    !name.is_empty()
32
        && name.len() <= 255
33
        && !name.starts_with('-')
34
        && !name.chars().any(|c| c.is_control())
35
        && !name.contains("..")
36
        && !name.contains("//")
37
        && !name.contains("@{")
38
        && name != "policy"
39
}
40
14edbaaf Andy McCall ↶ 41
/// The remote bookmark `spec` names as `REMOTE/NAME`, if `view` has one:
42
/// the remote, the bookmark's name there, and its state. The longest
43
/// matching remote name wins, and a bookmark name may hold `/` itself.
44
pub fn remote_bookmark<'a>(view: &'a View, spec: &str) -> Option<(&'a str, &'a str, ObjectRef)> {
45
    view.remotes
46
        .iter()
47
        .filter_map(|(remote, bookmarks)| {
48
            let rest = spec.strip_prefix(remote.as_str())?.strip_prefix('/')?;
49
            let (name, state) = bookmarks.get_key_value(rest)?;
50
            Some((remote.as_str(), name.as_str(), *state))
51
        })
52
        .max_by_key(|(remote, _, _)| remote.len())
53
}
54
55
/// A bookmark a remote has and this repository has no bookmark for.
56
#[derive(Clone, Debug)]
57
pub struct RemoteOnlyBookmark {
58
    pub remote: String,
59
    pub name: String,
60
    pub heads: Vec<ChangeHash>,
61
}
62
4fe7d604 Andy McCall ↶ 63
/// What one state lacks of another: see [`Repo::state_gap`].
64
#[derive(Debug, Clone, PartialEq, Eq)]
65
pub struct StateGap {
66
    /// Changes of the first state recorded before the latest head of the
67
    /// second but not in it, oldest first: what Git's ancestry would have
68
    /// included.
69
    pub earlier: Vec<ChangeHash>,
70
    /// How many changes of the first state the second lacks altogether.
71
    pub lacks: usize,
72
    /// The heads of the second state together with `earlier`: everything
73
    /// of the first state up to the second's latest head.
74
    pub upto: Vec<ChangeHash>,
75
}
76
7b9809e6 Andy McCall ↶ 77
impl Repo {
14edbaaf Andy McCall ↶ 78
    pub(crate) fn workspace_bookmark_path(&self) -> PathBuf {
75167672 Andy McCall 79
        self.oxy
80
            .join("workspaces")
81
            .join(self.workspace.to_hex())
82
            .join("bookmark")
7b9809e6 Andy McCall ↶ 83
    }
84
b35df36b Andy McCall ↶ 85
    /// The remote a command uses when none is named: `origin`, else the
86
    /// only remote there is (finding 29 of the metalgear feedback: a
87
    /// checkout whose one remote is `earth`).
88
    pub fn default_remote(&self) -> String {
42ccf504 Andy McCall ↶ 89
        if let Some(r) = &self.config.default_remote {
90
            return r.clone();
91
        }
b35df36b Andy McCall ↶ 92
        match self.config.remotes.keys().collect::<Vec<_>>().as_slice() {
93
            [only] if *only != "origin" => (*only).clone(),
94
            _ => "origin".into(),
95
        }
96
    }
97
42ccf504 Andy McCall ↶ 98
    /// The remote a bookmark is compared with: the default when it has
99
    /// the bookmark, else the first remote by name that does (a repository
100
    /// with `earth` and `gcs` and no `origin`, oxygen#83).
101
    fn tracking_remote(&self, view: &View, name: &str) -> Option<String> {
102
        // Only remotes still configured: a removed one's copies linger.
103
        let configured = |r: &str| self.config.remotes.contains_key(r);
104
        let default = self.default_remote();
105
        if configured(&default)
106
            && view
107
                .remotes
108
                .get(&default)
109
                .is_some_and(|b| b.contains_key(name))
110
        {
111
            return Some(default);
112
        }
113
        view.remotes
114
            .iter()
115
            .find(|(r, b)| configured(r) && b.contains_key(name))
116
            .map(|(r, _)| r.clone())
117
    }
118
b35df36b Andy McCall ↶ 119
    /// How bookmark `name` stands against `remote`'s copy of it: the
120
    /// changes only here and only there. `None` when either side has no
121
    /// such bookmark.
122
    pub fn tracking(&self, remote: &str, name: &str) -> Result<Option<(usize, usize)>> {
123
        let view = self.view()?;
124
        let (Some(st), Some(rs)) = (
125
            view.bookmarks.get(name),
126
            view.remotes.get(remote).and_then(|b| b.get(name)),
127
        ) else {
128
            return Ok(None);
129
        };
130
        let heads = self.store.get_state(st)?.heads;
131
        let remote_heads = self.store.get_state(rs)?.heads;
132
        Ok(Some((
133
            self.negotiate(&remote_heads, &heads)?.len(),
134
            self.negotiate(&heads, &remote_heads)?.len(),
135
        )))
136
    }
137
138
    /// Every bookmark with its heads and its difference from the default
139
    /// remote's copy.
7b9809e6 Andy McCall ↶ 140
    pub fn bookmarks(&self) -> Result<Vec<BookmarkInfo>> {
141
        let view = self.view()?;
142
        let mut out = Vec::new();
143
        for (name, st) in &view.bookmarks {
144
            let heads = self.store.get_state(st)?.heads;
42ccf504 Andy McCall ↶ 145
            let remote = self.tracking_remote(&view, name);
146
            let (ahead, behind) = match remote
147
                .as_ref()
148
                .and_then(|r| view.remotes.get(r))
149
                .and_then(|b| b.get(name))
150
            {
7b9809e6 Andy McCall ↶ 151
                Some(rs) => {
152
                    let remote_heads = self.store.get_state(rs)?.heads;
75167672 Andy McCall 153
                    (
154
                        self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(),
155
                        self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok(),
156
                    )
7b9809e6 Andy McCall ↶ 157
                }
158
                None => (None, None),
159
            };
75167672 Andy McCall 160
            out.push(BookmarkInfo {
161
                name: name.clone(),
162
                heads,
163
                current: *name == self.config.bookmark,
164
                ahead,
165
                behind,
42ccf504 Andy McCall ↶ 166
                remote,
75167672 Andy McCall 167
            });
7b9809e6 Andy McCall ↶ 168
        }
169
        Ok(out)
14edbaaf Andy McCall ↶ 170
    }
171
72484195 Andy McCall ↶ 172
    /// Move `name` to its default remote's state when it is only behind
173
    /// it: no local change the remote lacks, so nothing is lost
174
    /// (oxygen#74). Returns how many changes it caught up, if any.
175
    pub fn catch_up_bookmark(&self, name: &str) -> Result<Option<usize>> {
176
        let (op_ref, op) = self.head_op()?;
177
        let remote = self.default_remote();
178
        let (Some(local), Some(rs)) = (
179
            op.view.bookmarks.get(name).copied(),
180
            op.view
181
                .remotes
182
                .get(&remote)
183
                .and_then(|b| b.get(name))
184
                .copied(),
185
        ) else {
186
            return Ok(None);
187
        };
188
        if local == rs {
189
            return Ok(None);
190
        }
191
        let heads = self.store.get_state(&local)?.heads;
192
        let remote_heads = self.store.get_state(&rs)?.heads;
193
        if !self.negotiate(&remote_heads, &heads)?.is_empty() {
194
            return Ok(None);
195
        }
196
        let behind = self.negotiate(&heads, &remote_heads)?.len();
197
        if behind == 0 {
198
            return Ok(None);
199
        }
200
        let mut view = op.view.clone();
201
        view.bookmarks.insert(name.to_string(), rs);
202
        self.record_op(
203
            &[op_ref],
204
            &format!("oxy bookmark switch {name} (catch up with {remote})"),
205
            view,
206
        )?;
207
        Ok(Some(behind))
208
    }
209
14edbaaf Andy McCall ↶ 210
    /// Bookmarks of remotes with no local bookmark of the same name, by
211
    /// remote then name.
212
    pub fn remote_only_bookmarks(&self) -> Result<Vec<RemoteOnlyBookmark>> {
213
        let view = self.view()?;
214
        let mut out = Vec::new();
215
        for (remote, bookmarks) in &view.remotes {
216
            for (name, st) in bookmarks {
217
                if view.bookmarks.contains_key(name) {
218
                    continue;
219
                }
220
                out.push(RemoteOnlyBookmark {
221
                    remote: remote.clone(),
222
                    name: name.clone(),
223
                    heads: self.store.get_state(st)?.heads,
224
                });
225
            }
226
        }
227
        Ok(out)
228
    }
229
230
    /// The local bookmark to switch to for `spec`, creating it from a
231
    /// remote's when there is none (as `git switch` does): `NAME` takes
232
    /// `origin/NAME` (or the one remote that has it), and `REMOTE/NAME`
233
    /// takes that one. Returns the local name and, when it was just
234
    /// created, the remote it came from. A local bookmark tracks the
235
    /// remote's of the same name: `oxy pull` updates it.
236
    pub fn local_bookmark_for(&self, spec: &str) -> Result<(String, Option<String>)> {
237
        let (op_ref, op) = self.head_op()?;
238
        if op.view.bookmarks.contains_key(spec) {
239
            return Ok((spec.to_string(), None));
240
        }
241
        let (remote, name, state) = match remote_bookmark(&op.view, spec) {
242
            Some((remote, name, state)) => (remote.to_string(), name.to_string(), state),
243
            None => {
244
                let mut having: Vec<&String> = op
245
                    .view
246
                    .remotes
247
                    .iter()
248
                    .filter(|(_, b)| b.contains_key(spec))
249
                    .map(|(r, _)| r)
250
                    .collect();
251
                if let Some(i) = having.iter().position(|r| *r == "origin") {
252
                    having = vec![having[i]];
253
                }
254
                match having.as_slice() {
255
                    [] => return fail(format!("no bookmark {spec}, here or on a remote")),
256
                    [r] => {
257
                        let state = op.view.remotes[*r][spec];
258
                        (r.to_string(), spec.to_string(), state)
259
                    }
260
                    several => {
261
                        let names: Vec<String> =
262
                            several.iter().map(|r| format!("{r}/{spec}")).collect();
263
                        return fail(format!(
264
                            "several remotes have {spec}: name one, as {}",
265
                            names.join(" or ")
266
                        ));
267
                    }
268
                }
269
            }
270
        };
271
        if op.view.bookmarks.contains_key(&name) {
272
            // REMOTE/NAME where NAME is already local: switch to the local one.
273
            return Ok((name, None));
274
        }
275
        let mut view = op.view.clone();
276
        view.bookmarks.insert(name.clone(), state);
277
        self.record_op(
278
            &[op_ref],
279
            &format!("oxy bookmark track {remote}/{name}"),
280
            view,
281
        )?;
282
        Ok((name, Some(remote)))
7b9809e6 Andy McCall ↶ 283
    }
284
285
    /// Create `name` at `heads` (default: the working copy's base heads).
75167672 Andy McCall 286
    pub fn bookmark_create(
287
        &self,
288
        name: &str,
289
        heads: Option<&[ChangeHash]>,
290
    ) -> Result<Vec<ChangeHash>> {
7b9809e6 Andy McCall ↶ 291
        if !valid_reference_name(name) {
292
            return fail(format!("{name:?} is not a valid bookmark name"));
293
        }
294
        let (op_ref, op) = self.head_op()?;
295
        if op.view.bookmarks.contains_key(name) {
296
            return fail(format!("bookmark {name} already exists"));
297
        }
298
        let heads = match heads {
57eaac91 Andy McCall ↶ 299
            Some(h) => self.load_state_history(h)?.maximal(h),
7b9809e6 Andy McCall ↶ 300
            None => {
301
                let (_, wc) = self.working_copy()?;
57eaac91 Andy McCall ↶ 302
                self.load_state_history(&wc.deps)?.maximal(&wc.deps)
7b9809e6 Andy McCall ↶ 303
            }
304
        };
305
        let mut view = op.view.clone();
75167672 Andy McCall 306
        view.bookmarks.insert(
307
            name.to_string(),
308
            self.store.put_state(&StateObject::new(heads.clone()))?,
309
        );
7b9809e6 Andy McCall ↶ 310
        self.record_op(&[op_ref], &format!("oxy bookmark create {name}"), view)?;
311
        Ok(heads)
4fe7d604 Andy McCall ↶ 312
    }
313
314
    /// What a state at `at` lacks of the state at `from` (spec §10.1). A
315
    /// state is its heads and what they depend on, not everything recorded
316
    /// before them, so a bookmark created at one change of a state with
317
    /// several heads holds much less than someone used to Git expects.
318
    pub fn state_gap(&self, from: &[ChangeHash], at: &[ChangeHash]) -> Result<StateGap> {
319
        let mut all = from.to_vec();
320
        all.extend_from_slice(at);
321
        let st = self.load_state_history(&all)?;
322
        let with_closure = |hs: &[ChangeHash]| {
323
            let mut s: HashSet<ChangeHash> = hs.iter().copied().collect();
324
            for h in hs {
325
                s.extend(st.closure(h).iter().copied());
326
            }
327
            s
328
        };
329
        let from_set = with_closure(from);
330
        let at_set = with_closure(at);
331
        let key = |h: &ChangeHash| st.get(h).map(|m| (m.hlc, *h));
332
        let last = at.iter().filter_map(key).max();
333
        let missing: Vec<ChangeHash> = from_set
334
            .iter()
335
            .filter(|h| !at_set.contains(h))
336
            .copied()
337
            .collect();
338
        let mut earlier: Vec<ChangeHash> = missing
339
            .iter()
340
            .filter(|h| matches!((key(h), last), (Some(k), Some(l)) if k < l))
341
            .copied()
342
            .collect();
343
        earlier.sort_by_key(key);
344
        let mut candidates = at.to_vec();
345
        candidates.extend_from_slice(&earlier);
346
        Ok(StateGap {
347
            lacks: missing.len(),
348
            upto: st.maximal(&candidates),
349
            earlier,
350
        })
7b9809e6 Andy McCall ↶ 351
    }
352
353
    /// Point an existing bookmark at `heads`.
354
    pub fn bookmark_set(&self, name: &str, heads: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
355
        let (op_ref, op) = self.head_op()?;
356
        if !op.view.bookmarks.contains_key(name) {
75167672 Andy McCall 357
            return fail(format!(
358
                "no bookmark {name}; create it with `oxy bookmark create`"
359
            ));
7b9809e6 Andy McCall ↶ 360
        }
57eaac91 Andy McCall ↶ 361
        let heads = self.load_state_history(heads)?.maximal(heads);
7b9809e6 Andy McCall ↶ 362
        let mut view = op.view.clone();
75167672 Andy McCall 363
        view.bookmarks.insert(
364
            name.to_string(),
365
            self.store.put_state(&StateObject::new(heads.clone()))?,
366
        );
7b9809e6 Andy McCall ↶ 367
        self.record_op(&[op_ref], &format!("oxy bookmark set {name}"), view)?;
368
        if name == self.config.bookmark {
369
            self.drop_manifest();
370
        }
371
        Ok(heads)
372
    }
373
374
    /// Remove a bookmark; its changes stay until unreachable (spec §21).
375
    pub fn bookmark_delete(&self, name: &str) -> Result<()> {
376
        if name == self.config.bookmark {
75167672 Andy McCall 377
            return fail(format!(
378
                "{name} is this workspace's current bookmark; switch to another first"
379
            ));
7b9809e6 Andy McCall ↶ 380
        }
381
        for w in self.workspaces()? {
382
            if !w.current && self.workspace_bookmark_of(&w.id).as_deref() == Some(name) {
75167672 Andy McCall 383
                return fail(format!(
384
                    "{name} is the current bookmark of workspace {} at {}",
385
                    w.id.short(8),
386
                    w.path.display()
387
                ));
7b9809e6 Andy McCall ↶ 388
            }
389
        }
390
        let (op_ref, op) = self.head_op()?;
391
        let mut view = op.view.clone();
392
        if view.bookmarks.remove(name).is_none() {
393
            return fail(format!("no bookmark {name}"));
394
        }
395
        self.record_op(&[op_ref], &format!("oxy bookmark delete {name}"), view)?;
396
        Ok(())
397
    }
398
399
    fn workspace_bookmark_of(&self, id: &WorkspaceId) -> Option<String> {
75167672 Andy McCall 400
        fs::read_to_string(
401
            self.oxy
402
                .join("workspaces")
403
                .join(id.to_hex())
404
                .join("bookmark"),
405
        )
406
        .ok()
407
        .map(|s| s.trim().to_string())
408
        .filter(|s| !s.is_empty())
7b9809e6 Andy McCall ↶ 409
    }
410
411
    /// Make `name` this workspace's current bookmark and check it out.
412
    pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result<CheckoutReport> {
6caacfff Andy McCall ↶ 413
        let _wc = self.lock_working_copy()?;
14edbaaf Andy McCall ↶ 414
        let (name, _) = self.local_bookmark_for(name)?;
415
        let name = name.as_str();
7b9809e6 Andy McCall ↶ 416
        let view = self.view()?;
75167672 Andy McCall 417
        let Some(st) = view.bookmarks.get(name) else {
418
            return fail(format!("no bookmark {name}"));
419
        };
7b9809e6 Andy McCall ↶ 420
        let heads = self.store.get_state(st)?.heads;
421
        let report = self.checkout(&heads, discard, &format!("oxy bookmark switch {name}"))?;
422
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
423
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
424
        self.config.bookmark = name.to_string();
425
        Ok(report)
426
    }
427
61e3bce7 Andy McCall ↶ 428
    /// Switch to `name` taking uncommitted work along (spec §10.1), as `git
1c2bd9ab Andy McCall ↶ 429
    /// switch` does: see [`Repo::checkout_carry`]. Returns the checkout
430
    /// report and the carried paths.
61e3bce7 Andy McCall ↶ 431
    pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> {
6caacfff Andy McCall ↶ 432
        let _wc = self.lock_working_copy()?;
14edbaaf Andy McCall ↶ 433
        let (name, _) = self.local_bookmark_for(name)?;
434
        let name = name.as_str();
61e3bce7 Andy McCall ↶ 435
        let view = self.view()?;
436
        let Some(st) = view.bookmarks.get(name) else {
437
            return fail(format!("no bookmark {name}"));
438
        };
439
        let heads = self.store.get_state(st)?.heads;
1c2bd9ab Andy McCall ↶ 440
        let out =
441
            self.checkout_carry(&heads, name, &format!("oxy bookmark switch {name} --carry"))?;
442
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
443
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
444
        self.config.bookmark = name.to_string();
445
        self.snapshot(&format!("oxy bookmark switch {name} --carry"))?;
446
        Ok(out)
447
    }
448
449
    /// Check out `heads` taking uncommitted work along: every path the
450
    /// working copy changes must be the same in the target state as in the
451
    /// working copy's base, so carrying the files over applies exactly the
452
    /// same edits there. Otherwise nothing happens and the error names the
453
    /// paths that differ (`target` names the state in it). Returns the
454
    /// checkout report and the carried paths.
455
    pub fn checkout_carry(
456
        &self,
457
        heads: &[ChangeHash],
458
        target: &str,
459
        command: &str,
460
    ) -> Result<(CheckoutReport, Vec<String>)> {
6caacfff Andy McCall ↶ 461
        let _wc = self.lock_working_copy()?;
1c2bd9ab Andy McCall ↶ 462
        let snap = self.snapshot(command)?;
61e3bce7 Andy McCall ↶ 463
        // Every path the work touches: changed ones, and base files it
464
        // removed or renamed away (a rename reports only its new path).
465
        let mut touched: BTreeSet<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
466
        for (p, f) in &snap.base_snapshot.files {
467
            if f.kind != NodeKind::Dir
468
                && f.kind != NodeKind::Subrepo
469
                && fs::symlink_metadata(self.root.join(p)).is_err()
470
            {
471
                touched.insert(p.clone());
472
            }
473
        }
1c2bd9ab Andy McCall ↶ 474
        let target_state = self.load_state(heads)?.materialize();
72484195 Andy McCall ↶ 475
        // A subrepo whose nested repository is already at the target's pin
476
        // is no carried work: the target records exactly what is on disk
477
        // (oxygen#74).
478
        let at_target_pin = |p: &String| -> bool {
479
            let Some(f) = target_state.files.get(p) else {
480
                return false;
481
            };
482
            if f.kind != NodeKind::Subrepo {
483
                return false;
484
            }
485
            let FileContent::Opaque { blobs } = &f.content else {
486
                return false;
487
            };
488
            let Some(want) = blobs
489
                .first()
490
                .and_then(|b| self.read_blob(b).ok())
491
                .and_then(|b| Pin::decode(&b).ok())
492
            else {
493
                return false;
494
            };
495
            let hint = self
496
                .modules_on_disk()
497
                .entries
498
                .get(p)
499
                .and_then(|m| m.bookmark.clone());
500
            super::subrepos::read_pin(&self.root.join(p), hint.as_deref())
501
                .is_ok_and(|have| have == want)
502
        };
503
        touched.retain(|p| !at_target_pin(p));
61e3bce7 Andy McCall ↶ 504
        let same = |p: &String| {
505
            let a = snap.base_snapshot.files.get(p);
1c2bd9ab Andy McCall ↶ 506
            let b = target_state.files.get(p);
61e3bce7 Andy McCall ↶ 507
            match (a, b) {
508
                (None, None) => true,
509
                (Some(a), Some(b)) => {
510
                    a.kind == b.kind && a.mode == b.mode && a.content == b.content
511
                }
512
                _ => false,
513
            }
514
        };
fe9586d9 Andy McCall ↶ 515
        // Work already the target's: the file on disk is exactly the
516
        // target's version (or both are absent), so nothing needs carrying
517
        // and it is no conflict (oxygen#100), as when a proposal's work
518
        // has landed on the bookmark switched to.
519
        let already = |p: &String| -> bool {
520
            let full = self.root.join(p);
521
            match (fs::symlink_metadata(&full), target_state.files.get(p)) {
522
                (Err(_), None) => true,
523
                (Ok(m), Some(f)) if m.is_file() && f.kind == NodeKind::File => {
524
                    if is_executable(&m) != f.mode.exec {
525
                        return false;
526
                    }
527
                    let want = match &f.content {
528
                        FileContent::Text { bytes, .. } => Some(bytes.clone()),
529
                        FileContent::Opaque { blobs } if blobs.len() == 1 => {
530
                            self.read_blob(&blobs[0]).ok()
531
                        }
532
                        _ => None,
533
                    };
534
                    want.is_some_and(|w| fs::read(&full).is_ok_and(|have| have == w))
535
                }
536
                _ => false,
537
            }
538
        };
539
        touched.retain(|p| same(p) || !already(p));
61e3bce7 Andy McCall ↶ 540
        let differ: Vec<&String> = touched.iter().filter(|p| !same(p)).collect();
541
        if !differ.is_empty() {
542
            let list: Vec<&str> = differ.iter().take(10).map(|p| p.as_str()).collect();
543
            return fail(format!(
1c2bd9ab Andy McCall ↶ 544
                "{target} has other versions of {} you changed ({}{}); commit first, or drop the changes with --discard",
61e3bce7 Andy McCall ↶ 545
                if differ.len() == 1 { "a file" } else { "files" },
546
                list.join(", "),
547
                if differ.len() > 10 { ", …" } else { "" }
548
            ));
549
        }
1a3b6203 Andy McCall ↶ 550
        // A subrepo whose pin is carried work keeps its own state: checking
551
        // it out at the target's pin would roll it back (oxygen#38).
552
        let keep: BTreeSet<String> = touched
553
            .iter()
554
            .filter(|p| {
555
                snap.base_snapshot
556
                    .files
557
                    .get(*p)
558
                    .or_else(|| target_state.files.get(*p))
559
                    .is_some_and(|f| f.kind == NodeKind::Subrepo)
560
            })
561
            .cloned()
562
            .collect();
61e3bce7 Andy McCall ↶ 563
        // The work as it is on disk: content and mode, or absent.
564
        let mut carried: Vec<(String, Carried)> = Vec::new();
565
        for p in &touched {
1a3b6203 Andy McCall ↶ 566
            if keep.contains(p) {
567
                carried.push((p.clone(), Carried::Subrepo));
568
                continue;
569
            }
61e3bce7 Andy McCall ↶ 570
            let full = self.root.join(p);
571
            let item = match fs::symlink_metadata(&full) {
572
                Ok(m) if m.file_type().is_symlink() => {
573
                    Carried::Symlink(fs::read_link(&full)?.to_string_lossy().as_bytes().to_vec())
574
                }
575
                Ok(m) if m.is_file() => Carried::File(is_executable(&m), fs::read(&full)?),
576
                Ok(_) => continue,
577
                Err(_) => Carried::Absent,
578
            };
579
            carried.push((p.clone(), item));
580
        }
1a3b6203 Andy McCall ↶ 581
        let report = self.checkout_keeping(heads, true, command, &keep)?;
61e3bce7 Andy McCall ↶ 582
        for (p, item) in &carried {
583
            match item {
584
                Carried::Symlink(target) => self.write_symlink(p, target)?,
585
                Carried::File(exec, bytes) => self.write_file(p, bytes, *exec)?,
586
                Carried::Absent => {
587
                    let _ = fs::remove_file(self.root.join(p));
588
                }
1a3b6203 Andy McCall ↶ 589
                Carried::Subrepo => {}
61e3bce7 Andy McCall ↶ 590
            }
591
        }
592
        self.drop_manifest();
1c2bd9ab Andy McCall ↶ 593
        self.snapshot(command)?;
61e3bce7 Andy McCall ↶ 594
        Ok((report, carried.into_iter().map(|(p, _)| p).collect()))
595
    }
596
7b9809e6 Andy McCall ↶ 597
    /// Record `name` as the current bookmark of a new workspace.
598
    pub(crate) fn set_workspace_bookmark(&self, id: &WorkspaceId, name: &str) -> Result<()> {
599
        let dir = self.oxy.join("workspaces").join(id.to_hex());
600
        fs::create_dir_all(&dir)?;
601
        write_atomic(&dir.join("bookmark"), name.as_bytes())
602
    }
603
604
    /// Union the heads of bookmark `name` into the current bookmark (spec
1c2bd9ab Andy McCall ↶ 605
    /// §10.1). A working copy without changes follows.
7b9809e6 Andy McCall ↶ 606
    pub fn merge(&self, name: &str) -> Result<MergeReport> {
6caacfff Andy McCall ↶ 607
        let _wc = self.lock_working_copy()?;
e266b7d4 Andy McCall ↶ 608
        let (_, op) = self.head_op()?;
609
        if name == self.config.bookmark {
7b9809e6 Andy McCall ↶ 610
            return fail(format!("{name} is the current bookmark"));
611
        }
75167672 Andy McCall 612
        let Some(st) = op.view.bookmarks.get(name) else {
613
            return fail(format!("no bookmark {name}"));
614
        };
7b9809e6 Andy McCall ↶ 615
        let other = self.store.get_state(st)?.heads;
e266b7d4 Andy McCall ↶ 616
        self.merge_heads(other, &format!("oxy merge {name}"))
617
    }
618
619
    /// Bring CHANGE up to date with `onto` where a rebase would be
620
    /// conflicted (oxygen#42): the state of `onto` is merged into the
621
    /// current bookmark, which must hold the change, and a working copy
622
    /// without changes follows, so the conflict is resolved there and
623
    /// committed on top, as after `oxy merge`.
624
    pub fn rebase_into_working_copy(
625
        &self,
626
        c: &ChangeHash,
627
        onto: &[ChangeHash],
628
    ) -> Result<MergeReport> {
629
        let _wc = self.lock_working_copy()?;
630
        let (_, op) = self.head_op()?;
631
        let current = self.config.bookmark.clone();
632
        let local = self.heads_of(&op.view, &current)?;
633
        if !self.load_state_indexed(&local)?.contains(c) {
634
            return fail(format!(
635
                "#{} is not on the current bookmark {current}; switch to the bookmark \
636
                 holding it first",
637
                c.short(12)
638
            ));
639
        }
640
        let named: Vec<String> = onto.iter().map(|h| h.short(12)).collect();
641
        let label = format!(
642
            "oxy rebase {} --onto {} --into-working-copy",
643
            c.short(12),
644
            named.join(" ")
645
        );
646
        self.merge_heads(onto.to_vec(), &label)
647
    }
648
649
    /// Union `other` into the current bookmark's heads, recorded as
650
    /// `label`; a working copy without changes follows.
651
    fn merge_heads(&self, other: Vec<ChangeHash>, label: &str) -> Result<MergeReport> {
652
        let (op_ref, op) = self.head_op()?;
653
        let current = self.config.bookmark.clone();
7b9809e6 Andy McCall ↶ 654
        let local = self.heads_of(&op.view, &current)?;
655
        let mut all = local.clone();
656
        all.extend(other);
57eaac91 Andy McCall ↶ 657
        let mut heads = self.load_state_history(&all)?.maximal(&all);
7b9809e6 Andy McCall ↶ 658
        heads.sort();
659
        let mut sorted_local = local.clone();
660
        sorted_local.sort();
661
        if heads == sorted_local {
75167672 Andy McCall 662
            return Ok(MergeReport {
663
                heads,
664
                changed: false,
665
                checked_out: false,
666
            });
7b9809e6 Andy McCall ↶ 667
        }
668
        let mut view = op.view.clone();
75167672 Andy McCall 669
        view.bookmarks.insert(
670
            current,
671
            self.store.put_state(&StateObject::new(heads.clone()))?,
672
        );
e266b7d4 Andy McCall ↶ 673
        self.record_op(&[op_ref], label, view)?;
674
        let snap = self.snapshot(label)?;
7b9809e6 Andy McCall ↶ 675
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
676
        let mut base = snap.base.maximal(&snap.wc.deps);
677
        base.sort();
678
        let mut checked_out = false;
1c2bd9ab Andy McCall ↶ 679
        // A working copy without changes follows the merge, whichever state
680
        // it sat on: nothing of the user's can be lost.
681
        if clean {
e266b7d4 Andy McCall ↶ 682
            self.checkout(&heads, false, &format!("{label} (checkout)"))?;
7b9809e6 Andy McCall ↶ 683
            checked_out = true;
684
        }
75167672 Andy McCall 685
        Ok(MergeReport {
686
            heads,
687
            changed: true,
688
            checked_out,
689
        })
7b9809e6 Andy McCall ↶ 690
    }
61e3bce7 Andy McCall ↶ 691
}
692
693
/// A path's state on disk, taken along by `bookmark switch --carry`.
694
enum Carried {
1a3b6203 Andy McCall ↶ 695
    /// A subrepo whose pin is carried: left as it is.
696
    Subrepo,
61e3bce7 Andy McCall ↶ 697
    Symlink(Vec<u8>),
698
    /// Executable bit and content.
699
    File(bool, Vec<u8>),
700
    Absent,
7b9809e6 Andy McCall ↶ 701
}