Catch up a stale bookmark: switch, pull and carry 72484195899a
bookmark switch takes the remote's state when the local bookmark is only behind it, rather than checking out the stale one. pull takes uncommitted work along when the pulled changes leave its files alone, instead of leaving the working copy behind. checkout --carry no longer counts a subrepo already at the target's pin as a change of its own. closes #74
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 18:40 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
72484195899a701003ba873ec0b443b786474ed8428768d65cf04f88163447db- Depends on
- 14edbaaf5291 1c2bd9abdd4f 61e3bce7ea5b 7b9809e67c3d 982864068a59 bf3a67819b59 ee06a6c557b7 fce44ae9ad29
- Depended on by
- b5e98094eb8f c8d9059e4e70 157d02e95586 f6dccc4533c8 42ccf504aa50
- In bookmarks
- 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.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
- 9 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/repo/bookmarks.rs +67 −0
| … | 137 unchanged lines |
||
| 138 | 138 | Ok(out) |
|
| 139 | 139 | } |
|
| 140 | 140 | ||
| 141 | + | /// Move `name` to its default remote's state when it is only behind |
|
| 142 | + | /// it: no local change the remote lacks, so nothing is lost |
|
| 143 | + | /// (oxygen#74). Returns how many changes it caught up, if any. |
|
| 144 | + | pub fn catch_up_bookmark(&self, name: &str) -> Result<Option<usize>> { |
|
| 145 | + | let (op_ref, op) = self.head_op()?; |
|
| 146 | + | let remote = self.default_remote(); |
|
| 147 | + | let (Some(local), Some(rs)) = ( |
|
| 148 | + | op.view.bookmarks.get(name).copied(), |
|
| 149 | + | op.view |
|
| 150 | + | .remotes |
|
| 151 | + | .get(&remote) |
|
| 152 | + | .and_then(|b| b.get(name)) |
|
| 153 | + | .copied(), |
|
| 154 | + | ) else { |
|
| 155 | + | return Ok(None); |
|
| 156 | + | }; |
|
| 157 | + | if local == rs { |
|
| 158 | + | return Ok(None); |
|
| 159 | + | } |
|
| 160 | + | let heads = self.store.get_state(&local)?.heads; |
|
| 161 | + | let remote_heads = self.store.get_state(&rs)?.heads; |
|
| 162 | + | if !self.negotiate(&remote_heads, &heads)?.is_empty() { |
|
| 163 | + | return Ok(None); |
|
| 164 | + | } |
|
| 165 | + | let behind = self.negotiate(&heads, &remote_heads)?.len(); |
|
| 166 | + | if behind == 0 { |
|
| 167 | + | return Ok(None); |
|
| 168 | + | } |
|
| 169 | + | let mut view = op.view.clone(); |
|
| 170 | + | view.bookmarks.insert(name.to_string(), rs); |
|
| 171 | + | self.record_op( |
|
| 172 | + | &[op_ref], |
|
| 173 | + | &format!("oxy bookmark switch {name} (catch up with {remote})"), |
|
| 174 | + | view, |
|
| 175 | + | )?; |
|
| 176 | + | Ok(Some(behind)) |
|
| 177 | + | } |
|
| 178 | + | ||
| 141 | 179 | /// Bookmarks of remotes with no local bookmark of the same name, by |
|
| 142 | 180 | /// remote then name. |
|
| 143 | 181 | pub fn remote_only_bookmarks(&self) -> Result<Vec<RemoteOnlyBookmark>> { |
|
| … | 259 unchanged lines |
||
| 403 | 441 | } |
|
| 404 | 442 | } |
|
| 405 | 443 | let target_state = self.load_state(heads)?.materialize(); |
|
| 444 | + | // A subrepo whose nested repository is already at the target's pin |
|
| 445 | + | // is no carried work: the target records exactly what is on disk |
|
| 446 | + | // (oxygen#74). |
|
| 447 | + | let at_target_pin = |p: &String| -> bool { |
|
| 448 | + | let Some(f) = target_state.files.get(p) else { |
|
| 449 | + | return false; |
|
| 450 | + | }; |
|
| 451 | + | if f.kind != NodeKind::Subrepo { |
|
| 452 | + | return false; |
|
| 453 | + | } |
|
| 454 | + | let FileContent::Opaque { blobs } = &f.content else { |
|
| 455 | + | return false; |
|
| 456 | + | }; |
|
| 457 | + | let Some(want) = blobs |
|
| 458 | + | .first() |
|
| 459 | + | .and_then(|b| self.read_blob(b).ok()) |
|
| 460 | + | .and_then(|b| Pin::decode(&b).ok()) |
|
| 461 | + | else { |
|
| 462 | + | return false; |
|
| 463 | + | }; |
|
| 464 | + | let hint = self |
|
| 465 | + | .modules_on_disk() |
|
| 466 | + | .entries |
|
| 467 | + | .get(p) |
|
| 468 | + | .and_then(|m| m.bookmark.clone()); |
|
| 469 | + | super::subrepos::read_pin(&self.root.join(p), hint.as_deref()) |
|
| 470 | + | .is_ok_and(|have| have == want) |
|
| 471 | + | }; |
|
| 472 | + | touched.retain(|p| !at_target_pin(p)); |
|
| 406 | 473 | let same = |p: &String| { |
|
| 407 | 474 | let a = snap.base_snapshot.files.get(p); |
|
| 408 | 475 | let b = target_state.files.get(p); |
|
| … | 170 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +13 −3
| … | 1470 unchanged lines |
||
| 1471 | 1471 | let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); |
|
| 1472 | 1472 | let mut base = snap.base.maximal(&snap.wc.deps); |
|
| 1473 | 1473 | base.sort(); |
|
| 1474 | - | if clean && base == local_current { |
|
| 1475 | - | self.checkout(¤t_heads, false, "oxy pull (checkout)")?; |
|
| 1476 | - | checked_out = true; |
|
| 1474 | + | if base == local_current { |
|
| 1475 | + | if clean { |
|
| 1476 | + | self.checkout(¤t_heads, false, "oxy pull (checkout)")?; |
|
| 1477 | + | checked_out = true; |
|
| 1478 | + | } else { |
|
| 1479 | + | // Uncommitted work comes along when the pulled changes |
|
| 1480 | + | // leave its paths alone, as `git pull` does; otherwise |
|
| 1481 | + | // the working copy stays and the caller says so |
|
| 1482 | + | // (oxygen#74). |
|
| 1483 | + | checked_out = self |
|
| 1484 | + | .checkout_carry(¤t_heads, ¤t, "oxy pull (checkout --carry)") |
|
| 1485 | + | .is_ok(); |
|
| 1486 | + | } |
|
| 1477 | 1487 | } |
|
| 1478 | 1488 | } |
|
| 1479 | 1489 | Ok(PullReport { |
|
| … | 431 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +67 −0
| … | 7763 unchanged lines |
||
| 7764 | 7764 | .to_string(); |
|
| 7765 | 7765 | assert!(e.contains("no such bookmark"), "{e}"); |
|
| 7766 | 7766 | } |
|
| 7767 | + | ||
| 7768 | + | #[test] |
|
| 7769 | + | fn carrying_work_over_a_subrepo_already_at_the_target_pin_is_no_conflict() { |
|
| 7770 | + | // oxygen#74: a stale state with .oxymodules but no pin, a newer one |
|
| 7771 | + | // pinning the subrepo, and the nested repository on disk at that pin. |
|
| 7772 | + | hermetic(); |
|
| 7773 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 7774 | + | let root = dir.path(); |
|
| 7775 | + | let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); |
|
| 7776 | + | let lib_url = root.join("lib-origin").to_string_lossy().to_string(); |
|
| 7777 | + | let lib = Repo::clone(&lib_url, &root.join("lib")) |
|
| 7778 | + | .unwrap() |
|
| 7779 | + | .with_home(root.join("home")); |
|
| 7780 | + | write(&root.join("lib"), "lib.txt", b"v1\n"); |
|
| 7781 | + | lib.commit("lib v1", &[]).unwrap(); |
|
| 7782 | + | assert!(lib.push("origin", false).unwrap()[0].ok); |
|
| 7783 | + | let app_dir = root.join("app"); |
|
| 7784 | + | let app = Repo::init(&app_dir).unwrap().with_home(root.join("home")); |
|
| 7785 | + | write(&app_dir, "main.txt", b"app\n"); |
|
| 7786 | + | app.submodule_add(&lib_url, "libs/lib", None).unwrap(); |
|
| 7787 | + | // The stale state: registered in .oxymodules, never pinned. |
|
| 7788 | + | fs::rename(app_dir.join("libs/lib"), root.join("aside")).unwrap(); |
|
| 7789 | + | app.commit("initial", &[]).unwrap(); |
|
| 7790 | + | let stale = app.bookmark_heads().unwrap(); |
|
| 7791 | + | fs::rename(root.join("aside"), app_dir.join("libs/lib")).unwrap(); |
|
| 7792 | + | app.commit("pin lib", &[]).unwrap(); |
|
| 7793 | + | let newer = app.bookmark_heads().unwrap(); |
|
| 7794 | + | app.checkout(&stale, false, "test").unwrap(); |
|
| 7795 | + | write(&app_dir, "notes.txt", b"mine\n"); |
|
| 7796 | + | let (_, carried) = app.checkout_carry(&newer, "main", "test").unwrap(); |
|
| 7797 | + | assert_eq!(carried, vec!["notes.txt".to_string()]); |
|
| 7798 | + | assert!(app_dir.join("notes.txt").exists()); |
|
| 7799 | + | } |
|
| 7800 | + | ||
| 7801 | + | #[test] |
|
| 7802 | + | fn a_bookmark_only_behind_its_remote_catches_up() { |
|
| 7803 | + | // oxygen#74: switch takes the remote's state rather than a stale one. |
|
| 7804 | + | hermetic(); |
|
| 7805 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 7806 | + | let root = dir.path(); |
|
| 7807 | + | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 7808 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 7809 | + | let one = Repo::clone(&url, &root.join("one")) |
|
| 7810 | + | .unwrap() |
|
| 7811 | + | .with_home(root.join("home")); |
|
| 7812 | + | write(&root.join("one"), "a.txt", b"a\n"); |
|
| 7813 | + | one.commit("A", &[]).unwrap(); |
|
| 7814 | + | assert!(one.push("origin", false).unwrap()[0].ok); |
|
| 7815 | + | let two = Repo::clone(&url, &root.join("two")) |
|
| 7816 | + | .unwrap() |
|
| 7817 | + | .with_home(root.join("home")); |
|
| 7818 | + | write(&root.join("two"), "b.txt", b"b\n"); |
|
| 7819 | + | two.commit("B", &[]).unwrap(); |
|
| 7820 | + | assert!(two.push("origin", false).unwrap()[0].ok); |
|
| 7821 | + | assert_eq!(one.catch_up_bookmark("main").unwrap(), None, "not fetched"); |
|
| 7822 | + | one.fetch("origin").unwrap(); |
|
| 7823 | + | assert_eq!(one.catch_up_bookmark("main").unwrap(), Some(1)); |
|
| 7824 | + | assert_eq!(one.bookmarks().unwrap()[0].behind, Some(0)); |
|
| 7825 | + | // Local work the remote lacks: left alone. |
|
| 7826 | + | write(&root.join("one"), "c.txt", b"c\n"); |
|
| 7827 | + | one.commit("C", &[]).unwrap(); |
|
| 7828 | + | write(&root.join("two"), "d.txt", b"d\n"); |
|
| 7829 | + | two.commit("D", &[]).unwrap(); |
|
| 7830 | + | assert!(two.push("origin", false).unwrap()[0].ok); |
|
| 7831 | + | one.fetch("origin").unwrap(); |
|
| 7832 | + | assert_eq!(one.catch_up_bookmark("main").unwrap(), None); |
|
| 7833 | + | } |
modified docs/guide.md +5 −3
| … | 159 unchanged lines |
||
| 160 | 160 | `oxy bookmark switch develop` makes a local `develop` from `origin/develop` |
|
| 161 | 161 | when there is none, and `oxy pull` then keeps every local bookmark up to |
|
| 162 | 162 | date with the remote's of the same name, not only the current one. Pulling |
|
| 163 | - | is a union, so this never loses work. |
|
| 163 | + | is a union, so this never loses work. A local bookmark that is only behind |
|
| 164 | + | its remote (`-3` in `bookmark list`) is caught up when you switch to it. |
|
| 164 | 165 | ||
| 165 | 166 | ``` |
|
| 166 | 167 | oxy clone https://oxygenhub.io/owner/game --recursive # subrepos too |
|
| … | 211 unchanged lines |
||
| 378 | 379 | - `oxy pull` fetches, then makes your bookmark, and every other local |
|
| 379 | 380 | bookmark the remote also has, the union of both sides. |
|
| 380 | 381 | There is no merge commit; unrelated changes simply coexist, and a real |
|
| 381 | - | conflict is recorded as in section 6. The working copy follows when it was |
|
| 382 | - | clean. |
|
| 382 | + | conflict is recorded as in section 6. The working copy follows, taking |
|
| 383 | + | uncommitted work along when the pulled changes leave its files alone; |
|
| 384 | + | otherwise it stays and `oxy checkout` updates it. |
|
| 383 | 385 | - `oxy push` sends your changes and moves the remote bookmark. If the remote |
|
| 384 | 386 | moved since you last fetched, the push is refused: pull and push again. |
|
| 385 | 387 | `--force` overrides the compare-and-swap. |
|
| … | 267 unchanged lines |
modified src/cli/workspace.rs +11 −0
| … | 2514 unchanged lines |
||
| 2515 | 2515 | ); |
|
| 2516 | 2516 | } |
|
| 2517 | 2517 | let name = local.as_str(); |
|
| 2518 | + | // Only behind its remote: take the remote's state, so the switch does |
|
| 2519 | + | // not check out a stale one (oxygen#74). |
|
| 2520 | + | if let Some(n) = repo.catch_up_bookmark(name)? { |
|
| 2521 | + | println!( |
|
| 2522 | + | "{} {} with {}: {} it lacked", |
|
| 2523 | + | ui::done("Caught up"), |
|
| 2524 | + | ui::bookmark(name), |
|
| 2525 | + | ui::dim(format!("{}/{name}", repo.default_remote())), |
|
| 2526 | + | plural(n, "change") |
|
| 2527 | + | ); |
|
| 2528 | + | } |
|
| 2518 | 2529 | let sp = ui::spinner(&format!("Switching to {name}…")); |
|
| 2519 | 2530 | let (r, carried) = if carry { |
|
| 2520 | 2531 | repo.bookmark_switch_carry(name)? |
|
| … | 146 unchanged lines |