Skip to main content

aiw/
workspace.rs

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/// Encapsulates state and operations for a workspace under `<repo_root>/.workspaces/<name>`.
38///
39/// Supports both Jujutsu workspaces and Git worktrees via an internal `Vcs` engine.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Workspace {
42    vcs: Vcs,
43    name: String,
44    path: PathBuf,
45}
46
47impl Workspace {
48    /// Creates a new `Workspace` instance for a given repository root and workspace name.
49    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    /// Discovers the repository root and VCS enclosing `dir` and returns a `Workspace` handle.
55    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    /// Lists all available workspaces in the repository enclosing `dir`.
61    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    /// Lists all available workspaces for a given repository root.
67    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    /// Returns the workspace name.
94    #[must_use]
95    pub fn name(&self) -> &str {
96        &self.name
97    }
98
99    /// Returns the workspace directory path.
100    #[must_use]
101    pub fn path(&self) -> &Path {
102        &self.path
103    }
104
105    /// Returns the enclosing repository root path.
106    #[must_use]
107    pub fn repo_root(&self) -> &Path {
108        self.vcs.repo_root()
109    }
110
111    /// Returns the current worktree or workspace root path of the enclosing repository.
112    #[must_use]
113    pub fn worktree_root(&self) -> &Path {
114        self.vcs.worktree_root()
115    }
116
117    /// Returns `true` if the workspace currently exists on disk and contains appropriate VCS metadata.
118    #[must_use]
119    pub fn exists(&self) -> bool {
120        self.vcs.workspace_exists(&self.path)
121    }
122
123    /// Ensures the workspace exists on disk and is registered in the underlying VCS.
124    ///
125    /// Returns `Ok(true)` if the workspace was newly created, or `Ok(false)` if it already existed.
126    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        // Clean up any stale/forgotten directory on disk before adding
133        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    /// Forgets the workspace in the underlying VCS and removes the workspace directory from disk.
147    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        // First ensure returns Ok(true) indicating newly created
492        let is_new = ws.ensure()?;
493        expect_that!(is_new, is_true());
494        expect_that!(ws.exists(), is_true());
495
496        // Second ensure returns Ok(false) indicating already existed
497        let is_new_again = ws.ensure()?;
498        expect_that!(is_new_again, is_false());
499
500        // Test from_dir
501        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        // Forget workspace
508        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        // Re-creating the forgotten workspace must return Ok(true)
536        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        // list_from_dir called from inside a workspace path works identically
561        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        // After forgetting ws1
569        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        // list_from_dir called from inside a workspace path works identically
598        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        // After forgetting ws1
609        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}