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