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 | + | } |