OXYGENHUB

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, &current)?;
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, &current)?;
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