OXYGENHUB

Bookmarks: any number per repository, per-workspace current bookmark (bookmark create|set|delete|list|switch), oxy merge as union of heads, bookmark names resolve everywhere, push -b (spec 0.8 section 10.1) 7b9809e67c3d

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 14:04 UTC
Signature
✔ signed by Andy McCall
Hash
7b9809e67c3d5b6272bc211863db5c7853535b8f95b41afdc5bccab86e6e3715
Depends on
132b884b9a8d 135212de5370 23b217d75848 4e88e774777a 6d3eefbce2f0 982864068a59 b837d2b55853 fce44ae9ad29
Depended on by
b5e98094eb8f 4afe288b7946 44f1ebe7aa5b 733ea16a9701 767ac6cd0545 c895bc52e4e0 9b723f69b032 fe9586d93fb1 cb8b8082fa73 42ccf504aa50 72484195899a bf3a67819b59 a46f3e4f50b1 b35df36b7dc5 98a1620a3840 14edbaaf5291 ff1f62fee654 e266b7d4065c 06d16dc2bc13 d3cd7ef95da6 57eaac9107df 1a3b62032ed9 4fe7d6045860 6caacfff1d4f 6a81dbab471d 1c2bd9abdd4f 61e3bce7ea5b f8c695764c95 cc7a00565161 d42369abf180 75167672ebcd e02bf0cd0533 c6aa5bd9d1c8 728da0f92161 957fee30826e 4dee277c327a
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
24 atoms, 1 tree operation, 8 files

Browse the tree at this change

added crates/oxy-core/src/repo/bookmarks.rs +176 −0
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.join("workspaces").join(self.workspace.to_hex()).join("bookmark")
42 +
    }
43 +
44 +
    /// Every bookmark with its heads and remote-tracking difference.
45 +
    pub fn bookmarks(&self) -> Result<Vec<BookmarkInfo>> {
46 +
        let view = self.view()?;
47 +
        let mut out = Vec::new();
48 +
        for (name, st) in &view.bookmarks {
49 +
            let heads = self.store.get_state(st)?.heads;
50 +
            let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(name)) {
51 +
                Some(rs) => {
52 +
                    let remote_heads = self.store.get_state(rs)?.heads;
53 +
                    (self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(), self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok())
54 +
                }
55 +
                None => (None, None),
56 +
            };
57 +
            out.push(BookmarkInfo { name: name.clone(), heads, current: *name == self.config.bookmark, ahead, behind });
58 +
        }
59 +
        Ok(out)
60 +
    }
61 +
62 +
    /// Create `name` at `heads` (default: the working copy's base heads).
63 +
    pub fn bookmark_create(&self, name: &str, heads: Option<&[ChangeHash]>) -> Result<Vec<ChangeHash>> {
64 +
        if !valid_reference_name(name) {
65 +
            return fail(format!("{name:?} is not a valid bookmark name"));
66 +
        }
67 +
        let (op_ref, op) = self.head_op()?;
68 +
        if op.view.bookmarks.contains_key(name) {
69 +
            return fail(format!("bookmark {name} already exists"));
70 +
        }
71 +
        let heads = match heads {
72 +
            Some(h) => self.load_state(h)?.maximal(h),
73 +
            None => {
74 +
                let (_, wc) = self.working_copy()?;
75 +
                self.load_state(&wc.deps)?.maximal(&wc.deps)
76 +
            }
77 +
        };
78 +
        let mut view = op.view.clone();
79 +
        view.bookmarks.insert(name.to_string(), self.store.put_state(&StateObject::new(heads.clone()))?);
80 +
        self.record_op(&[op_ref], &format!("oxy bookmark create {name}"), view)?;
81 +
        Ok(heads)
82 +
    }
83 +
84 +
    /// Point an existing bookmark at `heads`.
85 +
    pub fn bookmark_set(&self, name: &str, heads: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
86 +
        let (op_ref, op) = self.head_op()?;
87 +
        if !op.view.bookmarks.contains_key(name) {
88 +
            return fail(format!("no bookmark {name}; create it with `oxy bookmark create`"));
89 +
        }
90 +
        let heads = self.load_state(heads)?.maximal(heads);
91 +
        let mut view = op.view.clone();
92 +
        view.bookmarks.insert(name.to_string(), self.store.put_state(&StateObject::new(heads.clone()))?);
93 +
        self.record_op(&[op_ref], &format!("oxy bookmark set {name}"), view)?;
94 +
        if name == self.config.bookmark {
95 +
            self.drop_manifest();
96 +
        }
97 +
        Ok(heads)
98 +
    }
