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::engine::runner::git_ops;
8use crate::engine::runner::git_ops::RebaseOutcome;
9use crate::engine::runner::git_ops::StashRebaseOpts;
10use crate::github::http::GhHttp;
11use crate::home;
12use crate::store;
13use crate::store::TaskStore;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use tokio::fs;
17use tokio::process::Command;
18use tokio::task;
19
20/// Result of worktree setup.
21#[derive(Debug)]
22pub struct WorktreeSetup {
23    /// The directory where the agent will work
24    pub work_dir: PathBuf,
25    /// The branch name
26    pub branch: String,
27    /// The default branch of the repository
28    pub default_branch: String,
29    /// The main project directory (original, not worktree)
30    pub main_project_dir: PathBuf,
31}
32
33/// Canonical project name used under `~/.orch/worktrees/`.
34pub fn project_name(project_dir: &Path) -> String {
35    project_dir
36        .file_name()
37        .map_or_else(
38            || "project".to_string(),
39            |s| s.to_string_lossy().to_string(),
40        )
41        .trim_end_matches(".git")
42        .to_string()
43}
44
45/// Base worktrees directory for a project.
46pub fn project_worktrees_dir(project_dir: &Path) -> PathBuf {
47    home::worktrees_dir()
48        .unwrap_or_default()
49        .join(project_name(project_dir))
50}
51
52/// List all worktree directories for a project.
53pub async fn list_project_worktrees(project_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
54    let base = project_worktrees_dir(project_dir);
55    let mut worktrees = Vec::new();
56
57    if fs::metadata(&base).await.is_err() {
58        return Ok(worktrees);
59    }
60
61    let mut entries = fs::read_dir(&base).await?;
62    while let Some(entry) = entries.next_entry().await? {
63        if entry.metadata().await.is_ok_and(|m| m.is_dir()) {
64            worktrees.push(entry.path());
65        }
66    }
67
68    Ok(worktrees)
69}
70
71/// Extract a task ID from a worktree directory name.
72pub fn task_id_from_worktree_name(name: &str) -> Option<String> {
73    if let Some(rest) = name.strip_prefix("internal-") {
74        let num = rest.split('-').next()?;
75        return num.parse::<u64>().ok().map(|n| format!("internal:{n}"));
76    }
77
78    for prefix in ["gh-issue-", "gh-task-"] {
79        if let Some(rest) = name.strip_prefix(prefix) {
80            let task_part = rest.split('-').next()?;
81            if task_part == "internal" {
82                let num = rest.split('-').nth(1)?;
83                return num.parse::<u64>().ok().map(|n| format!("internal:{n}"));
84            }
85            return Some(task_part.to_string());
86        }
87    }
88
89    None
90}
91
92/// Abort any rebase in progress for a worktree.
93pub async fn abort_worktree_rebase(worktree_dir: &Path) {
94    let _ = Command::new("git")
95        .args(["rebase", "--abort"])
96        .current_dir(worktree_dir)
97        .output_with_context()
98        .await;
99}
100
101/// Rebase a worktree on top of `origin/{default_branch}`.
102///
103/// Stashes any uncommitted changes before rebasing to avoid failing the
104/// rebase due to "You have unstaged changes".  The stash is restored after
105/// the rebase succeeds; on failure the stash is preserved for manual recovery.
106pub async fn rebase_worktree_on_origin_main(
107    worktree_dir: &Path,
108    default_branch: &str,
109) -> anyhow::Result<()> {
110    let origin_branch = format!("origin/{default_branch}");
111
112    match git_ops::stash_rebase_restore(worktree_dir, &origin_branch, StashRebaseOpts::default())
113        .await?
114    {
115        RebaseOutcome::Succeeded | RebaseOutcome::Skipped(_) => Ok(()),
116        RebaseOutcome::Failed(e) => {
117            anyhow::bail!("git rebase {origin_branch} failed: {e}")
118        }
119    }
120}
121
122/// Generate a branch name from task ID and title.
123///
124/// Format: `internal-{id}-{slug}` for internal tasks, `gh-issue-{id}-{slug}` for external.
125/// Slug is lowercase, non-alphanum→`-`, max 40 chars.
126pub fn branch_name(task_id: &str, title: &str) -> String {
127    let (prefix, sanitized_id) = if let Some(num) = task_id.strip_prefix("internal:") {
128        ("internal", num.to_string())
129    } else {
130        ("gh-issue", sanitize_task_id(task_id))
131    };
132
133    let raw: String = title
134        .to_lowercase()
135        .chars()
136        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
137        .collect();
138
139    // Collapse consecutive dashes and trim
140    let mut slug = String::with_capacity(raw.len());
141    let mut prev_dash = false;
142    for c in raw.chars() {
143        if c == '-' {
144            if !prev_dash {
145                slug.push('-');
146            }
147            prev_dash = true;
148        } else {
149            slug.push(c);
150            prev_dash = false;
151        }
152    }
153    let slug = slug.trim_matches('-').to_string();
154
155    // Truncate slug to 40 chars
156    let slug = if slug.len() > 40 { &slug[..40] } else { &slug };
157    let slug = slug.trim_end_matches('-');
158
159    if slug.is_empty() {
160        format!("{prefix}-{sanitized_id}")
161    } else {
162        format!("{prefix}-{sanitized_id}-{slug}")
163    }
164}
165
166/// Replace characters in `task_id` that are not safe for branch/worktree names.
167fn sanitize_task_id(task_id: &str) -> String {
168    task_id.replace(':', "-")
169}
170
171/// Normalize a git ref output to a plain branch name.
172///
173/// Handles the various output formats that different git versions and
174/// configurations can produce for remote HEAD refs:
175/// - `origin/main` → `main`
176/// - `refs/remotes/origin/main` → `main`
177/// - `remotes/origin/main` → `main`
178/// - `main` → `main`
179fn normalize_branch_name(raw: &str) -> Option<String> {
180    let branch = raw.trim();
181    if branch.is_empty() {
182        return None;
183    }
184
185    // Strip known prefixes in order of specificity.
186    let cleaned = branch
187        .strip_prefix("refs/remotes/origin/")
188        .or_else(|| branch.strip_prefix("remotes/origin/"))
189        .or_else(|| branch.strip_prefix("refs/heads/"))
190        .or_else(|| branch.strip_prefix("origin/"))
191        .unwrap_or(branch);
192
193    if cleaned.is_empty() {
194        return None;
195    }
196    Some(cleaned.to_string())
197}
198
199/// Detect the default branch of a repository.
200///
201/// Tries multiple strategies and normalizes the result so callers always
202/// receive a clean branch name like `"main"`, never a raw ref path.
203pub async fn detect_default_branch(project_dir: &Path) -> String {
204    // Strategy 1: symbolic-ref (fast, works when origin/HEAD is set).
205    let symref = Command::new("git")
206        .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
207        .current_dir(project_dir)
208        .output_with_context()
209        .await;
210
211    if let Ok(o) = &symref
212        && o.status.success()
213    {
214        let raw = String::from_utf8_lossy(&o.stdout);
215        if let Some(branch) = normalize_branch_name(&raw) {
216            return branch;
217        }
218    }
219
220    // Strategy 2: rev-parse --abbrev-ref (resolves symref to target).
221    let revparse = Command::new("git")
222        .args(["rev-parse", "--abbrev-ref", "refs/remotes/origin/HEAD"])
223        .current_dir(project_dir)
224        .output_with_context()
225        .await;
226
227    if let Ok(o) = &revparse
228        && o.status.success()
229    {
230        let raw = String::from_utf8_lossy(&o.stdout);
231        if let Some(branch) = normalize_branch_name(&raw) {
232            return branch;
233        }
234    }
235
236    // Strategy 3: parse `git remote show origin` output.
237    let remote_show = Command::new("git")
238        .args(["remote", "show", "origin"])
239        .current_dir(project_dir)
240        .output_with_context()
241        .await;
242
243    if let Ok(o) = &remote_show
244        && o.status.success()
245    {
246        let stdout = String::from_utf8_lossy(&o.stdout);
247        for line in stdout.lines() {
248            let trimmed = line.trim();
249            if trimmed.starts_with("HEAD branch:")
250                && let Some(branch) = trimmed.split(':').nth(1)
251            {
252                let branch = branch.trim();
253                if !branch.is_empty() && branch != "(unknown)" {
254                    return branch.to_string();
255                }
256            }
257        }
258    }
259
260    tracing::warn!(
261        project_dir = %project_dir.display(),
262        "could not detect default branch from git, falling back to 'main'"
263    );
264    "main".to_string()
265}
266
267/// Resolve PROJECT_DIR to the main repo if it's inside a worktree.
268///
269/// This prevents nested worktrees when subtasks inherit a parent's worktree dir.
270pub async fn resolve_main_repo(project_dir: &Path) -> PathBuf {
271    let output = Command::new("git")
272        .args(["worktree", "list", "--porcelain"])
273        .current_dir(project_dir)
274        .output_with_context()
275        .await;
276
277    if let Ok(o) = output
278        && o.status.success()
279    {
280        let stdout = String::from_utf8_lossy(&o.stdout);
281        if let Some(first_line) = stdout.lines().next()
282            && let Some(path) = first_line.strip_prefix("worktree ")
283        {
284            let main_path = PathBuf::from(path);
285            if main_path != project_dir {
286                tracing::info!(
287                    worktree = %project_dir.display(),
288                    main = %main_path.display(),
289                    "resolved worktree to main repo"
290                );
291                return main_path;
292            }
293        }
294    }
295
296    project_dir.to_path_buf()
297}
298
299/// Resolve the starting point for creating a new local branch.
300///
301/// Preference order:
302/// 1. `origin/<branch>` if it exists — cleanup kept the remote branch after
303///    deleting the local one, so resume from the agent's committed work.
304/// 2. `origin/<default_branch>` if it exists — anchors on the pushed tip of
305///    the default branch so unpushed local commits in the operator's main
306///    checkout don't leak into the worktree.
307/// 3. `default_branch` as a last resort (no origin remote / never fetched).
308pub(super) async fn resolve_branch_start_point(
309    repo_root: &str,
310    branch: &str,
311    default_branch: &str,
312) -> String {
313    let verify = |reference: String| async move {
314        Command::new("git")
315            .args(["-C", repo_root, "rev-parse", "--verify", &reference])
316            .output_with_context()
317            .await
318            .is_ok_and(|o| o.status.success())
319    };
320
321    let origin_branch_ref = format!("origin/{branch}");
322    if verify(origin_branch_ref.clone()).await {
323        return origin_branch_ref;
324    }
325
326    let origin_default_ref = format!("origin/{default_branch}");
327    if verify(origin_default_ref.clone()).await {
328        return origin_default_ref;
329    }
330
331    default_branch.to_string()
332}
333
334/// Validate that a worktree directory has a working gitdir link.
335///
336/// In a git worktree, `.git` is a file containing `gitdir: /path/to/.git/worktrees/<name>`.
337/// Returns `false` when the `.git` file is missing or points to a non-existent gitdir,
338/// indicating the worktree metadata is broken and the directory should be cleaned up.
339pub async fn validate_worktree_gitdir(worktree_dir: &Path) -> bool {
340    let git_file = worktree_dir.join(".git");
341    // Use tokio::fs::metadata for async-safe existence check
342    if fs::metadata(&git_file).await.is_err() {
343        return false;
344    }
345    // In a linked worktree .git is a file; in the main repo it's a directory (always valid).
346    if fs::metadata(&git_file).await.is_ok_and(|m| m.is_dir()) {
347        return true;
348    }
349
350    let Ok(content) = fs::read_to_string(&git_file).await else {
351        return false;
352    };
353    // Expected format: "gitdir: /absolute/path/to/.git/worktrees/<name>\n"
354    // Use spawn_blocking for the final exists check on the resolved path
355    if let Some(gitdir_path) = content
356        .lines()
357        .next()
358        .and_then(|l| l.strip_prefix("gitdir: "))
359    {
360        // Use async metadata for the gitdir check too.
361        return fs::metadata(gitdir_path.trim())
362            .await
363            .is_ok_and(|m| m.is_dir());
364    }
365    false
366}
367
368/// Resolve the branch and worktree directory from a parent task, if available.
369///
370/// When a child task is created from a mention on a PR, it should work on the
371/// parent task's branch so changes are committed to the existing PR rather than
372/// a new one being opened.
373#[expect(
374    clippy::ref_option,
375    reason = "the store helper it calls takes the store as &Option"
376)]
377async fn resolve_parent_branch(
378    store: &Option<Arc<TaskStore>>,
379    parent_id: Option<i64>,
380    worktrees_base: &Path,
381) -> Option<(String, PathBuf)> {
382    let parent_id = parent_id?;
383    let parent = store::opt_store_get_task_by_id(store, parent_id).await?;
384    if parent.branch.is_empty() {
385        return None;
386    }
387    let wt = if parent.worktree.is_empty() {
388        worktrees_base.join(&parent.branch)
389    } else {
390        // Use async metadata to avoid blocking the reactor thread.
391        let exists = fs::metadata(&parent.worktree)
392            .await
393            .is_ok_and(|m| m.is_dir());
394        if exists {
395            PathBuf::from(&parent.worktree)
396        } else {
397            worktrees_base.join(&parent.branch)
398        }
399    };
400    tracing::info!(
401        parent_id,
402        branch = %parent.branch,
403        worktree = %wt.display(),
404        "child task inheriting parent branch"
405    );
406    Some((parent.branch, wt))
407}
408
409/// Set up a worktree for task execution.
410///
411/// Returns the working directory, branch name, and default branch.
412pub async fn setup_worktree(
413    task_id: &str,
414    title: &str,
415    project_dir: &Path,
416    store: &Option<Arc<TaskStore>>,
417    repo: &str,
418) -> anyhow::Result<WorktreeSetup> {
419    // Resolve to main repo (avoid nested worktrees)
420    let main_dir = resolve_main_repo(project_dir).await;
421    let default_branch = detect_default_branch(&main_dir).await;
422
423    let worktrees_base = project_worktrees_dir(&main_dir);
424    fs::create_dir_all(&worktrees_base).await?;
425
426    // Check if we have a saved branch/worktree in store
427    let task_record = store::opt_store_get_task(store, repo, task_id).await;
428    let (saved_branch, saved_worktree, parent_id) =
429        task_record.as_ref().map_or((None, None, None), |t| {
430            (
431                Some(t.branch.clone()),
432                Some(t.worktree.clone()),
433                t.parent_id,
434            )
435        });
436
437    let mut force_fresh = false;
438    if let (Some(store), Some(record)) = (store.as_ref(), task_record.as_ref()) {
439        let key = store::worktree_refresh_key(record.id);
440        if matches!(store.kv_get(&key).await, Ok(Some(_))) {
441            let _ = store.kv_delete(&key).await;
442            force_fresh = true;
443            tracing::info!(
444                task_id,
445                "dependency merged, discarding the old worktree so the next run starts at the default branch"
446            );
447        }
448    }
449
450    let (branch_name_str, worktree_dir) = if force_fresh {
451        if let Some(existing_dir) = find_existing_worktree(&worktrees_base, task_id).await {
452            discard_worktree(&main_dir, &existing_dir).await;
453        }
454        if let Some(saved) = saved_worktree.as_ref().filter(|path| !path.is_empty()) {
455            let saved_path = PathBuf::from(saved);
456            if fs::metadata(&saved_path).await.is_ok() {
457                discard_worktree(&main_dir, &saved_path).await;
458            }
459        }
460        let bn = branch_name(task_id, title);
461        (bn.clone(), worktrees_base.join(&bn))
462    } else if let Some(ref saved) = saved_branch {
463        if !saved.is_empty() {
464            // Use spawn_blocking for blocking Path::exists call in async context
465            let wt = match &saved_worktree {
466                Some(wt) if !wt.is_empty() => {
467                    let wt_str = wt.clone();
468                    let exists = task::spawn_blocking(move || Path::new(&wt_str).exists())
469                        .await
470                        .unwrap_or(false);
471                    if exists {
472                        PathBuf::from(wt)
473                    } else {
474                        worktrees_base.join(saved)
475                    }
476                }
477                _ => worktrees_base.join(saved),
478            };
479            (saved.clone(), wt)
480        } else if let Some((parent_branch, parent_wt)) =
481            resolve_parent_branch(store, parent_id, &worktrees_base).await
482        {
483            // Child task with no saved branch: inherit parent's branch so we
484            // commit onto the existing PR instead of opening a new one.
485            (parent_branch, parent_wt)
486        } else {
487            let bn = branch_name(task_id, title);
488            (bn.clone(), worktrees_base.join(&bn))
489        }
490    } else {
491        // No store record — check parent branch, then existing worktree by prefix
492        if let Some((parent_branch, parent_wt)) =
493            resolve_parent_branch(store, parent_id, &worktrees_base).await
494        {
495            (parent_branch, parent_wt)
496        } else {
497            let existing = find_existing_worktree(&worktrees_base, task_id).await;
498            if let Some(existing_dir) = existing {
499                let bn = existing_dir
500                    .file_name()
501                    .filter(|n| !n.is_empty())
502                    .map_or_else(
503                        || branch_name(task_id, title),
504                        |n| n.to_string_lossy().to_string(),
505                    );
506                tracing::info!(task_id, worktree = %existing_dir.display(), "found existing worktree");
507                (bn, existing_dir)
508            } else {
509                let bn = branch_name(task_id, title);
510                (bn.clone(), worktrees_base.join(&bn))
511            }
512        }
513    };
514
515    if branch_name_str.is_empty() {
516        anyhow::bail!("empty branch name for task {task_id}");
517    }
518
519    // Abort any in-progress rebase in an existing worktree.
520    //
521    // Agents can leave a worktree stuck mid-rebase (e.g. after running
522    // `git rebase -i` interactively).  The next dispatch attempt would
523    // inherit that broken state, causing all subsequent git operations to
524    // fail.  Aborting here is safe: if no rebase is in progress git exits
525    // with a non-zero code which we silently ignore.
526    // A task with a recorded merge conflict keeps its rebase, auto_merge left it on purpose and
527    // told the agent to continue it.
528    // Use tokio::fs for async-safe existence check
529    if fs::metadata(&worktree_dir).await.is_ok() {
530        let keep_rebase = task_record
531            .as_ref()
532            .is_some_and(|t| t.merge_conflict_retries > 0)
533            && git_ops::rebase_in_progress(&worktree_dir).await;
534        if keep_rebase {
535            tracing::info!(
536                task_id,
537                "keeping the conflicted rebase auto_merge left for the agent"
538            );
539        } else {
540            let _ = Command::new("git")
541                .args(["rebase", "--abort"])
542                .current_dir(&worktree_dir)
543                .output_with_context()
544                .await;
545        }
546
547        // Check for a corrupted git index (e.g. "error: could not write index").
548        // `git status` reads the index; a non-zero exit with index-related stderr
549        // indicates corruption that will cause every subsequent git operation to
550        // fail, leading to an infinite stuck-recovery loop.  When detected, nuke
551        // the worktree and recreate it from scratch below.
552        let index_ok = Command::new("git")
553            .args(["status", "--porcelain"])
554            .current_dir(&worktree_dir)
555            .output_with_context()
556            .await
557            .is_ok_and(|o| o.status.success());
558
559        if !index_ok {
560            let stderr_hint = Command::new("git")
561                .args(["status", "--porcelain"])
562                .current_dir(&worktree_dir)
563                .output_with_context()
564                .await
565                .map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
566                .unwrap_or_default();
567            tracing::warn!(
568                task_id,
569                worktree = %worktree_dir.display(),
570                stderr = %stderr_hint,
571                "corrupted worktree index detected — removing and recreating worktree"
572            );
573            // Remove git worktree reference first, then delete the directory.
574            let _ = Command::new("git")
575                .args([
576                    "-C",
577                    &main_dir.to_string_lossy(),
578                    "worktree",
579                    "remove",
580                    "--force",
581                    &worktree_dir.to_string_lossy(),
582                ])
583                .output_with_context()
584                .await;
585            // If the directory still exists (e.g. worktree remove failed), remove it
586            // manually so the worktree-creation block below re-creates it cleanly.
587            if fs::metadata(&worktree_dir).await.is_ok() {
588                let _ = fs::remove_dir_all(&worktree_dir).await;
589            }
590            // Prune stale worktree metadata so git won't complain on re-add.
591            let _ = Command::new("git")
592                .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
593                .output_with_context()
594                .await;
595        }
596    }
597
598    // Create worktree if it doesn't exist
599    if fs::metadata(&worktree_dir).await.is_err() {
600        tracing::info!(task_id, worktree = %worktree_dir.display(), "creating worktree");
601
602        // Fetch latest so `origin/<default_branch>` (and any pre-existing
603        // remote task branch) is fresh. We deliberately do NOT `git pull` on
604        // the operator's checkout: branching from `origin/<default_branch>`
605        // means unpushed local commits or in-progress work on `main` never
606        // leak into the new worktree.
607        let _ = git_ops::git()
608            .await
609            .args([
610                "-C",
611                &main_dir.to_string_lossy(),
612                "fetch",
613                "--prune",
614                "origin",
615            ])
616            .output_with_context()
617            .await;
618
619        // Create local branch. Prefer `origin/<branch>` when it already exists
620        // (recovery case: cleanup deleted local branch but kept the remote),
621        // so the new worktree starts from the agent's committed work instead
622        // of the default branch tip.
623        let start_point = resolve_branch_start_point(
624            &main_dir.to_string_lossy(),
625            &branch_name_str,
626            &default_branch,
627        )
628        .await;
629        let _ = Command::new("git")
630            .args([
631                "-C",
632                &main_dir.to_string_lossy(),
633                "branch",
634                &branch_name_str,
635                &start_point,
636            ])
637            .output_with_context()
638            .await;
639
640        // Create worktree
641        if let Some(parent) = worktree_dir.parent() {
642            fs::create_dir_all(parent).await?;
643        }
644
645        let cloned = match super::cow::create_worktree(
646            &main_dir,
647            &project_name(&main_dir),
648            &worktree_dir,
649            &branch_name_str,
650            &default_branch,
651        )
652        .await
653        {
654            Ok(()) => {
655                tracing::info!(task_id, "worktree cloned from project template");
656                true
657            }
658            Err(e) => {
659                tracing::debug!(task_id, error = %e, "copy-on-write worktree unavailable, using git worktree add");
660                false
661            }
662        };
663
664        if !cloned {
665            let output = Command::new("git")
666                .args([
667                    "-C",
668                    &main_dir.to_string_lossy(),
669                    "worktree",
670                    "add",
671                    &worktree_dir.to_string_lossy(),
672                    &branch_name_str,
673                ])
674                .output_with_context()
675                .await?;
676
677            if !output.status.success() && fs::metadata(&worktree_dir).await.is_err() {
678                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
679                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
680                // Retry: prune and recreate
681                tracing::warn!(
682                    task_id,
683                    stdout = %stdout,
684                    stderr = %stderr,
685                    "worktree creation failed, retrying after prune"
686                );
687
688                let _ = Command::new("git")
689                    .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
690                    .output_with_context()
691                    .await;
692
693                let _ = Command::new("git")
694                    .args([
695                        "-C",
696                        &main_dir.to_string_lossy(),
697                        "branch",
698                        "-D",
699                        &branch_name_str,
700                    ])
701                    .output_with_context()
702                    .await;
703
704                let _ = Command::new("git")
705                    .args([
706                        "-C",
707                        &main_dir.to_string_lossy(),
708                        "branch",
709                        &branch_name_str,
710                        &start_point,
711                    ])
712                    .output_with_context()
713                    .await;
714
715                let retry_output = Command::new("git")
716                    .args([
717                        "-C",
718                        &main_dir.to_string_lossy(),
719                        "worktree",
720                        "add",
721                        &worktree_dir.to_string_lossy(),
722                        &branch_name_str,
723                    ])
724                    .output_with_context()
725                    .await;
726
727                let mut retry_stdout = String::new();
728                let mut retry_stderr = String::new();
729                let mut retry_error = String::new();
730                match retry_output {
731                    Ok(output) => {
732                        retry_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
733                        retry_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
734                        tracing::warn!(
735                            task_id,
736                            stdout = %retry_stdout,
737                            stderr = %retry_stderr,
738                            "worktree creation retry failed"
739                        );
740                    }
741                    Err(err) => {
742                        retry_error = err.to_string();
743                        tracing::warn!(
744                            task_id,
745                            error = %retry_error,
746                            "worktree creation retry failed to run"
747                        );
748                    }
749                }
750
751                if fs::metadata(&worktree_dir).await.is_err() {
752                    anyhow::bail!(
753                        "failed to create worktree at {} for task {} (stdout: {}, stderr: {}, retry stdout: {}, retry stderr: {}, retry error: {})",
754                        worktree_dir.display(),
755                        task_id,
756                        stdout,
757                        stderr,
758                        retry_stdout,
759                        retry_stderr,
760                        retry_error
761                    );
762                }
763            }
764        }
765    }
766
767    // Link issue to branch via GitHub API (Development sidebar).
768    // Must happen before the agent pushes — createLinkedBranch only works
769    // for branches that don't yet exist on GitHub.
770    if !task_id.starts_with("internal:")
771        && let Ok(issue_num) = task_id.parse::<u64>()
772        && let Ok(gh) = GhHttp::new()
773    {
774        match gh
775            .link_issue_to_branch(repo, issue_num, &branch_name_str)
776            .await
777        {
778            Ok(id) => {
779                tracing::info!(task_id, branch = %branch_name_str, linked = %id, "linked issue to branch");
780            }
781            Err(e) => {
782                tracing::debug!(task_id, error = %e, "failed to link issue to branch (non-fatal)");
783            }
784        }
785    }
786
787    // Save worktree info to store
788    if let Err(e) = store::store_set_result(
789        store,
790        repo,
791        task_id,
792        &[
793            (
794                "worktree",
795                serde_json::json!(worktree_dir.display().to_string()),
796            ),
797            ("branch", serde_json::json!(branch_name_str)),
798        ],
799    )
800    .await
801    {
802        tracing::warn!(task_id, err = %e, "failed to save worktree info to store (non-fatal)");
803    }
804
805    tracing::info!(
806        task_id,
807        worktree = %worktree_dir.display(),
808        branch = %branch_name_str,
809        "worktree ready"
810    );
811
812    Ok(WorktreeSetup {
813        work_dir: worktree_dir,
814        branch: branch_name_str,
815        default_branch,
816        main_project_dir: main_dir,
817    })
818}
819
820/// Find an existing worktree by task ID prefix.
821async fn discard_worktree(main_dir: &Path, worktree_dir: &Path) {
822    let _ = Command::new("git")
823        .args([
824            "-C",
825            &main_dir.to_string_lossy(),
826            "worktree",
827            "remove",
828            "--force",
829            &worktree_dir.to_string_lossy(),
830        ])
831        .output_with_context()
832        .await;
833    if fs::metadata(worktree_dir).await.is_ok() {
834        let _ = fs::remove_dir_all(worktree_dir).await;
835    }
836    let _ = Command::new("git")
837        .args(["-C", &main_dir.to_string_lossy(), "worktree", "prune"])
838        .output_with_context()
839        .await;
840}
841
842async fn find_existing_worktree(worktrees_base: &Path, task_id: &str) -> Option<PathBuf> {
843    let (prefix_new, legacy_prefixes) = if let Some(num) = task_id.strip_prefix("internal:") {
844        (
845            format!("internal-{num}-"),
846            vec![
847                format!("gh-task-internal-{num}-"),
848                format!("gh-issue-internal-{num}-"),
849            ],
850        )
851    } else {
852        let sanitized_id = sanitize_task_id(task_id);
853        (
854            format!("gh-issue-{sanitized_id}-"),
855            vec![format!("gh-task-{sanitized_id}-")],
856        )
857    };
858
859    let mut entries = match fs::read_dir(worktrees_base).await {
860        Ok(e) => e,
861        Err(e) => {
862            tracing::warn!(
863                error = %e,
864                path = %worktrees_base.display(),
865                "failed to read worktrees directory while searching for existing worktree"
866            );
867            return None;
868        }
869    };
870    loop {
871        let next_entry = match entries.next_entry().await {
872            Ok(Some(entry)) => entry,
873            Ok(None) => break,
874            Err(e) => {
875                // Log the error but continue scanning - a single entry error shouldn't stop us
876                tracing::warn!(error = %e, "failed to read directory entry while searching for worktree");
877                continue;
878            }
879        };
880
881        let name = next_entry.file_name().to_string_lossy().to_string();
882        let matches =
883            name.starts_with(&prefix_new) || legacy_prefixes.iter().any(|p| name.starts_with(p));
884        if matches {
885            match next_entry.metadata().await {
886                Ok(metadata) if metadata.is_dir() => return Some(next_entry.path()),
887                Ok(_) => {}
888                Err(e) => {
889                    tracing::warn!(
890                        error = %e,
891                        path = %next_entry.path().display(),
892                        "failed to read metadata for candidate worktree entry"
893                    );
894                }
895            }
896        }
897    }
898
899    None
900}
901
902#[cfg(test)]
903mod tests {
904    use futures::future;
905    use tokio::time;
906
907    use std::env;
908    use std::fs as std_fs;
909    use std::process;
910    use std::time::Duration;
911
912    use super::*;
913
914    /// Helper: run a git command in `dir`, panicking on failure.
915    fn git(dir: &Path, args: &[&str]) {
916        let mut cmd = process::Command::new("git");
917        cmd.current_dir(dir);
918        // Global commit.gpgsign calls the operator's signer (1Password)
919        if args.first() == Some(&"commit") {
920            cmd.args(["-c", "commit.gpgsign=false"]);
921        }
922        let out = cmd
923            .args(args)
924            .output()
925            .unwrap_or_else(|e| panic!("git {args:?} failed to start: {e}"));
926        assert!(
927            out.status.success(),
928            "git {:?} failed:\nstdout: {}\nstderr: {}",
929            args,
930            String::from_utf8_lossy(&out.stdout),
931            String::from_utf8_lossy(&out.stderr)
932        );
933    }
934
935    /// When `origin/<branch>` exists, `resolve_branch_start_point` should
936    /// return `"origin/<branch>"` so that the worktree starts from the
937    /// already-committed work, not from the default branch tip.
938    #[tokio::test]
939    async fn resolve_branch_start_point_prefers_remote_over_default() {
940        let remote = tempfile::tempdir().unwrap();
941        git(remote.path(), &["init", "--bare"]);
942
943        // Set up a working clone to push branches to the remote.
944        let setup = tempfile::tempdir().unwrap();
945        git(
946            setup.path(),
947            &["clone", remote.path().to_str().unwrap(), "."],
948        );
949        git(setup.path(), &["config", "user.email", "t@t.com"]);
950        git(setup.path(), &["config", "user.name", "T"]);
951
952        // Initial commit on main.
953        std_fs::write(setup.path().join("README.md"), "main").unwrap();
954        git(setup.path(), &["add", "."]);
955        git(setup.path(), &["commit", "-m", "init"]);
956        git(setup.path(), &["push", "origin", "HEAD:main"]);
957
958        // Feature branch with a distinct commit.
959        git(
960            setup.path(),
961            &["checkout", "-b", "gh-issue-42-fix-login-bug"],
962        );
963        std_fs::write(setup.path().join("feature.txt"), "work").unwrap();
964        git(setup.path(), &["add", "."]);
965        git(setup.path(), &["commit", "-m", "feature work"]);
966        git(
967            setup.path(),
968            &["push", "origin", "gh-issue-42-fix-login-bug"],
969        );
970
971        // Local repo: clone + fetch, but NO local branch (simulates cleanup
972        // with keep_remote_branch=true where local branch was deleted).
973        let local = tempfile::tempdir().unwrap();
974        git(
975            local.path(),
976            &["clone", remote.path().to_str().unwrap(), "."],
977        );
978        git(local.path(), &["config", "user.email", "t@t.com"]);
979        git(local.path(), &["config", "user.name", "T"]);
980        git(local.path(), &["fetch", "--all"]);
981        // Confirm the local branch does NOT exist; only the remote tracking ref.
982        let has_local = process::Command::new("git")
983            .args([
984                "-C",
985                local.path().to_str().unwrap(),
986                "rev-parse",
987                "--verify",
988                "gh-issue-42-fix-login-bug",
989            ])
990            .output()
991            .unwrap()
992            .status
993            .success();
994        assert!(!has_local, "local branch should not exist before the test");
995
996        let repo_root = local.path().to_str().unwrap();
997
998        // With remote branch present → should prefer origin/<branch>.
999        let start =
1000            resolve_branch_start_point(repo_root, "gh-issue-42-fix-login-bug", "main").await;
1001        assert_eq!(
1002            start, "origin/gh-issue-42-fix-login-bug",
1003            "should use remote branch as start point when origin/<branch> exists"
1004        );
1005    }
1006
1007    /// When `origin/<branch>` does NOT exist but `origin/<default_branch>` does,
1008    /// `resolve_branch_start_point` should anchor on the remote default branch
1009    /// so unpushed local commits on `main` don't leak into the worktree.
1010    #[tokio::test]
1011    async fn resolve_branch_start_point_prefers_origin_default_over_local() {
1012        let remote = tempfile::tempdir().unwrap();
1013        git(remote.path(), &["init", "--bare"]);
1014
1015        let local = tempfile::tempdir().unwrap();
1016        git(
1017            local.path(),
1018            &["clone", remote.path().to_str().unwrap(), "."],
1019        );
1020        git(local.path(), &["config", "user.email", "t@t.com"]);
1021        git(local.path(), &["config", "user.name", "T"]);
1022
1023        // Commit something so origin/main exists.
1024        std_fs::write(local.path().join("README.md"), "main").unwrap();
1025        git(local.path(), &["add", "."]);
1026        git(local.path(), &["commit", "-m", "init"]);
1027        git(local.path(), &["push", "origin", "HEAD:main"]);
1028        git(local.path(), &["fetch", "--all"]);
1029
1030        let repo_root = local.path().to_str().unwrap();
1031
1032        // Branch "no-such-branch" has never been pushed, but origin/main exists.
1033        let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1034        assert_eq!(
1035            start, "origin/main",
1036            "should anchor on origin/<default_branch> instead of local default"
1037        );
1038    }
1039
1040    /// Capture stdout of a git command in `dir`.
1041    fn git_stdout(dir: &Path, args: &[&str]) -> String {
1042        let out = process::Command::new("git")
1043            .args(args)
1044            .current_dir(dir)
1045            .output()
1046            .unwrap_or_else(|e| panic!("git {args:?} failed to start: {e}"));
1047        assert!(
1048            out.status.success(),
1049            "git {:?} failed:\nstdout: {}\nstderr: {}",
1050            args,
1051            String::from_utf8_lossy(&out.stdout),
1052            String::from_utf8_lossy(&out.stderr)
1053        );
1054        String::from_utf8_lossy(&out.stdout).trim().to_string()
1055    }
1056
1057    /// End-to-end guard for the operator-main-leak fix: when the operator's
1058    /// local `main` has commits that are not yet pushed, `setup_worktree`
1059    /// must branch from `origin/main` and produce a worktree whose HEAD is
1060    /// the pushed tip — never the local-only tip.
1061    #[tokio::test]
1062    #[serial_test::serial(orch_home)]
1063    async fn setup_worktree_anchors_on_origin_not_local_main() {
1064        // Isolate ORCH_HOME so the worktree lands in a temp dir.
1065        let orch_home_dir = tempfile::tempdir().unwrap();
1066        let prev_orch_home = env::var("ORCH_HOME").ok();
1067        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1068        unsafe { env::set_var("ORCH_HOME", orch_home_dir.path()) };
1069
1070        // Bare "remote".
1071        let remote = tempfile::tempdir().unwrap();
1072        git(remote.path(), &["init", "--bare"]);
1073
1074        // Operator's project clone.
1075        let project = tempfile::tempdir().unwrap();
1076        git(
1077            project.path(),
1078            &["clone", remote.path().to_str().unwrap(), "."],
1079        );
1080        git(project.path(), &["config", "user.email", "t@t.com"]);
1081        git(project.path(), &["config", "user.name", "T"]);
1082
1083        // Pushed baseline on main.
1084        std_fs::write(project.path().join("README.md"), "pushed\n").unwrap();
1085        git(project.path(), &["add", "."]);
1086        git(project.path(), &["commit", "-m", "pushed baseline"]);
1087        git(project.path(), &["push", "origin", "HEAD:main"]);
1088        let pushed_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1089
1090        // Ensure origin/HEAD is set so detect_default_branch resolves "main".
1091        git(project.path(), &["remote", "set-head", "origin", "main"]);
1092
1093        // Operator's WIP: an unpushed local commit on top of main.
1094        std_fs::write(project.path().join("LOCAL_ONLY.md"), "unpushed\n").unwrap();
1095        git(project.path(), &["add", "."]);
1096        git(project.path(), &["commit", "-m", "local unpushed commit"]);
1097        let local_sha = git_stdout(project.path(), &["rev-parse", "HEAD"]);
1098        assert_ne!(
1099            pushed_sha, local_sha,
1100            "precondition: local main must be ahead of origin/main"
1101        );
1102
1103        // Run setup_worktree for a fresh internal task (skips GitHub linking).
1104        let setup = setup_worktree(
1105            "internal:1",
1106            "leak guard",
1107            project.path(),
1108            &None,
1109            "test/repo",
1110        )
1111        .await
1112        .expect("setup_worktree should succeed");
1113
1114        // Worktree must sit on the pushed tip, not the local-only tip.
1115        let wt_sha = git_stdout(&setup.work_dir, &["rev-parse", "HEAD"]);
1116        assert_eq!(
1117            wt_sha, pushed_sha,
1118            "worktree HEAD must equal origin/main, not local main"
1119        );
1120        assert_ne!(
1121            wt_sha, local_sha,
1122            "worktree must NOT include the operator's unpushed commit"
1123        );
1124        assert!(
1125            !setup.work_dir.join("LOCAL_ONLY.md").exists(),
1126            "unpushed-only file must not appear in the worktree"
1127        );
1128
1129        // Restore ORCH_HOME.
1130        match prev_orch_home {
1131            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1132            Some(v) => unsafe { env::set_var("ORCH_HOME", v) },
1133            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1134            None => unsafe { env::remove_var("ORCH_HOME") },
1135        }
1136    }
1137
1138    /// auto_merge leaves a conflicted rebase for the agent, dispatch must not abort it.
1139    #[tokio::test]
1140    #[serial_test::serial(orch_home)]
1141    async fn setup_worktree_keeps_the_conflicted_rebase_for_a_conflict_task() {
1142        let orch_home_dir = tempfile::tempdir().unwrap();
1143        let prev_orch_home = env::var("ORCH_HOME").ok();
1144        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1145        unsafe { env::set_var("ORCH_HOME", orch_home_dir.path()) };
1146
1147        let remote = tempfile::tempdir().unwrap();
1148        git(remote.path(), &["init", "--bare"]);
1149        let project = tempfile::tempdir().unwrap();
1150        git(
1151            project.path(),
1152            &["clone", remote.path().to_str().unwrap(), "."],
1153        );
1154        git(project.path(), &["config", "user.email", "t@t.com"]);
1155        git(project.path(), &["config", "user.name", "T"]);
1156        std_fs::write(project.path().join("f.txt"), "base\n").unwrap();
1157        git(project.path(), &["add", "."]);
1158        git(project.path(), &["commit", "-m", "base"]);
1159        git(project.path(), &["push", "origin", "HEAD:main"]);
1160        git(project.path(), &["remote", "set-head", "origin", "main"]);
1161
1162        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1163        let id = store
1164            .create_internal("test/repo", "conflict", "", "test", "1", None)
1165            .await
1166            .unwrap();
1167        let task_id = format!("internal:{id}");
1168        let opt = Some(Arc::clone(&store));
1169
1170        let setup = setup_worktree(&task_id, "conflict", project.path(), &opt, "test/repo")
1171            .await
1172            .unwrap();
1173        let wt = setup.work_dir.clone();
1174        git(&wt, &["config", "user.email", "t@t.com"]);
1175        git(&wt, &["config", "user.name", "T"]);
1176        std_fs::write(wt.join("f.txt"), "agent\n").unwrap();
1177        git(&wt, &["commit", "-am", "agent change"]);
1178        git(&wt, &["push", "origin", "HEAD"]);
1179
1180        // main moves on with a conflicting change, then the rebase stops on it
1181        std_fs::write(project.path().join("f.txt"), "main\n").unwrap();
1182        git(project.path(), &["commit", "-am", "main change"]);
1183        git(project.path(), &["push", "origin", "HEAD:main"]);
1184        git(&wt, &["fetch", "origin"]);
1185        let rebase = process::Command::new("git")
1186            .args(["rebase", "origin/main"])
1187            .current_dir(&wt)
1188            .output()
1189            .unwrap();
1190        assert!(!rebase.status.success(), "precondition: rebase conflicts");
1191        assert!(git_ops::rebase_in_progress(&wt).await);
1192
1193        store.increment(id, "merge_conflict_retries").await.unwrap();
1194
1195        setup_worktree(&task_id, "conflict", project.path(), &opt, "test/repo")
1196            .await
1197            .unwrap();
1198        git_ops::rebase_on_default(&wt, "main").await;
1199        assert!(
1200            git_ops::rebase_in_progress(&wt).await,
1201            "the rebase auto_merge left must survive dispatch"
1202        );
1203
1204        match prev_orch_home {
1205            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1206            Some(v) => unsafe { env::set_var("ORCH_HOME", v) },
1207            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1208            None => unsafe { env::remove_var("ORCH_HOME") },
1209        }
1210    }
1211
1212    /// When neither `origin/<branch>` nor `origin/<default_branch>` exist,
1213    /// fall back to the local default branch name as a last resort.
1214    #[tokio::test]
1215    async fn resolve_branch_start_point_falls_back_to_local_default() {
1216        let local = tempfile::tempdir().unwrap();
1217        git(local.path(), &["init"]);
1218        git(local.path(), &["config", "user.email", "t@t.com"]);
1219        git(local.path(), &["config", "user.name", "T"]);
1220        git(local.path(), &["checkout", "-b", "main"]);
1221        std_fs::write(local.path().join("README.md"), "main").unwrap();
1222        git(local.path(), &["add", "."]);
1223        git(local.path(), &["commit", "-m", "init"]);
1224
1225        let repo_root = local.path().to_str().unwrap();
1226
1227        // No remote at all → fall back to "main".
1228        let start = resolve_branch_start_point(repo_root, "no-such-branch", "main").await;
1229        assert_eq!(
1230            start, "main",
1231            "should fall back to local default_branch when no remote refs exist"
1232        );
1233    }
1234
1235    #[test]
1236    fn branch_name_basic() {
1237        let name = branch_name("42", "Fix login bug");
1238        assert_eq!(name, "gh-issue-42-fix-login-bug");
1239    }
1240
1241    #[test]
1242    fn branch_name_internal_task() {
1243        let name = branch_name("internal:8", "Fix login bug");
1244        assert_eq!(name, "internal-8-fix-login-bug");
1245    }
1246
1247    #[test]
1248    fn branch_name_special_chars() {
1249        let name = branch_name("7", "Add OAuth2/OIDC (Google & GitHub)");
1250        assert_eq!(name, "gh-issue-7-add-oauth2-oidc-google-github");
1251    }
1252
1253    #[test]
1254    fn branch_name_truncates_long_slug() {
1255        let title =
1256            "This is a very long task title that should be truncated to forty characters maximum";
1257        let name = branch_name("1", title);
1258        // slug part should be max 40 chars
1259        let slug = name.strip_prefix("gh-issue-1-").unwrap();
1260        assert!(slug.len() <= 40, "slug length {} > 40", slug.len());
1261    }
1262
1263    #[test]
1264    fn branch_name_trims_trailing_dashes() {
1265        let name = branch_name("5", "Fix bug---");
1266        assert!(
1267            !name.ends_with('-'),
1268            "branch name should not end with dash: {name}"
1269        );
1270    }
1271
1272    #[test]
1273    fn branch_name_empty_title() {
1274        let name = branch_name("99", "");
1275        assert_eq!(name, "gh-issue-99");
1276    }
1277
1278    #[test]
1279    fn branch_name_empty_title_internal() {
1280        let name = branch_name("internal:99", "");
1281        assert_eq!(name, "internal-99");
1282    }
1283
1284    #[test]
1285    fn branch_name_all_special_chars() {
1286        let name = branch_name("10", "--- ??? ---");
1287        assert_eq!(name, "gh-issue-10");
1288        assert_ne!(name, "");
1289    }
1290
1291    #[test]
1292    fn branch_name_chinese_chars_no_panic() {
1293        let name = branch_name("265", "implement 用户认证 user auth");
1294        assert!(name.starts_with("gh-issue-265-"));
1295        assert_ne!(name, "");
1296        let slug = name.strip_prefix("gh-issue-265-").unwrap();
1297        assert!(slug.len() <= 40, "slug too long: {}", slug.len());
1298        assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1299    }
1300
1301    #[test]
1302    fn task_id_from_worktree_name_parses_issue_branches() {
1303        assert_eq!(
1304            task_id_from_worktree_name("gh-issue-42-fix-login"),
1305            Some("42".to_string())
1306        );
1307        assert_eq!(
1308            task_id_from_worktree_name("gh-task-42-fix-login"),
1309            Some("42".to_string())
1310        );
1311    }
1312
1313    #[test]
1314    fn task_id_from_worktree_name_parses_internal_branches() {
1315        assert_eq!(
1316            task_id_from_worktree_name("internal-8-fix-login"),
1317            Some("internal:8".to_string())
1318        );
1319        assert_eq!(
1320            task_id_from_worktree_name("gh-issue-internal-8-fix-login"),
1321            Some("internal:8".to_string())
1322        );
1323    }
1324
1325    #[test]
1326    fn task_id_from_worktree_name_returns_none_for_unknown_names() {
1327        assert!(task_id_from_worktree_name("random-worktree").is_none());
1328    }
1329
1330    #[test]
1331    fn branch_name_emoji_no_panic() {
1332        let name = branch_name("265", "fix 🚀 deployment pipeline 🔥");
1333        assert!(name.starts_with("gh-issue-265-"));
1334        let slug = name.strip_prefix("gh-issue-265-").unwrap();
1335        assert!(slug.is_ascii(), "slug contains non-ASCII: {slug}");
1336    }
1337
1338    #[test]
1339    fn branch_name_all_non_ascii_falls_back_to_task_id() {
1340        let name = branch_name("265", "用户认证 실행 тест");
1341        assert_eq!(name, "gh-issue-265");
1342    }
1343
1344    #[tokio::test]
1345    async fn find_existing_worktree_returns_none_for_missing() {
1346        let dir = tempfile::tempdir().unwrap();
1347        assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1348    }
1349
1350    #[tokio::test]
1351    async fn find_existing_worktree_matches_prefix() {
1352        let dir = tempfile::tempdir().unwrap();
1353        let wt_dir = dir.path().join("gh-issue-42-fix-login-bug");
1354        fs::create_dir(&wt_dir).await.unwrap();
1355
1356        let result = find_existing_worktree(dir.path(), "42").await;
1357        assert_eq!(result, Some(wt_dir));
1358    }
1359
1360    #[tokio::test]
1361    async fn find_existing_worktree_matches_internal_prefix() {
1362        let dir = tempfile::tempdir().unwrap();
1363        let wt_dir = dir.path().join("internal-8-fix");
1364        fs::create_dir(&wt_dir).await.unwrap();
1365
1366        let result = find_existing_worktree(dir.path(), "internal:8").await;
1367        assert_eq!(result, Some(wt_dir));
1368    }
1369
1370    #[tokio::test]
1371    async fn find_existing_worktree_matches_legacy_gh_task_prefix() {
1372        // Old worktrees created before the rename should still be found
1373        let dir = tempfile::tempdir().unwrap();
1374        let wt_dir = dir.path().join("gh-task-internal-8-fix");
1375        fs::create_dir(&wt_dir).await.unwrap();
1376
1377        let result = find_existing_worktree(dir.path(), "internal:8").await;
1378        assert_eq!(result, Some(wt_dir));
1379    }
1380
1381    #[tokio::test]
1382    async fn find_existing_worktree_matches_legacy_external_prefix() {
1383        // Old external worktrees with gh-task- prefix should still be found
1384        let dir = tempfile::tempdir().unwrap();
1385        let wt_dir = dir.path().join("gh-task-42-fix-login-bug");
1386        fs::create_dir(&wt_dir).await.unwrap();
1387
1388        let result = find_existing_worktree(dir.path(), "42").await;
1389        assert_eq!(result, Some(wt_dir));
1390    }
1391
1392    #[tokio::test]
1393    async fn find_existing_worktree_ignores_other_tasks() {
1394        let dir = tempfile::tempdir().unwrap();
1395        fs::create_dir(dir.path().join("gh-issue-43-other-task"))
1396            .await
1397            .unwrap();
1398
1399        assert!(find_existing_worktree(dir.path(), "42").await.is_none());
1400    }
1401
1402    // --- resolve_parent_branch tests ---
1403
1404    /// No store → always returns None.
1405    #[tokio::test]
1406    async fn resolve_parent_branch_no_store_returns_none() {
1407        let dir = tempfile::tempdir().unwrap();
1408        let result = resolve_parent_branch(&None, Some(1), dir.path()).await;
1409        assert!(result.is_none());
1410    }
1411
1412    /// parent_id = None → always returns None regardless of store.
1413    #[tokio::test]
1414    async fn resolve_parent_branch_no_parent_id_returns_none() {
1415        let dir = tempfile::tempdir().unwrap();
1416        let result = resolve_parent_branch(&None, None, dir.path()).await;
1417        assert!(result.is_none());
1418    }
1419
1420    /// Parent exists but has empty branch → returns None so child gets its own branch.
1421    #[tokio::test]
1422    async fn resolve_parent_branch_empty_parent_branch_returns_none() {
1423        let db_dir = tempfile::tempdir().unwrap();
1424        let db_path = db_dir.path().join("test.db");
1425        let store = Arc::new(TaskStore::open_single(&db_path).await.unwrap());
1426
1427        // Create parent task — branch defaults to empty string
1428        let parent_id = store
1429            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1430            .await
1431            .unwrap();
1432
1433        let dir = tempfile::tempdir().unwrap();
1434        let store_opt = Some(store);
1435        let result = resolve_parent_branch(&store_opt, Some(parent_id), dir.path()).await;
1436        assert!(result.is_none());
1437    }
1438
1439    /// Parent has a branch set → child inherits that branch and worktree path.
1440    #[tokio::test]
1441    async fn resolve_parent_branch_returns_parent_branch_and_worktree() {
1442        let db_dir = tempfile::tempdir().unwrap();
1443        let db_path = db_dir.path().join("test.db");
1444        let store = Arc::new(TaskStore::open_single(&db_path).await.unwrap());
1445
1446        // Create parent task and set its branch
1447        let parent_id = store
1448            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1449            .await
1450            .unwrap();
1451        store
1452            .set_fields(
1453                parent_id,
1454                &[("branch", serde_json::json!("gh-issue-99-parent-task"))],
1455            )
1456            .await
1457            .unwrap();
1458
1459        let worktrees_base = tempfile::tempdir().unwrap();
1460        let store_opt = Some(store);
1461        let result =
1462            resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1463
1464        let (branch, wt) = result.expect("should return parent branch");
1465        assert_eq!(branch, "gh-issue-99-parent-task");
1466        assert_eq!(wt, worktrees_base.path().join("gh-issue-99-parent-task"));
1467    }
1468
1469    /// When parent has both branch and a worktree path that exists, use the worktree path.
1470    #[tokio::test]
1471    async fn resolve_parent_branch_prefers_existing_worktree_path() {
1472        let db_dir = tempfile::tempdir().unwrap();
1473        let db_path = db_dir.path().join("test.db");
1474        let store = Arc::new(TaskStore::open_single(&db_path).await.unwrap());
1475
1476        let worktrees_base = tempfile::tempdir().unwrap();
1477        // Create the parent worktree directory
1478        let parent_wt_dir = worktrees_base.path().join("custom-worktree-path");
1479        fs::create_dir_all(&parent_wt_dir).await.unwrap();
1480
1481        let parent_id = store
1482            .create_internal("owner/repo", "parent task", "", "issue", "99", None)
1483            .await
1484            .unwrap();
1485        store
1486            .set_fields(
1487                parent_id,
1488                &[
1489                    ("branch", serde_json::json!("gh-issue-99-parent-task")),
1490                    (
1491                        "worktree",
1492                        serde_json::json!(parent_wt_dir.to_string_lossy().as_ref()),
1493                    ),
1494                ],
1495            )
1496            .await
1497            .unwrap();
1498
1499        let store_opt = Some(store);
1500        let result =
1501            resolve_parent_branch(&store_opt, Some(parent_id), worktrees_base.path()).await;
1502
1503        let (branch, wt) = result.expect("should return parent branch");
1504        assert_eq!(branch, "gh-issue-99-parent-task");
1505        assert_eq!(wt, parent_wt_dir);
1506    }
1507
1508    /// Regression test: concurrent calls to list_project_worktrees and
1509    /// validate_worktree_gitdir should not starve the Tokio worker thread
1510    /// pool. This was broken by blocking Path::exists/is_dir calls in async
1511    /// functions (issue #2467).
1512    #[tokio::test]
1513    async fn concurrent_worktree_checks_no_starvation() {
1514        let temp_dir = tempfile::tempdir().unwrap();
1515        let worktrees_base = temp_dir.path().join("worktrees");
1516        fs::create_dir_all(&worktrees_base).await.unwrap();
1517
1518        // Create a fake worktree with valid gitdir
1519        let wt_dir = worktrees_base.join("gh-issue-123-test");
1520        fs::create_dir_all(&wt_dir).await.unwrap();
1521        // Create .git as a file pointing to a real directory (worktree link)
1522        let gitdir_target = temp_dir
1523            .path()
1524            .join(".git")
1525            .join("worktrees")
1526            .join("gh-issue-123-test");
1527        fs::create_dir_all(&gitdir_target).await.unwrap();
1528        let git_file = wt_dir.join(".git");
1529        fs::write(&git_file, format!("gitdir: {}\n", gitdir_target.display()))
1530            .await
1531            .unwrap();
1532
1533        // Spawn many concurrent tasks - if any use blocking calls, they'll
1534        // block the worker thread and cause timeout or deadlock.
1535        let handles: Vec<_> = (0..20)
1536            .map(|i| {
1537                let wb = worktrees_base.clone();
1538                let wd = wt_dir.clone();
1539                tokio::spawn(async move {
1540                    if i % 2 == 0 {
1541                        list_project_worktrees(&wb).await.ok();
1542                    } else {
1543                        validate_worktree_gitdir(&wd).await;
1544                    }
1545                })
1546            })
1547            .collect();
1548
1549        // Wait with timeout to detect starvation
1550        let results = time::timeout(Duration::from_secs(10), async {
1551            future::join_all(handles).await
1552        })
1553        .await;
1554
1555        assert!(
1556            results.is_ok(),
1557            "concurrent worktree checks timed out (starvation?)"
1558        );
1559    }
1560
1561    // --- normalize_branch_name tests ---
1562
1563    #[test]
1564    fn normalize_branch_name_strips_origin_prefix() {
1565        assert_eq!(
1566            normalize_branch_name("origin/main"),
1567            Some("main".to_string())
1568        );
1569        assert_eq!(
1570            normalize_branch_name("origin/develop"),
1571            Some("develop".to_string())
1572        );
1573    }
1574
1575    #[test]
1576    fn normalize_branch_name_strips_full_ref_path() {
1577        assert_eq!(
1578            normalize_branch_name("refs/remotes/origin/main"),
1579            Some("main".to_string())
1580        );
1581        assert_eq!(
1582            normalize_branch_name("refs/remotes/origin/develop"),
1583            Some("develop".to_string())
1584        );
1585    }
1586
1587    #[test]
1588    fn normalize_branch_name_strips_remotes_prefix() {
1589        assert_eq!(
1590            normalize_branch_name("remotes/origin/main"),
1591            Some("main".to_string())
1592        );
1593    }
1594
1595    #[test]
1596    fn normalize_branch_name_passes_through_plain_branch() {
1597        assert_eq!(normalize_branch_name("main"), Some("main".to_string()));
1598        assert_eq!(normalize_branch_name("master"), Some("master".to_string()));
1599        assert_eq!(
1600            normalize_branch_name("feature/foo"),
1601            Some("feature/foo".to_string())
1602        );
1603    }
1604
1605    #[test]
1606    fn normalize_branch_name_rejects_empty_and_refs_only() {
1607        assert_eq!(normalize_branch_name(""), None);
1608        assert_eq!(normalize_branch_name("   "), None);
1609        assert_eq!(
1610            normalize_branch_name("refs/heads/main"),
1611            Some("main".to_string())
1612        );
1613    }
1614}