OXYGENHUB

Multiple workspaces: oxy workspace add|list|forget, secondary .oxy file, per-workspace snapshot lock (spec 0.6i) 4e88e774777a

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 22:44 UTC
Signature
✔ signed by Andy McCall
Hash
4e88e774777a64094484bd051a12ad247db4ab1584584e82aee18c55ecb34862
Depends on
132b884b9a8d 614541c3cebd 7421309f5372 982864068a59 fce44ae9ad29 ff0af1ee299d
Depended on by
9aec107d1071 544d0541a7e5 4476cdbb0443 767ac6cd0545 d069701a4997 a46f3e4f50b1 f9466021286a 6caacfff1d4f cc7a00565161 d42369abf180 19555025a2ac 75167672ebcd e02bf0cd0533 1196b29b5fa9 957fee30826e 4dee277c327a 7b9809e67c3d 8f10a07a1dbe 94084087a876 cdb5beba7f05
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
15 atoms, 1 tree operation, 7 files

Browse the tree at this change

added crates/oxy-core/src/repo/workspace.rs +157 −0
1 +
//! Multiple workspaces (spec §12.2): extra checkout directories sharing one
2 +
//! `.oxy`. A secondary workspace holds a `.oxy` *file* of the form
3 +
//! `oxydir: <path>\nworkspace: <id>`; the repository lists workspaces under
4 +
//! `.oxy/workspaces/<id>/path`. Each workspace has its own working-copy
5 +
//! change, manifest, and short-lived snapshot lock.
6 +
7 +
use super::*;
8 +
9 +
#[derive(Debug, Clone, PartialEq, Eq)]
10 +
pub struct WorkspaceInfo {
11 +
    pub id: WorkspaceId,
12 +
    pub path: PathBuf,
13 +
    /// Working-copy change hash from the current view, if the workspace is registered there.
14 +
    pub working_copy: Option<ChangeHash>,
15 +
    pub current: bool,
16 +
}
17 +
18 +
/// Held while a workspace is snapshotted; a stale lock (older than a
19 +
/// minute, its process gone) is taken over.
20 +
pub struct SnapshotLock {
21 +
    path: PathBuf,
22 +
}
23 +
24 +
impl Drop for SnapshotLock {
25 +
    fn drop(&mut self) {
26 +
        let _ = fs::remove_file(&self.path);
27 +
    }
28 +
}
29 +
30 +
/// Parse a secondary workspace's `.oxy` file.
31 +
pub(crate) fn parse_workspace_file(text: &str) -> Option<(PathBuf, WorkspaceId)> {
32 +
    let mut dir = None;
33 +
    let mut id = None;
34 +
    for line in text.lines() {
35 +
        if let Some(v) = line.strip_prefix("oxydir:") {
36 +
            dir = Some(PathBuf::from(v.trim()));
37 +
        } else if let Some(v) = line.strip_prefix("workspace:") {
38 +
            id = WorkspaceId::from_hex(v.trim());
39 +
        }
40 +
    }
41 +
    Some((dir?, id?))
42 +
}
43 +
44 +
impl Repo {
45 +
    fn workspace_dir(&self, id: &WorkspaceId) -> PathBuf {
46 +
        self.oxy.join("workspaces").join(id.to_hex())
47 +
    }
48 +
49 +
    /// Record where a workspace lives.
50 +
    pub(crate) fn register_workspace(&self, id: &WorkspaceId, path: &Path) -> Result<()> {
51 +
        let dir = self.workspace_dir(id);
52 +
        fs::create_dir_all(&dir)?;
53 +
        write_atomic(&dir.join("path"), path.to_string_lossy().as_bytes())
54 +
    }
55 +
56 +
    /// The directory of a workspace, if registered.
57 +
    pub fn workspace_path(&self, id: &WorkspaceId) -> Option<PathBuf> {
58 +
        fs::read_to_string(self.workspace_dir(id).join("path")).ok().map(|s| PathBuf::from(s.trim()))
59 +
    }
60 +
61 +
    /// Every workspace this repository knows about.
62 +
    pub fn workspaces(&self) -> Result<Vec<WorkspaceInfo>> {
63 +
        let view = self.view()?;
64 +
        let mut ids: BTreeSet<WorkspaceId> = view.workspaces.keys().copied().collect();
65 +
        if let Ok(rd) = fs::read_dir(self.oxy.join("workspaces")) {
66 +
            for e in rd.flatten() {
67 +
                if let Some(id) = WorkspaceId::from_hex(&e.file_name().to_string_lossy()) {
68 +
                    if e.path().join("path").exists() {
69 +
                        ids.insert(id);
70 +
                    }
71 +
                }
72 +
            }
73 +
        }
74 +
        let mut out = Vec::new();
75 +
        for id in ids {
76 +
            let path = if id == self.workspace {
77 +
                self.root.clone()
78 +
            } else {
79 +
                self.workspace_path(&id).unwrap_or_else(|| PathBuf::from("(unknown)"))
80 +
            };
81 +
            out.push(WorkspaceInfo { id, path, working_copy: view.workspaces.get(&id).copied(), current: id == self.workspace });
82 +
        }
83 +
        Ok(out)
84 +
    }
85 +
86 +
    /// Create a workspace at `dir` checked out on `heads` (default the
87 +
    /// bookmark's), and return a `Repo` rooted there.
88 +
    pub fn workspace_add(&self, dir: &Path, heads: Option<&[ChangeHash]>) -> Result<Repo> {
89 +
        if dir.exists() && fs::read_dir(dir)?.next().is_some() {
90 +
            return fail(format!("{} exists and is not empty", dir.display()));
91 +
        }
92 +
        fs::create_dir_all(dir)?;
93 +
        let dir = dir.canonicalize()?;
94 +
        if dir == self.root || dir.starts_with(&self.root) || self.root.starts_with(&dir) {
95 +
            return fail("a workspace cannot be inside another workspace of the same repository");
96 +
        }
97 +
        let heads = match heads {
98 +
            Some(h) => h.to_vec(),
99 +
            None => self.bookmark_heads()?,
100 +
        };
101 +
        let id = WorkspaceId::random();
102 +
        let wc = self.new_working_copy(heads.clone(), "");
103 +
        let wc_hash = self.store.put_change(&wc)?;
104 +
        let (op_ref, op) = self.head_op()?;
105 +
        let mut view = op.view.clone();
106 +
        view.workspaces.insert(id, wc_hash);
107 +
        self.record_op(&[op_ref], &format!("oxy workspace add {}", dir.display()), view)?;
108 +
        self.register_workspace(&id, &dir)?;
109 +
        write_atomic(&dir.join(OXY_DIR), format!("oxydir: {}\nworkspace: {}\n", self.oxy.display(), id.to_hex()).as_bytes())?;
110 +
        let repo = Repo::open(&dir)?.with_home(self.keys_dir.parent().unwrap_or(&self.keys_dir));
111 +
        repo.checkout(&heads, true, "oxy workspace add (checkout)")?;
112 +
        Ok(repo)
113 +
    }
114 +
115 +
    /// Drop a workspace from the view and registry; its files stay.
116 +
    pub fn workspace_forget(&self, id: &WorkspaceId) -> Result<()> {
117 +
        if *id == self.workspace {
118 +
            return fail("cannot forget the workspace you are in");
119 +
        }
120 +
        let (op_ref, op) = self.head_op()?;
121 +
        let mut view = op.view.clone();
122 +
        if view.workspaces.remove(id).is_none() && self.workspace_path(id).is_none() {
123 +
            return fail(format!("no workspace {}", id.short(8)));
124 +
        }
125 +
        self.record_op(&[op_ref], &format!("oxy workspace forget {}", id.short(8)), view)?;
126 +
        let _ = fs::remove_dir_all(self.workspace_dir(id));
127 +
        Ok(())
128 +
    }
129 +
130 +
    /// Take this workspace's snapshot lock (spec §12.1).
131 +
    pub(crate) fn lock_snapshot(&self) -> Result<SnapshotLock> {
132 +
        let dir = self.workspace_dir(&self.workspace);
133 +
        fs::create_dir_all(&dir)?;
134 +
        let path = dir.join("lock");
135 +
        for _ in 0..50 {
136 +
            match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
137 +
                Ok(mut f) => {
138 +
                    let _ = write!(f, "{}", std::process::id());
139 +
                    return Ok(SnapshotLock { path });
140 +
                }
141 +
                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
142 +
                    let stale = fs::metadata(&path)
143 +
                        .and_then(|m| m.modified())
144 +
                        .map(|t| t.elapsed().map(|d| d.as_secs() > 60).unwrap_or(false))
145 +
                        .unwrap_or(true);
146 +
                    if stale {
147 +
                        let _ = fs::remove_file(&path);
148 +
                        continue;
149 +
                    }
150 +
                    std::thread::sleep(std::time::Duration::from_millis(100));
151 +
                }
152 +
                Err(e) => return Err(e.into()),
153 +
            }
154 +
        }
