OXYGENHUB

oxy resolve PATH settles a replace conflict with the working copy's content (closes #140) 157d02e95586

A binary file or a subrepo pin replaced two ways had no markers to edit,
and only a hosted edit could pass the paths to settle to the change
builder, so every local commit was refused. The choice is kept per
workspace and passed by each snapshot until it is committed. Changelog
lines for this and #141; the guide and spec say how.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2e06135da59e
When
· 2026-10-25 16:51 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
157d02e955868ee277f6069430bfc29157a04ce406f691b7da7e07e338e6c895
Depends on
0bdc3f91a39f 1c2bd9abdd4f 236f35c0baba 23ff1288a5dd 2c9ddac3c387 4476cdbb0443 614541c3cebd 72484195899a 87322662f607 8fd78eb52ad2 982864068a59 c6aa5bd9d1c8 c863c7a7a2d1 d42369abf180 e4595038502a ee06a6c557b7 f7510b63cceb fce44ae9ad29 ff0af1ee299d
Depended on by
c0d4075cadc0 ac4ec124b9f2 4afe288b7946 9bd98cfb4f7e c8d9059e4e70
In bookmarks
identical-sides main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.7.0 v0.8.0 v0.9.0
Edits
19 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A binary file's or subrepo pin's conflict can be settled from the command line (#140).** Two changes that replaced the same binary file, or moved the same subrepo pin, leave a replace conflict with no markers to edit, and every commit was refused until it was settled, which only a hosted edit could do. `oxy resolve PATH…` now settles it with what the working copy holds at the path (the file, or the subrepo's pin): the next commit is a resolution replacing every version with it. A path with no replace conflict is refused, and a choice is forgotten once committed.
6 +
- **The object store works where hard links are refused (#141).** Android forbids apps hard links in their own storage, so the OxygenHub app's phone runner could clone nothing (`Permission denied`). Where a link is refused, objects are renamed into place with `renameat2(RENAME_NOREPLACE)`: as atomic, and still never over a name that is taken.
5 7
- **Rendering a big tree is many times faster, and clone, pull, switch and `oxy workspace add` stop rendering it twice (#137).** Measured on the VS Code history's depth-0 clone (19,545 files, 166,425 changes), `oxy workspace add` took 467 s on Earth and 176 s on an 8-core workstation, 11 GB at its peak; it now takes 7.5 s and 1.8 GB on the workstation. Three causes: (1) whether one change depends on another, which rendering asks about every deleted line, was answered by a depth-first walk that compared a change with the one sought only on coming back up to it, so it went through thousands of changes first although the answer is nearly always a direct dependency (spec §8.1): 262,678 questions took 4.4 billion steps and 838 CPU-seconds, and now take 5 CPU-seconds, with the same answers; (2) finding the subrepos rendered every file of the tree, after every clone, pull, switch and `workspace add`, where reading the tree and the subrepos' pins is enough; (3) a new workspace started on the target state with an empty directory, so the checkout's first snapshot found the whole tree deleted and rendered it to say so. `OXY_TIMINGS=1` now also says how a checkout's files were ordered (`graph: single order`…) and how its blobs were read.
6 8
- **`oxy serve` takes a push of more than 2 MB (#138).** It set no request limit of its own, so axum's default of 2 MB applied, and a larger push part was cut off with `Broken pipe` at the client and nothing in the server's log. It now takes up to 4 GiB a request (`oxy_server::BODY_LIMIT`), OxygenHub's default `push_body_limit`; OxygenHub was not affected.
7 9
- **A push no longer sends a replaced or removed binary file's old content again (#136).** A bundle carried both the old and the new blob of every binary file a change replaces, so a change removing ~1,100 tracked cache files (oxygenhub-android's set-up) pushed all of them again, over 100 MB, though the hub stored each when it was added. The old blob is always the new blob of a change the replacement depends on, which the receiver already holds or which the same bundle carries, so pushes and fetch answers now carry only the new one.
…
265 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +80 −1
…
77 unchanged lines
78 78
    written: u128,
79 79
}
80 80
81 +
/// Whether `base` has two or more live versions of the opaque file or
82 +
/// subrepo pin at `path` (a replace conflict, spec §9.3).
83 +
fn has_replace_conflict(base: &Snapshot, path: &str) -> bool {
84 +
    matches!(base.files.get(path).map(|f| &f.content), Some(FileContent::Opaque { blobs }) if blobs.len() > 1)
85 +
}
86 +
81 87
pub fn is_binary(content: &[u8]) -> bool {
82 88
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
83 89
}
…
295 unchanged lines
379 385
            self.sign_change(&hash)?;
380 386
        }
381 387
        Ok(hash)
388 +
    }
