OXYGENHUB

Merges move clean working copies; checkouts say what blocks them 1c2bd9abdd4f

A working copy without changes now follows a merge whichever state it sat
on (it had also to sit exactly on the bookmark's old heads, which left it
behind after a switch). A refused checkout names each changed path and
how ("removed b.txt" is in the base but not on disk) and offers --carry,
which takes the work along as bookmark switch --carry does; both share
Repo::checkout_carry.

closes #17
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 01:36 UTC
Signature
✔ signed by Andy McCall
Hash
1c2bd9abdd4f7dfc5fd04dc717ea5e15ecde477e71d5d2d55a1032da5835ab4b
Depends on
135212de5370 50a046e439e9 61e3bce7ea5b 75167672ebcd 7b9809e67c3d 86e682c242c6 982864068a59 c6aa5bd9d1c8 fce44ae9ad29 ff0af1ee299d
Depended on by
9bd98cfb4f7e 157d02e95586 733ea16a9701 fe9586d93fb1 72484195899a 2c9ddac3c387 091fef80755d e266b7d4065c d3cd7ef95da6 1a3b62032ed9 6caacfff1d4f e7ac6c2e84e4 6a81dbab471d
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.1.0 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
25 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/oxy-core/src/repo/bookmarks.rs +33 −16
…
172 unchanged lines
173 173
    }
174 174
175 175
    /// Switch to `name` taking uncommitted work along (spec §10.1), as `git
176 -
    /// switch` does: every path the working copy changes must be the same
177 -
    /// in `name`'s state as in the working copy's base, so carrying the
178 -
    /// files over applies exactly the same edits there. Otherwise nothing
179 -
    /// happens and the error names the paths that differ. Returns the
180 -
    /// checkout report and the carried paths.
176 +
    /// switch` does: see [`Repo::checkout_carry`]. Returns the checkout
177 +
    /// report and the carried paths.
181 178
    pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> {
182 179
        let view = self.view()?;
183 180
        let Some(st) = view.bookmarks.get(name) else {
184 181
            return fail(format!("no bookmark {name}"));
185 182
        };
186 183
        let heads = self.store.get_state(st)?.heads;
187 -
        let snap = self.snapshot(&format!("oxy bookmark switch {name} --carry"))?;
184 +
        let out =
185 +
            self.checkout_carry(&heads, name, &format!("oxy bookmark switch {name} --carry"))?;
186 +
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
187 +
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
188 +
        self.config.bookmark = name.to_string();
189 +
        self.snapshot(&format!("oxy bookmark switch {name} --carry"))?;
190 +
        Ok(out)
191 +
    }
192 +
193 +
    /// Check out `heads` taking uncommitted work along: every path the
194 +
    /// working copy changes must be the same in the target state as in the
195 +
    /// working copy's base, so carrying the files over applies exactly the
196 +
    /// same edits there. Otherwise nothing happens and the error names the
197 +
    /// paths that differ (`target` names the state in it). Returns the
198 +
    /// checkout report and the carried paths.
199 +
    pub fn checkout_carry(
200 +
        &self,
201 +
        heads: &[ChangeHash],
202 +
        target: &str,
203 +
        command: &str,
204 +
    ) -> Result<(CheckoutReport, Vec<String>)> {
205 +
        let snap = self.snapshot(command)?;
188 206
        // Every path the work touches: changed ones, and base files it
189 207
        // removed or renamed away (a rename reports only its new path).
190 208
        let mut touched: BTreeSet<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
…
5 unchanged lines
196 214
                touched.insert(p.clone());
197 215
            }
198 216
        }
199 -
        let target = self.load_state(&heads)?.materialize();
217 +
        let target_state = self.load_state(heads)?.materialize();
