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.
14#[derive(Debug)]
15pub struct WorktreeSetup {
16    /// The directory where the agent will work
17    pub work_dir: PathBuf,
18    /// The branch name
19    pub branch: String,
20    /// The default branch of the repository
21    pub default_branch: String,
22    /// The main project directory (original, not worktree)
23    pub main_project_dir: PathBuf,
24}
25
26/// Canonical project name used under `~/.orch/worktrees/`.
27pub fn project_name(project_dir: &Path) -> String {
28    project_dir
29        .file_name()
30        .map_or_else(
31            || "project".to_string(),
32            |s| s.to_string_lossy().to_string(),
33        )
34        .trim_end_matches(".git")
35        .to_string()
36}
37
38/// Base worktrees directory for a project.
39pub fn project_worktrees_dir(project_dir: &Path) -> PathBuf {
40    crate::home::worktrees_dir()
41        .unwrap_or_default()
42        .join(project_name(project_dir))
43}
44
45/// List all worktree directories for a project.
46pub async fn list_project_worktrees(project_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
47    let base = project_worktrees_dir(project_dir);
48    let mut worktrees = Vec::new();
49
50    if tokio::fs::metadata(&base).await.is_err() {
51        return Ok(worktrees);
52    }
53
54    let mut entries = tokio::fs::read_dir(&base).await?;
55    while let Some(entry) = entries.next_entry().await? {
56        if entry.metadata().await.is_ok_and(|m| m.is_dir()) {
57            worktrees.push(entry.path());
58        }
59    }
60
61    Ok(worktrees)
62}
63
64/// Extract a task ID from a worktree directory name.
65pub fn task_id_from_worktree_name(name: &str) -> Option<String> {
66    if let Some(rest) = name.strip_prefix("internal-") {
67        let num = rest.split('-').next()?;
68        return num.parse::<u64>().ok().map(|n| format!("internal:{n}"));
69    }
70
71    for prefix in ["gh-issue-", "gh-task-"] {
72        if let Some(rest) = name.strip_prefix(prefix) {
73            let task_part = rest.split('-').next()?;
74            if task_part == "internal" {
75                let num = rest.split('-').nth(1)?;
76                return num.parse::<u64>().ok().map(|n| format!("internal:{n}"));
77            }
78            return Some(task_part.to_string());
79        }
80    }
81
82    None
83}
84
85/// Abort any rebase in progress for a worktree.
86pub async fn abort_worktree_rebase(worktree_dir: &Path) {
87    let _ = Command::new("git")
88        .args(["rebase", "--abort"])
89        .current_dir(worktree_dir)
90        .output_with_context()
91        .await;
92}
93
94/// Rebase a worktree on top of `origin/{default_branch}`.
95///
96/// Stashes any uncommitted changes before rebasing to avoid failing the
97/// rebase due to "You have unstaged changes".  The stash is restored after
98/// the rebase succeeds; on failure the stash is preserved for manual recovery.
99pub async fn rebase_worktree_on_origin_main(
100    worktree_dir: &Path,
101    default_branch: &str,
102) -> anyhow::Result<()> {
103    let origin_branch = format!("origin/{default_branch}");
104
105    match crate::engine::runner::git_ops::stash_rebase_restore(
106        worktree_dir,
107        &origin_branch,
108        crate::engine::runner::git_ops::StashRebaseOpts::default(),
109    )
110    .await?
111    {
112        crate::engine::runner::git_ops::RebaseOutcome::Succeeded
113        | crate::engine::runner::git_ops::RebaseOutcome::Skipped(_) => Ok(()),
114        crate::engine::runner::git_ops::RebaseOutcome::Failed(e) => {
115            anyhow::bail!("git rebase {origin_branch} failed: {e}")
116        }
117    }
118}
119
120/// Generate a branch name from task ID and title.
121///
122/// Format: `internal-{id}-{slug}` for internal tasks, `gh-issue-{id}-{slug}` for external.
123/// Slug is lowercase, non-alphanum→`-`, max 40 chars.
124pub fn branch_name(task_id: &str, title: &str) -> String {
125    let (prefix, sanitized_id) = if let Some(num) = task_id.strip_prefix("internal:") {
126        ("internal", num.to_string())
127    } else {
128        ("gh-issue", sanitize_task_id(task_id))
129    };
130
131    let raw: String = title
132        .to_lowercase()
133        .chars()
134        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
135        .collect();
136
137    // Collapse consecutive dashes and trim
138    let mut slug = String::with_capacity(raw.len());
139    let mut prev_dash = false;
140    for c in raw.chars() {
141        if c == '-' {
142            if !prev_dash {
143                slug.push('-');
144            }
145            prev_dash = true;
146        } else {
147            slug.push(c);
148            prev_dash = false;
149        }
150    }
151    let slug = slug.trim_matches('-').to_string();
152
153    // Truncate slug to 40 chars
154    let slug = if slug.len() > 40 { &slug[..40] } else { &slug };
155    let slug = slug.trim_end_matches('-');
156
157    if slug.is_empty() {
158        format!("{prefix}-{sanitized_id}")
159    } else {
160        format!("{prefix}-{sanitized_id}-{slug}")
161    }
162}
163
164/// Replace characters in `task_id` that are not safe for branch/worktree names.
165fn sanitize_task_id(task_id: &str) -> String {
166    task_id.replace(':', "-")
167}
168
169/// Normalize a git ref output to a plain branch name.
170///
171/// Handles the various output formats that different git versions and
172/// configurations can produce for remote HEAD refs:
173/// - `origin/main` → `main`
174/// - `refs/remotes/origin/main` → `main`
175/// - `remotes/origin/main` → `main`
176/// - `main` → `main`
177fn normalize_branch_name(raw: &str) -> Option<String> {
178    let branch = raw.trim();
179    if branch.is_empty() {
180        return None;
181    }
182
183    // Strip known prefixes in order of specificity.
184    let cleaned = branch
185        .strip_prefix("refs/remotes/origin/")
186        .or_else(|| branch.strip_prefix("remotes/origin/"))
187        .or_else(|| branch.strip_prefix("refs/heads/"))
188        .or_else(|| branch.strip_prefix("origin/"))
189        .unwrap_or(branch);
190
191    if cleaned.is_empty() {
192        return None;
193    }
194    Some(cleaned.to_string())
195}
196
197/// Detect the default branch of a repository.
198///
199/// Tries multiple strategies and normalizes the result so callers always
200/// receive a clean branch name like `"main"`, never a raw ref path.
201pub async fn detect_default_branch(project_dir: &Path) -> String {
202    // Strategy 1: symbolic-ref (fast, works when origin/HEAD is set).
203    let symref = Command::new("git")
204        .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
205        .current_dir(project_dir)
206        .output_with_context()
207        .await;
208
209    if let Ok(o) = &symref
210        && o.status.success()
211    {
212        let raw = String::from_utf8_lossy(&o.stdout);
213        if let Some(branch) = normalize_branch_name(&raw) {
214            return branch;
215        }
216    }
217
218    // Strategy 2: rev-parse --abbrev-ref (resolves symref to target).
219    let revparse = Command::new("git")
220        .args(["rev-parse", "--abbrev-ref", "refs/remotes/origin/HEAD"])
221        .current_dir(project_dir)
222        .output_with_context()
223        .await;
224
225    if let Ok(o) = &revparse
226        && o.status.success()
227    {
228        let raw = String::from_utf8_lossy(&o.stdout);
229        if let Some(branch) = normalize_branch_name(&raw) {
230            return branch;
231        }
232    }
233
234    // Strategy 3: parse `git remote show origin` output.
235    let remote_show = Command::new("git")
236        .args(["remote", "show", "origin"])
237        .current_dir(project_dir)
238        .output_with_context()
239        .await;
240
241    if let Ok(o) = &remote_show
242        && o.status.success()
243    {
244        let stdout = String::from_utf8_lossy(&o.stdout);
245        for line in stdout.lines() {
246            let trimmed = line.trim();
247            if trimmed.starts_with("HEAD branch:")
248                && let Some(branch) = trimmed.split(':').nth(1)
249            {
250                let branch = branch.trim();
251                if !branch.is_empty() && branch != "(unknown)" {
252                    return branch.to_string();
253                }
254            }
255        }
256    }
257
258    tracing::warn!(
259        project_dir = %project_dir.display(),
260        "could not detect default branch from git, falling back to 'main'"
261    );
262    "main".to_string()
263}
264
265/// Resolve PROJECT_DIR to the main repo if it's inside a worktree.
266///
267/// This prevents nested worktrees when subtasks inherit a parent's worktree dir.
268pub async fn resolve_main_repo(project_dir: &Path) -> PathBuf {
269    let output = Command::new("git")
270        .args(["worktree", "list", "--porcelain"])
271        .current_dir(project_dir)
272        .output_with_context()
273        .await;
274
275    if let Ok(o) = output
276        && o.status.success()
277    {
278        let stdout = String::from_utf8_lossy(&o.stdout);
279        if let Some(first_line) = stdout.lines().next()
280            && let Some(path) = first_line.strip_prefix("worktree ")
281        {
282            let main_path = PathBuf::from(path);
283            if main_path != project_dir {
284                tracing::info!(
285                    worktree = %project_dir.display(),
286                    main = %main_path.display(),
287                    "resolved worktree to main repo"
288                );
289                return main_path;
290            }
291        }
292    }
293
294    project_dir.to_path_buf()
295}
296
297/// Resolve the starting point for creating a new local branch.
298///
299/// Preference order:
300/// 1. `origin/<branch>` if it exists — cleanup kept the remote branch after
301///    deleting the local one, so resume from the agent's committed work.
302/// 2. `origin/<default_branch>` if it exists — anchors on the pushed tip of
303///    the default branch so unpushed local commits in the operator's main
304///    checkout don't leak into the worktree.
305/// 3. `default_branch` as a last resort (no origin remote / never fetched).
306pub(super) async fn resolve_branch_start_point(
307    repo_root: &str,
308    branch: &str,
309    default_branch: &str,
310) -> String {
311    let verify = |reference: String| async move {
312        Command::new("git")
313            .args(["-C", repo_root, "rev-parse", "--verify", &reference])
314            .output_with_context()
315            .await
316            .is_ok_and(|o| o.status.success())
317    };
318
319    let origin_branch_ref = format!("origin/{branch}");
320    if verify(origin_branch_ref.clone()).await {
321        return origin_branch_ref;
322    }
323
324    let origin_default_ref = format!("origin/{default_branch}");
325    if verify(origin_default_ref.clone()).await {
326        return origin_default_ref;
327    }
328
329    default_branch.to_string()
330}
331
332/// Validate that a worktree directory has a working gitdir link.
333///
334/// In a git worktree, `.git` is a file containing `gitdir: /path/to/.git/worktrees/<name>`.
335/// Returns `false` when the `.git` file is missing or points to a non-existent gitdir,
336/// indicating the worktree metadata is broken and the directory should be cleaned up.
337pub async fn validate_worktree_gitdir(worktree_dir: &Path) -> bool {
338    let git_file = worktree_dir.join(".git");
339    // Use tokio::fs::metadata for async-safe existence check
340    if tokio::fs::metadata(&git_file).await.is_err() {
341        return false;
342    }
343    // In a linked worktree .git is a file; in the main repo it's a directory (always valid).
344    if tokio::fs::metadata(&git_file)
345        .await
346        .is_ok_and(|m| m.is_dir())
347    {
348        return true;
349    }
350
351    let Ok(content) = tokio::fs::read_to_string(&git_file).await else {
352        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            .is_ok_and(|m| m.is_dir());
365    }
366    false
367}
368
369/// Resolve the branch and worktree directory from a parent task, if available.
370///
371/// When a child task is created from a mention on a PR, it should work on the
372/// parent task's branch so changes are committed to the existing PR rather than
373/// a new one being opened.
374#[expect(
375    clippy::ref_option,
376    reason = "the store helper it calls takes the store as &Option"
377)]
378async fn resolve_parent_branch(
379    store: &Option<Arc<TaskStore>>,
380    parent_id: Option<i64>,
381    worktrees_base: &Path,
382) -> Option<(String, PathBuf)> {
383    let parent_id = parent_id?;
384    let parent = store::opt_store_get_task_by_id(store, parent_id).await?;
385    if parent.branch.is_empty() {
386        return None;
387    }
388    let wt = if parent.worktree.is_empty() {
389        worktrees_base.join(&parent.branch)
390    } else {
391        // Use async metadata to avoid blocking the reactor thread.
392        let exists = tokio::fs::metadata(&parent.worktree)
393            .await
394            .is_ok_and(|m| m.is_dir());
395        if exists {
396            PathBuf::from(&parent.worktree)
397        } else {
398            worktrees_base.join(&parent.branch)
399        }
400    };
401    tracing::info!(
402        parent_id,
403        branch = %parent.branch,
404        worktree = %wt.display(),
405        "child task inheriting parent branch"
406    );
407    Some((parent.branch, wt))
408}
409
410/// Set up a worktree for task execution.
411///
412/// Returns the working directory, branch name, and default branch.
413pub async fn setup_worktree(
414    task_id: &str,
415    title: &str,
416    project_dir: &Path,
417    store: &Option<Arc<TaskStore>>,
418    repo: &str,
419) -> anyhow::Result<WorktreeSetup> {
420    // Resolve to main repo (avoid nested worktrees)
421    let main_dir = resolve_main_repo(project_dir).await;
422    let default_branch = detect_default_branch(&main_dir).await;
423
424    let worktrees_base = project_worktrees_dir(&main_dir);
425    tokio::fs::create_dir_all(&worktrees_base).await?;
426
427    // Check if we have a saved branch/worktree in store
428    let task_record = store::opt_store_get_task(store, repo, task_id).await;
429    let (saved_branch, saved_worktree, parent_id) =
430        task_record.as_ref().map_or((None, None, None), |t| {
431            (
432                Some(t.branch.clone()),
433                Some(t.worktree.clone()),
434                t.parent_id,
435            )
436        });
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_or_else(
504                        || branch_name(task_id, title),
505                        |n| n.to_string_lossy().to_string(),
506                    );
507                tracing::info!(task_id, worktree = %existing_dir.display(), "found existing worktree");
508                (bn, existing_dir)
509            } else {
510                let bn = branch_name(task_id, title);
511                (bn.clone(), worktrees_base.join(&bn))
512            }
513        }
514    };
515
516    if branch_name_str.is_empty() {
517        anyhow::bail!("empty branch name for task {task_id}");
518    }
519
520    // Abort any in-progress rebase in an existing worktree.
521    //
522    // Agents can leave a worktree stuck mid-rebase (e.g. after running
523    // `git rebase -i` interactively).  The next dispatch attempt would
524    // inherit that broken state, causing all subsequent git operations to
525    // fail.  Aborting here is safe: if no rebase is in progress git exits
526    // with a non-zero code which we silently ignore.
527    // Use tokio::fs for async-safe existence check
528    if tokio::fs::metadata(&worktree_dir).await.is_ok() {
529        let _ = Command::new("git")
530            .args(["rebase", "--abort"])
531            .current_dir(&worktree_dir)
532            .output_with_context()
533            .await;
534
535        // Check for a corrupted git index (e.g. "error: could not write index").
536        // `git status` reads the index; a non-zero exit with index-related stderr
537        // indicates corruption that will cause every subsequent git operation to
538        // fail, leading to an infinite stuck-recovery loop.  When detected, nuke
539        // the worktree and recreate it from scratch below.
540        let index_ok = Command::new("git")
541            .args(["status", "--porcelain"])
542            .current_dir(&worktree_dir)
543            .output_with_context()
544            .await
545            .is_ok_and(|o| o.status.success());
546
547        if !index_ok {
548            let stderr_hint = Command::new("git")
549                .args(["status", "--porcelain"])
550                .current_dir(&worktree_dir)
551                .output_with_context()
552                .await
553                .map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
554                .unwrap_or_default();
555            tracing::warn!(
556                task_id,
557                worktree = %worktree_dir.display(),
558                stderr = %stderr_hint,
559                "corrupted worktree index detected — removing and recreating worktree"
560            );
561            // Remove git worktree reference first, then delete the directory.
562            let _ = Command::new("git")
563                .args([
564                    "-C",
565                    &main_dir.to_string_lossy(),
566                    "worktree",
567                    "remove",
568                    "--force",
569                    &worktree_dir.to_string_lossy(),
570                ])
571                .output_with_context()
572                .await;
573            // If the directory still exists (e.g. worktree remove failed), remove it
574            // manually so the worktree-creation block below re-creates it cleanly.
575            if tokio::fs::metadata(&worktree_dir).await.is_ok() {
576                let _ = tokio::fs::remove_dir_all(&worktree_dir).await;
577            }
578            // Prune stale worktree metadata so git won't complain on re-add.
579            let _ = Command::new("git")
580                .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
581                .output_with_context()
582                .await;
583        }
584    }
585
586    // Create worktree if it doesn't exist
587    if tokio::fs::metadata(&worktree_dir).await.is_err() {
588        tracing::info!(task_id, worktree = %worktree_dir.display(), "creating worktree");
589
590        // Fetch latest so `origin/<default_branch>` (and any pre-existing
591        // remote task branch) is fresh. We deliberately do NOT `git pull` on
592        // the operator's checkout: branching from `origin/<default_branch>`
593        // means unpushed local commits or in-progress work on `main` never
594        // leak into the new worktree.
595        let _ = crate::engine::runner::git_ops::git()
596            .await
597            .args([
598                "-C",
599                &main_dir.to_string_lossy(),
600                "fetch",
601                "--prune",
602                "origin",
603            ])
604            .output_with_context()
605            .await;
606
607        // Create local branch. Prefer `origin/<branch>` when it already exists
608        // (recovery case: cleanup deleted local branch but kept the remote),
609        // so the new worktree starts from the agent's committed work instead
610        // of the default branch tip.
611        let start_point = resolve_branch_start_point(
612            &main_dir.to_string_lossy(),
613            &branch_name_str,
614            &default_branch,
615        )
616        .await;
617        let _ = Command::new("git")
618            .args([
619                "-C",
620                &main_dir.to_string_lossy(),
621                "branch",
622                &branch_name_str,
623                &start_point,
624            ])
625            .output_with_context()
626            .await;
627
628        // Create worktree
629        if let Some(parent) = worktree_dir.parent() {
630            tokio::fs::create_dir_all(parent).await?;
631        }
632
633        let cloned = match super::cow::create_worktree(
634            &main_dir,
635            &project_name(&main_dir),
636            &worktree_dir,
637            &branch_name_str,
638            &default_branch,
639        )
640        .await
641        {
642            Ok(()) => {
643                tracing::info!(task_id, "worktree cloned from project template");
644                true
645            }
646            Err(e) => {
647                tracing::debug!(task_id, error = %e, "copy-on-write worktree unavailable, using git worktree add");
648                false
649            }
650        };
651
652        if !cloned {
653            let output = Command::new("git")
654                .args([
655                    "-C",
656                    &main_dir.to_string_lossy(),
657                    "worktree",
658                    "add",
659                    &worktree_dir.to_string_lossy(),
660                    &branch_name_str,
661                ])
662                .output_with_context()
663                .await?;
664
665            if !output.status.success() && tokio::fs::metadata(&worktree_dir).await.is_err() {
666                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
667                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
668                // Retry: prune and recreate
669                tracing::warn!(
670                    task_id,
671                    stdout = %stdout,
672                    stderr = %stderr,
673                    "worktree creation failed, retrying after prune"
674                );
675
676                let _ = Command::new("git")
677                    .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
678                    .output_with_context()
679                    .await;
680
681                let _ = Command::new("git")
682                    .args([
683                        "-C",
684                        &main_dir.to_string_lossy(),
685                        "branch",
686                        "-D",
687                        &branch_name_str,
688                    ])
689                    .output_with_context()
690                    .await;
691
692                let _ = Command::new("git")
693                    .args([
694                        "-C",
695                        &main_dir.to_string_lossy(),
696                        "branch",
697                        &branch_name_str,
698                        &start_point,
699                    ])
700                    .output_with_context()
701                    .await;
702
703                let retry_output = Command::new("git")
704                    .args([
705                        "-C",
706                        &main_dir.to_string_lossy(),
707                        "worktree",
708                        "add",
709                        &worktree_dir.to_string_lossy(),
710                        &branch_name_str,
711                    ])
712                    .output_with_context()
713                    .await;
714
715                let mut retry_stdout = String::new();
716                let mut retry_stderr = String::new();
717                let mut retry_error = String::new();
718                match retry_output {
719                    Ok(output) => {
720                        retry_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
721                        retry_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
722                        tracing::warn!(
723                            task_id,
724                            stdout = %retry_stdout,
725                            stderr = %retry_stderr,
726                            "worktree creation retry failed"
727                        );
728                    }
729                    Err(err) => {
730                        retry_error = err.to_string();
731                        tracing::warn!(
732                            task_id,
733                            error = %retry_error,
734                            "worktree creation retry failed to run"
735                        );
736                    }
737                }
738
739                if tokio::fs::metadata(&worktree_dir).await.is_err() {
740                    anyhow::bail!(
741                        "failed to create worktree at {} for task {} (stdout: {}, stderr: {}, retry stdout: {}, retry stderr: {}, retry error: {})",
742                        worktree_dir.display(),
743                        task_id,
744                        stdout,
745                        stderr,
746                        retry_stdout,
747                        retry_stderr,
748                        retry_error
749                    );
750                }
751            }
752        }
753    }
754
755    // Link issue to branch via GitHub API (Development sidebar).
756    // Must happen before the agent pushes — createLinkedBranch only works
757    // for branches that don't yet exist on GitHub.
758    if !task_id.starts_with("internal:")
759        && let Ok(issue_num) = task_id.parse::<u64>()
760        && let Ok(gh) = crate::github::http::GhHttp::new()
761    {
762        match gh
763            .link_issue_to_branch(repo, issue_num, &branch_name_str)
764            .await
765        {
766            Ok(id) => {
767                tracing::info!(task_id, branch = %branch_name_str, linked = %id, "linked issue to branch");
768            }
769            Err(e) => {
770                tracing::debug!(task_id, error = %e, "failed to link issue to branch (non-fatal)");
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 {args:?} failed to start: {e}"));
906        assert!(
907            out.status.success(),
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    /// 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 {args:?} failed to start: {e}"));
1027        assert!(
1028            out.status.success(),
1029            "git {:?} failed:\nstdout: {}\nstderr: {}",
1030            args,
1031            String::from_utf8_lossy(&out.stdout),
1032            String::from_utf8_lossy(&out.stderr)
1033        );
1034        String::from_utf8_lossy(&out.stdout).trim().to_string()
1035    }
1036
1037    /// End-to-end guard for the operator-main-leak fix: when the operator's
1038    /// local `main` has commits that are not yet pushed, `setup_worktree`
1039    /// must branch from `origin/main` and produce a worktree whose HEAD is
1040    /// the pushed tip — never the local-only tip.
1041    #[tokio::test]
1042    #[serial_test::serial(orch_home)]
1043    async fn setup_worktree_anchors_on_origin_not_local_main() {
1044        // Isolate ORCH_HOME so the worktree lands in a temp dir.
1045        let orch_home_dir = tempfile::tempdir().unwrap();
1046        let prev_orch_home = std::env::var("ORCH_HOME").ok();
1047        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1048        unsafe { 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            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1112            Some(v) => unsafe { std::env::set_var("ORCH_HOME", v) },
1113            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1114            None => unsafe { std::env::remove_var("ORCH_HOME") },
1115        }
1116    }
1117
1118    /// When neither `origin/<branch>` nor `origin/<default_branch>` exist,
1119    /// fall back to the local default branch name as a last resort.
1120    #[tokio::test]
1121    async fn resolve_branch_start_point_falls_back_to_local_default() {
1122        let local = tempfile::tempdir().unwrap();
1123        git(local.path(), &["init"]);
1124        git(local.path(), &["config", "user.email", "t@t.com"]);
1125        git(local.path(), &["config", "user.name", "T"]);
1126        git(local.path(), &["checkout", "-b", "main"]);
1127        std::fs::write(local.path().join("README.md"), "main").unwrap();
1128        git(local.path(), &["add", "."]);
1129        git(local.path(), &["commit", "-m", "init"]);
1130
1131        let repo_root = local.path().to_str().unwrap();
1132
1133        // No remote at all → fall back to "main".
1134        let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1135        assert_eq!(
1136            start, "main",
1137            "should fall back to local default_branch when no remote refs exist"
1138        );
1139    }
1140
1141    #[test]
1142    fn branch_name_basic() {
1143        let name = branch_name("42", "Fix login bug");
1144        assert_eq!(name, "gh-issue-42-fix-login-bug");
1145    }
1146
1147    #[test]
1148    fn branch_name_internal_task() {
1149        let name = branch_name("internal:8", "Fix login bug");
1150        assert_eq!(name, "internal-8-fix-login-bug");
1151    }
1152
1153    #[test]
1154    fn branch_name_special_chars() {
1155        let name = branch_name("7", "Add OAuth2/OIDC (Google & GitHub)");
1156        assert_eq!(name, "gh-issue-7-add-oauth2-oidc-google-github");
1157    }
1158
1159    #[test]
1160    fn branch_name_truncates_long_slug() {
1161        let title =
1162            "This is a very long task title that should be truncated to forty characters maximum";
1163        let name = branch_name("1", title);
1164        // slug part should be max 40 chars
1165        let slug = name.strip_prefix("gh-issue-1-").unwrap();
1166        assert!(slug.len() <= 40, "slug length {} > 40", slug.len());
1167    }
1168
1169    #[test]
1170    fn branch_name_trims_trailing_dashes() {
1171        let name = branch_name("5", "Fix bug---");
1172        assert!(
1173            !name.ends_with('-'),
1174            "branch name should not end with dash: {name}"
1175        );
1176    }
1177
1178    #[test]
1179    fn branch_name_empty_title() {
1180        let name = branch_name("99", "");
1181        assert_eq!(name, "gh-issue-99");
1182    }
1183
1184    #[test]
1185    fn branch_name_empty_title_internal() {
1186        let name = branch_name("internal:99", "");
1187        assert_eq!(name, "internal-99");
1188    }
1189
1190    #[test]
1191    fn branch_name_all_special_chars() {
1192        let name = branch_name("10", "--- ??? ---");
1193        assert_eq!(name, "gh-issue-10");
1194        assert_ne!(name, "");
1195    }
1196
1197    #[test]
1198    fn branch_name_chinese_chars_no_panic() {
1199        let name = branch_name("265", "implement 用户认证 user auth");
1200        assert!(name.starts_with("gh-issue-265-"));
1201        assert_ne!(name, "");
1202        let slug = name.strip_prefix("gh-issue-265-").unwrap();
1203        assert!(slug.len() <= 40, "slug too long: {}", slug.len());
1204        assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1205    }
1206
1207    #[test]
1208    fn task_id_from_worktree_name_parses_issue_branches() {
1209        assert_eq!(
1210            task_id_from_worktree_name("gh-issue-42-fix-login"),
1211            Some("42".to_string())
1212        );
1213        assert_eq!(
1214            task_id_from_worktree_name("gh-task-42-fix-login"),
1215            Some("42".to_string())
1216        );
1217    }
1218
1219    #[test]
1220    fn task_id_from_worktree_name_parses_internal_branches() {
1221        assert_eq!(
1222            task_id_from_worktree_name("internal-8-fix-login"),
1223            Some("internal:8".to_string())
1224        );
1225        assert_eq!(
1226            task_id_from_worktree_name("gh-issue-internal-8-fix-login"),
1227            Some("internal:8".to_string())
1228        );
1229    }
1230
1231    #[test]
1232    fn task_id_from_worktree_name_returns_none_for_unknown_names() {
1233        assert!(task_id_from_worktree_name("random-worktree").is_none());
1234    }
1235
1236    #[test]
1237    fn branch_name_emoji_no_panic() {
1238        let name = branch_name("265", "fix 🚀 deployment pipeline 🔥");
1239        assert!(name.starts_with("gh-issue-265-"));
1240        let slug = name.strip_prefix("gh-issue-265-").unwrap();
1241        assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1242    }
1243
1244    #[test]
1245    fn branch_name_all_non_ascii_falls_back_to_task_id() {
1246        let name = branch_name("265", "用户认证 실행 тест");
1247        assert_eq!(name, "gh-issue-265");
1248    }
1249
1250    #[tokio::test]
1251    async fn find_existing_worktree_returns_none_for_missing() {
1252        let dir = tempfile::tempdir().unwrap();
1253        assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1254    }
1255
1256    #[tokio::test]
1257    async fn find_existing_worktree_matches_prefix() {
1258        let dir = tempfile::tempdir().unwrap();
1259        let wt_dir = dir.path().join("gh-issue-42-fix-login-bug");
1260        tokio::fs::create_dir(&wt_dir).await.unwrap();
1261
1262        let result = find_existing_worktree(dir.path(), "42").await;
1263        assert_eq!(result, Some(wt_dir));
1264    }
1265
1266    #[tokio::test]
1267    async fn find_existing_worktree_matches_internal_prefix() {
1268        let dir = tempfile::tempdir().unwrap();
1269        let wt_dir = dir.path().join("internal-8-fix");
1270        tokio::fs::create_dir(&wt_dir).await.unwrap();
1271
1272        let result = find_existing_worktree(dir.path(), "internal:8").await;
1273        assert_eq!(result, Some(wt_dir));
1274    }
1275
1276    #[tokio::test]
1277    async fn find_existing_worktree_matches_legacy_gh_task_prefix() {
1278        // Old worktrees created before the rename should still be found
1279        let dir = tempfile::tempdir().unwrap();
1280        let wt_dir = dir.path().join("gh-task-internal-8-fix");
1281        tokio::fs::create_dir(&wt_dir).await.unwrap();
1282
1283        let result = find_existing_worktree(dir.path(), "internal:8").await;
1284        assert_eq!(result, Some(wt_dir));
1285    }
1286
1287    #[tokio::test]
1288    async fn find_existing_worktree_matches_legacy_external_prefix() {
1289        // Old external worktrees with gh-task- prefix should still be found
1290        let dir = tempfile::tempdir().unwrap();
1291        let wt_dir = dir.path().join("gh-task-42-fix-login-bug");
1292        tokio::fs::create_dir(&wt_dir).await.unwrap();
1293
1294        let result = find_existing_worktree(dir.path(), "42").await;
1295        assert_eq!(result, Some(wt_dir));
1296    }
1297
1298    #[tokio::test]
1299    async fn find_existing_worktree_ignores_other_tasks() {
1300        let dir = tempfile::tempdir().unwrap();
1301        tokio::fs::create_dir(dir.path().join("gh-issue-43-other-task"))
1302            .await
1303            .unwrap();
1304
1305        assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1306    }
1307
1308    // --- resolve_parent_branch tests ---
1309
1310    /// No store → always returns None.
1311    #[tokio::test]
1312    async fn resolve_parent_branch_no_store_returns_none() {
1313        let dir = tempfile::tempdir().unwrap();
1314        let result = resolve_parent_branch(&None, Some(1), dir.path()).await;
1315        assert!(result.is_none());
1316    }
1317
1318    /// parent_id = None → always returns None regardless of store.
1319    #[tokio::test]
1320    async fn resolve_parent_branch_no_parent_id_returns_none() {
1321        let dir = tempfile::tempdir().unwrap();
1322        let result = resolve_parent_branch(&None, None, dir.path()).await;
1323        assert!(result.is_none());
1324    }
1325
1326    /// Parent exists but has empty branch → returns None so child gets its own branch.
1327    #[tokio::test]
1328    async fn resolve_parent_branch_empty_parent_branch_returns_none() {
1329        let db_dir = tempfile::tempdir().unwrap();
1330        let db_path = db_dir.path().join("test.db");
1331        let store = Arc::new(
1332            crate::store::TaskStore::open_single(&db_path)
1333                .await
1334                .unwrap(),
1335        );
1336
1337        // Create parent task — branch defaults to empty string
1338        let parent_id = store
1339            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1340            .await
1341            .unwrap();
1342
1343        let dir = tempfile::tempdir().unwrap();
1344        let store_opt = Some(store);
1345        let result = resolve_parent_branch(&store_opt, Some(parent_id), dir.path()).await;
1346        assert!(result.is_none());
1347    }
1348
1349    /// Parent has a branch set → child inherits that branch and worktree path.
1350    #[tokio::test]
1351    async fn resolve_parent_branch_returns_parent_branch_and_worktree() {
1352        let db_dir = tempfile::tempdir().unwrap();
1353        let db_path = db_dir.path().join("test.db");
1354        let store = Arc::new(
1355            crate::store::TaskStore::open_single(&db_path)
1356                .await
1357                .unwrap(),
1358        );
1359
1360        // Create parent task and set its branch
1361        let parent_id = store
1362            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1363            .await
1364            .unwrap();
1365        store
1366            .set_fields(
1367                parent_id,
1368                &[("branch", serde_json::json!("gh-issue-99-parent-task"))],
1369            )
1370            .await
1371            .unwrap();
1372
1373        let worktrees_base = tempfile::tempdir().unwrap();
1374        let store_opt = Some(store);
1375        let result =
1376            resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1377
1378        let (branch, wt) = result.expect("should return parent branch");
1379        assert_eq!(branch, "gh-issue-99-parent-task");
1380        assert_eq!(wt, worktrees_base.path().join("gh-issue-99-parent-task"));
1381    }
1382
1383    /// When parent has both branch and a worktree path that exists, use the worktree path.
1384    #[tokio::test]
1385    async fn resolve_parent_branch_prefers_existing_worktree_path() {
1386        let db_dir = tempfile::tempdir().unwrap();
1387        let db_path = db_dir.path().join("test.db");
1388        let store = Arc::new(
1389            crate::store::TaskStore::open_single(&db_path)
1390                .await
1391                .unwrap(),
1392        );
1393
1394        let worktrees_base = tempfile::tempdir().unwrap();
1395        // Create the parent worktree directory
1396        let parent_wt_dir = worktrees_base.path().join("custom-worktree-path");
1397        tokio::fs::create_dir_all(&parent_wt_dir).await.unwrap();
1398
1399        let parent_id = store
1400            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1401            .await
1402            .unwrap();
1403        store
1404            .set_fields(
1405                parent_id,
1406                &[
1407                    ("branch", serde_json::json!("gh-issue-99-parent-task")),
1408                    (
1409                        "worktree",
1410                        serde_json::json!(parent_wt_dir.to_string_lossy().as_ref()),
1411                    ),
1412                ],
1413            )
1414            .await
1415            .unwrap();
1416
1417        let store_opt = Some(store);
1418        let result =
1419            resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1420
1421        let (branch, wt) = result.expect("should return parent branch");
1422        assert_eq!(branch, "gh-issue-99-parent-task");
1423        assert_eq!(wt, parent_wt_dir);
1424    }
1425
1426    /// Regression test: concurrent calls to list_project_worktrees and
1427    /// validate_worktree_gitdir should not starve the Tokio worker thread
1428    /// pool. This was broken by blocking Path::exists/is_dir calls in async
1429    /// functions (issue #2467).
1430    #[tokio::test]
1431    async fn concurrent_worktree_checks_no_starvation() {
1432        let temp_dir = tempfile::tempdir().unwrap();
1433        let worktrees_base = temp_dir.path().join("worktrees");
1434        tokio::fs::create_dir_all(&worktrees_base).await.unwrap();
1435
1436        // Create a fake worktree with valid gitdir
1437        let wt_dir = worktrees_base.join("gh-issue-123-test");
1438        tokio::fs::create_dir_all(&wt_dir).await.unwrap();
1439        // Create .git as a file pointing to a real directory (worktree link)
1440        let gitdir_target = temp_dir
1441            .path()
1442            .join(".git")
1443            .join("worktrees")
1444            .join("gh-issue-123-test");
1445        tokio::fs::create_dir_all(&gitdir_target).await.unwrap();
1446        let git_file = wt_dir.join(".git");
1447        tokio::fs::write(&git_file, format!("gitdir: {}\n", gitdir_target.display()))
1448            .await
1449            .unwrap();
1450
1451        // Spawn many concurrent tasks - if any use blocking calls, they'll
1452        // block the worker thread and cause timeout or deadlock.
1453        let handles: Vec<_> = (0..20)
1454            .map(|i| {
1455                let wb = worktrees_base.clone();
1456                let wd = wt_dir.clone();
1457                tokio::spawn(async move {
1458                    if i % 2 == 0 {
1459                        list_project_worktrees(&wb).await.ok();
1460                    } else {
1461                        validate_worktree_gitdir(&wd).await;
1462                    }
1463                })
1464            })
1465            .collect();
1466
1467        // Wait with timeout to detect starvation
1468        let results = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1469            futures::future::join_all(handles).await
1470        })
1471        .await;
1472
1473        assert!(
1474            results.is_ok(),
1475            "concurrent worktree checks timed out (starvation?)"
1476        );
1477    }
1478
1479    // --- normalize_branch_name tests ---
1480
1481    #[test]
1482    fn normalize_branch_name_strips_origin_prefix() {
1483        assert_eq!(
1484            normalize_branch_name("origin/main"),
1485            Some("main".to_string())
1486        );
1487        assert_eq!(
1488            normalize_branch_name("origin/develop"),
1489            Some("develop".to_string())
1490        );
1491    }
1492
1493    #[test]
1494    fn normalize_branch_name_strips_full_ref_path() {
1495        assert_eq!(
1496            normalize_branch_name("refs/remotes/origin/main"),
1497            Some("main".to_string())
1498        );
1499        assert_eq!(
1500            normalize_branch_name("refs/remotes/origin/develop"),
1501            Some("develop".to_string())
1502        );
1503    }
1504
1505    #[test]
1506    fn normalize_branch_name_strips_remotes_prefix() {
1507        assert_eq!(
1508            normalize_branch_name("remotes/origin/main"),
1509            Some("main".to_string())
1510        );
1511    }
1512
1513    #[test]
1514    fn normalize_branch_name_passes_through_plain_branch() {
1515        assert_eq!(normalize_branch_name("main"), Some("main".to_string()));
1516        assert_eq!(normalize_branch_name("master"), Some("master".to_string()));
1517        assert_eq!(
1518            normalize_branch_name("feature/foo"),
1519            Some("feature/foo".to_string())
1520        );
1521    }
1522
1523    #[test]
1524    fn normalize_branch_name_rejects_empty_and_refs_only() {
1525        assert_eq!(normalize_branch_name(""), None);
1526        assert_eq!(normalize_branch_name("   "), None);
1527        assert_eq!(
1528            normalize_branch_name("refs/heads/main"),
1529            Some("main".to_string())
1530        );
1531    }
1532}