//! Bookmarks (spec §10.1): named states, any number per repository, with a //! current bookmark per workspace. Merging is the union of two bookmarks' //! heads: no merge change, conflicts reified. use super::*; /// One bookmark as `oxy bookmark list` shows it. #[derive(Clone, Debug)] pub struct BookmarkInfo { pub name: String, pub heads: Vec, /// The workspace's current bookmark. pub current: bool, /// Changes ahead of / behind `origin`'s copy, when tracked. pub ahead: Option, pub behind: Option, } #[derive(Clone, Debug)] pub struct MergeReport { pub heads: Vec, pub changed: bool, pub checked_out: bool, } /// Bookmark and tag names (spec §10.1): UTF-8, 1–255 bytes, no control /// characters, no leading `-`, and none of `..`, `//`, `@{`. pub fn valid_reference_name(name: &str) -> bool { !name.is_empty() && name.len() <= 255 && !name.starts_with('-') && !name.chars().any(|c| c.is_control()) && !name.contains("..") && !name.contains("//") && !name.contains("@{") && name != "policy" } impl Repo { fn workspace_bookmark_path(&self) -> PathBuf { self.oxy .join("workspaces") .join(self.workspace.to_hex()) .join("bookmark") } /// Every bookmark with its heads and remote-tracking difference. pub fn bookmarks(&self) -> Result> { let view = self.view()?; let mut out = Vec::new(); for (name, st) in &view.bookmarks { let heads = self.store.get_state(st)?.heads; let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(name)) { Some(rs) => { let remote_heads = self.store.get_state(rs)?.heads; ( self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(), self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok(), ) } None => (None, None), }; out.push(BookmarkInfo { name: name.clone(), heads, current: *name == self.config.bookmark, ahead, behind, }); } Ok(out) } /// Create `name` at `heads` (default: the working copy's base heads). pub fn bookmark_create( &self, name: &str, heads: Option<&[ChangeHash]>, ) -> Result> { if !valid_reference_name(name) { return fail(format!("{name:?} is not a valid bookmark name")); } let (op_ref, op) = self.head_op()?; if op.view.bookmarks.contains_key(name) { return fail(format!("bookmark {name} already exists")); } let heads = match heads { Some(h) => self.load_state(h)?.maximal(h), None => { let (_, wc) = self.working_copy()?; self.load_state(&wc.deps)?.maximal(&wc.deps) } }; let mut view = op.view.clone(); view.bookmarks.insert( name.to_string(), self.store.put_state(&StateObject::new(heads.clone()))?, ); self.record_op(&[op_ref], &format!("oxy bookmark create {name}"), view)?; Ok(heads) } /// Point an existing bookmark at `heads`. pub fn bookmark_set(&self, name: &str, heads: &[ChangeHash]) -> Result> { let (op_ref, op) = self.head_op()?; if !op.view.bookmarks.contains_key(name) { return fail(format!( "no bookmark {name}; create it with `oxy bookmark create`" )); } let heads = self.load_state(heads)?.maximal(heads); let mut view = op.view.clone(); view.bookmarks.insert( name.to_string(), self.store.put_state(&StateObject::new(heads.clone()))?, ); self.record_op(&[op_ref], &format!("oxy bookmark set {name}"), view)?; if name == self.config.bookmark { self.drop_manifest(); } Ok(heads) } /// Remove a bookmark; its changes stay until unreachable (spec §21). pub fn bookmark_delete(&self, name: &str) -> Result<()> { if name == self.config.bookmark { return fail(format!( "{name} is this workspace's current bookmark; switch to another first" )); } for w in self.workspaces()? { if !w.current && self.workspace_bookmark_of(&w.id).as_deref() == Some(name) { return fail(format!( "{name} is the current bookmark of workspace {} at {}", w.id.short(8), w.path.display() )); } } let (op_ref, op) = self.head_op()?; let mut view = op.view.clone(); if view.bookmarks.remove(name).is_none() { return fail(format!("no bookmark {name}")); } self.record_op(&[op_ref], &format!("oxy bookmark delete {name}"), view)?; Ok(()) } fn workspace_bookmark_of(&self, id: &WorkspaceId) -> Option { 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()) } /// Make `name` this workspace's current bookmark and check it out. pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result { let view = self.view()?; let Some(st) = view.bookmarks.get(name) else { return fail(format!("no bookmark {name}")); }; let heads = self.store.get_state(st)?.heads; let report = self.checkout(&heads, discard, &format!("oxy bookmark switch {name}"))?; fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?; write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?; self.config.bookmark = name.to_string(); Ok(report) } /// Record `name` as the current bookmark of a new workspace. pub(crate) fn set_workspace_bookmark(&self, id: &WorkspaceId, name: &str) -> Result<()> { let dir = self.oxy.join("workspaces").join(id.to_hex()); fs::create_dir_all(&dir)?; write_atomic(&dir.join("bookmark"), name.as_bytes()) } /// Union the heads of bookmark `name` into the current bookmark (spec /// §10.1). The working copy follows when it was clean on the old heads. pub fn merge(&self, name: &str) -> Result { let (op_ref, op) = self.head_op()?; let current = self.config.bookmark.clone(); if name == current { return fail(format!("{name} is the current bookmark")); } let Some(st) = op.view.bookmarks.get(name) else { return fail(format!("no bookmark {name}")); }; let other = self.store.get_state(st)?.heads; let local = self.heads_of(&op.view, ¤t)?; let mut all = local.clone(); all.extend(other); let mut heads = self.load_state(&all)?.maximal(&all); heads.sort(); let mut sorted_local = local.clone(); sorted_local.sort(); if heads == sorted_local { return Ok(MergeReport { heads, changed: false, checked_out: false, }); } let mut view = op.view.clone(); view.bookmarks.insert( current, self.store.put_state(&StateObject::new(heads.clone()))?, ); self.record_op(&[op_ref], &format!("oxy merge {name}"), view)?; let snap = self.snapshot("oxy merge")?; let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); let mut base = snap.base.maximal(&snap.wc.deps); base.sort(); let mut checked_out = false; if clean && base == sorted_local { self.checkout(&heads, false, "oxy merge (checkout)")?; checked_out = true; } Ok(MergeReport { heads, changed: true, checked_out, }) } }