155 +
        fail("another oxy process is snapshotting this workspace")
156 +
    }
157 +
}
modified crates/oxy-core/src/repo/mod.rs +17 −0
…
62 unchanged lines
63 63
mod snapshot;
64 64
mod sync;
65 65
mod tags;
66 +
mod workspace;
66 67
#[cfg(test)]
67 68
mod tests;
68 69
…
6 unchanged lines
75 76
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult};
76 77
pub use sync::{FetchReport, PullReport};
77 78
pub use tags::TagInfo;
79 +
pub use workspace::{SnapshotLock, WorkspaceInfo};
78 80
79 81
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
80 82
    let tmp = path.with_extension("tmp");
…
89 unchanged lines
170 172
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
171 173
        let store = Store::open(oxy.join("objects"))?;
172 174
        let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
175 +
        repo.register_workspace(&workspace, &repo.root)?;
173 176
174 177
        let wc = repo.new_working_copy(vec![], "");
175 178
        let wc_hash = repo.store.put_change(&wc)?;
…
10 unchanged lines
186 189
        let mut dir: Option<&Path> = Some(&start);
187 190
        while let Some(d) = dir {
188 191
            let oxy = d.join(OXY_DIR);
192 +
            if oxy.is_file() {
193 +
                // A secondary workspace (spec §12.2): the file points at the repository.
194 +
                let text = fs::read_to_string(&oxy)?;
195 +
                let Some((shared, workspace)) = workspace::parse_workspace_file(&text) else {
196 +
                    return fail(format!("{} is not a valid workspace file", oxy.display()));
197 +
                };
198 +
                let format = fs::read_to_string(shared.join("format"))?;
199 +
                if format != FORMAT_LINE {
200 +
                    return fail(format!("unsupported repository format: {}", format.trim()));
201 +
                }
202 +
                let config = Config::load(Some(&default_user_config()), &shared.join("config"))?;
203 +
                let store = Store::open(shared.join("objects"))?;
204 +
                return Ok(Repo { root: d.to_path_buf(), oxy: shared, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config });
205 +
            }
189 206
            if oxy.is_dir() {
190 207
                let format = fs::read_to_string(oxy.join("format"))?;
191 208
                let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
…
423 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +1 −0
…
107 unchanged lines
108 108
109 109
    /// Snapshot the working copy into the working-copy change (spec §12.3).
110 110
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
111 +
        let _lock = self.lock_snapshot()?;
111 112
        let (op_ref, op) = self.head_op()?;
112 113
        let (old_hash, old_wc) = self.working_copy()?;
113 114
        let base = self.load_state(&old_wc.deps)?;
…
487 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +61 −0
…
1376 unchanged lines
1377 1377
    assert!(a.gc(true).is_ok());
1378 1378
    assert_eq!(a.tags().unwrap().len(), 3, "tags survive gc");
1379 1379
}
1380 +
1381 +
#[test]
1382 +
fn multiple_workspaces_share_a_repository() {
1383 +
    hermetic();
1384 +
    let dir = tempfile::tempdir().unwrap();
1385 +
    let root = dir.path();
1386 +
    let main_dir = root.join("main");
1387 +
    let repo = Repo::init(&main_dir).unwrap().with_home(root.join("home"));
1388 +
    write(&main_dir, "a.txt", b"a\n");
1389 +
    let h1 = repo.commit("a", &[]).unwrap();
1390 +
    assert_eq!(repo.workspaces().unwrap().len(), 1);
1391 +
1392 +
    // A second workspace elsewhere, checked out on the bookmark.
1393 +
    let ws_dir = root.join("ws2");
1394 +
    let ws = repo.workspace_add(&ws_dir, None).unwrap();
1395 +
    assert!(ws_dir.join(".oxy").is_file());
1396 +
    assert_eq!(fs::read(ws_dir.join("a.txt")).unwrap(), b"a\n");
1397 +
    assert_ne!(ws.workspace(), repo.workspace());
1398 +
    let list = repo.workspaces().unwrap();
1399 +
    assert_eq!(list.len(), 2);
1400 +
    assert!(list.iter().any(|w| w.id == ws.workspace() && w.path == ws_dir.canonicalize().unwrap() && !w.current));
1401 +
    assert!(!ws.snapshot("status").unwrap().changed);
1402 +
1403 +
    // Work in the second workspace: its own working copy, shared bookmark.
1404 +
    write(&ws_dir, "b.txt", b"b\n");
1405 +
    let s = ws.snapshot("status").unwrap();
1406 +
    assert_eq!(s.statuses.len(), 1);
1407 +
    assert!(!repo.snapshot("status").unwrap().changed, "the first workspace is untouched");
1408 +
    let h2 = ws.commit("b from ws2", &[]).unwrap();
1409 +
    let mut heads = repo.bookmark_heads().unwrap();
1410 +
    heads.sort();
1411 +
    let mut want = vec![h1, h2];
1412 +
    want.sort();
1413 +
    assert_eq!(heads, want, "both workspaces see the bookmark move");
1414 +
    assert!(!main_dir.join("b.txt").exists());
1415 +
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "co").unwrap();
1416 +
    assert!(main_dir.join("b.txt").exists());
1417 +
1418 +
    // Reopening from inside the second workspace finds the shared repository.
1419 +
    let reopened = Repo::open(&ws_dir).unwrap();
1420 +
    assert_eq!(reopened.workspace(), ws.workspace());
1421 +
    assert_eq!(reopened.id().unwrap(), repo.id().unwrap());
1422 +
    assert_eq!(reopened.quick_status().unwrap().heads, 2);
1423 +
1424 +
    // Workspaces cannot nest; forget drops one from the view but keeps files.
1425 +
    assert!(repo.workspace_add(&main_dir.join("inner"), None).is_err());
1426 +
    assert!(repo.workspace_forget(&repo.workspace()).is_err());
1427 +
    repo.workspace_forget(&ws.workspace()).unwrap();
1428 +
    assert_eq!(repo.workspaces().unwrap().len(), 1);
1429 +
    assert!(ws_dir.join("b.txt").exists());
1430 +
    assert!(ws.snapshot("status").is_err(), "a forgotten workspace is no longer in the view");
1431 +
1432 +
    // The snapshot lock: a fresh lock blocks a second, a stale one is taken over.
1433 +
    let lock = repo.lock_snapshot().unwrap();
1434 +
    drop(lock);
1435 +
    let lock_path = main_dir.join(".oxy/workspaces").join(repo.workspace().to_hex()).join("lock");
1436 +
    fs::write(&lock_path, "1").unwrap();
1437 +
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
1438 +
    fs::File::open(&lock_path).unwrap().set_modified(old).unwrap();
1439 +
    assert!(repo.lock_snapshot().is_ok(), "stale lock taken over");
1440 +
}
modified docs/spec.md +5 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6h |
5 +
| **Status** | Draft 0.6i |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
443 unchanged lines
452 452
453 453
### 12.2 Workspaces
454 454
455 -
A repository has one or more workspaces, each with a `WorkspaceId` (128-bit random) and a checkout directory. Each workspace has exactly one **working-copy change** at any time.
455 +
A repository has one or more workspaces, each with a `WorkspaceId` (128-bit random) and a checkout directory. Each workspace has exactly one **working-copy change** at any time. The primary workspace is the directory holding `.oxy/`; a secondary workspace holds a `.oxy` file instead, `oxydir: <path>` and `workspace: <id>`, pointing at the shared repository, and the repository records each workspace's directory under `.oxy/workspaces/<id>/`. `oxy workspace add PATH [CHANGE..]` creates one, `list` shows them, and `forget` drops one from the view without touching its files. Workspaces do not nest.
456 456
457 457
### 12.3 The working-copy change
458 458
…
383 unchanged lines
842 842
843 843
---
844 844
845 -
*Changelog.* Draft 0.6h (2026-09-27): tags in views, on the wire, and under policy (§5.5, §10.2, §16.3).
845 +
*Changelog.* Draft 0.6i (2026-09-27): secondary workspaces and their `.oxy` file (§12.2).
846 +
847 +
Draft 0.6h (2026-09-27): tags in views, on the wire, and under policy (§5.5, §10.2, §16.3).
846 848
847 849
Draft 0.6g (2026-09-27): shallow repositories as checkpoint plus history window, the `checkpoint` and `shallow` messages, deepening (§16.3, §17).
848 850
…
29 unchanged lines
modified src/cli/mod.rs +19 −0
…
107 unchanged lines
108 108
        #[command(subcommand)]