99 +
100 +
    /// Remove a bookmark; its changes stay until unreachable (spec §21).
101 +
    pub fn bookmark_delete(&self, name: &str) -> Result<()> {
102 +
        if name == self.config.bookmark {
103 +
            return fail(format!("{name} is this workspace's current bookmark; switch to another first"));
104 +
        }
105 +
        for w in self.workspaces()? {
106 +
            if !w.current && self.workspace_bookmark_of(&w.id).as_deref() == Some(name) {
107 +
                return fail(format!("{name} is the current bookmark of workspace {} at {}", w.id.short(8), w.path.display()));
108 +
            }
109 +
        }
110 +
        let (op_ref, op) = self.head_op()?;
111 +
        let mut view = op.view.clone();
112 +
        if view.bookmarks.remove(name).is_none() {
113 +
            return fail(format!("no bookmark {name}"));
114 +
        }
115 +
        self.record_op(&[op_ref], &format!("oxy bookmark delete {name}"), view)?;
116 +
        Ok(())
117 +
    }
118 +
119 +
    fn workspace_bookmark_of(&self, id: &WorkspaceId) -> Option<String> {
120 +
        fs::read_to_string(self.oxy.join("workspaces").join(id.to_hex()).join("bookmark")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
121 +
    }
122 +
123 +
    /// Make `name` this workspace's current bookmark and check it out.
124 +
    pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result<CheckoutReport> {
125 +
        let view = self.view()?;
126 +
        let Some(st) = view.bookmarks.get(name) else { return fail(format!("no bookmark {name}")) };
127 +
        let heads = self.store.get_state(st)?.heads;
128 +
        let report = self.checkout(&heads, discard, &format!("oxy bookmark switch {name}"))?;
129 +
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
130 +
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
131 +
        self.config.bookmark = name.to_string();
132 +
        Ok(report)
133 +
    }
134 +
135 +
    /// Record `name` as the current bookmark of a new workspace.
136 +
    pub(crate) fn set_workspace_bookmark(&self, id: &WorkspaceId, name: &str) -> Result<()> {
137 +
        let dir = self.oxy.join("workspaces").join(id.to_hex());
138 +
        fs::create_dir_all(&dir)?;
139 +
        write_atomic(&dir.join("bookmark"), name.as_bytes())
140 +
    }
141 +
142 +
    /// Union the heads of bookmark `name` into the current bookmark (spec
143 +
    /// §10.1). The working copy follows when it was clean on the old heads.
144 +
    pub fn merge(&self, name: &str) -> Result<MergeReport> {
145 +
        let (op_ref, op) = self.head_op()?;
146 +
        let current = self.config.bookmark.clone();
147 +
        if name == current {
148 +
            return fail(format!("{name} is the current bookmark"));
149 +
        }
150 +
        let Some(st) = op.view.bookmarks.get(name) else { return fail(format!("no bookmark {name}")) };
151 +
        let other = self.store.get_state(st)?.heads;
152 +
        let local = self.heads_of(&op.view, &current)?;
153 +
        let mut all = local.clone();
154 +
        all.extend(other);
155 +
        let mut heads = self.load_state(&all)?.maximal(&all);
156 +
        heads.sort();
157 +
        let mut sorted_local = local.clone();
158 +
        sorted_local.sort();
159 +
        if heads == sorted_local {
160 +
            return Ok(MergeReport { heads, changed: false, checked_out: false });
161 +
        }
162 +
        let mut view = op.view.clone();
163 +
        view.bookmarks.insert(current, self.store.put_state(&StateObject::new(heads.clone()))?);
164 +
        self.record_op(&[op_ref], &format!("oxy merge {name}"), view)?;
165 +
        let snap = self.snapshot("oxy merge")?;
166 +
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
167 +
        let mut base = snap.base.maximal(&snap.wc.deps);
168 +
        base.sort();
169 +
        let mut checked_out = false;
170 +
        if clean && base == sorted_local {
171 +
            self.checkout(&heads, false, "oxy merge (checkout)")?;
172 +
            checked_out = true;
173 +
        }
174 +
        Ok(MergeReport { heads, changed: true, checked_out })
175 +
    }
176 +
}
modified crates/oxy-core/src/repo/mod.rs +29 −4
…
50 unchanged lines
51 51
    Err(RepoError::Msg(s.into()))
