OXYGENHUB

carry: work the target already has is no conflict fe9586d93fb1

checkout --carry and bookmark switch --carry refused when uncommitted
files were byte-for-byte what the target state holds (or removed where
it has none), as after a proposal of that work landed, leaving only
--discard. Such paths are now already applied: no conflict and nothing
to carry, and the rest of the work comes along.

closes #100
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 23:37 UTC
Signature
✔ signed by Andy McCall
Hash
fe9586d93fb14837bcb44b78acbb490d9dc3ffdde51767e8cab215020bc432a5
Depends on
1c2bd9abdd4f 34fad4a92451 61e3bce7ea5b 7b9809e67c3d 8fd78eb52ad2 982864068a59 b689779e9e74
Depended on by
5d79b5c2de88
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.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
5 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **`--carry` takes work the target already has (#100).** Switching bookmarks or checking out with `--carry` refused when uncommitted files were byte-for-byte what the target state holds, as after a proposal of that work landed, leaving only `--discard`. Such work is now already applied: no conflict, nothing to carry, and the rest of the work comes along.
6 +
5 7
- **Large new files are not snapshotted unseen, and past snapshots expire (#98).** A 1 GB image left untracked in a working tree was taken in by the next `status` and kept by the operation log for good: deleting the file and running `gc --now` freed nothing. Now a new, untracked file over `snapshot.max-new-file-size` (default 100 MiB) is left out and named by `status`, with how to ignore it or raise the limit; tracked files and files already in the working copy are unaffected. Past working-copy snapshots keep their files only for `core.snapshot-retention` (default 7 days), so `gc` prunes what only they held, and `oxy gc --expire-snapshots --now` does it at once. `undo` and `op restore` to an operation whose working copy has expired keep the files as they are.
6 8
7 9
- **`oxy undo` undoes this workspace's last command (#99).** It undid the repository's newest operation, whichever workspace made it: in a repository with several workspaces an undo could take back another workspace's commit or merge, and it said only which operation it went back to. Operations now record their workspace, and undo takes back this workspace's last command, leaving other workspaces' work as it is. On a bookmark both share, only its own heads go; a change another workspace built on it, or the workspace it would take away, refuses the undo and names it. Undo now says which command it undid and what that changed, passes over pushes (the remote keeps what it was sent), and warns when an undone commit is already on a remote. `oxy amend` warns when it rewrites a change a remote already has, so the next push will need `--force`.
…
172 unchanged lines
modified crates/oxy-core/src/repo/bookmarks.rs +25 −0
…
511 unchanged lines
512 512
                _ => false,
513 513
            }
514 514
        };
515 +
        // Work already the target's: the file on disk is exactly the
516 +
        // target's version (or both are absent), so nothing needs carrying
517 +
        // and it is no conflict (oxygen#100), as when a proposal's work
518 +
        // has landed on the bookmark switched to.
519 +
        let already = |p: &String| -> bool {
520 +
            let full = self.root.join(p);
521 +
            match (fs::symlink_metadata(&full), target_state.files.get(p)) {
522 +
                (Err(_), None) => true,
523 +
                (Ok(m), Some(f)) if m.is_file() && f.kind == NodeKind::File => {
524 +
                    if is_executable(&m) != f.mode.exec {
525 +
                        return false;
526 +
                    }
527 +
                    let want = match &f.content {
528 +
                        FileContent::Text { bytes, .. } => Some(bytes.clone()),
529 +
                        FileContent::Opaque { blobs } if blobs.len() == 1 => {
530 +
                            self.read_blob(&blobs[0]).ok()
531 +
                        }
532 +
                        _ => None,
533 +
                    };
534 +
                    want.is_some_and(|w| fs::read(&full).is_ok_and(|have| have == w))
535 +
                }
536 +
                _ => false,
537 +
            }
538 +
        };
539 +
        touched.retain(|p| same(p) || !already(p));