109 109
        cmd: OpCmd,
110 110
    },
111 +
    /// Workspaces: extra checkout directories sharing this repository
112 +
    Workspace {
113 +
        #[command(subcommand)]
114 +
        cmd: WorkspaceCmd,
115 +
    },
111 116
    /// Tags: immutable named states
112 117
    Tag {
113 118
        #[command(subcommand)]
…
65 unchanged lines
179 184
}
180 185
181 186
#[derive(Subcommand)]
187 +
enum WorkspaceCmd {
188 +
    /// Create a workspace at PATH checked out on CHANGE... (default: the bookmark heads)
189 +
    Add { path: PathBuf, changes: Vec<String> },
190 +
    List,
191 +
    /// Stop tracking a workspace (by id prefix or path); its files stay
192 +
    Forget { workspace: String },
193 +
}
194 +
195 +
#[derive(Subcommand)]
182 196
enum TagCmd {
183 197
    /// Tag the state of CHANGE... (default: the bookmark heads)
184 198
    Create {
…
89 unchanged lines
274 288
        Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
275 289
        Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth),
276 290
        Cmd::Sparse { paths } => workspace::sparse(&paths),
291 +
        Cmd::Workspace { cmd } => match cmd {
292 +
            WorkspaceCmd::Add { path, changes } => workspace::workspace_add(&path, &changes),
293 +
            WorkspaceCmd::List => workspace::workspace_list(),
294 +
            WorkspaceCmd::Forget { workspace } => workspace::workspace_forget(&workspace),
295 +
        },
277 296
        Cmd::Tag { cmd } => match cmd {
278 297
            TagCmd::Create { name, changes, message } => workspace::tag_create(&name, &changes, &message),
279 298
            TagCmd::List => workspace::tag_list(),
…
27 unchanged lines
modified src/cli/workspace.rs +46 −0
…
347 unchanged lines
348 348
    }
349 349
    Ok(0)
350 350
}
351 +
352 +
pub fn workspace_add(path: &std::path::Path, changes: &[String]) -> Result<i32> {
353 +
    let repo = open()?;
354 +
    let heads = if changes.is_empty() {
355 +
        None
356 +
    } else {
357 +
        let mut heads = Vec::new();
358 +
        for c in changes {
359 +
            heads.extend(repo.resolve_heads(c)?);
360 +
        }
361 +
        Some(heads)
362 +
    };
363 +
    let sp = ui::spinner(&format!("Creating workspace at {}…", path.display()));
364 +
    let ws = repo.workspace_add(path, heads.as_deref())?;
365 +
    sp.done(&format!("{} {} (workspace {})", ui::ok("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
366 +
    Ok(0)
367 +
}
368 +
369 +
pub fn workspace_list() -> Result<i32> {
370 +
    let repo = open()?;
371 +
    for w in repo.workspaces()? {
372 +
        println!(
373 +
            "{} {} {}{}",
374 +
            if w.current { ui::ok("*") } else { " ".into() },
375 +
            ui::id(w.id.short(8)),
376 +
            ui::path(w.path.display()),
377 +
            w.working_copy.map(|h| ui::dim(format!("  wc #{}", h.short(12)))).unwrap_or_else(|| ui::warn("  (not in view)"))
378 +
        );
379 +
    }
380 +
    Ok(0)
381 +
}
382 +
383 +
pub fn workspace_forget(spec: &str) -> Result<i32> {
384 +
    let repo = open()?;
385 +
    let all = repo.workspaces()?;
386 +
    let target = std::fs::canonicalize(spec).ok();
387 +
    let matches: Vec<_> = all.iter().filter(|w| w.id.starts_with_hex(spec) || target.as_ref().is_some_and(|t| *t == w.path)).collect();
388 +
    let w = match matches.as_slice() {
389 +
        [w] => *w,
390 +
        [] => anyhow::bail!("no workspace matches {spec:?}"),
391 +
        _ => anyhow::bail!("{spec:?} is ambiguous"),
392 +
    };
393 +
    repo.workspace_forget(&w.id)?;
394 +
    println!("{} workspace {} at {}; its files were left in place.", ui::ok("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
395 +
    Ok(0)
396 +
}