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_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    // 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 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    // Global lock for environment variable tests
387    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        // Verify we're on the correct branch
569        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        // First call creates the workspace
591        ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
592
593        // Add some dirty files
594        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        // Second call should clean up
599        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        // Create a non-git directory (invalid workspace)
628        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        // Note: no origin remote added
652
653        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        // Restore HOME
683        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        // Restore HOME
709        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        // Remove HOME - tilde should not expand
722        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        // When HOME is unset, ~/workspaces is treated as relative to repo_root
734        assert_eq!(root, PathBuf::from("/tmp/ralph-test/~/workspaces"));
735
736        // Restore HOME
737        if let Some(v) = original_home {
738            unsafe { env::set_var("HOME", v) }
739        }
740    }
741}