1use 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#[derive(Debug, Clone)]
20pub struct WorkspaceInfo {
21 pub name: String,
23 pub branch: Option<String>,
25 pub path: PathBuf,
27 pub is_main: bool,
29 pub is_stale: bool,
31}
32
33pub fn list_workspaces(void_dir: &Path, root: &Path) -> Result<Vec<WorkspaceInfo>> {
35 let mut workspaces = Vec::new();
36
37 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 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 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, };
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
85pub 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 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 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 fs::create_dir_all(&ws_state_dir)?;
130
131 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 fs::create_dir_all(work_tree_path)?;
140
141 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 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 if let Some(cid) = target_cid {
157 let void_dir_utf8 = utf8(void_dir)?;
158 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 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
172pub 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 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 fs::remove_dir_all(&ws_state_dir)?;
197
198 Ok(())
199}
200
201pub 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, };
224
225 if is_stale {
226 fs::remove_dir_all(entry.path())?;
227 pruned.push(name);
228 }
229 }
230
231 Ok(pruned)
232}
233
234pub 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 let target_branch = branch
247 .strip_prefix("refs/heads/")
248 .unwrap_or(branch);
249
250 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 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
287pub 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
310fn 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 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 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 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 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 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 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 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 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}