Remote bookmarks work like local ones; a newer format names the oxy that cannot read it (closes #48, closes #49) 14edbaaf5291
origin/NAME names a remote's bookmark wherever a bookmark is expected, and oxy bookmark list --all lists the ones with no local bookmark. oxy bookmark switch NAME makes a local bookmark from origin/NAME (or REMOTE/NAME) when there is none. oxy pull now unions the remote into every local bookmark it also has, which a union makes safe, and names the others it moved. The refs response names the default bookmark (key 5): a server reports its host's default, else the repository's bookmark. A clone checks it out, dropping the empty bookmark init made, says how many other bookmarks the remote has, and takes --recursive to clone the subrepos. A clone, switch or pull says when subrepos are not at their pins. A format version this oxy cannot read now says whether a newer or older oxy wrote it, with this oxy's version and path, so a stale copy earlier on PATH shows.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 13:42 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
14edbaaf52916eab137492308fa167c5ff1c78fe724eeee808e791d96038d132- Depends on
- 132b884b9a8d 2863456a0f6d 28fc23d7706a 314db9b4f3f8 4dee277c327a 4fe7d6045860 57eaac9107df 5b9c6ad45f6e 61e3bce7ea5b 6a81dbab471d 6caacfff1d4f 750251b21a96 75167672ebcd 7b9809e67c3d 7d9dab131937 83e0677b07fe 86e1824f2d0b 8fd78eb52ad2 982864068a59 b3c5a59a875a c6aa5bd9d1c8 d42369abf180 e02bf0cd0533 ee06a6c557b7 f8c695764c95 fce44ae9ad29 ff0af1ee299d ff1f62fee654
- Depended on by
- ee130dd9eef2 d36ba2f79f95 028545dfa7a4 733ea16a9701 4b9d06778581 f53cbe7a204e f6dccc4533c8 42ccf504aa50 72484195899a bf3a67819b59 7993d465675b 9aa399044d9d 9f9e72b93e4e 98a1620a3840 2bf22305bc48
- 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.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
- 72 atoms, 0 tree operations, 14 files
Browse the tree at this change
modified CHANGELOG.md +7 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **A remote's bookmarks work like local ones (#48).** |
|
| 6 | + | - `origin/NAME` names a remote's bookmark wherever a bookmark is expected, and `oxy bookmark list --all` lists those with no local bookmark. |
|
| 7 | + | - `oxy bookmark switch NAME` makes a local bookmark from `origin/NAME` when there is none (`REMOTE/NAME` picks another remote). |
|
| 8 | + | - `oxy pull` updates every local bookmark the remote has too, not only the current one, and names the others it moved. |
|
| 9 | + | - `oxy clone` checks out the remote's default bookmark (servers now name it in `refs`), says how many other bookmarks the remote has, and takes `--recursive` to clone the subrepos. A clone, switch or pull says when subrepos are not at their pins. |
|
| 10 | + | - **A newer repository format names the oxy that cannot read it (#49).** Instead of "unsupported format version 2", oxy says the data was written by a newer oxy, and gives its own version and path, which shows an older copy earlier on `PATH`. |
|
| 11 | + | ||
| 5 | 12 | - **Sign in once per hub: `oxy auth login`, `status` and `logout` (#47).** `oxy auth login https://oxygenhub.io` signs in through the browser, and every repository on that hub then clones, fetches and pushes with the token, subrepos included. Nothing secret goes into a checkout any more. |
|
| 6 | 13 | - The token is kept in `~/.config/oxy/credentials.toml`, readable by you only, or in the system keyring through a Git credential helper (`oxy config --user auth.helper libsecret`). |
|
| 7 | 14 | - `--with-token` reads a token from standard input, for CI and scripts. `oxy auth logout` also revokes the token on the hub. |
|
| … | 35 unchanged lines |
modified crates/oxy-core/src/objects.rs +13 −1
| … | 76 unchanged lines |
||
| 77 | 77 | pub(crate) fn check_version(m: &Map) -> Result<(), ObjectError> { |
|
| 78 | 78 | let v = uint(field(m, 0, "version")?, "version")?; |
|
| 79 | 79 | if v != FORMAT_VERSION { |
|
| 80 | - | return schema(format!("unsupported format version {v}")); |
|
| 80 | + | let exe = std::env::current_exe() |
|
| 81 | + | .map(|p| format!(" at {}", p.display())) |
|
| 82 | + | .unwrap_or_default(); |
|
| 83 | + | let which = format!("oxy {}{exe}", crate::VERSION); |
|
| 84 | + | return schema(if v > FORMAT_VERSION { |
|
| 85 | + | format!( |
|
| 86 | + | "this data is format {v}, written by a newer oxy, and {which} reads format {FORMAT_VERSION}: install the current oxy, and check that no older copy comes first on PATH" |
|
| 87 | + | ) |
|
| 88 | + | } else { |
|
| 89 | + | format!( |
|
| 90 | + | "this data is format {v}, written by an older oxy, and {which} reads format {FORMAT_VERSION} only" |
|
| 91 | + | ) |
|
| 92 | + | }); |
|
| 81 | 93 | } |
|
| 82 | 94 | Ok(()) |
|
| 83 | 95 | } |
|
| … | 1448 unchanged lines |
modified crates/oxy-core/src/protocol.rs +15 −0
| … | 53 unchanged lines |
||
| 54 | 54 | /// Earlier policies, ascending by serial, so a client behind by more |
|
| 55 | 55 | /// than one update can verify each step (spec §14.5). |
|
| 56 | 56 | pub history: Vec<SignedPolicy>, |
|
| 57 | + | /// The bookmark a clone checks out (spec §16.1), when the remote names one. |
|
| 58 | + | pub default_bookmark: Option<String>, |
|
| 57 | 59 | } |
|
| 58 | 60 | ||
| 59 | 61 | /// A tag as a remote reports it (spec §10.1). |
|
| … | 736 unchanged lines |
||
| 796 | 798 | 4, |
|
| 797 | 799 | Value::Array(resp.history.iter().map(policy_value).collect()), |
|
| 798 | 800 | ), |
|
| 801 | + | ( |
|
| 802 | + | 5, |
|
| 803 | + | resp.default_bookmark |
|
| 804 | + | .clone() |
|
| 805 | + | .map(Value::Text) |
|
| 806 | + | .unwrap_or(Value::Null), |
|
| 807 | + | ), |
|
| 799 | 808 | ])) |
|
| 800 | 809 | } |
|
| 801 | 810 | pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], RefsResponse)> { |
|
| … | 32 unchanged lines |
||
| 834 | 843 | history.push(policy_from(p)?); |
|
| 835 | 844 | } |
|
| 836 | 845 | } |
|
| 846 | + | let default_bookmark = match m.get(&5) { |
|
| 847 | + | Some(Value::Text(t)) => Some(t.clone()), |
|
| 848 | + | _ => None, |
|
| 849 | + | }; |
|
| 837 | 850 | Ok(( |
|
| 838 | 851 | id, |
|
| 839 | 852 | RefsResponse { |
|
| … | 1 unchanged lines |
||
| 841 | 854 | policy, |
|
| 842 | 855 | tags, |
|
| 843 | 856 | history, |
|
| 857 | + | default_bookmark, |
|
| 844 | 858 | }, |
|
| 845 | 859 | )) |
|
| 846 | 860 | } |
|
| … | 243 unchanged lines |
||
| 1090 | 1104 | policy: policy.clone(), |
|
| 1091 | 1105 | sigs: vec![], |
|
| 1092 | 1106 | }], |
|
| 1107 | + | default_bookmark: Some("develop".into()), |
|
| 1093 | 1108 | }; |
|
| 1094 | 1109 | let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap(); |
|
| 1095 | 1110 | assert_eq!(id_back, [3; 16]); |
|
| … | 77 unchanged lines |
modified crates/oxy-core/src/repo/bookmarks.rs +102 −1
| … | 35 unchanged lines |
||
| 36 | 36 | && name != "policy" |
|
| 37 | 37 | } |
|
| 38 | 38 | ||
| 39 | + | /// The remote bookmark `spec` names as `REMOTE/NAME`, if `view` has one: |
|
| 40 | + | /// the remote, the bookmark's name there, and its state. The longest |
|
| 41 | + | /// matching remote name wins, and a bookmark name may hold `/` itself. |
|
| 42 | + | pub fn remote_bookmark<'a>(view: &'a View, spec: &str) -> Option<(&'a str, &'a str, ObjectRef)> { |
|
| 43 | + | view.remotes |
|
| 44 | + | .iter() |
|
| 45 | + | .filter_map(|(remote, bookmarks)| { |
|
| 46 | + | let rest = spec.strip_prefix(remote.as_str())?.strip_prefix('/')?; |
|
| 47 | + | let (name, state) = bookmarks.get_key_value(rest)?; |
|
| 48 | + | Some((remote.as_str(), name.as_str(), *state)) |
|
| 49 | + | }) |
|
| 50 | + | .max_by_key(|(remote, _, _)| remote.len()) |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | /// A bookmark a remote has and this repository has no bookmark for. |
|
| 54 | + | #[derive(Clone, Debug)] |
|
| 55 | + | pub struct RemoteOnlyBookmark { |
|
| 56 | + | pub remote: String, |
|
| 57 | + | pub name: String, |
|
| 58 | + | pub heads: Vec<ChangeHash>, |
|
| 59 | + | } |
|
| 60 | + | ||
| 39 | 61 | /// What one state lacks of another: see [`Repo::state_gap`]. |
|
| 40 | 62 | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| 41 | 63 | pub struct StateGap { |
|
| … | 9 unchanged lines |
||
| 51 | 73 | } |
|
| 52 | 74 | ||
| 53 | 75 | impl Repo { |
|
| 54 | - | fn workspace_bookmark_path(&self) -> PathBuf { |
|
| 76 | + | pub(crate) fn workspace_bookmark_path(&self) -> PathBuf { |
|
| 55 | 77 | self.oxy |
|
| 56 | 78 | .join("workspaces") |
|
| 57 | 79 | .join(self.workspace.to_hex()) |
|
| … | 25 unchanged lines |
||
| 83 | 105 | }); |
|
| 84 | 106 | } |
|
| 85 | 107 | Ok(out) |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// Bookmarks of remotes with no local bookmark of the same name, by |
|
| 111 | + | /// remote then name. |
|
| 112 | + | pub fn remote_only_bookmarks(&self) -> Result<Vec<RemoteOnlyBookmark>> { |
|
| 113 | + | let view = self.view()?; |
|
| 114 | + | let mut out = Vec::new(); |
|
| 115 | + | for (remote, bookmarks) in &view.remotes { |
|
| 116 | + | for (name, st) in bookmarks { |
|
| 117 | + | if view.bookmarks.contains_key(name) { |
|
| 118 | + | continue; |
|
| 119 | + | } |
|
| 120 | + | out.push(RemoteOnlyBookmark { |
|
| 121 | + | remote: remote.clone(), |
|
| 122 | + | name: name.clone(), |
|
| 123 | + | heads: self.store.get_state(st)?.heads, |
|
| 124 | + | }); |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | Ok(out) |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /// The local bookmark to switch to for `spec`, creating it from a |
|
| 131 | + | /// remote's when there is none (as `git switch` does): `NAME` takes |
|
| 132 | + | /// `origin/NAME` (or the one remote that has it), and `REMOTE/NAME` |
|
| 133 | + | /// takes that one. Returns the local name and, when it was just |
|
| 134 | + | /// created, the remote it came from. A local bookmark tracks the |
|
| 135 | + | /// remote's of the same name: `oxy pull` updates it. |
|
| 136 | + | pub fn local_bookmark_for(&self, spec: &str) -> Result<(String, Option<String>)> { |
|
| 137 | + | let (op_ref, op) = self.head_op()?; |
|
| 138 | + | if op.view.bookmarks.contains_key(spec) { |
|
| 139 | + | return Ok((spec.to_string(), None)); |
|
| 140 | + | } |
|
| 141 | + | let (remote, name, state) = match remote_bookmark(&op.view, spec) { |
|
| 142 | + | Some((remote, name, state)) => (remote.to_string(), name.to_string(), state), |
|
| 143 | + | None => { |
|
| 144 | + | let mut having: Vec<&String> = op |
|
| 145 | + | .view |
|
| 146 | + | .remotes |
|
| 147 | + | .iter() |
|
| 148 | + | .filter(|(_, b)| b.contains_key(spec)) |
|
| 149 | + | .map(|(r, _)| r) |
|
| 150 | + | .collect(); |
|
| 151 | + | if let Some(i) = having.iter().position(|r| *r == "origin") { |
|
| 152 | + | having = vec![having[i]]; |
|
| 153 | + | } |
|
| 154 | + | match having.as_slice() { |
|
| 155 | + | [] => return fail(format!("no bookmark {spec}, here or on a remote")), |
|
| 156 | + | [r] => { |
|
| 157 | + | let state = op.view.remotes[*r][spec]; |
|
| 158 | + | (r.to_string(), spec.to_string(), state) |
|
| 159 | + | } |
|
| 160 | + | several => { |
|
| 161 | + | let names: Vec<String> = |
|
| 162 | + | several.iter().map(|r| format!("{r}/{spec}")).collect(); |
|
| 163 | + | return fail(format!( |
|
| 164 | + | "several remotes have {spec}: name one, as {}", |
|
| 165 | + | names.join(" or ") |
|
| 166 | + | )); |
|
| 167 | + | } |
|
| 168 | + | } |
|
| 169 | + | } |
|
| 170 | + | }; |
|
| 171 | + | if op.view.bookmarks.contains_key(&name) { |
|
| 172 | + | // REMOTE/NAME where NAME is already local: switch to the local one. |
|
| 173 | + | return Ok((name, None)); |
|
| 174 | + | } |
|
| 175 | + | let mut view = op.view.clone(); |
|
| 176 | + | view.bookmarks.insert(name.clone(), state); |
|
| 177 | + | self.record_op( |
|
| 178 | + | &[op_ref], |
|
| 179 | + | &format!("oxy bookmark track {remote}/{name}"), |
|
| 180 | + | view, |
|
| 181 | + | )?; |
|
| 182 | + | Ok((name, Some(remote))) |
|
| 86 | 183 | } |
|
| 87 | 184 | ||
| 88 | 185 | /// Create `name` at `heads` (default: the working copy's base heads). |
|
| … | 125 unchanged lines |
||
| 214 | 311 | /// Make `name` this workspace's current bookmark and check it out. |
|
| 215 | 312 | pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result<CheckoutReport> { |
|
| 216 | 313 | let _wc = self.lock_working_copy()?; |
|
| 314 | + | let (name, _) = self.local_bookmark_for(name)?; |
|
| 315 | + | let name = name.as_str(); |
|
| 217 | 316 | let view = self.view()?; |
|
| 218 | 317 | let Some(st) = view.bookmarks.get(name) else { |
|
| 219 | 318 | return fail(format!("no bookmark {name}")); |
|
| … | 11 unchanged lines |
||
| 231 | 330 | /// report and the carried paths. |
|
| 232 | 331 | pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> { |
|
| 233 | 332 | let _wc = self.lock_working_copy()?; |
|
| 333 | + | let (name, _) = self.local_bookmark_for(name)?; |
|
| 334 | + | let name = name.as_str(); |
|
| 234 | 335 | let view = self.view()?; |
|
| 235 | 336 | let Some(st) = view.bookmarks.get(name) else { |
|
| 236 | 337 | return fail(format!("no bookmark {name}")); |
|
| … | 173 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +14 −6
| … | 81 unchanged lines |
||
| 82 | 82 | mod tests; |
|
| 83 | 83 | mod workspace; |
|
| 84 | 84 | ||
| 85 | - | pub use bookmarks::{BookmarkInfo, MergeReport, StateGap}; |
|
| 85 | + | pub use bookmarks::{remote_bookmark, BookmarkInfo, MergeReport, RemoteOnlyBookmark, StateGap}; |
|
| 86 | 86 | pub use checkout::CheckoutReport; |
|
| 87 | 87 | pub use config::{parse_size, read_config_file, Config}; |
|
| 88 | 88 | pub use doctor::{Check, DoctorReport, Health}; |
|
| … | 1655 unchanged lines |
||
| 1744 | 1744 | entries |
|
| 1745 | 1745 | } |
|
| 1746 | 1746 | ||
| 1747 | - | /// Resolve a spec to a set of heads: a tag name, the current bookmark's |
|
| 1748 | - | /// name, or a single change (`resolve_change`). |
|
| 1747 | + | /// Resolve a spec to a set of heads: a bookmark's name, a remote's |
|
| 1748 | + | /// bookmark as `REMOTE/NAME`, a tag name, or a single change |
|
| 1749 | + | /// (`resolve_change`). |
|
| 1749 | 1750 | pub fn resolve_heads(&self, spec: &str) -> Result<Vec<ChangeHash>> { |
|
| 1750 | 1751 | let view = self.view()?; |
|
| 1751 | 1752 | if let Some(st) = view.bookmarks.get(spec) { |
|
| 1752 | 1753 | return Ok(self.store.get_state(st)?.heads); |
|
| 1753 | 1754 | } |
|
| 1755 | + | if let Some((_, _, st)) = remote_bookmark(&view, spec) { |
|
| 1756 | + | return Ok(self.store.get_state(&st)?.heads); |
|
| 1757 | + | } |
|
| 1754 | 1758 | if let Some(heads) = self.tag_heads(spec)? { |
|
| 1755 | 1759 | return Ok(heads); |
|
| 1756 | 1760 | } |
|
| … | 1 unchanged lines |
||
| 1758 | 1762 | } |
|
| 1759 | 1763 | ||
| 1760 | 1764 | /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working |
|
| 1761 | - | /// copy), bookmark or tag (the one head of its state). |
|
| 1765 | + | /// copy), bookmark, `REMOTE/NAME` or tag (the one head of its state). |
|
| 1762 | 1766 | pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> { |
|
| 1763 | 1767 | if spec == "@" { |
|
| 1764 | 1768 | return Ok(self.working_copy()?.0); |
|
| 1765 | 1769 | } |
|
| 1766 | - | let named = match self.view()?.bookmarks.get(spec) { |
|
| 1770 | + | let view = self.view()?; |
|
| 1771 | + | let named = match view.bookmarks.get(spec) { |
|
| 1767 | 1772 | Some(st) => Some(("bookmark", self.store.get_state(st)?.heads)), |
|
| 1768 | - | None => self.tag_heads(spec)?.map(|h| ("tag", h)), |
|
| 1773 | + | None => match remote_bookmark(&view, spec) { |
|
| 1774 | + | Some((_, _, st)) => Some(("bookmark", self.store.get_state(&st)?.heads)), |
|
| 1775 | + | None => self.tag_heads(spec)?.map(|h| ("tag", h)), |
|
| 1776 | + | }, |
|
| 1769 | 1777 | }; |
|
| 1770 | 1778 | if let Some((kind, heads)) = named { |
|
| 1771 | 1779 | return match heads.len() { |
|
| … | 181 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +95 −45
| … | 33 unchanged lines |
||
| 34 | 34 | ||
| 35 | 35 | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| 36 | 36 | pub struct PullReport { |
|
| 37 | + | /// The current bookmark's heads after the pull. |
|
| 37 | 38 | pub heads: Vec<ChangeHash>, |
|
| 39 | + | /// Whether the current bookmark changed. |
|
| 38 | 40 | pub changed: bool, |
|
| 39 | 41 | pub checked_out: bool, |
|
| 42 | + | /// Other bookmarks the pull moved: each local bookmark tracks the |
|
| 43 | + | /// remote's of the same name. |
|
| 44 | + | pub others: Vec<String>, |
|
| 40 | 45 | } |
|
| 41 | 46 | ||
| 42 | 47 | impl Repo { |
|
| … | 60 unchanged lines |
||
| 103 | 108 | policy, |
|
| 104 | 109 | tags: self.tag_refs()?, |
|
| 105 | 110 | history, |
|
| 111 | + | default_bookmark: Some(self.config.bookmark.clone()), |
|
| 106 | 112 | }) |
|
| 107 | 113 | } |
|
| 108 | 114 | ||
| … | 1282 unchanged lines |
||
| 1391 | 1397 | let _wc = self.lock_working_copy()?; |
|
| 1392 | 1398 | self.fetch(remote_name)?; |
|
| 1393 | 1399 | let (op_ref, op) = self.head_op()?; |
|
| 1394 | - | let bookmark = self.config.bookmark.clone(); |
|
| 1395 | - | let local = self.heads_of(&op.view, &bookmark)?; |
|
| 1396 | - | let Some(remote_state) = op |
|
| 1400 | + | let current = self.config.bookmark.clone(); |
|
| 1401 | + | let mut local_current = self.heads_of(&op.view, ¤t)?; |
|
| 1402 | + | local_current.sort(); |
|
| 1403 | + | let mut view = op.view.clone(); |
|
| 1404 | + | let mut current_heads = local_current.clone(); |
|
| 1405 | + | let mut changed = false; |
|
| 1406 | + | let mut others = Vec::new(); |
|
| 1407 | + | let tracked = op |
|
| 1397 | 1408 | .view |
|
| 1398 | 1409 | .remotes |
|
| 1399 | 1410 | .get(remote_name) |
|
| 1400 | - | .and_then(|b| b.get(&bookmark)) |
|
| 1401 | - | .copied() |
|
| 1402 | - | else { |
|
| 1411 | + | .cloned() |
|
| 1412 | + | .unwrap_or_default(); |
|
| 1413 | + | for (name, remote_state) in tracked { |
|
| 1414 | + | // Every local bookmark tracks the remote's of the same name; |
|
| 1415 | + | // the current one may be new here. |
|
| 1416 | + | let local_state = op.view.bookmarks.get(&name).copied(); |
|
| 1417 | + | if local_state.is_none() && name != current { |
|
| 1418 | + | continue; |
|
| 1419 | + | } |
|
| 1420 | + | if local_state == Some(remote_state) { |
|
| 1421 | + | continue; |
|
| 1422 | + | } |
|
| 1423 | + | let local = self.heads_of(&op.view, &name)?; |
|
| 1424 | + | let remote_heads = self.store.get_state(&remote_state)?.heads; |
|
| 1425 | + | if remote_heads.iter().all(|h| local.contains(h)) { |
|
| 1426 | + | continue; |
|
| 1427 | + | } |
|
| 1428 | + | let mut all = local.clone(); |
|
| 1429 | + | all.extend(remote_heads); |
|
| 1430 | + | let st = self.load_state_history(&all)?; |
|
| 1431 | + | let mut heads = st.maximal(&all); |
|
| 1432 | + | heads.sort(); |
|
| 1433 | + | let mut sorted_local = local; |
|
| 1434 | + | sorted_local.sort(); |
|
| 1435 | + | if heads == sorted_local { |
|
| 1436 | + | continue; |
|
| 1437 | + | } |
|
| 1438 | + | view.bookmarks.insert( |
|
| 1439 | + | name.clone(), |
|
| 1440 | + | self.store.put_state(&StateObject::new(heads.clone()))?, |
|
| 1441 | + | ); |
|
| 1442 | + | if name == current { |
|
| 1443 | + | changed = true; |
|
| 1444 | + | current_heads = heads; |
|
| 1445 | + | } else { |
|
| 1446 | + | others.push(name); |
|
| 1447 | + | } |
|
| 1448 | + | } |
|
| 1449 | + | if !changed && others.is_empty() { |
|
| 1403 | 1450 | return Ok(PullReport { |
|
| 1404 | - | heads: local, |
|
| 1451 | + | heads: local_current, |
|
| 1405 | 1452 | changed: false, |
|
| 1406 | 1453 | checked_out: false, |
|
| 1454 | + | others, |
|
| 1407 | 1455 | }); |
|
| 1408 | - | }; |
|
| 1409 | - | let remote_heads = self.store.get_state(&remote_state)?.heads; |
|
| 1410 | - | let mut all = local.clone(); |
|
| 1411 | - | all.extend(remote_heads); |
|
| 1412 | - | let st = self.load_state_history(&all)?; |
|
| 1413 | - | let mut heads = st.maximal(&all); |
|
| 1414 | - | heads.sort(); |
|
| 1415 | - | let mut sorted_local = local.clone(); |
|
| 1416 | - | sorted_local.sort(); |
|
| 1417 | - | if heads == sorted_local { |
|
| 1418 | - | return Ok(PullReport { |
|
| 1419 | - | heads, |
|
| 1420 | - | changed: false, |
|
| 1421 | - | checked_out: false, |
|
| 1422 | - | }); |
|
| 1423 | 1456 | } |
|
| 1424 | - | let mut view = op.view.clone(); |
|
| 1425 | - | view.bookmarks.insert( |
|
| 1426 | - | bookmark.clone(), |
|
| 1427 | - | self.store.put_state(&StateObject::new(heads.clone()))?, |
|
| 1428 | - | ); |
|
| 1429 | 1457 | self.record_op(&[op_ref], &format!("oxy pull {remote_name}"), view)?; |
|
| 1430 | - | // Follow along if the working copy was clean and on the old heads. |
|
| 1431 | - | let snap = self.snapshot("oxy pull")?; |
|
| 1432 | - | let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); |
|
| 1433 | - | let mut base = snap.base.maximal(&snap.wc.deps); |
|
| 1434 | - | base.sort(); |
|
| 1435 | 1458 | let mut checked_out = false; |
|
| 1436 | - | if clean && base == sorted_local { |
|
| 1437 | - | self.checkout(&heads, false, "oxy pull (checkout)")?; |
|
| 1438 | - | checked_out = true; |
|
| 1459 | + | if changed { |
|
| 1460 | + | // Follow along if the working copy was clean and on the old heads. |
|
| 1461 | + | let snap = self.snapshot("oxy pull")?; |
|
| 1462 | + | let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); |
|
| 1463 | + | let mut base = snap.base.maximal(&snap.wc.deps); |
|
| 1464 | + | base.sort(); |
|
| 1465 | + | if clean && base == local_current { |
|
| 1466 | + | self.checkout(¤t_heads, false, "oxy pull (checkout)")?; |
|
| 1467 | + | checked_out = true; |
|
| 1468 | + | } |
|
| 1439 | 1469 | } |
|
| 1440 | 1470 | Ok(PullReport { |
|
| 1441 | - | heads, |
|
| 1442 | - | changed: true, |
|
| 1471 | + | heads: current_heads, |
|
| 1472 | + | changed, |
|
| 1443 | 1473 | checked_out, |
|
| 1474 | + | others, |
|
| 1444 | 1475 | }) |
|
| 1445 | 1476 | } |
|
| 1446 | 1477 | ||
| … | 363 unchanged lines |
||
| 1810 | 1841 | repo.fetch("origin")?; |
|
| 1811 | 1842 | } |
|
| 1812 | 1843 | let (op_ref, op) = repo.head_op()?; |
|
| 1813 | - | let bookmark = repo.config.bookmark.clone(); |
|
| 1814 | - | if let Some(state) = op |
|
| 1815 | - | .view |
|
| 1816 | - | .remotes |
|
| 1817 | - | .get("origin") |
|
| 1818 | - | .and_then(|b| b.get(&bookmark)) |
|
| 1819 | - | .copied() |
|
| 1820 | - | { |
|
| 1844 | + | // The remote's default bookmark, else ours (main), else its only one. |
|
| 1845 | + | let theirs = op.view.remotes.get("origin").cloned().unwrap_or_default(); |
|
| 1846 | + | let advertised = remote.refs().ok().and_then(|r| r.default_bookmark); |
|
| 1847 | + | let bookmark = advertised |
|
| 1848 | + | .filter(|b| theirs.contains_key(b)) |
|
| 1849 | + | .or_else(|| { |
|
| 1850 | + | theirs |
|
| 1851 | + | .contains_key(&repo.config.bookmark) |
|
| 1852 | + | .then(|| repo.config.bookmark.clone()) |
|
| 1853 | + | }) |
|
| 1854 | + | .or_else(|| { |
|
| 1855 | + | (theirs.len() == 1) |
|
| 1856 | + | .then(|| theirs.keys().next().cloned()) |
|
| 1857 | + | .flatten() |
|
| 1858 | + | }) |
|
| 1859 | + | .unwrap_or_else(|| repo.config.bookmark.clone()); |
|
| 1860 | + | let initial = repo.config.bookmark.clone(); |
|
| 1861 | + | if bookmark != repo.config.bookmark { |
|
| 1862 | + | fs::create_dir_all(repo.workspace_bookmark_path().parent().unwrap())?; |
|
| 1863 | + | write_atomic(&repo.workspace_bookmark_path(), bookmark.as_bytes())?; |
|
| 1864 | + | repo.config.bookmark = bookmark.clone(); |
|
| 1865 | + | } |
|
| 1866 | + | if let Some(state) = theirs.get(&bookmark).copied() { |
|
| 1821 | 1867 | let heads = repo.store.get_state(&state)?.heads; |
|
| 1822 | 1868 | let mut view = op.view.clone(); |
|
| 1869 | + | // The empty bookmark `init` made goes when another is checked out. |
|
| 1870 | + | if initial != bookmark && repo.heads_of(&view, &initial)?.is_empty() { |
|
| 1871 | + | view.bookmarks.remove(&initial); |
|
| 1872 | + | } |
|
| 1823 | 1873 | view.bookmarks.insert(bookmark, state); |
|
| 1824 | 1874 | repo.record_op(&[op_ref], &format!("oxy clone {url}"), view)?; |
|
| 1825 | 1875 | repo.checkout(&heads, false, "oxy clone (checkout)")?; |
|
| … | 15 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +74 −0
| … | 5755 unchanged lines |
||
| 5756 | 5756 | assert_eq!(main.behind, Some(0), "{main:?}"); |
|
| 5757 | 5757 | } |
|
| 5758 | 5758 | ||
| 5759 | + | /// oxygen#48: a remote's bookmarks are names here (`origin/NAME`); switching |
|
| 5760 | + | /// to one only the remote has makes a local bookmark from it; a pull moves |
|
| 5761 | + | /// every local bookmark the remote has too; a clone checks out the remote's |
|
| 5762 | + | /// default bookmark. |
|
| 5763 | + | #[test] |
|
| 5764 | + | fn remote_bookmarks_are_named_switched_to_pulled_and_cloned() { |
|
| 5765 | + | hermetic(); |
|
| 5766 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5767 | + | let root = dir.path(); |
|
| 5768 | + | let origin_path = root.join("origin"); |
|
| 5769 | + | let mut origin = Repo::init(&origin_path).unwrap(); |
|
| 5770 | + | let url = origin_path.to_string_lossy().to_string(); |
|
| 5771 | + | let mut a = Repo::clone(&url, &root.join("a")).unwrap(); |
|
| 5772 | + | write(&root.join("a"), "base.txt", b"base\n"); |
|
| 5773 | + | a.commit("base", &[]).unwrap(); |
|
| 5774 | + | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 5775 | + | a.bookmark_create("develop", None).unwrap(); |
|
| 5776 | + | a.bookmark_switch("develop", false).unwrap(); |
|
| 5777 | + | write(&root.join("a"), "base.txt", b"base\nd1\n"); |
|
| 5778 | + | let d1 = a.commit("d1", &[]).unwrap(); |
|
| 5779 | + | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 5780 | + | ||
| 5781 | + | // A clone has main only; origin/develop is a name all the same. |
|
| 5782 | + | let c_root = root.join("c"); |
|
| 5783 | + | let mut c = Repo::clone(&url, &c_root).unwrap(); |
|
| 5784 | + | assert_eq!(c.config.bookmark, "main"); |
|
| 5785 | + | assert!(!c.view().unwrap().bookmarks.contains_key("develop")); |
|
| 5786 | + | assert_eq!(c.resolve_heads("origin/develop").unwrap(), vec![d1]); |
|
| 5787 | + | assert_eq!(c.resolve_change("origin/develop").unwrap(), d1); |
|
| 5788 | + | assert_eq!(c.remote_only_bookmarks().unwrap().len(), 1); |
|
| 5789 | + | ||
| 5790 | + | // Switching makes the local bookmark from origin's. |
|
| 5791 | + | c.bookmark_switch("develop", false).unwrap(); |
|
| 5792 | + | assert_eq!(c.config.bookmark, "develop"); |
|
| 5793 | + | assert_eq!(c.bookmark_heads().unwrap(), vec![d1]); |
|
| 5794 | + | assert_eq!(fs::read(c_root.join("base.txt")).unwrap(), b"base\nd1\n"); |
|
| 5795 | + | assert!(c.remote_only_bookmarks().unwrap().is_empty()); |
|
| 5796 | + | assert!(c.bookmark_switch("nowhere", false).is_err()); |
|
| 5797 | + | ||
| 5798 | + | // Both bookmarks move on the remote; one pull takes both. |
|
| 5799 | + | write(&root.join("a"), "base.txt", b"base\nd1\nd2\n"); |
|
| 5800 | + | let d2 = a.commit("d2", &[]).unwrap(); |
|
| 5801 | + | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 5802 | + | a.bookmark_switch("main", false).unwrap(); |
|
| 5803 | + | write(&root.join("a"), "m.txt", b"m\n"); |
|
| 5804 | + | let m = a.commit("m", &[]).unwrap(); |
|
| 5805 | + | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 5806 | + | let r = c.pull("origin").unwrap(); |
|
| 5807 | + | assert!(r.changed && r.checked_out, "{r:?}"); |
|
| 5808 | + | assert_eq!(r.heads, vec![d2]); |
|
| 5809 | + | assert_eq!(r.others, vec!["main".to_string()]); |
|
| 5810 | + | assert!(c.resolve_heads("main").unwrap().contains(&m)); |
|
| 5811 | + | assert_eq!( |
|
| 5812 | + | fs::read(c_root.join("base.txt")).unwrap(), |
|
| 5813 | + | b"base\nd1\nd2\n" |
|
| 5814 | + | ); |
|
| 5815 | + | let again = c.pull("origin").unwrap(); |
|
| 5816 | + | assert!(!again.changed && again.others.is_empty(), "{again:?}"); |
|
| 5817 | + | ||
| 5818 | + | // A clone checks out the remote's default bookmark. |
|
| 5819 | + | origin.config.set("workspace.bookmark", "develop").unwrap(); |
|
| 5820 | + | origin.save_config().unwrap(); |
|
| 5821 | + | let e = Repo::clone(&url, &root.join("e")).unwrap(); |
|
| 5822 | + | assert_eq!(e.config.bookmark, "develop"); |
|
| 5823 | + | assert_eq!(e.bookmark_heads().unwrap(), vec![d2]); |
|
| 5824 | + | let reopened = Repo::open(&root.join("e")).unwrap(); |
|
| 5825 | + | assert_eq!(reopened.config.bookmark, "develop"); |
|
| 5826 | + | // REMOTE/NAME names the remote's bookmark to switch to. |
|
| 5827 | + | let mut e = reopened; |
|
| 5828 | + | e.bookmark_switch("origin/main", false).unwrap(); |
|
| 5829 | + | assert_eq!(e.config.bookmark, "main"); |
|
| 5830 | + | assert!(e.bookmark_heads().unwrap().contains(&m)); |
|
| 5831 | + | } |
|
| 5832 | + | ||
| 5759 | 5833 | /// oxygen#35: a history-only state keeps no inserted text. It reads a |
|
| 5760 | 5834 | /// stored insert's text blob only when it builds that file's graph, and |
|
| 5761 | 5835 | /// builds the same graph a full load does. |
|
| … | 544 unchanged lines |
modified crates/oxy-server/src/lib.rs +7 −1
| … | 231 unchanged lines |
||
| 232 | 232 | let r = self.backend.open(name)?; |
|
| 233 | 233 | Ok(match endpoint { |
|
| 234 | 234 | Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)), |
|
| 235 | - | Endpoint::Refs => ok(wire::encode_refs(r.id()?, &r.remote_refs()?)), |
|
| 235 | + | Endpoint::Refs => { |
|
| 236 | + | let mut refs = r.remote_refs()?; |
|
| 237 | + | if let Some(d) = self.backend.default_bookmark(name) { |
|
| 238 | + | refs.default_bookmark = Some(d); |
|
| 239 | + | } |
|
| 240 | + | ok(wire::encode_refs(r.id()?, &refs)) |
|
| 241 | + | } |
|
| 236 | 242 | Endpoint::Negotiate => { |
|
| 237 | 243 | let (have, want) = wire::decode_negotiate(&body)?; |
|
| 238 | 244 | ok(wire::encode_hashes(&r.negotiate(&have, &want)?)) |
|
| … | 173 unchanged lines |
modified docs/guide.md +18 −1
| … | 119 unchanged lines |
||
| 120 | 120 | `oxy bookmark switch` says when the bookmark you switch to lacks changes |
|
| 121 | 121 | that the one you left has. |
|
| 122 | 122 | ||
| 123 | + | **A remote's bookmarks.** A clone fetches every change, checks out the |
|
| 124 | + | remote's default bookmark, and names the others `origin/NAME`: use them |
|
| 125 | + | anywhere a bookmark is expected (`oxy log origin/develop`, |
|
| 126 | + | `oxy diff main origin/develop`). `oxy bookmark list --all` shows them. |
|
| 127 | + | `oxy bookmark switch develop` makes a local `develop` from `origin/develop` |
|
| 128 | + | when there is none, and `oxy pull` then keeps every local bookmark up to |
|
| 129 | + | date with the remote's of the same name, not only the current one. Pulling |
|
| 130 | + | is a union, so this never loses work. |
|
| 131 | + | ||
| 132 | + | ``` |
|
| 133 | + | oxy clone https://oxygenhub.io/owner/game --recursive # subrepos too |
|
| 134 | + | oxy bookmark list --all |
|
| 135 | + | oxy bookmark switch develop # from origin/develop |
|
| 136 | + | oxy pull # updates develop, main and the rest |
|
| 137 | + | ``` |
|
| 138 | + | ||
| 123 | 139 | ### A develop / feature flow |
|
| 124 | 140 | ||
| 125 | 141 | Names with `/` work everywhere, pushing included: |
|
| … | 158 unchanged lines |
||
| 284 | 300 | is ignored. |
|
| 285 | 301 | ||
| 286 | 302 | - `oxy fetch` updates `origin/main` without touching your bookmark. |
|
| 287 | - | - `oxy pull` fetches, then makes your bookmark the union of both sides. |
|
| 303 | + | - `oxy pull` fetches, then makes your bookmark, and every other local |
|
| 304 | + | bookmark the remote also has, the union of both sides. |
|
| 288 | 305 | There is no merge commit; unrelated changes simply coexist, and a real |
|
| 289 | 306 | conflict is recorded as in section 6. The working copy follows when it was |
|
| 290 | 307 | clean. |
|
| … | 235 unchanged lines |
modified docs/spec.md +1 −1
| … | 758 unchanged lines |
||
| 759 | 759 | | `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending | |
|
| 760 | 760 | | `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` | |
|
| 761 | 761 | | Signed policy | `[policy bytes, [Signature]]` | |
|
| 762 | - | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; the attestation is present only when it describes the current state; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 762 | + | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 763 | 763 | | `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order | |
|
| 764 | 764 | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size | |
|
| 765 | 765 | | `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks | |
|
| … | 280 unchanged lines |
modified src/cli/mod.rs +10 −3
| … | 53 unchanged lines |
||
| 54 | 54 | /// Shallow clone: fetch only this many levels of history below the heads |
|
| 55 | 55 | #[arg(long)] |
|
| 56 | 56 | depth: Option<u32>, |
|
| 57 | + | /// Clone the subrepos too (as oxy submodule update --init) |
|
| 58 | + | #[arg(long)] |
|
| 59 | + | recursive: bool, |
|
| 57 | 60 | }, |
|
| 58 | 61 | /// Fetch more history below a shallow clone's boundary |
|
| 59 | 62 | Deepen { |
|
| … | 490 unchanged lines |
||
| 550 | 553 | }, |
|
| 551 | 554 | /// List bookmarks with their heads and difference from origin |
|
| 552 | 555 | List { |
|
| 556 | + | /// Also list remotes' bookmarks that have no local bookmark |
|
| 557 | + | #[arg(long, short)] |
|
| 558 | + | all: bool, |
|
| 553 | 559 | /// Machine-readable output: one JSON document on stdout |
|
| 554 | 560 | #[arg(long)] |
|
| 555 | 561 | json: bool, |
|
| 556 | 562 | }, |
|
| 557 | 563 | /// Make NAME this workspace's bookmark and check it out. Uncommitted work stops it; --carry takes the work along, --discard drops it |
|
| 558 | 564 | Switch { |
|
| 559 | - | /// The bookmark to work on |
|
| 565 | + | /// The bookmark to work on; one only a remote has (NAME or REMOTE/NAME) is created from it |
|
| 560 | 566 | name: String, |
|
| 561 | 567 | /// Throw away uncommitted working-copy changes |
|
| 562 | 568 | #[arg(long)] |
|
| … | 489 unchanged lines |
||
| 1052 | 1058 | max_blob, |
|
| 1053 | 1059 | sparse, |
|
| 1054 | 1060 | depth, |
|
| 1055 | - | } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth), |
|
| 1061 | + | recursive, |
|
| 1062 | + | } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth, recursive), |
|
| 1056 | 1063 | Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth), |
|
| 1057 | 1064 | Cmd::Sparse { paths } => workspace::sparse(&paths), |
|
| 1058 | 1065 | Cmd::Lock { path } => workspace::lock(&path), |
|
| … | 12 unchanged lines |
||
| 1071 | 1078 | } => workspace::bookmark_create(&name, &changes, upto, only), |
|
| 1072 | 1079 | BookmarkCmd::Set { name, changes } => workspace::bookmark_set(&name, &changes), |
|
| 1073 | 1080 | BookmarkCmd::Delete { name } => workspace::bookmark_delete(&name), |
|
| 1074 | - | BookmarkCmd::List { json } => workspace::bookmark_list(json), |
|
| 1081 | + | BookmarkCmd::List { all, json } => workspace::bookmark_list(all, json), |
|
| 1075 | 1082 | BookmarkCmd::Switch { |
|
| 1076 | 1083 | name, |
|
| 1077 | 1084 | discard, |
|
| … | 181 unchanged lines |
modified src/cli/sync.rs +81 −2
| … | 13 unchanged lines |
||
| 14 | 14 | max_blob: Option<&str>, |
|
| 15 | 15 | sparse: &[String], |
|
| 16 | 16 | depth: Option<u32>, |
|
| 17 | + | recursive: bool, |
|
| 17 | 18 | ) -> Result<i32> { |
|
| 18 | 19 | let dest = dest.unwrap_or_else(|| { |
|
| 19 | 20 | let base = url |
|
| … | 20 unchanged lines |
||
| 40 | 41 | plural(heads.len(), "head") |
|
| 41 | 42 | )) |
|
| 42 | 43 | )); |
|
| 44 | + | let others = repo.remote_only_bookmarks()?.len(); |
|
| 45 | + | if others > 0 { |
|
| 46 | + | println!( |
|
| 47 | + | " {} {}", |
|
| 48 | + | ui::note(), |
|
| 49 | + | ui::dim(format!( |
|
| 50 | + | "origin has {} more: oxy bookmark list --all, then oxy bookmark switch NAME", |
|
| 51 | + | plural(others, "bookmark") |
|
| 52 | + | )) |
|
| 53 | + | ); |
|
| 54 | + | } |
|
| 55 | + | let subrepos = repo.subrepos()?.len(); |
|
| 56 | + | if subrepos == 0 { |
|
| 57 | + | return Ok(0); |
|
| 58 | + | } |
|
| 59 | + | if recursive { |
|
| 60 | + | let sp = ui::spinner("Cloning subrepos…"); |
|
| 61 | + | let results = repo.submodule_update(true, &[])?; |
|
| 62 | + | sp.clear(); |
|
| 63 | + | return Ok(print_subrepo_results(results)); |
|
| 64 | + | } |
|
| 65 | + | println!( |
|
| 66 | + | " {} {}", |
|
| 67 | + | ui::note(), |
|
| 68 | + | ui::dim(format!( |
|
| 69 | + | "{} not cloned: oxy submodule update --init (or clone with --recursive)", |
|
| 70 | + | plural(subrepos, "subrepo") |
|
| 71 | + | )) |
|
| 72 | + | ); |
|
| 43 | 73 | Ok(0) |
|
| 44 | 74 | } |
|
| 45 | 75 | ||
| 76 | + | /// After a switch or pull: say when subrepos are not at their pins. |
|
| 77 | + | pub fn subrepos_behind(repo: &Repo) { |
|
| 78 | + | let Ok(subs) = repo.subrepos() else { |
|
| 79 | + | return; |
|
| 80 | + | }; |
|
| 81 | + | let stale = subs |
|
| 82 | + | .iter() |
|
| 83 | + | .filter(|s| s.committed && s.actual.as_ref() != Some(&s.pin)) |
|
| 84 | + | .count(); |
|
| 85 | + | if stale > 0 { |
|
| 86 | + | println!( |
|
| 87 | + | " {} {}", |
|
| 88 | + | ui::note(), |
|
| 89 | + | ui::dim(format!( |
|
| 90 | + | "{} not at {} pin: oxy submodule update --init", |
|
| 91 | + | plural(stale, "subrepo"), |
|
| 92 | + | if stale == 1 { "its" } else { "their" } |
|
| 93 | + | )) |
|
| 94 | + | ); |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | ||
| 46 | 98 | pub fn remote_add(name: &str, url: &str) -> Result<i32> { |
|
| 47 | 99 | let mut repo = open()?; |
|
| 48 | 100 | repo.config.set(&format!("remote.{name}.url"), url)?; |
|
| … | 194 unchanged lines |
||
| 243 | 295 | ("heads", json::hashes(&r.heads)), |
|
| 244 | 296 | ("changed", json::b(r.changed)), |
|
| 245 | 297 | ("checked_out", json::b(r.checked_out)), |
|
| 246 | - | ("conflicted", json::b(conflicted)) |
|
| 298 | + | ("conflicted", json::b(conflicted)), |
|
| 299 | + | ("others", json::arr(r.others.iter().map(|o| json::s(o)))) |
|
| 247 | 300 | ]) |
|
| 248 | 301 | ); |
|
| 249 | 302 | return Ok(0); |
|
| … | 7 unchanged lines |
||
| 257 | 310 | repo.config.bookmark |
|
| 258 | 311 | )) |
|
| 259 | 312 | )); |
|
| 313 | + | others_pulled(&r.others); |
|
| 260 | 314 | others_behind(&repo, &remote); |
|
| 261 | 315 | return Ok(0); |
|
| 262 | 316 | } |
|
| … | 16 unchanged lines |
||
| 279 | 333 | ui::warn("the result has conflicts; see oxy status") |
|
| 280 | 334 | ); |
|
| 281 | 335 | } |
|
| 336 | + | others_pulled(&r.others); |
|
| 282 | 337 | others_behind(&repo, &remote); |
|
| 338 | + | subrepos_behind(&repo); |
|
| 283 | 339 | Ok(0) |
|
| 284 | 340 | } |
|
| 285 | 341 | ||
| 342 | + | /// The other bookmarks a pull moved. |
|
| 343 | + | fn others_pulled(others: &[String]) { |
|
| 344 | + | if others.is_empty() { |
|
| 345 | + | return; |
|
| 346 | + | } |
|
| 347 | + | let shown: Vec<String> = others.iter().take(8).map(ui::bookmark).collect(); |
|
| 348 | + | let more = others.len().saturating_sub(8); |
|
| 349 | + | println!( |
|
| 350 | + | " {} {}{}", |
|
| 351 | + | ui::done("Also updated"), |
|
| 352 | + | shown.join(", "), |
|
| 353 | + | if more > 0 { |
|
| 354 | + | ui::dim(format!(" and {more} more")) |
|
| 355 | + | } else { |
|
| 356 | + | String::new() |
|
| 357 | + | } |
|
| 358 | + | ); |
|
| 359 | + | } |
|
| 360 | + | ||
| 286 | 361 | /// `oxy push [REMOTE] --delete NAME`. |
|
| 287 | 362 | pub fn push_delete(remote: Option<&str>, name: &str, as_json: bool) -> Result<i32> { |
|
| 288 | 363 | let repo = open()?; |
|
| … | 309 unchanged lines |
||
| 598 | 673 | let sp = ui::spinner("Updating subrepos…"); |
|
| 599 | 674 | let results = repo.submodule_update(init, paths)?; |
|
| 600 | 675 | sp.clear(); |
|
| 676 | + | Ok(print_subrepo_results(results)) |
|
| 677 | + | } |
|
| 678 | + | ||
| 679 | + | fn print_subrepo_results(results: Vec<(String, oxy_core::repo::SubrepoSync)>) -> i32 { |
|
| 601 | 680 | if results.is_empty() { |
|
| 602 | 681 | println!("{}", ui::dim("No subrepos.")); |
|
| 603 | 682 | } |
|
| … | 29 unchanged lines |
||
| 633 | 712 | }; |
|
| 634 | 713 | println!("{line} {}", ui::path(path)); |
|
| 635 | 714 | } |
|
| 636 | - | Ok(code) |
|
| 715 | + | code |
|
| 637 | 716 | } |
|
| 638 | 717 | ||
| 639 | 718 | fn pin_json(p: &oxy_core::subrepo::Pin) -> String { |
|
| … | 113 unchanged lines |
modified src/cli/workspace.rs +65 −10
| … | 1701 unchanged lines |
||
| 1702 | 1702 | Ok(0) |
|
| 1703 | 1703 | } |
|
| 1704 | 1704 | ||
| 1705 | - | pub fn bookmark_list(as_json: bool) -> Result<i32> { |
|
| 1705 | + | pub fn bookmark_list(all: bool, as_json: bool) -> Result<i32> { |
|
| 1706 | 1706 | let repo = open()?; |
|
| 1707 | + | let remote_only = repo.remote_only_bookmarks()?; |
|
| 1707 | 1708 | if as_json { |
|
| 1708 | - | let items = repo.bookmarks()?.into_iter().map(|b| { |
|
| 1709 | - | json::obj([ |
|
| 1710 | - | ("name", json::s(&b.name)), |
|
| 1711 | - | ("heads", json::hashes(&b.heads)), |
|
| 1712 | - | ("current", json::b(b.current)), |
|
| 1713 | - | ("ahead", json::opt(b.ahead, json::n)), |
|
| 1714 | - | ("behind", json::opt(b.behind, json::n)), |
|
| 1715 | - | ]) |
|
| 1716 | - | }); |
|
| 1709 | + | let mut items: Vec<String> = repo |
|
| 1710 | + | .bookmarks()? |
|
| 1711 | + | .into_iter() |
|
| 1712 | + | .map(|b| { |
|
| 1713 | + | json::obj([ |
|
| 1714 | + | ("name", json::s(&b.name)), |
|
| 1715 | + | ("heads", json::hashes(&b.heads)), |
|
| 1716 | + | ("current", json::b(b.current)), |
|
| 1717 | + | ("ahead", json::opt(b.ahead, json::n)), |
|
| 1718 | + | ("behind", json::opt(b.behind, json::n)), |
|
| 1719 | + | ]) |
|
| 1720 | + | }) |
|
| 1721 | + | .collect(); |
|
| 1722 | + | if all { |
|
| 1723 | + | items.extend(remote_only.iter().map(|b| { |
|
| 1724 | + | json::obj([ |
|
| 1725 | + | ("name", json::s(&format!("{}/{}", b.remote, b.name))), |
|
| 1726 | + | ("heads", json::hashes(&b.heads)), |
|
| 1727 | + | ("current", json::b(false)), |
|
| 1728 | + | ("ahead", "null".into()), |
|
| 1729 | + | ("behind", "null".into()), |
|
| 1730 | + | ("remote", json::s(&b.remote)), |
|
| 1731 | + | ]) |
|
| 1732 | + | })); |
|
| 1733 | + | } |
|
| 1717 | 1734 | println!("{}", json::arr(items)); |
|
| 1718 | 1735 | return Ok(0); |
|
| 1719 | 1736 | } |
|
| … | 23 unchanged lines |
||
| 1743 | 1760 | println!( |
|
| 1744 | 1761 | "{mark} {}{}{}{tail} {head}", |
|
| 1745 | 1762 | ui::bookmark(&b.name), |
|
| 1763 | + | ui::sep(), |
|
| 1764 | + | ui::dim(plural(b.heads.len(), "head")) |
|
| 1765 | + | ); |
|
| 1766 | + | } |
|
| 1767 | + | if remote_only.is_empty() { |
|
| 1768 | + | return Ok(0); |
|
| 1769 | + | } |
|
| 1770 | + | if !all { |
|
| 1771 | + | println!( |
|
| 1772 | + | "{}", |
|
| 1773 | + | ui::dim(format!( |
|
| 1774 | + | " and {} only on a remote: oxy bookmark list --all", |
|
| 1775 | + | plural(remote_only.len(), "bookmark") |
|
| 1776 | + | )) |
|
| 1777 | + | ); |
|
| 1778 | + | return Ok(0); |
|
| 1779 | + | } |
|
| 1780 | + | for b in &remote_only { |
|
| 1781 | + | println!( |
|
| 1782 | + | "{} {}{}{}", |
|
| 1783 | + | ui::dim(ui::glyph("◌", ".")), |
|
| 1784 | + | ui::dim(format!("{}/{}", b.remote, b.name)), |
|
| 1746 | 1785 | ui::sep(), |
|
| 1747 | 1786 | ui::dim(plural(b.heads.len(), "head")) |
|
| 1748 | 1787 | ); |
|
| 1749 | 1788 | } |
|
| 1789 | + | println!( |
|
| 1790 | + | " {} {}", |
|
| 1791 | + | ui::note(), |
|
| 1792 | + | ui::dim("oxy bookmark switch NAME makes a local bookmark from a remote's") |
|
| 1793 | + | ); |
|
| 1750 | 1794 | Ok(0) |
|
| 1751 | 1795 | } |
|
| 1752 | 1796 | ||
| 1753 | 1797 | pub fn bookmark_switch(name: &str, discard: bool, carry: bool) -> Result<i32> { |
|
| 1754 | 1798 | let mut repo = open()?; |
|
| 1755 | 1799 | let from = repo.config.bookmark.clone(); |
|
| 1800 | + | let (local, created_from) = repo.local_bookmark_for(name)?; |
|
| 1801 | + | if let Some(remote) = created_from { |
|
| 1802 | + | println!( |
|
| 1803 | + | "{} {} from {}", |
|
| 1804 | + | ui::done("Created"), |
|
| 1805 | + | ui::bookmark(&local), |
|
| 1806 | + | ui::dim(format!("{remote}/{local}")) |
|
| 1807 | + | ); |
|
| 1808 | + | } |
|
| 1809 | + | let name = local.as_str(); |
|
| 1756 | 1810 | let before = repo |
|
| 1757 | 1811 | .working_copy() |
|
| 1758 | 1812 | .map(|(_, wc)| wc.deps) |
|
| … | 42 unchanged lines |
||
| 1801 | 1855 | ); |
|
| 1802 | 1856 | } |
|
| 1803 | 1857 | } |
|
| 1858 | + | super::sync::subrepos_behind(&repo); |
|
| 1804 | 1859 | Ok(0) |
|
| 1805 | 1860 | } |
|
| 1806 | 1861 | ||
| … | 77 unchanged lines |
modified tests/cli_json.rs +5 −4
| … | 446 unchanged lines |
||
| 447 | 447 | assert!(!r.join("a.txt").exists() && r.join("b.txt").exists()); |
|
| 448 | 448 | } |
|
| 449 | 449 | ||
| 450 | - | /// oxygen#29: pull acts on the current bookmark; a pull on a feature |
|
| 451 | - | /// bookmark said only "Already up to date" while main was behind. |
|
| 450 | + | /// oxygen#29: a pull on a feature bookmark said only "Already up to date" |
|
| 451 | + | /// while main was behind. Since oxygen#48 it updates main too, and says so. |
|
| 452 | 452 | #[test] |
|
| 453 | - | fn pull_names_its_bookmark_and_the_others_left_behind() { |
|
| 453 | + | fn pull_names_its_bookmark_and_the_others_it_moved() { |
|
| 454 | 454 | let tmp = tempfile::tempdir().unwrap(); |
|
| 455 | 455 | let home = tmp.path().join("home"); |
|
| 456 | 456 | std::fs::create_dir_all(&home).unwrap(); |
|
| … | 29 unchanged lines |
||
| 486 | 486 | assert_eq!(code, 0, "{err}"); |
|
| 487 | 487 | let out = String::from_utf8_lossy(&out); |
|
| 488 | 488 | assert!(out.contains("feat is up to date with origin"), "{out}"); |
|
| 489 | - | assert!(out.contains("main is 1 change behind origin/main"), "{out}"); |
|
| 489 | + | assert!(out.contains("Also updated main"), "{out}"); |
|
| 490 | + | assert!(!out.contains("behind"), "{out}"); |
|
| 490 | 491 | } |
|
| 491 | 492 | ||
| 492 | 493 | /// oxygen#35: a clean status is answered from the manifest, loading no |
|
| … | 50 unchanged lines |