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
13pub struct WorktreeSetup {
15 pub work_dir: PathBuf,
17 pub branch: String,
19 pub default_branch: String,
21 pub main_project_dir: PathBuf,
23}
24
25pub fn project_name(project_dir: &Path) -> String {
27 project_dir
28 .file_name()
29 .map(|s| s.to_string_lossy().to_string())
30 .unwrap_or_else(|| "project".to_string())
31 .trim_end_matches(".git")
32 .to_string()
33}
34
35pub fn project_worktrees_dir(project_dir: &Path) -> PathBuf {
37 crate::home::worktrees_dir()
38 .unwrap_or_default()
39 .join(project_name(project_dir))
40}
41
42pub async fn list_project_worktrees(project_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
44 let base = project_worktrees_dir(project_dir);
45 let mut worktrees = Vec::new();
46
47 if tokio::fs::metadata(&base).await.is_err() {
48 return Ok(worktrees);
49 }
50
51 let mut entries = tokio::fs::read_dir(&base).await?;
52 while let Some(entry) = entries.next_entry().await? {
53 if entry.metadata().await.map(|m| m.is_dir()).unwrap_or(false) {
54 worktrees.push(entry.path());
55 }
56 }
57
58 Ok(worktrees)
59}
60
61pub fn task_id_from_worktree_name(name: &str) -> Option<String> {
63 if let Some(rest) = name.strip_prefix("internal-") {
64 let num = rest.split('-').next()?;
65 return num.parse::<u64>().ok().map(|n| format!("internal:{n}"));
66 }
67
68 for prefix in ["gh-issue-", "gh-task-"] {
69 if let Some(rest) = name.strip_prefix(prefix) {
70 let task_part = rest.split('-').next()?;
71 if task_part == "internal" {
72 let num = rest.split('-').nth(1)?;
73 return num.parse::<u64>().ok().map(|n| format!("internal:{n}"));
74 }
75 return Some(task_part.to_string());
76 }
77 }
78
79 None
80}
81
82pub async fn abort_worktree_rebase(worktree_dir: &Path) {
84 let _ = Command::new("git")
85 .args(["rebase", "--abort"])
86 .current_dir(worktree_dir)
87 .output_with_context()
88 .await;
89}
90
91pub async fn rebase_worktree_on_origin_main(
97 worktree_dir: &Path,
98 default_branch: &str,
99) -> anyhow::Result<()> {
100 let origin_branch = format!("origin/{default_branch}");
101
102 match crate::engine::runner::git_ops::stash_rebase_restore(
103 worktree_dir,
104 &origin_branch,
105 crate::engine::runner::git_ops::StashRebaseOpts::default(),
106 )
107 .await?
108 {
109 crate::engine::runner::git_ops::RebaseOutcome::Succeeded => Ok(()),
110 crate::engine::runner::git_ops::RebaseOutcome::Failed(e) => {
111 anyhow::bail!("git rebase {} failed: {}", origin_branch, e)
112 }
113 crate::engine::runner::git_ops::RebaseOutcome::Skipped(_) => Ok(()),
114 }
115}
116
117pub fn branch_name(task_id: &str, title: &str) -> String {
122 let (prefix, sanitized_id) = if let Some(num) = task_id.strip_prefix("internal:") {
123 ("internal", num.to_string())
124 } else {
125 ("gh-issue", sanitize_task_id(task_id))
126 };
127
128 let raw: String = title
129 .to_lowercase()
130 .chars()
131 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
132 .collect();
133
134 let mut slug = String::with_capacity(raw.len());
136 let mut prev_dash = false;
137 for c in raw.chars() {
138 if c == '-' {
139 if !prev_dash {
140 slug.push('-');
141 }
142 prev_dash = true;
143 } else {
144 slug.push(c);
145 prev_dash = false;
146 }
147 }
148 let slug = slug.trim_matches('-').to_string();
149
150 let slug = if slug.len() > 40 { &slug[..40] } else { &slug };
152 let slug = slug.trim_end_matches('-');
153
154 if slug.is_empty() {
155 format!("{prefix}-{sanitized_id}")
156 } else {
157 format!("{prefix}-{sanitized_id}-{slug}")
158 }
159}
160
161fn sanitize_task_id(task_id: &str) -> String {
163 task_id.replace(':', "-")
164}
165
166fn normalize_branch_name(raw: &str) -> Option<String> {
175 let branch = raw.trim();
176 if branch.is_empty() {
177 return None;
178 }
179
180 let cleaned = branch
182 .strip_prefix("refs/remotes/origin/")
183 .or_else(|| branch.strip_prefix("remotes/origin/"))
184 .or_else(|| branch.strip_prefix("refs/heads/"))
185 .or_else(|| branch.strip_prefix("origin/"))
186 .unwrap_or(branch);
187
188 if cleaned.is_empty() {
189 return None;
190 }
191 Some(cleaned.to_string())
192}
193
194pub async fn detect_default_branch(project_dir: &Path) -> String {
199 let symref = Command::new("git")
201 .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
202 .current_dir(project_dir)
203 .output_with_context()
204 .await;
205
206 if let Ok(o) = &symref {
207 if o.status.success() {
208 let raw = String::from_utf8_lossy(&o.stdout);
209 if let Some(branch) = normalize_branch_name(&raw) {
210 return branch;
211 }
212 }
213 }
214
215 let revparse = Command::new("git")
217 .args(["rev-parse", "--abbrev-ref", "refs/remotes/origin/HEAD"])
218 .current_dir(project_dir)
219 .output_with_context()
220 .await;
221
222 if let Ok(o) = &revparse {
223 if o.status.success() {
224 let raw = String::from_utf8_lossy(&o.stdout);
225 if let Some(branch) = normalize_branch_name(&raw) {
226 return branch;
227 }
228 }
229 }
230
231 let remote_show = Command::new("git")
233 .args(["remote", "show", "origin"])
234 .current_dir(project_dir)
235 .output_with_context()
236 .await;
237
238 if let Ok(o) = &remote_show {
239 if o.status.success() {
240 let stdout = String::from_utf8_lossy(&o.stdout);
241 for line in stdout.lines() {
242 let trimmed = line.trim();
243 if trimmed.starts_with("HEAD branch:") {
244 if let Some(branch) = trimmed.split(':').nth(1) {
245 let branch = branch.trim();
246 if !branch.is_empty() && branch != "(unknown)" {
247 return branch.to_string();
248 }
249 }
250 }
251 }
252 }
253 }
254
255 tracing::warn!(
256 project_dir = %project_dir.display(),
257 "could not detect default branch from git, falling back to 'main'"
258 );
259 "main".to_string()
260}
261
262pub async fn resolve_main_repo(project_dir: &Path) -> PathBuf {
266 let output = Command::new("git")
267 .args(["worktree", "list", "--porcelain"])
268 .current_dir(project_dir)
269 .output_with_context()
270 .await;
271
272 if let Ok(o) = output {
273 if o.status.success() {
274 let stdout = String::from_utf8_lossy(&o.stdout);
275 if let Some(first_line) = stdout.lines().next() {
276 if let Some(path) = first_line.strip_prefix("worktree ") {
277 let main_path = PathBuf::from(path);
278 if main_path != project_dir {
279 tracing::info!(
280 worktree = %project_dir.display(),
281 main = %main_path.display(),
282 "resolved worktree to main repo"
283 );
284 return main_path;
285 }
286 }
287 }
288 }
289 }
290
291 project_dir.to_path_buf()
292}
293
294pub(super) async fn resolve_branch_start_point(
304 repo_root: &str,
305 branch: &str,
306 default_branch: &str,
307) -> String {
308 let verify = |reference: String| async move {
309 Command::new("git")
310 .args(["-C", repo_root, "rev-parse", "--verify", &reference])
311 .output_with_context()
312 .await
313 .map(|o| o.status.success())
314 .unwrap_or(false)
315 };
316
317 let origin_branch_ref = format!("origin/{branch}");
318 if verify(origin_branch_ref.clone()).await {
319 return origin_branch_ref;
320 }
321
322 let origin_default_ref = format!("origin/{default_branch}");
323 if verify(origin_default_ref.clone()).await {
324 return origin_default_ref;
325 }
326
327 default_branch.to_string()
328}
329
330pub async fn validate_worktree_gitdir(worktree_dir: &Path) -> bool {
336 let git_file = worktree_dir.join(".git");
337 if tokio::fs::metadata(&git_file).await.is_err() {
339 return false;
340 }
341 if tokio::fs::metadata(&git_file)
343 .await
344 .map(|m| m.is_dir())
345 .unwrap_or(false)
346 {
347 return true;
348 }
349
350 let content = match tokio::fs::read_to_string(&git_file).await {
351 Ok(c) => c,
352 Err(_) => 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 .map(|m| m.is_dir())
365 .unwrap_or(false);
366 }
367 false
368}
369
370async fn resolve_parent_branch(
376 store: &Option<Arc<TaskStore>>,
377 parent_id: Option<i64>,
378 worktrees_base: &Path,
379) -> Option<(String, PathBuf)> {
380 let parent_id = parent_id?;
381 let parent = store::opt_store_get_task_by_id(store, parent_id).await?;
382 if parent.branch.is_empty() {
383 return None;
384 }
385 let wt = if !parent.worktree.is_empty() {
386 let exists = tokio::fs::metadata(&parent.worktree)
388 .await
389 .map(|m| m.is_dir())
390 .unwrap_or(false);
391 if exists {
392 PathBuf::from(&parent.worktree)
393 } else {
394 worktrees_base.join(&parent.branch)
395 }
396 } else {
397 worktrees_base.join(&parent.branch)
398 };
399 tracing::info!(
400 parent_id,
401 branch = %parent.branch,
402 worktree = %wt.display(),
403 "child task inheriting parent branch"
404 );
405 Some((parent.branch, wt))
406}
407
408pub async fn setup_worktree(
412 task_id: &str,
413 title: &str,
414 project_dir: &Path,
415 store: &Option<Arc<TaskStore>>,
416 repo: &str,
417) -> anyhow::Result<WorktreeSetup> {
418 let main_dir = resolve_main_repo(project_dir).await;
420 let default_branch = detect_default_branch(&main_dir).await;
421
422 let worktrees_base = project_worktrees_dir(&main_dir);
423 tokio::fs::create_dir_all(&worktrees_base).await?;
424
425 let task_record = store::opt_store_get_task(store, repo, task_id).await;
427 let (saved_branch, saved_worktree, parent_id) = task_record
428 .as_ref()
429 .map(|t| {
430 (
431 Some(t.branch.clone()),
432 Some(t.worktree.clone()),
433 t.parent_id,
434 )
435 })
436 .unwrap_or((None, None, None));
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(|n| n.to_string_lossy().to_string())
504 .unwrap_or_else(|| branch_name(task_id, title));
505 tracing::info!(task_id, worktree = %existing_dir.display(), "found existing worktree");
506 (bn, existing_dir)
507 } else {
508 let bn = branch_name(task_id, title);
509 (bn.clone(), worktrees_base.join(&bn))
510 }
511 }
512 };
513
514 if branch_name_str.is_empty() {
515 anyhow::bail!("empty branch name for task {task_id}");
516 }
517
518 if tokio::fs::metadata(&worktree_dir).await.is_ok() {
527 let _ = Command::new("git")
528 .args(["rebase", "--abort"])
529 .current_dir(&worktree_dir)
530 .output_with_context()
531 .await;
532
533 let index_ok = Command::new("git")
539 .args(["status", "--porcelain"])
540 .current_dir(&worktree_dir)
541 .output_with_context()
542 .await
543 .map(|o| o.status.success())
544 .unwrap_or(false);
545
546 if !index_ok {
547 let stderr_hint = Command::new("git")
548 .args(["status", "--porcelain"])
549 .current_dir(&worktree_dir)
550 .output_with_context()
551 .await
552 .map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
553 .unwrap_or_default();
554 tracing::warn!(
555 task_id,
556 worktree = %worktree_dir.display(),
557 stderr = %stderr_hint,
558 "corrupted worktree index detected — removing and recreating worktree"
559 );
560 let _ = Command::new("git")
562 .args([
563 "-C",
564 &main_dir.to_string_lossy(),
565 "worktree",
566 "remove",
567 "--force",
568 &worktree_dir.to_string_lossy(),
569 ])
570 .output_with_context()
571 .await;
572 if tokio::fs::metadata(&worktree_dir).await.is_ok() {
575 let _ = tokio::fs::remove_dir_all(&worktree_dir).await;
576 }
577 let _ = Command::new("git")
579 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
580 .output_with_context()
581 .await;
582 }
583 }
584
585 if tokio::fs::metadata(&worktree_dir).await.is_err() {
587 tracing::info!(task_id, worktree = %worktree_dir.display(), "creating worktree");
588
589 let _ = crate::engine::runner::git_ops::git()
595 .await
596 .args([
597 "-C",
598 &main_dir.to_string_lossy(),
599 "fetch",
600 "--prune",
601 "origin",
602 ])
603 .output_with_context()
604 .await;
605
606 let start_point = resolve_branch_start_point(
611 &main_dir.to_string_lossy(),
612 &branch_name_str,
613 &default_branch,
614 )
615 .await;
616 let _ = Command::new("git")
617 .args([
618 "-C",
619 &main_dir.to_string_lossy(),
620 "branch",
621 &branch_name_str,
622 &start_point,
623 ])
624 .output_with_context()
625 .await;
626
627 if let Some(parent) = worktree_dir.parent() {
629 tokio::fs::create_dir_all(parent).await?;
630 }
631
632 let cloned = match super::cow::create_worktree(
633 &main_dir,
634 &project_name(&main_dir),
635 &worktree_dir,
636 &branch_name_str,
637 &default_branch,
638 )
639 .await
640 {
641 Ok(()) => {
642 tracing::info!(task_id, "worktree cloned from project template");
643 true
644 }
645 Err(e) => {
646 tracing::debug!(task_id, error = %e, "copy-on-write worktree unavailable, using git worktree add");
647 false
648 }
649 };
650
651 if !cloned {
652 let output = Command::new("git")
653 .args([
654 "-C",
655 &main_dir.to_string_lossy(),
656 "worktree",
657 "add",
658 &worktree_dir.to_string_lossy(),
659 &branch_name_str,
660 ])
661 .output_with_context()
662 .await?;
663
664 if !output.status.success() && tokio::fs::metadata(&worktree_dir).await.is_err() {
665 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
666 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
667 tracing::warn!(
669 task_id,
670 stdout = %stdout,
671 stderr = %stderr,
672 "worktree creation failed, retrying after prune"
673 );
674
675 let _ = Command::new("git")
676 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
677 .output_with_context()
678 .await;
679
680 let _ = Command::new("git")
681 .args([
682 "-C",
683 &main_dir.to_string_lossy(),
684 "branch",
685 "-D",
686 &branch_name_str,
687 ])
688 .output_with_context()
689 .await;
690
691 let _ = Command::new("git")
692 .args([
693 "-C",
694 &main_dir.to_string_lossy(),
695 "branch",
696 &branch_name_str,
697 &start_point,
698 ])
699 .output_with_context()
700 .await;
701
702 let retry_output = Command::new("git")
703 .args([
704 "-C",
705 &main_dir.to_string_lossy(),
706 "worktree",
707 "add",
708 &worktree_dir.to_string_lossy(),
709 &branch_name_str,
710 ])
711 .output_with_context()
712 .await;
713
714 let mut retry_stdout = String::new();
715 let mut retry_stderr = String::new();
716 let mut retry_error = String::new();
717 match retry_output {
718 Ok(output) => {
719 retry_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
720 retry_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
721 tracing::warn!(
722 task_id,
723 stdout = %retry_stdout,
724 stderr = %retry_stderr,
725 "worktree creation retry failed"
726 );
727 }
728 Err(err) => {
729 retry_error = err.to_string();
730 tracing::warn!(
731 task_id,
732 error = %retry_error,
733 "worktree creation retry failed to run"
734 );
735 }
736 }
737
738 if tokio::fs::metadata(&worktree_dir).await.is_err() {
739 anyhow::bail!(
740 "failed to create worktree at {} for task {} (stdout: {}, stderr: {}, retry stdout: {}, retry stderr: {}, retry error: {})",
741 worktree_dir.display(),
742 task_id,
743 stdout,
744 stderr,
745 retry_stdout,
746 retry_stderr,
747 retry_error
748 );
749 }
750 }
751 }
752 }
753
754 if !task_id.starts_with("internal:") {
758 if let Ok(issue_num) = task_id.parse::<u64>() {
759 if let Ok(gh) = crate::github::http::GhHttp::new() {
760 match gh
761 .link_issue_to_branch(repo, issue_num, &branch_name_str)
762 .await
763 {
764 Ok(id) => {
765 tracing::info!(task_id, branch = %branch_name_str, linked = %id, "linked issue to branch");
766 }
767 Err(e) => {
768 tracing::debug!(task_id, error = %e, "failed to link issue to branch (non-fatal)");
769 }
770 }
771 }
772 }
773 }
774
775 if let Err(e) = store::store_set_result(
777 store,
778 repo,
779 task_id,
780 &[
781 (
782 "worktree",
783 serde_json::json!(worktree_dir.display().to_string()),
784 ),
785 ("branch", serde_json::json!(branch_name_str)),
786 ],
787 )
788 .await
789 {
790 tracing::warn!(task_id, err = %e, "failed to save worktree info to store (non-fatal)");
791 }
792
793 tracing::info!(
794 task_id,
795 worktree = %worktree_dir.display(),
796 branch = %branch_name_str,
797 "worktree ready"
798 );
799
800 Ok(WorktreeSetup {
801 work_dir: worktree_dir,
802 branch: branch_name_str,
803 default_branch,
804 main_project_dir: main_dir,
805 })
806}
807
808async fn discard_worktree(main_dir: &Path, worktree_dir: &Path) {
810 let _ = Command::new("git")
811 .args([
812 "-C",
813 &main_dir.to_string_lossy(),
814 "worktree",
815 "remove",
816 "--force",
817 &worktree_dir.to_string_lossy(),
818 ])
819 .output_with_context()
820 .await;
821 if tokio::fs::metadata(worktree_dir).await.is_ok() {
822 let _ = tokio::fs::remove_dir_all(worktree_dir).await;
823 }
824 let _ = Command::new("git")
825 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
826 .output_with_context()
827 .await;
828}
829
830async fn find_existing_worktree(worktrees_base: &Path, task_id: &str) -> Option<PathBuf> {
831 let (prefix_new, legacy_prefixes) = if let Some(num) = task_id.strip_prefix("internal:") {
832 (
833 format!("internal-{num}-"),
834 vec![
835 format!("gh-task-internal-{num}-"),
836 format!("gh-issue-internal-{num}-"),
837 ],
838 )
839 } else {
840 let sanitized_id = sanitize_task_id(task_id);
841 (
842 format!("gh-issue-{sanitized_id}-"),
843 vec![format!("gh-task-{sanitized_id}-")],
844 )
845 };
846
847 let mut entries = match tokio::fs::read_dir(worktrees_base).await {
848 Ok(e) => e,
849 Err(e) => {
850 tracing::warn!(
851 error = %e,
852 path = %worktrees_base.display(),
853 "failed to read worktrees directory while searching for existing worktree"
854 );
855 return None;
856 }
857 };
858 loop {
859 let next_entry = match entries.next_entry().await {
860 Ok(Some(entry)) => entry,
861 Ok(None) => break,
862 Err(e) => {
863 tracing::warn!(error = %e, "failed to read directory entry while searching for worktree");
865 continue;
866 }
867 };
868
869 let name = next_entry.file_name().to_string_lossy().to_string();
870 let matches =
871 name.starts_with(&prefix_new) || legacy_prefixes.iter().any(|p| name.starts_with(p));
872 if matches {
873 match next_entry.metadata().await {
874 Ok(metadata) if metadata.is_dir() => return Some(next_entry.path()),
875 Ok(_) => {}
876 Err(e) => {
877 tracing::warn!(
878 error = %e,
879 path = %next_entry.path().display(),
880 "failed to read metadata for candidate worktree entry"
881 );
882 }
883 }
884 }
885 }
886
887 None
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 fn git(dir: &std::path::Path, args: &[&str]) {
896 let mut cmd = std::process::Command::new("git");
897 cmd.current_dir(dir);
898 if args.first() == Some(&"commit") {
900 cmd.args(["-c", "commit.gpgsign=false"]);
901 }
902 let out = cmd
903 .args(args)
904 .output()
905 .unwrap_or_else(|e| panic!("git {:?} failed to start: {e}", args));
906 if !out.status.success() {
907 panic!(
908 "git {:?} failed:\nstdout: {}\nstderr: {}",
909 args,
910 String::from_utf8_lossy(&out.stdout),
911 String::from_utf8_lossy(&out.stderr)
912 );
913 }
914 }
915
916 #[tokio::test]
920 async fn resolve_branch_start_point_prefers_remote_over_default() {
921 let remote = tempfile::tempdir().unwrap();
922 git(remote.path(), &["init", "--bare"]);
923
924 let setup = tempfile::tempdir().unwrap();
926 git(
927 setup.path(),
928 &["clone", remote.path().to_str().unwrap(), "."],
929 );
930 git(setup.path(), &["config", "user.email", "t@t.com"]);
931 git(setup.path(), &["config", "user.name", "T"]);
932
933 std::fs::write(setup.path().join("README.md"), "main").unwrap();
935 git(setup.path(), &["add", "."]);
936 git(setup.path(), &["commit", "-m", "init"]);
937 git(setup.path(), &["push", "origin", "HEAD:main"]);
938
939 git(
941 setup.path(),
942 &["checkout", "-b", "gh-issue-42-fix-login-bug"],
943 );
944 std::fs::write(setup.path().join("feature.txt"), "work").unwrap();
945 git(setup.path(), &["add", "."]);
946 git(setup.path(), &["commit", "-m", "feature work"]);
947 git(
948 setup.path(),
949 &["push", "origin", "gh-issue-42-fix-login-bug"],
950 );
951
952 let local = tempfile::tempdir().unwrap();
955 git(
956 local.path(),
957 &["clone", remote.path().to_str().unwrap(), "."],
958 );
959 git(local.path(), &["config", "user.email", "t@t.com"]);
960 git(local.path(), &["config", "user.name", "T"]);
961 git(local.path(), &["fetch", "--all"]);
962 let has_local = std::process::Command::new("git")
964 .args([
965 "-C",
966 local.path().to_str().unwrap(),
967 "rev-parse",
968 "--verify",
969 "gh-issue-42-fix-login-bug",
970 ])
971 .output()
972 .unwrap()
973 .status
974 .success();
975 assert!(!has_local, "local branch should not exist before the test");
976
977 let repo_root = local.path().to_str().unwrap();
978
979 let start =
981 resolve_branch_start_point(repo_root, "gh-issue-42-fix-login-bug", "main").await;
982 assert_eq!(
983 start, "origin/gh-issue-42-fix-login-bug",
984 "should use remote branch as start point when origin/<branch> exists"
985 );
986 }
987
988 #[tokio::test]
992 async fn resolve_branch_start_point_prefers_origin_default_over_local() {
993 let remote = tempfile::tempdir().unwrap();
994 git(remote.path(), &["init", "--bare"]);
995
996 let local = tempfile::tempdir().unwrap();
997 git(
998 local.path(),
999 &["clone", remote.path().to_str().unwrap(), "."],
1000 );
1001 git(local.path(), &["config", "user.email", "t@t.com"]);
1002 git(local.path(), &["config", "user.name", "T"]);
1003
1004 std::fs::write(local.path().join("README.md"), "main").unwrap();
1006 git(local.path(), &["add", "."]);
1007 git(local.path(), &["commit", "-m", "init"]);
1008 git(local.path(), &["push", "origin", "HEAD:main"]);
1009 git(local.path(), &["fetch", "--all"]);
1010
1011 let repo_root = local.path().to_str().unwrap();
1012
1013 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1015 assert_eq!(
1016 start, "origin/main",
1017 "should anchor on origin/<default_branch> instead of local default"
1018 );
1019 }
1020
1021 fn git_stdout(dir: &std::path::Path, args: &[&str]) -> String {
1023 let out = std::process::Command::new("git")
1024 .args(args)
1025 .current_dir(dir)
1026 .output()
1027 .unwrap_or_else(|e| panic!("git {:?} failed to start: {e}", args));
1028 if !out.status.success() {
1029 panic!(
1030 "git {:?} failed:\nstdout: {}\nstderr: {}",
1031 args,
1032 String::from_utf8_lossy(&out.stdout),
1033 String::from_utf8_lossy(&out.stderr)
1034 );
1035 }
1036 String::from_utf8_lossy(&out.stdout).trim().to_string()
1037 }
1038
1039 #[tokio::test]
1044 #[serial_test::serial(orch_home)]
1045 async fn setup_worktree_anchors_on_origin_not_local_main() {
1046 let orch_home_dir = tempfile::tempdir().unwrap();
1048 let prev_orch_home = std::env::var("ORCH_HOME").ok();
1049 std::env::set_var("ORCH_HOME", orch_home_dir.path());
1050
1051 let remote = tempfile::tempdir().unwrap();
1053 git(remote.path(), &["init", "--bare"]);
1054
1055 let project = tempfile::tempdir().unwrap();
1057 git(
1058 project.path(),
1059 &["clone", remote.path().to_str().unwrap(), "."],
1060 );
1061 git(project.path(), &["config", "user.email", "t@t.com"]);
1062 git(project.path(), &["config", "user.name", "T"]);
1063
1064 std::fs::write(project.path().join("README.md"), "pushed\n").unwrap();
1066 git(project.path(), &["add", "."]);
1067 git(project.path(), &["commit", "-m", "pushed baseline"]);
1068 git(project.path(), &["push", "origin", "HEAD:main"]);
1069 let pushed_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1070
1071 git(project.path(), &["remote", "set-head", "origin", "main"]);
1073
1074 std::fs::write(project.path().join("LOCAL_ONLY.md"), "unpushed\n").unwrap();
1076 git(project.path(), &["add", "."]);
1077 git(project.path(), &["commit", "-m", "local unpushed commit"]);
1078 let local_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1079 assert_ne!(
1080 pushed_sha, local_sha,
1081 "precondition: local main must be ahead of origin/main"
1082 );
1083
1084 let setup = setup_worktree(
1086 "internal:1",
1087 "leak guard",
1088 project.path(),
1089 &None,
1090 "test/repo",
1091 )
1092 .await
1093 .expect("setup_worktree should succeed");
1094
1095 let wt_sha = git_stdout(&setup.work_dir, &["rev-parse", "HEAD"]);
1097 assert_eq!(
1098 wt_sha, pushed_sha,
1099 "worktree HEAD must equal origin/main, not local main"
1100 );
1101 assert_ne!(
1102 wt_sha, local_sha,
1103 "worktree must NOT include the operator's unpushed commit"
1104 );
1105 assert!(
1106 !setup.work_dir.join("LOCAL_ONLY.md").exists(),
1107 "unpushed-only file must not appear in the worktree"
1108 );
1109
1110 match prev_orch_home {
1112 Some(v) => std::env::set_var("ORCH_HOME", v),
1113 None => std::env::remove_var("ORCH_HOME"),
1114 }
1115 }
1116
1117 #[tokio::test]
1120 async fn resolve_branch_start_point_falls_back_to_local_default() {
1121 let local = tempfile::tempdir().unwrap();
1122 git(local.path(), &["init"]);
1123 git(local.path(), &["config", "user.email", "t@t.com"]);
1124 git(local.path(), &["config", "user.name", "T"]);
1125 git(local.path(), &["checkout", "-b", "main"]);
1126 std::fs::write(local.path().join("README.md"), "main").unwrap();
1127 git(local.path(), &["add", "."]);
1128 git(local.path(), &["commit", "-m", "init"]);
1129
1130 let repo_root = local.path().to_str().unwrap();
1131
1132 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1134 assert_eq!(
1135 start, "main",
1136 "should fall back to local default_branch when no remote refs exist"
1137 );
1138 }
1139
1140 #[test]
1141 fn branch_name_basic() {
1142 let name = branch_name("42", "Fix login bug");
1143 assert_eq!(name, "gh-issue-42-fix-login-bug");
1144 }
1145
1146 #[test]
1147 fn branch_name_internal_task() {
1148 let name = branch_name("internal:8", "Fix login bug");
1149 assert_eq!(name, "internal-8-fix-login-bug");
1150 }
1151
1152 #[test]
1153 fn branch_name_special_chars() {
1154 let name = branch_name("7", "Add OAuth2/OIDC (Google & GitHub)");
1155 assert_eq!(name, "gh-issue-7-add-oauth2-oidc-google-github");
1156 }
1157
1158 #[test]
1159 fn branch_name_truncates_long_slug() {
1160 let title =
1161 "This is a very long task title that should be truncated to forty characters maximum";
1162 let name = branch_name("1", title);
1163 let slug = name.strip_prefix("gh-issue-1-").unwrap();
1165 assert!(slug.len() <= 40, "slug length {} > 40", slug.len());
1166 }
1167
1168 #[test]
1169 fn branch_name_trims_trailing_dashes() {
1170 let name = branch_name("5", "Fix bug---");
1171 assert!(
1172 !name.ends_with('-'),
1173 "branch name should not end with dash: {name}"
1174 );
1175 }
1176
1177 #[test]
1178 fn branch_name_empty_title() {
1179 let name = branch_name("99", "");
1180 assert_eq!(name, "gh-issue-99");
1181 }
1182
1183 #[test]
1184 fn branch_name_empty_title_internal() {
1185 let name = branch_name("internal:99", "");
1186 assert_eq!(name, "internal-99");
1187 }
1188
1189 #[test]
1190 fn branch_name_all_special_chars() {
1191 let name = branch_name("10", "--- ??? ---");
1192 assert_eq!(name, "gh-issue-10");
1193 assert!(!name.is_empty());
1194 }
1195
1196 #[test]
1197 fn branch_name_chinese_chars_no_panic() {
1198 let name = branch_name("265", "implement 用户认证 user auth");
1199 assert!(name.starts_with("gh-issue-265-"));
1200 assert!(!name.is_empty());
1201 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1202 assert!(slug.len() <= 40, "slug too long: {}", slug.len());
1203 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1204 }
1205
1206 #[test]
1207 fn task_id_from_worktree_name_parses_issue_branches() {
1208 assert_eq!(
1209 task_id_from_worktree_name("gh-issue-42-fix-login"),
1210 Some("42".to_string())
1211 );
1212 assert_eq!(
1213 task_id_from_worktree_name("gh-task-42-fix-login"),
1214 Some("42".to_string())
1215 );
1216 }
1217
1218 #[test]
1219 fn task_id_from_worktree_name_parses_internal_branches() {
1220 assert_eq!(
1221 task_id_from_worktree_name("internal-8-fix-login"),
1222 Some("internal:8".to_string())
1223 );
1224 assert_eq!(
1225 task_id_from_worktree_name("gh-issue-internal-8-fix-login"),
1226 Some("internal:8".to_string())
1227 );
1228 }
1229
1230 #[test]
1231 fn task_id_from_worktree_name_returns_none_for_unknown_names() {
1232 assert!(task_id_from_worktree_name("random-worktree").is_none());
1233 }
1234
1235 #[test]
1236 fn branch_name_emoji_no_panic() {
1237 let name = branch_name("265", "fix 🚀 deployment pipeline 🔥");
1238 assert!(name.starts_with("gh-issue-265-"));
1239 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1240 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1241 }
1242
1243 #[test]
1244 fn branch_name_all_non_ascii_falls_back_to_task_id() {
1245 let name = branch_name("265", "用户认证 실행 тест");
1246 assert_eq!(name, "gh-issue-265");
1247 }
1248
1249 #[tokio::test]
1250 async fn find_existing_worktree_returns_none_for_missing() {
1251 let dir = tempfile::tempdir().unwrap();
1252 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1253 }
1254
1255 #[tokio::test]
1256 async fn find_existing_worktree_matches_prefix() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let wt_dir = dir.path().join("gh-issue-42-fix-login-bug");
1259 tokio::fs::create_dir(&wt_dir).await.unwrap();
1260
1261 let result = find_existing_worktree(dir.path(), "42").await;
1262 assert_eq!(result, Some(wt_dir));
1263 }
1264
1265 #[tokio::test]
1266 async fn find_existing_worktree_matches_internal_prefix() {
1267 let dir = tempfile::tempdir().unwrap();
1268 let wt_dir = dir.path().join("internal-8-fix");
1269 tokio::fs::create_dir(&wt_dir).await.unwrap();
1270
1271 let result = find_existing_worktree(dir.path(), "internal:8").await;
1272 assert_eq!(result, Some(wt_dir));
1273 }
1274
1275 #[tokio::test]
1276 async fn find_existing_worktree_matches_legacy_gh_task_prefix() {
1277 let dir = tempfile::tempdir().unwrap();
1279 let wt_dir = dir.path().join("gh-task-internal-8-fix");
1280 tokio::fs::create_dir(&wt_dir).await.unwrap();
1281
1282 let result = find_existing_worktree(dir.path(), "internal:8").await;
1283 assert_eq!(result, Some(wt_dir));
1284 }
1285
1286 #[tokio::test]
1287 async fn find_existing_worktree_matches_legacy_external_prefix() {
1288 let dir = tempfile::tempdir().unwrap();
1290 let wt_dir = dir.path().join("gh-task-42-fix-login-bug");
1291 tokio::fs::create_dir(&wt_dir).await.unwrap();
1292
1293 let result = find_existing_worktree(dir.path(), "42").await;
1294 assert_eq!(result, Some(wt_dir));
1295 }
1296
1297 #[tokio::test]
1298 async fn find_existing_worktree_ignores_other_tasks() {
1299 let dir = tempfile::tempdir().unwrap();
1300 tokio::fs::create_dir(dir.path().join("gh-issue-43-other-task"))
1301 .await
1302 .unwrap();
1303
1304 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1305 }
1306
1307 #[tokio::test]
1311 async fn resolve_parent_branch_no_store_returns_none() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let result = resolve_parent_branch(&None, Some(1), dir.path()).await;
1314 assert!(result.is_none());
1315 }
1316
1317 #[tokio::test]
1319 async fn resolve_parent_branch_no_parent_id_returns_none() {
1320 let dir = tempfile::tempdir().unwrap();
1321 let result = resolve_parent_branch(&None, None, dir.path()).await;
1322 assert!(result.is_none());
1323 }
1324
1325 #[tokio::test]
1327 async fn resolve_parent_branch_empty_parent_branch_returns_none() {
1328 let db_dir = tempfile::tempdir().unwrap();
1329 let db_path = db_dir.path().join("test.db");
1330 let store = Arc::new(
1331 crate::store::TaskStore::open_single(&db_path)
1332 .await
1333 .unwrap(),
1334 );
1335
1336 let parent_id = store
1338 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1339 .await
1340 .unwrap();
1341
1342 let dir = tempfile::tempdir().unwrap();
1343 let store_opt = Some(store);
1344 let result = resolve_parent_branch(&store_opt, Some(parent_id), dir.path()).await;
1345 assert!(result.is_none());
1346 }
1347
1348 #[tokio::test]
1350 async fn resolve_parent_branch_returns_parent_branch_and_worktree() {
1351 let db_dir = tempfile::tempdir().unwrap();
1352 let db_path = db_dir.path().join("test.db");
1353 let store = Arc::new(
1354 crate::store::TaskStore::open_single(&db_path)
1355 .await
1356 .unwrap(),
1357 );
1358
1359 let parent_id = store
1361 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1362 .await
1363 .unwrap();
1364 store
1365 .set_fields(
1366 parent_id,
1367 &[("branch", serde_json::json!("gh-issue-99-parent-task"))],
1368 )
1369 .await
1370 .unwrap();
1371
1372 let worktrees_base = tempfile::tempdir().unwrap();
1373 let store_opt = Some(store);
1374 let result =
1375 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1376
1377 let (branch, wt) = result.expect("should return parent branch");
1378 assert_eq!(branch, "gh-issue-99-parent-task");
1379 assert_eq!(wt, worktrees_base.path().join("gh-issue-99-parent-task"));
1380 }
1381
1382 #[tokio::test]
1384 async fn resolve_parent_branch_prefers_existing_worktree_path() {
1385 let db_dir = tempfile::tempdir().unwrap();
1386 let db_path = db_dir.path().join("test.db");
1387 let store = Arc::new(
1388 crate::store::TaskStore::open_single(&db_path)
1389 .await
1390 .unwrap(),
1391 );
1392
1393 let worktrees_base = tempfile::tempdir().unwrap();
1394 let parent_wt_dir = worktrees_base.path().join("custom-worktree-path");
1396 tokio::fs::create_dir_all(&parent_wt_dir).await.unwrap();
1397
1398 let parent_id = store
1399 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1400 .await
1401 .unwrap();
1402 store
1403 .set_fields(
1404 parent_id,
1405 &[
1406 ("branch", serde_json::json!("gh-issue-99-parent-task")),
1407 (
1408 "worktree",
1409 serde_json::json!(parent_wt_dir.to_string_lossy().as_ref()),
1410 ),
1411 ],
1412 )
1413 .await
1414 .unwrap();
1415
1416 let store_opt = Some(store);
1417 let result =
1418 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1419
1420 let (branch, wt) = result.expect("should return parent branch");
1421 assert_eq!(branch, "gh-issue-99-parent-task");
1422 assert_eq!(wt, parent_wt_dir);
1423 }
1424
1425 #[tokio::test]
1430 async fn concurrent_worktree_checks_no_starvation() {
1431 let temp_dir = tempfile::tempdir().unwrap();
1432 let worktrees_base = temp_dir.path().join("worktrees");
1433 tokio::fs::create_dir_all(&worktrees_base).await.unwrap();
1434
1435 let wt_dir = worktrees_base.join("gh-issue-123-test");
1437 tokio::fs::create_dir_all(&wt_dir).await.unwrap();
1438 let gitdir_target = temp_dir
1440 .path()
1441 .join(".git")
1442 .join("worktrees")
1443 .join("gh-issue-123-test");
1444 tokio::fs::create_dir_all(&gitdir_target).await.unwrap();
1445 let git_file = wt_dir.join(".git");
1446 tokio::fs::write(&git_file, format!("gitdir: {}\n", gitdir_target.display()))
1447 .await
1448 .unwrap();
1449
1450 let handles: Vec<_> = (0..20)
1453 .map(|i| {
1454 let wb = worktrees_base.clone();
1455 let wd = wt_dir.clone();
1456 tokio::spawn(async move {
1457 if i % 2 == 0 {
1458 list_project_worktrees(&wb).await.ok();
1459 } else {
1460 validate_worktree_gitdir(&wd).await;
1461 }
1462 })
1463 })
1464 .collect();
1465
1466 let results = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1468 futures::future::join_all(handles).await
1469 })
1470 .await;
1471
1472 assert!(
1473 results.is_ok(),
1474 "concurrent worktree checks timed out (starvation?)"
1475 );
1476 }
1477
1478 #[test]
1481 fn normalize_branch_name_strips_origin_prefix() {
1482 assert_eq!(
1483 normalize_branch_name("origin/main"),
1484 Some("main".to_string())
1485 );
1486 assert_eq!(
1487 normalize_branch_name("origin/develop"),
1488 Some("develop".to_string())
1489 );
1490 }
1491
1492 #[test]
1493 fn normalize_branch_name_strips_full_ref_path() {
1494 assert_eq!(
1495 normalize_branch_name("refs/remotes/origin/main"),
1496 Some("main".to_string())
1497 );
1498 assert_eq!(
1499 normalize_branch_name("refs/remotes/origin/develop"),
1500 Some("develop".to_string())
1501 );
1502 }
1503
1504 #[test]
1505 fn normalize_branch_name_strips_remotes_prefix() {
1506 assert_eq!(
1507 normalize_branch_name("remotes/origin/main"),
1508 Some("main".to_string())
1509 );
1510 }
1511
1512 #[test]
1513 fn normalize_branch_name_passes_through_plain_branch() {
1514 assert_eq!(normalize_branch_name("main"), Some("main".to_string()));
1515 assert_eq!(normalize_branch_name("master"), Some("master".to_string()));
1516 assert_eq!(
1517 normalize_branch_name("feature/foo"),
1518 Some("feature/foo".to_string())
1519 );
1520 }
1521
1522 #[test]
1523 fn normalize_branch_name_rejects_empty_and_refs_only() {
1524 assert_eq!(normalize_branch_name(""), None);
1525 assert_eq!(normalize_branch_name(" "), None);
1526 assert_eq!(
1527 normalize_branch_name("refs/heads/main"),
1528 Some("main".to_string())
1529 );
1530 }
1531}