OXYGENHUB

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(&current_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(&current_heads, &current, "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(&current_heads, &current, "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