200 218
        let same = |p: &String| {
201 219
            let a = snap.base_snapshot.files.get(p);
202 -
            let b = target.files.get(p);
220 +
            let b = target_state.files.get(p);
203 221
            match (a, b) {
204 222
                (None, None) => true,
205 223
                (Some(a), Some(b)) => {
…
6 unchanged lines
212 230
        if !differ.is_empty() {
213 231
            let list: Vec<&str> = differ.iter().take(10).map(|p| p.as_str()).collect();
214 232
            return fail(format!(
215 -
                "{name} has other versions of {} you changed ({}{}); commit first, or switch with --discard",
233 +
                "{target} has other versions of {} you changed ({}{}); commit first, or drop the changes with --discard",
216 234
                if differ.len() == 1 { "a file" } else { "files" },
217 235
                list.join(", "),
218 236
                if differ.len() > 10 { ", …" } else { "" }
…
13 unchanged lines
232 250
            };
233 251
            carried.push((p.clone(), item));
234 252
        }
235 -
        let report = self.checkout(&heads, true, &format!("oxy bookmark switch {name} --carry"))?;
253 +
        let report = self.checkout(heads, true, command)?;
236 254
        for (p, item) in &carried {
237 255
            match item {
238 256
                Carried::Symlink(target) => self.write_symlink(p, target)?,
…
3 unchanged lines
242 260
                }
243 261
            }
244 262
        }
245 -
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
246 -
        write_atomic(&self.workspace_bookmark_path(), name.as_bytes())?;
247 -
        self.config.bookmark = name.to_string();
248 263
        self.drop_manifest();
249 -
        self.snapshot(&format!("oxy bookmark switch {name} --carry"))?;
264 +
        self.snapshot(command)?;
250 265
        Ok((report, carried.into_iter().map(|(p, _)| p).collect()))
251 266
    }
252 267
…
5 unchanged lines
258 273
    }
259 274
260 275
    /// Union the heads of bookmark `name` into the current bookmark (spec
261 -
    /// §10.1). The working copy follows when it was clean on the old heads.
276 +
    /// §10.1). A working copy without changes follows.
262 277
    pub fn merge(&self, name: &str) -> Result<MergeReport> {
263 278
        let (op_ref, op) = self.head_op()?;
264 279
        let current = self.config.bookmark.clone();
…
29 unchanged lines
294 309
        let mut base = snap.base.maximal(&snap.wc.deps);
295 310
        base.sort();
296 311
        let mut checked_out = false;
297 -
        if clean && base == sorted_local {
312 +
        // A working copy without changes follows the merge, whichever state
313 +
        // it sat on: nothing of the user's can be lost.
314 +
        if clean {
298 315
            self.checkout(&heads, false, "oxy merge (checkout)")?;
299 316
            checked_out = true;
300 317
        }
…
15 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +25 −1
…
22 unchanged lines
23 23
    ) -> Result<CheckoutReport> {
24 24
        let snap = self.snapshot(command)?;
25 25
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
26 -
            return fail("the working copy has changes; commit them first or pass --discard");
26 +
            let list: Vec<String> = snap
27 +
                .statuses
28 +
                .iter()
29 +
                .take(8)
30 +
                .map(|s| format!("{} {}", status_word(s.status), s.path))
31 +
                .collect();
32 +
            return fail(format!(
33 +
                "the working copy has changes ({}{}); commit them, take them along with --carry, or drop them with --discard",
34 +
                list.join(", "),
35 +
                if snap.statuses.len() > 8 { ", …" } else { "" }
36 +
            ));
27 37
        }
28 38
        let target_state = self.load_state(heads)?;
29 39
        let heads = target_state.maximal(heads);
…
211 unchanged lines
241 251
            fs::write(&full, target)?;
242 252
            Ok(())
243 253
        }
254 +
    }
255 +
}
256 +
257 +
/// How a refusal names a path's change: "removed" is a file of the working
258 +
/// copy's base that is missing on disk.
259 +
fn status_word(s: ChangeStatus) -> &'static str {
260 +
    match s {
261 +
        ChangeStatus::Added => "added",
262 +
        ChangeStatus::Modified => "modified",
263 +
        ChangeStatus::Removed => "removed",
264 +
        ChangeStatus::Renamed => "renamed",
265 +
        ChangeStatus::ModeChanged => "mode changed",
266 +
        ChangeStatus::ConflictEdited => "conflict edited",
267 +
        ChangeStatus::Resolved => "resolved",
244 268
    }
245 269
}
modified crates/oxy-core/src/repo/tests.rs +57 −0
…
5034 unchanged lines
5035 5035
    assert_eq!(repo.config.bookmark, "feature", "nothing happened");
