Skip to main content

ralph/git/
workspace.rs

1//! Git workspace helpers for parallel task isolation (clone-based).
2//!
3//! Responsibilities:
4//! - Create and remove isolated git workspaces for parallel task execution.
5//! - Compute the workspace root path using resolved configuration.
6//! - Ensure clones are pushable by resolving the real origin remote.
7//!
8//! Not handled here:
9//! - Task selection or worker orchestration (see `commands::run::parallel`).
10//! - PR creation or merge operations (see `git::pr`).
11//! - Integration conflict resolution policy (see `commands::run::parallel::integration`).
12//!
13//! Invariants/assumptions:
14//! - `git` is available and the repo root is valid.
15//! - Workspace paths are unique per task ID.
16//! - Clones must have a pushable `origin` remote.
17
18use crate::contracts::Config;
19use crate::fsutil;
20use crate::git::error::git_output;
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    // Fetch is best-effort: local clone already has refs, and remote may not be reachable in tests.
93    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/// Ensures a workspace exists and is properly configured for the given branch.
107///
108/// If the workspace exists and is a valid git clone (`.git` exists), it is reused.
109/// If the workspace exists but is invalid (not a directory / missing `.git`), it is deleted.
110/// If missing, it is cloned from `repo_root` (local clone).
111///
112/// After ensuring the workspace exists:
113/// - Retarget `origin` fetch/push URLs to match `repo_root`'s origin (pushable required).
114/// - Fetch `origin --prune`.
115/// - Checkout/reset branch to remote: `checkout -B <branch> origin/<branch>`.
116/// - Hard reset + clean to ensure deterministic working tree.
117///
118/// Note: Kept for legacy callers that need a branch-specific workspace reset helper.
119#[allow(dead_code)]
120pub(crate) fn ensure_workspace_exists(
121    repo_root: &Path,
122    workspace_path: &Path,
123    branch: &str,
124) -> Result<()> {
125    // Validate or create workspace
126    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    // Retarget origin to be pushable
149    let (fetch_url, push_url) = origin_urls(repo_root)?;
150    retarget_origin(workspace_path, &fetch_url, &push_url)?;
151
152    // Fetch origin --prune (best-effort)
153    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    // Checkout branch from origin
161    let remote_ref = format!("origin/{}", branch);
162    checkout_branch_from_base(workspace_path, branch, &remote_ref)?;
163
164    // Hard reset and clean
165    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 dest_owned = dest.to_string_lossy().into_owned();
198    let output = git_output(repo_root, &["clone", "--no-hardlinks", ".", &dest_owned])
199        .with_context(|| format!("run git clone into {}", dest.display()))?;
200    if !output.status.success() {
201        let stderr = String::from_utf8_lossy(&output.stderr);
202        bail!("git clone failed: {}", stderr.trim());
203    }
204    Ok(())
205}
206
207pub(crate) fn origin_urls(repo_root: &Path) -> Result<(String, String)> {
208    let fetch = remote_url(repo_root, &["remote", "get-url", "origin"])?;
209    let push = remote_url(repo_root, &["remote", "get-url", "--push", "origin"])?;
210
211    match (fetch, push) {
212        (Some(fetch_url), Some(push_url)) => Ok((fetch_url, push_url)),
213        (Some(fetch_url), None) => Ok((fetch_url.clone(), fetch_url)),
214        (None, Some(push_url)) => Ok((push_url.clone(), push_url)),
215        (None, None) => {
216            bail!(
217                "No 'origin' git remote configured (required for parallel mode).\n\
218Parallel workspaces need a pushable `origin` remote to retarget and push branches.\n\
219\n\
220Fix options:\n\
2211) Add origin:\n\
222   git remote add origin <url>\n\
2232) Or disable parallel mode:\n\
224   run without `--parallel` (use the non-parallel run loop)\n"
225            )
226        }
227    }
228}
229
230fn remote_url(repo_root: &Path, args: &[&str]) -> Result<Option<String>> {
231    let output = git_output(repo_root, args)
232        .with_context(|| format!("run git {} in {}", args.join(" "), repo_root.display()))?;
233    if !output.status.success() {
234        return Ok(None);
235    }
236    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
237    Ok((!value.is_empty()).then_some(value))
238}
239
240fn retarget_origin(workspace_path: &Path, fetch_url: &str, push_url: &str) -> Result<()> {
241    let output = git_output(
242        workspace_path,
243        &["remote", "set-url", "origin", fetch_url.trim()],
244    )
245    .with_context(|| format!("set origin fetch url in {}", workspace_path.display()))?;
246    if !output.status.success() {
247        let stderr = String::from_utf8_lossy(&output.stderr);
248        bail!("git remote set-url origin failed: {}", stderr.trim());
249    }
250
251    let output = git_output(
252        workspace_path,
253        &["remote", "set-url", "--push", "origin", push_url.trim()],
254    )
255    .with_context(|| format!("set origin push url in {}", workspace_path.display()))?;
256    if !output.status.success() {
257        let stderr = String::from_utf8_lossy(&output.stderr);
258        bail!("git remote set-url --push origin failed: {}", stderr.trim());
259    }
260    Ok(())
261}
262
263fn fetch_origin(workspace_path: &Path) -> Result<()> {
264    let output = git_output(workspace_path, &["fetch", "origin", "--prune"])
265        .with_context(|| format!("run git fetch in {}", workspace_path.display()))?;
266    if !output.status.success() {
267        let stderr = String::from_utf8_lossy(&output.stderr);
268        bail!("git fetch failed: {}", stderr.trim());
269    }
270    Ok(())
271}
272
273fn resolve_base_ref(workspace_path: &Path, base_branch: &str) -> Result<String> {
274    let remote_ref = format!("refs/remotes/origin/{}", base_branch);
275    if git_ref_exists(workspace_path, &remote_ref)? {
276        return Ok(format!("origin/{}", base_branch));
277    }
278    let local_ref = format!("refs/heads/{}", base_branch);
279    if git_ref_exists(workspace_path, &local_ref)? {
280        return Ok(base_branch.to_string());
281    }
282    bail!("base branch '{}' not found in workspace", base_branch);
283}
284
285fn git_ref_exists(repo_root: &Path, full_ref: &str) -> Result<bool> {
286    let output = git_output(repo_root, &["show-ref", "--verify", "--quiet", full_ref])
287        .with_context(|| format!("run git show-ref in {}", repo_root.display()))?;
288    if output.status.success() {
289        return Ok(true);
290    }
291    match output.status.code() {
292        Some(1) => Ok(false),
293        _ => {
294            let stderr = String::from_utf8_lossy(&output.stderr);
295            bail!("git show-ref failed: {}", stderr.trim())
296        }
297    }
298}
299
300fn checkout_branch_from_base(workspace_path: &Path, branch: &str, base_ref: &str) -> Result<()> {
301    let output = git_output(workspace_path, &["checkout", "-B", branch, base_ref])
302        .with_context(|| format!("run git checkout -B in {}", workspace_path.display()))?;
303    if !output.status.success() {
304        let stderr = String::from_utf8_lossy(&output.stderr);
305        bail!("git checkout -B failed: {}", stderr.trim());
306    }
307    Ok(())
308}
309
310fn hard_reset_and_clean(workspace_path: &Path, base_ref: &str) -> Result<()> {
311    let output = git_output(workspace_path, &["reset", "--hard", base_ref])
312        .with_context(|| format!("run git reset in {}", workspace_path.display()))?;
313    if !output.status.success() {
314        let stderr = String::from_utf8_lossy(&output.stderr);
315        bail!("git reset --hard failed: {}", stderr.trim());
316    }
317
318    let output = git_output(workspace_path, &["clean", "-fd"])
319        .with_context(|| format!("run git clean in {}", workspace_path.display()))?;
320    if !output.status.success() {
321        let stderr = String::from_utf8_lossy(&output.stderr);
322        bail!("git clean failed: {}", stderr.trim());
323    }
324    Ok(())
325}
326
327fn ensure_clean_workspace(workspace_path: &Path) -> Result<()> {
328    let output = git_output(workspace_path, &["status", "--porcelain"])
329        .with_context(|| format!("run git status in {}", workspace_path.display()))?;
330    if !output.status.success() {
331        let stderr = String::from_utf8_lossy(&output.stderr);
332        bail!("git status failed: {}", stderr.trim());
333    }
334    let status = String::from_utf8_lossy(&output.stdout);
335    if !status.trim().is_empty() {
336        bail!("workspace is dirty; use force to remove");
337    }
338    Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::contracts::{Config, ParallelConfig};
345    use crate::testsupport::git as git_test;
346    use serial_test::serial;
347    use std::env;
348    use std::sync::Mutex;
349    use tempfile::TempDir;
350
351    // Global lock for environment variable tests
352    static ENV_LOCK: Mutex<()> = Mutex::new(());
353
354    #[test]
355    fn workspace_root_uses_repo_root_for_relative_path() {
356        let cfg = Config {
357            parallel: ParallelConfig {
358                workspace_root: Some(PathBuf::from(".ralph/workspaces/custom")),
359                ..ParallelConfig::default()
360            },
361            ..Config::default()
362        };
363        let repo_root = PathBuf::from("/tmp/ralph-test");
364        let root = workspace_root(&repo_root, &cfg);
365        assert_eq!(
366            root,
367            PathBuf::from("/tmp/ralph-test/.ralph/workspaces/custom")
368        );
369    }
370
371    #[test]
372    fn workspace_root_accepts_absolute_path() {
373        let cfg = Config {
374            parallel: ParallelConfig {
375                workspace_root: Some(PathBuf::from("/tmp/ralph-workspaces")),
376                ..ParallelConfig::default()
377            },
378            ..Config::default()
379        };
380        let repo_root = PathBuf::from("/tmp/ralph-test");
381        let root = workspace_root(&repo_root, &cfg);
382        assert_eq!(root, PathBuf::from("/tmp/ralph-workspaces"));
383    }
384
385    #[test]
386    fn workspace_root_defaults_outside_repo() {
387        let cfg = Config {
388            parallel: ParallelConfig::default(),
389            ..Config::default()
390        };
391        let repo_root = PathBuf::from("/tmp/ralph-test");
392        let root = workspace_root(&repo_root, &cfg);
393        assert_eq!(root, PathBuf::from("/tmp/.workspaces/ralph-test/parallel"));
394    }
395
396    #[test]
397    fn create_and_remove_workspace_round_trips() -> Result<()> {
398        let temp = TempDir::new()?;
399        git_test::init_repo(temp.path())?;
400        std::fs::write(temp.path().join("init.txt"), "init")?;
401        git_test::commit_all(temp.path(), "init")?;
402        git_test::git_run(
403            temp.path(),
404            &["remote", "add", "origin", "https://example.com/repo.git"],
405        )?;
406
407        let base_branch =
408            git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
409        let root = temp.path().join(".ralph/workspaces/parallel");
410
411        let spec = create_workspace_at(temp.path(), &root, "RQ-0001", &base_branch)?;
412        assert!(spec.path.exists(), "workspace path should exist");
413        assert_eq!(spec.branch, base_branch);
414
415        remove_workspace(&root, &spec, true)?;
416        assert!(!spec.path.exists());
417        Ok(())
418    }
419
420    #[test]
421    fn create_workspace_reuses_existing_and_cleans() -> Result<()> {
422        let temp = TempDir::new()?;
423        git_test::init_repo(temp.path())?;
424        std::fs::write(temp.path().join("init.txt"), "init")?;
425        git_test::commit_all(temp.path(), "init")?;
426        git_test::git_run(
427            temp.path(),
428            &["remote", "add", "origin", "https://example.com/repo.git"],
429        )?;
430
431        let base_branch =
432            git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
433        let root = temp.path().join(".ralph/workspaces/parallel");
434
435        let first = create_workspace_at(temp.path(), &root, "RQ-0001", &base_branch)?;
436        std::fs::write(first.path.join("dirty.txt"), "dirty")?;
437
438        let second = create_workspace_at(temp.path(), &root, "RQ-0001", &base_branch)?;
439        assert_eq!(first.path, second.path);
440        assert!(!second.path.join("dirty.txt").exists());
441        assert_eq!(second.branch, base_branch);
442
443        remove_workspace(&root, &second, true)?;
444        Ok(())
445    }
446
447    #[test]
448    fn create_workspace_with_existing_branch() -> Result<()> {
449        let temp = TempDir::new()?;
450        git_test::init_repo(temp.path())?;
451        std::fs::write(temp.path().join("init.txt"), "init")?;
452        git_test::commit_all(temp.path(), "init")?;
453        git_test::git_run(
454            temp.path(),
455            &["remote", "add", "origin", "https://example.com/repo.git"],
456        )?;
457        let base_branch =
458            git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
459        let root = temp.path().join(".ralph/workspaces/parallel");
460
461        let spec = create_workspace_at(temp.path(), &root, "RQ-0002", &base_branch)?;
462        assert!(spec.path.exists());
463        assert_eq!(spec.branch, base_branch);
464
465        remove_workspace(&root, &spec, true)?;
466        Ok(())
467    }
468
469    #[test]
470    fn create_workspace_requires_origin_remote() -> Result<()> {
471        let temp = TempDir::new()?;
472        git_test::init_repo(temp.path())?;
473        std::fs::write(temp.path().join("init.txt"), "init")?;
474        git_test::commit_all(temp.path(), "init")?;
475
476        let base_branch =
477            git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
478        let root = temp.path().join(".ralph/workspaces/parallel");
479
480        let err = create_workspace_at(temp.path(), &root, "RQ-0003", &base_branch)
481            .expect_err("missing origin should fail");
482        assert!(err.to_string().contains("origin"));
483        Ok(())
484    }
485
486    #[test]
487    fn remove_workspace_requires_force_when_dirty() -> Result<()> {
488        let temp = TempDir::new()?;
489        git_test::init_repo(temp.path())?;
490        std::fs::write(temp.path().join("init.txt"), "init")?;
491        git_test::commit_all(temp.path(), "init")?;
492        git_test::git_run(
493            temp.path(),
494            &["remote", "add", "origin", "https://example.com/repo.git"],
495        )?;
496
497        let base_branch =
498            git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
499        let root = temp.path().join(".ralph/workspaces/parallel");
500
501        let spec = create_workspace_at(temp.path(), &root, "RQ-0004", &base_branch)?;
502        std::fs::write(spec.path.join("dirty.txt"), "dirty")?;
503        let err = remove_workspace(&root, &spec, false).expect_err("dirty should fail");
504        assert!(err.to_string().contains("dirty"));
505        assert!(spec.path.exists());
506
507        remove_workspace(&root, &spec, true)?;
508        Ok(())
509    }
510
511    #[test]
512    fn ensure_workspace_exists_creates_missing_workspace() -> Result<()> {
513        let temp = TempDir::new()?;
514        git_test::init_repo(temp.path())?;
515        std::fs::write(temp.path().join("init.txt"), "init")?;
516        git_test::commit_all(temp.path(), "init")?;
517        git_test::git_run(
518            temp.path(),
519            &["remote", "add", "origin", "https://example.com/repo.git"],
520        )?;
521
522        let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
523        let workspace_path = temp.path().join("workspaces/RQ-0001");
524
525        ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
526
527        assert!(workspace_path.exists(), "workspace path should exist");
528        assert!(
529            workspace_path.join(".git").exists(),
530            "workspace should be a git repo"
531        );
532
533        // Verify we're on the correct branch
534        let current_branch =
535            git_test::git_output(&workspace_path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
536        assert_eq!(current_branch, branch);
537
538        Ok(())
539    }
540
541    #[test]
542    fn ensure_workspace_exists_reuses_existing_and_cleans() -> Result<()> {
543        let temp = TempDir::new()?;
544        git_test::init_repo(temp.path())?;
545        std::fs::write(temp.path().join("init.txt"), "init")?;
546        git_test::commit_all(temp.path(), "init")?;
547        git_test::git_run(
548            temp.path(),
549            &["remote", "add", "origin", "https://example.com/repo.git"],
550        )?;
551
552        let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
553        let workspace_path = temp.path().join("workspaces/RQ-0001");
554
555        // First call creates the workspace
556        ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
557
558        // Add some dirty files
559        std::fs::write(workspace_path.join("dirty.txt"), "dirty")?;
560        std::fs::create_dir_all(workspace_path.join("untracked_dir"))?;
561        std::fs::write(workspace_path.join("untracked_dir/file.txt"), "untracked")?;
562
563        // Second call should clean up
564        ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
565
566        assert!(
567            !workspace_path.join("dirty.txt").exists(),
568            "dirty file should be cleaned"
569        );
570        assert!(
571            !workspace_path.join("untracked_dir").exists(),
572            "untracked dir should be cleaned"
573        );
574
575        Ok(())
576    }
577
578    #[test]
579    fn ensure_workspace_exists_replaces_invalid_workspace() -> Result<()> {
580        let temp = TempDir::new()?;
581        git_test::init_repo(temp.path())?;
582        std::fs::write(temp.path().join("init.txt"), "init")?;
583        git_test::commit_all(temp.path(), "init")?;
584        git_test::git_run(
585            temp.path(),
586            &["remote", "add", "origin", "https://example.com/repo.git"],
587        )?;
588
589        let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
590        let workspace_path = temp.path().join("workspaces/RQ-0001");
591
592        // Create a non-git directory (invalid workspace)
593        std::fs::create_dir_all(&workspace_path)?;
594        std::fs::write(workspace_path.join("some_file.txt"), "content")?;
595
596        ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
597
598        assert!(
599            workspace_path.join(".git").exists(),
600            "workspace should be a valid git repo"
601        );
602        assert!(
603            !workspace_path.join("some_file.txt").exists(),
604            "old file should be gone"
605        );
606
607        Ok(())
608    }
609
610    #[test]
611    fn ensure_workspace_exists_fails_without_origin() -> Result<()> {
612        let temp = TempDir::new()?;
613        git_test::init_repo(temp.path())?;
614        std::fs::write(temp.path().join("init.txt"), "init")?;
615        git_test::commit_all(temp.path(), "init")?;
616        // Note: no origin remote added
617
618        let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
619        let workspace_path = temp.path().join("workspaces/RQ-0001");
620
621        let err = ensure_workspace_exists(temp.path(), &workspace_path, &branch)
622            .expect_err("should fail without origin");
623        assert!(err.to_string().contains("origin"));
624
625        Ok(())
626    }
627
628    #[test]
629    #[serial]
630    fn workspace_root_expands_tilde_to_home() {
631        let _guard = ENV_LOCK.lock().expect("env lock");
632        let original_home = env::var("HOME").ok();
633
634        unsafe { env::set_var("HOME", "/custom/home") };
635
636        let cfg = Config {
637            parallel: ParallelConfig {
638                workspace_root: Some(PathBuf::from("~/ralph-workspaces")),
639                ..ParallelConfig::default()
640            },
641            ..Config::default()
642        };
643        let repo_root = PathBuf::from("/tmp/ralph-test");
644        let root = workspace_root(&repo_root, &cfg);
645        assert_eq!(root, PathBuf::from("/custom/home/ralph-workspaces"));
646
647        // Restore HOME
648        match original_home {
649            Some(v) => unsafe { env::set_var("HOME", v) },
650            None => unsafe { env::remove_var("HOME") },
651        }
652    }
653
654    #[test]
655    #[serial]
656    fn workspace_root_expands_tilde_alone_to_home() {
657        let _guard = ENV_LOCK.lock().expect("env lock");
658        let original_home = env::var("HOME").ok();
659
660        unsafe { env::set_var("HOME", "/custom/home") };
661
662        let cfg = Config {
663            parallel: ParallelConfig {
664                workspace_root: Some(PathBuf::from("~")),
665                ..ParallelConfig::default()
666            },
667            ..Config::default()
668        };
669        let repo_root = PathBuf::from("/tmp/ralph-test");
670        let root = workspace_root(&repo_root, &cfg);
671        assert_eq!(root, PathBuf::from("/custom/home"));
672
673        // Restore HOME
674        match original_home {
675            Some(v) => unsafe { env::set_var("HOME", v) },
676            None => unsafe { env::remove_var("HOME") },
677        }
678    }
679
680    #[test]
681    #[serial]
682    fn workspace_root_relative_when_home_unset() {
683        let _guard = ENV_LOCK.lock().expect("env lock");
684        let original_home = env::var("HOME").ok();
685
686        // Remove HOME - tilde should not expand
687        unsafe { env::remove_var("HOME") };
688
689        let cfg = Config {
690            parallel: ParallelConfig {
691                workspace_root: Some(PathBuf::from("~/workspaces")),
692                ..ParallelConfig::default()
693            },
694            ..Config::default()
695        };
696        let repo_root = PathBuf::from("/tmp/ralph-test");
697        let root = workspace_root(&repo_root, &cfg);
698        // When HOME is unset, ~/workspaces is treated as relative to repo_root
699        assert_eq!(root, PathBuf::from("/tmp/ralph-test/~/workspaces"));
700
701        // Restore HOME
702        if let Some(v) = original_home {
703            unsafe { env::set_var("HOME", v) }
704        }
705    }
706}