OXYGENHUB

checkout BOOKMARK switches to it; workspace add takes a bookmark and its subrepos a46f3e4f50b1

oxy checkout NAME, NAME a bookmark other than the current one, is
bookmark switch; it used to check NAME's files out under the current
bookmark, and in metalgear three features were committed onto
feature/46-actor-banks from a workspace that said it was on develop.
workspace add PATH BOOKMARK puts the workspace on BOOKMARK. A new
workspace gets each subrepo at its pin by a local clone of this
workspace's copy, its origin set back to the subrepo's URL. Test.

closes #60
closes #61
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 21:06 UTC
Signature
✔ signed by Andy McCall
Hash
a46f3e4f50b1899b5e7b404baf4ae8f4a396d496bb822a900eb922b5b312bd74
Depends on
132b884b9a8d 4e88e774777a 75167672ebcd 7b9809e67c3d 827281eed2ed 8fd78eb52ad2 982864068a59 bea00d4177c6 c07406bcc237 fce44ae9ad29
Depended on by
47ce8b8e2d4b 6a0008781d5e
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.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
11 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +4 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Being on the bookmark you asked for (#60).** `oxy checkout BOOKMARK` switches to that bookmark (as `oxy bookmark switch` does, with `--discard` or `--carry`), where it used to write its files under the current bookmark's name, so the next commit moved the wrong bookmark. `oxy workspace add PATH BOOKMARK` makes a workspace on that bookmark rather than this one's.
6 +
7 +
- **A new workspace has its subrepos (#61).** `oxy workspace add` brings each subrepo at its pin from this workspace's own copy, with no network or sign-in, its remote pointing where the subrepo comes from.
8 +
5 9
## 0.3.0 (2026-10-02)
6 10
7 11
- **Fetch says when a bookmark is behind (#56).** `oxy fetch` reports each bookmark against the remote's copy ("main is 1 change behind: oxy pull to take it", ahead, diverged) and says "up to date" only when they match; `--json` gives `ahead` and `behind`. A change that was fetched but not pulled resolves by id or hash, and `oxy show` says it is only in `origin/main`. With one remote not called `origin`, fetch, pull and push use it, and `bookmark list` and the prompt count against it.
…
70 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +55 −0
…
6938 unchanged lines
6939 6939
    assert_eq!(b.config.remotes["origin"], url);
6940 6940
    b.fetch("origin").unwrap();
6941 6941
}
6942 +
6943 +
/// oxygen#60 and #61: a workspace added on a bookmark is on that bookmark,
6944 +
/// and gets its subrepos at their pins from this workspace's copies, with
6945 +
/// their remotes pointing where the subrepos come from.
6946 +
#[test]
6947 +
fn a_workspace_added_on_a_bookmark_is_on_it_with_its_subrepos() {
6948 +
    hermetic();
6949 +
    let dir = tempfile::tempdir().unwrap();
6950 +
    let root = dir.path();
6951 +
    Repo::init(&root.join("lib-origin")).unwrap();
6952 +
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
6953 +
    let lib = Repo::clone(&lib_url, &root.join("lib"))
6954 +
        .unwrap()
6955 +
        .with_home(root.join("home"));
6956 +
    write(&root.join("lib"), "lib.txt", b"v1\n");
6957 +
    let l1 = lib.commit("lib v1", &[]).unwrap();
6958 +
    assert!(lib.push("origin", false).unwrap()[0].ok);
6959 +
6960 +
    let app_dir = root.join("app");
6961 +
    let app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
6962 +
    write(&app_dir, "main.txt", b"app\n");
6963 +
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
6964 +
    app.commit("app with lib", &[]).unwrap();
6965 +
    // A second bookmark, and the main workspace on another.
6966 +
    app.bookmark_create("develop", None).unwrap();
6967 +
    app.bookmark_create("feature", None).unwrap();
6968 +
    let mut app = app;
6969 +
    app.bookmark_switch("feature", false).unwrap();
6970 +
    assert_eq!(app.config.bookmark, "feature");
6971 +
6972 +
    // The library's origin goes away: the subrepo must come from here.
6973 +
    std::fs::rename(root.join("lib-origin"), root.join("lib-origin-gone")).unwrap();
6974 +
    let heads = app.resolve_heads("develop").unwrap();
6975 +
    let ws = app
6976 +
        .workspace_add_on(&root.join("ws"), Some(&heads), Some("develop"))
6977 +
        .unwrap();
6978 +
    assert_eq!(
6979 +
        ws.config.bookmark, "develop",
6980 +
        "the new workspace is on develop"
6981 +
    );
6982 +
    assert_eq!(
6983 +
        app.config.bookmark, "feature",
6984 +
        "and this one stays on its own"
6985 +
    );
6986 +
    let sub = Repo::open(&root.join("ws/libs/lib")).unwrap();
6987 +
    assert_eq!(
6988 +
        sub.config.remotes["origin"], lib_url,
6989 +
        "its origin is the subrepo's URL"
6990 +
    );
6991 +
    assert_eq!(sub.bookmark_heads().unwrap(), vec![l1], "at its pin");
6992 +
    assert_eq!(
6993 +
        std::fs::read(root.join("ws/libs/lib/lib.txt")).unwrap(),
6994 +
        b"v1\n"
6995 +
    );
6996 +
}
modified crates/oxy-core/src/repo/workspace.rs +67 −1
…
183 unchanged lines
184 184
185 185
    /// Create a workspace at `dir` checked out on `heads` (default the
186 186
    /// bookmark's), and return a `Repo` rooted there.
187 +
    /// `bookmark` is the new workspace's current bookmark (oxygen#60), by
188 +
    /// default this workspace's.
187 189
    pub fn workspace_add(&self, dir: &Path, heads: Option<&[ChangeHash]>) -> Result<Repo> {
190 +
        self.workspace_add_on(dir, heads, None)
191 +
    }
192 +
193 +
    /// [`Repo::workspace_add`] with the new workspace on `bookmark`.
194 +
    pub fn workspace_add_on(
195 +
        &self,
196 +
        dir: &Path,
197 +
        heads: Option<&[ChangeHash]>,
198 +
        bookmark: Option<&str>,
199 +
    ) -> Result<Repo> {
188 200
        if dir.exists() && fs::read_dir(dir)?.next().is_some() {
189 201
            return fail(format!("{} exists and is not empty", dir.display()));
190 202
        }
…
18 unchanged lines
209 221
            view,
210 222
        )?;
211 223
        self.register_workspace(&id, &dir)?;
212 -
        self.set_workspace_bookmark(&id, &self.config.bookmark)?;
224 +
        self.set_workspace_bookmark(&id, bookmark.unwrap_or(&self.config.bookmark))?;
213 225
        write_atomic(
214 226
            &dir.join(OXY_DIR),
215 227
            format!(
…
5 unchanged lines
221 233
        )?;
222 234
        let repo = Repo::open(&dir)?.with_home(self.keys_dir.parent().unwrap_or(&self.keys_dir));
223 235
        repo.checkout(&heads, true, "oxy workspace add (checkout)")?;
236 +
        self.copy_subrepos_into(&repo);
224 237
        Ok(repo)
238 +
    }
239 +
240 +
    /// The new workspace's subrepos at their pins, from this workspace's
241 +
    /// own copies (oxygen#61): a local clone, its remote then pointed at
242 +
    /// the subrepo's URL, and the pin checked out, with no network unless
243 +
    /// this copy lacks the pinned changes. A subrepo this workspace does
244 +
    /// not have stays for `oxy submodule update --init`. Best effort.
245 +
    fn copy_subrepos_into(&self, new: &Repo) {
246 +
        let Ok(subs) = new.subrepos() else { return };
247 +
        let modules = new.modules_on_disk();
248 +
        for s in subs {
249 +
            let (src, dst) = (self.root.join(&s.path), new.root.join(&s.path));
250 +
            if dst.join(OXY_DIR).exists() || dst.join(".git").exists() {
251 +
                continue;
252 +
            }
253 +
            let url = modules.entries.get(&s.path).map(|m| m.url.clone());
254 +
            if let Some(parent) = dst.parent() {
255 +
                let _ = fs::create_dir_all(parent);
256 +
            }
257 +
            let _ = fs::remove_dir(&dst);
258 +
            if src.join(OXY_DIR).exists() {
259 +
                match Repo::clone(&src.to_string_lossy(), &dst) {
260 +
                    Ok(mut sub) => {
261 +
                        if let Some(u) = &url {
262 +
                            if sub.config.set("remote.origin.url", u).is_ok() {
263 +
                                let _ = sub.save_config();
264 +
                            }
265 +
                        }
266 +
                    }
267 +
                    Err(_) => continue,
268 +
                }
269 +
            } else if src.join(".git").exists() {
270 +
                let cloned = std::process::Command::new("git")
271 +
                    .args(["clone", "-q", "--local"])
272 +
                    .arg(&src)
273 +
                    .arg(&dst)
274 +
                    .status()
275 +
                    .is_ok_and(|st| st.success());
276 +
                if !cloned {
277 +
                    continue;
278 +
                }
279 +
                if let Some(u) = &url {
280 +
                    let _ = std::process::Command::new("git")
281 +
                        .arg("-C")
282 +
                        .arg(&dst)
283 +
                        .args(["remote", "set-url", "origin", u])
284 +
                        .status();
285 +
                }
286 +
            } else {
287 +
                continue;
288 +
            }
289 +
            let _ = new.sync_subrepo(&s.path, &s.pin, false);
290 +
        }
225 291
    }
226 292
227 293
    /// Drop a workspace from the view and registry; its files stay.
…
61 unchanged lines
modified src/cli/workspace.rs +37 −0
…
1114 unchanged lines
1115 1115
1116 1116
pub fn checkout(changes: &[String], discard: bool, carry: bool) -> Result<i32> {
1117 1117
    let repo = open()?;
1118 +
    // `oxy checkout BOOKMARK` is being on that bookmark, as `git checkout
1119 +
    // BRANCH` is: checking its files out under another bookmark's name
1120 +
    // made the next commit move the wrong bookmark (oxygen#60).
1121 +
    if let [name] = changes {
1122 +
        if *name != repo.config.bookmark && names_bookmark(&repo, name)? {
1123 +
            drop(repo);
1124 +
            return bookmark_switch(name, discard, carry);
1125 +
        }
1126 +
    }
1118 1127
    let heads = if changes.is_empty() {
1119 1128
        repo.bookmark_heads()?
1120 1129
    } else {
…
788 unchanged lines
1909 1918
        );
1910 1919
    }
1911 1920
    Ok(0)
1921 +
}
1922 +
1923 +
/// Whether `name` is a bookmark, here or only on a remote: what checkout
1924 +
/// and workspace add take as "be on this bookmark" (oxygen#60).
1925 +
fn names_bookmark(repo: &oxy_core::repo::Repo, name: &str) -> Result<bool> {
1926 +
    Ok(repo.view()?.bookmarks.contains_key(name)
1927 +
        || repo
1928 +
            .remote_only_bookmarks()?
1929 +
            .iter()
1930 +
            .any(|b| b.name == name || format!("{}/{}", b.remote, b.name) == name))
1912 1931
}
1913 1932
1914 1933
pub fn workspace_add(path: &std::path::Path, changes: &[String]) -> Result<i32> {
1915 1934
    let repo = open()?;
1935 +
    // One bookmark named: the new workspace is on it (oxygen#60), not on
1936 +
    // this workspace's bookmark with that one's files.
1937 +
    if let [name] = changes {
1938 +
        if names_bookmark(&repo, name)? {
1939 +
            let (local, _) = repo.local_bookmark_for(name)?;
1940 +
            let heads = repo.resolve_heads(&local)?;
1941 +
            let sp = ui::spinner(&format!("Creating workspace at {}…", path.display()));
1942 +
            let ws = repo.workspace_add_on(path, Some(&heads), Some(&local))?;
1943 +
            sp.done(&format!(
1944 +
                "{} {} (workspace {}) on {}",
1945 +
                ui::done("Workspace created at"),
1946 +
                ui::path(ws.root().display()),
1947 +
                ui::id(ws.workspace().short(8)),
1948 +
                ui::bookmark(&local)
1949 +
            ));
1950 +
            return Ok(0);
1951 +
        }
1952 +
    }
1916 1953
    let heads = if changes.is_empty() {
1917 1954
        None
1918 1955
    } else {
…
486 unchanged lines