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 mut force_fresh = false;
439    if let (Some(store), Some(record)) = (store.as_ref(), task_record.as_ref()) {
440        let key = crate::store::worktree_refresh_key(record.id);
441        if matches!(store.kv_get(&key).await, Ok(Some(_))) {
442            let _ = store.kv_delete(&key).await;
443            force_fresh = true;
444            tracing::info!(
445                task_id,
446                "dependency merged, discarding the old worktree so the next run starts at the default branch"
447            );
448        }
449    }
450
451    let (branch_name_str, worktree_dir) = if force_fresh {
452        if let Some(existing_dir) = find_existing_worktree(&worktrees_base, task_id).await {
453            discard_worktree(&main_dir, &existing_dir).await;
454        }
455        if let Some(saved) = saved_worktree.as_ref().filter(|path| !path.is_empty()) {
456            let saved_path = PathBuf::from(saved);
457            if tokio::fs::metadata(&saved_path).await.is_ok() {
458                discard_worktree(&main_dir, &saved_path).await;
459            }
460        }
461        let bn = branch_name(task_id, title);
462        (bn.clone(), worktrees_base.join(&bn))
463    } else if let Some(ref saved) = saved_branch {
464        if !saved.is_empty() {
465            // Use spawn_blocking for blocking Path::exists call in async context
466            let wt = match &saved_worktree {
467                Some(wt) if !wt.is_empty() => {
468                    let wt_str = wt.clone();
469                    let exists = tokio::task::spawn_blocking(move || Path::new(&wt_str).exists())
470                        .await
471                        .unwrap_or(false);
472                    if exists {
473                        PathBuf::from(wt)
474                    } else {
475                        worktrees_base.join(saved)
476                    }
477                }
478                _ => worktrees_base.join(saved),
479            };
480            (saved.clone(), wt)
481        } else if let Some((parent_branch, parent_wt)) =
482            resolve_parent_branch(store, parent_id, &worktrees_base).await
483        {
484            // Child task with no saved branch: inherit parent's branch so we
485            // commit onto the existing PR instead of opening a new one.
486            (parent_branch, parent_wt)
487        } else {
488            let bn = branch_name(task_id, title);
489            (bn.clone(), worktrees_base.join(&bn))
490        }
491    } else {
492        // No store record — check parent branch, then existing worktree by prefix
493        if let Some((parent_branch, parent_wt)) =
494            resolve_parent_branch(store, parent_id, &worktrees_base).await
495        {
496            (parent_branch, parent_wt)
497        } else {
498            let existing = find_existing_worktree(&worktrees_base, task_id).await;
499            if let Some(existing_dir) = existing {
500                let bn = existing_dir
501                    .file_name()
502                    .filter(|n| !n.is_empty())
503                    .map(|n| n.to_string_lossy().to_string())
504                    .unwrap_or_else(|| branch_name(task_id, title));
505                tracing::info!(task_id, worktree = %existing_dir.display(), "found existing worktree");
506                (bn, existing_dir)
507            } else {
508                let bn = branch_name(task_id, title);
509                (bn.clone(), worktrees_base.join(&bn))
510            }
511        }
512    };
513
514    if branch_name_str.is_empty() {
515        anyhow::bail!("empty branch name for task {task_id}");
516    }
517
518    // Abort any in-progress rebase in an existing worktree.
519    //
520    // Agents can leave a worktree stuck mid-rebase (e.g. after running
521    // `git rebase -i` interactively).  The next dispatch attempt would
522    // inherit that broken state, causing all subsequent git operations to
523    // fail.  Aborting here is safe: if no rebase is in progress git exits
524    // with a non-zero code which we silently ignore.
525    // Use tokio::fs for async-safe existence check
526    if tokio::fs::metadata(&worktree_dir).await.is_ok() {
527        let _ = Command::new("git")
528            .args(["rebase", "--abort"])
529            .current_dir(&worktree_dir)
530            .output_with_context()
531            .await;
532
533        // Check for a corrupted git index (e.g. "error: could not write index").
534        // `git status` reads the index; a non-zero exit with index-related stderr
535        // indicates corruption that will cause every subsequent git operation to
536        // fail, leading to an infinite stuck-recovery loop.  When detected, nuke
537        // the worktree and recreate it from scratch below.
538        let index_ok = Command::new("git")
539            .args(["status", "--porcelain"])
540            .current_dir(&worktree_dir)
541            .output_with_context()
542            .await
543            .map(|o| o.status.success())
544            .unwrap_or(false);
545
546        if !index_ok {
547            let stderr_hint = Command::new("git")
548                .args(["status", "--porcelain"])
549                .current_dir(&worktree_dir)
550                .output_with_context()
551                .await
552                .map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
553                .unwrap_or_default();
554            tracing::warn!(
555                task_id,
556                worktree = %worktree_dir.display(),
557                stderr = %stderr_hint,
558                "corrupted worktree index detected — removing and recreating worktree"
559            );
560            // Remove git worktree reference first, then delete the directory.
561            let _ = Command::new("git")
562                .args([
563                    "-C",
564                    &main_dir.to_string_lossy(),
565                    "worktree",
566                    "remove",
567                    "--force",
568                    &worktree_dir.to_string_lossy(),
569                ])
570                .output_with_context()
571                .await;
572            // If the directory still exists (e.g. worktree remove failed), remove it
573            // manually so the worktree-creation block below re-creates it cleanly.
574            if tokio::fs::metadata(&worktree_dir).await.is_ok() {
575                let _ = tokio::fs::remove_dir_all(&worktree_dir).await;
576            }
577            // Prune stale worktree metadata so git won't complain on re-add.
578            let _ = Command::new("git")
579                .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
580                .output_with_context()
581                .await;
582        }
583    }
584
585    // Create worktree if it doesn't exist
586    if tokio::fs::metadata(&worktree_dir).await.is_err() {
587        tracing::info!(task_id, worktree = %worktree_dir.display(), "creating worktree");
588
589        // Fetch latest so `origin/<default_branch>` (and any pre-existing
590        // remote task branch) is fresh. We deliberately do NOT `git pull` on
591        // the operator's checkout: branching from `origin/<default_branch>`
592        // means unpushed local commits or in-progress work on `main` never
593        // leak into the new worktree.
594        let _ = crate::engine::runner::git_ops::git()
595            .await
596            .args([
597                "-C",
598                &main_dir.to_string_lossy(),
599                "fetch",
600                "--prune",
601                "origin",
602            ])
603            .output_with_context()
604            .await;
605
606        // Create local branch. Prefer `origin/<branch>` when it already exists
607        // (recovery case: cleanup deleted local branch but kept the remote),
608        // so the new worktree starts from the agent's committed work instead
609        // of the default branch tip.
610        let start_point = resolve_branch_start_point(
611            &main_dir.to_string_lossy(),
612            &branch_name_str,
613            &default_branch,
614        )
615        .await;
616        let _ = Command::new("git")
617            .args([
618                "-C",
619                &main_dir.to_string_lossy(),
620                "branch",
621                &branch_name_str,
622                &start_point,
623            ])
624            .output_with_context()
625            .await;
626
627        // Create worktree
628        if let Some(parent) = worktree_dir.parent() {
629            tokio::fs::create_dir_all(parent).await?;
630        }
631
632        let cloned = match super::cow::create_worktree(
633            &main_dir,
634            &project_name(&main_dir),
635            &worktree_dir,
636            &branch_name_str,
637            &default_branch,
638        )
639        .await
640        {
641            Ok(()) => {
642                tracing::info!(task_id, "worktree cloned from project template");
643                true
644            }
645            Err(e) => {
646                tracing::debug!(task_id, error = %e, "copy-on-write worktree unavailable, using git worktree add");
647                false
648            }
649        };
650
651        if !cloned {
652            let output = Command::new("git")
653                .args([
654                    "-C",
655                    &main_dir.to_string_lossy(),
656                    "worktree",
657                    "add",
658                    &worktree_dir.to_string_lossy(),
659                    &branch_name_str,
660                ])
661                .output_with_context()
662                .await?;
663
664            if !output.status.success() && tokio::fs::metadata(&worktree_dir).await.is_err() {
665                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
666                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
667                // Retry: prune and recreate
668                tracing::warn!(
669                    task_id,
670                    stdout = %stdout,
671                    stderr = %stderr,
672                    "worktree creation failed, retrying after prune"
673                );
674
675                let _ = Command::new("git")
676                    .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
677                    .output_with_context()
678                    .await;
679
680                let _ = Command::new("git")
681                    .args([
682                        "-C",
683                        &main_dir.to_string_lossy(),
684                        "branch",
685                        "-D",
686                        &branch_name_str,
687                    ])
688                    .output_with_context()
689                    .await;
690
691                let _ = Command::new("git")
692                    .args([
693                        "-C",
694                        &main_dir.to_string_lossy(),
695                        "branch",
696                        &branch_name_str,
697                        &start_point,
698                    ])
699                    .output_with_context()
700                    .await;
701
702                let retry_output = Command::new("git")
703                    .args([
704                        "-C",
705                        &main_dir.to_string_lossy(),
706                        "worktree",
707                        "add",
708                        &worktree_dir.to_string_lossy(),
709                        &branch_name_str,
710                    ])
711                    .output_with_context()
712                    .await;
713
714                let mut retry_stdout = String::new();
715                let mut retry_stderr = String::new();
716                let mut retry_error = String::new();
717                match retry_output {
718                    Ok(output) => {
719                        retry_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
720                        retry_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
721                        tracing::warn!(
722                            task_id,
723                            stdout = %retry_stdout,
724                            stderr = %retry_stderr,
725                            "worktree creation retry failed"
726                        );
727                    }
728                    Err(err) => {
729                        retry_error = err.to_string();
730                        tracing::warn!(
731                            task_id,
732                            error = %retry_error,
733                            "worktree creation retry failed to run"
734                        );
735                    }
736                }
737
738                if tokio::fs::metadata(&worktree_dir).await.is_err() {
739                    anyhow::bail!(
740                    "failed to create worktree at {} for task {} (stdout: {}, stderr: {}, retry stdout: {}, retry stderr: {}, retry error: {})",
741                    worktree_dir.display(),
742                    task_id,
743                    stdout,
744                    stderr,
745                    retry_stdout,
746                    retry_stderr,
747                    retry_error
748                );
749                }
750            }
751        }
752    }
753
754    // Link issue to branch via GitHub API (Development sidebar).
755    // Must happen before the agent pushes — createLinkedBranch only works
756    // for branches that don't yet exist on GitHub.
757    if !task_id.starts_with("internal:") {
758        if let Ok(issue_num) = task_id.parse::<u64>() {
759            if let Ok(gh) = crate::github::http::GhHttp::new() {
760                match gh
761                    .link_issue_to_branch(repo, issue_num, &branch_name_str)
762                    .await
763                {
764                    Ok(id) => {
765                        tracing::info!(task_id, branch = %branch_name_str, linked = %id, "linked issue to branch");
766                    }
767                    Err(e) => {
768                        tracing::debug!(task_id, error = %e, "failed to link issue to branch (non-fatal)");
769                    }
770                }
771            }
772        }
773    }
774
775    // Save worktree info to store
776    if let Err(e) = store::store_set_result(
777        store,
778        repo,
779        task_id,
780        &[
781            (
782                "worktree",
783                serde_json::json!(worktree_dir.display().to_string()),
784            ),
785            ("branch", serde_json::json!(branch_name_str)),
786        ],
787    )
788    .await
789    {
790        tracing::warn!(task_id, err = %e, "failed to save worktree info to store (non-fatal)");
791    }
792
793    tracing::info!(
794        task_id,
795        worktree = %worktree_dir.display(),
796        branch = %branch_name_str,
797        "worktree ready"
798    );
799
800    Ok(WorktreeSetup {
801        work_dir: worktree_dir,
802        branch: branch_name_str,
803        default_branch,
804        main_project_dir: main_dir,
805    })
806}
807
808/// Find an existing worktree by task ID prefix.
809async fn discard_worktree(main_dir: &Path, worktree_dir: &Path) {
810    let _ = Command::new("git")
811        .args([
812            "-C",
813            &main_dir.to_string_lossy(),
814            "worktree",
815            "remove",
816            "--force",
817            &worktree_dir.to_string_lossy(),
818        ])
819        .output_with_context()
820        .await;
821    if tokio::fs::metadata(worktree_dir).await.is_ok() {
822        let _ = tokio::fs::remove_dir_all(worktree_dir).await;
823    }
824    let _ = Command::new("git")
825        .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
826        .output_with_context()
827        .await;
828}
829
830async fn find_existing_worktree(worktrees_base: &Path, task_id: &str) -> Option<PathBuf> {
831    let (prefix_new, legacy_prefixes) = if let Some(num) = task_id.strip_prefix("internal:") {
832        (
833            format!("internal-{num}-"),
834            vec![
835                format!("gh-task-internal-{num}-"),
836                format!("gh-issue-internal-{num}-"),
837            ],
838        )
839    } else {
840        let sanitized_id = sanitize_task_id(task_id);
841        (
842            format!("gh-issue-{sanitized_id}-"),
843            vec![format!("gh-task-{sanitized_id}-")],
844        )
845    };
846
847    let mut entries = match tokio::fs::read_dir(worktrees_base).await {
848        Ok(e) => e,
849        Err(e) => {
850            tracing::warn!(
851                error = %e,
852                path = %worktrees_base.display(),
853                "failed to read worktrees directory while searching for existing worktree"
854            );
855            return None;
856        }
857    };
858    loop {
859        let next_entry = match entries.next_entry().await {
860            Ok(Some(entry)) => entry,
861            Ok(None) => break,
862            Err(e) => {
863                // Log the error but continue scanning - a single entry error shouldn't stop us
864                tracing::warn!(error = %e, "failed to read directory entry while searching for worktree");
865                continue;
866            }
867        };
868
869        let name = next_entry.file_name().to_string_lossy().to_string();
870        let matches =
871            name.starts_with(&prefix_new) || legacy_prefixes.iter().any(|p| name.starts_with(p));
872        if matches {
873            match next_entry.metadata().await {
874                Ok(metadata) if metadata.is_dir() => return Some(next_entry.path()),
875                Ok(_) => {}
876                Err(e) => {
877                    tracing::warn!(
878                        error = %e,
879                        path = %next_entry.path().display(),
880                        "failed to read metadata for candidate worktree entry"
881                    );
882                }
883            }
884        }
885    }
886
887    None
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    /// Helper: run a git command in `dir`, panicking on failure.
895    fn git(dir: &std::path::Path, args: &[&str]) {
896        let mut cmd = std::process::Command::new("git");
897        cmd.current_dir(dir);
898        // Global commit.gpgsign calls the operator's signer (1Password)
899        if args.first() == Some(&"commit") {
900            cmd.args(["-c", "commit.gpgsign=false"]);
901        }
902        let out = cmd
903            .args(args)
904            .output()
905            .unwrap_or_else(|e| panic!("git {:?} failed to start: {e}", args));
906        if !out.status.success() {
907            panic!(
908                "git {:?} failed:\nstdout: {}\nstderr: {}",
909                args,
910                String::from_utf8_lossy(&out.stdout),
911                String::from_utf8_lossy(&out.stderr)
912            );
913        }
914    }
915
916    /// When `origin/<branch>` exists, `resolve_branch_start_point` should
917    /// return `"origin/<branch>"` so that the worktree starts from the
918    /// already-committed work, not from the default branch tip.
919    #[tokio::test]
920    async fn resolve_branch_start_point_prefers_remote_over_default() {
921        let remote = tempfile::tempdir().unwrap();
922        git(remote.path(), &["init", "--bare"]);
923
924        // Set up a working clone to push branches to the remote.
925        let setup = tempfile::tempdir().unwrap();
926        git(
927            setup.path(),
928            &["clone", remote.path().to_str().unwrap(), "."],
929        );
930        git(setup.path(), &["config", "user.email", "t@t.com"]);
931        git(setup.path(), &["config", "user.name", "T"]);
932
933        // Initial commit on main.
934        std::fs::write(setup.path().join("README.md"), "main").unwrap();
935        git(setup.path(), &["add", "."]);
936        git(setup.path(), &["commit", "-m", "init"]);
937        git(setup.path(), &["push", "origin", "HEAD:main"]);
938
939        // Feature branch with a distinct commit.
940        git(
941            setup.path(),
942            &["checkout", "-b", "gh-issue-42-fix-login-bug"],
943        );
944        std::fs::write(setup.path().join("feature.txt"), "work").unwrap();
945        git(setup.path(), &["add", "."]);
946        git(setup.path(), &["commit", "-m", "feature work"]);
947        git(
948            setup.path(),
949            &["push", "origin", "gh-issue-42-fix-login-bug"],
950        );
951
952        // Local repo: clone + fetch, but NO local branch (simulates cleanup
953        // with keep_remote_branch=true where local branch was deleted).
954        let local = tempfile::tempdir().unwrap();
955        git(
956            local.path(),
957            &["clone", remote.path().to_str().unwrap(), "."],
958        );
959        git(local.path(), &["config", "user.email", "t@t.com"]);
960        git(local.path(), &["config", "user.name", "T"]);
961        git(local.path(), &["fetch", "--all"]);
962        // Confirm the local branch does NOT exist; only the remote tracking ref.
963        let has_local = std::process::Command::new("git")
964            .args([
965                "-C",
966                local.path().to_str().unwrap(),
967                "rev-parse",
968                "--verify",
969                "gh-issue-42-fix-login-bug",
970            ])
971            .output()
972            .unwrap()
973            .status
974            .success();
975        assert!(!has_local, "local branch should not exist before the test");
976
977        let repo_root = local.path().to_str().unwrap();
978
979        // With remote branch present → should prefer origin/<branch>.
980        let start =
981            resolve_branch_start_point(repo_root, "gh-issue-42-fix-login-bug", "main").await;
982        assert_eq!(
983            start, "origin/gh-issue-42-fix-login-bug",
984            "should use remote branch as start point when origin/<branch> exists"
985        );
986    }
987
988    /// When `origin/<branch>` does NOT exist but `origin/<default_branch>` does,
989    /// `resolve_branch_start_point` should anchor on the remote default branch
990    /// so unpushed local commits on `main` don't leak into the worktree.
991    #[tokio::test]
992    async fn resolve_branch_start_point_prefers_origin_default_over_local() {
993        let remote = tempfile::tempdir().unwrap();
994        git(remote.path(), &["init", "--bare"]);
995
996        let local = tempfile::tempdir().unwrap();
997        git(
998            local.path(),
999            &["clone", remote.path().to_str().unwrap(), "."],
1000        );
1001        git(local.path(), &["config", "user.email", "t@t.com"]);
1002        git(local.path(), &["config", "user.name", "T"]);
1003
1004        // Commit something so origin/main exists.
1005        std::fs::write(local.path().join("README.md"), "main").unwrap();
1006        git(local.path(), &["add", "."]);
1007        git(local.path(), &["commit", "-m", "init"]);
1008        git(local.path(), &["push", "origin", "HEAD:main"]);
1009        git(local.path(), &["fetch", "--all"]);
1010
1011        let repo_root = local.path().to_str().unwrap();
1012
1013        // Branch "no-such-branch" has never been pushed, but origin/main exists.
1014        let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1015        assert_eq!(
1016            start, "origin/main",
1017            "should anchor on origin/<default_branch> instead of local default"
1018        );
1019    }
1020
1021    /// Capture stdout of a git command in `dir`.
1022    fn git_stdout(dir: &std::path::Path, args: &[&str]) -> String {
1023        let out = std::process::Command::new("git")
1024            .args(args)
1025            .current_dir(dir)
1026            .output()
1027            .unwrap_or_else(|e| panic!("git {:?} failed to start: {e}", args));
1028        if !out.status.success() {
1029            panic!(
1030                "git {:?} failed:\nstdout: {}\nstderr: {}",
1031                args,
1032                String::from_utf8_lossy(&out.stdout),
1033                String::from_utf8_lossy(&out.stderr)
1034            );
1035        }
1036        String::from_utf8_lossy(&out.stdout).trim().to_string()
1037    }
1038
1039    /// End-to-end guard for the operator-main-leak fix: when the operator's
1040    /// local `main` has commits that are not yet pushed, `setup_worktree`
1041    /// must branch from `origin/main` and produce a worktree whose HEAD is
1042    /// the pushed tip — never the local-only tip.
1043    #[tokio::test]
1044    #[serial_test::serial(orch_home)]
1045    async fn setup_worktree_anchors_on_origin_not_local_main() {
1046        // Isolate ORCH_HOME so the worktree lands in a temp dir.
1047        let orch_home_dir = tempfile::tempdir().unwrap();
1048        let prev_orch_home = std::env::var("ORCH_HOME").ok();
1049        std::env::set_var("ORCH_HOME", orch_home_dir.path());
1050
1051        // Bare "remote".
1052        let remote = tempfile::tempdir().unwrap();
1053        git(remote.path(), &["init", "--bare"]);
1054
1055        // Operator's project clone.
1056        let project = tempfile::tempdir().unwrap();
1057        git(
1058            project.path(),
1059            &["clone", remote.path().to_str().unwrap(), "."],
1060        );
1061        git(project.path(), &["config", "user.email", "t@t.com"]);
1062        git(project.path(), &["config", "user.name", "T"]);
1063
1064        // Pushed baseline on main.
1065        std::fs::write(project.path().join("README.md"), "pushed\n").unwrap();
1066        git(project.path(), &["add", "."]);
1067        git(project.path(), &["commit", "-m", "pushed baseline"]);
1068        git(project.path(), &["push", "origin", "HEAD:main"]);
1069        let pushed_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1070
1071        // Ensure origin/HEAD is set so detect_default_branch resolves "main".
1072        git(project.path(), &["remote", "set-head", "origin", "main"]);
1073
1074        // Operator's WIP: an unpushed local commit on top of main.
1075        std::fs::write(project.path().join("LOCAL_ONLY.md"), "unpushed\n").unwrap();
1076        git(project.path(), &["add", "."]);
1077        git(project.path(), &["commit", "-m", "local unpushed commit"]);
1078        let local_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1079        assert_ne!(
1080            pushed_sha, local_sha,
1081            "precondition: local main must be ahead of origin/main"
1082        );
1083
1084        // Run setup_worktree for a fresh internal task (skips GitHub linking).
1085        let setup = setup_worktree(
1086            "internal:1",
1087            "leak guard",
1088            project.path(),
1089            &None,
1090            "test/repo",
1091        )
1092        .await
1093        .expect("setup_worktree should succeed");
1094
1095        // Worktree must sit on the pushed tip, not the local-only tip.
1096        let wt_sha = git_stdout(&setup.work_dir, &["rev-parse", "HEAD"]);
1097        assert_eq!(
1098            wt_sha, pushed_sha,
1099            "worktree HEAD must equal origin/main, not local main"
1100        );
1101        assert_ne!(
1102            wt_sha, local_sha,
1103            "worktree must NOT include the operator's unpushed commit"
1104        );
1105        assert!(
1106            !setup.work_dir.join("LOCAL_ONLY.md").exists(),
1107            "unpushed-only file must not appear in the worktree"
1108        );
1109
1110        // Restore ORCH_HOME.
1111        match prev_orch_home {
1112            Some(v) => std::env::set_var("ORCH_HOME", v),
1113            None => std::env::remove_var("ORCH_HOME"),
1114        }
1115    }
1116
1117    /// When neither `origin/<branch>` nor `origin/<default_branch>` exist,
1118    /// fall back to the local default branch name as a last resort.
1119    #[tokio::test]
1120    async fn resolve_branch_start_point_falls_back_to_local_default() {
1121        let local = tempfile::tempdir().unwrap();
1122        git(local.path(), &["init"]);
1123        git(local.path(), &["config", "user.email", "t@t.com"]);
1124        git(local.path(), &["config", "user.name", "T"]);
1125        git(local.path(), &["checkout", "-b", "main"]);
1126        std::fs::write(local.path().join("README.md"), "main").unwrap();
1127        git(local.path(), &["add", "."]);
1128        git(local.path(), &["commit", "-m", "init"]);
1129
1130        let repo_root = local.path().to_str().unwrap();
1131
1132        // No remote at all → fall back to "main".
1133        let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1134        assert_eq!(
1135            start, "main",
1136            "should fall back to local default_branch when no remote refs exist"
1137        );
1138    }
1139
1140    #[test]
1141    fn branch_name_basic() {
1142        let name = branch_name("42", "Fix login bug");
1143        assert_eq!(name, "gh-issue-42-fix-login-bug");
1144    }
1145
1146    #[test]
1147    fn branch_name_internal_task() {
1148        let name = branch_name("internal:8", "Fix login bug");
1149        assert_eq!(name, "internal-8-fix-login-bug");
1150    }
1151
1152    #[test]
1153    fn branch_name_special_chars() {
1154        let name = branch_name("7", "Add OAuth2/OIDC (Google & GitHub)");
1155        assert_eq!(name, "gh-issue-7-add-oauth2-oidc-google-github");
1156    }
1157
1158    #[test]
1159    fn branch_name_truncates_long_slug() {
1160        let title =
1161            "This is a very long task title that should be truncated to forty characters maximum";
1162        let name = branch_name("1", title);
1163        // slug part should be max 40 chars
1164        let slug = name.strip_prefix("gh-issue-1-").unwrap();
1165        assert!(slug.len() <= 40, "slug length {} > 40", slug.len());
1166    }
1167
1168    #[test]
1169    fn branch_name_trims_trailing_dashes() {
1170        let name = branch_name("5", "Fix bug---");
1171        assert!(
1172            !name.ends_with('-'),
1173            "branch name should not end with dash: {name}"
1174        );
1175    }
1176
1177    #[test]
1178    fn branch_name_empty_title() {
1179        let name = branch_name("99", "");
1180        assert_eq!(name, "gh-issue-99");
1181    }
1182
1183    #[test]
1184    fn branch_name_empty_title_internal() {
1185        let name = branch_name("internal:99", "");
1186        assert_eq!(name, "internal-99");
1187    }
1188
1189    #[test]
1190    fn branch_name_all_special_chars() {
1191        let name = branch_name("10", "--- ??? ---");
1192        assert_eq!(name, "gh-issue-10");
1193        assert!(!name.is_empty());
1194    }
1195
1196    #[test]
1197    fn branch_name_chinese_chars_no_panic() {
1198        let name = branch_name("265", "implement 用户认证 user auth");
1199        assert!(name.starts_with("gh-issue-265-"));
1200        assert!(!name.is_empty());
1201        let slug = name.strip_prefix("gh-issue-265-").unwrap();
1202        assert!(slug.len() <= 40, "slug too long: {}", slug.len());
1203        assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1204    }
1205
1206    #[test]
1207    fn task_id_from_worktree_name_parses_issue_branches() {
1208        assert_eq!(
1209            task_id_from_worktree_name("gh-issue-42-fix-login"),
1210            Some("42".to_string())
1211        );
1212        assert_eq!(
1213            task_id_from_worktree_name("gh-task-42-fix-login"),
1214            Some("42".to_string())
1215        );
1216    }
1217
1218    #[test]
1219    fn task_id_from_worktree_name_parses_internal_branches() {
1220        assert_eq!(
1221            task_id_from_worktree_name("internal-8-fix-login"),
1222            Some("internal:8".to_string())
1223        );
1224        assert_eq!(
1225            task_id_from_worktree_name("gh-issue-internal-8-fix-login"),
1226            Some("internal:8".to_string())
1227        );
1228    }
1229
1230    #[test]
1231    fn task_id_from_worktree_name_returns_none_for_unknown_names() {
1232        assert!(task_id_from_worktree_name("random-worktree").is_none());
1233    }
1234
1235    #[test]
1236    fn branch_name_emoji_no_panic() {
1237        let name = branch_name("265", "fix 🚀 deployment pipeline 🔥");
1238        assert!(name.starts_with("gh-issue-265-"));
1239        let slug = name.strip_prefix("gh-issue-265-").unwrap();
1240        assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1241    }
1242
1243    #[test]
1244    fn branch_name_all_non_ascii_falls_back_to_task_id() {
1245        let name = branch_name("265", "用户认证 실행 тест");
1246        assert_eq!(name, "gh-issue-265");
1247    }
1248
1249    #[tokio::test]
1250    async fn find_existing_worktree_returns_none_for_missing() {
1251        let dir = tempfile::tempdir().unwrap();
1252        assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1253    }
1254
1255    #[tokio::test]
1256    async fn find_existing_worktree_matches_prefix() {
1257        let dir = tempfile::tempdir().unwrap();
1258        let wt_dir = dir.path().join("gh-issue-42-fix-login-bug");
1259        tokio::fs::create_dir(&wt_dir).await.unwrap();
1260
1261        let result = find_existing_worktree(dir.path(), "42").await;
1262        assert_eq!(result, Some(wt_dir));
1263    }
1264
1265    #[tokio::test]
1266    async fn find_existing_worktree_matches_internal_prefix() {
1267        let dir = tempfile::tempdir().unwrap();
1268        let wt_dir = dir.path().join("internal-8-fix");
1269        tokio::fs::create_dir(&wt_dir).await.unwrap();
1270
1271        let result = find_existing_worktree(dir.path(), "internal:8").await;
1272        assert_eq!(result, Some(wt_dir));
1273    }
1274
1275    #[tokio::test]
1276    async fn find_existing_worktree_matches_legacy_gh_task_prefix() {
1277        // Old worktrees created before the rename should still be found
1278        let dir = tempfile::tempdir().unwrap();
1279        let wt_dir = dir.path().join("gh-task-internal-8-fix");
1280        tokio::fs::create_dir(&wt_dir).await.unwrap();
1281
1282        let result = find_existing_worktree(dir.path(), "internal:8").await;
1283        assert_eq!(result, Some(wt_dir));
1284    }
1285
1286    #[tokio::test]
1287    async fn find_existing_worktree_matches_legacy_external_prefix() {
1288        // Old external worktrees with gh-task- prefix should still be found
1289        let dir = tempfile::tempdir().unwrap();
1290        let wt_dir = dir.path().join("gh-task-42-fix-login-bug");
1291        tokio::fs::create_dir(&wt_dir).await.unwrap();
1292
1293        let result = find_existing_worktree(dir.path(), "42").await;
1294        assert_eq!(result, Some(wt_dir));
1295    }
1296
1297    #[tokio::test]
1298    async fn find_existing_worktree_ignores_other_tasks() {
1299        let dir = tempfile::tempdir().unwrap();
1300        tokio::fs::create_dir(dir.path().join("gh-issue-43-other-task"))
1301            .await
1302            .unwrap();
1303
1304        assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1305    }
1306
1307    // --- resolve_parent_branch tests ---
1308
1309    /// No store → always returns None.
1310    #[tokio::test]
1311    async fn resolve_parent_branch_no_store_returns_none() {
1312        let dir = tempfile::tempdir().unwrap();
1313        let result = resolve_parent_branch(&None, Some(1), dir.path()).await;
1314        assert!(result.is_none());
1315    }
1316
1317    /// parent_id = None → always returns None regardless of store.
1318    #[tokio::test]
1319    async fn resolve_parent_branch_no_parent_id_returns_none() {
1320        let dir = tempfile::tempdir().unwrap();
1321        let result = resolve_parent_branch(&None, None, dir.path()).await;
1322        assert!(result.is_none());
1323    }
1324
1325    /// Parent exists but has empty branch → returns None so child gets its own branch.
1326    #[tokio::test]
1327    async fn resolve_parent_branch_empty_parent_branch_returns_none() {
1328        let db_dir = tempfile::tempdir().unwrap();
1329        let db_path = db_dir.path().join("test.db");
1330        let store = Arc::new(
1331            crate::store::TaskStore::open_single(&db_path)
1332                .await
1333                .unwrap(),
1334        );
1335
1336        // Create parent task — branch defaults to empty string
1337        let parent_id = store
1338            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1339            .await
1340            .unwrap();
1341
1342        let dir = tempfile::tempdir().unwrap();
1343        let store_opt = Some(store);
1344        let result = resolve_parent_branch(&store_opt, Some(parent_id), dir.path()).await;
1345        assert!(result.is_none());
1346    }
1347
1348    /// Parent has a branch set → child inherits that branch and worktree path.
1349    #[tokio::test]
1350    async fn resolve_parent_branch_returns_parent_branch_and_worktree() {
1351        let db_dir = tempfile::tempdir().unwrap();
1352        let db_path = db_dir.path().join("test.db");
1353        let store = Arc::new(
1354            crate::store::TaskStore::open_single(&db_path)
1355                .await
1356                .unwrap(),
1357        );
1358
1359        // Create parent task and set its branch
1360        let parent_id = store
1361            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1362            .await
1363            .unwrap();
1364        store
1365            .set_fields(
1366                parent_id,
1367                &[("branch", serde_json::json!("gh-issue-99-parent-task"))],
1368            )
1369            .await
1370            .unwrap();
1371
1372        let worktrees_base = tempfile::tempdir().unwrap();
1373        let store_opt = Some(store);
1374        let result =
1375            resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1376
1377        let (branch, wt) = result.expect("should return parent branch");
1378        assert_eq!(branch, "gh-issue-99-parent-task");
1379        assert_eq!(wt, worktrees_base.path().join("gh-issue-99-parent-task"));
1380    }
1381
1382    /// When parent has both branch and a worktree path that exists, use the worktree path.
1383    #[tokio::test]
1384    async fn resolve_parent_branch_prefers_existing_worktree_path() {
1385        let db_dir = tempfile::tempdir().unwrap();
1386        let db_path = db_dir.path().join("test.db");
1387        let store = Arc::new(
1388            crate::store::TaskStore::open_single(&db_path)
1389                .await
1390                .unwrap(),
1391        );
1392
1393        let worktrees_base = tempfile::tempdir().unwrap();
1394        // Create the parent worktree directory
1395        let parent_wt_dir = worktrees_base.path().join("custom-worktree-path");
1396        tokio::fs::create_dir_all(&parent_wt_dir).await.unwrap();
1397
1398        let parent_id = store
1399            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1400            .await
1401            .unwrap();
1402        store
1403            .set_fields(
1404                parent_id,
1405                &[
1406                    ("branch", serde_json::json!("gh-issue-99-parent-task")),
1407                    (
1408                        "worktree",
1409                        serde_json::json!(parent_wt_dir.to_string_lossy().as_ref()),
1410                    ),
1411                ],
1412            )
1413            .await
1414            .unwrap();
1415
1416        let store_opt = Some(store);
1417        let result =
1418            resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1419
1420        let (branch, wt) = result.expect("should return parent branch");
1421        assert_eq!(branch, "gh-issue-99-parent-task");
1422        assert_eq!(wt, parent_wt_dir);
1423    }
1424
1425    /// Regression test: concurrent calls to list_project_worktrees and
1426    /// validate_worktree_gitdir should not starve the Tokio worker thread
1427    /// pool. This was broken by blocking Path::exists/is_dir calls in async
1428    /// functions (issue #2467).
1429    #[tokio::test]
1430    async fn concurrent_worktree_checks_no_starvation() {
1431        let temp_dir = tempfile::tempdir().unwrap();
1432        let worktrees_base = temp_dir.path().join("worktrees");
1433        tokio::fs::create_dir_all(&worktrees_base).await.unwrap();
1434
1435        // Create a fake worktree with valid gitdir
1436        let wt_dir = worktrees_base.join("gh-issue-123-test");
1437        tokio::fs::create_dir_all(&wt_dir).await.unwrap();
1438        // Create .git as a file pointing to a real directory (worktree link)
1439        let gitdir_target = temp_dir
1440            .path()
1441            .join(".git")
1442            .join("worktrees")
1443            .join("gh-issue-123-test");
1444        tokio::fs::create_dir_all(&gitdir_target).await.unwrap();
1445        let git_file = wt_dir.join(".git");
1446        tokio::fs::write(&git_file, format!("gitdir: {}\n", gitdir_target.display()))
1447            .await
1448            .unwrap();
1449
1450        // Spawn many concurrent tasks - if any use blocking calls, they'll
1451        // block the worker thread and cause timeout or deadlock.
1452        let handles: Vec<_> = (0..20)
1453            .map(|i| {
1454                let wb = worktrees_base.clone();
1455                let wd = wt_dir.clone();
1456                tokio::spawn(async move {
1457                    if i % 2 == 0 {
1458                        list_project_worktrees(&wb).await.ok();
1459                    } else {
1460                        validate_worktree_gitdir(&wd).await;
1461                    }
1462                })
1463            })
1464            .collect();
1465
1466        // Wait with timeout to detect starvation
1467        let results = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1468            futures::future::join_all(handles).await
1469        })
1470        .await;
1471
1472        assert!(
1473            results.is_ok(),
1474            "concurrent worktree checks timed out (starvation?)"
1475        );
1476    }
1477
1478    // --- normalize_branch_name tests ---
1479
1480    #[test]
1481    fn normalize_branch_name_strips_origin_prefix() {
1482        assert_eq!(
1483            normalize_branch_name("origin/main"),
1484            Some("main".to_string())
1485        );
1486        assert_eq!(
1487            normalize_branch_name("origin/develop"),
1488            Some("develop".to_string())
1489        );
1490    }
1491
1492    #[test]
1493    fn normalize_branch_name_strips_full_ref_path() {
1494        assert_eq!(
1495            normalize_branch_name("refs/remotes/origin/main"),
1496            Some("main".to_string())
1497        );
1498        assert_eq!(
1499            normalize_branch_name("refs/remotes/origin/develop"),
1500            Some("develop".to_string())
1501        );
1502    }
1503
1504    #[test]
1505    fn normalize_branch_name_strips_remotes_prefix() {
1506        assert_eq!(
1507            normalize_branch_name("remotes/origin/main"),
1508            Some("main".to_string())
1509        );
1510    }
1511
1512    #[test]
1513    fn normalize_branch_name_passes_through_plain_branch() {
1514        assert_eq!(normalize_branch_name("main"), Some("main".to_string()));
1515        assert_eq!(normalize_branch_name("master"), Some("master".to_string()));
1516        assert_eq!(
1517            normalize_branch_name("feature/foo"),
1518            Some("feature/foo".to_string())
1519        );
1520    }
1521
1522    #[test]
1523    fn normalize_branch_name_rejects_empty_and_refs_only() {
1524        assert_eq!(normalize_branch_name(""), None);
1525        assert_eq!(normalize_branch_name("   "), None);
1526        assert_eq!(
1527            normalize_branch_name("refs/heads/main"),
1528            Some("main".to_string())
1529        );
1530    }
1531}