515 540
        let differ: Vec<&String> = touched.iter().filter(|p| !same(p)).collect();
516 541
        if !differ.is_empty() {
517 542
            let list: Vec<&str> = differ.iter().take(10).map(|p| p.as_str()).collect();
…
159 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +47 −11
…
5243 unchanged lines
5244 5244
        err.contains("modified a.txt") && err.contains("removed b.txt") && err.contains("--carry"),
5245 5245
        "{err}"
5246 5246
    );
5247 -
    // --carry takes them along: h1 has a.txt as the base has it, and no b.txt
5248 -
    // is carried as absent (h1 has none either, so that is refused).
5249 -
    let err = repo
5250 -
        .checkout_carry(&[h1], "the first change", "checkout")
5251 -
        .unwrap_err()
5252 -
        .to_string();
5253 -
    assert!(
5254 -
        err.contains("b.txt") && err.contains("the first change"),
5255 -
        "{err}"
5256 -
    );
5257 -
    write(root, "b.txt", b"b\n");
5247 +
    // --carry takes them along: h1 has a.txt as the base has it, and the
5248 +
    // removed b.txt is already as h1 has it (none), so that is no conflict
5249 +
    // either (oxygen#100).
5258 5250
    let (_, carried) = repo
5259 5251
        .checkout_carry(&[h1], "the first change", "checkout")
5260 5252
        .unwrap();
…
3257 unchanged lines
8518 8510
        b"a\n",
8519 8511
        "committed files stay"
8520 8512
    );
8513 +
}
8514 +
8515 +
/// oxygen#100: uncommitted work byte-for-byte what the bookmark switched to
8516 +
/// already has is no conflict: the switch goes through, nothing is lost,
8517 +
/// and other carried work still comes along.
8518 +
#[test]
8519 +
fn carrying_work_the_target_already_has_is_no_conflict() {
8520 +
    hermetic();
8521 +
    let dir = tempfile::tempdir().unwrap();
8522 +
    let root = dir.path();
8523 +
    let mut repo = Repo::init(root).unwrap();
8524 +
    write(root, "a.txt", b"one\n");
8525 +
    write(root, "b.txt", b"b\n");
8526 +
    repo.commit("one", &[]).unwrap();
8527 +
    repo.bookmark_create("landed", None).unwrap();
8528 +
    // On landed, a.txt becomes "two" (as a proposal landing would).
8529 +
    repo.bookmark_switch("landed", false).unwrap();
8530 +
    write(root, "a.txt", b"two\n");
8531 +
    repo.commit("two", &[]).unwrap();
8532 +
    repo.bookmark_switch("main", false).unwrap();
8533 +
    assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"one\n");
8534 +
    // The same edit, uncommitted, on main; and another edit besides.
8535 +
    write(root, "a.txt", b"two\n");
8536 +
    write(root, "b.txt", b"b, edited\n");
8537 +
    let (_, carried) = repo.bookmark_switch_carry("landed").unwrap();
8538 +
    assert_eq!(
8539 +
        carried,
8540 +
        vec!["b.txt".to_string()],
8541 +
        "only the work landed lacks is carried"
8542 +
    );
8543 +
    assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"two\n");
8544 +
    assert_eq!(std::fs::read(root.join("b.txt")).unwrap(), b"b, edited\n");
8545 +
    let s = repo.snapshot("oxy status").unwrap();
8546 +
    let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
8547 +
    assert_eq!(paths, ["b.txt"]);
8548 +
8549 +
    // A real difference is still refused.
8550 +
    repo.bookmark_switch("main", true).unwrap();
8551 +
    write(root, "a.txt", b"three\n");
8552 +
    let e = repo
8553 +
        .bookmark_switch_carry("landed")
8554 +
        .unwrap_err()
8555 +
        .to_string();
8556 +
    assert!(e.contains("a.txt") && e.contains("--discard"), "{e}");
8521 8557
}