//! 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 _wc = self.lock_working_copy()?; 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) } /// Switch to `name` taking uncommitted work along (spec §10.1), as `git /// switch` does: see [`Repo::checkout_carry`]. Returns the checkout /// report and the carried paths. pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec)> { let _wc = self.lock_working_copy()?; 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 out = self.checkout_carry(&heads, name, &format!("oxy bookmark switch {name} --carry"))?; 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(); self.snapshot(&format!("oxy bookmark switch {name} --carry"))?; Ok(out) } /// Check out `heads` taking uncommitted work along: every path the /// working copy changes must be the same in the target state as in the /// working copy's base, so carrying the files over applies exactly the /// same edits there. Otherwise nothing happens and the error names the /// paths that differ (`target` names the state in it). Returns the /// checkout report and the carried paths. pub fn checkout_carry( &self, heads: &[ChangeHash], target: &str, command: &str, ) -> Result<(CheckoutReport, Vec)> { let _wc = self.lock_working_copy()?; let snap = self.snapshot(command)?; // Every path the work touches: changed ones, and base files it // removed or renamed away (a rename reports only its new path). let mut touched: BTreeSet = snap.statuses.iter().map(|s| s.path.clone()).collect(); for (p, f) in &snap.base_snapshot.files { if f.kind != NodeKind::Dir && f.kind != NodeKind::Subrepo && fs::symlink_metadata(self.root.join(p)).is_err() { touched.insert(p.clone()); } } let target_state = self.load_state(heads)?.materialize(); let same = |p: &String| { let a = snap.base_snapshot.files.get(p); let b = target_state.files.get(p); match (a, b) { (None, None) => true, (Some(a), Some(b)) => { a.kind == b.kind && a.mode == b.mode && a.content == b.content } _ => false, } }; let differ: Vec<&String> = touched.iter().filter(|p| !same(p)).collect(); if !differ.is_empty() { let list: Vec<&str> = differ.iter().take(10).map(|p| p.as_str()).collect(); return fail(format!( "{target} has other versions of {} you changed ({}{}); commit first, or drop the changes with --discard", if differ.len() == 1 { "a file" } else { "files" }, list.join(", "), if differ.len() > 10 { ", …" } else { "" } )); } // The work as it is on disk: content and mode, or absent. let mut carried: Vec<(String, Carried)> = Vec::new(); for p in &touched { let full = self.root.join(p); let item = match fs::symlink_metadata(&full) { Ok(m) if m.file_type().is_symlink() => { Carried::Symlink(fs::read_link(&full)?.to_string_lossy().as_bytes().to_vec()) } Ok(m) if m.is_file() => Carried::File(is_executable(&m), fs::read(&full)?), Ok(_) => continue, Err(_) => Carried::Absent, }; carried.push((p.clone(), item)); } let report = self.checkout(heads, true, command)?; for (p, item) in &carried { match item { Carried::Symlink(target) => self.write_symlink(p, target)?, Carried::File(exec, bytes) => self.write_file(p, bytes, *exec)?, Carried::Absent => { let _ = fs::remove_file(self.root.join(p)); } } } self.drop_manifest(); self.snapshot(command)?; Ok((report, carried.into_iter().map(|(p, _)| p).collect())) } /// 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). A working copy without changes follows. pub fn merge(&self, name: &str) -> Result { let _wc = self.lock_working_copy()?; 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; // A working copy without changes follows the merge, whichever state // it sat on: nothing of the user's can be lost. if clean { self.checkout(&heads, false, "oxy merge (checkout)")?; checked_out = true; } Ok(MergeReport { heads, changed: true, checked_out, }) } } /// A path's state on disk, taken along by `bookmark switch --carry`. enum Carried { Symlink(Vec), /// Executable bit and content. File(bool, Vec), Absent, }