Skip to main content

void_core/workspace/
workspaces.rs

1//! Workspace management — create, list, remove, and prune linked workspaces.
2//!
3//! Layout:
4//! - Main workspace: `root/.void/` (workspace_dir == void_dir)
5//! - Linked workspace: `<path>/.void` is a *file* containing `voidDir=<abs path>\nworktree=<name>`
6//!   Per-workspace state lives in `<void_dir>/worktrees/<name>/` (HEAD, index, staged, stash, merge state)
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use camino::Utf8PathBuf;
12
13use crate::refs;
14use crate::{Result, VoidError};
15
16const WORKTREES_DIR: &str = "worktrees";
17
18/// Information about a workspace.
19#[derive(Debug, Clone)]
20pub struct WorkspaceInfo {
21    /// Workspace name ("main" for the primary workspace)
22    pub name: String,
23    /// Branch currently checked out (None if detached HEAD)
24    pub branch: Option<String>,
25    /// Working tree root path
26    pub path: PathBuf,
27    /// True for the primary workspace
28    pub is_main: bool,
29    /// True if the working tree path no longer exists
30    pub is_stale: bool,
31}
32
33/// List all workspaces (main + linked).
34pub fn list_workspaces(void_dir: &Path, root: &Path) -> Result<Vec<WorkspaceInfo>> {
35    let mut workspaces = Vec::new();
36
37    // Main workspace
38    let void_dir_utf8 = utf8(void_dir)?;
39    let main_branch = read_branch_from_head(&void_dir_utf8);
40    workspaces.push(WorkspaceInfo {
41        name: "main".to_string(),
42        branch: main_branch,
43        path: root.to_path_buf(),
44        is_main: true,
45        is_stale: false,
46    });
47
48    // Linked workspaces
49    let worktrees_dir = void_dir.join(WORKTREES_DIR);
50    if worktrees_dir.is_dir() {
51        for entry in fs::read_dir(&worktrees_dir)? {
52            let entry = entry?;
53            let meta = entry.metadata()?;
54            if !meta.is_dir() {
55                continue;
56            }
57            let name = entry.file_name().to_string_lossy().to_string();
58            let ws_state_dir = entry.path();
59
60            // Read path file to find working tree root
61            let path_file = ws_state_dir.join("path");
62            let work_tree_path = match fs::read_to_string(&path_file) {
63                Ok(p) => PathBuf::from(p.trim()),
64                Err(_) => continue, // Skip broken entries
65            };
66
67            let is_stale = !work_tree_path.exists();
68
69            let ws_dir_utf8 = utf8(&ws_state_dir).ok();
70            let branch = ws_dir_utf8.and_then(|d| read_branch_from_head(&d));
71
72            workspaces.push(WorkspaceInfo {
73                name,
74                branch,
75                path: work_tree_path,
76                is_main: false,
77                is_stale,
78            });
79        }
80    }
81
82    Ok(workspaces)
83}
84
85/// Create a linked workspace.
86///
87/// Creates:
88/// 1. `<void_dir>/worktrees/<name>/` with HEAD and path files
89/// 2. `<work_tree_path>/.void` file pointing back to the main void dir
90///
91/// Returns the per-workspace state directory (`<void_dir>/worktrees/<name>/`).
92pub fn create_workspace(
93    void_dir: &Path,
94    name: &str,
95    branch: &str,
96    target_cid: Option<&void_crypto::CommitCid>,
97    work_tree_path: &Path,
98) -> Result<PathBuf> {
99    // Validate name
100    if name.is_empty()
101        || name == "main"
102        || name.contains('/')
103        || name.contains('\\')
104        || name.contains('\0')
105    {
106        return Err(VoidError::InvalidPattern(format!(
107            "invalid workspace name: '{}'",
108            name
109        )));
110    }
111
112    let ws_state_dir = void_dir.join(WORKTREES_DIR).join(name);
113    if ws_state_dir.exists() {
114        return Err(VoidError::InvalidPattern(format!(
115            "workspace '{}' already exists",
116            name
117        )));
118    }
119
120    // Check branch isn't already checked out in another workspace
121    if let Some(ws) = find_workspace_for_branch(void_dir, void_dir, branch, Some(name))? {
122        return Err(VoidError::InvalidPattern(format!(
123            "branch '{}' is already checked out in workspace '{}'",
124            branch, ws
125        )));
126    }
127
128    // Create per-workspace state directory
129    fs::create_dir_all(&ws_state_dir)?;
130
131    // Write HEAD (symbolic ref to the branch)
132    let ws_dir_utf8 = utf8(&ws_state_dir)?;
133    refs::write_head(
134        &ws_dir_utf8,
135        &refs::HeadRef::Symbolic(branch.to_string()),
136    )?;
137
138    // Create the working tree directory first so we can canonicalize it
139    fs::create_dir_all(work_tree_path)?;
140
141    // Write path file (canonicalized so symlinks like /tmp → /private/tmp resolve)
142    let abs_path = fs::canonicalize(work_tree_path).unwrap_or_else(|_| work_tree_path.to_path_buf());
143    fs::write(ws_state_dir.join("path"), abs_path.to_string_lossy().as_bytes())?;
144
145    // Write .void file in the working tree (pointer back to main void dir)
146    let abs_void_dir =
147        fs::canonicalize(void_dir).unwrap_or_else(|_| void_dir.to_path_buf());
148    let void_file_content = format!(
149        "voidDir={}\nworktree={}\n",
150        abs_void_dir.display(),
151        name
152    );
153    fs::write(work_tree_path.join(".void"), void_file_content)?;
154
155    // Write branch ref if target_cid is provided
156    if let Some(cid) = target_cid {
157        let void_dir_utf8 = utf8(void_dir)?;
158        // Only write if branch doesn't already exist
159        let branch_path = void_dir.join("refs").join("heads").join(branch);
160        if !branch_path.exists() {
161            refs::write_branch(&void_dir_utf8, branch, cid)?;
162        }
163    }
164
165    // Create index and staged directories in workspace state dir
166    fs::create_dir_all(ws_state_dir.join("index"))?;
167    fs::create_dir_all(ws_state_dir.join("staged"))?;
168
169    Ok(ws_state_dir)
170}
171
172/// Remove a linked workspace.
173pub fn remove_workspace(void_dir: &Path, name: &str) -> Result<()> {
174    if name == "main" {
175        return Err(VoidError::InvalidPattern(
176            "cannot remove the main workspace".to_string(),
177        ));
178    }
179
180    let ws_state_dir = void_dir.join(WORKTREES_DIR).join(name);
181    if !ws_state_dir.exists() {
182        return Err(VoidError::NotFound(format!("workspace '{}' not found", name)));
183    }
184
185    // Read path to optionally clean up .void file in working tree
186    let path_file = ws_state_dir.join("path");
187    if let Ok(work_tree) = fs::read_to_string(&path_file) {
188        let wt_path = PathBuf::from(work_tree.trim());
189        let void_file = wt_path.join(".void");
190        if void_file.is_file() {
191            let _ = fs::remove_file(&void_file);
192        }
193    }
194
195    // Remove per-workspace state directory
196    fs::remove_dir_all(&ws_state_dir)?;
197
198    Ok(())
199}
200
201/// Prune stale workspace entries (where the working tree no longer exists).
202///
203/// Returns the names of pruned workspaces.
204pub fn prune_workspaces(void_dir: &Path) -> Result<Vec<String>> {
205    let worktrees_dir = void_dir.join(WORKTREES_DIR);
206    if !worktrees_dir.is_dir() {
207        return Ok(Vec::new());
208    }
209
210    let mut pruned = Vec::new();
211
212    for entry in fs::read_dir(&worktrees_dir)? {
213        let entry = entry?;
214        if !entry.metadata()?.is_dir() {
215            continue;
216        }
217        let name = entry.file_name().to_string_lossy().to_string();
218        let path_file = entry.path().join("path");
219
220        let is_stale = match fs::read_to_string(&path_file) {
221            Ok(p) => !PathBuf::from(p.trim()).exists(),
222            Err(_) => true, // Can't read path file → stale
223        };
224
225        if is_stale {
226            fs::remove_dir_all(entry.path())?;
227            pruned.push(name);
228        }
229    }
230
231    Ok(pruned)
232}
233
234/// Check if a branch is checked out in any workspace.
235///
236/// Returns the name of the workspace that has the branch checked out, or None.
237/// `exclude` allows skipping a specific workspace (useful when the caller is that workspace).
238pub fn find_workspace_for_branch(
239    void_dir: &Path,
240    main_void_dir: &Path,
241    branch: &str,
242    exclude: Option<&str>,
243) -> Result<Option<String>> {
244    // read_head strips the `ref: refs/heads/` prefix, so compare against
245    // the bare branch name.
246    let target_branch = branch
247        .strip_prefix("refs/heads/")
248        .unwrap_or(branch);
249
250    // Check main workspace
251    if exclude.map_or(true, |e| e != "main") {
252        let main_utf8 = utf8(main_void_dir)?;
253        if let Some(refs::HeadRef::Symbolic(ref r)) = refs::read_head(&main_utf8)? {
254            if *r == target_branch {
255                return Ok(Some("main".to_string()));
256            }
257        }
258    }
259
260    // Check linked workspaces
261    let worktrees_dir = void_dir.join(WORKTREES_DIR);
262    if worktrees_dir.is_dir() {
263        for entry in fs::read_dir(&worktrees_dir)? {
264            let entry = entry?;
265            if !entry.metadata()?.is_dir() {
266                continue;
267            }
268            let name = entry.file_name().to_string_lossy().to_string();
269            if exclude.map_or(false, |e| e == name) {
270                continue;
271            }
272
273            let ws_dir = entry.path();
274            if let Ok(ws_utf8) = utf8(&ws_dir) {
275                if let Ok(Some(refs::HeadRef::Symbolic(ref r))) = refs::read_head(&ws_utf8) {
276                    if *r == target_branch {
277                        return Ok(Some(name));
278                    }
279                }
280            }
281        }
282    }
283
284    Ok(None)
285}
286
287/// Parse a `.void` file (from a linked workspace) and return `(void_dir, worktree_name)`.
288pub fn parse_void_file(void_file_path: &Path) -> Result<(PathBuf, String)> {
289    let content = fs::read_to_string(void_file_path)?;
290    let mut void_dir = None;
291    let mut worktree = None;
292
293    for line in content.lines() {
294        let line = line.trim();
295        if let Some(val) = line.strip_prefix("voidDir=") {
296            void_dir = Some(PathBuf::from(val));
297        } else if let Some(val) = line.strip_prefix("worktree=") {
298            worktree = Some(val.to_string());
299        }
300    }
301
302    match (void_dir, worktree) {
303        (Some(vd), Some(wt)) => Ok((vd, wt)),
304        _ => Err(VoidError::InvalidPattern(
305            "invalid .void file format".to_string(),
306        )),
307    }
308}
309
310// ============================================================================
311// Helpers
312// ============================================================================
313
314fn utf8(path: &Path) -> Result<Utf8PathBuf> {
315    Utf8PathBuf::try_from(path.to_path_buf())
316        .map_err(|e| VoidError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
317}
318
319fn read_branch_from_head(dir: &Utf8PathBuf) -> Option<String> {
320    match refs::read_head(dir) {
321        Ok(Some(refs::HeadRef::Symbolic(r))) => {
322            Some(r.strip_prefix("refs/heads/").unwrap_or(&r).to_string())
323        }
324        _ => None,
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use tempfile::TempDir;
332
333    fn setup_void_dir() -> (TempDir, PathBuf) {
334        let temp = TempDir::new().unwrap();
335        let root = temp.path().to_path_buf();
336        let void_dir = root.join(".void");
337        fs::create_dir_all(&void_dir).unwrap();
338        // Write a HEAD file for main workspace
339        let void_utf8 = utf8(&void_dir).unwrap();
340        refs::write_head(
341            &void_utf8,
342            &refs::HeadRef::Symbolic("trunk".to_string()),
343        )
344        .unwrap();
345        (temp, void_dir)
346    }
347
348    #[test]
349    fn list_workspaces_shows_main() {
350        let (temp, void_dir) = setup_void_dir();
351        let root = temp.path().to_path_buf();
352
353        let ws = list_workspaces(&void_dir, &root).unwrap();
354        assert_eq!(ws.len(), 1);
355        assert!(ws[0].is_main);
356        assert_eq!(ws[0].name, "main");
357        assert_eq!(ws[0].branch, Some("trunk".to_string()));
358    }
359
360    #[test]
361    fn create_and_list_workspace() {
362        let (temp, void_dir) = setup_void_dir();
363        let root = temp.path().to_path_buf();
364        let ws_path = temp.path().join("test-ws");
365
366        create_workspace(&void_dir, "test-ws", "feature", None, &ws_path).unwrap();
367
368        // Verify directory structure
369        assert!(void_dir.join("worktrees/test-ws").exists());
370        assert!(void_dir.join("worktrees/test-ws/HEAD").exists());
371        assert!(void_dir.join("worktrees/test-ws/path").exists());
372        assert!(ws_path.join(".void").is_file());
373
374        // List should show both
375        let ws = list_workspaces(&void_dir, &root).unwrap();
376        assert_eq!(ws.len(), 2);
377        let linked = ws.iter().find(|w| w.name == "test-ws").unwrap();
378        assert!(!linked.is_main);
379        assert_eq!(linked.branch, Some("feature".to_string()));
380        assert!(!linked.is_stale);
381    }
382
383    #[test]
384    fn create_duplicate_workspace_fails() {
385        let (temp, void_dir) = setup_void_dir();
386        let ws_path = temp.path().join("test-ws");
387
388        create_workspace(&void_dir, "test-ws", "feature", None, &ws_path).unwrap();
389        let result = create_workspace(&void_dir, "test-ws", "other", None, &ws_path);
390        assert!(result.is_err());
391    }
392
393    #[test]
394    fn create_workspace_with_locked_branch_fails() {
395        let (temp, void_dir) = setup_void_dir();
396
397        // trunk is checked out in main — can't create a workspace for it
398        let ws_path = temp.path().join("trunk-ws");
399        let result = create_workspace(&void_dir, "trunk-ws", "trunk", None, &ws_path);
400        assert!(result.is_err());
401        let err_msg = format!("{}", result.unwrap_err());
402        assert!(err_msg.contains("already checked out"));
403    }
404
405    #[test]
406    fn remove_workspace_cleans_up() {
407        let (temp, void_dir) = setup_void_dir();
408        let ws_path = temp.path().join("test-ws");
409
410        create_workspace(&void_dir, "test-ws", "feature", None, &ws_path).unwrap();
411        assert!(void_dir.join("worktrees/test-ws").exists());
412
413        remove_workspace(&void_dir, "test-ws").unwrap();
414        assert!(!void_dir.join("worktrees/test-ws").exists());
415        // .void file in working tree should be removed
416        assert!(!ws_path.join(".void").exists());
417    }
418
419    #[test]
420    fn remove_main_workspace_fails() {
421        let (_temp, void_dir) = setup_void_dir();
422        let result = remove_workspace(&void_dir, "main");
423        assert!(result.is_err());
424    }
425
426    #[test]
427    fn prune_removes_stale_workspaces() {
428        let (temp, void_dir) = setup_void_dir();
429        let ws_path = temp.path().join("test-ws");
430
431        create_workspace(&void_dir, "test-ws", "feature", None, &ws_path).unwrap();
432
433        // Delete the working tree to make it stale
434        fs::remove_dir_all(&ws_path).unwrap();
435
436        let pruned = prune_workspaces(&void_dir).unwrap();
437        assert_eq!(pruned, vec!["test-ws"]);
438        assert!(!void_dir.join("worktrees/test-ws").exists());
439    }
440
441    #[test]
442    fn prune_keeps_valid_workspaces() {
443        let (temp, void_dir) = setup_void_dir();
444        let ws_path = temp.path().join("test-ws");
445
446        create_workspace(&void_dir, "test-ws", "feature", None, &ws_path).unwrap();
447
448        let pruned = prune_workspaces(&void_dir).unwrap();
449        assert!(pruned.is_empty());
450        assert!(void_dir.join("worktrees/test-ws").exists());
451    }
452
453    #[test]
454    fn find_workspace_for_branch_finds_main() {
455        let (_temp, void_dir) = setup_void_dir();
456
457        let ws = find_workspace_for_branch(&void_dir, &void_dir, "trunk", None).unwrap();
458        assert_eq!(ws, Some("main".to_string()));
459    }
460
461    #[test]
462    fn find_workspace_for_branch_finds_linked() {
463        let (temp, void_dir) = setup_void_dir();
464        let ws_path = temp.path().join("feat-ws");
465
466        create_workspace(&void_dir, "feat-ws", "feature", None, &ws_path).unwrap();
467
468        let ws = find_workspace_for_branch(&void_dir, &void_dir, "feature", None).unwrap();
469        assert_eq!(ws, Some("feat-ws".to_string()));
470    }
471
472    #[test]
473    fn find_workspace_for_branch_with_exclude() {
474        let (_temp, void_dir) = setup_void_dir();
475
476        // Exclude main — trunk should not be found
477        let ws =
478            find_workspace_for_branch(&void_dir, &void_dir, "trunk", Some("main")).unwrap();
479        assert_eq!(ws, None);
480    }
481
482    #[test]
483    fn find_workspace_for_nonexistent_branch() {
484        let (_temp, void_dir) = setup_void_dir();
485
486        let ws =
487            find_workspace_for_branch(&void_dir, &void_dir, "nonexistent", None).unwrap();
488        assert_eq!(ws, None);
489    }
490
491    #[test]
492    fn parse_void_file_roundtrip() {
493        let (temp, void_dir) = setup_void_dir();
494        let ws_path = temp.path().join("test-ws");
495
496        create_workspace(&void_dir, "test-ws", "feature", None, &ws_path).unwrap();
497
498        let (parsed_void_dir, parsed_name) =
499            parse_void_file(&ws_path.join(".void")).unwrap();
500        assert_eq!(parsed_name, "test-ws");
501        assert_eq!(
502            fs::canonicalize(&parsed_void_dir).unwrap(),
503            fs::canonicalize(&void_dir).unwrap()
504        );
505    }
506
507    #[test]
508    fn invalid_workspace_names() {
509        let (temp, void_dir) = setup_void_dir();
510        let ws_path = temp.path().join("ws");
511
512        assert!(create_workspace(&void_dir, "", "branch", None, &ws_path).is_err());
513        assert!(create_workspace(&void_dir, "main", "branch", None, &ws_path).is_err());
514        assert!(create_workspace(&void_dir, "a/b", "branch", None, &ws_path).is_err());
515    }
516
517    #[test]
518    fn workspace_dir_equals_void_dir_for_main() {
519        // Verify the backwards-compatible invariant: for the main workspace,
520        // workspace_dir == void_dir
521        let dir = tempfile::tempdir().unwrap();
522        let void_dir = dir.path().join(".void");
523        std::fs::create_dir_all(void_dir.join("objects")).unwrap();
524        let vault = std::sync::Arc::new(
525            crate::crypto::KeyVault::new([0u8; 32]).unwrap(),
526        );
527        let ctx = crate::VoidContext::headless(&void_dir, vault, 0).unwrap();
528        assert_eq!(ctx.paths.workspace_dir, ctx.paths.void_dir);
529    }
530}