1use crate::contracts::Config;
19use crate::fsutil;
20use crate::git::error::git_base_command;
21use anyhow::{Context, Result, bail};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone)]
26pub(crate) struct WorkspaceSpec {
27 pub path: PathBuf,
28 #[allow(dead_code)]
29 pub branch: String,
30}
31
32pub(crate) fn workspace_root(repo_root: &Path, cfg: &Config) -> PathBuf {
33 let raw = cfg
34 .parallel
35 .workspace_root
36 .clone()
37 .unwrap_or_else(|| default_workspace_root(repo_root));
38
39 let root = fsutil::expand_tilde(&raw);
40 if root.is_absolute() {
41 root
42 } else {
43 repo_root.join(root)
44 }
45}
46
47fn default_workspace_root(repo_root: &Path) -> PathBuf {
48 let repo_name = repo_root
49 .file_name()
50 .and_then(|value| value.to_str())
51 .unwrap_or("repo");
52 let parent = repo_root.parent().unwrap_or(repo_root);
53 parent.join(".workspaces").join(repo_name).join("parallel")
54}
55
56pub(crate) fn create_workspace_at(
57 repo_root: &Path,
58 workspace_root: &Path,
59 task_id: &str,
60 base_branch: &str,
61) -> Result<WorkspaceSpec> {
62 let trimmed_id = task_id.trim();
63 if trimmed_id.is_empty() {
64 bail!("workspace task_id must be non-empty");
65 }
66
67 let branch = base_branch.trim().to_string();
68 if branch.is_empty() {
69 bail!("workspace base_branch must be non-empty");
70 }
71 let path = workspace_root.join(trimmed_id);
72
73 fs::create_dir_all(workspace_root).with_context(|| {
74 format!(
75 "create workspace root directory {}",
76 workspace_root.display()
77 )
78 })?;
79
80 let (fetch_url, push_url) = origin_urls(repo_root)?;
81 if path.exists() {
82 if !path.join(".git").exists() {
83 fs::remove_dir_all(&path)
84 .with_context(|| format!("remove non-git workspace {}", path.display()))?;
85 clone_repo_from_local(repo_root, &path)?;
86 }
87 } else {
88 clone_repo_from_local(repo_root, &path)?;
89 }
90
91 retarget_origin(&path, &fetch_url, &push_url)?;
92 if let Err(e) = fetch_origin(&path) {
94 log::debug!(
95 "Best-effort git fetch failed (expected in tests/offline): {}",
96 e
97 );
98 }
99 let base_ref = resolve_base_ref(&path, base_branch)?;
100 checkout_branch_from_base(&path, &branch, &base_ref)?;
101 hard_reset_and_clean(&path, &base_ref)?;
102
103 Ok(WorkspaceSpec { path, branch })
104}
105
106#[allow(dead_code)]
120pub(crate) fn ensure_workspace_exists(
121 repo_root: &Path,
122 workspace_path: &Path,
123 branch: &str,
124) -> Result<()> {
125 if workspace_path.exists() {
127 if !workspace_path.join(".git").exists() {
128 fs::remove_dir_all(workspace_path).with_context(|| {
129 format!(
130 "remove invalid workspace (missing .git) {}",
131 workspace_path.display()
132 )
133 })?;
134 clone_repo_from_local(repo_root, workspace_path)?;
135 }
136 } else {
137 fs::create_dir_all(workspace_path.parent().unwrap_or(workspace_path)).with_context(
138 || {
139 format!(
140 "create workspace parent directory {}",
141 workspace_path.display()
142 )
143 },
144 )?;
145 clone_repo_from_local(repo_root, workspace_path)?;
146 }
147
148 let (fetch_url, push_url) = origin_urls(repo_root)?;
150 retarget_origin(workspace_path, &fetch_url, &push_url)?;
151
152 if let Err(e) = fetch_origin(workspace_path) {
154 log::debug!(
155 "Best-effort git fetch failed (expected in tests/offline): {}",
156 e
157 );
158 }
159
160 let remote_ref = format!("origin/{}", branch);
162 checkout_branch_from_base(workspace_path, branch, &remote_ref)?;
163
164 hard_reset_and_clean(workspace_path, &remote_ref)?;
166
167 Ok(())
168}
169
170pub(crate) fn remove_workspace(
171 workspace_root: &Path,
172 spec: &WorkspaceSpec,
173 force: bool,
174) -> Result<()> {
175 if !spec.path.exists() {
176 return Ok(());
177 }
178 if !spec.path.starts_with(workspace_root) {
179 bail!(
180 "workspace path {} is outside root {}",
181 spec.path.display(),
182 workspace_root.display()
183 );
184 }
185 if force {
186 fs::remove_dir_all(&spec.path)
187 .with_context(|| format!("remove workspace {}", spec.path.display()))?;
188 return Ok(());
189 }
190
191 ensure_clean_workspace(&spec.path)?;
192 fs::remove_dir_all(&spec.path)
193 .with_context(|| format!("remove workspace {}", spec.path.display()))
194}
195
196fn clone_repo_from_local(repo_root: &Path, dest: &Path) -> Result<()> {
197 let output = git_base_command(repo_root)
198 .arg("clone")
199 .arg("--no-hardlinks")
200 .arg(".")
201 .arg(dest)
202 .output()
203 .with_context(|| format!("run git clone into {}", dest.display()))?;
204 if !output.status.success() {
205 let stderr = String::from_utf8_lossy(&output.stderr);
206 bail!("git clone failed: {}", stderr.trim());
207 }
208 Ok(())
209}
210
211pub(crate) fn origin_urls(repo_root: &Path) -> Result<(String, String)> {
212 let fetch = remote_url(repo_root, &["remote", "get-url", "origin"])?;
213 let push = remote_url(repo_root, &["remote", "get-url", "--push", "origin"])?;
214
215 match (fetch, push) {
216 (Some(fetch_url), Some(push_url)) => Ok((fetch_url, push_url)),
217 (Some(fetch_url), None) => Ok((fetch_url.clone(), fetch_url)),
218 (None, Some(push_url)) => Ok((push_url.clone(), push_url)),
219 (None, None) => {
220 bail!(
221 "No 'origin' git remote configured (required for parallel mode).\n\
222Parallel workspaces need a pushable `origin` remote to retarget and push branches.\n\
223\n\
224Fix options:\n\
2251) Add origin:\n\
226 git remote add origin <url>\n\
2272) Or disable parallel mode:\n\
228 run without `--parallel` (use the non-parallel run loop)\n"
229 )
230 }
231 }
232}
233
234fn remote_url(repo_root: &Path, args: &[&str]) -> Result<Option<String>> {
235 let output = git_base_command(repo_root)
236 .args(args)
237 .output()
238 .with_context(|| format!("run git {} in {}", args.join(" "), repo_root.display()))?;
239 if !output.status.success() {
240 return Ok(None);
241 }
242 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
243 Ok((!value.is_empty()).then_some(value))
244}
245
246fn retarget_origin(workspace_path: &Path, fetch_url: &str, push_url: &str) -> Result<()> {
247 let output = git_base_command(workspace_path)
248 .arg("remote")
249 .arg("set-url")
250 .arg("origin")
251 .arg(fetch_url.trim())
252 .output()
253 .with_context(|| format!("set origin fetch url in {}", workspace_path.display()))?;
254 if !output.status.success() {
255 let stderr = String::from_utf8_lossy(&output.stderr);
256 bail!("git remote set-url origin failed: {}", stderr.trim());
257 }
258
259 let output = git_base_command(workspace_path)
260 .arg("remote")
261 .arg("set-url")
262 .arg("--push")
263 .arg("origin")
264 .arg(push_url.trim())
265 .output()
266 .with_context(|| format!("set origin push url in {}", workspace_path.display()))?;
267 if !output.status.success() {
268 let stderr = String::from_utf8_lossy(&output.stderr);
269 bail!("git remote set-url --push origin failed: {}", stderr.trim());
270 }
271 Ok(())
272}
273
274fn fetch_origin(workspace_path: &Path) -> Result<()> {
275 let output = git_base_command(workspace_path)
276 .arg("fetch")
277 .arg("origin")
278 .arg("--prune")
279 .output()
280 .with_context(|| format!("run git fetch in {}", workspace_path.display()))?;
281 if !output.status.success() {
282 let stderr = String::from_utf8_lossy(&output.stderr);
283 bail!("git fetch failed: {}", stderr.trim());
284 }
285 Ok(())
286}
287
288fn resolve_base_ref(workspace_path: &Path, base_branch: &str) -> Result<String> {
289 let remote_ref = format!("refs/remotes/origin/{}", base_branch);
290 if git_ref_exists(workspace_path, &remote_ref)? {
291 return Ok(format!("origin/{}", base_branch));
292 }
293 let local_ref = format!("refs/heads/{}", base_branch);
294 if git_ref_exists(workspace_path, &local_ref)? {
295 return Ok(base_branch.to_string());
296 }
297 bail!("base branch '{}' not found in workspace", base_branch);
298}
299
300fn git_ref_exists(repo_root: &Path, full_ref: &str) -> Result<bool> {
301 let output = git_base_command(repo_root)
302 .arg("show-ref")
303 .arg("--verify")
304 .arg("--quiet")
305 .arg(full_ref)
306 .output()
307 .with_context(|| format!("run git show-ref in {}", repo_root.display()))?;
308 if output.status.success() {
309 return Ok(true);
310 }
311 match output.status.code() {
312 Some(1) => Ok(false),
313 _ => {
314 let stderr = String::from_utf8_lossy(&output.stderr);
315 bail!("git show-ref failed: {}", stderr.trim())
316 }
317 }
318}
319
320fn checkout_branch_from_base(workspace_path: &Path, branch: &str, base_ref: &str) -> Result<()> {
321 let output = git_base_command(workspace_path)
322 .arg("checkout")
323 .arg("-B")
324 .arg(branch)
325 .arg(base_ref)
326 .output()
327 .with_context(|| format!("run git checkout -B in {}", workspace_path.display()))?;
328 if !output.status.success() {
329 let stderr = String::from_utf8_lossy(&output.stderr);
330 bail!("git checkout -B failed: {}", stderr.trim());
331 }
332 Ok(())
333}
334
335fn hard_reset_and_clean(workspace_path: &Path, base_ref: &str) -> Result<()> {
336 let output = git_base_command(workspace_path)
337 .arg("reset")
338 .arg("--hard")
339 .arg(base_ref)
340 .output()
341 .with_context(|| format!("run git reset in {}", workspace_path.display()))?;
342 if !output.status.success() {
343 let stderr = String::from_utf8_lossy(&output.stderr);
344 bail!("git reset --hard failed: {}", stderr.trim());
345 }
346
347 let output = git_base_command(workspace_path)
348 .arg("clean")
349 .arg("-fd")
350 .output()
351 .with_context(|| format!("run git clean in {}", workspace_path.display()))?;
352 if !output.status.success() {
353 let stderr = String::from_utf8_lossy(&output.stderr);
354 bail!("git clean failed: {}", stderr.trim());
355 }
356 Ok(())
357}
358
359fn ensure_clean_workspace(workspace_path: &Path) -> Result<()> {
360 let output = git_base_command(workspace_path)
361 .arg("status")
362 .arg("--porcelain")
363 .output()
364 .with_context(|| format!("run git status in {}", workspace_path.display()))?;
365 if !output.status.success() {
366 let stderr = String::from_utf8_lossy(&output.stderr);
367 bail!("git status failed: {}", stderr.trim());
368 }
369 let status = String::from_utf8_lossy(&output.stdout);
370 if !status.trim().is_empty() {
371 bail!("workspace is dirty; use force to remove");
372 }
373 Ok(())
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::contracts::{Config, ParallelConfig};
380 use crate::testsupport::git as git_test;
381 use serial_test::serial;
382 use std::env;
383 use std::sync::Mutex;
384 use tempfile::TempDir;
385
386 static ENV_LOCK: Mutex<()> = Mutex::new(());
388
389 #[test]
390 fn workspace_root_uses_repo_root_for_relative_path() {
391 let cfg = Config {
392 parallel: ParallelConfig {
393 workspace_root: Some(PathBuf::from(".ralph/workspaces/custom")),
394 ..ParallelConfig::default()
395 },
396 ..Config::default()
397 };
398 let repo_root = PathBuf::from("/tmp/ralph-test");
399 let root = workspace_root(&repo_root, &cfg);
400 assert_eq!(
401 root,
402 PathBuf::from("/tmp/ralph-test/.ralph/workspaces/custom")
403 );
404 }
405
406 #[test]
407 fn workspace_root_accepts_absolute_path() {
408 let cfg = Config {
409 parallel: ParallelConfig {
410 workspace_root: Some(PathBuf::from("/tmp/ralph-workspaces")),
411 ..ParallelConfig::default()
412 },
413 ..Config::default()
414 };
415 let repo_root = PathBuf::from("/tmp/ralph-test");
416 let root = workspace_root(&repo_root, &cfg);
417 assert_eq!(root, PathBuf::from("/tmp/ralph-workspaces"));
418 }
419
420 #[test]
421 fn workspace_root_defaults_outside_repo() {
422 let cfg = Config {
423 parallel: ParallelConfig::default(),
424 ..Config::default()
425 };
426 let repo_root = PathBuf::from("/tmp/ralph-test");
427 let root = workspace_root(&repo_root, &cfg);
428 assert_eq!(root, PathBuf::from("/tmp/.workspaces/ralph-test/parallel"));
429 }
430
431 #[test]
432 fn create_and_remove_workspace_round_trips() -> Result<()> {
433 let temp = TempDir::new()?;
434 git_test::init_repo(temp.path())?;
435 std::fs::write(temp.path().join("init.txt"), "init")?;
436 git_test::commit_all(temp.path(), "init")?;
437 git_test::git_run(
438 temp.path(),
439 &["remote", "add", "origin", "https://example.com/repo.git"],
440 )?;
441
442 let base_branch =
443 git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
444 let root = temp.path().join(".ralph/workspaces/parallel");
445
446 let spec = create_workspace_at(temp.path(), &root, "RQ-0001", &base_branch)?;
447 assert!(spec.path.exists(), "workspace path should exist");
448 assert_eq!(spec.branch, base_branch);
449
450 remove_workspace(&root, &spec, true)?;
451 assert!(!spec.path.exists());
452 Ok(())
453 }
454
455 #[test]
456 fn create_workspace_reuses_existing_and_cleans() -> Result<()> {
457 let temp = TempDir::new()?;
458 git_test::init_repo(temp.path())?;
459 std::fs::write(temp.path().join("init.txt"), "init")?;
460 git_test::commit_all(temp.path(), "init")?;
461 git_test::git_run(
462 temp.path(),
463 &["remote", "add", "origin", "https://example.com/repo.git"],
464 )?;
465
466 let base_branch =
467 git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
468 let root = temp.path().join(".ralph/workspaces/parallel");
469
470 let first = create_workspace_at(temp.path(), &root, "RQ-0001", &base_branch)?;
471 std::fs::write(first.path.join("dirty.txt"), "dirty")?;
472
473 let second = create_workspace_at(temp.path(), &root, "RQ-0001", &base_branch)?;
474 assert_eq!(first.path, second.path);
475 assert!(!second.path.join("dirty.txt").exists());
476 assert_eq!(second.branch, base_branch);
477
478 remove_workspace(&root, &second, true)?;
479 Ok(())
480 }
481
482 #[test]
483 fn create_workspace_with_existing_branch() -> Result<()> {
484 let temp = TempDir::new()?;
485 git_test::init_repo(temp.path())?;
486 std::fs::write(temp.path().join("init.txt"), "init")?;
487 git_test::commit_all(temp.path(), "init")?;
488 git_test::git_run(
489 temp.path(),
490 &["remote", "add", "origin", "https://example.com/repo.git"],
491 )?;
492 let base_branch =
493 git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
494 let root = temp.path().join(".ralph/workspaces/parallel");
495
496 let spec = create_workspace_at(temp.path(), &root, "RQ-0002", &base_branch)?;
497 assert!(spec.path.exists());
498 assert_eq!(spec.branch, base_branch);
499
500 remove_workspace(&root, &spec, true)?;
501 Ok(())
502 }
503
504 #[test]
505 fn create_workspace_requires_origin_remote() -> Result<()> {
506 let temp = TempDir::new()?;
507 git_test::init_repo(temp.path())?;
508 std::fs::write(temp.path().join("init.txt"), "init")?;
509 git_test::commit_all(temp.path(), "init")?;
510
511 let base_branch =
512 git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
513 let root = temp.path().join(".ralph/workspaces/parallel");
514
515 let err = create_workspace_at(temp.path(), &root, "RQ-0003", &base_branch)
516 .expect_err("missing origin should fail");
517 assert!(err.to_string().contains("origin"));
518 Ok(())
519 }
520
521 #[test]
522 fn remove_workspace_requires_force_when_dirty() -> Result<()> {
523 let temp = TempDir::new()?;
524 git_test::init_repo(temp.path())?;
525 std::fs::write(temp.path().join("init.txt"), "init")?;
526 git_test::commit_all(temp.path(), "init")?;
527 git_test::git_run(
528 temp.path(),
529 &["remote", "add", "origin", "https://example.com/repo.git"],
530 )?;
531
532 let base_branch =
533 git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
534 let root = temp.path().join(".ralph/workspaces/parallel");
535
536 let spec = create_workspace_at(temp.path(), &root, "RQ-0004", &base_branch)?;
537 std::fs::write(spec.path.join("dirty.txt"), "dirty")?;
538 let err = remove_workspace(&root, &spec, false).expect_err("dirty should fail");
539 assert!(err.to_string().contains("dirty"));
540 assert!(spec.path.exists());
541
542 remove_workspace(&root, &spec, true)?;
543 Ok(())
544 }
545
546 #[test]
547 fn ensure_workspace_exists_creates_missing_workspace() -> Result<()> {
548 let temp = TempDir::new()?;
549 git_test::init_repo(temp.path())?;
550 std::fs::write(temp.path().join("init.txt"), "init")?;
551 git_test::commit_all(temp.path(), "init")?;
552 git_test::git_run(
553 temp.path(),
554 &["remote", "add", "origin", "https://example.com/repo.git"],
555 )?;
556
557 let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
558 let workspace_path = temp.path().join("workspaces/RQ-0001");
559
560 ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
561
562 assert!(workspace_path.exists(), "workspace path should exist");
563 assert!(
564 workspace_path.join(".git").exists(),
565 "workspace should be a git repo"
566 );
567
568 let current_branch =
570 git_test::git_output(&workspace_path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
571 assert_eq!(current_branch, branch);
572
573 Ok(())
574 }
575
576 #[test]
577 fn ensure_workspace_exists_reuses_existing_and_cleans() -> Result<()> {
578 let temp = TempDir::new()?;
579 git_test::init_repo(temp.path())?;
580 std::fs::write(temp.path().join("init.txt"), "init")?;
581 git_test::commit_all(temp.path(), "init")?;
582 git_test::git_run(
583 temp.path(),
584 &["remote", "add", "origin", "https://example.com/repo.git"],
585 )?;
586
587 let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
588 let workspace_path = temp.path().join("workspaces/RQ-0001");
589
590 ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
592
593 std::fs::write(workspace_path.join("dirty.txt"), "dirty")?;
595 std::fs::create_dir_all(workspace_path.join("untracked_dir"))?;
596 std::fs::write(workspace_path.join("untracked_dir/file.txt"), "untracked")?;
597
598 ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
600
601 assert!(
602 !workspace_path.join("dirty.txt").exists(),
603 "dirty file should be cleaned"
604 );
605 assert!(
606 !workspace_path.join("untracked_dir").exists(),
607 "untracked dir should be cleaned"
608 );
609
610 Ok(())
611 }
612
613 #[test]
614 fn ensure_workspace_exists_replaces_invalid_workspace() -> Result<()> {
615 let temp = TempDir::new()?;
616 git_test::init_repo(temp.path())?;
617 std::fs::write(temp.path().join("init.txt"), "init")?;
618 git_test::commit_all(temp.path(), "init")?;
619 git_test::git_run(
620 temp.path(),
621 &["remote", "add", "origin", "https://example.com/repo.git"],
622 )?;
623
624 let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
625 let workspace_path = temp.path().join("workspaces/RQ-0001");
626
627 std::fs::create_dir_all(&workspace_path)?;
629 std::fs::write(workspace_path.join("some_file.txt"), "content")?;
630
631 ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
632
633 assert!(
634 workspace_path.join(".git").exists(),
635 "workspace should be a valid git repo"
636 );
637 assert!(
638 !workspace_path.join("some_file.txt").exists(),
639 "old file should be gone"
640 );
641
642 Ok(())
643 }
644
645 #[test]
646 fn ensure_workspace_exists_fails_without_origin() -> Result<()> {
647 let temp = TempDir::new()?;
648 git_test::init_repo(temp.path())?;
649 std::fs::write(temp.path().join("init.txt"), "init")?;
650 git_test::commit_all(temp.path(), "init")?;
651 let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
654 let workspace_path = temp.path().join("workspaces/RQ-0001");
655
656 let err = ensure_workspace_exists(temp.path(), &workspace_path, &branch)
657 .expect_err("should fail without origin");
658 assert!(err.to_string().contains("origin"));
659
660 Ok(())
661 }
662
663 #[test]
664 #[serial]
665 fn workspace_root_expands_tilde_to_home() {
666 let _guard = ENV_LOCK.lock().expect("env lock");
667 let original_home = env::var("HOME").ok();
668
669 unsafe { env::set_var("HOME", "/custom/home") };
670
671 let cfg = Config {
672 parallel: ParallelConfig {
673 workspace_root: Some(PathBuf::from("~/ralph-workspaces")),
674 ..ParallelConfig::default()
675 },
676 ..Config::default()
677 };
678 let repo_root = PathBuf::from("/tmp/ralph-test");
679 let root = workspace_root(&repo_root, &cfg);
680 assert_eq!(root, PathBuf::from("/custom/home/ralph-workspaces"));
681
682 match original_home {
684 Some(v) => unsafe { env::set_var("HOME", v) },
685 None => unsafe { env::remove_var("HOME") },
686 }
687 }
688
689 #[test]
690 #[serial]
691 fn workspace_root_expands_tilde_alone_to_home() {
692 let _guard = ENV_LOCK.lock().expect("env lock");
693 let original_home = env::var("HOME").ok();
694
695 unsafe { env::set_var("HOME", "/custom/home") };
696
697 let cfg = Config {
698 parallel: ParallelConfig {
699 workspace_root: Some(PathBuf::from("~")),
700 ..ParallelConfig::default()
701 },
702 ..Config::default()
703 };
704 let repo_root = PathBuf::from("/tmp/ralph-test");
705 let root = workspace_root(&repo_root, &cfg);
706 assert_eq!(root, PathBuf::from("/custom/home"));
707
708 match original_home {
710 Some(v) => unsafe { env::set_var("HOME", v) },
711 None => unsafe { env::remove_var("HOME") },
712 }
713 }
714
715 #[test]
716 #[serial]
717 fn workspace_root_relative_when_home_unset() {
718 let _guard = ENV_LOCK.lock().expect("env lock");
719 let original_home = env::var("HOME").ok();
720
721 unsafe { env::remove_var("HOME") };
723
724 let cfg = Config {
725 parallel: ParallelConfig {
726 workspace_root: Some(PathBuf::from("~/workspaces")),
727 ..ParallelConfig::default()
728 },
729 ..Config::default()
730 };
731 let repo_root = PathBuf::from("/tmp/ralph-test");
732 let root = workspace_root(&repo_root, &cfg);
733 assert_eq!(root, PathBuf::from("/tmp/ralph-test/~/workspaces"));
735
736 if let Some(v) = original_home {
738 unsafe { env::set_var("HOME", v) }
739 }
740 }
741}