OXYGENHUB

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