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