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 |