52 52
}
53 53
54 +
mod bookmarks;
54 55
mod checkout;
55 56
mod checkpoint;
56 57
mod config;
…
11 unchanged lines
68 69
#[cfg(test)]
69 70
mod tests;
70 71
72 +
pub use bookmarks::{BookmarkInfo, MergeReport};
71 73
pub use checkout::CheckoutReport;
72 74
pub use config::{parse_size, read_config_file, Config};
73 75
pub use gc::GcReport;
…
143 unchanged lines
217 219
                }
218 220
                let config = Config::load(Some(&default_user_config()), &shared.join("config"))?;
219 221
                let store = Store::open(shared.join("objects"))?;
220 -
                return Ok(Repo { root: d.to_path_buf(), oxy: shared, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config });
222 +
                let mut repo = Repo { root: d.to_path_buf(), oxy: shared, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
223 +
                repo.apply_workspace_bookmark();
224 +
                return Ok(repo);
221 225
            }
222 226
            if oxy.is_dir() {
223 227
                let format = fs::read_to_string(oxy.join("format"))?;
…
1 unchanged lines
225 229
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
226 230
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
227 231
                let store = Store::open(oxy.join("objects"))?;
228 -
                let repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
232 +
                let mut repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
233 +
                repo.apply_workspace_bookmark();
229 234
                if format == FORMAT1_LINE {
230 235
                    repo.migrate_format1()?;
231 236
                } else if format != FORMAT_LINE {
…
78 unchanged lines
310 315
        if let Ok(c) = Config::load(Some(&self.user_config), &self.oxy.join("config")) {
311 316
            self.config = c;
312 317
        }
318 +
        self.apply_workspace_bookmark();
313 319
        self
314 320
    }
315 321
322 +
    /// The workspace's current bookmark (spec §10.1) overrides the
323 +
    /// configured default when `oxy bookmark switch` has recorded one.
324 +
    fn apply_workspace_bookmark(&mut self) {
325 +
        if let Ok(b) = fs::read_to_string(self.oxy.join("workspaces").join(self.workspace.to_hex()).join("bookmark")) {
326 +
            let b = b.trim();
327 +
            if !b.is_empty() {
328 +
                self.config.bookmark = b.to_string();
329 +
            }
330 +
        }
331 +
    }
332 +
316 333
    pub fn user_config_path(&self) -> &Path {
317 334
        &self.user_config
318 335
    }
…
357 unchanged lines
676 693
    /// Resolve a spec to a set of heads: a tag name, the current bookmark's
677 694
    /// name, or a single change (`resolve_change`).
678 695
    pub fn resolve_heads(&self, spec: &str) -> Result<Vec<ChangeHash>> {
679 -
        if spec == self.config.bookmark {
680 -
            return self.bookmark_heads();
696 +
        let view = self.view()?;
697 +
        if let Some(st) = view.bookmarks.get(spec) {
698 +
            return Ok(self.store.get_state(st)?.heads);
681 699
        }
682 700
        if let Some(heads) = self.tag_heads(spec)? {
683 701
            return Ok(heads);
…
6 unchanged lines
690 708
        if spec == "@" {
691 709
            return Ok(self.working_copy()?.0);
692 710
        }
711 +
        if let Some(st) = self.view()?.bookmarks.get(spec) {
712 +
            let heads = self.store.get_state(st)?.heads;
713 +
            return match heads.len() {
714 +
                1 => Ok(heads[0]),
715 +
                n => fail(format!("bookmark {spec} has {n} heads; name a change, or use it where a state is expected")),
716 +
            };
717 +
        }
693 718
        if spec.is_empty() || spec == "#" {
694 719
            return fail("empty change specifier");
695 720
        }
…
77 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +10 −1
…
797 unchanged lines
798 798
    /// Push the current bookmark (spec §16.3), sealing this author's unsigned
799 799
    /// changes and attesting the reference when a key is available.
800 800
    pub fn push(&self, remote_name: &str, force: bool) -> Result<Vec<RefResult>> {
801 -
        let (op_ref, op) = self.head_op()?;
802 801
        let bookmark = self.config.bookmark.clone();
802 +
        self.push_bookmark(remote_name, &bookmark, force)
803 +
    }
804 +
805 +
    /// `push` for a named bookmark (`oxy push -b NAME`).
806 +
    pub fn push_bookmark(&self, remote_name: &str, bookmark: &str, force: bool) -> Result<Vec<RefResult>> {
807 +
        let (op_ref, op) = self.head_op()?;
808 +
        if !op.view.bookmarks.contains_key(bookmark) {
809 +
            return fail(format!("no bookmark {bookmark}"));
810 +
        }
811 +
        let bookmark = bookmark.to_string();
803 812
        let local_heads = self.heads_of(&op.view, &bookmark)?;
804 813
        // pre-push hook: may veto before the remote is contacted.
805 814
        self.run_hook(
…
166 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +69 −0
…
2098 unchanged lines
2099 2099
    assert!(err.contains("depends on the split-off paths"), "{err}");
2100 2100
}
2101 2101
2102 +
#[test]
2103 +
fn bookmarks_switch_merge_and_push() {
2104 +
    hermetic();
2105 +
    let dir = tempfile::tempdir().unwrap();
2106 +
    let root = dir.path();
2107 +
    let origin = Repo::init(&root.join("origin")).unwrap();
2108 +
    let url = root.join("origin").to_string_lossy().to_string();
2109 +
    let mut repo = Repo::clone(&url, &root.join("r")).unwrap().with_home(root.join("home"));
2110 +
    let r = root.join("r");
2111 +
    write(&r, "a.txt", b"a\n");
2112 +
    let h_a = repo.commit("a", &[]).unwrap();
2113 +
    assert!(repo.push("origin", false).unwrap()[0].ok);
2114 +
2115 +
    // A bookmark at the working copy's base, then work on it.
2116 +
    assert!(repo.bookmark_create("bad name..", None).is_err());
2117 +
    assert!(repo.bookmark_create("-x", None).is_err());
2118 +
    assert_eq!(repo.bookmark_create("feature", None).unwrap(), vec![h_a]);
2119 +
    assert!(repo.bookmark_create("feature", None).is_err(), "exists");
2120 +
    repo.bookmark_switch("feature", false).unwrap();
2121 +
    assert_eq!(repo.config.bookmark, "feature");
2122 +
    write(&r, "a.txt", b"a\nb\n");
2123 +
    write(&r, "b.txt", b"b\n");
2124 +
    let h_b = repo.commit("b on feature", &[]).unwrap();
2125 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h_b], "b edits a.txt, so it depends on a");
2126 +
    assert_eq!(repo.resolve_heads("main").unwrap(), vec![h_a], "main is untouched");
2127 +
    assert_eq!(repo.resolve_change("feature").unwrap(), h_b, "a one-head bookmark names its change");
2128 +
    // Reopening keeps the workspace's bookmark.
2129 +
    let reopened = Repo::open(&r).unwrap().with_home(root.join("home"));
2130 +
    assert_eq!(reopened.config.bookmark, "feature");
2131 +
    // Back to main: b.txt goes away; work there.
2132 +
    repo.bookmark_switch("main", false).unwrap();
2133 +
    assert!(!r.join("b.txt").exists());
2134 +
    assert_eq!(fs::read(r.join("a.txt")).unwrap(), b"a\n");
2135 +
    write(&r, "c.txt", b"c\n");
2136 +
    let h_c = repo.commit("c on main", &[]).unwrap();
2137 +
    let list = repo.bookmarks().unwrap();
2138 +
    assert_eq!(list.iter().map(|b| (b.name.as_str(), b.current)).collect::<Vec<_>>(), vec![("feature", false), ("main", true)]);
2139 +
    assert_eq!(list.iter().find(|b| b.name == "main").unwrap().ahead, Some(1), "c is not on origin yet");
2140 +
    // Merge feature into main: the union, both heads, no merge change.
2141 +
    let m = repo.merge("feature").unwrap();
2142 +
    assert!(m.changed && m.checked_out);
2143 +
    let mut want = vec![h_b, h_c];
2144 +
    want.sort();
2145 +
    assert_eq!(m.heads, want);
2146 +
    assert_eq!(fs::read(r.join("b.txt")).unwrap(), b"b\n");
2147 +
    assert!(!repo.merge("feature").unwrap().changed, "already merged");
2148 +
    assert!(repo.merge("main").is_err());
2149 +
    // The other bookmark can be pushed by name and appears at origin.
2150 +
    assert!(repo.push_bookmark("origin", "feature", false).unwrap()[0].ok);
2151 +
    assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_b]);
2152 +
    assert!(repo.push("origin", false).unwrap()[0].ok);
2153 +
    assert_eq!(repo.bookmarks().unwrap().iter().find(|b| b.name == "main").unwrap().ahead, Some(0));
2154 +
    // A second workspace inherits the current bookmark and can switch independently.
2155 +
    let ws = repo.workspace_add(&root.join("ws"), None).unwrap();
2156 +
    assert_eq!(ws.config.bookmark, "main");
2157 +
    let mut ws = ws;
2158 +
    ws.bookmark_switch("feature", false).unwrap();
2159 +
    assert_eq!(Repo::open(&r).unwrap().config.bookmark, "main", "the first workspace is unaffected");
2160 +
    assert!(repo.bookmark_delete("feature").is_err(), "current bookmark of another workspace");
2161 +
    ws.bookmark_switch("main", false).unwrap();
2162 +
    // Set and delete.
2163 +
    assert_eq!(repo.bookmark_set("feature", &[h_a]).unwrap(), vec![h_a]);
2164 +
    assert!(repo.bookmark_delete("main").is_err(), "current here");
2165 +
    repo.bookmark_delete("feature").unwrap();
2166 +
    assert!(repo.resolve_heads("feature").is_err());
2167 +
    assert!(repo.bookmark_set("feature", &[h_a]).is_err());
2168 +
    assert!(repo.checkout(&[h_a], false, "checkout").is_ok(), "changes of a deleted bookmark remain");
2169 +
}
2170 +
modified crates/oxy-core/src/repo/workspace.rs +1 −0
…
105 unchanged lines
106 106
        view.workspaces.insert(id, wc_hash);
107 107
        self.record_op(&[op_ref], &format!("oxy workspace add {}", dir.display()), view)?;
108 108
        self.register_workspace(&id, &dir)?;
109 +
        self.set_workspace_bookmark(&id, &self.config.bookmark)?;
109 110
        write_atomic(&dir.join(OXY_DIR), format!("oxydir: {}\nworkspace: {}\n", self.oxy.display(), id.to_hex()).as_bytes())?;
110 111
        let repo = Repo::open(&dir)?.with_home(self.keys_dir.parent().unwrap_or(&self.keys_dir));
111 112
        repo.checkout(&heads, true, "oxy workspace add (checkout)")?;
…
46 unchanged lines
modified src/cli/mod.rs +38 −1
…
135 unchanged lines
136 136
        #[command(subcommand)]
137 137
        cmd: WorkspaceCmd,
138 138
    },