389 +
390 +
    fn resolutions_path(&self) -> PathBuf {
391 +
        self.oxy
392 +
            .join("workspaces")
393 +
            .join(self.workspace.to_hex())
394 +
            .join("resolve")
395 +
    }
396 +
397 +
    /// The paths whose replace conflicts the user chose to settle with what
398 +
    /// the working copy holds there (`oxy resolve PATH`, spec §9.3).
399 +
    pub fn resolutions(&self) -> Result<BTreeSet<String>> {
400 +
        match fs::read_to_string(self.resolutions_path()) {
401 +
            Ok(text) => Ok(text
402 +
                .lines()
403 +
                .filter(|l| !l.is_empty())
404 +
                .map(String::from)
405 +
                .collect()),
406 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()),
407 +
            Err(e) => Err(e.into()),
408 +
        }
409 +
    }
410 +
411 +
    /// Settle the replace conflicts at `paths` (a file's or a subrepo pin's,
412 +
    /// spec §9.3) with what the working copy holds there: the next snapshot
413 +
    /// replaces every live candidate with it. Refused for a path whose base
414 +
    /// has no replace conflict.
415 +
    pub fn choose_resolutions(&self, base: &Snapshot, paths: &[String]) -> Result<()> {
416 +
        for p in paths {
417 +
            if !has_replace_conflict(base, p) {
418 +
                return Err(RepoError::Msg(format!(
419 +
                    "{p} has no replace conflict to settle (a text file's conflict is settled by editing out its markers)"
420 +
                )));
421 +
            }
422 +
        }
423 +
        let mut all = self.resolutions()?;
424 +
        all.extend(paths.iter().cloned());
425 +
        let text: String = all.iter().map(|p| format!("{p}\n")).collect();
426 +
        crate::durable::write_atomic(&self.resolutions_path(), text.as_bytes())?;
427 +
        Ok(())
428 +
    }
429 +
430 +
    /// The chosen resolutions `base` still needs; the rest are forgotten.
431 +
    fn resolutions_for(&self, base: &Snapshot) -> Result<HashSet<String>> {
432 +
        let all = self.resolutions()?;
433 +
        if all.is_empty() {
434 +
            return Ok(HashSet::new());
435 +
        }
436 +
        let live: HashSet<String> = all
437 +
            .iter()
438 +
            .filter(|p| has_replace_conflict(base, p))
439 +
            .cloned()
440 +
            .collect();
441 +
        if live.is_empty() {
442 +
            match fs::remove_file(self.resolutions_path()) {
443 +
                Ok(()) => {}
444 +
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
445 +
                Err(e) => return Err(e.into()),
446 +
            }
447 +
        }
448 +
        Ok(live)
382 449
    }
383 450
384 451
    /// Snapshot the working copy into the working-copy change (spec §12.3).
…
124 unchanged lines
509 576
        // paths, so an unchanged working copy hashes identically (spec §12.3).
510 577
        let reuse = added_node_paths(&old_wc, &base_snapshot);
511 578
        self.platform_normalize(&mut fs_entries, &base_snapshot);
579 +
        // Replace conflicts the user settled with `oxy resolve PATH`
580 +
        // (oxygen#140): those still in the base; a choice the base no longer
581 +
        // needs (once committed) is forgotten.
582 +
        let resolve = self.resolutions_for(&base_snapshot)?;
