OXYGENHUB

Fetch every bookmark in one exchange, and none when nothing is new: a no-op pull of 160 bookmarks 169 s to 3 s, a clone 324 s to 26 s (closes #158) 2f0b4b0651ea

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key aacd5c0cf038
When
· 2026-11-01 02:25 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
2f0b4b0651ea3b534a83ef0c851ec3be69430d6e959e3b9ec6eb38b34eb7a12e
Depends on
028545dfa7a4 091fef80755d 132b884b9a8d 16aa9e03d85b 2c75672fb2fe 4b9d06778581 5b9c6ad45f6e 6f4226bfb87f 75167672ebcd 83e0677b07fe 8fd78eb52ad2 982864068a59 b3c5a59a875a b63fc7a77fc6 c397a8c36a9f f3811eb20bea
Depended on by
aded4110f0bd
In bookmarks
main
In tags
v0.10.0 v0.11.0
Edits
18 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Fixed: fetch, pull and clone are far faster with many bookmarks (#158).** They fetched each bookmark in its own exchange with the hub, and changes that bookmarks share were sent again for each one; a pull with nothing new still asked for a bundle per bookmark. Now every bookmark's heads are fetched in one exchange, and none when nothing is new. On a 47 MB repository with 160 bookmarks: a pull with nothing new 169 s → 3.5 s, a clone 324 s → 26 s. `OXY_TIMINGS` now also shows the refs, transparency log and per-bookmark checks of a fetch.
5 6
- **Added: `oxy git colocate`, a `.git` beside `.oxy` (oxygenhub#355).** Your editor's Git features (blame, history, gutter marks) and other Git tools now work on an Oxygen checkout: oxy keeps a `.git` there with the current bookmark exported after every command, Git's index at its tree so `git status` shows only your uncommitted work, and `.oxy/` excluded. One way: commit with oxy. `--off` stops it.
6 7
- **Changed: `.git` is never content.** oxy no longer picks up a `.git` directory at a checkout's top, and checkout refuses a path with a `.git` component at any depth (as Git does), so a change cannot plant Git hooks.
7 8
- **Added: a Git export from the change graph (oxygenhub#355).** oxy-core's `git_export_graph` writes every bookmark and tag to a bare Git repository so that the same changes give the same commits wherever it runs: each change a commit whose parents are its dependencies, each bookmark a branch (a merge of its heads when it has several), tags as tags, incrementally. It is what OxygenHub serves to `git clone`. (`oxy git export` still follows the checkout's own history.)
…
310 unchanged lines
modified crates/oxy-core/src/protocol.rs +9 −0
…
261 unchanged lines
262 262
    }
263 263
}
264 264
265 +
// Bundles a local remote served on this thread, for tests that count
266 +
// round trips (oxygen#158).
267 +
#[cfg(test)]
268 +
thread_local! {
269 +
    pub(crate) static LOCAL_FETCHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
270 +
}
271 +
265 272
impl Remote for LocalRemote {
266 273
    fn repo_id(&self) -> Result<[u8; 16]> {
267 274
        self.repo.id()
…
5 unchanged lines
273 280
        self.repo.negotiate(have, want)
274 281
    }
275 282
    fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> {
283 +
        #[cfg(test)]
284 +
        LOCAL_FETCHES.with(|n| n.set(n.get() + 1));
276 285
        self.repo.bundle_raw_for(changes, max_blob)
277 286
    }
278 287
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
…
1552 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +87 −22
…
1704 unchanged lines
1705 1705
    pub fn fetch(&self, remote_name: &str) -> Result<FetchReport> {
1706 1706
        let remote = self.open_named_remote(remote_name)?;
1707 1707
        self.check_remote_id(remote.as_ref(), remote_name, false)?;
1708 -
        let resp = remote.refs()?;
1708 +
        let resp = {
1709 +
            let _t = crate::timing::phase("fetch: refs");
1710 +
            remote.refs()?
1711 +
        };
1709 1712
        // The host's transparency log (spec §14.8): a fork fails here.
1713 +
        let log_phase = crate::timing::phase("fetch: transparency log");
1710 1714
        let log = self.check_log(
1711 1715
            &self.remote_url(remote_name)?,
1712 1716
            self.config.remote_cas.get(remote_name).map(Path::new),
1713 1717
            &resp,
1714 1718
        )?;
1719 +
        drop(log_phase);
1715 1720
        let mut log_notes = log.notes.clone();
1716 1721
        // Trust policy (spec §14.5). Adopt the remote's policy when it is newer
1717 1722
        // and signed by roots we trust, or on first contact. A remote that
…
47 unchanged lines
1765 1770
        let mut report = Vec::new();
1766 1771
        let mut skipped = Vec::new();
1767 1772
        let max_blob = self.config.max_blob;
1768 -
        for r in resp.refs {
1773 +
        // One negotiation and one bundle for every bookmark's heads at
1774 +
        // once (oxygen#158): a bookmark at a time cost a round trip each and
1775 +
        // sent again the changes bookmarks share, as `have` did not grow
1776 +
        // between them (160 bookmarks: 160 bundles, 49k changes encoded for
1777 +
        // a few hundred). Heads already here, with every attestation's
1778 +
        // signer known, need no round trip at all.
1779 +
        let mut wanted: Vec<ChangeHash> = resp
1780 +
            .refs
1781 +
            .iter()
1782 +
            .flat_map(|r| r.heads.iter().copied())
1783 +
            .filter(|h| !self.store.has(h))
1784 +
            .collect();
1785 +
        wanted.sort();
1786 +
        wanted.dedup();
1787 +
        // A bundle also carries the identities and certificates of the keys
1788 +
        // that signed the remote's attestations (spec §16.2): with no change
1789 +
        // wanted, ask anyway when one of those signers is unknown here.
1790 +
        let unknown_signer = resp
1791 +
            .refs
1792 +
            .iter()
1793 +
            .filter_map(|r| r.attestation.as_ref())
1794 +
            .chain(resp.tags.iter().filter_map(|t| t.attestation.as_ref()))
1795 +
            .flat_map(|a| a.sigs.iter().map(|s| s.key))
1796 +
            .any(|k| {
1797 +
                self.store.get_identity(&k).is_err()
1798 +
                    && !matches!(self.store.certificate_of(&k), Ok(Some(_)))
1799 +
            });
1800 +
        let shallow = self.is_shallow();
1801 +
        let imported: Vec<ChangeHash> = if wanted.is_empty() && !unknown_signer {
1802 +
            Vec::new()
1803 +
        } else {
1769 1804
            let needed = {
1770 1805
                let _t = crate::timing::phase("fetch: negotiate");
1771 -
                remote.negotiate(&have, &r.heads)?
1806 +
                remote.negotiate(&have, &wanted)?
1772 1807
            };
1773 1808
            let bundle = {
1774 1809
                let _t = crate::timing::phase("fetch: bundle");
1775 1810
                remote.fetch(&needed, max_blob)?
1776 1811
            };
1777 -
            // Changes are not individually re-verified here: the bookmark's
1812 +
            // Changes are not individually re-verified here: each bookmark's
1778 1813
            // attestation (checked below) binds its whole state, and the
1779 1814
            // receiver of the push verified the added changes at that time.
1780 1815
            // A shallow repository takes changes whose dependencies it never
1781 1816
            // held (below what it cloned), as its clone did (#137).
1782 -
            let shallow = self.is_shallow();
1783 -
            let imported = {
1784 -
                let _t = crate::timing::phase("fetch: import");
1785 -
                if shallow {
1786 -
                    self.import_bundle_shallow(&bundle, false)?
1787 -
                } else {
1788 -
                    self.import_bundle(&bundle, false)?
1789 -
                }
1790 -
            };
1817 +
            let _t = crate::timing::phase("fetch: import");
1791 1818
            if shallow {
1792 -
                self.refresh_boundary(&imported)?;
1819 +
                self.import_bundle_shallow(&bundle, false)?
1820 +
            } else {
1821 +
                self.import_bundle(&bundle, false)?
1793 1822
            }
1794 -
            if max_blob.is_some() {
1795 -
                self.register_missing_blobs(&imported, remote_name)?;
1823 +
        };
1824 +
        if shallow {
1825 +
            self.refresh_boundary(&imported)?;
1826 +
        }
1827 +
        if max_blob.is_some() {
1828 +
            self.register_missing_blobs(&imported, remote_name)?;
1829 +
        }
1830 +
        let boundary = if shallow {
1831 +
            self.boundary()
1832 +
        } else {
1833 +
            BTreeSet::new()
1834 +
        };
1835 +
        // Each bookmark's new changes: those just imported that its heads
1836 +
        // reach, for the report.
1837 +
        let mut new_deps: HashMap<ChangeHash, Vec<ChangeHash>> = HashMap::new();
1838 +
        for h in &imported {
1839 +
            if let Ok(m) = self.store.get_change_meta(h) {
1840 +
                new_deps.insert(*h, m.deps);
1796 1841
            }
1797 -
            let boundary = if shallow {
1798 -
                self.boundary()
1799 -
            } else {
1800 -
                BTreeSet::new()
1801 -
            };
1842 +
        }
1843 +
        let new_in = |heads: &[ChangeHash]| -> usize {
1844 +
            let mut seen: HashSet<ChangeHash> = HashSet::new();
1845 +
            let mut stack: Vec<ChangeHash> = heads
1846 +
                .iter()
1847 +
                .filter(|h| new_deps.contains_key(*h))
1848 +
                .copied()
1849 +
                .collect();
1850 +
            while let Some(h) = stack.pop() {
1851 +
                if seen.insert(h) {
1852 +
                    stack.extend(
1853 +
                        new_deps[&h]
1854 +
                            .iter()
1855 +
                            .filter(|d| new_deps.contains_key(*d) && !seen.contains(*d)),
1856 +
                    );
1857 +
                }
1858 +
            }
1859 +
            seen.len()
1860 +
        };
1861 +
        let checks = crate::timing::phase("fetch: check each bookmark");
1862 +
        for r in resp.refs {
1802 1863
            for h in &r.heads {
1803 1864
                if !self.store.has(h) && !boundary.contains(h) {
1804 1865
                    return fail(format!("remote did not send head {}", h.short(12)));
…
74 unchanged lines
1879 1940
                .entry(remote_name.to_string())
1880 1941
                .or_default()
1881 1942
                .insert(r.name.clone(), state);
1882 -
            report.push((r.name, r.heads, imported.len()));
1943 +
            let n = new_in(&r.heads);
1944 +
            report.push((r.name, r.heads, n));
1883 1945
        }
1946 +
        drop(checks);
1947 +
        let tags_phase = crate::timing::phase("fetch: tags");
1884 1948
        let mut new_tags = 0;
1885 1949
        for t in &resp.tags {
1886 1950
            if view.tags.contains_key(&t.name) {
…
34 unchanged lines
1921 1985
        // The remote withdrew a tag this repository has: so does this one,
1922 1986
        // once the withdrawal is checked as a push's would be; one that is
1923 1987
        // not is left as it is, said so (oxygenhub#301).
1988 +
        drop(tags_phase);
1924 1989
        let mut withdrawn_tags = Vec::new();
1925 1990
        for t in &resp.withdrawn {
1926 1991
            if !view.tags.contains_key(&t.name) {
…
1151 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +74 −0
…
11042 unchanged lines
11043 11043
    );
11044 11044
    assert_eq!(git(&b, &["rev-parse", "v1^{commit}"]), c2);
11045 11045
}
11046 +
11047 +
#[test]
11048 +
fn a_fetch_of_many_bookmarks_takes_shared_changes_once() {
11049 +
    // oxygen#158: every bookmark's heads are fetched in one exchange, so
11050 +
    // changes bookmarks share are not sent again for each; each bookmark
11051 +
    // still says how many of its changes were new, and a fetch with
11052 +
    // nothing new imports nothing.
11053 +
    hermetic();
11054 +
    let dir = tempfile::tempdir().unwrap();
11055 +
    let root = dir.path();
11056 +
    drop(Repo::init(&root.join("origin")).unwrap());
11057 +
    let url = root.join("origin").to_string_lossy().to_string();
11058 +
    let mut a = Repo::clone(&url, &root.join("a"))
11059 +
        .unwrap()
11060 +
        .with_home(root.join("home-a"));
11061 +
    let w = root.join("a");
11062 +
    write(&w, "f.txt", b"one\n");
11063 +
    a.commit("one", &[]).unwrap();
11064 +
    write(&w, "f.txt", b"one\ntwo\n");
11065 +
    a.commit("two", &[]).unwrap();
11066 +
    a.push("origin", false).unwrap();
11067 +
    // Five bookmarks on main's history, each one change of its own.
11068 +
    for i in 0..5 {
11069 +
        a.bookmark_create(&format!("feat{i}"), None).unwrap();
11070 +
        a.bookmark_switch(&format!("feat{i}"), false).unwrap();
11071 +
        write(&w, &format!("feat{i}.txt"), format!("{i}\n").as_bytes());
11072 +
        a.commit(&format!("feat {i}"), &[]).unwrap();
11073 +
        a.push("origin", false).unwrap();
11074 +
        a.bookmark_switch("main", false).unwrap();
11075 +
    }
11076 +
11077 +
    let b = Repo::clone(&url, &root.join("b"))
11078 +
        .unwrap()
11079 +
        .with_home(root.join("home-b"));
11080 +
    let report = b.fetch("origin").unwrap();
11081 +
    assert_eq!(report.refs.len(), 6, "every bookmark: {:?}", report.refs);
11082 +
    assert!(
11083 +
        report.refs.iter().all(|(_, _, n)| *n == 0),
11084 +
        "the clone brought them all: {:?}",
11085 +
        report.refs
11086 +
    );
11087 +
11088 +
    // One new change on one bookmark: counted there, nowhere else.
11089 +
    a.bookmark_switch("feat3", false).unwrap();
11090 +
    write(&w, "feat3.txt", b"3\nmore\n");
11091 +
    a.commit("feat 3 again", &[]).unwrap();
11092 +
    a.push("origin", false).unwrap();
11093 +
    let fetches = || crate::protocol::LOCAL_FETCHES.with(|n| n.get());
11094 +
    let at = fetches();
11095 +
    let report = b.fetch("origin").unwrap();
11096 +
    assert_eq!(fetches() - at, 1, "one bundle for all six bookmarks");
11097 +
    let counts: Vec<(String, usize)> = report
11098 +
        .refs
11099 +
        .iter()
11100 +
        .map(|(n, _, c)| (n.clone(), *c))
11101 +
        .collect();
11102 +
    assert!(counts.contains(&("feat3".to_string(), 1)), "{counts:?}");
11103 +
    assert_eq!(
11104 +
        counts.iter().filter(|(_, c)| *c > 0).count(),
11105 +
        1,
11106 +
        "only feat3 had anything new: {counts:?}"
11107 +
    );
11108 +
11109 +
    // Nothing new: no bundle at all, every bookmark still listed.
11110 +
    let at = fetches();
11111 +
    let report = b.fetch("origin").unwrap();
11112 +
    assert_eq!(fetches() - at, 0, "nothing to fetch, no round trip");
11113 +
    assert_eq!(report.refs.len(), 6);
11114 +
    assert!(
11115 +
        report.refs.iter().all(|(_, _, n)| *n == 0),
11116 +
        "{:?}",
11117 +
        report.refs
11118 +
    );
11119 +
}