//! 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 the default remote's copy, when tracked. pub ahead: Option, pub behind: Option, /// The remote `ahead` and `behind` are measured against. pub remote: 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" } /// The remote bookmark `spec` names as `REMOTE/NAME`, if `view` has one: /// the remote, the bookmark's name there, and its state. The longest /// matching remote name wins, and a bookmark name may hold `/` itself. pub fn remote_bookmark<'a>(view: &'a View, spec: &str) -> Option<(&'a str, &'a str, ObjectRef)> { view.remotes .iter() .filter_map(|(remote, bookmarks)| { let rest = spec.strip_prefix(remote.as_str())?.strip_prefix('/')?; let (name, state) = bookmarks.get_key_value(rest)?; Some((remote.as_str(), name.as_str(), *state)) }) .max_by_key(|(remote, _, _)| remote.len()) } /// A bookmark a remote has and this repository has no bookmark for. #[derive(Clone, Debug)] pub struct RemoteOnlyBookmark { pub remote: String, pub name: String, pub heads: Vec, } /// What one state lacks of another: see [`Repo::state_gap`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StateGap { /// Changes of the first state recorded before the latest head of the /// second but not in it, oldest first: what Git's ancestry would have /// included. pub earlier: Vec, /// How many changes of the first state the second lacks altogether. pub lacks: usize, /// The heads of the second state together with `earlier`: everything /// of the first state up to the second's latest head. pub upto: Vec, } impl Repo { pub(crate) fn workspace_bookmark_path(&self) -> PathBuf { self.oxy .join("workspaces") .join(self.workspace.to_hex()) .join("bookmark") } /// The remote a command uses when none is named: `origin`, else the /// only remote there is (finding 29 of the metalgear feedback: a /// checkout whose one remote is `earth`). pub fn default_remote(&self) -> String { if let Some(r) = &self.config.default_remote { return r.clone(); } match self.config.remotes.keys().collect::>().as_slice() { [only] if *only != "origin" => (*only).clone(), _ => "origin".into(), } } /// The remote a bookmark is compared with: the default when it has /// the bookmark, else the first remote by name that does (a repository /// with `earth` and `gcs` and no `origin`, oxygen#83). fn tracking_remote(&self, view: &View, name: &str) -> Option { // Only remotes still configured: a removed one's copies linger. let configured = |r: &str| self.config.remotes.contains_key(r); let default = self.default_remote(); if configured(&default) && view .remotes .get(&default) .is_some_and(|b| b.contains_key(name)) { return Some(default); } view.remotes .iter() .find(|(r, b)| configured(r) && b.contains_key(name)) .map(|(r, _)| r.clone()) } /// How bookmark `name` stands against `remote`'s copy of it: the /// changes only here and only there. `None` when either side has no /// such bookmark. pub fn tracking(&self, remote: &str, name: &str) -> Result> { let view = self.view()?; let (Some(st), Some(rs)) = ( view.bookmarks.get(name), view.remotes.get(remote).and_then(|b| b.get(name)), ) else { return Ok(None); }; let heads = self.store.get_state(st)?.heads; let remote_heads = self.store.get_state(rs)?.heads; Ok(Some(( self.negotiate(&remote_heads, &heads)?.len(), self.negotiate(&heads, &remote_heads)?.len(), ))) } /// Every bookmark with its heads and its difference from the default /// remote's copy. 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 remote = self.tracking_remote(&view, name); let (ahead, behind) = match remote .as_ref() .and_then(|r| view.remotes.get(r)) .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, remote, }); } Ok(out) } /// Move `name` to its default remote's state when it is only behind /// it: no local change the remote lacks, so nothing is lost /// (oxygen#74). Returns how many changes it caught up, if any. pub fn catch_up_bookmark(&self, name: &str) -> Result> { let (op_ref, op) = self.head_op()?; let remote = self.default_remote(); let (Some(local), Some(rs)) = ( op.view.bookmarks.get(name).copied(), op.view .remotes .get(&remote) .and_then(|b| b.get(name)) .copied(), ) else { return Ok(None); }; if local == rs { return Ok(None); } let heads = self.store.get_state(&local)?.heads; let remote_heads = self.store.get_state(&rs)?.heads; if !self.negotiate(&remote_heads, &heads)?.is_empty() { return Ok(None); } let behind = self.negotiate(&heads, &remote_heads)?.len(); if behind == 0 { return Ok(None); } let mut view = op.view.clone(); view.bookmarks.insert(name.to_string(), rs); self.record_op( &[op_ref], &format!("oxy bookmark switch {name} (catch up with {remote})"), view, )?; Ok(Some(behind)) } /// Bookmarks of remotes with no local bookmark of the same name, by /// remote then name. pub fn remote_only_bookmarks(&self) -> Result> { let view = self.view()?; let mut out = Vec::new(); for (remote, bookmarks) in &view.remotes { for (name, st) in bookmarks { if view.bookmarks.contains_key(name) { continue; } out.push(RemoteOnlyBookmark { remote: remote.clone(), name: name.clone(), heads: self.store.get_state(st)?.heads, }); } } Ok(out) } /// The local bookmark to switch to for `spec`, creating it from a /// remote's when there is none (as `git switch` does): `NAME` takes /// `origin/NAME` (or the one remote that has it), and `REMOTE/NAME` /// takes that one. Returns the local name and, when it was just /// created, the remote it came from. A local bookmark tracks the /// remote's of the same name: `oxy pull` updates it. pub fn local_bookmark_for(&self, spec: &str) -> Result<(String, Option)> { let (op_ref, op) = self.head_op()?; if op.view.bookmarks.contains_key(spec) { return Ok((spec.to_string(), None)); } let (remote, name, state) = match remote_bookmark(&op.view, spec) { Some((remote, name, state)) => (remote.to_string(), name.to_string(), state), None => { let mut having: Vec<&String> = op .view .remotes .iter() .filter(|(_, b)| b.contains_key(spec)) .map(|(r, _)| r) .collect(); if let Some(i) = having.iter().position(|r| *r == "origin") { having = vec![having[i]]; } match having.as_slice() { [] => return fail(format!("no bookmark {spec}, here or on a remote")), [r] => { let state = op.view.remotes[*r][spec]; (r.to_string(), spec.to_string(), state) } several => { let names: Vec = several.iter().map(|r| format!("{r}/{spec}")).collect(); return fail(format!( "several remotes have {spec}: name one, as {}", names.join(" or ") )); } } } }; if op.view.bookmarks.contains_key(&name) { // REMOTE/NAME where NAME is already local: switch to the local one. return Ok((name, None)); } let mut view = op.view.clone(); view.bookmarks.insert(name.clone(), state); self.record_op( &[op_ref], &format!("oxy bookmark track {remote}/{name}"), view, )?; Ok((name, Some(remote))) } /// 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_history(h)?.maximal(h), None => { let (_, wc) = self.working_copy()?; self.load_state_history(&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) } /// What a state at `at` lacks of the state at `from` (spec §10.1). A /// state is its heads and what they depend on, not everything recorded /// before them, so a bookmark created at one change of a state with /// several heads holds much less than someone used to Git expects. pub fn state_gap(&self, from: &[ChangeHash], at: &[ChangeHash]) -> Result { let mut all = from.to_vec(); all.extend_from_slice(at); let st = self.load_state_history(&all)?; let with_closure = |hs: &[ChangeHash]| { let mut s: HashSet = hs.iter().copied().collect(); for h in hs { s.extend(st.closure(h).iter().copied()); } s }; let from_set = with_closure(from); let at_set = with_closure(at); let key = |h: &ChangeHash| st.get(h).map(|m| (m.hlc, *h)); let last = at.iter().filter_map(key).max(); let missing: Vec = from_set .iter() .filter(|h| !at_set.contains(h)) .copied() .collect(); let mut earlier: Vec = missing .iter() .filter(|h| matches!((key(h), last), (Some(k), Some(l)) if k < l)) .copied() .collect(); earlier.sort_by_key(key); let mut candidates = at.to_vec(); candidates.extend_from_slice(&earlier); Ok(StateGap { lacks: missing.len(), upto: st.maximal(&candidates), earlier, }) } /// 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_history(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 (name, _) = self.local_bookmark_for(name)?; let name = name.as_str(); 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 (name, _) = self.local_bookmark_for(name)?; let name = name.as_str(); 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(); // A subrepo whose nested repository is already at the target's pin // is no carried work: the target records exactly what is on disk // (oxygen#74). let at_target_pin = |p: &String| -> bool { let Some(f) = target_state.files.get(p) else { return false; }; if f.kind != NodeKind::Subrepo { return false; } let FileContent::Opaque { blobs } = &f.content else { return false; }; let Some(want) = blobs .first() .and_then(|b| self.read_blob(b).ok()) .and_then(|b| Pin::decode(&b).ok()) else { return false; }; let hint = self .modules_on_disk() .entries .get(p) .and_then(|m| m.bookmark.clone()); super::subrepos::read_pin(&self.root.join(p), hint.as_deref()) .is_ok_and(|have| have == want) }; touched.retain(|p| !at_target_pin(p)); 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, } }; // Work already the target's: the file on disk is exactly the // target's version (or both are absent), so nothing needs carrying // and it is no conflict (oxygen#100), as when a proposal's work // has landed on the bookmark switched to. let already = |p: &String| -> bool { let full = self.root.join(p); match (fs::symlink_metadata(&full), target_state.files.get(p)) { (Err(_), None) => true, (Ok(m), Some(f)) if m.is_file() && f.kind == NodeKind::File => { if is_executable(&m) != f.mode.exec { return false; } let want = match &f.content { FileContent::Text { bytes, .. } => Some(bytes.clone()), FileContent::Opaque { blobs } if blobs.len() == 1 => { self.read_blob(&blobs[0]).ok() } _ => None, }; want.is_some_and(|w| fs::read(&full).is_ok_and(|have| have == w)) } _ => false, } }; touched.retain(|p| same(p) || !already(p)); 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 { "" } )); } // A subrepo whose pin is carried work keeps its own state: checking // it out at the target's pin would roll it back (oxygen#38). let keep: BTreeSet = touched .iter() .filter(|p| { snap.base_snapshot .files .get(*p) .or_else(|| target_state.files.get(*p)) .is_some_and(|f| f.kind == NodeKind::Subrepo) }) .cloned() .collect(); // The work as it is on disk: content and mode, or absent. let mut carried: Vec<(String, Carried)> = Vec::new(); for p in &touched { if keep.contains(p) { carried.push((p.clone(), Carried::Subrepo)); continue; } 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 mut report = self.checkout_keeping(heads, true, command, &keep)?; 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)); } Carried::Subrepo => {} } } // What was carried is on disk, not removed (oxygen#120): a new file // the target lacks is taken off by the checkout and written back, so // the report names only what is really gone. let back: BTreeSet<&str> = carried .iter() .filter(|(p, item)| { matches!(item, Carried::File(..) | Carried::Symlink(_)) && !target_state.files.contains_key(p) }) .map(|(p, _)| p.as_str()) .collect(); report.removed = report.removed.saturating_sub(back.len()); report.removed_paths.retain(|p| !back.contains(p.as_str())); 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 bookmark here, or a remote's as its tracking bookmark /// holds it (`origin/develop`, oxygen#117), so merging what a remote /// has needs no switch. A working copy without changes follows. pub fn merge(&self, name: &str) -> Result { let _wc = self.lock_working_copy()?; let (_, op) = self.head_op()?; if name == self.config.bookmark { return fail(format!("{name} is the current bookmark")); } let here = op.view.bookmarks.get(name).copied(); let Some(state) = here.or_else(|| remote_bookmark(&op.view, name).map(|(_, _, st)| st)) else { return fail(format!( "no bookmark {name}, here or at a remote (oxy bookmark list --all lists them)" )); }; let other = self.store.get_state(&state)?.heads; self.merge_heads(other, &format!("oxy merge {name}")) } /// Bring CHANGE up to date with `onto` where a rebase would be /// conflicted (oxygen#42): the state of `onto` is merged into the /// current bookmark, which must hold the change, and a working copy /// without changes follows, so the conflict is resolved there and /// committed on top, as after `oxy merge`. pub fn rebase_into_working_copy( &self, c: &ChangeHash, onto: &[ChangeHash], ) -> Result { let _wc = self.lock_working_copy()?; let (_, op) = self.head_op()?; let current = self.config.bookmark.clone(); let local = self.heads_of(&op.view, ¤t)?; if !self.load_state_indexed(&local)?.contains(c) { return fail(format!( "#{} is not on the current bookmark {current}; switch to the bookmark \ holding it first", c.short(12) )); } let named: Vec = onto.iter().map(|h| h.short(12)).collect(); let label = format!( "oxy rebase {} --onto {} --into-working-copy", c.short(12), named.join(" ") ); self.merge_heads(onto.to_vec(), &label) } /// Union `other` into the current bookmark's heads, recorded as /// `label`; a working copy without changes follows. fn merge_heads(&self, other: Vec, label: &str) -> Result { let (op_ref, op) = self.head_op()?; let current = self.config.bookmark.clone(); let local = self.heads_of(&op.view, ¤t)?; let mut all = local.clone(); all.extend(other); let mut heads = self.load_state_history(&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], label, view)?; let snap = self.snapshot(label)?; 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, &format!("{label} (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 { /// A subrepo whose pin is carried: left as it is. Subrepo, Symlink(Vec), /// Executable bit and content. File(bool, Vec), Absent, }