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