OXYGENHUB

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(&current_heads, false, "oxy pull (checkout)")?;
1476 -
                checked_out = true;
1474 +
            if base == local_current {
1475 +
                if clean {
1476 +
                    self.checkout(&current_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(&current_heads, &current, "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