pull: bring a working copy that fell behind up to its bookmark f6dccc4533c8
A pull moved the working copy only from exactly the bookmark's old heads, and not at all when it brought nothing new. A working copy left behind by bookmark set, a landing or a pull in another workspace stayed on old code while status said clean: supermarioland's lacked 22 of develop's changes after a pull. The working copy now follows whenever its base is part of the bookmark's state, checked out when clean and carried when it has work, and status says when it is behind and by how many changes. Checked on supermarioland itself: the pull brought it up, the landed files are on disk, status is clean with no warning. closes #89
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-09 10:55 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
f6dccc4533c80e091e46c4defba12d9c29712a9fd6ec444457b5f75eb4882a94- Depends on
- 135212de5370 14edbaaf5291 3604179679eb 50a046e439e9 72484195899a 75167672ebcd 7d9dab131937 8fd78eb52ad2 982864068a59 cbe8499a5a8c d256e826ac5d da868d5d2646 ee06a6c557b7 fce44ae9ad29
- Depended on by
- b63fc7a77fc6 d36ba2f79f95 170bb436e1d5
- 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.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
- 16 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **`pull` brings a working copy that fell behind up to its bookmark (#89).** The working copy followed a pull only from exactly the bookmark's old heads, and not at all when the pull brought nothing new, so one left behind by `bookmark set`, a landing or a pull from another workspace stayed on old code while `status` said clean. It now follows whenever its base is part of the bookmark's state, clean or carrying uncommitted work, and `status` says when the working copy is behind its bookmark and by how many changes. |
|
| 6 | + | ||
| 5 | 7 | - **Faster merges in a Git import (#80).** A merge is built from only the paths it can change, as an ordinary commit is: on VS Code at 139 000 commits merges import 3.2 times faster (9.5 s against 30.9 s on average) and the import as a whole 2.7 times faster, with exactly the history the whole-tree path builds. `OXY_IMPORT_WHOLE_MERGES=1` takes the old path. |
|
| 6 | 8 | ||
| 7 | 9 | - **A binary file changed and made executable at once keeps both (#87).** `oxy commit` and the Git import dropped the executable bit when a binary file got new contents in the same change. |
|
| … | 138 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +76 −31
| … | 1465 unchanged lines |
||
| 1466 | 1466 | others.push(name); |
|
| 1467 | 1467 | } |
|
| 1468 | 1468 | } |
|
| 1469 | - | if !changed && others.is_empty() { |
|
| 1470 | - | return Ok(PullReport { |
|
| 1471 | - | heads: local_current, |
|
| 1472 | - | changed: false, |
|
| 1473 | - | checked_out: false, |
|
| 1474 | - | others, |
|
| 1475 | - | }); |
|
| 1469 | + | if changed || !others.is_empty() { |
|
| 1470 | + | self.record_op(&[op_ref], &format!("oxy pull {remote_name}"), view)?; |
|
| 1476 | 1471 | } |
|
| 1477 | - | self.record_op(&[op_ref], &format!("oxy pull {remote_name}"), view)?; |
|
| 1478 | - | let mut checked_out = false; |
|
| 1479 | - | if changed { |
|
| 1480 | - | // Follow along if the working copy was clean and on the old heads. |
|
| 1481 | - | let snap = self.snapshot("oxy pull")?; |
|
| 1482 | - | let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); |
|
| 1483 | - | let mut base = snap.base.maximal(&snap.wc.deps); |
|
| 1484 | - | base.sort(); |
|
| 1485 | - | if base == local_current { |
|
| 1486 | - | if clean { |
|
| 1487 | - | self.checkout(¤t_heads, false, "oxy pull (checkout)")?; |
|
| 1488 | - | checked_out = true; |
|
| 1489 | - | } else { |
|
| 1490 | - | // Uncommitted work comes along when the pulled changes |
|
| 1491 | - | // leave its paths alone, as `git pull` does; otherwise |
|
| 1492 | - | // the working copy stays and the caller says so |
|
| 1493 | - | // (oxygen#74). |
|
| 1494 | - | checked_out = self |
|
| 1495 | - | .checkout_carry(¤t_heads, ¤t, "oxy pull (checkout --carry)") |
|
| 1496 | - | .is_ok(); |
|
| 1497 | - | } |
|
| 1498 | - | } |
|
| 1499 | - | } |
|
| 1472 | + | // The working copy follows its bookmark whenever nothing would be |
|
| 1473 | + | // lost, also when this pull brought nothing new but it had been |
|
| 1474 | + | // left behind (oxygen#89). |
|
| 1475 | + | let checked_out = self.follow_bookmark(¤t_heads, ¤t, "oxy pull")?; |
|
| 1500 | 1476 | Ok(PullReport { |
|
| 1501 | - | heads: current_heads, |
|
| 1477 | + | heads: if changed { |
|
| 1478 | + | current_heads |
|
| 1479 | + | } else { |
|
| 1480 | + | local_current |
|
| 1481 | + | }, |
|
| 1502 | 1482 | changed, |
|
| 1503 | 1483 | checked_out, |
|
| 1504 | 1484 | others, |
|
| 1505 | 1485 | }) |
|
| 1486 | + | } |
|
| 1487 | + | ||
| 1488 | + | /// How many changes the working copy's bookmark has that its base |
|
| 1489 | + | /// lacks, when the base is part of the bookmark's state (it fell behind, |
|
| 1490 | + | /// and `oxy checkout` or `oxy pull` brings it up); `None` when it is on |
|
| 1491 | + | /// the bookmark or holds changes the bookmark lacks. History is read |
|
| 1492 | + | /// only when the base and the bookmark's heads differ (oxygen#89). |
|
| 1493 | + | pub fn working_copy_behind(&self) -> Result<Option<usize>> { |
|
| 1494 | + | let (_, wc) = self.working_copy()?; |
|
| 1495 | + | let mut base = wc.deps.clone(); |
|
| 1496 | + | base.sort(); |
|
| 1497 | + | let mut heads = self.bookmark_heads()?; |
|
| 1498 | + | heads.sort(); |
|
| 1499 | + | if base == heads || heads.is_empty() { |
|
| 1500 | + | return Ok(None); |
|
| 1501 | + | } |
|
| 1502 | + | let state = self.load_state_history(&heads)?; |
|
| 1503 | + | if !base.iter().all(|h| state.contains(h)) { |
|
| 1504 | + | return Ok(None); |
|
| 1505 | + | } |
|
| 1506 | + | let had = if base.is_empty() { |
|
| 1507 | + | 0 |
|
| 1508 | + | } else { |
|
| 1509 | + | self.load_state_history(&base)?.len() |
|
| 1510 | + | }; |
|
| 1511 | + | Ok(Some(state.len().saturating_sub(had)).filter(|n| *n > 0)) |
|
| 1512 | + | } |
|
| 1513 | + | ||
| 1514 | + | /// Bring the working copy to `heads`, the state of its bookmark |
|
| 1515 | + | /// `bookmark`, when nothing would be lost: its base is part of that |
|
| 1516 | + | /// state, so the bookmark only adds to it. Clean, it is checked out; |
|
| 1517 | + | /// with uncommitted work, the work comes along when the new changes |
|
| 1518 | + | /// leave its paths alone (as `git pull` does), else it stays. Before, |
|
| 1519 | + | /// it followed only from exactly the bookmark's old heads, so a working |
|
| 1520 | + | /// copy already behind (the bookmark moved by `bookmark set`, a landing |
|
| 1521 | + | /// or a pull from another workspace) never caught up, and nothing said |
|
| 1522 | + | /// so (oxygen#74, #89). Returns whether it moved. |
|
| 1523 | + | pub fn follow_bookmark( |
|
| 1524 | + | &self, |
|
| 1525 | + | heads: &[ChangeHash], |
|
| 1526 | + | bookmark: &str, |
|
| 1527 | + | command: &str, |
|
| 1528 | + | ) -> Result<bool> { |
|
| 1529 | + | let snap = self.snapshot(command)?; |
|
| 1530 | + | let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); |
|
| 1531 | + | let mut base = snap.base.maximal(&snap.wc.deps); |
|
| 1532 | + | base.sort(); |
|
| 1533 | + | let mut target = heads.to_vec(); |
|
| 1534 | + | target.sort(); |
|
| 1535 | + | if base == target { |
|
| 1536 | + | return Ok(false); |
|
| 1537 | + | } |
|
| 1538 | + | let state = self.load_state_history(heads)?; |
|
| 1539 | + | if !base.iter().all(|h| state.contains(h)) { |
|
| 1540 | + | // The working copy holds changes the bookmark lacks: not ours to move. |
|
| 1541 | + | return Ok(false); |
|
| 1542 | + | } |
|
| 1543 | + | if clean { |
|
| 1544 | + | self.checkout(heads, false, &format!("{command} (checkout)"))?; |
|
| 1545 | + | Ok(true) |
|
| 1546 | + | } else { |
|
| 1547 | + | Ok(self |
|
| 1548 | + | .checkout_carry(heads, bookmark, &format!("{command} (checkout --carry)")) |
|
| 1549 | + | .is_ok()) |
|
| 1550 | + | } |
|
| 1506 | 1551 | } |
|
| 1507 | 1552 | ||
| 1508 | 1553 | /// Push the current bookmark (spec §16.3), sealing this author's unsigned |
|
| … | 423 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +37 −0
| … | 7894 unchanged lines |
||
| 7895 | 7895 | ); |
|
| 7896 | 7896 | assert!(e.contains("this repository's config"), "{e}"); |
|
| 7897 | 7897 | } |
|
| 7898 | + | ||
| 7899 | + | #[test] |
|
| 7900 | + | fn a_working_copy_behind_its_bookmark_is_flagged_and_pull_brings_it_up() { |
|
| 7901 | + | // oxygen#89 (supermarioland, 2026-10-09): the bookmark moved on without |
|
| 7902 | + | // the working copy, a pull with nothing new left it there, and status |
|
| 7903 | + | // said "clean". |
|
| 7904 | + | hermetic(); |
|
| 7905 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 7906 | + | let root = dir.path(); |
|
| 7907 | + | let _up = Repo::init(&root.join("up")).unwrap(); |
|
| 7908 | + | let url = root.join("up").to_string_lossy().to_string(); |
|
| 7909 | + | let other = Repo::clone(&url, &root.join("other")) |
|
| 7910 | + | .unwrap() |
|
| 7911 | + | .with_home(root.join("home")); |
|
| 7912 | + | write(&root.join("other"), "a.txt", b"a\n"); |
|
| 7913 | + | other.commit("a", &[]).unwrap(); |
|
| 7914 | + | assert!(other.push("origin", false).unwrap()[0].ok); |
|
| 7915 | + | let one = Repo::clone(&url, &root.join("one")) |
|
| 7916 | + | .unwrap() |
|
| 7917 | + | .with_home(root.join("home")); |
|
| 7918 | + | write(&root.join("other"), "b.txt", b"b\n"); |
|
| 7919 | + | other.commit("b", &[]).unwrap(); |
|
| 7920 | + | assert!(other.push("origin", false).unwrap()[0].ok); |
|
| 7921 | + | one.fetch("origin").unwrap(); |
|
| 7922 | + | // The bookmark moves; the working copy does not. |
|
| 7923 | + | let theirs = one.resolve_heads("origin/main").unwrap(); |
|
| 7924 | + | one.bookmark_set("main", &theirs).unwrap(); |
|
| 7925 | + | assert!(!root.join("one/b.txt").exists()); |
|
| 7926 | + | assert_eq!(one.working_copy_behind().unwrap(), Some(1)); |
|
| 7927 | + | let r = one.pull("origin").unwrap(); |
|
| 7928 | + | assert!( |
|
| 7929 | + | !r.changed && r.checked_out, |
|
| 7930 | + | "nothing new, but the working copy caught up" |
|
| 7931 | + | ); |
|
| 7932 | + | assert!(root.join("one/b.txt").exists()); |
|
| 7933 | + | assert_eq!(one.working_copy_behind().unwrap(), None); |
|
| 7934 | + | } |
modified docs/guide.md +3 −1
| … | 380 unchanged lines |
||
| 381 | 381 | There is no merge commit; unrelated changes simply coexist, and a real |
|
| 382 | 382 | conflict is recorded as in section 6. The working copy follows, taking |
|
| 383 | 383 | uncommitted work along when the pulled changes leave its files alone; |
|
| 384 | - | otherwise it stays and `oxy checkout` updates it. |
|
| 384 | + | otherwise it stays and `oxy checkout` updates it. A working copy that had |
|
| 385 | + | fallen behind its bookmark catches up on the next pull too, and `oxy |
|
| 386 | + | status` says when it is behind. |
|
| 385 | 387 | - `oxy push` sends your changes and moves the remote bookmark. If the remote |
|
| 386 | 388 | moved since you last fetched, the push is refused: pull and push again. |
|
| 387 | 389 | `--force` overrides the compare-and-swap. |
|
| … | 271 unchanged lines |
modified src/cli/sync.rs +10 −2
| … | 340 unchanged lines |
||
| 341 | 341 | } |
|
| 342 | 342 | if !r.changed { |
|
| 343 | 343 | sp.done(&format!( |
|
| 344 | - | "{} {}", |
|
| 344 | + | "{} {}{}", |
|
| 345 | 345 | ui::ok(ui::glyph("✔", "*")), |
|
| 346 | 346 | ui::dim(format!( |
|
| 347 | 347 | "{} is up to date with {remote}.", |
|
| 348 | 348 | repo.config.bookmark |
|
| 349 | - | )) |
|
| 349 | + | )), |
|
| 350 | + | if r.checked_out { |
|
| 351 | + | ui::dim(" The working copy had fallen behind it and is updated.") |
|
| 352 | + | } else { |
|
| 353 | + | String::new() |
|
| 354 | + | } |
|
| 350 | 355 | )); |
|
| 351 | 356 | others_pulled(&r.others); |
|
| 352 | 357 | others_behind(&repo, &remote); |
|
| 358 | + | if r.checked_out { |
|
| 359 | + | super::workspace::where_now(&repo); |
|
| 360 | + | } |
|
| 353 | 361 | return Ok(0); |
|
| 354 | 362 | } |
|
| 355 | 363 | sp.done(&format!( |
|
| … | 554 unchanged lines |
modified src/cli/workspace.rs +18 −0
| … | 120 unchanged lines |
||
| 121 | 121 | } |
|
| 122 | 122 | } |
|
| 123 | 123 | ||
| 124 | + | /// Say when the working copy has fallen behind its own bookmark: a |
|
| 125 | + | /// "clean" status there hid a pull that left it on old code (oxygen#89). |
|
| 126 | + | fn print_behind(repo: &Repo) { |
|
| 127 | + | if let Ok(Some(n)) = repo.working_copy_behind() { |
|
| 128 | + | println!( |
|
| 129 | + | " {} {}", |
|
| 130 | + | ui::caution(), |
|
| 131 | + | ui::warn(format!( |
|
| 132 | + | "the working copy is behind {}: it lacks {} the bookmark has; oxy checkout brings it up", |
|
| 133 | + | repo.config.bookmark, |
|
| 134 | + | plural(n, "change") |
|
| 135 | + | )) |
|
| 136 | + | ); |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | ||
| 124 | 140 | pub fn status(json: bool) -> Result<i32> { |
|
| 125 | 141 | let repo = open()?; |
|
| 126 | 142 | // Answered by the manifest alone, with no history to load (oxygen#35, |
|
| … | 15 unchanged lines |
||
| 142 | 158 | ui::sep(), |
|
| 143 | 159 | ui::dim(plural(heads.len(), "head")) |
|
| 144 | 160 | ); |
|
| 161 | + | print_behind(&repo); |
|
| 145 | 162 | print_statuses(&statuses); |
|
| 146 | 163 | print_untracked_nested(&repo, &repo.untracked_nested()); |
|
| 147 | 164 | return Ok(0); |
|
| … | 23 unchanged lines |
||
| 171 | 188 | ui::sep(), |
|
| 172 | 189 | ui::dim(plural(heads.len(), "head")) |
|
| 173 | 190 | ); |
|
| 191 | + | print_behind(&repo); |
|
| 174 | 192 | print_statuses(&snap.statuses); |
|
| 175 | 193 | print_untracked_nested(&repo, &snap.untracked_nested); |
|
| 176 | 194 | if !snap.base_snapshot.is_conflicted() { |
|
| … | 2508 unchanged lines |