bookmark switch --carry takes uncommitted work along 61e3bce7ea5b
Switching with uncommitted work was refused, with --discard the only option. --carry takes the work along, as git switch does: when every path it touches (edited, added, removed, renamed) is the same in the target's state as in the working copy's base, the files are carried over as they are and the next snapshot records the same edits on the new bookmark. Otherwise nothing happens and the error names the paths that differ. bookmark create feat && bookmark switch feat --carry moves work started on one bookmark onto a new one. The switch help now says what happens to uncommitted work. closes #15
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 23:10 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
61e3bce7ea5b05a2d52af0d6a40af1f27804bdff4141768c62a9a53ffcd4c93b- Depends on
- 54cd08a1e7bf 75167672ebcd 7b9809e67c3d 86e682c242c6 86fe29fd62ea 982864068a59 c6aa5bd9d1c8 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 44f1ebe7aa5b 51d4862a16d8 e59512f2f075 fe9586d93fb1 72484195899a bf3a67819b59 14edbaaf5291 ff1f62fee654 1a3b62032ed9 6caacfff1d4f 1c2bd9abdd4f
- 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
- 14 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/repo/bookmarks.rs +86 −0
| … | 171 unchanged lines |
||
| 172 | 172 | Ok(report) |
|
| 173 | 173 | } |
|
| 174 | 174 | ||
| 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. |
|
| 181 | + | pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> { |
|
| 182 | + | let view = self.view()?; |
|
| 183 | + | let Some(st) = view.bookmarks.get(name) else { |
|
| 184 | + | return fail(format!("no bookmark {name}")); |
|
| 185 | + | }; |
|
| 186 | + | let heads = self.store.get_state(st)?.heads; |
|
| 187 | + | let snap = self.snapshot(&format!("oxy bookmark switch {name} --carry"))?; |
|
| 188 | + | // Every path the work touches: changed ones, and base files it |
|
| 189 | + | // removed or renamed away (a rename reports only its new path). |
|
| 190 | + | let mut touched: BTreeSet<String> = snap.statuses.iter().map(|s| s.path.clone()).collect(); |
|
| 191 | + | for (p, f) in &snap.base_snapshot.files { |
|
| 192 | + | if f.kind != NodeKind::Dir |
|
| 193 | + | && f.kind != NodeKind::Subrepo |
|
| 194 | + | && fs::symlink_metadata(self.root.join(p)).is_err() |
|
| 195 | + | { |
|
| 196 | + | touched.insert(p.clone()); |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | let target = self.load_state(&heads)?.materialize(); |
|
| 200 | + | let same = |p: &String| { |
|
| 201 | + | let a = snap.base_snapshot.files.get(p); |
|
| 202 | + | let b = target.files.get(p); |
|
| 203 | + | match (a, b) { |
|
| 204 | + | (None, None) => true, |
|
| 205 | + | (Some(a), Some(b)) => { |
|
| 206 | + | a.kind == b.kind && a.mode == b.mode && a.content == b.content |
|
| 207 | + | } |
|
| 208 | + | _ => false, |
|
| 209 | + | } |
|
| 210 | + | }; |
|
| 211 | + | let differ: Vec<&String> = touched.iter().filter(|p| !same(p)).collect(); |
|
| 212 | + | if !differ.is_empty() { |
|
| 213 | + | let list: Vec<&str> = differ.iter().take(10).map(|p| p.as_str()).collect(); |
|
| 214 | + | return fail(format!( |
|
| 215 | + | "{name} has other versions of {} you changed ({}{}); commit first, or switch with --discard", |
|
| 216 | + | if differ.len() == 1 { "a file" } else { "files" }, |
|
| 217 | + | list.join(", "), |
|
| 218 | + | if differ.len() > 10 { ", …" } else { "" } |
|
| 219 | + | )); |
|
| 220 | + | } |
|
| 221 | + | // The work as it is on disk: content and mode, or absent. |
|
| 222 | + | let mut carried: Vec<(String, Carried)> = Vec::new(); |
|
| 223 | + | for p in &touched { |
|
| 224 | + | let full = self.root.join(p); |
|
| 225 | + | let item = match fs::symlink_metadata(&full) { |
|
| 226 | + | Ok(m) if m.file_type().is_symlink() => { |
|
| 227 | + | Carried::Symlink(fs::read_link(&full)?.to_string_lossy().as_bytes().to_vec()) |
|
| 228 | + | } |
|
| 229 | + | Ok(m) if m.is_file() => Carried::File(is_executable(&m), fs::read(&full)?), |
|
| 230 | + | Ok(_) => continue, |
|
| 231 | + | Err(_) => Carried::Absent, |
|
| 232 | + | }; |
|
| 233 | + | carried.push((p.clone(), item)); |
|
| 234 | + | } |
|
| 235 | + | let report = self.checkout(&heads, true, &format!("oxy bookmark switch {name} --carry"))?; |
|
| 236 | + | for (p, item) in &carried { |
|
| 237 | + | match item { |
|
| 238 | + | Carried::Symlink(target) => self.write_symlink(p, target)?, |
|
| 239 | + | Carried::File(exec, bytes) => self.write_file(p, bytes, *exec)?, |
|
| 240 | + | Carried::Absent => { |
|
| 241 | + | let _ = fs::remove_file(self.root.join(p)); |
|
| 242 | + | } |
|
| 243 | + | } |
|
| 244 | + | } |
|
| 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 | + | self.drop_manifest(); |
|
| 249 | + | self.snapshot(&format!("oxy bookmark switch {name} --carry"))?; |
|
| 250 | + | Ok((report, carried.into_iter().map(|(p, _)| p).collect())) |
|
| 251 | + | } |
|
| 252 | + | ||
| 175 | 253 | /// Record `name` as the current bookmark of a new workspace. |
|
| 176 | 254 | pub(crate) fn set_workspace_bookmark(&self, id: &WorkspaceId, name: &str) -> Result<()> { |
|
| 177 | 255 | let dir = self.oxy.join("workspaces").join(id.to_hex()); |
|
| … | 48 unchanged lines |
||
| 226 | 304 | checked_out, |
|
| 227 | 305 | }) |
|
| 228 | 306 | } |
|
| 307 | + | } |
|
| 308 | + | ||
| 309 | + | /// A path's state on disk, taken along by `bookmark switch --carry`. |
|
| 310 | + | enum Carried { |
|
| 311 | + | Symlink(Vec<u8>), |
|
| 312 | + | /// Executable bit and content. |
|
| 313 | + | File(bool, Vec<u8>), |
|
| 314 | + | Absent, |
|
| 229 | 315 | } |
modified crates/oxy-core/src/repo/tests.rs +48 −0
| … | 4986 unchanged lines |
||
| 4987 | 4987 | let heads = repo.resolve_heads("feature/x").unwrap(); |
|
| 4988 | 4988 | assert!(repo.load_state_checkpointed(&heads).unwrap().is_some()); |
|
| 4989 | 4989 | } |
|
| 4990 | + | ||
| 4991 | + | /// `bookmark switch --carry` (spec §10.1): uncommitted work goes along |
|
| 4992 | + | /// when the target has the same versions of the files it touches, and is |
|
| 4993 | + | /// refused, untouched, when it has others. |
|
| 4994 | + | #[test] |
|
| 4995 | + | fn switching_bookmarks_carries_uncommitted_work() { |
|
| 4996 | + | hermetic(); |
|
| 4997 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4998 | + | let root = dir.path(); |
|
| 4999 | + | let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 5000 | + | write(root, "a.txt", b"a\n"); |
|
| 5001 | + | write(root, "b.txt", b"b\n"); |
|
| 5002 | + | write(root, "gone.txt", b"gone\n"); |
|
| 5003 | + | repo.commit("base", &[]).unwrap(); |
|
| 5004 | + | repo.bookmark_create("feature", None).unwrap(); |
|
| 5005 | + | // Started on main, belongs on feature: edit, add, remove. |
|
| 5006 | + | write(root, "a.txt", b"a\nmore\n"); |
|
| 5007 | + | write(root, "new.txt", b"new\n"); |
|
| 5008 | + | fs::remove_file(root.join("gone.txt")).unwrap(); |
|
| 5009 | + | let (_, carried) = repo.bookmark_switch_carry("feature").unwrap(); |
|
| 5010 | + | assert_eq!(carried, vec!["a.txt", "gone.txt", "new.txt"]); |
|
| 5011 | + | assert_eq!(repo.config.bookmark, "feature"); |
|
| 5012 | + | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a\nmore\n"); |
|
| 5013 | + | assert_eq!(fs::read(root.join("new.txt")).unwrap(), b"new\n"); |
|
| 5014 | + | assert!(!root.join("gone.txt").exists()); |
|
| 5015 | + | let snap = repo.snapshot("status").unwrap(); |
|
| 5016 | + | assert_eq!( |
|
| 5017 | + | statuses(&snap), |
|
| 5018 | + | vec![ |
|
| 5019 | + | ("a.txt".to_string(), ChangeStatus::Modified), |
|
| 5020 | + | ("gone.txt".to_string(), ChangeStatus::Removed), |
|
| 5021 | + | ("new.txt".to_string(), ChangeStatus::Added) |
|
| 5022 | + | ] |
|
| 5023 | + | ); |
|
| 5024 | + | repo.commit("the work", &[]).unwrap(); |
|
| 5025 | + | assert!(repo.resolve_heads("main").unwrap().len() == 1); |
|
| 5026 | + | ||
| 5027 | + | // main moves b.txt on; work on feature touching b.txt cannot follow. |
|
| 5028 | + | repo.bookmark_switch("main", false).unwrap(); |
|
| 5029 | + | write(root, "b.txt", b"b on main\n"); |
|
| 5030 | + | repo.commit("b on main", &[]).unwrap(); |
|
| 5031 | + | repo.bookmark_switch("feature", false).unwrap(); |
|
| 5032 | + | write(root, "b.txt", b"b edited\n"); |
|
| 5033 | + | let err = repo.bookmark_switch_carry("main").unwrap_err().to_string(); |
|
| 5034 | + | assert!(err.contains("b.txt") && err.contains("--discard"), "{err}"); |
|
| 5035 | + | assert_eq!(repo.config.bookmark, "feature", "nothing happened"); |
|
| 5036 | + | assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"b edited\n"); |
|
| 5037 | + | } |
modified docs/spec.md +1 −1
| … | 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. `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). `oxy checkout NAME` materializes a bookmark without switching to it. 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 | ||
| … | 580 unchanged lines |
modified src/cli/mod.rs +10 −2
| … | 510 unchanged lines |
||
| 511 | 511 | #[arg(long)] |
|
| 512 | 512 | json: bool, |
|
| 513 | 513 | }, |
|
| 514 | - | /// Make NAME this workspace's bookmark and check it out |
|
| 514 | + | /// Make NAME this workspace's bookmark and check it out. Uncommitted work stops it; --carry takes the work along, --discard drops it |
|
| 515 | 515 | Switch { |
|
| 516 | 516 | /// The bookmark to work on |
|
| 517 | 517 | name: String, |
|
| 518 | 518 | /// Throw away uncommitted working-copy changes |
|
| 519 | 519 | #[arg(long)] |
|
| 520 | 520 | discard: bool, |
|
| 521 | + | /// Take uncommitted changes along; refused when NAME has other |
|
| 522 | + | /// versions of the files they touch |
|
| 523 | + | #[arg(long, conflicts_with = "discard")] |
|
| 524 | + | carry: bool, |
|
| 521 | 525 | }, |
|
| 522 | 526 | } |
|
| 523 | 527 | ||
| … | 395 unchanged lines |
||
| 919 | 923 | BookmarkCmd::Set { name, changes } => workspace::bookmark_set(&name, &changes), |
|
| 920 | 924 | BookmarkCmd::Delete { name } => workspace::bookmark_delete(&name), |
|
| 921 | 925 | BookmarkCmd::List { json } => workspace::bookmark_list(json), |
|
| 922 | - | BookmarkCmd::Switch { name, discard } => workspace::bookmark_switch(&name, discard), |
|
| 926 | + | BookmarkCmd::Switch { |
|
| 927 | + | name, |
|
| 928 | + | discard, |
|
| 929 | + | carry, |
|
| 930 | + | } => workspace::bookmark_switch(&name, discard, carry), |
|
| 923 | 931 | }, |
|
| 924 | 932 | Cmd::Merge { bookmark } => workspace::merge(&bookmark), |
|
| 925 | 933 | Cmd::Submodule { cmd } => match cmd { |
|
| … | 148 unchanged lines |
modified src/cli/workspace.rs +13 −4
| … | 1616 unchanged lines |
||
| 1617 | 1617 | Ok(0) |
|
| 1618 | 1618 | } |
|
| 1619 | 1619 | ||
| 1620 | - | pub fn bookmark_switch(name: &str, discard: bool) -> Result<i32> { |
|
| 1620 | + | pub fn bookmark_switch(name: &str, discard: bool, carry: bool) -> Result<i32> { |
|
| 1621 | 1621 | let mut repo = open()?; |
|
| 1622 | 1622 | let sp = ui::spinner(&format!("Switching to {name}…")); |
|
| 1623 | - | let r = repo.bookmark_switch(name, discard)?; |
|
| 1623 | + | let (r, carried) = if carry { |
|
| 1624 | + | repo.bookmark_switch_carry(name)? |
|
| 1625 | + | } else { |
|
| 1626 | + | (repo.bookmark_switch(name, discard)?, vec![]) |
|
| 1627 | + | }; |
|
| 1624 | 1628 | sp.done(&format!( |
|
| 1625 | - | "{} to {}: {} written, {} removed", |
|
| 1629 | + | "{} to {}: {} written, {} removed{}", |
|
| 1626 | 1630 | ui::done("Switched"), |
|
| 1627 | 1631 | ui::bookmark(name), |
|
| 1628 | 1632 | plural(r.written, "file"), |
|
| 1629 | - | r.removed |
|
| 1633 | + | r.removed, |
|
| 1634 | + | if carried.is_empty() { |
|
| 1635 | + | String::new() |
|
| 1636 | + | } else { |
|
| 1637 | + | format!("; carried {}", plural(carried.len(), "changed path")) |
|
| 1638 | + | } |
|
| 1630 | 1639 | )); |
|
| 1631 | 1640 | if r.conflicted { |
|
| 1632 | 1641 | println!( |
|
| … | 85 unchanged lines |