5036 5036
    assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"b edited\n");
5037 5037
}
5038 +
5039 +
/// Merging and checking out (feedback 3): a working copy without changes
5040 +
/// follows a merge whichever state it sat on; a refused checkout names
5041 +
/// what is in the way; `checkout --carry` takes the work along.
5042 +
#[test]
5043 +
fn merges_move_clean_working_copies_and_checkouts_say_what_blocks_them() {
5044 +
    hermetic();
5045 +
    let dir = tempfile::tempdir().unwrap();
5046 +
    let root = dir.path();
5047 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
5048 +
    write(root, "a.txt", b"a\n");
5049 +
    let h1 = repo.commit("a", &[]).unwrap();
5050 +
    write(root, "b.txt", b"b\n");
5051 +
    repo.commit("b", &[]).unwrap();
5052 +
    repo.bookmark_create("feature", None).unwrap();
5053 +
    repo.bookmark_switch("feature", false).unwrap();
5054 +
    write(root, "c.txt", b"c\n");
5055 +
    repo.commit("c on feature", &[]).unwrap();
5056 +
    repo.bookmark_switch("main", false).unwrap();
5057 +
    // The working copy sits on an older state than main's heads.
5058 +
    repo.checkout(&[h1], false, "checkout").unwrap();
5059 +
    assert!(!root.join("b.txt").exists());
5060 +
    let m = repo.merge("feature").unwrap();
5061 +
    assert!(m.checked_out, "a clean working copy follows the merge");
5062 +
    for f in ["a.txt", "b.txt", "c.txt"] {
5063 +
        assert!(root.join(f).exists(), "{f}");
5064 +
    }
5065 +
5066 +
    // A refusal says which paths are in the way and what to do.
5067 +
    fs::remove_file(root.join("b.txt")).unwrap();
5068 +
    write(root, "a.txt", b"a edited\n");
5069 +
    let err = repo
5070 +
        .checkout(&[h1], false, "checkout")
5071 +
        .unwrap_err()
5072 +
        .to_string();
5073 +
    assert!(
5074 +
        err.contains("modified a.txt") && err.contains("removed b.txt") && err.contains("--carry"),
5075 +
        "{err}"
5076 +
    );
5077 +
    // --carry takes them along: h1 has a.txt as the base has it, and no b.txt
5078 +
    // is carried as absent (h1 has none either, so that is refused).
5079 +
    let err = repo
5080 +
        .checkout_carry(&[h1], "the first change", "checkout")
5081 +
        .unwrap_err()
5082 +
        .to_string();
5083 +
    assert!(
5084 +
        err.contains("b.txt") && err.contains("the first change"),
5085 +
        "{err}"
5086 +
    );
5087 +
    write(root, "b.txt", b"b\n");
5088 +
    let (_, carried) = repo
5089 +
        .checkout_carry(&[h1], "the first change", "checkout")
5090 +
        .unwrap();
5091 +
    assert_eq!(carried, vec!["a.txt"]);
5092 +
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a edited\n");
5093 +
    assert!(!root.join("b.txt").exists(), "h1 has no b.txt");
5094 +
}
modified docs/spec.md +2 −2
…
422 unchanged lines
423 423
State { version [0]: 1, heads [1]: [ChangeHash] }   // sorted, unique; may be empty
424 424
```
425 425
426 -
A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. Switching refuses uncommitted work unless told what to do with it: `--discard` drops it, and `--carry` takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. `oxy bookmark create feat && oxy bookmark switch feat --carry` moves work started on one bookmark onto a new one. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads), `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9). `oxy checkout NAME` materializes a bookmark without switching to it. Bookmark names are resolved before change prefixes wherever a change is expected.
426 +
A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. Switching refuses uncommitted work unless told what to do with it: `--discard` drops it, and `--carry` takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. `oxy bookmark create feat && oxy bookmark switch feat --carry` moves work started on one bookmark onto a new one. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads), `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9); a working copy without changes follows the merge, whichever state it sat on. `oxy checkout NAME` materializes a bookmark without switching to it; with uncommitted work it refuses, naming each changed path and how it changed (a *removed* path is in the working copy's base but not on disk), unless `--discard` or `--carry` (as for switching) says what to do with the work. Bookmark names are resolved before change prefixes wherever a change is expected.
427 427
428 428
Every reference, attestation, view, and checkpoint therefore names a state by one ObjectRef however many heads it has. Multiple heads are normal in this model: every independent change is a head until something depends on it, so a busy bookmark may have dozens. That is not a conflict in the §9 sense; `oxy log` shows the heads as parallel, and a state object keeps the cost of naming them constant for everything that refers to the bookmark. The empty state, `State { heads: [] }`, is what a new bookmark points at.
429 429
…
413 unchanged lines
843 843
oxy log [-n N] [-v]                 oxy show CHANGE
844 844
oxy describe [CHANGE] -m MSG        oxy amend [-m MSG] [--into CHANGE]
845 845
oxy split CHANGE [PATH]..           oxy abandon CHANGE           oxy resolve
846 -
oxy checkout [CHANGE].. [--discard] oxy undo                     oxy op log
846 +
oxy checkout [CHANGE].. [--discard|--carry] oxy undo                     oxy op log
847 847
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
848 848
oxy bookmark create|set|delete|list|switch  oxy merge BOOKMARK
849 849
oxy commit [PATH].. [--interactive]         oxy squash CHANGE --into DEST   oxy rebase CHANGE --onto HEADS..
…
160 unchanged lines
modified src/cli/mod.rs +9 −1
…
231 unchanged lines
232 232
        /// Throw away uncommitted working-copy changes
233 233
        #[arg(long)]
234 234
        discard: bool,
235 +
        /// Take uncommitted changes along; refused when the target has
236 +
        /// other versions of the files they touch
237 +
        #[arg(long, conflicts_with = "discard")]
238 +
        carry: bool,
235 239
    },
236 240
    /// Report which conflicts of the working copy's base are resolved by the current files
237 241
    Resolve {
…
654 unchanged lines
892 896
        Cmd::Split { change, paths } => workspace::split(&change, &paths),
893 897
        Cmd::Abandon { change } => workspace::abandon(&change),
894 898
        Cmd::Show { change, json } => workspace::show(&change, json),
895 -
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
899 +
        Cmd::Checkout {
900 +
            changes,
901 +
            discard,
902 +
            carry,
903 +
        } => workspace::checkout(&changes, discard, carry),
896 904
        Cmd::Resolve { json } => workspace::resolve(json),
897 905
        Cmd::Undo => workspace::undo(),
898 906
        Cmd::Gc { now } => workspace::gc(now),
…
183 unchanged lines
modified src/cli/workspace.rs +14 −4
…
840 unchanged lines
841 841
    Ok(0)
842 842
}
843 843
844 -
pub fn checkout(changes: &[String], discard: bool) -> Result<i32> {
844 +
pub fn checkout(changes: &[String], discard: bool, carry: bool) -> Result<i32> {
845 845
    let repo = open()?;
846 846
    let heads = if changes.is_empty() {
847 847
        repo.bookmark_heads()?
…
10 unchanged lines
858 858
        changes.join(" ")
859 859
    };
860 860
    let sp = ui::spinner(&format!("Checking out {spec}…"));
861 -
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
861 +
    let command = format!("oxy checkout {spec}");
862 +
    let (r, carried) = if carry {
863 +
        repo.checkout_carry(&heads, &spec, &command)?
864 +
    } else {
865 +
        (repo.checkout(&heads, discard, &command)?, vec![])
866 +
    };
862 867
    sp.done(&format!(
863 -
        "{} on {}: {} written, {} removed",
868 +
        "{} on {}: {} written, {} removed{}",
864 869
        ui::done("Working copy"),
865 870
        ui::bookmark(&spec),
866 871
        plural(r.written, "file"),
867 -
        r.removed
872 +
        r.removed,
873 +
        if carried.is_empty() {
874 +
            String::new()
875 +
        } else {
876 +
            format!("; carried {}", plural(carried.len(), "changed path"))
877 +
        }
868 878
    ));
869 879
    for n in &r.notes {
870 880
        println!("  {} {n}", ui::note());
…
856 unchanged lines