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() {
529 let _ = Command::new("git")
530 .args(["rebase", "--abort"])
531 .current_dir(&worktree_dir)
532 .output_with_context()
533 .await;
534
535 let index_ok = Command::new("git")
541 .args(["status", "--porcelain"])
542 .current_dir(&worktree_dir)
543 .output_with_context()
544 .await
545 .is_ok_and(|o| o.status.success());
546
547 if !index_ok {
548 let stderr_hint = Command::new("git")
549 .args(["status", "--porcelain"])
550 .current_dir(&worktree_dir)
551 .output_with_context()
552 .await
553 .map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
554 .unwrap_or_default();
555 tracing::warn!(
556 task_id,
557 worktree = %worktree_dir.display(),
558 stderr = %stderr_hint,
559 "corrupted worktree index detected — removing and recreating worktree"
560 );
561 let _ = Command::new("git")
563 .args([
564 "-C",
565 &main_dir.to_string_lossy(),
566 "worktree",
567 "remove",
568 "--force",
569 &worktree_dir.to_string_lossy(),
570 ])
571 .output_with_context()
572 .await;
573 if tokio::fs::metadata(&worktree_dir).await.is_ok() {
576 let _ = tokio::fs::remove_dir_all(&worktree_dir).await;
577 }
578 let _ = Command::new("git")
580 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
581 .output_with_context()
582 .await;
583 }
584 }
585
586 if tokio::fs::metadata(&worktree_dir).await.is_err() {
588 tracing::info!(task_id, worktree = %worktree_dir.display(), "creating worktree");
589
590 let _ = crate::engine::runner::git_ops::git()
596 .await
597 .args([
598 "-C",
599 &main_dir.to_string_lossy(),
600 "fetch",
601 "--prune",
602 "origin",
603 ])
604 .output_with_context()
605 .await;
606
607 let start_point = resolve_branch_start_point(
612 &main_dir.to_string_lossy(),
613 &branch_name_str,
614 &default_branch,
615 )
616 .await;
617 let _ = Command::new("git")
618 .args([
619 "-C",
620 &main_dir.to_string_lossy(),
621 "branch",
622 &branch_name_str,
623 &start_point,
624 ])
625 .output_with_context()
626 .await;
627
628 if let Some(parent) = worktree_dir.parent() {
630 tokio::fs::create_dir_all(parent).await?;
631 }
632
633 let cloned = match super::cow::create_worktree(
634 &main_dir,
635 &project_name(&main_dir),
636 &worktree_dir,
637 &branch_name_str,
638 &default_branch,
639 )
640 .await
641 {
642 Ok(()) => {
643 tracing::info!(task_id, "worktree cloned from project template");
644 true
645 }
646 Err(e) => {
647 tracing::debug!(task_id, error = %e, "copy-on-write worktree unavailable, using git worktree add");
648 false
649 }
650 };
651
652 if !cloned {
653 let output = Command::new("git")
654 .args([
655 "-C",
656 &main_dir.to_string_lossy(),
657 "worktree",
658 "add",
659 &worktree_dir.to_string_lossy(),
660 &branch_name_str,
661 ])
662 .output_with_context()
663 .await?;
664
665 if !output.status.success() && tokio::fs::metadata(&worktree_dir).await.is_err() {
666 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
667 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
668 tracing::warn!(
670 task_id,
671 stdout = %stdout,
672 stderr = %stderr,
673 "worktree creation failed, retrying after prune"
674 );
675
676 let _ = Command::new("git")
677 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
678 .output_with_context()
679 .await;
680
681 let _ = Command::new("git")
682 .args([
683 "-C",
684 &main_dir.to_string_lossy(),
685 "branch",
686 "-D",
687 &branch_name_str,
688 ])
689 .output_with_context()
690 .await;
691
692 let _ = Command::new("git")
693 .args([
694 "-C",
695 &main_dir.to_string_lossy(),
696 "branch",
697 &branch_name_str,
698 &start_point,
699 ])
700 .output_with_context()
701 .await;
702
703 let retry_output = Command::new("git")
704 .args([
705 "-C",
706 &main_dir.to_string_lossy(),
707 "worktree",
708 "add",
709 &worktree_dir.to_string_lossy(),
710 &branch_name_str,
711 ])
712 .output_with_context()
713 .await;
714
715 let mut retry_stdout = String::new();
716 let mut retry_stderr = String::new();
717 let mut retry_error = String::new();
718 match retry_output {
719 Ok(output) => {
720 retry_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
721 retry_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
722 tracing::warn!(
723 task_id,
724 stdout = %retry_stdout,
725 stderr = %retry_stderr,
726 "worktree creation retry failed"
727 );
728 }
729 Err(err) => {
730 retry_error = err.to_string();
731 tracing::warn!(
732 task_id,
733 error = %retry_error,
734 "worktree creation retry failed to run"
735 );
736 }
737 }
738
739 if tokio::fs::metadata(&worktree_dir).await.is_err() {
740 anyhow::bail!(
741 "failed to create worktree at {} for task {} (stdout: {}, stderr: {}, retry stdout: {}, retry stderr: {}, retry error: {})",
742 worktree_dir.display(),
743 task_id,
744 stdout,
745 stderr,
746 retry_stdout,
747 retry_stderr,
748 retry_error
749 );
750 }
751 }
752 }
753 }
754
755 if !task_id.starts_with("internal:")
759 && let Ok(issue_num) = task_id.parse::<u64>()
760 && let Ok(gh) = crate::github::http::GhHttp::new()
761 {
762 match gh
763 .link_issue_to_branch(repo, issue_num, &branch_name_str)
764 .await
765 {
766 Ok(id) => {
767 tracing::info!(task_id, branch = %branch_name_str, linked = %id, "linked issue to branch");
768 }
769 Err(e) => {
770 tracing::debug!(task_id, error = %e, "failed to link issue to branch (non-fatal)");
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 {args:?} failed to start: {e}"));
906 assert!(
907 out.status.success(),
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 #[tokio::test]
919 async fn resolve_branch_start_point_prefers_remote_over_default() {
920 let remote = tempfile::tempdir().unwrap();
921 git(remote.path(), &["init", "--bare"]);
922
923 let setup = tempfile::tempdir().unwrap();
925 git(
926 setup.path(),
927 &["clone", remote.path().to_str().unwrap(), "."],
928 );
929 git(setup.path(), &["config", "user.email", "t@t.com"]);
930 git(setup.path(), &["config", "user.name", "T"]);
931
932 std::fs::write(setup.path().join("README.md"), "main").unwrap();
934 git(setup.path(), &["add", "."]);
935 git(setup.path(), &["commit", "-m", "init"]);
936 git(setup.path(), &["push", "origin", "HEAD:main"]);
937
938 git(
940 setup.path(),
941 &["checkout", "-b", "gh-issue-42-fix-login-bug"],
942 );
943 std::fs::write(setup.path().join("feature.txt"), "work").unwrap();
944 git(setup.path(), &["add", "."]);
945 git(setup.path(), &["commit", "-m", "feature work"]);
946 git(
947 setup.path(),
948 &["push", "origin", "gh-issue-42-fix-login-bug"],
949 );
950
951 let local = tempfile::tempdir().unwrap();
954 git(
955 local.path(),
956 &["clone", remote.path().to_str().unwrap(), "."],
957 );
958 git(local.path(), &["config", "user.email", "t@t.com"]);
959 git(local.path(), &["config", "user.name", "T"]);
960 git(local.path(), &["fetch", "--all"]);
961 let has_local = std::process::Command::new("git")
963 .args([
964 "-C",
965 local.path().to_str().unwrap(),
966 "rev-parse",
967 "--verify",
968 "gh-issue-42-fix-login-bug",
969 ])
970 .output()
971 .unwrap()
972 .status
973 .success();
974 assert!(!has_local, "local branch should not exist before the test");
975
976 let repo_root = local.path().to_str().unwrap();
977
978 let start =
980 resolve_branch_start_point(repo_root, "gh-issue-42-fix-login-bug", "main").await;
981 assert_eq!(
982 start, "origin/gh-issue-42-fix-login-bug",
983 "should use remote branch as start point when origin/<branch> exists"
984 );
985 }
986
987 #[tokio::test]
991 async fn resolve_branch_start_point_prefers_origin_default_over_local() {
992 let remote = tempfile::tempdir().unwrap();
993 git(remote.path(), &["init", "--bare"]);
994
995 let local = tempfile::tempdir().unwrap();
996 git(
997 local.path(),
998 &["clone", remote.path().to_str().unwrap(), "."],
999 );
1000 git(local.path(), &["config", "user.email", "t@t.com"]);
1001 git(local.path(), &["config", "user.name", "T"]);
1002
1003 std::fs::write(local.path().join("README.md"), "main").unwrap();
1005 git(local.path(), &["add", "."]);
1006 git(local.path(), &["commit", "-m", "init"]);
1007 git(local.path(), &["push", "origin", "HEAD:main"]);
1008 git(local.path(), &["fetch", "--all"]);
1009
1010 let repo_root = local.path().to_str().unwrap();
1011
1012 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1014 assert_eq!(
1015 start, "origin/main",
1016 "should anchor on origin/<default_branch> instead of local default"
1017 );
1018 }
1019
1020 fn git_stdout(dir: &std::path::Path, args: &[&str]) -> String {
1022 let out = std::process::Command::new("git")
1023 .args(args)
1024 .current_dir(dir)
1025 .output()
1026 .unwrap_or_else(|e| panic!("git {args:?} failed to start: {e}"));
1027 assert!(
1028 out.status.success(),
1029 "git {:?} failed:\nstdout: {}\nstderr: {}",
1030 args,
1031 String::from_utf8_lossy(&out.stdout),
1032 String::from_utf8_lossy(&out.stderr)
1033 );
1034 String::from_utf8_lossy(&out.stdout).trim().to_string()
1035 }
1036
1037 #[tokio::test]
1042 #[serial_test::serial(orch_home)]
1043 async fn setup_worktree_anchors_on_origin_not_local_main() {
1044 let orch_home_dir = tempfile::tempdir().unwrap();
1046 let prev_orch_home = std::env::var("ORCH_HOME").ok();
1047 unsafe { std::env::set_var("ORCH_HOME", orch_home_dir.path()) };
1049
1050 let remote = tempfile::tempdir().unwrap();
1052 git(remote.path(), &["init", "--bare"]);
1053
1054 let project = tempfile::tempdir().unwrap();
1056 git(
1057 project.path(),
1058 &["clone", remote.path().to_str().unwrap(), "."],
1059 );
1060 git(project.path(), &["config", "user.email", "t@t.com"]);
1061 git(project.path(), &["config", "user.name", "T"]);
1062
1063 std::fs::write(project.path().join("README.md"), "pushed\n").unwrap();
1065 git(project.path(), &["add", "."]);
1066 git(project.path(), &["commit", "-m", "pushed baseline"]);
1067 git(project.path(), &["push", "origin", "HEAD:main"]);
1068 let pushed_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1069
1070 git(project.path(), &["remote", "set-head", "origin", "main"]);
1072
1073 std::fs::write(project.path().join("LOCAL_ONLY.md"), "unpushed\n").unwrap();
1075 git(project.path(), &["add", "."]);
1076 git(project.path(), &["commit", "-m", "local unpushed commit"]);
1077 let local_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1078 assert_ne!(
1079 pushed_sha, local_sha,
1080 "precondition: local main must be ahead of origin/main"
1081 );
1082
1083 let setup = setup_worktree(
1085 "internal:1",
1086 "leak guard",
1087 project.path(),
1088 &None,
1089 "test/repo",
1090 )
1091 .await
1092 .expect("setup_worktree should succeed");
1093
1094 let wt_sha = git_stdout(&setup.work_dir, &["rev-parse", "HEAD"]);
1096 assert_eq!(
1097 wt_sha, pushed_sha,
1098 "worktree HEAD must equal origin/main, not local main"
1099 );
1100 assert_ne!(
1101 wt_sha, local_sha,
1102 "worktree must NOT include the operator's unpushed commit"
1103 );
1104 assert!(
1105 !setup.work_dir.join("LOCAL_ONLY.md").exists(),
1106 "unpushed-only file must not appear in the worktree"
1107 );
1108
1109 match prev_orch_home {
1111 Some(v) => unsafe { std::env::set_var("ORCH_HOME", v) },
1113 None => unsafe { std::env::remove_var("ORCH_HOME") },
1115 }
1116 }
1117
1118 #[tokio::test]
1121 async fn resolve_branch_start_point_falls_back_to_local_default() {
1122 let local = tempfile::tempdir().unwrap();
1123 git(local.path(), &["init"]);
1124 git(local.path(), &["config", "user.email", "t@t.com"]);
1125 git(local.path(), &["config", "user.name", "T"]);
1126 git(local.path(), &["checkout", "-b", "main"]);
1127 std::fs::write(local.path().join("README.md"), "main").unwrap();
1128 git(local.path(), &["add", "."]);
1129 git(local.path(), &["commit", "-m", "init"]);
1130
1131 let repo_root = local.path().to_str().unwrap();
1132
1133 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1135 assert_eq!(
1136 start, "main",
1137 "should fall back to local default_branch when no remote refs exist"
1138 );
1139 }
1140
1141 #[test]
1142 fn branch_name_basic() {
1143 let name = branch_name("42", "Fix login bug");
1144 assert_eq!(name, "gh-issue-42-fix-login-bug");
1145 }
1146
1147 #[test]
1148 fn branch_name_internal_task() {
1149 let name = branch_name("internal:8", "Fix login bug");
1150 assert_eq!(name, "internal-8-fix-login-bug");
1151 }
1152
1153 #[test]
1154 fn branch_name_special_chars() {
1155 let name = branch_name("7", "Add OAuth2/OIDC (Google & GitHub)");
1156 assert_eq!(name, "gh-issue-7-add-oauth2-oidc-google-github");
1157 }
1158
1159 #[test]
1160 fn branch_name_truncates_long_slug() {
1161 let title =
1162 "This is a very long task title that should be truncated to forty characters maximum";
1163 let name = branch_name("1", title);
1164 let slug = name.strip_prefix("gh-issue-1-").unwrap();
1166 assert!(slug.len() <= 40, "slug length {} > 40", slug.len());
1167 }
1168
1169 #[test]
1170 fn branch_name_trims_trailing_dashes() {
1171 let name = branch_name("5", "Fix bug---");
1172 assert!(
1173 !name.ends_with('-'),
1174 "branch name should not end with dash: {name}"
1175 );
1176 }
1177
1178 #[test]
1179 fn branch_name_empty_title() {
1180 let name = branch_name("99", "");
1181 assert_eq!(name, "gh-issue-99");
1182 }
1183
1184 #[test]
1185 fn branch_name_empty_title_internal() {
1186 let name = branch_name("internal:99", "");
1187 assert_eq!(name, "internal-99");
1188 }
1189
1190 #[test]
1191 fn branch_name_all_special_chars() {
1192 let name = branch_name("10", "--- ??? ---");
1193 assert_eq!(name, "gh-issue-10");
1194 assert_ne!(name, "");
1195 }
1196
1197 #[test]
1198 fn branch_name_chinese_chars_no_panic() {
1199 let name = branch_name("265", "implement 用户认证 user auth");
1200 assert!(name.starts_with("gh-issue-265-"));
1201 assert_ne!(name, "");
1202 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1203 assert!(slug.len() <= 40, "slug too long: {}", slug.len());
1204 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1205 }
1206
1207 #[test]
1208 fn task_id_from_worktree_name_parses_issue_branches() {
1209 assert_eq!(
1210 task_id_from_worktree_name("gh-issue-42-fix-login"),
1211 Some("42".to_string())
1212 );
1213 assert_eq!(
1214 task_id_from_worktree_name("gh-task-42-fix-login"),
1215 Some("42".to_string())
1216 );
1217 }
1218
1219 #[test]
1220 fn task_id_from_worktree_name_parses_internal_branches() {
1221 assert_eq!(
1222 task_id_from_worktree_name("internal-8-fix-login"),
1223 Some("internal:8".to_string())
1224 );
1225 assert_eq!(
1226 task_id_from_worktree_name("gh-issue-internal-8-fix-login"),
1227 Some("internal:8".to_string())
1228 );
1229 }
1230
1231 #[test]
1232 fn task_id_from_worktree_name_returns_none_for_unknown_names() {
1233 assert!(task_id_from_worktree_name("random-worktree").is_none());
1234 }
1235
1236 #[test]
1237 fn branch_name_emoji_no_panic() {
1238 let name = branch_name("265", "fix 🚀 deployment pipeline 🔥");
1239 assert!(name.starts_with("gh-issue-265-"));
1240 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1241 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1242 }
1243
1244 #[test]
1245 fn branch_name_all_non_ascii_falls_back_to_task_id() {
1246 let name = branch_name("265", "用户认证 실행 тест");
1247 assert_eq!(name, "gh-issue-265");
1248 }
1249
1250 #[tokio::test]
1251 async fn find_existing_worktree_returns_none_for_missing() {
1252 let dir = tempfile::tempdir().unwrap();
1253 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1254 }
1255
1256 #[tokio::test]
1257 async fn find_existing_worktree_matches_prefix() {
1258 let dir = tempfile::tempdir().unwrap();
1259 let wt_dir = dir.path().join("gh-issue-42-fix-login-bug");
1260 tokio::fs::create_dir(&wt_dir).await.unwrap();
1261
1262 let result = find_existing_worktree(dir.path(), "42").await;
1263 assert_eq!(result, Some(wt_dir));
1264 }
1265
1266 #[tokio::test]
1267 async fn find_existing_worktree_matches_internal_prefix() {
1268 let dir = tempfile::tempdir().unwrap();
1269 let wt_dir = dir.path().join("internal-8-fix");
1270 tokio::fs::create_dir(&wt_dir).await.unwrap();
1271
1272 let result = find_existing_worktree(dir.path(), "internal:8").await;
1273 assert_eq!(result, Some(wt_dir));
1274 }
1275
1276 #[tokio::test]
1277 async fn find_existing_worktree_matches_legacy_gh_task_prefix() {
1278 let dir = tempfile::tempdir().unwrap();
1280 let wt_dir = dir.path().join("gh-task-internal-8-fix");
1281 tokio::fs::create_dir(&wt_dir).await.unwrap();
1282
1283 let result = find_existing_worktree(dir.path(), "internal:8").await;
1284 assert_eq!(result, Some(wt_dir));
1285 }
1286
1287 #[tokio::test]
1288 async fn find_existing_worktree_matches_legacy_external_prefix() {
1289 let dir = tempfile::tempdir().unwrap();
1291 let wt_dir = dir.path().join("gh-task-42-fix-login-bug");
1292 tokio::fs::create_dir(&wt_dir).await.unwrap();
1293
1294 let result = find_existing_worktree(dir.path(), "42").await;
1295 assert_eq!(result, Some(wt_dir));
1296 }
1297
1298 #[tokio::test]
1299 async fn find_existing_worktree_ignores_other_tasks() {
1300 let dir = tempfile::tempdir().unwrap();
1301 tokio::fs::create_dir(dir.path().join("gh-issue-43-other-task"))
1302 .await
1303 .unwrap();
1304
1305 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1306 }
1307
1308 #[tokio::test]
1312 async fn resolve_parent_branch_no_store_returns_none() {
1313 let dir = tempfile::tempdir().unwrap();
1314 let result = resolve_parent_branch(&None, Some(1), dir.path()).await;
1315 assert!(result.is_none());
1316 }
1317
1318 #[tokio::test]
1320 async fn resolve_parent_branch_no_parent_id_returns_none() {
1321 let dir = tempfile::tempdir().unwrap();
1322 let result = resolve_parent_branch(&None, None, dir.path()).await;
1323 assert!(result.is_none());
1324 }
1325
1326 #[tokio::test]
1328 async fn resolve_parent_branch_empty_parent_branch_returns_none() {
1329 let db_dir = tempfile::tempdir().unwrap();
1330 let db_path = db_dir.path().join("test.db");
1331 let store = Arc::new(
1332 crate::store::TaskStore::open_single(&db_path)
1333 .await
1334 .unwrap(),
1335 );
1336
1337 let parent_id = store
1339 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1340 .await
1341 .unwrap();
1342
1343 let dir = tempfile::tempdir().unwrap();
1344 let store_opt = Some(store);
1345 let result = resolve_parent_branch(&store_opt, Some(parent_id), dir.path()).await;
1346 assert!(result.is_none());
1347 }
1348
1349 #[tokio::test]
1351 async fn resolve_parent_branch_returns_parent_branch_and_worktree() {
1352 let db_dir = tempfile::tempdir().unwrap();
1353 let db_path = db_dir.path().join("test.db");
1354 let store = Arc::new(
1355 crate::store::TaskStore::open_single(&db_path)
1356 .await
1357 .unwrap(),
1358 );
1359
1360 let parent_id = store
1362 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1363 .await
1364 .unwrap();
1365 store
1366 .set_fields(
1367 parent_id,
1368 &[("branch", serde_json::json!("gh-issue-99-parent-task"))],
1369 )
1370 .await
1371 .unwrap();
1372
1373 let worktrees_base = tempfile::tempdir().unwrap();
1374 let store_opt = Some(store);
1375 let result =
1376 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1377
1378 let (branch, wt) = result.expect("should return parent branch");
1379 assert_eq!(branch, "gh-issue-99-parent-task");
1380 assert_eq!(wt, worktrees_base.path().join("gh-issue-99-parent-task"));
1381 }
1382
1383 #[tokio::test]
1385 async fn resolve_parent_branch_prefers_existing_worktree_path() {
1386 let db_dir = tempfile::tempdir().unwrap();
1387 let db_path = db_dir.path().join("test.db");
1388 let store = Arc::new(
1389 crate::store::TaskStore::open_single(&db_path)
1390 .await
1391 .unwrap(),
1392 );
1393
1394 let worktrees_base = tempfile::tempdir().unwrap();
1395 let parent_wt_dir = worktrees_base.path().join("custom-worktree-path");
1397 tokio::fs::create_dir_all(&parent_wt_dir).await.unwrap();
1398
1399 let parent_id = store
1400 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1401 .await
1402 .unwrap();
1403 store
1404 .set_fields(
1405 parent_id,
1406 &[
1407 ("branch", serde_json::json!("gh-issue-99-parent-task")),
1408 (
1409 "worktree",
1410 serde_json::json!(parent_wt_dir.to_string_lossy().as_ref()),
1411 ),
1412 ],
1413 )
1414 .await
1415 .unwrap();
1416
1417 let store_opt = Some(store);
1418 let result =
1419 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1420
1421 let (branch, wt) = result.expect("should return parent branch");
1422 assert_eq!(branch, "gh-issue-99-parent-task");
1423 assert_eq!(wt, parent_wt_dir);
1424 }
1425
1426 #[tokio::test]
1431 async fn concurrent_worktree_checks_no_starvation() {
1432 let temp_dir = tempfile::tempdir().unwrap();
1433 let worktrees_base = temp_dir.path().join("worktrees");
1434 tokio::fs::create_dir_all(&worktrees_base).await.unwrap();
1435
1436 let wt_dir = worktrees_base.join("gh-issue-123-test");
1438 tokio::fs::create_dir_all(&wt_dir).await.unwrap();
1439 let gitdir_target = temp_dir
1441 .path()
1442 .join(".git")
1443 .join("worktrees")
1444 .join("gh-issue-123-test");
1445 tokio::fs::create_dir_all(&gitdir_target).await.unwrap();
1446 let git_file = wt_dir.join(".git");
1447 tokio::fs::write(&git_file, format!("gitdir: {}\n", gitdir_target.display()))
1448 .await
1449 .unwrap();
1450
1451 let handles: Vec<_> = (0..20)
1454 .map(|i| {
1455 let wb = worktrees_base.clone();
1456 let wd = wt_dir.clone();
1457 tokio::spawn(async move {
1458 if i % 2 == 0 {
1459 list_project_worktrees(&wb).await.ok();
1460 } else {
1461 validate_worktree_gitdir(&wd).await;
1462 }
1463 })
1464 })
1465 .collect();
1466
1467 let results = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1469 futures::future::join_all(handles).await
1470 })
1471 .await;
1472
1473 assert!(
1474 results.is_ok(),
1475 "concurrent worktree checks timed out (starvation?)"
1476 );
1477 }
1478
1479 #[test]
1482 fn normalize_branch_name_strips_origin_prefix() {
1483 assert_eq!(
1484 normalize_branch_name("origin/main"),
1485 Some("main".to_string())
1486 );
1487 assert_eq!(
1488 normalize_branch_name("origin/develop"),
1489 Some("develop".to_string())
1490 );
1491 }
1492
1493 #[test]
1494 fn normalize_branch_name_strips_full_ref_path() {
1495 assert_eq!(
1496 normalize_branch_name("refs/remotes/origin/main"),
1497 Some("main".to_string())
1498 );
1499 assert_eq!(
1500 normalize_branch_name("refs/remotes/origin/develop"),
1501 Some("develop".to_string())
1502 );
1503 }
1504
1505 #[test]
1506 fn normalize_branch_name_strips_remotes_prefix() {
1507 assert_eq!(
1508 normalize_branch_name("remotes/origin/main"),
1509 Some("main".to_string())
1510 );
1511 }
1512
1513 #[test]
1514 fn normalize_branch_name_passes_through_plain_branch() {
1515 assert_eq!(normalize_branch_name("main"), Some("main".to_string()));
1516 assert_eq!(normalize_branch_name("master"), Some("master".to_string()));
1517 assert_eq!(
1518 normalize_branch_name("feature/foo"),
1519 Some("feature/foo".to_string())
1520 );
1521 }
1522
1523 #[test]
1524 fn normalize_branch_name_rejects_empty_and_refs_only() {
1525 assert_eq!(normalize_branch_name(""), None);
1526 assert_eq!(normalize_branch_name(" "), None);
1527 assert_eq!(
1528 normalize_branch_name("refs/heads/main"),
1529 Some("main".to_string())
1530 );
1531 }
1532}