512 583
        let built = {
513 584
            let _t = crate::timing::phase("snapshot: diff against base");
514 -
            if partial && self.config.sparse_paths.is_empty() {
585 +
            if !resolve.is_empty() {
586 +
                self.build_from_entries_resolving(
587 +
                    &base,
588 +
                    &base_snapshot,
589 +
                    reuse,
590 +
                    &fs_entries,
591 +
                    &resolve,
592 +
                )?
593 +
            } else if partial && self.config.sparse_paths.is_empty() {
515 594
                #[cfg(test)]
516 595
                let full =
517 596
                    self.build_from_entries(&base, &base_snapshot, reuse.clone(), &fs_entries)?;
…
2602 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +131 −0
…
9040 unchanged lines
9041 9041
    assert!(e.contains("no such bookmark"), "{e}");
9042 9042
}
9043 9043
9044 +
/// oxygen#140: a replace conflict is settled from the working copy, as
9045 +
/// `oxy resolve PATH` does: the path's content on disk becomes the
9046 +
/// resolution, and the commit that records it is a Resolution.
9044 9047
#[test]
9048 +
fn a_replace_conflict_is_settled_from_the_working_copy() {
9049 +
    hermetic();
9050 +
    let dir = tempfile::tempdir().unwrap();
9051 +
    let root = dir.path();
9052 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
9053 +
    let blob = |n: u8| {
9054 +
        let mut v = vec![0u8; 64];
9055 +
        v[1] = n;
9056 +
        v
9057 +
    };
9058 +
    write(root, "img.bin", &blob(1));
9059 +
    write(root, "a.txt", b"a\n");
9060 +
    repo.commit("add img", &[]).unwrap();
9061 +
    repo.bookmark_create("other", None).unwrap();
9062 +
    write(root, "img.bin", &blob(2));
9063 +
    repo.commit("img two", &[]).unwrap();
9064 +
    repo.bookmark_switch("other", false).unwrap();
9065 +
    write(root, "img.bin", &blob(3));
9066 +
    repo.commit("img three", &[]).unwrap();
9067 +
    repo.bookmark_switch("main", false).unwrap();
9068 +
    repo.merge("other").unwrap();
9069 +
    // Without a choice, nothing the working copy holds settles it, and
9070 +
    // other work cannot be committed past it.
9071 +
    write(root, "img.bin", &blob(9));
9072 +
    write(root, "a.txt", b"a\nmore\n");
9073 +
    assert!(
9074 +
        repo.commit("edit a", &[]).is_err(),
9075 +
        "refused while conflicted"
9076 +
    );
9077 +
    let snap = repo.snapshot("oxy resolve").unwrap();
9078 +
    assert!(
9079 +
        repo.choose_resolutions(&snap.base_snapshot, &["a.txt".to_string()])
9080 +
            .is_err(),
9081 +
        "a.txt has no replace conflict"
9082 +
    );
9083 +
    repo.choose_resolutions(&snap.base_snapshot, &["img.bin".to_string()])
9084 +
        .unwrap();
9085 +
    let snap = repo.snapshot("oxy resolve").unwrap();
9086 +
    assert!(snap
9087 +
        .statuses
9088 +
        .iter()
9089 +
        .any(|s| s.path == "img.bin" && s.status == ChangeStatus::Resolved));
9090 +
    let c = repo.commit("settle img", &[]).unwrap();
9091 +
    assert_eq!(
9092 +
        repo.store().get_change(&c).unwrap().kind,
9093 +
        ChangeKind::Resolution
9094 +
    );
9095 +
    let after = repo
9096 +
        .load_state(&repo.bookmark_heads().unwrap())
9097 +
        .unwrap()
9098 +
        .materialize();
9099 +
    assert!(!after.is_conflicted());
9100 +
    assert_eq!(
9101 +
        after.files["img.bin"].content,
9102 +
        FileContent::Opaque {
9103 +
            blobs: vec![Repo::blob_ref(&blob(9))]
9104 +
        }
9105 +
    );
9106 +
    assert!(
9107 +
        matches!(&after.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nmore\n")
9108 +
    );
9109 +
    // Settled: the choice is forgotten, and the working copy is clean.
9110 +
    let snap = repo.snapshot("status").unwrap();
9111 +
    assert!(snap.statuses.is_empty(), "{:?}", snap.statuses);
9112 +
    assert!(repo.resolutions().unwrap().is_empty());
9113 +
}
9114 +
9115 +
/// oxygen#140: the same for a subrepo pin moved two ways at once.
9116 +
#[test]
9117 +
fn a_pin_conflict_is_settled_from_the_working_copy() {
9118 +
    hermetic();
9119 +
    let dir = tempfile::tempdir().unwrap();
9120 +
    let root = dir.path();
9121 +
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
9122 +
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
9123 +
    let lib = Repo::clone(&lib_url, &root.join("lib"))
9124 +
        .unwrap()
9125 +
        .with_home(root.join("home"));
9126 +
    write(&root.join("lib"), "lib.txt", b"v1\n");
9127 +
    lib.commit("lib v1", &[]).unwrap();
9128 +
    assert!(lib.push("origin", false).unwrap()[0].ok);
9129 +
    let app_dir = root.join("app");
9130 +
    let mut app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
9131 +
    write(&app_dir, "main.txt", b"app\n");
9132 +
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
9133 +
    app.commit("pin v1", &[]).unwrap();
9134 +
    let nested = Repo::open(&app_dir.join("libs/lib"))
9135 +
        .unwrap()
9136 +
        .with_home(root.join("home"));
9137 +
    fn pin(app: &Repo, nested: &Repo, text: &[u8], message: &str) {
9138 +
        write(nested.root(), "lib.txt", text);
9139 +
        nested.commit(message, &[]).unwrap();
9140 +
        app.commit(&format!("pin {message}"), &[]).unwrap();
9141 +
    }
9142 +
    app.bookmark_create("other", None).unwrap();
9143 +
    pin(&app, &nested, b"v2\n", "v2");
9144 +
    app.bookmark_switch("other", false).unwrap();
9145 +
    pin(&app, &nested, b"v3\n", "v3");
9146 +
    app.bookmark_switch("main", false).unwrap();
9147 +
    app.merge("other").unwrap();
9148 +
    let merged = app
9149 +
        .load_state(&app.bookmark_heads().unwrap())
9150 +
        .unwrap()
9151 +
        .materialize();
9152 +
    assert!(
9153 +
        matches!(&merged.files["libs/lib"].content, FileContent::Opaque { blobs } if blobs.len() == 2),
9154 +
        "a pin conflict"
9155 +
    );
9156 +
    // The nested repository moves on once more; that pin is the choice.
9157 +
    write(&app_dir.join("libs/lib"), "lib.txt", b"v4\n");
9158 +
    nested.commit("v4", &[]).unwrap();
9159 +
    let snap = app.snapshot("oxy resolve").unwrap();
9160 +
    app.choose_resolutions(&snap.base_snapshot, &["libs/lib".to_string()])
9161 +
        .unwrap();
9162 +
    let c = app.commit("settle the pin", &[]).unwrap();
9163 +
    assert_eq!(
9164 +
        app.store().get_change(&c).unwrap().kind,
9165 +
        ChangeKind::Resolution
9166 +
    );
9167 +
    let after = app
9168 +
        .load_state(&app.bookmark_heads().unwrap())
9169 +
        .unwrap()
9170 +
        .materialize();
9171 +
    assert!(!after.is_conflicted());
9172 +
    assert!(app.snapshot("status").unwrap().statuses.is_empty());
9173 +
}
9174 +
9175 +
#[test]
9045 9176
fn carrying_work_over_a_subrepo_already_at_the_target_pin_is_no_conflict() {
9046 9177
    // oxygen#74: a stale state with .oxymodules but no pin, a newer one
9047 9178
    // pinning the subrepo, and the nested repository on disk at that pin.
…
1127 unchanged lines
modified docs/guide.md +9 −0
…
318 unchanged lines
319 319
name, appear as `<name>.oxy-conflict-<n>` candidates: keep the one you
320 320
want under the right name and delete the rest.
321 321
322 +
A binary file or a subrepo pin changed two ways has no markers to edit:
323 +
the checkout writes one version, and you say which content wins by
324 +
naming it. Put the bytes you want at the path (or move the subrepo to the
325 +
pin you want), then:
326 +
327 +
```
328 +
oxy resolve assets/logo.png deps/lib   # these paths' content settles them
329 +
```
330 +
322 331
```
323 332
oxy resolve      # which conflicts your edits resolve, which still have markers
324 333
oxy commit -m "Resolve retry vs refactor"
…
384 unchanged lines
modified docs/spec.md +2 −2
…
365 unchanged lines
366 366
367 367
Two live `Replace` edits with the same `old` and different `new` are a **replace conflict**. The state exposes both candidate blobs.
368 368
369 -
Which blobs are live: a replace record is superseded by a later replacement of its blob, a record whose `old` is its `new` and whose change depends on its change (§8.1 makes every replacement depend on the change that made its `old` current). Replacements that do not know each other both stay live, and a file reverted to an earlier blob keeps it. A replace conflict is **resolved** by a `kind = resolution` change with one `Replace` from each live blob to the chosen one, depending on every change that made a candidate live, so each is superseded and one blob remains. A working-copy snapshot never settles a replace conflict on its own (a checkout writes one candidate at the path); a tool that knows the user's choice, such as a forge's resolver, builds the resolution. A replacement recorded without that dependency (before Draft 0.8i) supersedes every record with its `new` that does not come after it, the rule until then.
369 +
Which blobs are live: a replace record is superseded by a later replacement of its blob, a record whose `old` is its `new` and whose change depends on its change (§8.1 makes every replacement depend on the change that made its `old` current). Replacements that do not know each other both stay live, and a file reverted to an earlier blob keeps it. A replace conflict is **resolved** by a `kind = resolution` change with one `Replace` from each live blob to the chosen one, depending on every change that made a candidate live, so each is superseded and one blob remains. A working-copy snapshot never settles a replace conflict on its own (a checkout writes one candidate at the path); a tool that knows the user's choice, such as a forge's resolver or `oxy resolve PATH` (which takes the working copy's content at the path), builds the resolution. A replacement recorded without that dependency (before Draft 0.8i) supersedes every record with its `new` that does not come after it, the rule until then.
370 370
371 371
### 9.4 Properties
372 372
…
647 unchanged lines
1020 1020
oxy commit -m MSG [--depends-on CHANGE]..
1021 1021
oxy log [-r REVSET] [-n N] [-v] [-L S,E:PATH]   oxy show CHANGE | -r REVSET [--stat]
1022 1022
oxy describe [CHANGE] [-m MSG]      oxy amend [-m MSG] [--into CHANGE | PATH..]
1023 -
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve
1023 +
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve [PATH]..
1024 1024
oxy checkout [CHANGE].. [--discard|--carry] oxy undo                     oxy op log
1025 1025
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
1026 1026
oxy bookmark create|set|delete|list|switch  oxy merge BOOKMARK
…
163 unchanged lines
modified src/cli/mod.rs +6 −2
…
294 unchanged lines
295 295
        #[arg(long, conflicts_with = "discard")]
296 296
        carry: bool,
297 297
    },
298 -
    /// Report which conflicts of the working copy's base are resolved by the current files
298 +
    /// Report which conflicts of the working copy's base are resolved by the current files;
299 +
    /// with paths, settle their replace conflicts (binary files, subrepo pins) with what the
300 +
    /// working copy holds there
299 301
    Resolve {
302 +
        /// Opaque files or subrepos whose replace conflict to settle with their current content
303 +
        paths: Vec<String>,
300 304
        /// Machine-readable output: one JSON document on stdout
301 305
        #[arg(long)]
302 306
        json: bool,
…
967 unchanged lines
1270 1274
            discard,
1271 1275
            carry,
1272 1276
        } => workspace::checkout(&changes, discard, carry),
1273 -
        Cmd::Resolve { json } => workspace::resolve(json),
1277 +
        Cmd::Resolve { paths, json } => workspace::resolve(&paths, json),
1274 1278
        Cmd::Undo => workspace::undo(),
1275 1279
        Cmd::Gc {
1276 1280
            now,
…
292 unchanged lines
modified src/cli/workspace.rs +12 −2
…
1409 unchanged lines
1410 1410
    Ok(0)
1411 1411
}
1412 1412
1413 -
pub fn resolve(as_json: bool) -> Result<i32> {
1413 +
pub fn resolve(paths: &[String], as_json: bool) -> Result<i32> {
1414 1414
    let repo = open()?;
1415 -
    let snap = repo.snapshot("oxy resolve")?;
1415 +
    let mut snap = repo.snapshot("oxy resolve")?;
1416 +
    if !paths.is_empty() {
1417 +
        // A replace conflict has no markers to edit out: the user says which
1418 +
        // content wins (oxygen#140, spec §9.3), and the snapshot builds it.
1419 +
        let paths: Vec<String> = paths
1420 +
            .iter()
1421 +
            .map(|p| p.trim_start_matches("./").trim_end_matches('/').to_string())
1422 +
            .collect();
1423 +
        repo.choose_resolutions(&snap.base_snapshot, &paths)?;
1424 +
        snap = repo.snapshot("oxy resolve")?;
1425 +
    }
1416 1426
    if as_json {
1417 1427
        println!("{}", repo.status_json(&snap)?);
1418 1428
        return Ok(if snap.base_snapshot.is_conflicted() {
…
1587 unchanged lines