OXYGENHUB

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, &current)?;
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(&current_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