Skip to main content

orch/engine/runner/
worktree.rs

1//! Git worktree management for task execution.
2//!
3//! Each task runs in an isolated worktree to prevent conflicts.
4//! Worktrees are stored at `~/.orch/worktrees/<project>/<branch>/`.
5
6use 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/// Result of worktree setup.
14pub struct WorktreeSetup {
15    /// The directory where the agent will work
16    pub work_dir: PathBuf,
17    /// The branch name
18    pub branch: String,
19    /// The default branch of the repository
20    pub default_branch: String,
21    /// The main project directory (original, not worktree)
22    pub main_project_dir: PathBuf,
23}
24
25/// Canonical project name used under `~/.orch/worktrees/`.
26pub 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
35/// Base worktrees directory for a project.
36pub 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
42/// List all worktree directories for a project.
43pub 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
61/// Extract a task ID from a worktree directory name.
62pub 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
82/// Abort any rebase in progress for a worktree.
83pub 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
91/// Rebase a worktree on top of `origin/{default_branch}`.
92///
93/// Stashes any uncommitted changes before rebasing to avoid failing the
94/// rebase due to "You have unstaged changes".  The stash is restored after
95/// the rebase succeeds; on failure the stash is preserved for manual recovery.
96pub 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
117/// Generate a branch name from task ID and title.
118///
119/// Format: `internal-{id}-{slug}` for internal tasks, `gh-issue-{id}-{slug}` for external.
120/// Slug is lowercase, non-alphanum→`-`, max 40 chars.
121pub 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    // Collapse consecutive dashes and trim
135    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    // Truncate slug to 40 chars
151    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
161/// Replace characters in `task_id` that are not safe for branch/worktree names.
162fn sanitize_task_id(task_id: &str) -> String {
163    task_id.replace(':', "-")
164}
165
166/// Normalize a git ref output to a plain branch name.
167///
168/// Handles the various output formats that different git versions and
169/// configurations can produce for remote HEAD refs:
170/// - `origin/main` → `main`
171/// - `refs/remotes/origin/main` → `main`
172/// - `remotes/origin/main` → `main`
173/// - `main` → `main`
174fn normalize_branch_name(raw: &str) -> Option<String> {
175    let branch = raw.trim();
176    if branch.is_empty() {
177        return None;
178    }
179
180    // Strip known prefixes in order of specificity.
181    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
194/// Detect the default branch of a repository.
195///
196/// Tries multiple strategies and normalizes the result so callers always
197/// receive a clean branch name like `"main"`, never a raw ref path.
198pub async fn detect_default_branch(project_dir: &Path) -> String {
199    // Strategy 1: symbolic-ref (fast, works when origin/HEAD is set).
200    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    // Strategy 2: rev-parse --abbrev-ref (resolves symref to target).
216    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    // Strategy 3: parse `git remote show origin` output.
232    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
262/// Resolve PROJECT_DIR to the main repo if it's inside a worktree.
263///
264/// This prevents nested worktrees when subtasks inherit a parent's worktree dir.
265pub 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
294/// Resolve the starting point for creating a new local branch.
295///
296/// Preference order:
297/// 1. `origin/<branch>` if it exists — cleanup kept the remote branch after
298///    deleting the local one, so resume from the agent's committed work.
299/// 2. `origin/<default_branch>` if it exists — anchors on the pushed tip of
300///    the default branch so unpushed local commits in the operator's main
301///    checkout don't leak into the worktree.
302/// 3. `default_branch` as a last resort (no origin remote / never fetched).
303pub(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
330/// Validate that a worktree directory has a working gitdir link.
331///
332/// In a git worktree, `.git` is a file containing `gitdir: /path/to/.git/worktrees/<name>`.
333/// Returns `false` when the `.git` file is missing or points to a non-existent gitdir,
334/// indicating the worktree metadata is broken and the directory should be cleaned up.
335pub async fn validate_worktree_gitdir(worktree_dir: &Path) -> bool {
336    let git_file = worktree_dir.join(".git");
337    // Use tokio::fs::metadata for async-safe existence check
338    if tokio::fs::metadata(&git_file).await.is_err() {
339        return false;
340    }
341    // In a linked worktree .git is a file; in the main repo it's a directory (always valid).
342    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    // Expected format: "gitdir: /absolute/path/to/.git/worktrees/<name>\n"
355    // Use spawn_blocking for the final exists check on the resolved path
356    if let Some(gitdir_path) = content
357        .lines()
358        .next()
359        .and_then(|l| l.strip_prefix("gitdir: "))
360    {
361        // Use async metadata for the gitdir check too.
362        return tokio::fs::metadata(gitdir_path.trim())
363            .await
364            .map(|m| m.is_dir())
365            .unwrap_or(false);
366    }
367    false
368}
369
370/// Resolve the branch and worktree directory from a parent task, if available.
371///
372/// When a child task is created from a mention on a PR, it should work on the
373/// parent task's branch so changes are committed to the existing PR rather than
374/// a new one being opened.
375async 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        // Use async metadata to avoid blocking the reactor thread.
387        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
408/// Set up a worktree for task execution.
409///
410/// Returns the working directory, branch name, and default branch.
411pub 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    // Resolve to main repo (avoid nested worktrees)
419    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    // Check if we have a saved branch/worktree in store
426    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            // Use spawn_blocking for blocking Path::exists call in async context
441            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            // Child task with no saved branch: inherit parent's branch so we
460            // commit onto the existing PR instead of opening a new one.
461            (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        // No store record — check parent branch, then existing worktree by prefix
468        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    // Abort any in-progress rebase in an existing worktree.
494    //
495    // Agents can leave a worktree stuck mid-rebase (e.g. after running
496    // `git rebase -i` interactively).  The next dispatch attempt would
497    // inherit that broken state, causing all subsequent git operations to
498    // fail.  Aborting here is safe: if no rebase is in progress git exits
499    // with a non-zero code which we silently ignore.
500    // Use tokio::fs for async-safe existence check
501    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        // Check for a corrupted git index (e.g. "error: could not write index").
509        // `git status` reads the index; a non-zero exit with index-related stderr
510        // indicates corruption that will cause every subsequent git operation to
511        // fail, leading to an infinite stuck-recovery loop.  When detected, nuke
512        // the worktree and recreate it from scratch below.
513        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            // Remove git worktree reference first, then delete the directory.
536            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 the directory still exists (e.g. worktree remove failed), remove it
548            // manually so the worktree-creation block below re-creates it cleanly.
549            if tokio::fs::metadata(&worktree_dir).await.is_ok() {
550                let _ = tokio::fs::remove_dir_all(&worktree_dir).await;
551            }
552            // Prune stale worktree metadata so git won't complain on re-add.
553            let _ = Command::new("git")
554                .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
555                .output_with_context()
556                .await;
557        }
558    }
559
560    // Create worktree if it doesn't exist
561    if tokio::fs::metadata(&worktree_dir).await.is_err() {
562        tracing::info!(task_id, worktree = %worktree_dir.display(), "creating worktree");
563
564        // Fetch latest so `origin/<default_branch>` (and any pre-existing
565        // remote task branch) is fresh. We deliberately do NOT `git pull` on
566        // the operator's checkout: branching from `origin/<default_branch>`
567        // means unpushed local commits or in-progress work on `main` never
568        // leak into the new worktree.
569        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        // Create local branch. Prefer `origin/<branch>` when it already exists
581        // (recovery case: cleanup deleted local branch but kept the remote),
582        // so the new worktree starts from the agent's committed work instead
583        // of the default branch tip.
584        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        // Create worktree
602        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                // Retry: prune and recreate
642                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    // Link issue to branch via GitHub API (Development sidebar).
729    // Must happen before the agent pushes — createLinkedBranch only works
730    // for branches that don't yet exist on GitHub.
731    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    // Save worktree info to store
750    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
782/// Find an existing worktree by task ID prefix.
783async 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                // Log the error but continue scanning - a single entry error shouldn't stop us
817                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    /// Helper: run a git command in `dir`, panicking on failure.
848    fn git(dir: &std::path::Path, args: &[&str]) {
849        let mut cmd = std::process::Command::new("git");
850        cmd.current_dir(dir);
851        // Global commit.gpgsign calls the operator's signer (1Password)
852        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    /// When `origin/<branch>` exists, `resolve_branch_start_point` should
870    /// return `"origin/<branch>"` so that the worktree starts from the
871    /// already-committed work, not from the default branch tip.
872    #[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        // Set up a working clone to push branches to the remote.
878        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        // Initial commit on main.
887        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        // Feature branch with a distinct commit.
893        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        // Local repo: clone + fetch, but NO local branch (simulates cleanup
906        // with keep_remote_branch=true where local branch was deleted).
907        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        // Confirm the local branch does NOT exist; only the remote tracking ref.
916        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        // With remote branch present → should prefer origin/<branch>.
933        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    /// When `origin/<branch>` does NOT exist but `origin/<default_branch>` does,
942    /// `resolve_branch_start_point` should anchor on the remote default branch
943    /// so unpushed local commits on `main` don't leak into the worktree.
944    #[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        // Commit something so origin/main exists.
958        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        // Branch "no-such-branch" has never been pushed, but origin/main exists.
967        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    /// Capture stdout of a git command in `dir`.
975    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    /// End-to-end guard for the operator-main-leak fix: when the operator's
993    /// local `main` has commits that are not yet pushed, `setup_worktree`
994    /// must branch from `origin/main` and produce a worktree whose HEAD is
995    /// the pushed tip — never the local-only tip.
996    #[tokio::test]
997    #[serial_test::serial(orch_home)]
998    async fn setup_worktree_anchors_on_origin_not_local_main() {
999        // Isolate ORCH_HOME so the worktree lands in a temp dir.
1000        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        // Bare "remote".
1005        let remote = tempfile::tempdir().unwrap();
1006        git(remote.path(), &["init", "--bare"]);
1007
1008        // Operator's project clone.
1009        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        // Pushed baseline on main.
1018        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        // Ensure origin/HEAD is set so detect_default_branch resolves "main".
1025        git(project.path(), &["remote", "set-head", "origin", "main"]);
1026
1027        // Operator's WIP: an unpushed local commit on top of main.
1028        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        // Run setup_worktree for a fresh internal task (skips GitHub linking).
1038        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        // Worktree must sit on the pushed tip, not the local-only tip.
1049        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        // Restore ORCH_HOME.
1064        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    /// When neither `origin/<branch>` nor `origin/<default_branch>` exist,
1071    /// fall back to the local default branch name as a last resort.
1072    #[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        // No remote at all → fall back to "main".
1086        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        // slug part should be max 40 chars
1117        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        // Old worktrees created before the rename should still be found
1231        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        // Old external worktrees with gh-task- prefix should still be found
1242        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    // --- resolve_parent_branch tests ---
1261
1262    /// No store → always returns None.
1263    #[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    /// parent_id = None → always returns None regardless of store.
1271    #[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    /// Parent exists but has empty branch → returns None so child gets its own branch.
1279    #[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        // Create parent task — branch defaults to empty string
1290        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    /// Parent has a branch set → child inherits that branch and worktree path.
1302    #[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        // Create parent task and set its branch
1313        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    /// When parent has both branch and a worktree path that exists, use the worktree path.
1336    #[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        // Create the parent worktree directory
1348        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    /// Regression test: concurrent calls to list_project_worktrees and
1379    /// validate_worktree_gitdir should not starve the Tokio worker thread
1380    /// pool. This was broken by blocking Path::exists/is_dir calls in async
1381    /// functions (issue #2467).
1382    #[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        // Create a fake worktree with valid gitdir
1389        let wt_dir = worktrees_base.join("gh-issue-123-test");
1390        tokio::fs::create_dir_all(&wt_dir).await.unwrap();
1391        // Create .git as a file pointing to a real directory (worktree link)
1392        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        // Spawn many concurrent tasks - if any use blocking calls, they'll
1404        // block the worker thread and cause timeout or deadlock.
1405        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        // Wait with timeout to detect starvation
1420        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    // --- normalize_branch_name tests ---
1432
1433    #[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}