1use crate::cmd::CommandErrorContext;
7use crate::store;
8use crate::store::TaskStore;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use tokio::process::Command;
12
13#[derive(Debug)]
15pub struct WorktreeSetup {
16 pub work_dir: PathBuf,
18 pub branch: String,
20 pub default_branch: String,
22 pub main_project_dir: PathBuf,
24}
25
26pub fn project_name(project_dir: &Path) -> String {
28 project_dir
29 .file_name()
30 .map_or_else(
31 || "project".to_string(),
32 |s| s.to_string_lossy().to_string(),
33 )
34 .trim_end_matches(".git")
35 .to_string()
36}
37
38pub fn project_worktrees_dir(project_dir: &Path) -> PathBuf {
40 crate::home::worktrees_dir()
41 .unwrap_or_default()
42 .join(project_name(project_dir))
43}
44
45pub async fn list_project_worktrees(project_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
47 let base = project_worktrees_dir(project_dir);
48 let mut worktrees = Vec::new();
49
50 if tokio::fs::metadata(&base).await.is_err() {
51 return Ok(worktrees);
52 }
53
54 let mut entries = tokio::fs::read_dir(&base).await?;
55 while let Some(entry) = entries.next_entry().await? {
56 if entry.metadata().await.is_ok_and(|m| m.is_dir()) {
57 worktrees.push(entry.path());
58 }
59 }
60
61 Ok(worktrees)
62}
63
64pub fn task_id_from_worktree_name(name: &str) -> Option<String> {
66 if let Some(rest) = name.strip_prefix("internal-") {
67 let num = rest.split('-').next()?;
68 return num.parse::<u64>().ok().map(|n| format!("internal:{n}"));
69 }
70
71 for prefix in ["gh-issue-", "gh-task-"] {
72 if let Some(rest) = name.strip_prefix(prefix) {
73 let task_part = rest.split('-').next()?;
74 if task_part == "internal" {
75 let num = rest.split('-').nth(1)?;
76 return num.parse::<u64>().ok().map(|n| format!("internal:{n}"));
77 }
78 return Some(task_part.to_string());
79 }
80 }
81
82 None
83}
84
85pub async fn abort_worktree_rebase(worktree_dir: &Path) {
87 let _ = Command::new("git")
88 .args(["rebase", "--abort"])
89 .current_dir(worktree_dir)
90 .output_with_context()
91 .await;
92}
93
94pub async fn rebase_worktree_on_origin_main(
100 worktree_dir: &Path,
101 default_branch: &str,
102) -> anyhow::Result<()> {
103 let origin_branch = format!("origin/{default_branch}");
104
105 match crate::engine::runner::git_ops::stash_rebase_restore(
106 worktree_dir,
107 &origin_branch,
108 crate::engine::runner::git_ops::StashRebaseOpts::default(),
109 )
110 .await?
111 {
112 crate::engine::runner::git_ops::RebaseOutcome::Succeeded
113 | crate::engine::runner::git_ops::RebaseOutcome::Skipped(_) => Ok(()),
114 crate::engine::runner::git_ops::RebaseOutcome::Failed(e) => {
115 anyhow::bail!("git rebase {origin_branch} failed: {e}")
116 }
117 }
118}
119
120pub fn branch_name(task_id: &str, title: &str) -> String {
125 let (prefix, sanitized_id) = if let Some(num) = task_id.strip_prefix("internal:") {
126 ("internal", num.to_string())
127 } else {
128 ("gh-issue", sanitize_task_id(task_id))
129 };
130
131 let raw: String = title
132 .to_lowercase()
133 .chars()
134 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
135 .collect();
136
137 let mut slug = String::with_capacity(raw.len());
139 let mut prev_dash = false;
140 for c in raw.chars() {
141 if c == '-' {
142 if !prev_dash {
143 slug.push('-');
144 }
145 prev_dash = true;
146 } else {
147 slug.push(c);
148 prev_dash = false;
149 }
150 }
151 let slug = slug.trim_matches('-').to_string();
152
153 let slug = if slug.len() > 40 { &slug[..40] } else { &slug };
155 let slug = slug.trim_end_matches('-');
156
157 if slug.is_empty() {
158 format!("{prefix}-{sanitized_id}")
159 } else {
160 format!("{prefix}-{sanitized_id}-{slug}")
161 }
162}
163
164fn sanitize_task_id(task_id: &str) -> String {
166 task_id.replace(':', "-")
167}
168
169fn normalize_branch_name(raw: &str) -> Option<String> {
178 let branch = raw.trim();
179 if branch.is_empty() {
180 return None;
181 }
182
183 let cleaned = branch
185 .strip_prefix("refs/remotes/origin/")
186 .or_else(|| branch.strip_prefix("remotes/origin/"))
187 .or_else(|| branch.strip_prefix("refs/heads/"))
188 .or_else(|| branch.strip_prefix("origin/"))
189 .unwrap_or(branch);
190
191 if cleaned.is_empty() {
192 return None;
193 }
194 Some(cleaned.to_string())
195}
196
197pub async fn detect_default_branch(project_dir: &Path) -> String {
202 let symref = Command::new("git")
204 .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
205 .current_dir(project_dir)
206 .output_with_context()
207 .await;
208
209 if let Ok(o) = &symref
210 && o.status.success()
211 {
212 let raw = String::from_utf8_lossy(&o.stdout);
213 if let Some(branch) = normalize_branch_name(&raw) {
214 return branch;
215 }
216 }
217
218 let revparse = Command::new("git")
220 .args(["rev-parse", "--abbrev-ref", "refs/remotes/origin/HEAD"])
221 .current_dir(project_dir)
222 .output_with_context()
223 .await;
224
225 if let Ok(o) = &revparse
226 && o.status.success()
227 {
228 let raw = String::from_utf8_lossy(&o.stdout);
229 if let Some(branch) = normalize_branch_name(&raw) {
230 return branch;
231 }
232 }
233
234 let remote_show = Command::new("git")
236 .args(["remote", "show", "origin"])
237 .current_dir(project_dir)
238 .output_with_context()
239 .await;
240
241 if let Ok(o) = &remote_show
242 && o.status.success()
243 {
244 let stdout = String::from_utf8_lossy(&o.stdout);
245 for line in stdout.lines() {
246 let trimmed = line.trim();
247 if trimmed.starts_with("HEAD branch:")
248 && let Some(branch) = trimmed.split(':').nth(1)
249 {
250 let branch = branch.trim();
251 if !branch.is_empty() && branch != "(unknown)" {
252 return branch.to_string();
253 }
254 }
255 }
256 }
257
258 tracing::warn!(
259 project_dir = %project_dir.display(),
260 "could not detect default branch from git, falling back to 'main'"
261 );
262 "main".to_string()
263}
264
265pub async fn resolve_main_repo(project_dir: &Path) -> PathBuf {
269 let output = Command::new("git")
270 .args(["worktree", "list", "--porcelain"])
271 .current_dir(project_dir)
272 .output_with_context()
273 .await;
274
275 if let Ok(o) = output
276 && o.status.success()
277 {
278 let stdout = String::from_utf8_lossy(&o.stdout);
279 if let Some(first_line) = stdout.lines().next()
280 && let Some(path) = first_line.strip_prefix("worktree ")
281 {
282 let main_path = PathBuf::from(path);
283 if main_path != project_dir {
284 tracing::info!(
285 worktree = %project_dir.display(),
286 main = %main_path.display(),
287 "resolved worktree to main repo"
288 );
289 return main_path;
290 }
291 }
292 }
293
294 project_dir.to_path_buf()
295}
296
297pub(super) async fn resolve_branch_start_point(
307 repo_root: &str,
308 branch: &str,
309 default_branch: &str,
310) -> String {
311 let verify = |reference: String| async move {
312 Command::new("git")
313 .args(["-C", repo_root, "rev-parse", "--verify", &reference])
314 .output_with_context()
315 .await
316 .is_ok_and(|o| o.status.success())
317 };
318
319 let origin_branch_ref = format!("origin/{branch}");
320 if verify(origin_branch_ref.clone()).await {
321 return origin_branch_ref;
322 }
323
324 let origin_default_ref = format!("origin/{default_branch}");
325 if verify(origin_default_ref.clone()).await {
326 return origin_default_ref;
327 }
328
329 default_branch.to_string()
330}
331
332pub async fn validate_worktree_gitdir(worktree_dir: &Path) -> bool {
338 let git_file = worktree_dir.join(".git");
339 if tokio::fs::metadata(&git_file).await.is_err() {
341 return false;
342 }
343 if tokio::fs::metadata(&git_file)
345 .await
346 .is_ok_and(|m| m.is_dir())
347 {
348 return true;
349 }
350
351 let Ok(content) = tokio::fs::read_to_string(&git_file).await else {
352 return false;
353 };
354 if let Some(gitdir_path) = content
357 .lines()
358 .next()
359 .and_then(|l| l.strip_prefix("gitdir: "))
360 {
361 return tokio::fs::metadata(gitdir_path.trim())
363 .await
364 .is_ok_and(|m| m.is_dir());
365 }
366 false
367}
368
369#[expect(
375 clippy::ref_option,
376 reason = "the store helper it calls takes the store as &Option"
377)]
378async fn resolve_parent_branch(
379 store: &Option<Arc<TaskStore>>,
380 parent_id: Option<i64>,
381 worktrees_base: &Path,
382) -> Option<(String, PathBuf)> {
383 let parent_id = parent_id?;
384 let parent = store::opt_store_get_task_by_id(store, parent_id).await?;
385 if parent.branch.is_empty() {
386 return None;
387 }
388 let wt = if parent.worktree.is_empty() {
389 worktrees_base.join(&parent.branch)
390 } else {
391 let exists = tokio::fs::metadata(&parent.worktree)
393 .await
394 .is_ok_and(|m| m.is_dir());
395 if exists {
396 PathBuf::from(&parent.worktree)
397 } else {
398 worktrees_base.join(&parent.branch)
399 }
400 };
401 tracing::info!(
402 parent_id,
403 branch = %parent.branch,
404 worktree = %wt.display(),
405 "child task inheriting parent branch"
406 );
407 Some((parent.branch, wt))
408}
409
410pub async fn setup_worktree(
414 task_id: &str,
415 title: &str,
416 project_dir: &Path,
417 store: &Option<Arc<TaskStore>>,
418 repo: &str,
419) -> anyhow::Result<WorktreeSetup> {
420 let main_dir = resolve_main_repo(project_dir).await;
422 let default_branch = detect_default_branch(&main_dir).await;
423
424 let worktrees_base = project_worktrees_dir(&main_dir);
425 tokio::fs::create_dir_all(&worktrees_base).await?;
426
427 let task_record = store::opt_store_get_task(store, repo, task_id).await;
429 let (saved_branch, saved_worktree, parent_id) =
430 task_record.as_ref().map_or((None, None, None), |t| {
431 (
432 Some(t.branch.clone()),
433 Some(t.worktree.clone()),
434 t.parent_id,
435 )
436 });
437
438 let mut force_fresh = false;
439 if let (Some(store), Some(record)) = (store.as_ref(), task_record.as_ref()) {
440 let key = crate::store::worktree_refresh_key(record.id);
441 if matches!(store.kv_get(&key).await, Ok(Some(_))) {
442 let _ = store.kv_delete(&key).await;
443 force_fresh = true;
444 tracing::info!(
445 task_id,
446 "dependency merged, discarding the old worktree so the next run starts at the default branch"
447 );
448 }
449 }
450
451 let (branch_name_str, worktree_dir) = if force_fresh {
452 if let Some(existing_dir) = find_existing_worktree(&worktrees_base, task_id).await {
453 discard_worktree(&main_dir, &existing_dir).await;
454 }
455 if let Some(saved) = saved_worktree.as_ref().filter(|path| !path.is_empty()) {
456 let saved_path = PathBuf::from(saved);
457 if tokio::fs::metadata(&saved_path).await.is_ok() {
458 discard_worktree(&main_dir, &saved_path).await;
459 }
460 }
461 let bn = branch_name(task_id, title);
462 (bn.clone(), worktrees_base.join(&bn))
463 } else if let Some(ref saved) = saved_branch {
464 if !saved.is_empty() {
465 let wt = match &saved_worktree {
467 Some(wt) if !wt.is_empty() => {
468 let wt_str = wt.clone();
469 let exists = tokio::task::spawn_blocking(move || Path::new(&wt_str).exists())
470 .await
471 .unwrap_or(false);
472 if exists {
473 PathBuf::from(wt)
474 } else {
475 worktrees_base.join(saved)
476 }
477 }
478 _ => worktrees_base.join(saved),
479 };
480 (saved.clone(), wt)
481 } else if let Some((parent_branch, parent_wt)) =
482 resolve_parent_branch(store, parent_id, &worktrees_base).await
483 {
484 (parent_branch, parent_wt)
487 } else {
488 let bn = branch_name(task_id, title);
489 (bn.clone(), worktrees_base.join(&bn))
490 }
491 } else {
492 if let Some((parent_branch, parent_wt)) =
494 resolve_parent_branch(store, parent_id, &worktrees_base).await
495 {
496 (parent_branch, parent_wt)
497 } else {
498 let existing = find_existing_worktree(&worktrees_base, task_id).await;
499 if let Some(existing_dir) = existing {
500 let bn = existing_dir
501 .file_name()
502 .filter(|n| !n.is_empty())
503 .map_or_else(
504 || branch_name(task_id, title),
505 |n| n.to_string_lossy().to_string(),
506 );
507 tracing::info!(task_id, worktree = %existing_dir.display(), "found existing worktree");
508 (bn, existing_dir)
509 } else {
510 let bn = branch_name(task_id, title);
511 (bn.clone(), worktrees_base.join(&bn))
512 }
513 }
514 };
515
516 if branch_name_str.is_empty() {
517 anyhow::bail!("empty branch name for task {task_id}");
518 }
519
520 if tokio::fs::metadata(&worktree_dir).await.is_ok() {
531 let keep_rebase = task_record
532 .as_ref()
533 .is_some_and(|t| t.merge_conflict_retries > 0)
534 && crate::engine::runner::git_ops::rebase_in_progress(&worktree_dir).await;
535 if keep_rebase {
536 tracing::info!(
537 task_id,
538 "keeping the conflicted rebase auto_merge left for the agent"
539 );
540 } else {
541 let _ = Command::new("git")
542 .args(["rebase", "--abort"])
543 .current_dir(&worktree_dir)
544 .output_with_context()
545 .await;
546 }
547
548 let index_ok = Command::new("git")
554 .args(["status", "--porcelain"])
555 .current_dir(&worktree_dir)
556 .output_with_context()
557 .await
558 .is_ok_and(|o| o.status.success());
559
560 if !index_ok {
561 let stderr_hint = Command::new("git")
562 .args(["status", "--porcelain"])
563 .current_dir(&worktree_dir)
564 .output_with_context()
565 .await
566 .map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
567 .unwrap_or_default();
568 tracing::warn!(
569 task_id,
570 worktree = %worktree_dir.display(),
571 stderr = %stderr_hint,
572 "corrupted worktree index detected — removing and recreating worktree"
573 );
574 let _ = Command::new("git")
576 .args([
577 "-C",
578 &main_dir.to_string_lossy(),
579 "worktree",
580 "remove",
581 "--force",
582 &worktree_dir.to_string_lossy(),
583 ])
584 .output_with_context()
585 .await;
586 if tokio::fs::metadata(&worktree_dir).await.is_ok() {
589 let _ = tokio::fs::remove_dir_all(&worktree_dir).await;
590 }
591 let _ = Command::new("git")
593 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
594 .output_with_context()
595 .await;
596 }
597 }
598
599 if tokio::fs::metadata(&worktree_dir).await.is_err() {
601 tracing::info!(task_id, worktree = %worktree_dir.display(), "creating worktree");
602
603 let _ = crate::engine::runner::git_ops::git()
609 .await
610 .args([
611 "-C",
612 &main_dir.to_string_lossy(),
613 "fetch",
614 "--prune",
615 "origin",
616 ])
617 .output_with_context()
618 .await;
619
620 let start_point = resolve_branch_start_point(
625 &main_dir.to_string_lossy(),
626 &branch_name_str,
627 &default_branch,
628 )
629 .await;
630 let _ = Command::new("git")
631 .args([
632 "-C",
633 &main_dir.to_string_lossy(),
634 "branch",
635 &branch_name_str,
636 &start_point,
637 ])
638 .output_with_context()
639 .await;
640
641 if let Some(parent) = worktree_dir.parent() {
643 tokio::fs::create_dir_all(parent).await?;
644 }
645
646 let cloned = match super::cow::create_worktree(
647 &main_dir,
648 &project_name(&main_dir),
649 &worktree_dir,
650 &branch_name_str,
651 &default_branch,
652 )
653 .await
654 {
655 Ok(()) => {
656 tracing::info!(task_id, "worktree cloned from project template");
657 true
658 }
659 Err(e) => {
660 tracing::debug!(task_id, error = %e, "copy-on-write worktree unavailable, using git worktree add");
661 false
662 }
663 };
664
665 if !cloned {
666 let output = Command::new("git")
667 .args([
668 "-C",
669 &main_dir.to_string_lossy(),
670 "worktree",
671 "add",
672 &worktree_dir.to_string_lossy(),
673 &branch_name_str,
674 ])
675 .output_with_context()
676 .await?;
677
678 if !output.status.success() && tokio::fs::metadata(&worktree_dir).await.is_err() {
679 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
680 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
681 tracing::warn!(
683 task_id,
684 stdout = %stdout,
685 stderr = %stderr,
686 "worktree creation failed, retrying after prune"
687 );
688
689 let _ = Command::new("git")
690 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
691 .output_with_context()
692 .await;
693
694 let _ = Command::new("git")
695 .args([
696 "-C",
697 &main_dir.to_string_lossy(),
698 "branch",
699 "-D",
700 &branch_name_str,
701 ])
702 .output_with_context()
703 .await;
704
705 let _ = Command::new("git")
706 .args([
707 "-C",
708 &main_dir.to_string_lossy(),
709 "branch",
710 &branch_name_str,
711 &start_point,
712 ])
713 .output_with_context()
714 .await;
715
716 let retry_output = Command::new("git")
717 .args([
718 "-C",
719 &main_dir.to_string_lossy(),
720 "worktree",
721 "add",
722 &worktree_dir.to_string_lossy(),
723 &branch_name_str,
724 ])
725 .output_with_context()
726 .await;
727
728 let mut retry_stdout = String::new();
729 let mut retry_stderr = String::new();
730 let mut retry_error = String::new();
731 match retry_output {
732 Ok(output) => {
733 retry_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
734 retry_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
735 tracing::warn!(
736 task_id,
737 stdout = %retry_stdout,
738 stderr = %retry_stderr,
739 "worktree creation retry failed"
740 );
741 }
742 Err(err) => {
743 retry_error = err.to_string();
744 tracing::warn!(
745 task_id,
746 error = %retry_error,
747 "worktree creation retry failed to run"
748 );
749 }
750 }
751
752 if tokio::fs::metadata(&worktree_dir).await.is_err() {
753 anyhow::bail!(
754 "failed to create worktree at {} for task {} (stdout: {}, stderr: {}, retry stdout: {}, retry stderr: {}, retry error: {})",
755 worktree_dir.display(),
756 task_id,
757 stdout,
758 stderr,
759 retry_stdout,
760 retry_stderr,
761 retry_error
762 );
763 }
764 }
765 }
766 }
767
768 if !task_id.starts_with("internal:")
772 && let Ok(issue_num) = task_id.parse::<u64>()
773 && let Ok(gh) = crate::github::http::GhHttp::new()
774 {
775 match gh
776 .link_issue_to_branch(repo, issue_num, &branch_name_str)
777 .await
778 {
779 Ok(id) => {
780 tracing::info!(task_id, branch = %branch_name_str, linked = %id, "linked issue to branch");
781 }
782 Err(e) => {
783 tracing::debug!(task_id, error = %e, "failed to link issue to branch (non-fatal)");
784 }
785 }
786 }
787
788 if let Err(e) = store::store_set_result(
790 store,
791 repo,
792 task_id,
793 &[
794 (
795 "worktree",
796 serde_json::json!(worktree_dir.display().to_string()),
797 ),
798 ("branch", serde_json::json!(branch_name_str)),
799 ],
800 )
801 .await
802 {
803 tracing::warn!(task_id, err = %e, "failed to save worktree info to store (non-fatal)");
804 }
805
806 tracing::info!(
807 task_id,
808 worktree = %worktree_dir.display(),
809 branch = %branch_name_str,
810 "worktree ready"
811 );
812
813 Ok(WorktreeSetup {
814 work_dir: worktree_dir,
815 branch: branch_name_str,
816 default_branch,
817 main_project_dir: main_dir,
818 })
819}
820
821async fn discard_worktree(main_dir: &Path, worktree_dir: &Path) {
823 let _ = Command::new("git")
824 .args([
825 "-C",
826 &main_dir.to_string_lossy(),
827 "worktree",
828 "remove",
829 "--force",
830 &worktree_dir.to_string_lossy(),
831 ])
832 .output_with_context()
833 .await;
834 if tokio::fs::metadata(worktree_dir).await.is_ok() {
835 let _ = tokio::fs::remove_dir_all(worktree_dir).await;
836 }
837 let _ = Command::new("git")
838 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
839 .output_with_context()
840 .await;
841}
842
843async fn find_existing_worktree(worktrees_base: &Path, task_id: &str) -> Option<PathBuf> {
844 let (prefix_new, legacy_prefixes) = if let Some(num) = task_id.strip_prefix("internal:") {
845 (
846 format!("internal-{num}-"),
847 vec![
848 format!("gh-task-internal-{num}-"),
849 format!("gh-issue-internal-{num}-"),
850 ],
851 )
852 } else {
853 let sanitized_id = sanitize_task_id(task_id);
854 (
855 format!("gh-issue-{sanitized_id}-"),
856 vec![format!("gh-task-{sanitized_id}-")],
857 )
858 };
859
860 let mut entries = match tokio::fs::read_dir(worktrees_base).await {
861 Ok(e) => e,
862 Err(e) => {
863 tracing::warn!(
864 error = %e,
865 path = %worktrees_base.display(),
866 "failed to read worktrees directory while searching for existing worktree"
867 );
868 return None;
869 }
870 };
871 loop {
872 let next_entry = match entries.next_entry().await {
873 Ok(Some(entry)) => entry,
874 Ok(None) => break,
875 Err(e) => {
876 tracing::warn!(error = %e, "failed to read directory entry while searching for worktree");
878 continue;
879 }
880 };
881
882 let name = next_entry.file_name().to_string_lossy().to_string();
883 let matches =
884 name.starts_with(&prefix_new) || legacy_prefixes.iter().any(|p| name.starts_with(p));
885 if matches {
886 match next_entry.metadata().await {
887 Ok(metadata) if metadata.is_dir() => return Some(next_entry.path()),
888 Ok(_) => {}
889 Err(e) => {
890 tracing::warn!(
891 error = %e,
892 path = %next_entry.path().display(),
893 "failed to read metadata for candidate worktree entry"
894 );
895 }
896 }
897 }
898 }
899
900 None
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906
907 fn git(dir: &std::path::Path, args: &[&str]) {
909 let mut cmd = std::process::Command::new("git");
910 cmd.current_dir(dir);
911 if args.first() == Some(&"commit") {
913 cmd.args(["-c", "commit.gpgsign=false"]);
914 }
915 let out = cmd
916 .args(args)
917 .output()
918 .unwrap_or_else(|e| panic!("git {args:?} failed to start: {e}"));
919 assert!(
920 out.status.success(),
921 "git {:?} failed:\nstdout: {}\nstderr: {}",
922 args,
923 String::from_utf8_lossy(&out.stdout),
924 String::from_utf8_lossy(&out.stderr)
925 );
926 }
927
928 #[tokio::test]
932 async fn resolve_branch_start_point_prefers_remote_over_default() {
933 let remote = tempfile::tempdir().unwrap();
934 git(remote.path(), &["init", "--bare"]);
935
936 let setup = tempfile::tempdir().unwrap();
938 git(
939 setup.path(),
940 &["clone", remote.path().to_str().unwrap(), "."],
941 );
942 git(setup.path(), &["config", "user.email", "t@t.com"]);
943 git(setup.path(), &["config", "user.name", "T"]);
944
945 std::fs::write(setup.path().join("README.md"), "main").unwrap();
947 git(setup.path(), &["add", "."]);
948 git(setup.path(), &["commit", "-m", "init"]);
949 git(setup.path(), &["push", "origin", "HEAD:main"]);
950
951 git(
953 setup.path(),
954 &["checkout", "-b", "gh-issue-42-fix-login-bug"],
955 );
956 std::fs::write(setup.path().join("feature.txt"), "work").unwrap();
957 git(setup.path(), &["add", "."]);
958 git(setup.path(), &["commit", "-m", "feature work"]);
959 git(
960 setup.path(),
961 &["push", "origin", "gh-issue-42-fix-login-bug"],
962 );
963
964 let local = tempfile::tempdir().unwrap();
967 git(
968 local.path(),
969 &["clone", remote.path().to_str().unwrap(), "."],
970 );
971 git(local.path(), &["config", "user.email", "t@t.com"]);
972 git(local.path(), &["config", "user.name", "T"]);
973 git(local.path(), &["fetch", "--all"]);
974 let has_local = std::process::Command::new("git")
976 .args([
977 "-C",
978 local.path().to_str().unwrap(),
979 "rev-parse",
980 "--verify",
981 "gh-issue-42-fix-login-bug",
982 ])
983 .output()
984 .unwrap()
985 .status
986 .success();
987 assert!(!has_local, "local branch should not exist before the test");
988
989 let repo_root = local.path().to_str().unwrap();
990
991 let start =
993 resolve_branch_start_point(repo_root, "gh-issue-42-fix-login-bug", "main").await;
994 assert_eq!(
995 start, "origin/gh-issue-42-fix-login-bug",
996 "should use remote branch as start point when origin/<branch> exists"
997 );
998 }
999
1000 #[tokio::test]
1004 async fn resolve_branch_start_point_prefers_origin_default_over_local() {
1005 let remote = tempfile::tempdir().unwrap();
1006 git(remote.path(), &["init", "--bare"]);
1007
1008 let local = tempfile::tempdir().unwrap();
1009 git(
1010 local.path(),
1011 &["clone", remote.path().to_str().unwrap(), "."],
1012 );
1013 git(local.path(), &["config", "user.email", "t@t.com"]);
1014 git(local.path(), &["config", "user.name", "T"]);
1015
1016 std::fs::write(local.path().join("README.md"), "main").unwrap();
1018 git(local.path(), &["add", "."]);
1019 git(local.path(), &["commit", "-m", "init"]);
1020 git(local.path(), &["push", "origin", "HEAD:main"]);
1021 git(local.path(), &["fetch", "--all"]);
1022
1023 let repo_root = local.path().to_str().unwrap();
1024
1025 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1027 assert_eq!(
1028 start, "origin/main",
1029 "should anchor on origin/<default_branch> instead of local default"
1030 );
1031 }
1032
1033 fn git_stdout(dir: &std::path::Path, args: &[&str]) -> String {
1035 let out = std::process::Command::new("git")
1036 .args(args)
1037 .current_dir(dir)
1038 .output()
1039 .unwrap_or_else(|e| panic!("git {args:?} failed to start: {e}"));
1040 assert!(
1041 out.status.success(),
1042 "git {:?} failed:\nstdout: {}\nstderr: {}",
1043 args,
1044 String::from_utf8_lossy(&out.stdout),
1045 String::from_utf8_lossy(&out.stderr)
1046 );
1047 String::from_utf8_lossy(&out.stdout).trim().to_string()
1048 }
1049
1050 #[tokio::test]
1055 #[serial_test::serial(orch_home)]
1056 async fn setup_worktree_anchors_on_origin_not_local_main() {
1057 let orch_home_dir = tempfile::tempdir().unwrap();
1059 let prev_orch_home = std::env::var("ORCH_HOME").ok();
1060 unsafe { std::env::set_var("ORCH_HOME", orch_home_dir.path()) };
1062
1063 let remote = tempfile::tempdir().unwrap();
1065 git(remote.path(), &["init", "--bare"]);
1066
1067 let project = tempfile::tempdir().unwrap();
1069 git(
1070 project.path(),
1071 &["clone", remote.path().to_str().unwrap(), "."],
1072 );
1073 git(project.path(), &["config", "user.email", "t@t.com"]);
1074 git(project.path(), &["config", "user.name", "T"]);
1075
1076 std::fs::write(project.path().join("README.md"), "pushed\n").unwrap();
1078 git(project.path(), &["add", "."]);
1079 git(project.path(), &["commit", "-m", "pushed baseline"]);
1080 git(project.path(), &["push", "origin", "HEAD:main"]);
1081 let pushed_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1082
1083 git(project.path(), &["remote", "set-head", "origin", "main"]);
1085
1086 std::fs::write(project.path().join("LOCAL_ONLY.md"), "unpushed\n").unwrap();
1088 git(project.path(), &["add", "."]);
1089 git(project.path(), &["commit", "-m", "local unpushed commit"]);
1090 let local_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1091 assert_ne!(
1092 pushed_sha, local_sha,
1093 "precondition: local main must be ahead of origin/main"
1094 );
1095
1096 let setup = setup_worktree(
1098 "internal:1",
1099 "leak guard",
1100 project.path(),
1101 &None,
1102 "test/repo",
1103 )
1104 .await
1105 .expect("setup_worktree should succeed");
1106
1107 let wt_sha = git_stdout(&setup.work_dir, &["rev-parse", "HEAD"]);
1109 assert_eq!(
1110 wt_sha, pushed_sha,
1111 "worktree HEAD must equal origin/main, not local main"
1112 );
1113 assert_ne!(
1114 wt_sha, local_sha,
1115 "worktree must NOT include the operator's unpushed commit"
1116 );
1117 assert!(
1118 !setup.work_dir.join("LOCAL_ONLY.md").exists(),
1119 "unpushed-only file must not appear in the worktree"
1120 );
1121
1122 match prev_orch_home {
1124 Some(v) => unsafe { std::env::set_var("ORCH_HOME", v) },
1126 None => unsafe { std::env::remove_var("ORCH_HOME") },
1128 }
1129 }
1130
1131 #[tokio::test]
1133 #[serial_test::serial(orch_home)]
1134 async fn setup_worktree_keeps_the_conflicted_rebase_for_a_conflict_task() {
1135 let orch_home_dir = tempfile::tempdir().unwrap();
1136 let prev_orch_home = std::env::var("ORCH_HOME").ok();
1137 unsafe { std::env::set_var("ORCH_HOME", orch_home_dir.path()) };
1139
1140 let remote = tempfile::tempdir().unwrap();
1141 git(remote.path(), &["init", "--bare"]);
1142 let project = tempfile::tempdir().unwrap();
1143 git(
1144 project.path(),
1145 &["clone", remote.path().to_str().unwrap(), "."],
1146 );
1147 git(project.path(), &["config", "user.email", "t@t.com"]);
1148 git(project.path(), &["config", "user.name", "T"]);
1149 std::fs::write(project.path().join("f.txt"), "base\n").unwrap();
1150 git(project.path(), &["add", "."]);
1151 git(project.path(), &["commit", "-m", "base"]);
1152 git(project.path(), &["push", "origin", "HEAD:main"]);
1153 git(project.path(), &["remote", "set-head", "origin", "main"]);
1154
1155 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1156 let id = store
1157 .create_internal("test/repo", "conflict", "", "test", "1", None)
1158 .await
1159 .unwrap();
1160 let task_id = format!("internal:{id}");
1161 let opt = Some(Arc::clone(&store));
1162
1163 let setup = setup_worktree(&task_id, "conflict", project.path(), &opt, "test/repo")
1164 .await
1165 .unwrap();
1166 let wt = setup.work_dir.clone();
1167 git(&wt, &["config", "user.email", "t@t.com"]);
1168 git(&wt, &["config", "user.name", "T"]);
1169 std::fs::write(wt.join("f.txt"), "agent\n").unwrap();
1170 git(&wt, &["commit", "-am", "agent change"]);
1171 git(&wt, &["push", "origin", "HEAD"]);
1172
1173 std::fs::write(project.path().join("f.txt"), "main\n").unwrap();
1175 git(project.path(), &["commit", "-am", "main change"]);
1176 git(project.path(), &["push", "origin", "HEAD:main"]);
1177 git(&wt, &["fetch", "origin"]);
1178 let rebase = std::process::Command::new("git")
1179 .args(["rebase", "origin/main"])
1180 .current_dir(&wt)
1181 .output()
1182 .unwrap();
1183 assert!(!rebase.status.success(), "precondition: rebase conflicts");
1184 assert!(crate::engine::runner::git_ops::rebase_in_progress(&wt).await);
1185
1186 store.increment(id, "merge_conflict_retries").await.unwrap();
1187
1188 setup_worktree(&task_id, "conflict", project.path(), &opt, "test/repo")
1189 .await
1190 .unwrap();
1191 crate::engine::runner::git_ops::rebase_on_default(&wt, "main").await;
1192 assert!(
1193 crate::engine::runner::git_ops::rebase_in_progress(&wt).await,
1194 "the rebase auto_merge left must survive dispatch"
1195 );
1196
1197 match prev_orch_home {
1198 Some(v) => unsafe { std::env::set_var("ORCH_HOME", v) },
1200 None => unsafe { std::env::remove_var("ORCH_HOME") },
1202 }
1203 }
1204
1205 #[tokio::test]
1208 async fn resolve_branch_start_point_falls_back_to_local_default() {
1209 let local = tempfile::tempdir().unwrap();
1210 git(local.path(), &["init"]);
1211 git(local.path(), &["config", "user.email", "t@t.com"]);
1212 git(local.path(), &["config", "user.name", "T"]);
1213 git(local.path(), &["checkout", "-b", "main"]);
1214 std::fs::write(local.path().join("README.md"), "main").unwrap();
1215 git(local.path(), &["add", "."]);
1216 git(local.path(), &["commit", "-m", "init"]);
1217
1218 let repo_root = local.path().to_str().unwrap();
1219
1220 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1222 assert_eq!(
1223 start, "main",
1224 "should fall back to local default_branch when no remote refs exist"
1225 );
1226 }
1227
1228 #[test]
1229 fn branch_name_basic() {
1230 let name = branch_name("42", "Fix login bug");
1231 assert_eq!(name, "gh-issue-42-fix-login-bug");
1232 }
1233
1234 #[test]
1235 fn branch_name_internal_task() {
1236 let name = branch_name("internal:8", "Fix login bug");
1237 assert_eq!(name, "internal-8-fix-login-bug");
1238 }
1239
1240 #[test]
1241 fn branch_name_special_chars() {
1242 let name = branch_name("7", "Add OAuth2/OIDC (Google & GitHub)");
1243 assert_eq!(name, "gh-issue-7-add-oauth2-oidc-google-github");
1244 }
1245
1246 #[test]
1247 fn branch_name_truncates_long_slug() {
1248 let title =
1249 "This is a very long task title that should be truncated to forty characters maximum";
1250 let name = branch_name("1", title);
1251 let slug = name.strip_prefix("gh-issue-1-").unwrap();
1253 assert!(slug.len() <= 40, "slug length {} > 40", slug.len());
1254 }
1255
1256 #[test]
1257 fn branch_name_trims_trailing_dashes() {
1258 let name = branch_name("5", "Fix bug---");
1259 assert!(
1260 !name.ends_with('-'),
1261 "branch name should not end with dash: {name}"
1262 );
1263 }
1264
1265 #[test]
1266 fn branch_name_empty_title() {
1267 let name = branch_name("99", "");
1268 assert_eq!(name, "gh-issue-99");
1269 }
1270
1271 #[test]
1272 fn branch_name_empty_title_internal() {
1273 let name = branch_name("internal:99", "");
1274 assert_eq!(name, "internal-99");
1275 }
1276
1277 #[test]
1278 fn branch_name_all_special_chars() {
1279 let name = branch_name("10", "--- ??? ---");
1280 assert_eq!(name, "gh-issue-10");
1281 assert_ne!(name, "");
1282 }
1283
1284 #[test]
1285 fn branch_name_chinese_chars_no_panic() {
1286 let name = branch_name("265", "implement 用户认证 user auth");
1287 assert!(name.starts_with("gh-issue-265-"));
1288 assert_ne!(name, "");
1289 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1290 assert!(slug.len() <= 40, "slug too long: {}", slug.len());
1291 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1292 }
1293
1294 #[test]
1295 fn task_id_from_worktree_name_parses_issue_branches() {
1296 assert_eq!(
1297 task_id_from_worktree_name("gh-issue-42-fix-login"),
1298 Some("42".to_string())
1299 );
1300 assert_eq!(
1301 task_id_from_worktree_name("gh-task-42-fix-login"),
1302 Some("42".to_string())
1303 );
1304 }
1305
1306 #[test]
1307 fn task_id_from_worktree_name_parses_internal_branches() {
1308 assert_eq!(
1309 task_id_from_worktree_name("internal-8-fix-login"),
1310 Some("internal:8".to_string())
1311 );
1312 assert_eq!(
1313 task_id_from_worktree_name("gh-issue-internal-8-fix-login"),
1314 Some("internal:8".to_string())
1315 );
1316 }
1317
1318 #[test]
1319 fn task_id_from_worktree_name_returns_none_for_unknown_names() {
1320 assert!(task_id_from_worktree_name("random-worktree").is_none());
1321 }
1322
1323 #[test]
1324 fn branch_name_emoji_no_panic() {
1325 let name = branch_name("265", "fix 🚀 deployment pipeline 🔥");
1326 assert!(name.starts_with("gh-issue-265-"));
1327 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1328 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1329 }
1330
1331 #[test]
1332 fn branch_name_all_non_ascii_falls_back_to_task_id() {
1333 let name = branch_name("265", "用户认证 실행 тест");
1334 assert_eq!(name, "gh-issue-265");
1335 }
1336
1337 #[tokio::test]
1338 async fn find_existing_worktree_returns_none_for_missing() {
1339 let dir = tempfile::tempdir().unwrap();
1340 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1341 }
1342
1343 #[tokio::test]
1344 async fn find_existing_worktree_matches_prefix() {
1345 let dir = tempfile::tempdir().unwrap();
1346 let wt_dir = dir.path().join("gh-issue-42-fix-login-bug");
1347 tokio::fs::create_dir(&wt_dir).await.unwrap();
1348
1349 let result = find_existing_worktree(dir.path(), "42").await;
1350 assert_eq!(result, Some(wt_dir));
1351 }
1352
1353 #[tokio::test]
1354 async fn find_existing_worktree_matches_internal_prefix() {
1355 let dir = tempfile::tempdir().unwrap();
1356 let wt_dir = dir.path().join("internal-8-fix");
1357 tokio::fs::create_dir(&wt_dir).await.unwrap();
1358
1359 let result = find_existing_worktree(dir.path(), "internal:8").await;
1360 assert_eq!(result, Some(wt_dir));
1361 }
1362
1363 #[tokio::test]
1364 async fn find_existing_worktree_matches_legacy_gh_task_prefix() {
1365 let dir = tempfile::tempdir().unwrap();
1367 let wt_dir = dir.path().join("gh-task-internal-8-fix");
1368 tokio::fs::create_dir(&wt_dir).await.unwrap();
1369
1370 let result = find_existing_worktree(dir.path(), "internal:8").await;
1371 assert_eq!(result, Some(wt_dir));
1372 }
1373
1374 #[tokio::test]
1375 async fn find_existing_worktree_matches_legacy_external_prefix() {
1376 let dir = tempfile::tempdir().unwrap();
1378 let wt_dir = dir.path().join("gh-task-42-fix-login-bug");
1379 tokio::fs::create_dir(&wt_dir).await.unwrap();
1380
1381 let result = find_existing_worktree(dir.path(), "42").await;
1382 assert_eq!(result, Some(wt_dir));
1383 }
1384
1385 #[tokio::test]
1386 async fn find_existing_worktree_ignores_other_tasks() {
1387 let dir = tempfile::tempdir().unwrap();
1388 tokio::fs::create_dir(dir.path().join("gh-issue-43-other-task"))
1389 .await
1390 .unwrap();
1391
1392 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1393 }
1394
1395 #[tokio::test]
1399 async fn resolve_parent_branch_no_store_returns_none() {
1400 let dir = tempfile::tempdir().unwrap();
1401 let result = resolve_parent_branch(&None, Some(1), dir.path()).await;
1402 assert!(result.is_none());
1403 }
1404
1405 #[tokio::test]
1407 async fn resolve_parent_branch_no_parent_id_returns_none() {
1408 let dir = tempfile::tempdir().unwrap();
1409 let result = resolve_parent_branch(&None, None, dir.path()).await;
1410 assert!(result.is_none());
1411 }
1412
1413 #[tokio::test]
1415 async fn resolve_parent_branch_empty_parent_branch_returns_none() {
1416 let db_dir = tempfile::tempdir().unwrap();
1417 let db_path = db_dir.path().join("test.db");
1418 let store = Arc::new(
1419 crate::store::TaskStore::open_single(&db_path)
1420 .await
1421 .unwrap(),
1422 );
1423
1424 let parent_id = store
1426 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1427 .await
1428 .unwrap();
1429
1430 let dir = tempfile::tempdir().unwrap();
1431 let store_opt = Some(store);
1432 let result = resolve_parent_branch(&store_opt, Some(parent_id), dir.path()).await;
1433 assert!(result.is_none());
1434 }
1435
1436 #[tokio::test]
1438 async fn resolve_parent_branch_returns_parent_branch_and_worktree() {
1439 let db_dir = tempfile::tempdir().unwrap();
1440 let db_path = db_dir.path().join("test.db");
1441 let store = Arc::new(
1442 crate::store::TaskStore::open_single(&db_path)
1443 .await
1444 .unwrap(),
1445 );
1446
1447 let parent_id = store
1449 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1450 .await
1451 .unwrap();
1452 store
1453 .set_fields(
1454 parent_id,
1455 &[("branch", serde_json::json!("gh-issue-99-parent-task"))],
1456 )
1457 .await
1458 .unwrap();
1459
1460 let worktrees_base = tempfile::tempdir().unwrap();
1461 let store_opt = Some(store);
1462 let result =
1463 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1464
1465 let (branch, wt) = result.expect("should return parent branch");
1466 assert_eq!(branch, "gh-issue-99-parent-task");
1467 assert_eq!(wt, worktrees_base.path().join("gh-issue-99-parent-task"));
1468 }
1469
1470 #[tokio::test]
1472 async fn resolve_parent_branch_prefers_existing_worktree_path() {
1473 let db_dir = tempfile::tempdir().unwrap();
1474 let db_path = db_dir.path().join("test.db");
1475 let store = Arc::new(
1476 crate::store::TaskStore::open_single(&db_path)
1477 .await
1478 .unwrap(),
1479 );
1480
1481 let worktrees_base = tempfile::tempdir().unwrap();
1482 let parent_wt_dir = worktrees_base.path().join("custom-worktree-path");
1484 tokio::fs::create_dir_all(&parent_wt_dir).await.unwrap();
1485
1486 let parent_id = store
1487 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1488 .await
1489 .unwrap();
1490 store
1491 .set_fields(
1492 parent_id,
1493 &[
1494 ("branch", serde_json::json!("gh-issue-99-parent-task")),
1495 (
1496 "worktree",
1497 serde_json::json!(parent_wt_dir.to_string_lossy().as_ref()),
1498 ),
1499 ],
1500 )
1501 .await
1502 .unwrap();
1503
1504 let store_opt = Some(store);
1505 let result =
1506 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1507
1508 let (branch, wt) = result.expect("should return parent branch");
1509 assert_eq!(branch, "gh-issue-99-parent-task");
1510 assert_eq!(wt, parent_wt_dir);
1511 }
1512
1513 #[tokio::test]
1518 async fn concurrent_worktree_checks_no_starvation() {
1519 let temp_dir = tempfile::tempdir().unwrap();
1520 let worktrees_base = temp_dir.path().join("worktrees");
1521 tokio::fs::create_dir_all(&worktrees_base).await.unwrap();
1522
1523 let wt_dir = worktrees_base.join("gh-issue-123-test");
1525 tokio::fs::create_dir_all(&wt_dir).await.unwrap();
1526 let gitdir_target = temp_dir
1528 .path()
1529 .join(".git")
1530 .join("worktrees")
1531 .join("gh-issue-123-test");
1532 tokio::fs::create_dir_all(&gitdir_target).await.unwrap();
1533 let git_file = wt_dir.join(".git");
1534 tokio::fs::write(&git_file, format!("gitdir: {}\n", gitdir_target.display()))
1535 .await
1536 .unwrap();
1537
1538 let handles: Vec<_> = (0..20)
1541 .map(|i| {
1542 let wb = worktrees_base.clone();
1543 let wd = wt_dir.clone();
1544 tokio::spawn(async move {
1545 if i % 2 == 0 {
1546 list_project_worktrees(&wb).await.ok();
1547 } else {
1548 validate_worktree_gitdir(&wd).await;
1549 }
1550 })
1551 })
1552 .collect();
1553
1554 let results = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1556 futures::future::join_all(handles).await
1557 })
1558 .await;
1559
1560 assert!(
1561 results.is_ok(),
1562 "concurrent worktree checks timed out (starvation?)"
1563 );
1564 }
1565
1566 #[test]
1569 fn normalize_branch_name_strips_origin_prefix() {
1570 assert_eq!(
1571 normalize_branch_name("origin/main"),
1572 Some("main".to_string())
1573 );
1574 assert_eq!(
1575 normalize_branch_name("origin/develop"),
1576 Some("develop".to_string())
1577 );
1578 }
1579
1580 #[test]
1581 fn normalize_branch_name_strips_full_ref_path() {
1582 assert_eq!(
1583 normalize_branch_name("refs/remotes/origin/main"),
1584 Some("main".to_string())
1585 );
1586 assert_eq!(
1587 normalize_branch_name("refs/remotes/origin/develop"),
1588 Some("develop".to_string())
1589 );
1590 }
1591
1592 #[test]
1593 fn normalize_branch_name_strips_remotes_prefix() {
1594 assert_eq!(
1595 normalize_branch_name("remotes/origin/main"),
1596 Some("main".to_string())
1597 );
1598 }
1599
1600 #[test]
1601 fn normalize_branch_name_passes_through_plain_branch() {
1602 assert_eq!(normalize_branch_name("main"), Some("main".to_string()));
1603 assert_eq!(normalize_branch_name("master"), Some("master".to_string()));
1604 assert_eq!(
1605 normalize_branch_name("feature/foo"),
1606 Some("feature/foo".to_string())
1607 );
1608 }
1609
1610 #[test]
1611 fn normalize_branch_name_rejects_empty_and_refs_only() {
1612 assert_eq!(normalize_branch_name(""), None);
1613 assert_eq!(normalize_branch_name(" "), None);
1614 assert_eq!(
1615 normalize_branch_name("refs/heads/main"),
1616 Some("main".to_string())
1617 );
1618 }
1619}