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, ¤t)?; |
|
| 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 | + |