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 | + | } |