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 (branch_name_str, worktree_dir) = if let Some(ref saved) = saved_branch {
439 if !saved.is_empty() {
440 let wt = match &saved_worktree {
442 Some(wt) if !wt.is_empty() => {
443 let wt_str = wt.clone();
444 let exists = tokio::task::spawn_blocking(move || Path::new(&wt_str).exists())
445 .await
446 .unwrap_or(false);
447 if exists {
448 PathBuf::from(wt)
449 } else {
450 worktrees_base.join(saved)
451 }
452 }
453 _ => worktrees_base.join(saved),
454 };
455 (saved.clone(), wt)
456 } else if let Some((parent_branch, parent_wt)) =
457 resolve_parent_branch(store, parent_id, &worktrees_base).await
458 {
459 (parent_branch, parent_wt)
462 } else {
463 let bn = branch_name(task_id, title);
464 (bn.clone(), worktrees_base.join(&bn))
465 }
466 } else {
467 if let Some((parent_branch, parent_wt)) =
469 resolve_parent_branch(store, parent_id, &worktrees_base).await
470 {
471 (parent_branch, parent_wt)
472 } else {
473 let existing = find_existing_worktree(&worktrees_base, task_id).await;
474 if let Some(existing_dir) = existing {
475 let bn = existing_dir
476 .file_name()
477 .filter(|n| !n.is_empty())
478 .map(|n| n.to_string_lossy().to_string())
479 .unwrap_or_else(|| branch_name(task_id, title));
480 tracing::info!(task_id, worktree = %existing_dir.display(), "found existing worktree");
481 (bn, existing_dir)
482 } else {
483 let bn = branch_name(task_id, title);
484 (bn.clone(), worktrees_base.join(&bn))
485 }
486 }
487 };
488
489 if branch_name_str.is_empty() {
490 anyhow::bail!("empty branch name for task {task_id}");
491 }
492
493 if tokio::fs::metadata(&worktree_dir).await.is_ok() {
502 let _ = Command::new("git")
503 .args(["rebase", "--abort"])
504 .current_dir(&worktree_dir)
505 .output_with_context()
506 .await;
507
508 let index_ok = Command::new("git")
514 .args(["status", "--porcelain"])
515 .current_dir(&worktree_dir)
516 .output_with_context()
517 .await
518 .map(|o| o.status.success())
519 .unwrap_or(false);
520
521 if !index_ok {
522 let stderr_hint = Command::new("git")
523 .args(["status", "--porcelain"])
524 .current_dir(&worktree_dir)
525 .output_with_context()
526 .await
527 .map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
528 .unwrap_or_default();
529 tracing::warn!(
530 task_id,
531 worktree = %worktree_dir.display(),
532 stderr = %stderr_hint,
533 "corrupted worktree index detected — removing and recreating worktree"
534 );
535 let _ = Command::new("git")
537 .args([
538 "-C",
539 &main_dir.to_string_lossy(),
540 "worktree",
541 "remove",
542 "--force",
543 &worktree_dir.to_string_lossy(),
544 ])
545 .output_with_context()
546 .await;
547 if tokio::fs::metadata(&worktree_dir).await.is_ok() {
550 let _ = tokio::fs::remove_dir_all(&worktree_dir).await;
551 }
552 let _ = Command::new("git")
554 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
555 .output_with_context()
556 .await;
557 }
558 }
559
560 if tokio::fs::metadata(&worktree_dir).await.is_err() {
562 tracing::info!(task_id, worktree = %worktree_dir.display(), "creating worktree");
563
564 let _ = Command::new("git")
570 .args([
571 "-C",
572 &main_dir.to_string_lossy(),
573 "fetch",
574 "--prune",
575 "origin",
576 ])
577 .output_with_context()
578 .await;
579
580 let start_point = resolve_branch_start_point(
585 &main_dir.to_string_lossy(),
586 &branch_name_str,
587 &default_branch,
588 )
589 .await;
590 let _ = Command::new("git")
591 .args([
592 "-C",
593 &main_dir.to_string_lossy(),
594 "branch",
595 &branch_name_str,
596 &start_point,
597 ])
598 .output_with_context()
599 .await;
600
601 if let Some(parent) = worktree_dir.parent() {
603 tokio::fs::create_dir_all(parent).await?;
604 }
605
606 let cloned = match super::cow::create_worktree(
607 &main_dir,
608 &project_name(&main_dir),
609 &worktree_dir,
610 &branch_name_str,
611 &default_branch,
612 )
613 .await
614 {
615 Ok(()) => {
616 tracing::info!(task_id, "worktree cloned from project template");
617 true
618 }
619 Err(e) => {
620 tracing::debug!(task_id, error = %e, "copy-on-write worktree unavailable, using git worktree add");
621 false
622 }
623 };
624
625 if !cloned {
626 let output = Command::new("git")
627 .args([
628 "-C",
629 &main_dir.to_string_lossy(),
630 "worktree",
631 "add",
632 &worktree_dir.to_string_lossy(),
633 &branch_name_str,
634 ])
635 .output_with_context()
636 .await?;
637
638 if !output.status.success() && tokio::fs::metadata(&worktree_dir).await.is_err() {
639 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
640 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
641 tracing::warn!(
643 task_id,
644 stdout = %stdout,
645 stderr = %stderr,
646 "worktree creation failed, retrying after prune"
647 );
648
649 let _ = Command::new("git")
650 .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
651 .output_with_context()
652 .await;
653
654 let _ = Command::new("git")
655 .args([
656 "-C",
657 &main_dir.to_string_lossy(),
658 "branch",
659 "-D",
660 &branch_name_str,
661 ])
662 .output_with_context()
663 .await;
664
665 let _ = Command::new("git")
666 .args([
667 "-C",
668 &main_dir.to_string_lossy(),
669 "branch",
670 &branch_name_str,
671 &start_point,
672 ])
673 .output_with_context()
674 .await;
675
676 let retry_output = Command::new("git")
677 .args([
678 "-C",
679 &main_dir.to_string_lossy(),
680 "worktree",
681 "add",
682 &worktree_dir.to_string_lossy(),
683 &branch_name_str,
684 ])
685 .output_with_context()
686 .await;
687
688 let mut retry_stdout = String::new();
689 let mut retry_stderr = String::new();
690 let mut retry_error = String::new();
691 match retry_output {
692 Ok(output) => {
693 retry_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
694 retry_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
695 tracing::warn!(
696 task_id,
697 stdout = %retry_stdout,
698 stderr = %retry_stderr,
699 "worktree creation retry failed"
700 );
701 }
702 Err(err) => {
703 retry_error = err.to_string();
704 tracing::warn!(
705 task_id,
706 error = %retry_error,
707 "worktree creation retry failed to run"
708 );
709 }
710 }
711
712 if tokio::fs::metadata(&worktree_dir).await.is_err() {
713 anyhow::bail!(
714 "failed to create worktree at {} for task {} (stdout: {}, stderr: {}, retry stdout: {}, retry stderr: {}, retry error: {})",
715 worktree_dir.display(),
716 task_id,
717 stdout,
718 stderr,
719 retry_stdout,
720 retry_stderr,
721 retry_error
722 );
723 }
724 }
725 }
726 }
727
728 if !task_id.starts_with("internal:") {
732 if let Ok(issue_num) = task_id.parse::<u64>() {
733 if let Ok(gh) = crate::github::http::GhHttp::new() {
734 match gh
735 .link_issue_to_branch(repo, issue_num, &branch_name_str)
736 .await
737 {
738 Ok(id) => {
739 tracing::info!(task_id, branch = %branch_name_str, linked = %id, "linked issue to branch");
740 }
741 Err(e) => {
742 tracing::debug!(task_id, error = %e, "failed to link issue to branch (non-fatal)");
743 }
744 }
745 }
746 }
747 }
748
749 if let Err(e) = store::store_set_result(
751 store,
752 repo,
753 task_id,
754 &[
755 (
756 "worktree",
757 serde_json::json!(worktree_dir.display().to_string()),
758 ),
759 ("branch", serde_json::json!(branch_name_str)),
760 ],
761 )
762 .await
763 {
764 tracing::warn!(task_id, err = %e, "failed to save worktree info to store (non-fatal)");
765 }
766
767 tracing::info!(
768 task_id,
769 worktree = %worktree_dir.display(),
770 branch = %branch_name_str,
771 "worktree ready"
772 );
773
774 Ok(WorktreeSetup {
775 work_dir: worktree_dir,
776 branch: branch_name_str,
777 default_branch,
778 main_project_dir: main_dir,
779 })
780}
781
782async fn find_existing_worktree(worktrees_base: &Path, task_id: &str) -> Option<PathBuf> {
784 let (prefix_new, legacy_prefixes) = if let Some(num) = task_id.strip_prefix("internal:") {
785 (
786 format!("internal-{num}-"),
787 vec![
788 format!("gh-task-internal-{num}-"),
789 format!("gh-issue-internal-{num}-"),
790 ],
791 )
792 } else {
793 let sanitized_id = sanitize_task_id(task_id);
794 (
795 format!("gh-issue-{sanitized_id}-"),
796 vec![format!("gh-task-{sanitized_id}-")],
797 )
798 };
799
800 let mut entries = match tokio::fs::read_dir(worktrees_base).await {
801 Ok(e) => e,
802 Err(e) => {
803 tracing::warn!(
804 error = %e,
805 path = %worktrees_base.display(),
806 "failed to read worktrees directory while searching for existing worktree"
807 );
808 return None;
809 }
810 };
811 loop {
812 let next_entry = match entries.next_entry().await {
813 Ok(Some(entry)) => entry,
814 Ok(None) => break,
815 Err(e) => {
816 tracing::warn!(error = %e, "failed to read directory entry while searching for worktree");
818 continue;
819 }
820 };
821
822 let name = next_entry.file_name().to_string_lossy().to_string();
823 let matches =
824 name.starts_with(&prefix_new) || legacy_prefixes.iter().any(|p| name.starts_with(p));
825 if matches {
826 match next_entry.metadata().await {
827 Ok(metadata) if metadata.is_dir() => return Some(next_entry.path()),
828 Ok(_) => {}
829 Err(e) => {
830 tracing::warn!(
831 error = %e,
832 path = %next_entry.path().display(),
833 "failed to read metadata for candidate worktree entry"
834 );
835 }
836 }
837 }
838 }
839
840 None
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 fn git(dir: &std::path::Path, args: &[&str]) {
849 let mut cmd = std::process::Command::new("git");
850 cmd.current_dir(dir);
851 if args.first() == Some(&"commit") {
853 cmd.args(["-c", "commit.gpgsign=false"]);
854 }
855 let out = cmd
856 .args(args)
857 .output()
858 .unwrap_or_else(|e| panic!("git {:?} failed to start: {e}", args));
859 if !out.status.success() {
860 panic!(
861 "git {:?} failed:\nstdout: {}\nstderr: {}",
862 args,
863 String::from_utf8_lossy(&out.stdout),
864 String::from_utf8_lossy(&out.stderr)
865 );
866 }
867 }
868
869 #[tokio::test]
873 async fn resolve_branch_start_point_prefers_remote_over_default() {
874 let remote = tempfile::tempdir().unwrap();
875 git(remote.path(), &["init", "--bare"]);
876
877 let setup = tempfile::tempdir().unwrap();
879 git(
880 setup.path(),
881 &["clone", remote.path().to_str().unwrap(), "."],
882 );
883 git(setup.path(), &["config", "user.email", "t@t.com"]);
884 git(setup.path(), &["config", "user.name", "T"]);
885
886 std::fs::write(setup.path().join("README.md"), "main").unwrap();
888 git(setup.path(), &["add", "."]);
889 git(setup.path(), &["commit", "-m", "init"]);
890 git(setup.path(), &["push", "origin", "HEAD:main"]);
891
892 git(
894 setup.path(),
895 &["checkout", "-b", "gh-issue-42-fix-login-bug"],
896 );
897 std::fs::write(setup.path().join("feature.txt"), "work").unwrap();
898 git(setup.path(), &["add", "."]);
899 git(setup.path(), &["commit", "-m", "feature work"]);
900 git(
901 setup.path(),
902 &["push", "origin", "gh-issue-42-fix-login-bug"],
903 );
904
905 let local = tempfile::tempdir().unwrap();
908 git(
909 local.path(),
910 &["clone", remote.path().to_str().unwrap(), "."],
911 );
912 git(local.path(), &["config", "user.email", "t@t.com"]);
913 git(local.path(), &["config", "user.name", "T"]);
914 git(local.path(), &["fetch", "--all"]);
915 let has_local = std::process::Command::new("git")
917 .args([
918 "-C",
919 local.path().to_str().unwrap(),
920 "rev-parse",
921 "--verify",
922 "gh-issue-42-fix-login-bug",
923 ])
924 .output()
925 .unwrap()
926 .status
927 .success();
928 assert!(!has_local, "local branch should not exist before the test");
929
930 let repo_root = local.path().to_str().unwrap();
931
932 let start =
934 resolve_branch_start_point(repo_root, "gh-issue-42-fix-login-bug", "main").await;
935 assert_eq!(
936 start, "origin/gh-issue-42-fix-login-bug",
937 "should use remote branch as start point when origin/<branch> exists"
938 );
939 }
940
941 #[tokio::test]
945 async fn resolve_branch_start_point_prefers_origin_default_over_local() {
946 let remote = tempfile::tempdir().unwrap();
947 git(remote.path(), &["init", "--bare"]);
948
949 let local = tempfile::tempdir().unwrap();
950 git(
951 local.path(),
952 &["clone", remote.path().to_str().unwrap(), "."],
953 );
954 git(local.path(), &["config", "user.email", "t@t.com"]);
955 git(local.path(), &["config", "user.name", "T"]);
956
957 std::fs::write(local.path().join("README.md"), "main").unwrap();
959 git(local.path(), &["add", "."]);
960 git(local.path(), &["commit", "-m", "init"]);
961 git(local.path(), &["push", "origin", "HEAD:main"]);
962 git(local.path(), &["fetch", "--all"]);
963
964 let repo_root = local.path().to_str().unwrap();
965
966 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
968 assert_eq!(
969 start, "origin/main",
970 "should anchor on origin/<default_branch> instead of local default"
971 );
972 }
973
974 fn git_stdout(dir: &std::path::Path, args: &[&str]) -> String {
976 let out = std::process::Command::new("git")
977 .args(args)
978 .current_dir(dir)
979 .output()
980 .unwrap_or_else(|e| panic!("git {:?} failed to start: {e}", args));
981 if !out.status.success() {
982 panic!(
983 "git {:?} failed:\nstdout: {}\nstderr: {}",
984 args,
985 String::from_utf8_lossy(&out.stdout),
986 String::from_utf8_lossy(&out.stderr)
987 );
988 }
989 String::from_utf8_lossy(&out.stdout).trim().to_string()
990 }
991
992 #[tokio::test]
997 #[serial_test::serial(orch_home)]
998 async fn setup_worktree_anchors_on_origin_not_local_main() {
999 let orch_home_dir = tempfile::tempdir().unwrap();
1001 let prev_orch_home = std::env::var("ORCH_HOME").ok();
1002 std::env::set_var("ORCH_HOME", orch_home_dir.path());
1003
1004 let remote = tempfile::tempdir().unwrap();
1006 git(remote.path(), &["init", "--bare"]);
1007
1008 let project = tempfile::tempdir().unwrap();
1010 git(
1011 project.path(),
1012 &["clone", remote.path().to_str().unwrap(), "."],
1013 );
1014 git(project.path(), &["config", "user.email", "t@t.com"]);
1015 git(project.path(), &["config", "user.name", "T"]);
1016
1017 std::fs::write(project.path().join("README.md"), "pushed\n").unwrap();
1019 git(project.path(), &["add", "."]);
1020 git(project.path(), &["commit", "-m", "pushed baseline"]);
1021 git(project.path(), &["push", "origin", "HEAD:main"]);
1022 let pushed_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1023
1024 git(project.path(), &["remote", "set-head", "origin", "main"]);
1026
1027 std::fs::write(project.path().join("LOCAL_ONLY.md"), "unpushed\n").unwrap();
1029 git(project.path(), &["add", "."]);
1030 git(project.path(), &["commit", "-m", "local unpushed commit"]);
1031 let local_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1032 assert_ne!(
1033 pushed_sha, local_sha,
1034 "precondition: local main must be ahead of origin/main"
1035 );
1036
1037 let setup = setup_worktree(
1039 "internal:1",
1040 "leak guard",
1041 project.path(),
1042 &None,
1043 "test/repo",
1044 )
1045 .await
1046 .expect("setup_worktree should succeed");
1047
1048 let wt_sha = git_stdout(&setup.work_dir, &["rev-parse", "HEAD"]);
1050 assert_eq!(
1051 wt_sha, pushed_sha,
1052 "worktree HEAD must equal origin/main, not local main"
1053 );
1054 assert_ne!(
1055 wt_sha, local_sha,
1056 "worktree must NOT include the operator's unpushed commit"
1057 );
1058 assert!(
1059 !setup.work_dir.join("LOCAL_ONLY.md").exists(),
1060 "unpushed-only file must not appear in the worktree"
1061 );
1062
1063 match prev_orch_home {
1065 Some(v) => std::env::set_var("ORCH_HOME", v),
1066 None => std::env::remove_var("ORCH_HOME"),
1067 }
1068 }
1069
1070 #[tokio::test]
1073 async fn resolve_branch_start_point_falls_back_to_local_default() {
1074 let local = tempfile::tempdir().unwrap();
1075 git(local.path(), &["init"]);
1076 git(local.path(), &["config", "user.email", "t@t.com"]);
1077 git(local.path(), &["config", "user.name", "T"]);
1078 git(local.path(), &["checkout", "-b", "main"]);
1079 std::fs::write(local.path().join("README.md"), "main").unwrap();
1080 git(local.path(), &["add", "."]);
1081 git(local.path(), &["commit", "-m", "init"]);
1082
1083 let repo_root = local.path().to_str().unwrap();
1084
1085 let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1087 assert_eq!(
1088 start, "main",
1089 "should fall back to local default_branch when no remote refs exist"
1090 );
1091 }
1092
1093 #[test]
1094 fn branch_name_basic() {
1095 let name = branch_name("42", "Fix login bug");
1096 assert_eq!(name, "gh-issue-42-fix-login-bug");
1097 }
1098
1099 #[test]
1100 fn branch_name_internal_task() {
1101 let name = branch_name("internal:8", "Fix login bug");
1102 assert_eq!(name, "internal-8-fix-login-bug");
1103 }
1104
1105 #[test]
1106 fn branch_name_special_chars() {
1107 let name = branch_name("7", "Add OAuth2/OIDC (Google & GitHub)");
1108 assert_eq!(name, "gh-issue-7-add-oauth2-oidc-google-github");
1109 }
1110
1111 #[test]
1112 fn branch_name_truncates_long_slug() {
1113 let title =
1114 "This is a very long task title that should be truncated to forty characters maximum";
1115 let name = branch_name("1", title);
1116 let slug = name.strip_prefix("gh-issue-1-").unwrap();
1118 assert!(slug.len() <= 40, "slug length {} > 40", slug.len());
1119 }
1120
1121 #[test]
1122 fn branch_name_trims_trailing_dashes() {
1123 let name = branch_name("5", "Fix bug---");
1124 assert!(
1125 !name.ends_with('-'),
1126 "branch name should not end with dash: {name}"
1127 );
1128 }
1129
1130 #[test]
1131 fn branch_name_empty_title() {
1132 let name = branch_name("99", "");
1133 assert_eq!(name, "gh-issue-99");
1134 }
1135
1136 #[test]
1137 fn branch_name_empty_title_internal() {
1138 let name = branch_name("internal:99", "");
1139 assert_eq!(name, "internal-99");
1140 }
1141
1142 #[test]
1143 fn branch_name_all_special_chars() {
1144 let name = branch_name("10", "--- ??? ---");
1145 assert_eq!(name, "gh-issue-10");
1146 assert!(!name.is_empty());
1147 }
1148
1149 #[test]
1150 fn branch_name_chinese_chars_no_panic() {
1151 let name = branch_name("265", "implement 用户认证 user auth");
1152 assert!(name.starts_with("gh-issue-265-"));
1153 assert!(!name.is_empty());
1154 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1155 assert!(slug.len() <= 40, "slug too long: {}", slug.len());
1156 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1157 }
1158
1159 #[test]
1160 fn task_id_from_worktree_name_parses_issue_branches() {
1161 assert_eq!(
1162 task_id_from_worktree_name("gh-issue-42-fix-login"),
1163 Some("42".to_string())
1164 );
1165 assert_eq!(
1166 task_id_from_worktree_name("gh-task-42-fix-login"),
1167 Some("42".to_string())
1168 );
1169 }
1170
1171 #[test]
1172 fn task_id_from_worktree_name_parses_internal_branches() {
1173 assert_eq!(
1174 task_id_from_worktree_name("internal-8-fix-login"),
1175 Some("internal:8".to_string())
1176 );
1177 assert_eq!(
1178 task_id_from_worktree_name("gh-issue-internal-8-fix-login"),
1179 Some("internal:8".to_string())
1180 );
1181 }
1182
1183 #[test]
1184 fn task_id_from_worktree_name_returns_none_for_unknown_names() {
1185 assert!(task_id_from_worktree_name("random-worktree").is_none());
1186 }
1187
1188 #[test]
1189 fn branch_name_emoji_no_panic() {
1190 let name = branch_name("265", "fix 🚀 deployment pipeline 🔥");
1191 assert!(name.starts_with("gh-issue-265-"));
1192 let slug = name.strip_prefix("gh-issue-265-").unwrap();
1193 assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1194 }
1195
1196 #[test]
1197 fn branch_name_all_non_ascii_falls_back_to_task_id() {
1198 let name = branch_name("265", "用户认证 실행 тест");
1199 assert_eq!(name, "gh-issue-265");
1200 }
1201
1202 #[tokio::test]
1203 async fn find_existing_worktree_returns_none_for_missing() {
1204 let dir = tempfile::tempdir().unwrap();
1205 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1206 }
1207
1208 #[tokio::test]
1209 async fn find_existing_worktree_matches_prefix() {
1210 let dir = tempfile::tempdir().unwrap();
1211 let wt_dir = dir.path().join("gh-issue-42-fix-login-bug");
1212 tokio::fs::create_dir(&wt_dir).await.unwrap();
1213
1214 let result = find_existing_worktree(dir.path(), "42").await;
1215 assert_eq!(result, Some(wt_dir));
1216 }
1217
1218 #[tokio::test]
1219 async fn find_existing_worktree_matches_internal_prefix() {
1220 let dir = tempfile::tempdir().unwrap();
1221 let wt_dir = dir.path().join("internal-8-fix");
1222 tokio::fs::create_dir(&wt_dir).await.unwrap();
1223
1224 let result = find_existing_worktree(dir.path(), "internal:8").await;
1225 assert_eq!(result, Some(wt_dir));
1226 }
1227
1228 #[tokio::test]
1229 async fn find_existing_worktree_matches_legacy_gh_task_prefix() {
1230 let dir = tempfile::tempdir().unwrap();
1232 let wt_dir = dir.path().join("gh-task-internal-8-fix");
1233 tokio::fs::create_dir(&wt_dir).await.unwrap();
1234
1235 let result = find_existing_worktree(dir.path(), "internal:8").await;
1236 assert_eq!(result, Some(wt_dir));
1237 }
1238
1239 #[tokio::test]
1240 async fn find_existing_worktree_matches_legacy_external_prefix() {
1241 let dir = tempfile::tempdir().unwrap();
1243 let wt_dir = dir.path().join("gh-task-42-fix-login-bug");
1244 tokio::fs::create_dir(&wt_dir).await.unwrap();
1245
1246 let result = find_existing_worktree(dir.path(), "42").await;
1247 assert_eq!(result, Some(wt_dir));
1248 }
1249
1250 #[tokio::test]
1251 async fn find_existing_worktree_ignores_other_tasks() {
1252 let dir = tempfile::tempdir().unwrap();
1253 tokio::fs::create_dir(dir.path().join("gh-issue-43-other-task"))
1254 .await
1255 .unwrap();
1256
1257 assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1258 }
1259
1260 #[tokio::test]
1264 async fn resolve_parent_branch_no_store_returns_none() {
1265 let dir = tempfile::tempdir().unwrap();
1266 let result = resolve_parent_branch(&None, Some(1), dir.path()).await;
1267 assert!(result.is_none());
1268 }
1269
1270 #[tokio::test]
1272 async fn resolve_parent_branch_no_parent_id_returns_none() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let result = resolve_parent_branch(&None, None, dir.path()).await;
1275 assert!(result.is_none());
1276 }
1277
1278 #[tokio::test]
1280 async fn resolve_parent_branch_empty_parent_branch_returns_none() {
1281 let db_dir = tempfile::tempdir().unwrap();
1282 let db_path = db_dir.path().join("test.db");
1283 let store = Arc::new(
1284 crate::store::TaskStore::open_single(&db_path)
1285 .await
1286 .unwrap(),
1287 );
1288
1289 let parent_id = store
1291 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1292 .await
1293 .unwrap();
1294
1295 let dir = tempfile::tempdir().unwrap();
1296 let store_opt = Some(store);
1297 let result = resolve_parent_branch(&store_opt, Some(parent_id), dir.path()).await;
1298 assert!(result.is_none());
1299 }
1300
1301 #[tokio::test]
1303 async fn resolve_parent_branch_returns_parent_branch_and_worktree() {
1304 let db_dir = tempfile::tempdir().unwrap();
1305 let db_path = db_dir.path().join("test.db");
1306 let store = Arc::new(
1307 crate::store::TaskStore::open_single(&db_path)
1308 .await
1309 .unwrap(),
1310 );
1311
1312 let parent_id = store
1314 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1315 .await
1316 .unwrap();
1317 store
1318 .set_fields(
1319 parent_id,
1320 &[("branch", serde_json::json!("gh-issue-99-parent-task"))],
1321 )
1322 .await
1323 .unwrap();
1324
1325 let worktrees_base = tempfile::tempdir().unwrap();
1326 let store_opt = Some(store);
1327 let result =
1328 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1329
1330 let (branch, wt) = result.expect("should return parent branch");
1331 assert_eq!(branch, "gh-issue-99-parent-task");
1332 assert_eq!(wt, worktrees_base.path().join("gh-issue-99-parent-task"));
1333 }
1334
1335 #[tokio::test]
1337 async fn resolve_parent_branch_prefers_existing_worktree_path() {
1338 let db_dir = tempfile::tempdir().unwrap();
1339 let db_path = db_dir.path().join("test.db");
1340 let store = Arc::new(
1341 crate::store::TaskStore::open_single(&db_path)
1342 .await
1343 .unwrap(),
1344 );
1345
1346 let worktrees_base = tempfile::tempdir().unwrap();
1347 let parent_wt_dir = worktrees_base.path().join("custom-worktree-path");
1349 tokio::fs::create_dir_all(&parent_wt_dir).await.unwrap();
1350
1351 let parent_id = store
1352 .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1353 .await
1354 .unwrap();
1355 store
1356 .set_fields(
1357 parent_id,
1358 &[
1359 ("branch", serde_json::json!("gh-issue-99-parent-task")),
1360 (
1361 "worktree",
1362 serde_json::json!(parent_wt_dir.to_string_lossy().as_ref()),
1363 ),
1364 ],
1365 )
1366 .await
1367 .unwrap();
1368
1369 let store_opt = Some(store);
1370 let result =
1371 resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1372
1373 let (branch, wt) = result.expect("should return parent branch");
1374 assert_eq!(branch, "gh-issue-99-parent-task");
1375 assert_eq!(wt, parent_wt_dir);
1376 }
1377
1378 #[tokio::test]
1383 async fn concurrent_worktree_checks_no_starvation() {
1384 let temp_dir = tempfile::tempdir().unwrap();
1385 let worktrees_base = temp_dir.path().join("worktrees");
1386 tokio::fs::create_dir_all(&worktrees_base).await.unwrap();
1387
1388 let wt_dir = worktrees_base.join("gh-issue-123-test");
1390 tokio::fs::create_dir_all(&wt_dir).await.unwrap();
1391 let gitdir_target = temp_dir
1393 .path()
1394 .join(".git")
1395 .join("worktrees")
1396 .join("gh-issue-123-test");
1397 tokio::fs::create_dir_all(&gitdir_target).await.unwrap();
1398 let git_file = wt_dir.join(".git");
1399 tokio::fs::write(&git_file, format!("gitdir: {}\n", gitdir_target.display()))
1400 .await
1401 .unwrap();
1402
1403 let handles: Vec<_> = (0..20)
1406 .map(|i| {
1407 let wb = worktrees_base.clone();
1408 let wd = wt_dir.clone();
1409 tokio::spawn(async move {
1410 if i % 2 == 0 {
1411 list_project_worktrees(&wb).await.ok();
1412 } else {
1413 validate_worktree_gitdir(&wd).await;
1414 }
1415 })
1416 })
1417 .collect();
1418
1419 let results = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1421 futures::future::join_all(handles).await
1422 })
1423 .await;
1424
1425 assert!(
1426 results.is_ok(),
1427 "concurrent worktree checks timed out (starvation?)"
1428 );
1429 }
1430
1431 #[test]
1434 fn normalize_branch_name_strips_origin_prefix() {
1435 assert_eq!(
1436 normalize_branch_name("origin/main"),
1437 Some("main".to_string())
1438 );
1439 assert_eq!(
1440 normalize_branch_name("origin/develop"),
1441 Some("develop".to_string())
1442 );
1443 }
1444
1445 #[test]
1446 fn normalize_branch_name_strips_full_ref_path() {
1447 assert_eq!(
1448 normalize_branch_name("refs/remotes/origin/main"),
1449 Some("main".to_string())
1450 );
1451 assert_eq!(
1452 normalize_branch_name("refs/remotes/origin/develop"),
1453 Some("develop".to_string())
1454 );
1455 }
1456
1457 #[test]
1458 fn normalize_branch_name_strips_remotes_prefix() {
1459 assert_eq!(
1460 normalize_branch_name("remotes/origin/main"),
1461 Some("main".to_string())
1462 );
1463 }
1464
1465 #[test]
1466 fn normalize_branch_name_passes_through_plain_branch() {
1467 assert_eq!(normalize_branch_name("main"), Some("main".to_string()));
1468 assert_eq!(normalize_branch_name("master"), Some("master".to_string()));
1469 assert_eq!(
1470 normalize_branch_name("feature/foo"),
1471 Some("feature/foo".to_string())
1472 );
1473 }
1474
1475 #[test]
1476 fn normalize_branch_name_rejects_empty_and_refs_only() {
1477 assert_eq!(normalize_branch_name(""), None);
1478 assert_eq!(normalize_branch_name(" "), None);
1479 assert_eq!(
1480 normalize_branch_name("refs/heads/main"),
1481 Some("main".to_string())
1482 );
1483 }
1484}