1use 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 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 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 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 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 ensure_workspace_exists(temp.path(), &workspace_path, &branch)?;
557
558 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 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 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 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 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 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 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 assert_eq!(root, PathBuf::from("/tmp/ralph-test/~/workspaces"));
700
701 if let Some(v) = original_home {
703 unsafe { env::set_var("HOME", v) }
704 }
705 }
706}