139 +
    /// Bookmarks: named states; each workspace works on one
140 +
    Bookmark {
141 +
        #[command(subcommand)]
142 +
        cmd: BookmarkCmd,
143 +
    },
144 +
    /// Union another bookmark's heads into the current one (no merge change)
145 +
    Merge { bookmark: String },
139 146
    /// Tags: immutable named states
140 147
    Tag {
141 148
        #[command(subcommand)]
…
14 unchanged lines
156 163
        /// Allow dropping heads the remote has
157 164
        #[arg(long)]
158 165
        force: bool,
166 +
        /// Push this bookmark instead of the workspace's current one
167 +
        #[arg(short = 'b', long)]
168 +
        bookmark: Option<String>,
159 169
    },
160 170
    /// Serve repositories under ROOT over HTTPS (with --tls-cert/--tls-key) or plain HTTP
161 171
    Serve {
…
54 unchanged lines
216 226
}
217 227
218 228
#[derive(Subcommand)]
229 +
enum BookmarkCmd {
230 +
    /// Create a bookmark at CHANGE... (default: the working copy's base)
231 +
    Create { name: String, changes: Vec<String> },
232 +
    /// Point an existing bookmark at CHANGE...
233 +
    Set { name: String, changes: Vec<String> },
234 +
    /// Remove a bookmark; its changes stay
235 +
    Delete { name: String },
236 +
    /// List bookmarks with their heads and difference from origin
237 +
    List,
238 +
    /// Make NAME this workspace's bookmark and check it out
239 +
    Switch {
240 +
        name: String,
241 +
        /// Throw away uncommitted working-copy changes
242 +
        #[arg(long)]
243 +
        discard: bool,
244 +
    },
245 +
}
246 +
247 +
#[derive(Subcommand)]
219 248
enum TagCmd {
220 249
    /// Tag the state of CHANGE... (default: the bookmark heads)
221 250
    Create {
…
188 unchanged lines
410 439
            WorkspaceCmd::List => workspace::workspace_list(),
411 440
            WorkspaceCmd::Forget { workspace } => workspace::workspace_forget(&workspace),
412 441
        },
442 +
        Cmd::Bookmark { cmd } => match cmd {
443 +
            BookmarkCmd::Create { name, changes } => workspace::bookmark_create(&name, &changes),
444 +
            BookmarkCmd::Set { name, changes } => workspace::bookmark_set(&name, &changes),
445 +
            BookmarkCmd::Delete { name } => workspace::bookmark_delete(&name),
446 +
            BookmarkCmd::List => workspace::bookmark_list(),
447 +
            BookmarkCmd::Switch { name, discard } => workspace::bookmark_switch(&name, discard),
448 +
        },
449 +
        Cmd::Merge { bookmark } => workspace::merge(&bookmark),
413 450
        Cmd::Tag { cmd } => match cmd {
414 451
            TagCmd::Create { name, changes, message } => workspace::tag_create(&name, &changes, &message),
415 452
            TagCmd::List => workspace::tag_list(),
…
5 unchanged lines
421 458
        },
422 459
        Cmd::Fetch { remote } => sync::fetch(remote.as_deref()),
423 460
        Cmd::Pull { remote } => sync::pull(remote.as_deref()),
424 -
        Cmd::Push { remote, force } => sync::push(remote.as_deref(), force),
461 +
        Cmd::Push { remote, force, bookmark } => sync::push(remote.as_deref(), force, bookmark.as_deref()),
425 462
        Cmd::Serve { root, bind, tokens, tls_cert, tls_key } => sync::serve(root, &bind, tokens, tls_cert.zip(tls_key)),
426 463
        Cmd::Identity { cmd } => match cmd {
427 464
            IdentityCmd::Create { name, email, local, expires } => identity::create(name, email, local, expires),
…
31 unchanged lines
modified src/cli/sync.rs +5 −2
…
105 unchanged lines
106 106
    Ok(0)
107 107
}
108 108
109 -
pub fn push(remote: Option<&str>, force: bool) -> Result<i32> {
109 +
pub fn push(remote: Option<&str>, force: bool, bookmark: Option<&str>) -> Result<i32> {
110 110
    let repo = open()?;
111 111
    let remote = remote_name(remote);
112 112
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
113 -
    let results = repo.push(&remote, force)?;
113 +
    let results = match bookmark {
114 +
        Some(b) => repo.push_bookmark(&remote, b, force)?,
115 +
        None => repo.push(&remote, force)?,
116 +
    };
114 117
    sp.clear();
115 118
    let mut code = 0;
116 119
    for r in results {
…
126 unchanged lines
modified src/cli/workspace.rs +86 −0
…
483 unchanged lines
484 484
    println!("{} into {} {} and {} {}", ui::done("Split"), ui::id(short_id(&c1)), ui::hash(format!("#{}", h1.short(12))), ui::id(short_id(&c2)), ui::hash(format!("#{}", h2.short(12))));
485 485
    Ok(0)
486 486
}
487 +
488 +
fn heads_from(repo: &Repo, changes: &[String]) -> Result<Vec<oxy_core::hash::ChangeHash>> {
489 +
    let mut heads = Vec::new();
490 +
    for c in changes {
491 +
        heads.extend(repo.resolve_heads(c)?);
492 +
    }
493 +
    Ok(heads)
494 +
}
495 +
496 +
pub fn bookmark_create(name: &str, changes: &[String]) -> Result<i32> {
497 +
    let repo = open()?;
498 +
    repo.snapshot("oxy bookmark create")?;
499 +
    let heads = if changes.is_empty() { None } else { Some(heads_from(&repo, changes)?) };
500 +
    let heads = repo.bookmark_create(name, heads.as_deref())?;
501 +
    println!("{} bookmark {} {} {}", ui::done("Created"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")), ui::dim(plural(heads.len(), "head")));
502 +
    println!("  {} {}", ui::note(), ui::dim(format!("switch to it with oxy bookmark switch {name}")));
503 +
    Ok(0)
504 +
}
505 +
506 +
pub fn bookmark_set(name: &str, changes: &[String]) -> Result<i32> {
507 +
    let repo = open()?;
508 +
    repo.snapshot("oxy bookmark set")?;
509 +
    let heads = heads_from(&repo, changes)?;
510 +
    let heads = repo.bookmark_set(name, &heads)?;
511 +
    println!("{} bookmark {} {} {}", ui::done("Moved"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")), ui::dim(plural(heads.len(), "head")));
512 +
    Ok(0)
513 +
}
514 +
515 +
pub fn bookmark_delete(name: &str) -> Result<i32> {
516 +
    let repo = open()?;
517 +
    repo.bookmark_delete(name)?;
518 +
    println!("{} bookmark {} {}", ui::done("Deleted"), ui::bookmark(name), ui::dim("(its changes stay until unreachable)"));
519 +
    Ok(0)
520 +
}
521 +
522 +
pub fn bookmark_list() -> Result<i32> {
523 +
    let repo = open()?;
524 +
    for b in repo.bookmarks()? {
525 +
        let mark = if b.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) };
526 +
        let mut tail = String::new();
527 +
        if let Some(n) = b.ahead.filter(|n| *n > 0) {
528 +
            tail += &ui::ok(format!(" {}{n}", ui::glyph("↑", "+")));
529 +
        }
530 +
        if let Some(n) = b.behind.filter(|n| *n > 0) {
531 +
            tail += &ui::warn(format!(" {}{n}", ui::glyph("↓", "-")));
532 +
        }
533 +
        let head = b.heads.first().map(|h| repo.store().get_change(h).map(|c| ui::subject(subject_of(&c.message))).unwrap_or_default()).unwrap_or_else(|| ui::dim("(empty)"));
534 +
        println!("{mark} {}{}{}{tail}  {head}", ui::bookmark(&b.name), ui::sep(), ui::dim(plural(b.heads.len(), "head")));
535 +
    }
536 +
    Ok(0)
537 +
}
538 +
539 +
pub fn bookmark_switch(name: &str, discard: bool) -> Result<i32> {
540 +
    let mut repo = open()?;
541 +
    let sp = ui::spinner(&format!("Switching to {name}…"));
542 +
    let r = repo.bookmark_switch(name, discard)?;
543 +
    sp.done(&format!("{} to {}: {} written, {} removed", ui::done("Switched"), ui::bookmark(name), plural(r.written, "file"), r.removed));
544 +
    if r.conflicted {
545 +
        println!("  {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
546 +
    }
547 +
    Ok(0)
548 +
}
549 +
550 +
pub fn merge(bookmark: &str) -> Result<i32> {
551 +
    let repo = open()?;
552 +
    let sp = ui::spinner(&format!("Merging {bookmark}…"));
553 +
    let r = repo.merge(bookmark)?;
554 +
    if !r.changed {
555 +
        sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim(format!("{} already contains {bookmark}", repo.config.bookmark))));
556 +
        return Ok(0);
557 +
    }
558 +
    sp.done(&format!(
559 +
        "{} {} into {}: now {}{}",
560 +
        ui::done("Merged"),
561 +
        ui::bookmark(bookmark),
562 +
        ui::bookmark(&repo.config.bookmark),
563 +
        plural(r.heads.len(), "head"),
564 +
        ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" })
565 +
    ));
566 +
    let snap = repo.snapshot("oxy merge")?;
567 +
    if snap.base_snapshot.is_conflicted() {
568 +
        println!("  {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status"));
569 +
    }
570 +
    Ok(0)
571 +
}
572 +