1use std::path::{Path, PathBuf};
2
3use crate::vcs::Vcs;
4
5#[derive(Debug, thiserror::Error)]
6pub enum WorkspaceError {
7 #[error("Not inside a Jujutsu or Git repository")]
8 NotInRepo,
9 #[error("Not inside a Jujutsu repository")]
10 NotInJjRepo,
11 #[error("Not inside a Git repository")]
12 NotInGitRepo,
13 #[error("Failed to execute Jujutsu command: {0}")]
14 JjCommandFailed(String),
15 #[error("Failed to execute Git command: {0}")]
16 GitCommandFailed(String),
17 #[error("Invalid workspace name: {0}")]
18 InvalidWorkspaceName(String),
19 #[error("I/O error: {0}")]
20 Io(#[from] std::io::Error),
21}
22
23pub(crate) fn validate_workspace_name(workspace_name: &str) -> Result<(), WorkspaceError> {
24 if workspace_name.is_empty()
25 || workspace_name == "."
26 || workspace_name == ".."
27 || workspace_name.contains('/')
28 || workspace_name.contains('\\')
29 {
30 return Err(WorkspaceError::InvalidWorkspaceName(
31 workspace_name.to_string(),
32 ));
33 }
34 Ok(())
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Workspace {
42 vcs: Vcs,
43 name: String,
44 path: PathBuf,
45}
46
47impl Workspace {
48 pub fn new(repo_root: &Path, name: &str) -> Result<Self, WorkspaceError> {
50 let vcs = Vcs::from_path(repo_root)?;
51 Self::from_vcs(vcs, name)
52 }
53
54 pub fn from_dir(dir: &Path, name: &str) -> Result<Self, WorkspaceError> {
56 let vcs = Vcs::from_path(dir)?;
57 Self::from_vcs(vcs, name)
58 }
59
60 pub fn list_from_dir(dir: &Path) -> Result<Vec<Self>, WorkspaceError> {
62 let vcs = Vcs::from_path(dir)?;
63 Self::list_from_vcs(vcs)
64 }
65
66 pub fn list(repo_root: &Path) -> Result<Vec<Self>, WorkspaceError> {
68 let vcs = Vcs::from_path(repo_root)?;
69 Self::list_from_vcs(vcs)
70 }
71
72 fn list_from_vcs(vcs: Vcs) -> Result<Vec<Self>, WorkspaceError> {
73 let mut names = vcs.list_workspaces()?;
74 names.sort();
75 names.dedup();
76 let mut workspaces = Vec::with_capacity(names.len());
77 for name in names {
78 workspaces.push(Self::from_vcs(vcs.clone(), &name)?);
79 }
80 Ok(workspaces)
81 }
82
83 fn from_vcs(vcs: Vcs, name: &str) -> Result<Self, WorkspaceError> {
84 validate_workspace_name(name)?;
85 let path = vcs.repo_root().join(".workspaces").join(name);
86 Ok(Self {
87 vcs,
88 name: name.to_string(),
89 path,
90 })
91 }
92
93 #[must_use]
95 pub fn name(&self) -> &str {
96 &self.name
97 }
98
99 #[must_use]
101 pub fn path(&self) -> &Path {
102 &self.path
103 }
104
105 #[must_use]
107 pub fn repo_root(&self) -> &Path {
108 self.vcs.repo_root()
109 }
110
111 #[must_use]
113 pub fn worktree_root(&self) -> &Path {
114 self.vcs.worktree_root()
115 }
116
117 #[must_use]
119 pub fn exists(&self) -> bool {
120 self.vcs.workspace_exists(&self.path)
121 }
122
123 pub fn ensure(&self) -> Result<bool, WorkspaceError> {
127 let is_registered = self.vcs.is_workspace_registered(&self.name)?;
128 if is_registered && self.exists() {
129 return Ok(false);
130 }
131
132 if self.path.exists() {
134 std::fs::remove_dir_all(&self.path)?;
135 }
136
137 let workspaces_dir = self.repo_root().join(".workspaces");
138 std::fs::create_dir_all(&workspaces_dir)?;
139
140 let rel_workspace_path = Path::new(".workspaces").join(&self.name);
141 self.vcs.add_workspace(&rel_workspace_path, &self.name)?;
142
143 Ok(true)
144 }
145
146 pub fn forget(&self) -> Result<(), WorkspaceError> {
148 self.vcs.forget_workspace(&self.name)?;
149 if self.path.exists() {
150 std::fs::remove_dir_all(&self.path)?;
151 }
152 Ok(())
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use googletest::prelude::*;
160
161 fn init_test_jj_repo(path: &Path) {
162 let output = std::process::Command::new("jj")
163 .args(["--no-pager", "git", "init"])
164 .arg(path)
165 .output();
166
167 let success = matches!(output, Ok(out) if out.status.success());
168 if !success {
169 let fallback = std::process::Command::new("jj")
170 .args(["--no-pager", "init", "--git"])
171 .arg(path)
172 .output()
173 .expect("failed to run jj init");
174 assert!(fallback.status.success());
175 }
176 }
177
178 fn init_test_git_repo(path: &Path) {
179 let output = std::process::Command::new("git")
180 .args(["init", "-b", "main"])
181 .arg(path)
182 .output();
183
184 let success = matches!(output, Ok(ref out) if out.status.success());
185 if !success {
186 let fallback = std::process::Command::new("git")
187 .arg("init")
188 .arg(path)
189 .output()
190 .expect("failed to run git init");
191 assert!(fallback.status.success(), "Failed to init git repo");
192 }
193
194 drop(
195 std::process::Command::new("git")
196 .args(["config", "user.name", "Test"])
197 .current_dir(path)
198 .output(),
199 );
200 drop(
201 std::process::Command::new("git")
202 .args(["config", "user.email", "test@example.com"])
203 .current_dir(path)
204 .output(),
205 );
206
207 let f = path.join("file.txt");
208 std::fs::write(&f, "content\n").expect("write file");
209 drop(
210 std::process::Command::new("git")
211 .args(["add", "file.txt"])
212 .current_dir(path)
213 .output(),
214 );
215 drop(
216 std::process::Command::new("git")
217 .args(["commit", "-m", "init"])
218 .current_dir(path)
219 .output(),
220 );
221 }
222
223 type TestResult = Result<(), Box<dyn std::error::Error>>;
224
225 #[googletest::test]
226 fn workspace_from_dir_in_jj_repo_creates_workspace() -> TestResult {
227 let dir = tempfile::tempdir()?;
228 let repo_root = dir.path();
229 init_test_jj_repo(repo_root);
230
231 let sub = repo_root.join("sub1").join("sub2");
232 std::fs::create_dir_all(&sub)?;
233
234 let ws = Workspace::from_dir(&sub, "jj-ws")?;
235 expect_that!(ws.name(), eq("jj-ws"));
236 expect_that!(ws.repo_root(), eq(repo_root));
237 expect_that!(ws.path(), eq(&repo_root.join(".workspaces").join("jj-ws")));
238 expect_that!(ws.exists(), is_false());
239
240 let created = ws.ensure()?;
241 expect_that!(created, is_true());
242 expect_that!(ws.exists(), is_true());
243 Ok(())
244 }
245
246 #[googletest::test]
247 fn workspace_from_dir_in_git_repo_creates_worktree() -> TestResult {
248 let dir = tempfile::tempdir()?;
249 let repo_root = dir.path();
250 init_test_git_repo(repo_root);
251
252 let sub = repo_root.join("sub1").join("sub2");
253 std::fs::create_dir_all(&sub)?;
254
255 let ws = Workspace::from_dir(&sub, "git-ws")?;
256 let canonical_root = repo_root
257 .canonicalize()
258 .unwrap_or_else(|_| repo_root.to_path_buf());
259 expect_that!(ws.name(), eq("git-ws"));
260 expect_that!(ws.repo_root(), eq(&canonical_root));
261 expect_that!(
262 ws.path(),
263 eq(&canonical_root.join(".workspaces").join("git-ws"))
264 );
265 expect_that!(ws.exists(), is_false());
266
267 let created = ws.ensure()?;
268 expect_that!(created, is_true());
269 expect_that!(ws.exists(), is_true());
270 Ok(())
271 }
272
273 #[googletest::test]
274 fn workspace_ensure_creates_workspace_and_registers_in_jj() -> TestResult {
275 let dir = tempfile::tempdir()?;
276 let repo_root = dir.path();
277 init_test_jj_repo(repo_root);
278
279 let ws_name = "ws-alpha";
280 let ws = Workspace::new(repo_root, ws_name)?;
281 let expected_path = repo_root.join(".workspaces").join(ws_name);
282
283 expect_that!(ws.path(), eq(&expected_path));
284 expect_that!(ws.exists(), is_false());
285
286 let created = ws.ensure()?;
287 expect_that!(created, is_true());
288 expect_that!(ws.exists(), is_true());
289 expect_that!(expected_path.join(".jj").exists(), is_true());
290
291 let output = std::process::Command::new("jj")
292 .args(["--no-pager", "workspace", "list"])
293 .current_dir(repo_root)
294 .output()?;
295 expect_that!(output.status.success(), is_true());
296 let stdout = String::from_utf8_lossy(&output.stdout);
297 expect_that!(stdout.as_ref(), contains_substring(ws_name));
298 Ok(())
299 }
300
301 #[googletest::test]
302 fn workspace_ensure_creates_workspace_and_registers_in_git() -> TestResult {
303 let dir = tempfile::tempdir()?;
304 let repo_root = dir.path();
305 init_test_git_repo(repo_root);
306
307 let ws_name = "git-ws-alpha";
308 let ws = Workspace::new(repo_root, ws_name)?;
309 let canonical_root = repo_root
310 .canonicalize()
311 .unwrap_or_else(|_| repo_root.to_path_buf());
312 let expected_path = canonical_root.join(".workspaces").join(ws_name);
313
314 expect_that!(ws.exists(), is_false());
315
316 let created = ws.ensure()?;
317 expect_that!(created, is_true());
318 expect_that!(ws.exists(), is_true());
319 expect_that!(expected_path.join(".git").exists(), is_true());
320
321 let output = std::process::Command::new("git")
322 .args(["--no-pager", "worktree", "list", "--porcelain"])
323 .current_dir(repo_root)
324 .output()?;
325 expect_that!(output.status.success(), is_true());
326 let stdout = String::from_utf8_lossy(&output.stdout);
327 expect_that!(stdout.as_ref(), contains_substring(ws_name));
328 Ok(())
329 }
330
331 #[googletest::test]
332 fn workspace_ensure_when_already_exists_succeeds_idempotently() -> TestResult {
333 let dir = tempfile::tempdir()?;
334 let repo_root = dir.path();
335 init_test_jj_repo(repo_root);
336
337 let ws_name = "ws-beta";
338 let ws = Workspace::new(repo_root, ws_name)?;
339 let created1 = ws.ensure()?;
340 let created2 = ws.ensure()?;
341
342 expect_that!(created1, is_true());
343 expect_that!(created2, is_false());
344 expect_that!(ws.exists(), is_true());
345 Ok(())
346 }
347
348 #[googletest::test]
349 fn workspace_exists_reflects_presence() -> TestResult {
350 let dir = tempfile::tempdir()?;
351 let repo_root = dir.path();
352 init_test_jj_repo(repo_root);
353
354 let ws_name = "ws-check";
355 let ws = Workspace::new(repo_root, ws_name)?;
356 expect_that!(ws.exists(), is_false());
357
358 ws.ensure()?;
359 expect_that!(ws.exists(), is_true());
360 Ok(())
361 }
362
363 #[googletest::test]
364 fn workspace_new_outside_repo_returns_not_in_repo_error() -> TestResult {
365 let dir = tempfile::tempdir()?;
366 let non_repo = dir.path();
367
368 let res = Workspace::new(non_repo, "ws-gamma");
369 expect_that!(
370 res,
371 matches_pattern!(Err(matches_pattern!(WorkspaceError::NotInRepo)))
372 );
373 expect_that!(non_repo.join(".workspaces").exists(), is_false());
374 Ok(())
375 }
376
377 #[googletest::test]
378 fn workspace_new_with_empty_name_returns_invalid_workspace_name_error() -> TestResult {
379 let dir = tempfile::tempdir()?;
380 let repo_root = dir.path();
381 init_test_jj_repo(repo_root);
382
383 let res = Workspace::new(repo_root, "");
384 expect_that!(
385 res,
386 matches_pattern!(Err(matches_pattern!(WorkspaceError::InvalidWorkspaceName(
387 anything()
388 ))))
389 );
390 Ok(())
391 }
392
393 #[googletest::test]
394 fn workspace_new_with_path_traversal_name_returns_invalid_workspace_name_error() -> TestResult {
395 let dir = tempfile::tempdir()?;
396 let repo_root = dir.path();
397 init_test_jj_repo(repo_root);
398
399 for bad_name in [".", "..", "foo/bar", "foo\\bar", "../escape"] {
400 let res = Workspace::new(repo_root, bad_name);
401 expect_that!(
402 res,
403 matches_pattern!(Err(matches_pattern!(WorkspaceError::InvalidWorkspaceName(
404 anything()
405 ))))
406 );
407 }
408 Ok(())
409 }
410
411 #[googletest::test]
412 fn workspace_forget_removes_workspace_from_jj() -> TestResult {
413 let dir = tempfile::tempdir()?;
414 let repo_root = dir.path();
415 init_test_jj_repo(repo_root);
416
417 let ws_name = "ws-to-forget";
418 let ws = Workspace::new(repo_root, ws_name)?;
419 ws.ensure()?;
420
421 let before = std::process::Command::new("jj")
422 .args(["--no-pager", "workspace", "list"])
423 .current_dir(repo_root)
424 .output()?;
425 expect_that!(
426 String::from_utf8_lossy(&before.stdout).as_ref(),
427 contains_substring(ws_name)
428 );
429
430 ws.forget()?;
431
432 let after = std::process::Command::new("jj")
433 .args(["--no-pager", "workspace", "list"])
434 .current_dir(repo_root)
435 .output()?;
436 expect_that!(
437 String::from_utf8_lossy(&after.stdout).as_ref(),
438 not(contains_substring(ws_name))
439 );
440 Ok(())
441 }
442
443 #[googletest::test]
444 fn workspace_forget_removes_workspace_from_git() -> TestResult {
445 let dir = tempfile::tempdir()?;
446 let repo_root = dir.path();
447 init_test_git_repo(repo_root);
448
449 let ws_name = "git-ws-to-forget";
450 let ws = Workspace::new(repo_root, ws_name)?;
451 ws.ensure()?;
452
453 let before = std::process::Command::new("git")
454 .args(["--no-pager", "worktree", "list", "--porcelain"])
455 .current_dir(repo_root)
456 .output()?;
457 expect_that!(
458 String::from_utf8_lossy(&before.stdout).as_ref(),
459 contains_substring(ws_name)
460 );
461
462 ws.forget()?;
463
464 let after = std::process::Command::new("git")
465 .args(["--no-pager", "worktree", "list", "--porcelain"])
466 .current_dir(repo_root)
467 .output()?;
468 expect_that!(
469 String::from_utf8_lossy(&after.stdout).as_ref(),
470 not(contains_substring(ws_name))
471 );
472 expect_that!(ws.path().exists(), is_false());
473 Ok(())
474 }
475
476 #[googletest::test]
477 fn workspace_struct_lifecycle_and_methods() -> TestResult {
478 let dir = tempfile::tempdir()?;
479 let repo_root = dir.path();
480 init_test_jj_repo(repo_root);
481
482 let ws = Workspace::new(repo_root, "struct-ws")?;
483 expect_that!(ws.name(), eq("struct-ws"));
484 expect_that!(ws.repo_root(), eq(repo_root));
485 expect_that!(
486 ws.path(),
487 eq(&repo_root.join(".workspaces").join("struct-ws"))
488 );
489 expect_that!(ws.exists(), is_false());
490
491 let is_new = ws.ensure()?;
493 expect_that!(is_new, is_true());
494 expect_that!(ws.exists(), is_true());
495
496 let is_new_again = ws.ensure()?;
498 expect_that!(is_new_again, is_false());
499
500 let sub = repo_root.join("subdir");
502 std::fs::create_dir_all(&sub)?;
503 let ws_from_sub = Workspace::from_dir(&sub, "from-sub")?;
504 expect_that!(ws_from_sub.repo_root(), eq(repo_root));
505 expect_that!(ws_from_sub.name(), eq("from-sub"));
506
507 ws.forget()?;
509 expect_that!(ws.path().exists(), is_false());
510 let list_output = std::process::Command::new("jj")
511 .args(["--no-pager", "workspace", "list"])
512 .current_dir(repo_root)
513 .output()?;
514 expect_that!(
515 String::from_utf8_lossy(&list_output.stdout).as_ref(),
516 not(contains_substring("struct-ws"))
517 );
518 Ok(())
519 }
520
521 #[googletest::test]
522 fn workspace_ensure_after_forget_recreates_and_returns_true() -> TestResult {
523 let dir = tempfile::tempdir()?;
524 let repo_root = dir.path();
525 init_test_jj_repo(repo_root);
526
527 let ws = Workspace::new(repo_root, "recreate-ws")?;
528 let first_ensure = ws.ensure()?;
529 expect_that!(first_ensure, is_true());
530 expect_that!(ws.path().exists(), is_true());
531
532 ws.forget()?;
533 expect_that!(ws.path().exists(), is_false());
534
535 let second_ensure = ws.ensure()?;
537 expect_that!(second_ensure, is_true());
538 expect_that!(ws.path().exists(), is_true());
539 Ok(())
540 }
541
542 #[googletest::test]
543 fn workspace_list_in_jj_repo_returns_available_workspaces() -> TestResult {
544 let dir = tempfile::tempdir()?;
545 let repo_root = dir.path();
546 init_test_jj_repo(repo_root);
547
548 let initial = Workspace::list(repo_root)?;
549 expect_that!(initial, is_empty());
550
551 let ws1 = Workspace::new(repo_root, "ws-alpha")?;
552 let ws2 = Workspace::new(repo_root, "ws-beta")?;
553 ws1.ensure()?;
554 ws2.ensure()?;
555
556 let listed = Workspace::list(repo_root)?;
557 let names: Vec<String> = listed.into_iter().map(|w| w.name().to_string()).collect();
558 expect_that!(names, elements_are![eq("ws-alpha"), eq("ws-beta")]);
559
560 let listed_from_sub = Workspace::list_from_dir(ws1.path())?;
562 let names_from_sub: Vec<String> = listed_from_sub
563 .into_iter()
564 .map(|w| w.name().to_string())
565 .collect();
566 expect_that!(names_from_sub, elements_are![eq("ws-alpha"), eq("ws-beta")]);
567
568 ws1.forget()?;
570 let listed_after_forget = Workspace::list(repo_root)?;
571 let names_after_forget: Vec<String> = listed_after_forget
572 .into_iter()
573 .map(|w| w.name().to_string())
574 .collect();
575 expect_that!(names_after_forget, elements_are![eq("ws-beta")]);
576 Ok(())
577 }
578
579 #[googletest::test]
580 fn workspace_list_in_git_repo_returns_available_workspaces() -> TestResult {
581 let dir = tempfile::tempdir()?;
582 let repo_root = dir.path();
583 init_test_git_repo(repo_root);
584
585 let initial = Workspace::list(repo_root)?;
586 expect_that!(initial, is_empty());
587
588 let ws1 = Workspace::new(repo_root, "git-alpha")?;
589 let ws2 = Workspace::new(repo_root, "git-beta")?;
590 ws1.ensure()?;
591 ws2.ensure()?;
592
593 let listed = Workspace::list(repo_root)?;
594 let names: Vec<String> = listed.into_iter().map(|w| w.name().to_string()).collect();
595 expect_that!(names, elements_are![eq("git-alpha"), eq("git-beta")]);
596
597 let listed_from_sub = Workspace::list_from_dir(ws1.path())?;
599 let names_from_sub: Vec<String> = listed_from_sub
600 .into_iter()
601 .map(|w| w.name().to_string())
602 .collect();
603 expect_that!(
604 names_from_sub,
605 elements_are![eq("git-alpha"), eq("git-beta")]
606 );
607
608 ws1.forget()?;
610 let listed_after_forget = Workspace::list(repo_root)?;
611 let names_after_forget: Vec<String> = listed_after_forget
612 .into_iter()
613 .map(|w| w.name().to_string())
614 .collect();
615 expect_that!(names_after_forget, elements_are![eq("git-beta")]);
616 Ok(())
617 }
618
619 #[googletest::test]
620 fn workspace_list_outside_repo_returns_not_in_repo_error() -> TestResult {
621 let dir = tempfile::tempdir()?;
622 let non_repo = dir.path();
623
624 let res = Workspace::list(non_repo);
625 expect_that!(
626 res,
627 matches_pattern!(Err(matches_pattern!(WorkspaceError::NotInRepo)))
628 );
629 Ok(())
630 }
631}