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 |