Rebase: --into-working-copy resolves a conflicted rebase in the working copy (closes #42) e266b7d4065c
Where oxy rebase CHANGE --onto HEADS would be conflicted, it now says so with an error of its own, and --into-working-copy merges the HEADS state into the current bookmark (which must hold CHANGE) instead: a working copy without changes follows, so the conflict is fixed there and committed on top, as after oxy merge. merge and this share merge_heads. The other two parts of #42 (bookmark set --upto/--only, the hub's conflict line naming the fix) were done earlier.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 22:58 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e266b7d4065cf05d339a592be8c87d877bddf9f443dad0021a870c3d5988e9ad- Depends on
- 06d16dc2bc13 1c2bd9abdd4f 614541c3cebd 61e9ad6ddc2d 6caacfff1d4f 728da0f92161 75167672ebcd 7b9809e67c3d 86fe29fd62ea 8b678b298fee 957fee30826e 982864068a59 ee06a6c557b7 f9466021286a fce44ae9ad29 ff0af1ee299d
- Depended on by
- 733ea16a9701 631fc8970bd0 98a1620a3840 d4dbab97e948
- In bookmarks
- agents-in-readme 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.2.0 v0.3.0 v0.3.1 v0.4.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
- 23 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified crates/oxy-core/src/repo/bookmarks.rs +43 −6
| … | 349 unchanged lines |
||
| 350 | 350 | /// §10.1). A working copy without changes follows. |
|
| 351 | 351 | pub fn merge(&self, name: &str) -> Result<MergeReport> { |
|
| 352 | 352 | let _wc = self.lock_working_copy()?; |
|
| 353 | - | let (op_ref, op) = self.head_op()?; |
|
| 354 | - | let current = self.config.bookmark.clone(); |
|
| 355 | - | if name == current { |
|
| 353 | + | let (_, op) = self.head_op()?; |
|
| 354 | + | if name == self.config.bookmark { |
|
| 356 | 355 | return fail(format!("{name} is the current bookmark")); |
|
| 357 | 356 | } |
|
| 358 | 357 | let Some(st) = op.view.bookmarks.get(name) else { |
|
| 359 | 358 | return fail(format!("no bookmark {name}")); |
|
| 360 | 359 | }; |
|
| 361 | 360 | let other = self.store.get_state(st)?.heads; |
|
| 361 | + | self.merge_heads(other, &format!("oxy merge {name}")) |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | /// Bring CHANGE up to date with `onto` where a rebase would be |
|
| 365 | + | /// conflicted (oxygen#42): the state of `onto` is merged into the |
|
| 366 | + | /// current bookmark, which must hold the change, and a working copy |
|
| 367 | + | /// without changes follows, so the conflict is resolved there and |
|
| 368 | + | /// committed on top, as after `oxy merge`. |
|
| 369 | + | pub fn rebase_into_working_copy( |
|
| 370 | + | &self, |
|
| 371 | + | c: &ChangeHash, |
|
| 372 | + | onto: &[ChangeHash], |
|
| 373 | + | ) -> Result<MergeReport> { |
|
| 374 | + | let _wc = self.lock_working_copy()?; |
|
| 375 | + | let (_, op) = self.head_op()?; |
|
| 376 | + | let current = self.config.bookmark.clone(); |
|
| 377 | + | let local = self.heads_of(&op.view, ¤t)?; |
|
| 378 | + | if !self.load_state_indexed(&local)?.contains(c) { |
|
| 379 | + | return fail(format!( |
|
| 380 | + | "#{} is not on the current bookmark {current}; switch to the bookmark \ |
|
| 381 | + | holding it first", |
|
| 382 | + | c.short(12) |
|
| 383 | + | )); |
|
| 384 | + | } |
|
| 385 | + | let named: Vec<String> = onto.iter().map(|h| h.short(12)).collect(); |
|
| 386 | + | let label = format!( |
|
| 387 | + | "oxy rebase {} --onto {} --into-working-copy", |
|
| 388 | + | c.short(12), |
|
| 389 | + | named.join(" ") |
|
| 390 | + | ); |
|
| 391 | + | self.merge_heads(onto.to_vec(), &label) |
|
| 392 | + | } |
|
| 393 | + | ||
| 394 | + | /// Union `other` into the current bookmark's heads, recorded as |
|
| 395 | + | /// `label`; a working copy without changes follows. |
|
| 396 | + | fn merge_heads(&self, other: Vec<ChangeHash>, label: &str) -> Result<MergeReport> { |
|
| 397 | + | let (op_ref, op) = self.head_op()?; |
|
| 398 | + | let current = self.config.bookmark.clone(); |
|
| 362 | 399 | let local = self.heads_of(&op.view, ¤t)?; |
|
| 363 | 400 | let mut all = local.clone(); |
|
| 364 | 401 | all.extend(other); |
|
| … | 13 unchanged lines |
||
| 378 | 415 | current, |
|
| 379 | 416 | self.store.put_state(&StateObject::new(heads.clone()))?, |
|
| 380 | 417 | ); |
|
| 381 | - | self.record_op(&[op_ref], &format!("oxy merge {name}"), view)?; |
|
| 382 | - | let snap = self.snapshot("oxy merge")?; |
|
| 418 | + | self.record_op(&[op_ref], label, view)?; |
|
| 419 | + | let snap = self.snapshot(label)?; |
|
| 383 | 420 | let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); |
|
| 384 | 421 | let mut base = snap.base.maximal(&snap.wc.deps); |
|
| 385 | 422 | base.sort(); |
|
| … | 1 unchanged lines |
||
| 387 | 424 | // A working copy without changes follows the merge, whichever state |
|
| 388 | 425 | // it sat on: nothing of the user's can be lost. |
|
| 389 | 426 | if clean { |
|
| 390 | - | self.checkout(&heads, false, "oxy merge (checkout)")?; |
|
| 427 | + | self.checkout(&heads, false, &format!("{label} (checkout)"))?; |
|
| 391 | 428 | checked_out = true; |
|
| 392 | 429 | } |
|
| 393 | 430 | Ok(MergeReport { |
|
| … | 16 unchanged lines |
modified crates/oxy-core/src/repo/history.rs +5 −5
| … | 1130 unchanged lines |
||
| 1131 | 1131 | .is_some_and(|f| f.is_conflicted()) |
|
| 1132 | 1132 | || after.files.get(p).is_some_and(|f| f.is_conflicted()) |
|
| 1133 | 1133 | { |
|
| 1134 | - | return fail(format!( |
|
| 1134 | + | return Err(RepoError::Conflicted(format!( |
|
| 1135 | 1135 | "{p} would be conflicted, and a rebased change cannot hold a conflict. \ |
|
| 1136 | - | To resolve it in the working copy, merge the target in instead: on the \ |
|
| 1137 | - | bookmark holding #{}, run oxy merge with the target bookmark, fix the \ |
|
| 1138 | - | marked files and oxy commit", |
|
| 1136 | + | To resolve it in the working copy instead, on the bookmark holding #{} \ |
|
| 1137 | + | pass --into-working-copy (the target is merged in), fix the marked files \ |
|
| 1138 | + | and oxy commit", |
|
| 1139 | 1139 | c.short(12) |
|
| 1140 | - | )); |
|
| 1140 | + | ))); |
|
| 1141 | 1141 | } |
|
| 1142 | 1142 | } |
|
| 1143 | 1143 | // Entries: the target's files, with touched paths as c leaves them. |
|
| … | 68 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +4 −0
| … | 49 unchanged lines |
||
| 50 | 50 | State(#[from] crate::state::StateError), |
|
| 51 | 51 | #[error("{0}")] |
|
| 52 | 52 | Msg(String), |
|
| 53 | + | /// The operation would leave a conflict where none may be (a rebased |
|
| 54 | + | /// change cannot hold one); the caller may offer another way. |
|
| 55 | + | #[error("{0}")] |
|
| 56 | + | Conflicted(String), |
|
| 53 | 57 | } |
|
| 54 | 58 | ||
| 55 | 59 | pub type Result<T> = std::result::Result<T, RepoError>; |
|
| … | 1870 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +50 −0
| … | 6345 unchanged lines |
||
| 6346 | 6346 | "after an edit a status saw" |
|
| 6347 | 6347 | ); |
|
| 6348 | 6348 | } |
|
| 6349 | + | ||
| 6350 | + | #[test] |
|
| 6351 | + | fn a_conflicted_rebase_can_be_resolved_in_the_working_copy() { |
|
| 6352 | + | hermetic(); |
|
| 6353 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 6354 | + | let root = dir.path(); |
|
| 6355 | + | let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 6356 | + | write(root, "a.txt", b"1\n2\n"); |
|
| 6357 | + | let a = repo.commit("A", &[]).unwrap(); |
|
| 6358 | + | repo.bookmark_create("develop", Some(&[a])).unwrap(); |
|
| 6359 | + | write(root, "a.txt", b"1\nX\n"); |
|
| 6360 | + | let c = repo.commit("C: 2 to X", &[]).unwrap(); |
|
| 6361 | + | repo.bookmark_switch("develop", false).unwrap(); |
|
| 6362 | + | write(root, "a.txt", b"1\nY\n"); |
|
| 6363 | + | let d = repo.commit("D: 2 to Y", &[]).unwrap(); |
|
| 6364 | + | repo.bookmark_switch("main", false).unwrap(); |
|
| 6365 | + | ||
| 6366 | + | let err = repo.rebase(&c, &[d]).unwrap_err(); |
|
| 6367 | + | assert!(matches!(err, RepoError::Conflicted(_)), "{err}"); |
|
| 6368 | + | let r = repo.rebase_into_working_copy(&c, &[d]).unwrap(); |
|
| 6369 | + | assert!(r.changed && r.checked_out); |
|
| 6370 | + | let snap = repo.snapshot_status("status").unwrap(); |
|
| 6371 | + | assert!( |
|
| 6372 | + | snap.base_snapshot.is_conflicted(), |
|
| 6373 | + | "the conflict is in the working copy" |
|
| 6374 | + | ); |
|
| 6375 | + | let text = fs::read_to_string(root.join("a.txt")).unwrap(); |
|
| 6376 | + | assert!(text.contains('X') && text.contains('Y'), "{text}"); |
|
| 6377 | + | ||
| 6378 | + | write(root, "a.txt", b"1\nXY\n"); |
|
| 6379 | + | repo.commit("Resolve", &[]).unwrap(); |
|
| 6380 | + | let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); |
|
| 6381 | + | assert!(st.contains(&c) && st.contains(&d)); |
|
| 6382 | + | let files = st.materialize().files; |
|
| 6383 | + | assert!(!files["a.txt"].is_conflicted()); |
|
| 6384 | + | assert!( |
|
| 6385 | + | matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\nXY\n") |
|
| 6386 | + | ); |
|
| 6387 | + | ||
| 6388 | + | // A change the current bookmark does not hold is refused. |
|
| 6389 | + | repo.bookmark_switch("develop", false).unwrap(); |
|
| 6390 | + | write(root, "b.txt", b"b\n"); |
|
| 6391 | + | let e = repo.commit("E", &[]).unwrap(); |
|
| 6392 | + | repo.bookmark_switch("main", false).unwrap(); |
|
| 6393 | + | let err = repo.rebase_into_working_copy(&e, &[a]).unwrap_err(); |
|
| 6394 | + | assert!( |
|
| 6395 | + | err.to_string().contains("not on the current bookmark"), |
|
| 6396 | + | "{err}" |
|
| 6397 | + | ); |
|
| 6398 | + | } |
modified docs/backlog-overnight-4.md +1 −1
| … | 101 unchanged lines |
||
| 102 | 102 | ||
| 103 | 103 | ## Also |
|
| 104 | 104 | ||
| 105 | - | - [ ] R1 **Rebase into the working copy** (oxygen#42, Andy's choice for the |
|
| 105 | + | - [x] R1 **Rebase into the working copy** (oxygen#42, Andy's choice for the |
|
| 106 | 106 | overnight loop): when `oxy rebase CHANGE --onto TARGET` would be |
|
| 107 | 107 | conflicted, offer to leave the conflict in the working copy to resolve and |
|
| 108 | 108 | commit, as `merge` does, rather than refusing. |
|
| … | 8 unchanged lines |
modified docs/guide.md +1 −0
| … | 166 unchanged lines |
||
| 167 | 167 | oxy abandon 658990aa # drop a change; its content stays on disk |
|
| 168 | 168 | oxy squash 658990aa --into 9df9ac29 # fold a change into one it depends on |
|
| 169 | 169 | oxy rebase 658990aa --onto feature # re-express a change against another base |
|
| 170 | + | oxy rebase 658990aa --onto develop --into-working-copy # conflicted? resolve it here, then commit |
|
| 170 | 171 | oxy undo # undo the last operation, whatever it was |
|
| 171 | 172 | oxy op log # the operation log |
|
| 172 | 173 | ``` |
|
| … | 334 unchanged lines |
modified docs/spec.md +2 −2
| … | 342 unchanged lines |
||
| 343 | 343 | ||
| 344 | 344 | **Squash.** `oxy squash C --into D` folds change `C` into `D`, where `D` is a dependency of `C` and every atom, placement, and mode that `C` refers to in `D` is one `D` introduces or one both already know. The result `D'` carries `D`'s operations followed by `C`'s, with `C`'s references to `D` turned into self-references, keeps `D`'s ChangeId, and is signed afresh by its author; `C` is removed from the state and every change that depended on `C` or `D` is rewritten with the correspondence of §8.4. A `C` that depends on changes `D` does not depend on cannot be squashed into `D`; the command names them. `oxy amend --into` is the special case where `C` is the working copy. |
|
| 345 | 345 | ||
| 346 | - | **Rebase.** `oxy rebase C --onto HEADS` re-expresses `C` against a different base: the state `HEADS` names is materialized with and without `C` added to it (commutation makes the union meaningful, and `HEADS` must contain `C`'s dependencies), the difference is re-derived as edits against the `HEADS` state, and the new change keeps `C`'s ChangeId with dependencies computed from what it now references (plus `HEADS` as explicit dependencies). This is the move a Git user makes with `rebase --onto`: it changes what a change is said to build on. It is refused when `HEADS` conflicts in any file `C` touches, since the re-derivation would have no single text to diff against, and when the result would be empty. Dependents of `C` are rewritten per §8.4. Because commuting changes already coexist, the common reason to rebase is to drop an accidental dependency, for instance on a change that is to be abandoned. |
|
| 346 | + | **Rebase.** `oxy rebase C --onto HEADS` re-expresses `C` against a different base: the state `HEADS` names is materialized with and without `C` added to it (commutation makes the union meaningful, and `HEADS` must contain `C`'s dependencies), the difference is re-derived as edits against the `HEADS` state, and the new change keeps `C`'s ChangeId with dependencies computed from what it now references (plus `HEADS` as explicit dependencies). This is the move a Git user makes with `rebase --onto`: it changes what a change is said to build on. It is refused when `HEADS` conflicts in any file `C` touches, since the re-derivation would have no single text to diff against, and when the result would be empty. With `--into-working-copy`, a rebase that would be conflicted instead merges the `HEADS` state into the current bookmark, which must hold `C`: a working copy without changes follows, so the conflict is fixed there and committed as a resolution on top, as after `oxy merge` (§10.1). Dependents of `C` are rewritten per §8.4. Because commuting changes already coexist, the common reason to rebase is to drop an accidental dependency, for instance on a change that is to be abandoned. |
|
| 347 | 347 | ||
| 348 | 348 | ## 9. Conflicts |
|
| 349 | 349 | ||
| … | 529 unchanged lines |
||
| 879 | 879 | oxy checkout [CHANGE].. [--discard|--carry] oxy undo oxy op log |
|
| 880 | 880 | oxy tag create NAME [CHANGE].. [-m MSG] oxy tag list |
|
| 881 | 881 | oxy bookmark create|set|delete|list|switch oxy merge BOOKMARK |
|
| 882 | - | oxy commit [PATH].. [--interactive] oxy squash CHANGE --into DEST oxy rebase CHANGE --onto HEADS.. |
|
| 882 | + | oxy commit [PATH].. [--interactive] oxy squash CHANGE --into DEST oxy rebase CHANGE --onto HEADS.. [--into-working-copy] |
|
| 883 | 883 | oxy submodule add|update|status|remove oxy blame PATH oxy bisect start|good|bad|run|reset |
|
| 884 | 884 | oxy workspace add|list|forget oxy sparse [PATH].. oxy deepen [N] |
|
| 885 | 885 | oxy fetch [REMOTE] oxy pull [REMOTE] oxy push [REMOTE] [--force] |
|
| … | 159 unchanged lines |
modified src/cli/mod.rs +10 −1
| … | 120 unchanged lines |
||
| 121 | 121 | /// The changes, bookmark or tag whose state becomes the new base |
|
| 122 | 122 | #[arg(long, required = true, num_args = 1..)] |
|
| 123 | 123 | onto: Vec<String>, |
|
| 124 | + | /// Where the rebase would be conflicted, merge the target into the |
|
| 125 | + | /// current bookmark instead, leaving the conflict in the working |
|
| 126 | + | /// copy to fix and commit |
|
| 127 | + | #[arg(long)] |
|
| 128 | + | into_working_copy: bool, |
|
| 124 | 129 | }, |
|
| 125 | 130 | /// Show which change introduced each line of a file |
|
| 126 | 131 | Blame { |
|
| … | 849 unchanged lines |
||
| 976 | 981 | Cmd::Discard { paths, hunks } => workspace::discard(&paths, &hunks), |
|
| 977 | 982 | Cmd::Hunks { json } => workspace::hunks(json), |
|
| 978 | 983 | Cmd::Squash { change, into } => workspace::squash(&change, &into), |
|
| 979 | - | Cmd::Rebase { change, onto } => workspace::rebase(&change, &onto), |
|
| 984 | + | Cmd::Rebase { |
|
| 985 | + | change, |
|
| 986 | + | onto, |
|
| 987 | + | into_working_copy, |
|
| 988 | + | } => workspace::rebase(&change, &onto, into_working_copy), |
|
| 980 | 989 | Cmd::Blame { path, json } => workspace::blame(&path, json), |
|
| 981 | 990 | Cmd::Bisect { cmd } => match cmd { |
|
| 982 | 991 | BisectCmd::Start => workspace::bisect_start(), |
|
| … | 252 unchanged lines |
modified src/cli/workspace.rs +28 −2
| … | 1871 unchanged lines |
||
| 1872 | 1872 | Ok(0) |
|
| 1873 | 1873 | } |
|
| 1874 | 1874 | ||
| 1875 | - | pub fn rebase(change: &str, onto: &[String]) -> Result<i32> { |
|
| 1875 | + | pub fn rebase(change: &str, onto: &[String], into_working_copy: bool) -> Result<i32> { |
|
| 1876 | 1876 | let repo = open()?; |
|
| 1877 | 1877 | let c = repo.resolve_change(change)?; |
|
| 1878 | 1878 | let mut heads = Vec::new(); |
|
| … | 1 unchanged lines |
||
| 1880 | 1880 | heads.extend(repo.resolve_heads(o)?); |
|
| 1881 | 1881 | } |
|
| 1882 | 1882 | let sp = ui::spinner("Rebasing…"); |
|
| 1883 | - | let h = repo.rebase(&c, &heads)?; |
|
| 1883 | + | let h = match repo.rebase(&c, &heads) { |
|
| 1884 | + | Err(oxy_core::repo::RepoError::Conflicted(_)) if into_working_copy => { |
|
| 1885 | + | let r = repo.rebase_into_working_copy(&c, &heads)?; |
|
| 1886 | + | sp.done(&format!( |
|
| 1887 | + | "{} {} into {}: now {}", |
|
| 1888 | + | ui::done("Merged"), |
|
| 1889 | + | onto.join(" "), |
|
| 1890 | + | ui::bookmark(&repo.config.bookmark), |
|
| 1891 | + | plural(r.heads.len(), "head"), |
|
| 1892 | + | )); |
|
| 1893 | + | if r.checked_out { |
|
| 1894 | + | println!( |
|
| 1895 | + | " {} {}", |
|
| 1896 | + | ui::caution(), |
|
| 1897 | + | ui::warn("the conflict is in the working copy: fix the marked files (see oxy status), then oxy commit") |
|
| 1898 | + | ); |
|
| 1899 | + | } else { |
|
| 1900 | + | println!( |
|
| 1901 | + | " {} {}", |
|
| 1902 | + | ui::caution(), |
|
| 1903 | + | ui::warn("the working copy has changes of its own: commit or discard them, then oxy checkout to take the conflict in") |
|
| 1904 | + | ); |
|
| 1905 | + | } |
|
| 1906 | + | return Ok(0); |
|
| 1907 | + | } |
|
| 1908 | + | other => other?, |
|
| 1909 | + | }; |
|
| 1884 | 1910 | let new = repo.store().get_change(&h)?; |
|
| 1885 | 1911 | sp.done(&format!( |
|
| 1886 | 1912 | "{} {} {} {}", |
|
| … | 10 unchanged lines |