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 _ = Command::new("git")
595 .args([
596 "-C",
597 &main_dir.to_string_lossy(),
598 "fetch",
599 "--prune",
600 "origin",
601 ])
602 .output_with_context()
603 .await;
604
605 let start_point = resolve_branch_start_point(
610 &main_dir.to_string_lossy(),
611 &branch_name_str,
612 &default_branch,
613 )
614 .await;
615 let _ = Command::new("git")
616 .args([
617 "-C",
618 &main_dir.to_string_lossy(),
619 "branch",
620 &branch_name_str,
621 &start_point,
622 ])
623 .output_with_context()
624 .await;
625
626 if let Some(parent) = worktree_dir.parent() {
628 tokio::fs::create_dir_all(parent).await?;
629 }
630
631 let cloned = match super::cow::create_worktree(
632 &main_dir,
633 &project_name(&main_dir),
634 &worktree_dir,
635 &branch_name_str,
636 &default_branch,
637 )
638 .await
639 {
640 Ok(()) => {
641 tracing::info!(task_id, "worktree cloned from project template");
642 true
643 }
644 Err(e) => {
645 tracing::debug!(task_id, error = %e, "copy-on-write worktree unavailable, using git worktree add");
646 false
647 }
648 };
649
650 if !cloned {
651 let output = Command::new("git")
652 .args([
653 "-C",
654 &main_dir.to_string_lossy(),
655 "worktree",
656 "add",
657 &worktree_dir.to_string_lossy(),
658 &branch_name_str,
659 ])
660 .output_with_context()
661 .await?;
662
663 if !output.status.success() && tokio::fs::metadata(&worktree_dir).await.is_err() {
664 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
665 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
666 tracing::warn!(
668 task_id,
669 stdout = %stdout,
670 stderr = %stderr,
671 "worktree creation failed, retrying after prune"
672 );
673
674 let _ = Command::new("git")
675 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
676 .output_with_context()
677 .await;
678
679 let _ = Command::new("git")
680 .args([
681 "-C",
682 &main_dir.to_string_lossy(),
683 "branch",
684 "-D",
685 &branch_name_str,
686 ])
687 .output_with_context()
688 .await;
689
690 let _ = Command::new("git")
691 .args([
692 "-C",
693 &main_dir.to_string_lossy(),
694 "branch",
695 &branch_name_str,
696 &start_point,
697 ])
698 .output_with_context()
699 .await;
700
701 let retry_output = Command::new("git")
702 .args([
703 "-C",
704 &main_dir.to_string_lossy(),
705 "worktree",
706 "add",
707 &worktree_dir.to_string_lossy(),
708 &branch_name_str,
709 ])
710 .output_with_context()
711 .await;
712
713 let mut retry_stdout = String::new();
714 let mut retry_stderr = String::new();
715 let mut retry_error = String::new();
716 match retry_output {
717 Ok(output) => {
718 retry_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
719 retry_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
720 tracing::warn!(
721 task_id,
722 stdout = %retry_stdout,
723 stderr = %retry_stderr,
724 "worktree creation retry failed"
725 );
726 }
727 Err(err) => {
728 retry_error = err.to_string();
729 tracing::warn!(
730 task_id,
731 error = %retry_error,
732 "worktree creation retry failed to run"
733 );
734 }
735 }
736
737 if tokio::fs::metadata(&worktree_dir).await.is_err() {
738 anyhow::bail!(
739 "failed to create worktree at {} for task {} (stdout: {}, stderr: {}, retry stdout: {}, retry stderr: {}, retry error: {})",
740 worktree_dir.display(),
741 task_id,
742 stdout,
743 stderr,
744 retry_stdout,
745 retry_stderr,
746 retry_error
747 );
748 }
749 }
750 }
751 }
752
753 if !task_id.starts_with("internal:") {
757 if let Ok(issue_num) = task_id.parse::<u64>() {
758 if let Ok(gh) = crate::github::http::GhHttp::new() {
759 match gh
760 .link_issue_to_branch(repo, issue_num, &branch_name_str)
761 .await
762 {
763 Ok(id) => {
764 tracing::info!(task_id, branch = %branch_name_str, linked = %id, "linked issue to branch");
765 }
766 Err(e) => {
767 tracing::debug!(task_id, error = %e, "failed to link issue to branch (non-fatal)");
768 }
769 }
770 }
771 }
772 }
773
774 if let Err(e) = store::store_set_result(
776 store,
777 repo,
778 task_id,
779 &[
780 (
781 "worktree",
782 serde_json::json!(worktree_dir.display().to_string()),
783 ),
784 ("branch", serde_json::json!(branch_name_str)),
785 ],
786 )
787 .await
788 {
789 tracing::warn!(task_id, err = %e, "failed to save worktree info to store (non-fatal)");
790 }
791
792 tracing::info!(
793 task_id,
794 worktree = %worktree_dir.display(),
795 branch = %branch_name_str,
796 "worktree ready"
797 );
798
799 Ok(WorktreeSetup {
800 work_dir: worktree_dir,
801 branch: branch_name_str,
802 default_branch,
803 main_project_dir: main_dir,
804 })
805}
806
807async fn discard_worktree(main_dir: &Path, worktree_dir: &Path) {
809 let _ = Command::new("git")
810 .args([
811 "-C",
812 &main_dir.to_string_lossy(),
813 "worktree",
814 "remove",
815 "--force",
816 &worktree_dir.to_string_lossy(),
817 ])
818 .output_with_context()
819 .await;
820 if tokio::fs::metadata(worktree_dir).await.is_ok() {
821 let _ = tokio::fs::remove_dir_all(worktree_dir).await;
822 }
823 let _ = Command::new("git")
824 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
825 .output_with_context()
826 .await;
827}
828
829async fn find_existing_worktree(worktrees_base: &Path, task_id: &str) -> Option<PathBuf> {
830 let (prefix_new, legacy_prefixes) = if let Some(num) = task_id.strip_prefix("internal:") {
831 (
832 format!("internal-{num}-"),
833 vec![
834 format!("gh-task-internal-{num}-"),
835 format!("gh-issue-internal-{num}-"),
836 ],
837 )
838 } else {
839 let sanitized_id = sanitize_task_id(task_id);
840 (
841 format!("gh-issue-{sanitized_id}-"),
842 vec![format!("gh-task-{sanitized_id}-")],
843 )
844 };
845
846 let mut entries = match tokio::fs::read_dir(worktrees_base).await {
847 Ok(e) => e,
848 Err(e) => {
849 tracing::warn!(
850 error = %e,
851 path = %worktrees_base.display(),
852 "failed to read worktrees directory while searching for existing worktree"
853 );
854 return None;
855 }
856 };
857 loop {
858 let next_entry = match entries.next_entry().await {
859 Ok(Some(entry)) => entry,
860 Ok(None) => break,
861 Err(e) => {
862 tracing::warn!(error = %e, "failed to read directory entry while searching for worktree");
864 continue;
865 }
866 };
867
868 let name = next_entry.file_name().to_string_lossy().to_string();
869 let matches =
870 name.starts_with(&prefix_new) || legacy_prefixes.iter().any(|p| name.starts_with(p));
871 if matches {
872 match next_entry.metadata().await {
873 Ok(metadata) if metadata.is_dir() => return Some(next_entry.path()),
874 Ok(_) => {}
875 Err(e) => {
876 tracing::warn!(
877 error = %e,
878 path = %next_entry.path().display(),
879 "failed to read metadata for candidate worktree entry"
880 );
881 }
882 }
883 }
884 }
885
886 None
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 fn git(dir: &std::path::Path, args: &[&str]) {
895 let mut cmd = std::process::Command::new("git");
896 cmd.current_dir(dir);
897 if args.first() == Some(&"commit") {
899 cmd.args(["-c", "commit.gpgsign=false"]);
900 }
901 let out = cmd
902 .args(args)
903 .output()
904 .unwrap_or_else(|e| panic!("git {:?} failed to start: {e}", args));
905 if !out.status.success() {
906 panic!(
907 "git {:?} failed:\nstdout: {}\nstderr: {}",
908 args,
909 String::from_utf8_lossy(&out.stdout),
910 String::from_utf8_lossy(&out.stderr)
911 );
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 {:?} failed to start: {e}", args));
1027 if !out.status.success() {
1028 panic!(
1029 "git {:?} failed:\nstdout: {}\nstderr: {}",
1030 args,
1031 String::from_utf8_lossy(&out.stdout),
1032 String::from_utf8_lossy(&out.stderr)
1033 );
1034 }
1035 String::from_utf8_lossy(&out.stdout).trim().to_string()
1036 }
1037
1038 #[tokio::test]
1043 #[serial_test::serial(orch_home)]
1044 async fn setup_worktree_anchors_on_origin_not_local_main() {
1045 let orch_home_dir = tempfile::tempdir().unwrap();
1047 let prev_orch_home = std::env::var("ORCH_HOME").ok();
1048 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) => std::env::set_var("ORCH_HOME", v),
1112 None => std::env::remove_var("ORCH_HOME"),
1113 }
1114 }
1115
1116 #[tokio::test]
1119 async fn resolve_branch_start_point_falls_back_to_local_default() {
1120 let local = tempfile::tempdir().unwrap();
1121 git(local.path(), &["init"]);
1122 git(local.path(), &["config", "user.email", "t@t.com"]);
1123 git(local.path(), &["config", "user.name", "T"]);
1124 git(local.path(), &["checkout", "-b", "main"]);
1125 std::fs::write(local.path().join("README.md"), "main").unwrap();
1126 git(local.path(), &["add", "."]);
1127 git(local.path(), &["commit", "-m", "init"]);
1128
1129 let repo_root = local.path().to_str().unwrap();
1130
1131 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1133 assert_eq!(
1134 start, "main",
1135 "should fall back to local default_branch when no remote refs exist"
1136 );
1137 }
1138
1139 #[test]
1140 fn branch_name_basic() {
1141 let name = branch_name("42", "Fix login bug");
1142 assert_eq!(name, "gh-issue-42-fix-login-bug");
1143 }
1144
1145 #[test]
1146 fn branch_name_internal_task() {
1147 let name = branch_name("internal:8", "Fix login bug");
1148 assert_eq!(name, "internal-8-fix-login-bug");
1149 }
1150
1151 #[test]
1152 fn branch_name_special_chars() {
1153 let name = branch_name("7", "Add OAuth2/OIDC (Google & GitHub)");
1154 assert_eq!(name, "gh-issue-7-add-oauth2-oidc-google-github");
1155 }
1156
1157 #[test]
1158 fn branch_name_truncates_long_slug() {
1159 let title =
1160 "This is a very long task title that should be truncated to forty characters maximum";
1161 let name = branch_name("1", title);
1162 let slug = name.strip_prefix("gh-issue-1-").unwrap();
1164 assert!(slug.len() <= 40, "slug length {} > 40", slug.len());
1165 }
1166
1167 #[test]
1168 fn branch_name_trims_trailing_dashes() {
1169 let name = branch_name("5", "Fix bug---");
1170 assert!(
1171 !name.ends_with('-'),
1172 "branch name should not end with dash: {name}"
1173 );
1174 }
1175
1176 #[test]
1177 fn branch_name_empty_title() {
1178 let name = branch_name("99", "");
1179 assert_eq!(name, "gh-issue-99");
1180 }
1181
1182 #[test]
1183 fn branch_name_empty_title_internal() {
1184 let name = branch_name("internal:99", "");
1185 assert_eq!(name, "internal-99");
1186 }
1187
1188 #[test]
1189 fn branch_name_all_special_chars() {
1190 let name = branch_name("10", "--- ??? ---");
1191 assert_eq!(name, "gh-issue-10");
1192 assert!(!name.is_empty());
1193 }
1194
1195 #[test]
1196 fn branch_name_chinese_chars_no_panic() {
1197 let name = branch_name("265", "implement 用户认证 user auth");
1198 assert!(name.starts_with("gh-issue-265-"));
1199 assert!(!name.is_empty());
1200 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1201 assert!(slug.len() <= 40, "slug too long: {}", slug.len());
1202 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1203 }
1204
1205 #[test]
1206 fn task_id_from_worktree_name_parses_issue_branches() {
1207 assert_eq!(
1208 task_id_from_worktree_name("gh-issue-42-fix-login"),
1209 Some("42".to_string())
1210 );
1211 assert_eq!(
1212 task_id_from_worktree_name("gh-task-42-fix-login"),
1213 Some("42".to_string())
1214 );
1215 }
1216
1217 #[test]
1218 fn task_id_from_worktree_name_parses_internal_branches() {
1219 assert_eq!(
1220 task_id_from_worktree_name("internal-8-fix-login"),
1221 Some("internal:8".to_string())
1222 );
1223 assert_eq!(
1224 task_id_from_worktree_name("gh-issue-internal-8-fix-login"),
1225 Some("internal:8".to_string())
1226 );
1227 }
1228
1229 #[test]
1230 fn task_id_from_worktree_name_returns_none_for_unknown_names() {
1231 assert!(task_id_from_worktree_name("random-worktree").is_none());
1232 }
1233
1234 #[test]
1235 fn branch_name_emoji_no_panic() {
1236 let name = branch_name("265", "fix 🚀 deployment pipeline 🔥");
1237 assert!(name.starts_with("gh-issue-265-"));
1238 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1239 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1240 }
1241
1242 #[test]
1243 fn branch_name_all_non_ascii_falls_back_to_task_id() {
1244 let name = branch_name("265", "用户认证 실행 тест");
1245 assert_eq!(name, "gh-issue-265");
1246 }
1247
1248 #[tokio::test]
1249 async fn find_existing_worktree_returns_none_for_missing() {
1250 let dir = tempfile::tempdir().unwrap();
1251 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1252 }
1253
1254 #[tokio::test]
1255 async fn find_existing_worktree_matches_prefix() {
1256 let dir = tempfile::tempdir().unwrap();
1257 let wt_dir = dir.path().join("gh-issue-42-fix-login-bug");
1258 tokio::fs::create_dir(&wt_dir).await.unwrap();
1259
1260 let result = find_existing_worktree(dir.path(), "42").await;
1261 assert_eq!(result, Some(wt_dir));
1262 }
1263
1264 #[tokio::test]
1265 async fn find_existing_worktree_matches_internal_prefix() {
1266 let dir = tempfile::tempdir().unwrap();
1267 let wt_dir = dir.path().join("internal-8-fix");
1268 tokio::fs::create_dir(&wt_dir).await.unwrap();
1269
1270 let result = find_existing_worktree(dir.path(), "internal:8").await;
1271 assert_eq!(result, Some(wt_dir));
1272 }
1273
1274 #[tokio::test]
1275 async fn find_existing_worktree_matches_legacy_gh_task_prefix() {
1276 let dir = tempfile::tempdir().unwrap();
1278 let wt_dir = dir.path().join("gh-task-internal-8-fix");
1279 tokio::fs::create_dir(&wt_dir).await.unwrap();
1280
1281 let result = find_existing_worktree(dir.path(), "internal:8").await;
1282 assert_eq!(result, Some(wt_dir));
1283 }
1284
1285 #[tokio::test]
1286 async fn find_existing_worktree_matches_legacy_external_prefix() {
1287 let dir = tempfile::tempdir().unwrap();
1289 let wt_dir = dir.path().join("gh-task-42-fix-login-bug");
1290 tokio::fs::create_dir(&wt_dir).await.unwrap();
1291
1292 let result = find_existing_worktree(dir.path(), "42").await;
1293 assert_eq!(result, Some(wt_dir));
1294 }
1295
1296 #[tokio::test]
1297 async fn find_existing_worktree_ignores_other_tasks() {
1298 let dir = tempfile::tempdir().unwrap();
1299 tokio::fs::create_dir(dir.path().join("gh-issue-43-other-task"))
1300 .await
1301 .unwrap();
1302
1303 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1304 }
1305
1306 #[tokio::test]
1310 async fn resolve_parent_branch_no_store_returns_none() {
1311 let dir = tempfile::tempdir().unwrap();
1312 let result = resolve_parent_branch(&None, Some(1), dir.path()).await;
1313 assert!(result.is_none());
1314 }
1315
1316 #[tokio::test]
1318 async fn resolve_parent_branch_no_parent_id_returns_none() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let result = resolve_parent_branch(&None, None, dir.path()).await;
1321 assert!(result.is_none());
1322 }
1323
1324 #[tokio::test]
1326 async fn resolve_parent_branch_empty_parent_branch_returns_none() {
1327 let db_dir = tempfile::tempdir().unwrap();
1328 let db_path = db_dir.path().join("test.db");
1329 let store = Arc::new(
1330 crate::store::TaskStore::open_single(&db_path)
1331 .await
1332 .unwrap(),
1333 );
1334
1335 let parent_id = store
1337 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1338 .await
1339 .unwrap();
1340
1341 let dir = tempfile::tempdir().unwrap();
1342 let store_opt = Some(store);
1343 let result = resolve_parent_branch(&store_opt, Some(parent_id), dir.path()).await;
1344 assert!(result.is_none());
1345 }
1346
1347 #[tokio::test]
1349 async fn resolve_parent_branch_returns_parent_branch_and_worktree() {
1350 let db_dir = tempfile::tempdir().unwrap();
1351 let db_path = db_dir.path().join("test.db");
1352 let store = Arc::new(
1353 crate::store::TaskStore::open_single(&db_path)
1354 .await
1355 .unwrap(),
1356 );
1357
1358 let parent_id = store
1360 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1361 .await
1362 .unwrap();
1363 store
1364 .set_fields(
1365 parent_id,
1366 &[("branch", serde_json::json!("gh-issue-99-parent-task"))],
1367 )
1368 .await
1369 .unwrap();
1370
1371 let worktrees_base = tempfile::tempdir().unwrap();
1372 let store_opt = Some(store);
1373 let result =
1374 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1375
1376 let (branch, wt) = result.expect("should return parent branch");
1377 assert_eq!(branch, "gh-issue-99-parent-task");
1378 assert_eq!(wt, worktrees_base.path().join("gh-issue-99-parent-task"));
1379 }
1380
1381 #[tokio::test]
1383 async fn resolve_parent_branch_prefers_existing_worktree_path() {
1384 let db_dir = tempfile::tempdir().unwrap();
1385 let db_path = db_dir.path().join("test.db");
1386 let store = Arc::new(
1387 crate::store::TaskStore::open_single(&db_path)
1388 .await
1389 .unwrap(),
1390 );
1391
1392 let worktrees_base = tempfile::tempdir().unwrap();
1393 let parent_wt_dir = worktrees_base.path().join("custom-worktree-path");
1395 tokio::fs::create_dir_all(&parent_wt_dir).await.unwrap();
1396
1397 let parent_id = store
1398 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1399 .await
1400 .unwrap();
1401 store
1402 .set_fields(
1403 parent_id,
1404 &[
1405 ("branch", serde_json::json!("gh-issue-99-parent-task")),
1406 (
1407 "worktree",
1408 serde_json::json!(parent_wt_dir.to_string_lossy().as_ref()),
1409 ),
1410 ],
1411 )
1412 .await
1413 .unwrap();
1414
1415 let store_opt = Some(store);
1416 let result =
1417 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1418
1419 let (branch, wt) = result.expect("should return parent branch");
1420 assert_eq!(branch, "gh-issue-99-parent-task");
1421 assert_eq!(wt, parent_wt_dir);
1422 }
1423
1424 #[tokio::test]
1429 async fn concurrent_worktree_checks_no_starvation() {
1430 let temp_dir = tempfile::tempdir().unwrap();
1431 let worktrees_base = temp_dir.path().join("worktrees");
1432 tokio::fs::create_dir_all(&worktrees_base).await.unwrap();
1433
1434 let wt_dir = worktrees_base.join("gh-issue-123-test");
1436 tokio::fs::create_dir_all(&wt_dir).await.unwrap();
1437 let gitdir_target = temp_dir
1439 .path()
1440 .join(".git")
1441 .join("worktrees")
1442 .join("gh-issue-123-test");
1443 tokio::fs::create_dir_all(&gitdir_target).await.unwrap();
1444 let git_file = wt_dir.join(".git");
1445 tokio::fs::write(&git_file, format!("gitdir: {}\n", gitdir_target.display()))
1446 .await
1447 .unwrap();
1448
1449 let handles: Vec<_> = (0..20)
1452 .map(|i| {
1453 let wb = worktrees_base.clone();
1454 let wd = wt_dir.clone();
1455 tokio::spawn(async move {
1456 if i % 2 == 0 {
1457 list_project_worktrees(&wb).await.ok();
1458 } else {
1459 validate_worktree_gitdir(&wd).await;
1460 }
1461 })
1462 })
1463 .collect();
1464
1465 let results = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1467 futures::future::join_all(handles).await
1468 })
1469 .await;
1470
1471 assert!(
1472 results.is_ok(),
1473 "concurrent worktree checks timed out (starvation?)"
1474 );
1475 }
1476
1477 #[test]
1480 fn normalize_branch_name_strips_origin_prefix() {
1481 assert_eq!(
1482 normalize_branch_name("origin/main"),
1483 Some("main".to_string())
1484 );
1485 assert_eq!(
1486 normalize_branch_name("origin/develop"),
1487 Some("develop".to_string())
1488 );
1489 }
1490
1491 #[test]
1492 fn normalize_branch_name_strips_full_ref_path() {
1493 assert_eq!(
1494 normalize_branch_name("refs/remotes/origin/main"),
1495 Some("main".to_string())
1496 );
1497 assert_eq!(
1498 normalize_branch_name("refs/remotes/origin/develop"),
1499 Some("develop".to_string())
1500 );
1501 }
1502
1503 #[test]
1504 fn normalize_branch_name_strips_remotes_prefix() {
1505 assert_eq!(
1506 normalize_branch_name("remotes/origin/main"),
1507 Some("main".to_string())
1508 );
1509 }
1510
1511 #[test]
1512 fn normalize_branch_name_passes_through_plain_branch() {
1513 assert_eq!(normalize_branch_name("main"), Some("main".to_string()));
1514 assert_eq!(normalize_branch_name("master"), Some("master".to_string()));
1515 assert_eq!(
1516 normalize_branch_name("feature/foo"),
1517 Some("feature/foo".to_string())
1518 );
1519 }
1520
1521 #[test]
1522 fn normalize_branch_name_rejects_empty_and_refs_only() {
1523 assert_eq!(normalize_branch_name(""), None);
1524 assert_eq!(normalize_branch_name(" "), None);
1525 assert_eq!(
1526 normalize_branch_name("refs/heads/main"),
1527 Some("main".to_string())
1528 );
1529 }
1530}