Skip to main content

orch/engine/
cleanup.rs

1//! Worktree cleanup and merged-PR detection.
2//!
3//! Contains the post-merge cleanup pipeline: removing git worktrees,
4//! deleting local/remote branches, pulling main, and detecting
5//! already-merged PRs so their tasks can be marked done.
6
7use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
8use crate::cmd::CommandErrorContext;
9use crate::engine::tasks::TaskManager;
10use crate::store;
11use crate::store::TaskStatus;
12use crate::store::TaskStore;
13use crate::store::store_log_activity;
14use dashmap::{DashMap, DashSet};
15use futures::future;
16use std::collections::HashMap;
17use std::collections::HashSet;
18use std::env;
19use std::fs as std_fs;
20use std::io::ErrorKind;
21use std::path::Path;
22use std::path::PathBuf;
23use std::process::Output;
24use std::sync::Arc;
25use std::sync::LazyLock;
26use std::time::SystemTime;
27use std::time::{Duration, Instant};
28use tokio::fs;
29use tokio::process::Command;
30use tokio::time::sleep;
31use tokio::time::timeout;
32
33use super::sync::ReviewTaskSnapshot;
34use crate::config;
35use crate::engine;
36use crate::engine::auto_merge;
37use crate::engine::runner::cow;
38use crate::engine::runner::git_ops;
39use crate::engine::runner::worktree;
40use crate::github::http::GhHttp;
41use crate::home;
42
43/// Reconciliation calls can be slow on repos with many issues or during GitHub instability.
44/// Cleanup runs in a background task (tokio::spawn) so it does not block the main tick loop.
45/// 30 s gives enough headroom for paginated list_all_tasks calls while still bounding the
46/// overall background-task duration.
47const RECONCILIATION_LIST_TIMEOUT: Duration = Duration::from_secs(30);
48/// Closed-issue reconciliation is a best-effort cleanup fallback, not a per-sync requirement.
49/// Run it on a slower cadence so repeated GitHub slowness does not consume 30 s every sync.
50const RECONCILIATION_INTERVAL: Duration = Duration::from_mins(15);
51
52/// Per-repo in-flight guard for closed-issue reconciliation/cleanup.
53/// Prevents overlapping cleanup runs from piling up across sync cycles.
54static CLEANUP_IN_FLIGHT: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
55/// Per-repo reconciliation cadence tracker.
56static RECONCILIATION_LAST_RUN: LazyLock<DashMap<String, Instant>> = LazyLock::new(DashMap::new);
57
58struct CleanupInFlightGuard {
59    repo: String,
60}
61
62impl CleanupInFlightGuard {
63    fn try_acquire(repo: &str) -> Option<Self> {
64        if CLEANUP_IN_FLIGHT.insert(repo.to_string()) {
65            Some(Self {
66                repo: repo.to_string(),
67            })
68        } else {
69            None
70        }
71    }
72}
73
74impl Drop for CleanupInFlightGuard {
75    fn drop(&mut self) {
76        CLEANUP_IN_FLIGHT.remove(&self.repo);
77    }
78}
79
80fn should_run_reconciliation(repo: &str) -> bool {
81    let now = Instant::now();
82    if let Some(mut last_run) = RECONCILIATION_LAST_RUN.get_mut(repo) {
83        if last_run.elapsed() < RECONCILIATION_INTERVAL {
84            return false;
85        }
86        *last_run = now;
87        true
88    } else {
89        RECONCILIATION_LAST_RUN.insert(repo.to_string(), now);
90        true
91    }
92}
93
94/// Options controlling the worktree janitor.
95#[derive(Debug, Clone)]
96pub struct JanitorOptions {
97    /// Minimum age (in hours) of the worktree directory before it is eligible for removal.
98    ///
99    /// A worktree whose filesystem mtime is more recent than this threshold is skipped
100    /// even if the task is already in a terminal state. This provides a safety buffer
101    /// against races (e.g. review agent still writing when the janitor runs).
102    ///
103    /// Default: 24.  Configurable via `workflow.worktree_janitor_ttl_hours` in config.
104    pub ttl_hours: u64,
105
106    /// When `true`, log what would be done but skip all destructive operations.
107    ///
108    /// Configurable via `workflow.worktree_janitor_dry_run: true` in config.
109    pub dry_run: bool,
110}
111
112impl Default for JanitorOptions {
113    fn default() -> Self {
114        Self {
115            ttl_hours: 24,
116            dry_run: false,
117        }
118    }
119}
120
121impl JanitorOptions {
122    /// Read options from the global config, falling back to defaults.
123    pub fn from_config() -> Self {
124        let ttl_hours = config::get("workflow.worktree_janitor_ttl_hours")
125            .ok()
126            .and_then(|v| v.parse::<u64>().ok())
127            .unwrap_or(24);
128
129        let dry_run = config::get("workflow.worktree_janitor_dry_run").is_ok_and(|v| v == "true");
130
131        Self { ttl_hours, dry_run }
132    }
133}
134
135/// Cleanup worktrees for completed tasks.
136///
137/// Targets tasks that are finished: status:done OR closed on GitHub
138/// (e.g. when a PR merge auto-closes the issue via "Fixes #N").
139///
140/// Note: This is a fallback. Primary cleanup happens inline in
141/// `auto_merge_pr` via `cleanup_task_worktree`. This catches edge cases
142/// where the inline cleanup missed (e.g., manual merges, auto-closed issues).
143pub(crate) async fn cleanup_done_worktrees(
144    backend: &Arc<dyn ExternalBackend>,
145    repo: &str,
146    task_manager: &Arc<TaskManager>,
147    store: &Arc<TaskStore>,
148) -> anyhow::Result<()> {
149    let opts = JanitorOptions::from_config();
150    cleanup_done_worktrees_with_opts(backend, repo, task_manager, store, &opts).await
151}
152
153async fn reconcile_closed_tasks(
154    task_ids: &mut Vec<String>,
155    all_tasks: &[ExternalTask],
156    task_manager: &TaskManager,
157) {
158    const MAX_RECONCILE_PER_CYCLE: usize = 10;
159
160    let done_set: HashSet<String> = task_ids.iter().cloned().collect();
161    let closed_not_in_store: Vec<_> = all_tasks
162        .iter()
163        .filter(|t| t.state == "closed" && !done_set.contains(&t.id.0))
164        .collect();
165
166    // Split: issues that already have status:done label just need worktree
167    // cleanup (no API call needed), vs those needing label reconciliation.
168    let mut already_labeled: Vec<String> = Vec::new();
169    let mut needs_reconcile: Vec<&ExternalTask> = Vec::new();
170    for task in &closed_not_in_store {
171        if task.labels.iter().any(|l| l == "status:done") {
172            already_labeled.push(task.id.0.clone());
173        } else {
174            needs_reconcile.push(task);
175        }
176    }
177
178    // Add already-labeled issues directly (no API call).
179    if !already_labeled.is_empty() {
180        tracing::debug!(
181            count = already_labeled.len(),
182            "closed issues already labeled status:done — adding to cleanup set"
183        );
184        task_ids.extend(already_labeled);
185    }
186
187    // Reconcile issues missing the done label — cap to avoid rate-limit
188    // exhaustion. Remaining issues will be picked up in subsequent cycles.
189    if needs_reconcile.len() > MAX_RECONCILE_PER_CYCLE {
190        tracing::info!(
191            total = needs_reconcile.len(),
192            batch = MAX_RECONCILE_PER_CYCLE,
193            "capping closed-issue reconciliation to avoid rate-limit exhaustion"
194        );
195    }
196    let mut futs = Vec::new();
197    for task in needs_reconcile.iter().take(MAX_RECONCILE_PER_CYCLE) {
198        let id = task.id.clone();
199        let task_id_str = task.id.0.clone();
200        let labels = task.labels.clone();
201        futs.push(async move {
202            tracing::info!(
203                task_id = task_id_str,
204                labels = ?labels,
205                "closed issue with stale status label — reconciling to done and cleaning up"
206            );
207            match task_manager.update_task_status(&id, Status::Done).await {
208                Ok(()) => Some(task_id_str),
209                Err(e) => {
210                    tracing::warn!(
211                        task_id = task_id_str,
212                        err = %e,
213                        "failed to reconcile closed issue status to done"
214                    );
215                    None
216                }
217            }
218        });
219    }
220    let results = future::join_all(futs).await;
221    task_ids.extend(results.into_iter().flatten());
222}
223
224/// Cleanup worktrees for completed tasks with explicit options.
225///
226/// Separated from `cleanup_done_worktrees` so that integration tests can
227/// inject specific options (e.g. `ttl_hours: 0`, `dry_run: true`) without
228/// touching the global config.
229pub(crate) async fn cleanup_done_worktrees_with_opts(
230    backend: &Arc<dyn ExternalBackend>,
231    repo: &str,
232    task_manager: &Arc<TaskManager>,
233    store: &Arc<TaskStore>,
234    opts: &JanitorOptions,
235) -> anyhow::Result<()> {
236    let Some(_guard) = CleanupInFlightGuard::try_acquire(repo) else {
237        tracing::debug!(
238            repo = %repo,
239            "cleanup already in progress for repo — skipping overlapping run"
240        );
241        return Ok(());
242    };
243
244    // Read done tasks from the store first; fall back to backend before first sync.
245    let done_tasks = task_manager.list_all_by_status(Status::Done).await?;
246    tracing::debug!(count = done_tasks.len(), "checking done tasks for cleanup");
247
248    // Collect task IDs from external done tasks.
249    let mut task_ids: Vec<String> = done_tasks.iter().map(|t| t.id.0.clone()).collect();
250
251    // Also include closed-but-not-done external tasks.
252    // When a PR merge auto-closes a GitHub issue (via "Fixes #N"), the issue
253    // state becomes "closed" but orch never updates the status label to "done".
254    // These orphaned worktrees accumulate unless we catch them here.
255    // Note: This still queries the backend because GitHub `state` (open/closed)
256    // is backend-specific and not tracked in the store.
257    //
258    // Use list_reconciliation_candidates() (closed issues only, bounded recent window) directly.
259    // list_all_tasks() fetches every issue ever created (state=all, all pages) and
260    // consistently times out on repos with many issues — it is not appropriate for
261    // a cleanup fallback path.
262    if should_run_reconciliation(repo) {
263        match timeout(
264            RECONCILIATION_LIST_TIMEOUT,
265            backend.list_reconciliation_candidates(),
266        )
267        .await
268        {
269            Ok(Ok(candidates)) if !candidates.is_empty() => {
270                tracing::debug!(
271                    count = candidates.len(),
272                    "reconciling closed issues from candidates"
273                );
274                reconcile_closed_tasks(&mut task_ids, &candidates, task_manager).await;
275            }
276            Ok(Ok(_)) => {
277                tracing::debug!("no reconciliation candidates found");
278            }
279            Ok(Err(e)) => {
280                tracing::warn!(err = %e, "failed to list reconciliation candidates");
281            }
282            Err(_) => {
283                tracing::warn!(
284                    timeout_secs = RECONCILIATION_LIST_TIMEOUT.as_secs(),
285                    retry_after_secs = RECONCILIATION_INTERVAL.as_secs(),
286                    "timed out listing reconciliation candidates"
287                );
288            }
289        }
290    } else {
291        tracing::debug!(
292            retry_after_secs = RECONCILIATION_INTERVAL.as_secs(),
293            "skipping closed-issue reconciliation until interval elapses"
294        );
295    }
296
297    // Also include internal done tasks.
298    if let Ok(internal_done) = task_manager.list_internal_by_status(TaskStatus::Done).await {
299        task_ids.extend(internal_done.iter().map(|t| t.id.0.clone()));
300    }
301
302    tracing::debug!(
303        count = task_ids.len(),
304        "checking all terminal tasks for cleanup"
305    );
306
307    if let Err(e) = auto_merge::sweep_ci_timer_keys(store, repo, &task_ids).await {
308        tracing::warn!(err = %e, "failed to sweep CI timer keys");
309    }
310
311    // Prefetch all cleaned external IDs in one query instead of N+1 per-task lookups.
312    // NOTE: This is now primarily a hint to skip processing, NOT a correctness
313    // guarantee — tasks with external_id NOT in the store at all (or mismatched
314    // repo) will still be cleaned by iterating all worktree directories.
315    let already_cleaned = store.cleaned_external_ids(repo).await.unwrap_or_default();
316
317    let mut cleaned_any = false;
318    for task_id in &task_ids {
319        // Skip if already cleaned (checked via prefetched set).
320        if already_cleaned.contains(task_id) {
321            continue;
322        }
323
324        match cleanup_task_worktree_with_opts(task_id, repo, store, opts).await {
325            Ok(true) => cleaned_any = true,
326            Ok(false) => {
327                // Skipped (TTL guard, tmux guard, or nothing to remove) — do not
328                // pull main; we did not change the repo state.
329            }
330            Err(e) => {
331                tracing::warn!(task_id, err = %e, "worktree cleanup failed for task");
332            }
333        }
334    }
335
336    // ── Orphaned worktree scanner ─────────────────────────────────────────────
337    //
338    // Even if every task in our list was already cleaned (or had no worktree),
339    // there may be worktree directories on disk that are not owned by any task.
340    // This happens when a task was ingested into the store but its worktree_cleaned
341    // flag was never set — e.g. because the task was never in `list_all_by_status`
342    // (unknown repo / missing from store at cleanup time), or the cleanup skipped
343    // due to TTL guard and never retried.
344    //
345    // We scan all worktree directories in this repo's worktrees subdirectory and
346    // remove any that are not referenced by an existing task. This catches the
347    // "done but worktree not cleaned" case that `orch service doctor` reports.
348    let worktrees_base = match home::worktrees_dir() {
349        Ok(base) => Some(base),
350        Err(e) => {
351            tracing::debug!(err = %e, "worktrees_dir() unavailable — skipping orphan scan");
352            None
353        }
354    };
355
356    if let Some(base) = worktrees_base {
357        let project = repo
358            .rsplit('/')
359            .next()
360            .unwrap_or(repo)
361            .trim_end_matches(".git");
362        let repo_worktrees = base.join(project);
363
364        // Build set of all known worktree paths for this repo (all statuses).
365        let mut combined: HashSet<String> = HashSet::new();
366        if let Ok(tasks) = store.list_all(repo).await {
367            for t in tasks {
368                if !t.worktree.is_empty() {
369                    combined.insert(t.worktree);
370                }
371            }
372        }
373
374        // Also include active worktrees (in case task is in-memory but has a dir).
375        if let Ok(project_dir) = resolve_project_dir_for_repo(repo)
376            && let Ok(active_wts) = worktree::list_project_worktrees(&project_dir).await
377        {
378            for wt in active_wts {
379                combined.insert(wt.to_string_lossy().to_string());
380            }
381        }
382
383        // Scan all branch directories and remove orphaned ones.
384        if let Ok(mut entries) = fs::read_dir(&repo_worktrees).await {
385            while let Ok(Some(entry)) = entries.next_entry().await {
386                let Ok(entry_meta) = entry.metadata().await else {
387                    continue;
388                };
389                if !entry_meta.is_dir() {
390                    continue;
391                }
392                let wt_path = entry.path().to_string_lossy().to_string();
393                if combined.contains(&wt_path) {
394                    continue;
395                }
396                tracing::info!(worktree = %wt_path, "found orphaned worktree — attempting cleanup");
397                match resolve_repo_root_for_orphaned_worktree(&entry.path()).await {
398                    Ok(repo_root) => {
399                        let removed = remove_worktree_and_branch(
400                            "orphan-scan",
401                            &entry.path(),
402                            None,
403                            Path::new(&repo_root),
404                            false,
405                        )
406                        .await;
407                        if removed {
408                            tracing::info!(worktree = %wt_path, "orphaned worktree removed");
409                        } else {
410                            tracing::warn!(worktree = %wt_path, "orphaned worktree could not be removed");
411                        }
412                    }
413                    Err(e) => {
414                        tracing::warn!(worktree = %wt_path, err = %e, "could not resolve repo root for orphaned worktree");
415                    }
416                }
417            }
418        }
419    }
420
421    // Prune stale worktree metadata and pull main after cleanup.
422    if cleaned_any
423        && !opts.dry_run
424        && let Ok(repo_root) = resolve_repo_root(repo).await
425    {
426        const GIT_TIMEOUT: Duration = Duration::from_secs(60);
427
428        // Prune stale .git/worktrees/ entries whose directories no longer exist.
429        let prune_result = timeout(
430            GIT_TIMEOUT,
431            Command::new("git")
432                .args(["-C", &repo_root, "worktree", "prune"])
433                .output_with_context(),
434        )
435        .await;
436        match prune_result {
437            Ok(Ok(output)) if output.status.success() => {
438                tracing::debug!(%repo, "pruned stale worktree metadata");
439            }
440            Ok(Ok(output)) => {
441                let stderr = String::from_utf8_lossy(&output.stderr);
442                tracing::warn!(%repo, err = %stderr, "git worktree prune failed");
443            }
444            Ok(Err(e)) => {
445                tracing::warn!(%repo, err = %e, "git worktree prune failed");
446            }
447            Err(_) => {
448                tracing::warn!(%repo, "git worktree prune timed out after 60s");
449            }
450        }
451
452        // Fetch instead of pull: refresh `origin/*` refs without touching
453        // the operator's checked-out branch or working tree. `pull` would
454        // operate on whatever HEAD happens to be (often a feature branch,
455        // not main) and would silently no-op on divergence.
456        let fetch_result = timeout(
457            GIT_TIMEOUT,
458            git_ops::git()
459                .await
460                .args(["-C", &repo_root, "fetch", "--prune", "origin"])
461                .output_with_context(),
462        )
463        .await;
464        match fetch_result {
465            Ok(Ok(output)) if output.status.success() => {
466                tracing::info!(%repo, "fetched origin after worktree cleanup");
467            }
468            Ok(Ok(output)) => {
469                let stderr = String::from_utf8_lossy(&output.stderr);
470                tracing::debug!(%repo, err = %stderr, "git fetch skipped after cleanup");
471            }
472            Ok(Err(e)) => {
473                tracing::debug!(%repo, err = %e, "git fetch failed after cleanup");
474            }
475            Err(_) => {
476                tracing::debug!(%repo, "git fetch timed out after 60s");
477            }
478        }
479    }
480
481    Ok(())
482}
483
484/// Cleanup a single task's worktree and branches.
485///
486/// Removes the git worktree, deletes local + remote branches,
487/// fetches origin to refresh remote tracking refs, and marks the task as cleaned.
488///
489/// This function is used for inline post-merge cleanup (called from
490/// auto-merge flows) and must attempt immediate removal — it constructs
491/// janitor options with `ttl_hours = 0` so the janitor age guard does not
492/// postpone removing freshly-created worktrees.
493pub(crate) async fn cleanup_task_worktree(
494    task_id: &str,
495    repo: &str,
496    store: &Arc<TaskStore>,
497) -> anyhow::Result<()> {
498    let opts = JanitorOptions {
499        ttl_hours: 0,
500        ..Default::default()
501    };
502    let cleaned = cleanup_task_worktree_with_opts(task_id, repo, store, &opts).await?;
503
504    // Fetch origin once after inline cleanup (post-merge single-task path).
505    // Only fetch if cleanup actually happened (not skipped due to active session).
506    // Fetch (not pull) so the operator's checked-out branch and working tree
507    // are never touched — see the matching comment in cleanup_orphaned_worktrees.
508    if cleaned && let Ok(repo_root) = resolve_repo_root(repo).await {
509        const GIT_TIMEOUT: Duration = Duration::from_secs(60);
510        let fetch_result = timeout(
511            GIT_TIMEOUT,
512            git_ops::git()
513                .await
514                .args(["-C", &repo_root, "fetch", "--prune", "origin"])
515                .output_with_context(),
516        )
517        .await;
518        match fetch_result {
519            Ok(Ok(output)) if output.status.success() => {
520                tracing::debug!(%repo, "fetched origin after task cleanup");
521            }
522            Ok(Ok(output)) => {
523                let stderr = String::from_utf8_lossy(&output.stderr);
524                tracing::debug!(%repo, err = %stderr, "git fetch skipped after task cleanup");
525            }
526            Ok(Err(e)) => {
527                tracing::debug!(%repo, err = %e, "git fetch failed after task cleanup");
528            }
529            Err(_) => {
530                tracing::debug!(%repo, "git fetch timed out after 60s");
531            }
532        }
533    }
534
535    Ok(())
536}
537
538/// Cleanup a single task's worktree and branches with explicit janitor options.
539///
540/// Returns `Ok(true)` when cleanup was actually performed and the task was
541/// marked as cleaned.  Returns `Ok(false)` when cleanup was intentionally
542/// skipped (TTL guard, active tmux session guard, or nothing on disk to remove).
543/// Returns `Err` on unexpected failures (e.g. cannot resolve the repo root).
544pub(crate) async fn cleanup_task_worktree_with_opts(
545    task_id: &str,
546    repo: &str,
547    store: &Arc<TaskStore>,
548    opts: &JanitorOptions,
549) -> anyhow::Result<bool> {
550    let (worktree, branch, keep_remote_branch, merged, external_id, pr_merged) =
551        store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
552            .await
553            .map_or((None, None, false, false, None, false), |t| {
554                // Blocked tasks with a PR should keep their remote branch so the
555                // PR stays open for human review.
556                let keep = t.status == TaskStatus::Blocked && t.pr_number.is_some();
557                let merged = t.status == TaskStatus::Done;
558                (
559                    Some(t.worktree),
560                    Some(t.branch),
561                    keep,
562                    merged,
563                    t.external_id,
564                    // Done means the PR merged, and a squash leaves the branch commits on no ref
565                    merged && t.pr_number.is_some(),
566                )
567            });
568
569    let worktree_path = worktree.as_ref().map(PathBuf::from);
570
571    // Get worktrees base path
572    let worktrees_base = home::worktrees_dir().unwrap_or_else(|_| PathBuf::from(".orch/worktrees"));
573
574    // Determine which directory to remove.
575    //
576    // Priority:
577    //   1. stored "worktree" path, if it exists on disk.
578    //   2. Construct from worktrees_base + project name + branch, if it exists.
579    let worktree_to_remove = if let Some(ref wt) = worktree_path {
580        if match fs::try_exists(wt).await {
581            Ok(true) => true,
582            Ok(false) => false,
583            Err(e) => {
584                tracing::warn!(task_id, worktree = %wt.display(), err = %e, "try_exists failed on stored worktree path — trying branch-based fallback");
585                false
586            }
587        } {
588            Some(wt.clone())
589        } else {
590            // The recorded worktree path doesn't exist — try reconstructing from branch.
591            if let Some(ref b) = branch {
592                let project = repo
593                    .rsplit('/')
594                    .next()
595                    .unwrap_or(repo)
596                    .trim_end_matches(".git");
597                let candidate = worktrees_base.join(project).join(b);
598                if match fs::try_exists(&candidate).await {
599                    Ok(true) => true,
600                    Ok(false) => false,
601                    Err(e) => {
602                        tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
603                        false
604                    }
605                } {
606                    Some(candidate)
607                } else {
608                    None
609                }
610            } else {
611                None
612            }
613        }
614    } else if let Some(ref b) = branch {
615        // No worktree path in store — try branch-based path.
616        let project = repo
617            .rsplit('/')
618            .next()
619            .unwrap_or(repo)
620            .trim_end_matches(".git");
621        let candidate = worktrees_base.join(project).join(b);
622        if match fs::try_exists(&candidate).await {
623            Ok(true) => true,
624            Ok(false) => false,
625            Err(e) => {
626                tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
627                false
628            }
629        } {
630            Some(candidate)
631        } else {
632            None
633        }
634    } else {
635        None
636    };
637
638    let mut did_clean = false;
639
640    // If there's nothing to remove (no worktree path and no branch),
641    // treat as a no-op and return Ok(false) without attempting to
642    // resolve the repo root. Resolving the repo root can fail for
643    // projects not registered in config and that should not make
644    // cleanup a hard error when there is nothing to do.
645    let branch_nonempty = branch.as_ref().filter(|b| !b.is_empty());
646    if worktree_to_remove.is_none() && branch_nonempty.is_none() {
647        // If the stored worktree path is non-empty but doesn't exist on disk,
648        // the worktree is already gone — mark it cleaned so we stop retrying.
649        if worktree_path
650            .as_ref()
651            .is_some_and(|p| !p.as_os_str().is_empty())
652        {
653            tracing::debug!(
654                task_id,
655                worktree = ?worktree_path,
656                "stored worktree path no longer exists on disk — marking cleaned"
657            );
658            if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await
659                && let Err(e) = store.mark_cleaned(store_id).await
660            {
661                tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned in store — will retry");
662            }
663        }
664        return Ok(false);
665    }
666
667    if let Some(wt) = worktree_to_remove {
668        // TTL guard: skip if the worktree directory is too young.
669        if let Some(age_hours) = worktree_age_hours(&wt).await
670            && age_hours < opts.ttl_hours
671        {
672            tracing::debug!(
673                task_id,
674                worktree = %wt.display(),
675                age_hours,
676                ttl_hours = opts.ttl_hours,
677                "worktree too young for cleanup, skipping"
678            );
679            return Ok(false);
680        }
681
682        // Tmux guard: skip if any active pane still has its cwd inside this worktree.
683        if is_worktree_in_active_session(&wt).await {
684            tracing::warn!(
685                task_id,
686                worktree = %wt.display(),
687                "worktree is referenced by an active tmux session — skipping cleanup"
688            );
689            return Ok(false);
690        }
691
692        // Validate git metadata before attempting any git operations.
693        // If the .git file points to a deleted gitdir the worktree is
694        // unrecoverable — force-remove the directory and skip git commands.
695        let gitdir_valid = worktree::validate_worktree_gitdir(&wt).await;
696
697        if opts.dry_run {
698            tracing::info!(
699                task_id,
700                worktree = %wt.display(),
701                branch = ?branch,
702                gitdir_valid,
703                "[dry-run] would remove worktree and branch"
704            );
705        } else if !gitdir_valid {
706            tracing::warn!(
707                task_id,
708                worktree = %wt.display(),
709                "worktree has broken git metadata — force-removing directory without git operations"
710            );
711            match fs::remove_dir_all(&wt).await {
712                Ok(()) => {
713                    did_clean = true;
714                }
715                Err(e) => {
716                    tracing::warn!(
717                        task_id,
718                        worktree = %wt.display(),
719                        err = %e,
720                        "failed to force-remove broken worktree directory"
721                    );
722                }
723            }
724        } else {
725            tracing::info!(task_id, worktree = %wt.display(), "removing worktree");
726            // Derive repo root from the worktree itself (for cross-project support).
727            // If the worktree belongs to a different project than the current repo
728            // context (e.g., internal task with worktree in another project), we can
729            // still find the repo root by examining the worktree's .git file.
730            let repo_root = match resolve_repo_root_from_worktree(&wt).await {
731                Ok(root) => root,
732                Err(_) => resolve_repo_root(repo).await?,
733            };
734            if let Some(br) = branch.as_deref().filter(|b| !b.is_empty())
735                && !pr_merged
736                && !rescue_and_notify(
737                    task_id,
738                    repo,
739                    external_id.as_deref(),
740                    Some(&wt),
741                    br,
742                    Path::new(&repo_root),
743                )
744                .await
745            {
746                return Ok(false);
747            }
748            if merged {
749                cow::warm_from_merged(
750                    Path::new(&repo_root),
751                    &worktree::project_name(Path::new(&repo_root)),
752                    &wt,
753                )
754                .await;
755            }
756            let removed = remove_worktree_and_branch_unchecked(
757                task_id,
758                &wt,
759                branch.as_deref(),
760                Path::new(&repo_root),
761                keep_remote_branch,
762            )
763            .await;
764            if removed {
765                did_clean = true;
766            } else {
767                tracing::warn!(
768                    task_id,
769                    worktree = %wt.display(),
770                    "worktree directory still exists after removal attempt — not marking as cleaned"
771                );
772            }
773        }
774    } else if let Some(ref br) = branch {
775        // Worktree directory is already gone, but the branch may still exist
776        // on the remote. Delete it to avoid orphaned branches.
777        if !opts.dry_run && !keep_remote_branch {
778            tracing::debug!(task_id, branch = %br, "no worktree on disk, cleaning up branch only");
779            // Resolve repo root lazily — branch cleanup needs it.
780            let repo_root = resolve_repo_root(repo).await?;
781            if !pr_merged
782                && !rescue_and_notify(
783                    task_id,
784                    repo,
785                    external_id.as_deref(),
786                    None,
787                    br,
788                    Path::new(&repo_root),
789                )
790                .await
791            {
792                return Ok(false);
793            }
794            delete_branches(task_id, br, Path::new(&repo_root)).await;
795            did_clean = true;
796        }
797    }
798
799    if did_clean {
800        // Mark as cleaned in store so we don't retry next tick.
801        // If resolve_task_id returns None the task is not (or no longer) in
802        // the store; that is fine — we already did the on-disk work.
803        if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await
804            && let Err(e) = store.mark_cleaned(store_id).await
805        {
806            tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned — will retry");
807            // Don't log activity; retry next tick will do the actual cleanup
808            return Ok(did_clean);
809        }
810        store_log_activity(
811            &Some(Arc::clone(store)),
812            repo,
813            task_id,
814            "branch_delete",
815            None,
816            None,
817            None,
818            None,
819            Some(&serde_json::json!({
820                "worktree": worktree_path.map(|p| p.display().to_string()),
821                "branch": branch,
822                "keep_remote_branch": keep_remote_branch,
823            })),
824        )
825        .await;
826    }
827
828    Ok(did_clean)
829}
830
831/// Result of saving a task branch's unpushed work before cleanup deletes it
832#[derive(Debug, PartialEq, Eq)]
833pub(crate) enum Rescue {
834    /// Every commit is on a remote ref already
835    Clean,
836    /// The commits were pushed to `rescue/<branch>`
837    Pushed {
838        branch: String,
839        commits: Vec<String>,
840    },
841    /// Git failed, so the worktree and branch must stay
842    Failed,
843}
844
845async fn git_in(dir: &Path, args: &[&str]) -> Option<Output> {
846    git_ops::git()
847        .await
848        .arg("-C")
849        .arg(dir)
850        .args(args)
851        .output_with_context()
852        .await
853        .ok()
854}
855
856/**
857 * Cleanup deletes the worktree and the local branch, which can be the only refs to commits the
858 * agent made before its run ended without a push (#3999). Uncommitted changes are committed
859 * first, then everything not on a remote ref goes to `rescue/<branch>`
860 **/
861pub(crate) async fn rescue_unpushed_work(
862    task_id: &str,
863    wt: Option<&Path>,
864    br: &str,
865    repo_root: &Path,
866) -> Rescue {
867    if let Some(wt) = wt
868        && fs::try_exists(wt.join(".git")).await.unwrap_or(false)
869    {
870        let Some(status) = git_in(wt, &["status", "--porcelain"]).await else {
871            tracing::warn!(task_id, branch = %br, "cannot read worktree status, keeping worktree");
872            return Rescue::Failed;
873        };
874        if !status.status.success() {
875            tracing::warn!(task_id, branch = %br, "git status failed, keeping worktree");
876            return Rescue::Failed;
877        }
878        if !status.stdout.is_empty() {
879            let committed = git_in(wt, &["add", "-A"])
880                .await
881                .is_some_and(|o| o.status.success())
882                && git_in(
883                    wt,
884                    &[
885                        "-c",
886                        "user.name=orch",
887                        "-c",
888                        "user.email=orch@localhost",
889                        "commit",
890                        "-m",
891                        "rescue: uncommitted changes saved by orch cleanup",
892                    ],
893                )
894                .await
895                .is_some_and(|o| o.status.success());
896            if !committed {
897                tracing::warn!(task_id, branch = %br, "cannot commit uncommitted changes, keeping worktree");
898                return Rescue::Failed;
899            }
900        }
901    }
902
903    let local_ref = format!("refs/heads/{br}");
904    let exists = git_in(repo_root, &["rev-parse", "--verify", "--quiet", &local_ref])
905        .await
906        .is_some_and(|o| o.status.success());
907    if !exists {
908        return Rescue::Clean;
909    }
910    // Commits on the local default branch survive the branch delete, so they do not count
911    let default_ref = format!(
912        "refs/heads/{}",
913        worktree::detect_default_branch(repo_root).await
914    );
915    // A bare clone has no remote-tracking refs, so ask the remote for the branch tip
916    let remote_tip = git_in(repo_root, &["ls-remote", "origin", &local_ref])
917        .await
918        .filter(|o| o.status.success())
919        .and_then(|o| {
920            String::from_utf8_lossy(&o.stdout)
921                .split_whitespace()
922                .next()
923                .map(str::to_string)
924        });
925    let mut args = vec!["rev-list", "--oneline", &local_ref, "--not", "--remotes"];
926    if let Some(tip) = remote_tip.as_deref() {
927        args.push(tip);
928    }
929    let default_exists = default_ref != local_ref
930        && git_in(
931            repo_root,
932            &["rev-parse", "--verify", "--quiet", &default_ref],
933        )
934        .await
935        .is_some_and(|o| o.status.success());
936    if default_exists {
937        args.push(&default_ref);
938    }
939    let Some(list) = git_in(repo_root, &args)
940        .await
941        .filter(|o| o.status.success())
942    else {
943        tracing::warn!(task_id, branch = %br, "cannot list unpushed commits, keeping worktree and branch");
944        return Rescue::Failed;
945    };
946    let commits: Vec<String> = String::from_utf8_lossy(&list.stdout)
947        .lines()
948        .map(str::to_string)
949        .collect();
950    if commits.is_empty() {
951        return Rescue::Clean;
952    }
953
954    let rescue = format!("rescue/{br}");
955    let refspec = format!("{local_ref}:refs/heads/{rescue}");
956    let pushed = git_in(
957        repo_root,
958        &["-c", "lfs.locksverify=false", "push", "origin", &refspec],
959    )
960    .await;
961    if pushed.is_some_and(|o| o.status.success()) {
962        tracing::warn!(task_id, branch = %br, rescue = %rescue, commits = commits.len(), "pushed unpushed commits to rescue branch");
963        Rescue::Pushed {
964            branch: rescue,
965            commits,
966        }
967    } else {
968        tracing::warn!(task_id, branch = %br, rescue = %rescue, "rescue push failed, keeping worktree and branch");
969        Rescue::Failed
970    }
971}
972
973fn rescue_comment(rescue_branch: &str, commits: &[String]) -> String {
974    let list = commits
975        .iter()
976        .map(|c| format!("- `{c}`"))
977        .collect::<Vec<_>>()
978        .join("\n");
979    format!(
980        "Cleanup found commits on this task's branch that were never pushed. They are saved on `{rescue_branch}`:\n\n{list}"
981    )
982}
983
984/// Save unpushed work and tell the task about it. Returns false when cleanup must not go on
985async fn rescue_and_notify(
986    task_id: &str,
987    repo: &str,
988    external_id: Option<&str>,
989    wt: Option<&Path>,
990    br: &str,
991    repo_root: &Path,
992) -> bool {
993    match rescue_unpushed_work(task_id, wt, br, repo_root).await {
994        Rescue::Clean => true,
995        Rescue::Failed => false,
996        Rescue::Pushed { branch, commits } => {
997            if let Some(number) = external_id.filter(|n| n.parse::<u64>().is_ok()) {
998                match GhHttp::new() {
999                    Ok(gh) => {
1000                        if let Err(e) = gh
1001                            .add_comment(repo, number, &rescue_comment(&branch, &commits))
1002                            .await
1003                        {
1004                            tracing::warn!(task_id, err = %e, "failed to comment on rescue branch");
1005                        }
1006                    }
1007                    Err(e) => {
1008                        tracing::warn!(task_id, err = %e, "cannot comment on rescue branch");
1009                    }
1010                }
1011            }
1012            true
1013        }
1014    }
1015}
1016
1017/// Remove a git worktree directory and its local + remote branches.
1018/// When `keep_remote_branch` is true, only the worktree and local branch are
1019/// removed — the remote branch stays so that any linked PR remains open
1020/// (used for blocked tasks awaiting human review).
1021/// Returns `true` if the worktree directory is gone after the call
1022/// (either we removed it or it was already absent).
1023pub(crate) async fn remove_worktree_and_branch(
1024    task_id: &str,
1025    wt: &Path,
1026    branch: Option<&str>,
1027    repo_root: &Path,
1028    keep_remote_branch: bool,
1029) -> bool {
1030    if let Some(br) = branch.filter(|b| !b.is_empty())
1031        && rescue_unpushed_work(task_id, Some(wt), br, repo_root).await == Rescue::Failed
1032    {
1033        return false;
1034    }
1035
1036    remove_worktree_and_branch_unchecked(task_id, wt, branch, repo_root, keep_remote_branch).await
1037}
1038
1039/// The caller has already rescued the branch or knows its PR merged
1040async fn remove_worktree_and_branch_unchecked(
1041    task_id: &str,
1042    wt: &Path,
1043    branch: Option<&str>,
1044    repo_root: &Path,
1045    keep_remote_branch: bool,
1046) -> bool {
1047    let wt_str = wt.to_string_lossy().to_string();
1048    let repo_root_str = repo_root.to_string_lossy();
1049
1050    // Remove worktree FIRST, then delete the branch.
1051    // Git refuses to remove a worktree if its branch is already deleted.
1052    let remove_result = Command::new("git")
1053        .args([
1054            "-C",
1055            repo_root_str.as_ref(),
1056            "worktree",
1057            "remove",
1058            &wt_str,
1059            "--force",
1060        ])
1061        .output_with_context()
1062        .await;
1063
1064    match remove_result {
1065        Ok(output) if output.status.success() => {
1066            const MAX_RETRIES: usize = 3;
1067            const RETRY_DELAY_MS: u64 = 2000;
1068            tracing::info!(task_id, "worktree removed (git metadata)");
1069            // `git worktree remove` only removes git metadata — the physical directory
1070            // with working files may remain. Clean it up with retry logic, since macOS
1071            // sometimes needs a moment to release file handles from a recently-exited
1072            // process. Retrying with a delay addresses "Directory not empty" errors.
1073            let mut removed = false;
1074            for attempt in 0..MAX_RETRIES {
1075                match fs::remove_dir_all(wt).await {
1076                    Ok(()) => {
1077                        removed = true;
1078                        break;
1079                    }
1080                    Err(e) if e.kind() == ErrorKind::NotFound => {
1081                        // Directory was already removed by git or another cleanup pass.
1082                        removed = true;
1083                        break;
1084                    }
1085                    Err(e) if e.kind() == ErrorKind::DirectoryNotEmpty => {
1086                        if attempt < MAX_RETRIES - 1 {
1087                            tracing::debug!(
1088                                task_id,
1089                                path = %wt.display(),
1090                                attempt = attempt + 1,
1091                                "worktree directory not empty — retrying after delay"
1092                            );
1093                            sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
1094                        } else {
1095                            tracing::warn!(
1096                                task_id,
1097                                path = %wt.display(),
1098                                err = %e,
1099                                "failed to remove worktree directory after {} attempts (Directory not empty)",
1100                                MAX_RETRIES
1101                            );
1102                        }
1103                    }
1104                    Err(e) => {
1105                        tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
1106                        break;
1107                    }
1108                }
1109            }
1110            if removed {
1111                tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
1112            } else {
1113                tracing::warn!(
1114                    task_id,
1115                    path = %wt.display(),
1116                    "worktree directory still present after git worktree remove"
1117                );
1118            }
1119        }
1120        Ok(output) => {
1121            let stderr = String::from_utf8_lossy(&output.stderr);
1122            // "is not a working tree" means git's metadata is stale — the path
1123            // exists on disk but is no longer registered as a worktree. Treat
1124            // this as an already-gone worktree: physically remove the directory
1125            // and prune stale git metadata. Downgrade to debug — this is an
1126            // expected idempotency case, not a real error.
1127            if stderr.contains("is not a working tree") {
1128                tracing::debug!(
1129                    task_id,
1130                    path = %wt.display(),
1131                    "path is not a registered worktree (stale metadata) — removing directory and pruning"
1132                );
1133                if let Err(e) = fs::remove_dir_all(wt).await {
1134                    // Only warn if the directory actually still exists after removal attempt.
1135                    if match fs::try_exists(wt).await {
1136                        Ok(true) => true,
1137                        Ok(false) => false,
1138                        Err(e) => {
1139                            tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
1140                            false
1141                        }
1142                    } {
1143                        tracing::warn!(
1144                            task_id,
1145                            path = %wt.display(),
1146                            err = %e,
1147                            "failed to remove stale worktree directory"
1148                        );
1149                    }
1150                } else {
1151                    tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
1152                }
1153                // Prune stale worktree entries from git metadata so the warning
1154                // stops recurring on future `git worktree list` / cleanup cycles.
1155                if let Err(e) = Command::new("git")
1156                    .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
1157                    .output_with_context()
1158                    .await
1159                {
1160                    tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
1161                }
1162            } else {
1163                tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
1164            }
1165        }
1166        Err(e) => {
1167            tracing::warn!(task_id, err = %e, "failed to remove worktree");
1168        }
1169    }
1170
1171    // Prune stale .git/worktrees/ entries left behind after removal.
1172    if let Err(e) = Command::new("git")
1173        .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
1174        .output_with_context()
1175        .await
1176    {
1177        tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
1178    }
1179
1180    // Delete local and remote branch from the main repo root (worktree is already gone).
1181    // When keep_remote_branch is set, only delete the local branch — the remote stays
1182    // so any linked PR remains open for human review.
1183    if let Some(br) = branch {
1184        if keep_remote_branch {
1185            delete_local_branch(task_id, br, repo_root).await;
1186        } else {
1187            delete_branches(task_id, br, repo_root).await;
1188        }
1189    }
1190
1191    // Definitively check: is the worktree directory actually gone?
1192    match fs::try_exists(wt).await {
1193        Ok(false) => true, // confirmed gone
1194        Ok(true) => false, // still there
1195        Err(e) => {
1196            tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
1197            false // conservative: don't claim success
1198        }
1199    }
1200}
1201
1202/// Commits reachable from `rev` and from none of `others`. `None` when git cannot answer
1203async fn commits_outside(repo_root: &Path, rev: &str, others: &[&str]) -> Option<u64> {
1204    let mut args = vec![
1205        "rev-list".to_string(),
1206        "--count".to_string(),
1207        rev.to_string(),
1208    ];
1209    for other in others {
1210        let exists = Command::new("git")
1211            .args([
1212                "-C",
1213                &repo_root.to_string_lossy(),
1214                "rev-parse",
1215                "--verify",
1216                "--quiet",
1217                other,
1218            ])
1219            .output_with_context()
1220            .await
1221            .is_ok_and(|o| o.status.success());
1222        if exists {
1223            args.push("--not".to_string());
1224            args.push(other.to_string());
1225        }
1226    }
1227    let output = Command::new("git")
1228        .arg("-C")
1229        .arg(repo_root)
1230        .args(&args)
1231        .output_with_context()
1232        .await
1233        .ok()?;
1234    if !output.status.success() {
1235        return None;
1236    }
1237    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
1238}
1239
1240/// Delete the local branch only when origin or the default branch holds every commit on it
1241async fn delete_local_branch(task_id: &str, br: &str, repo_root: &Path) {
1242    let default_branch = worktree::detect_default_branch(repo_root).await;
1243    let remote_ref = format!("refs/remotes/origin/{br}");
1244    let default_ref = format!("refs/remotes/origin/{default_branch}");
1245    let local_ref = format!("refs/heads/{br}");
1246    let local_default_ref = format!("refs/heads/{default_branch}");
1247    let rescue_ref = format!("refs/remotes/origin/rescue/{br}");
1248    match commits_outside(
1249        repo_root,
1250        &local_ref,
1251        &[&remote_ref, &default_ref, &local_default_ref, &rescue_ref],
1252    )
1253    .await
1254    {
1255        Some(0) => {}
1256        Some(n) => {
1257            tracing::warn!(task_id, branch = %br, unpushed = n, "local branch holds commits origin does not have, keeping it");
1258            return;
1259        }
1260        None => {
1261            tracing::debug!(task_id, branch = %br, "local branch missing or unreadable, nothing to delete");
1262            return;
1263        }
1264    }
1265    let output = Command::new("git")
1266        .args(["-C", &repo_root.to_string_lossy(), "branch", "-D", br])
1267        .output_with_context()
1268        .await;
1269    match output {
1270        Ok(o) if o.status.success() => {
1271            tracing::info!(task_id, branch = %br, "local branch deleted");
1272        }
1273        Ok(o) => {
1274            let stderr = String::from_utf8_lossy(&o.stderr);
1275            tracing::debug!(task_id, err = %stderr, "local branch delete skipped (may not exist)");
1276        }
1277        Err(e) => {
1278            tracing::warn!(task_id, err = %e, "failed to delete local branch");
1279        }
1280    }
1281}
1282
1283/**
1284 * Delete local and remote branches for a task, but never the only copy of a commit.
1285 * The remote branch goes only when the default branch already contains all of it, which
1286 * covers fast-forward and merge-commit merges. A squash-merged branch stays, and GitHub's
1287 * "automatically delete head branches" removes it. Every other caller (startup rebase
1288 * failure, closed-issue reconciliation, orphan sweeps) may hold unmerged work (#3781)
1289 **/
1290pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &Path) {
1291    let default_branch = worktree::detect_default_branch(repo_root).await;
1292    let _ = git_ops::git()
1293        .await
1294        .args([
1295            "-C",
1296            &repo_root.to_string_lossy(),
1297            "fetch",
1298            "origin",
1299            &default_branch,
1300            br,
1301        ])
1302        .output_with_context()
1303        .await;
1304
1305    delete_local_branch(task_id, br, repo_root).await;
1306
1307    let remote_ref = format!("refs/remotes/origin/{br}");
1308    let default_ref = format!("refs/remotes/origin/{default_branch}");
1309    match commits_outside(repo_root, &remote_ref, &[&default_ref]).await {
1310        Some(0) => {}
1311        Some(n) => {
1312            tracing::info!(task_id, branch = %br, unmerged = n, "remote branch has commits the default branch lacks, keeping it");
1313            return;
1314        }
1315        None => {
1316            tracing::debug!(task_id, branch = %br, "remote branch missing or unreadable, nothing to delete");
1317            return;
1318        }
1319    }
1320
1321    let remote_delete = git_ops::git()
1322        .await
1323        .args([
1324            "-C",
1325            &repo_root.to_string_lossy(),
1326            "-c",
1327            "lfs.locksverify=false",
1328            "push",
1329            "origin",
1330            "--delete",
1331            br,
1332        ])
1333        .output_with_context()
1334        .await;
1335
1336    match remote_delete {
1337        Ok(output) if output.status.success() => {
1338            tracing::info!(task_id, branch = %br, "remote branch deleted");
1339            git_ops::clear_push_lease(repo_root, br).await;
1340        }
1341        Ok(output) => {
1342            let stderr = String::from_utf8_lossy(&output.stderr);
1343            tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1344        }
1345        Err(e) => {
1346            tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1347        }
1348    }
1349}
1350
1351/// Returns `true` if any active tmux pane has its cwd inside `worktree`.
1352///
1353/// Uses `tmux list-panes -a -F '#{pane_current_path}'` to enumerate all
1354/// pane working directories across every session. If tmux is not running
1355/// (command fails), we conservatively return `false` so cleanup can proceed.
1356async fn is_worktree_in_active_session(worktree: &Path) -> bool {
1357    let output = Command::new("tmux")
1358        .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1359        .output()
1360        .await;
1361
1362    match output {
1363        Ok(o) if o.status.success() => {
1364            let worktree_str = worktree.to_string_lossy();
1365            String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1366                let pane_path = line.trim();
1367                // Match exact path or any path that starts with worktree + separator.
1368                pane_path == worktree_str.as_ref()
1369                    || pane_path.starts_with(&format!("{worktree_str}/"))
1370            })
1371        }
1372        // tmux not running or no sessions — treat as safe to clean.
1373        _ => false,
1374    }
1375}
1376
1377/// Returns how many hours old the worktree directory is, based on its mtime.
1378///
1379/// Returns `None` if the directory does not exist or the mtime cannot be read.
1380async fn worktree_age_hours(worktree: &Path) -> Option<u64> {
1381    // Use tokio::fs to avoid blocking the async runtime when reading metadata.
1382    let metadata = fs::metadata(worktree).await.ok()?;
1383    let modified = metadata.modified().ok()?;
1384    let age = SystemTime::now().duration_since(modified).ok()?;
1385    Some(age.as_secs() / 3600)
1386}
1387
1388/// Resolve the repo root for an orphaned worktree and remove it.
1389///
1390/// Used by `orch service doctor --fix` and `orch service prune` to clean up worktrees that are
1391/// no longer owned by any task. Returns `true` if the worktree directory is gone
1392/// after the call.
1393pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &Path) -> bool {
1394    let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1395        Ok(root) => root,
1396        Err(e) => {
1397            tracing::warn!(
1398                worktree = %wt.display(),
1399                error = %e,
1400                "cannot resolve repo root for orphaned worktree"
1401            );
1402            return false;
1403        }
1404    };
1405    remove_worktree_and_branch(task_id, wt, None, Path::new(&repo_root), false).await
1406}
1407
1408/// Resolve the main git repository root for an orphaned worktree.
1409///
1410/// First tries to parse the `.git` file's `gitdir:` pointer (two levels up from
1411/// the pointer gives the bare repo root, which is the orch convention). Falls back
1412/// to looking up registered projects by directory name when the `.git` file is
1413/// absent or unparseable.
1414pub(crate) async fn resolve_repo_root_for_orphaned_worktree(wt: &Path) -> anyhow::Result<String> {
1415    let git_file = wt.join(".git");
1416    if fs::try_exists(&git_file).await.unwrap_or(false)
1417        && let Ok(content) = fs::read_to_string(&git_file).await
1418    {
1419        for line in content.lines() {
1420            if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1421                // gitdir points to: <repo>/.git/worktrees/<name> (non-bare)
1422                //                or <repo>.git/worktrees/<name>   (bare)
1423                // Two levels up reaches the .git dir / bare repo root.
1424                let gitdir_path = PathBuf::from(gitdir);
1425                if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1426                    return Ok(repo_root.display().to_string());
1427                }
1428            }
1429        }
1430    }
1431
1432    // Fallback: look up registered projects by the project-name component of the
1433    // worktree path (format: ~/.orch/worktrees/<project>/<branch>).
1434    let worktrees_dir = home::worktrees_dir()?;
1435    if let Ok(relative) = wt.strip_prefix(&worktrees_dir)
1436        && let Some(project_name) = relative.components().next()
1437    {
1438        let project_name = project_name.as_os_str().to_string_lossy();
1439
1440        if let Ok(paths) = config::get_project_paths() {
1441            for path_str in &paths {
1442                let path = Path::new(path_str);
1443                if let Some(name) = path.file_name()
1444                    && name.to_string_lossy() == project_name
1445                {
1446                    return Ok(path_str.clone());
1447                }
1448            }
1449        }
1450
1451        // Try bare clone at ~/.orch/projects/<owner>/<project>.git
1452        let parts: Vec<&str> = project_name.split('/').collect();
1453        let bare = if parts.len() == 2 {
1454            home::projects_dir()
1455                .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1456                .unwrap_or_default()
1457        } else {
1458            return Err(anyhow::anyhow!(
1459                "cannot determine owner for single-segment project name '{project_name}' — \
1460                     bare repo location is ambiguous; register the project with 'owner/repo' format"
1461            ));
1462        };
1463        if fs::try_exists(&bare).await.unwrap_or(false) {
1464            return Ok(bare.display().to_string());
1465        }
1466    }
1467
1468    anyhow::bail!(
1469        "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1470        wt.display()
1471    )
1472}
1473
1474/// Resolve the main git repository root from a worktree's .git file.
1475///
1476/// For cross-project worktree cleanup, we cannot rely on `resolve_repo_root` because
1477/// the worktree may belong to a different project than the current repo context.
1478/// Instead, we extract the repo root from the worktree's `.git` file which contains
1479/// a `gitdir:` pointer to the main repo's git directory.
1480pub async fn resolve_repo_root_from_worktree(wt: &Path) -> anyhow::Result<String> {
1481    // For worktrees, .git is a file (not a directory) containing gitdir path
1482    let git_file = wt.join(".git");
1483    if !fs::try_exists(&git_file).await.unwrap_or(false) {
1484        anyhow::bail!(".git file not found in worktree at {}", wt.display());
1485    }
1486
1487    let content = fs::read_to_string(&git_file)
1488        .await
1489        .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1490
1491    // Parse gitdir line: "gitdir: /path/to/repo/.git/worktrees/<name>"
1492    for line in content.lines() {
1493        if let Some(gitdir) = line.strip_prefix("gitdir:") {
1494            let gitdir = gitdir.trim();
1495            // The main repo is the parent of the .git directory
1496            // gitdir points to: <repo>/.git/worktrees/<name>
1497            // We need to go up 3 levels: worktrees/<name> -> .git -> repo
1498            let gitdir_path = Path::new(gitdir);
1499            if let Some(repo_root) = gitdir_path
1500                .parent() // worktrees/<name>
1501                .and_then(|p| p.parent()) // .git
1502                .and_then(|p| p.parent())
1503            // repo root
1504            {
1505                return Ok(repo_root.display().to_string());
1506            }
1507        }
1508    }
1509
1510    anyhow::bail!(
1511        "cannot resolve repo root from worktree .git file at {}",
1512        wt.display()
1513    )
1514}
1515
1516/// Resolve the main git repository root path for a project.
1517///
1518/// Looks up the local project path from config, then verifies it's a git repo.
1519/// This avoids relying on cwd (which is undefined under launchd services).
1520pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1521    // Look up the local path from registered projects
1522    let paths = config::get_project_paths().unwrap_or_default();
1523    for path_str in &paths {
1524        let path = Path::new(path_str);
1525        let orch_yml = path.join(".orch.yml");
1526        let legacy = path.join(".orchestrator.yml");
1527        let config_file = if fs::try_exists(&orch_yml).await.unwrap_or(false) {
1528            orch_yml
1529        } else if fs::try_exists(&legacy).await.unwrap_or(false) {
1530            legacy
1531        } else {
1532            continue;
1533        };
1534        if let Ok(content) = fs::read_to_string(&config_file).await
1535            && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1536            && let Some(r) = doc
1537                .get("gh")
1538                .and_then(|gh| gh.get("repo"))
1539                .and_then(|r| r.as_str())
1540            && r == repo
1541        {
1542            return Ok(path_str.clone());
1543        }
1544    }
1545
1546    // Fallback: try bare clone in ~/.orch/projects/<owner>/<repo>.git
1547    let parts: Vec<&str> = repo.split('/').collect();
1548    let bare = if parts.len() == 2 {
1549        home::projects_dir()
1550            .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1551            .unwrap_or_default()
1552    } else {
1553        PathBuf::new()
1554    };
1555    if fs::try_exists(&bare).await.unwrap_or(false) {
1556        return Ok(bare.display().to_string());
1557    }
1558
1559    anyhow::bail!(
1560        "cannot find local path for project {repo} — \
1561         checked {} registered project(s) and bare clone at {}",
1562        paths.len(),
1563        bare.display()
1564    )
1565}
1566
1567/// Resolve the project directory for a given repo string.
1568///
1569/// Replicates the logic from `TaskRunner::resolve_project_dir()` without requiring
1570/// a Runner instance. Used by the orphan scanner to enumerate active worktrees.
1571fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<PathBuf> {
1572    // Check PROJECT_DIR env var first
1573    if let Ok(dir) = env::var("PROJECT_DIR")
1574        && !dir.is_empty()
1575    {
1576        return Ok(PathBuf::from(dir));
1577    }
1578
1579    // Look up registered projects in config
1580    if let Ok(paths) = config::get_project_paths() {
1581        for path_str in &paths {
1582            let path = Path::new(path_str);
1583            let orch_yml = path.join(".orch.yml");
1584            let legacy = path.join(".orchestrator.yml");
1585            let config_file = if Path::new(&orch_yml).exists() {
1586                orch_yml
1587            } else if Path::new(&legacy).exists() {
1588                legacy
1589            } else {
1590                continue;
1591            };
1592            if let Ok(content) = std_fs::read_to_string(&config_file)
1593                && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1594                && let Some(r) = doc
1595                    .get("gh")
1596                    .and_then(|gh| gh.get("repo"))
1597                    .and_then(|r| r.as_str())
1598                && r == repo
1599            {
1600                return Ok(path_str.clone().into());
1601            }
1602        }
1603    }
1604
1605    anyhow::bail!("project dir not found for repo: {repo}")
1606}
1607
1608/// Check for merged PRs and update task status accordingly.
1609///
1610/// Queries status:in_review and status:needs_review tasks, checks if their PR
1611/// is merged, and updates status to done if merged.
1612pub(crate) async fn check_merged_prs(
1613    backend: &Arc<dyn ExternalBackend>,
1614    repo: &str,
1615    store: &Arc<TaskStore>,
1616    task_manager: &Arc<TaskManager>,
1617    in_review_tasks: &[ReviewTaskSnapshot],
1618    needs_review_tasks: &[ReviewTaskSnapshot],
1619) -> anyhow::Result<()> {
1620    let mut branch_map: HashMap<String, String> = HashMap::new();
1621    let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1622        .iter()
1623        .chain(needs_review_tasks.iter())
1624        .collect();
1625
1626    for task in &all_review_tasks {
1627        if !task.stored.branch.is_empty() {
1628            branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1629        }
1630    }
1631
1632    tracing::debug!(
1633        count = all_review_tasks.len(),
1634        "checking review tasks for merged PRs"
1635    );
1636
1637    // Collect (task_id, branch) pairs — skip tasks with no branch recorded.
1638    // Use the pre-built branch map; fall back to store lookup only for tasks
1639    // not in the map (e.g. internal tasks, backend fallback path).
1640    let mut task_branches: Vec<(String, String)> = Vec::new();
1641    for task in all_review_tasks {
1642        let task_id = &task.external.id.0;
1643        let branch = if let Some(b) = branch_map.get(task_id) {
1644            b.clone()
1645        } else {
1646            match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1647                .await
1648                .map(|t| t.branch)
1649            {
1650                Some(b) if !b.is_empty() => b,
1651                _ => {
1652                    tracing::debug!(task_id, "no branch info, skipping PR check");
1653                    continue;
1654                }
1655            }
1656        };
1657        task_branches.push((task_id.clone(), branch));
1658    }
1659
1660    if task_branches.is_empty() {
1661        return Ok(());
1662    }
1663
1664    // Single GraphQL call for all branches — N REST calls → 1 GraphQL call.
1665    let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1666    let merged_map = match backend.batch_is_pr_merged(&branches).await {
1667        Ok(map) => map,
1668        Err(e) => {
1669            tracing::warn!(
1670                err = %e,
1671                branch_count = branches.len(),
1672                "batch PR merge check failed, retrying affected branches individually"
1673            );
1674            fallback_is_pr_merged_by_branch(backend, &branches).await
1675        }
1676    };
1677
1678    for (task_id, branch) in task_branches {
1679        let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1680        if !is_merged {
1681            continue;
1682        }
1683
1684        tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1685
1686        let id = ExternalId(task_id.clone());
1687        if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1688            tracing::warn!(task_id, err = %e, "failed to update status to done");
1689            continue;
1690        }
1691
1692        if let Err(e) = cleanup_task_worktree(&task_id, repo, store).await {
1693            tracing::warn!(task_id, err = %e, "worktree cleanup after merge failed");
1694        }
1695
1696        let comment = format!("PR merged, marking task complete{}", engine::orch_footer());
1697        if let Err(e) = backend.post_comment(&id, &comment).await {
1698            tracing::warn!(task_id, err = %e, "failed to post comment");
1699        }
1700    }
1701
1702    Ok(())
1703}
1704
1705async fn fallback_is_pr_merged_by_branch(
1706    backend: &Arc<dyn ExternalBackend>,
1707    branches: &[String],
1708) -> HashMap<String, bool> {
1709    let futs: Vec<_> = branches
1710        .iter()
1711        .map(|branch| {
1712            let backend = Arc::clone(backend);
1713            let branch = branch.clone();
1714            async move {
1715                match backend.is_pr_merged(&branch).await {
1716                    Ok(is_merged) => Some((branch, is_merged)),
1717                    Err(err) => {
1718                        tracing::warn!(
1719                            branch,
1720                            err = %err,
1721                            "fallback PR merge check failed for branch"
1722                        );
1723                        None
1724                    }
1725                }
1726            }
1727        })
1728        .collect();
1729
1730    future::join_all(futs).await.into_iter().flatten().collect()
1731}
1732
1733#[cfg(test)]
1734mod tests {
1735    use super::*;
1736    use crate::engine::tasks;
1737    use crate::store::{NewTask, TaskStore};
1738    use async_trait::async_trait;
1739    use std::collections::HashMap;
1740    use std::process;
1741    use std::sync::Mutex;
1742    use std::sync::atomic::{AtomicUsize, Ordering};
1743
1744    // worktree_age_hours
1745
1746    #[tokio::test]
1747    async fn worktree_age_hours_returns_none_for_missing_dir() {
1748        let tmp = tempfile::tempdir().unwrap();
1749        let missing = tmp.path().join("does-not-exist");
1750        assert!(worktree_age_hours(&missing).await.is_none());
1751    }
1752
1753    #[tokio::test]
1754    async fn worktree_age_hours_returns_some_for_existing_dir() {
1755        let tmp = tempfile::tempdir().unwrap();
1756        // A freshly created directory should have age ~0 hours.
1757        let age = worktree_age_hours(tmp.path()).await;
1758        assert!(age.is_some());
1759        assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1760    }
1761
1762    // JanitorOptions::from_config defaults
1763
1764    #[test]
1765    fn janitor_options_default_values() {
1766        let opts = JanitorOptions::default();
1767        assert_eq!(opts.ttl_hours, 24);
1768        assert!(!opts.dry_run);
1769    }
1770
1771    // cleanup_task_worktree_with_opts TTL guard (skips if git missing)
1772
1773    /// Create a minimal git repo + worktree; return (tmp, worktree_dir).
1774    fn setup_test_repo() -> Option<(tempfile::TempDir, PathBuf)> {
1775        let tmp = tempfile::tempdir().ok()?;
1776
1777        // Create a real git repo (not bare) so we can add worktrees.
1778        let repo_dir = tmp.path().join("repo.git");
1779        std_fs::create_dir_all(&repo_dir).ok()?;
1780
1781        let init = process::Command::new("git")
1782            .args([
1783                "init",
1784                "--initial-branch=main",
1785                repo_dir
1786                    .to_str()
1787                    .expect("test repo path contains non-UTF-8 characters"),
1788            ])
1789            .output()
1790            .ok()?;
1791        if !init.status.success() {
1792            // Older git may not have --initial-branch; fall back.
1793            let _ = process::Command::new("git")
1794                .args([
1795                    "init",
1796                    repo_dir
1797                        .to_str()
1798                        .expect("test repo path contains non-UTF-8 characters"),
1799                ])
1800                .output()
1801                .ok()?;
1802        }
1803
1804        // Create an initial commit so the repo has a HEAD.
1805        let readme = repo_dir.join("README.md");
1806        std_fs::write(&readme, "# test").ok()?;
1807        process::Command::new("git")
1808            .args(["-C", repo_dir.to_str()?, "add", "."])
1809            .output()
1810            .ok()?;
1811        process::Command::new("git")
1812            .args([
1813                "-C",
1814                repo_dir.to_str()?,
1815                "-c",
1816                "user.email=test@test.com",
1817                "-c",
1818                "user.name=Test",
1819                "-c",
1820                "commit.gpgsign=false",
1821                "commit",
1822                "--allow-empty",
1823                "-m",
1824                "init",
1825            ])
1826            .output()
1827            .ok()?;
1828
1829        // Create a worktree branch.
1830        let wt_dir = tmp
1831            .path()
1832            .join("worktrees")
1833            .join("repo.git")
1834            .join("gh-issue-42-test");
1835        std_fs::create_dir_all(wt_dir.parent()?).ok()?;
1836        let wt_out = process::Command::new("git")
1837            .args([
1838                "-C",
1839                repo_dir.to_str()?,
1840                "worktree",
1841                "add",
1842                "-b",
1843                "gh-issue-42-test",
1844                wt_dir.to_str()?,
1845            ])
1846            .output()
1847            .ok()?;
1848
1849        if !wt_out.status.success() {
1850            return None;
1851        }
1852
1853        Some((tmp, wt_dir))
1854    }
1855
1856    #[tokio::test]
1857    async fn janitor_skips_young_worktree() {
1858        let Some((tmp, wt_dir)) = setup_test_repo() else {
1859            eprintln!("skipping test: git not available");
1860            return;
1861        };
1862
1863        // The worktree was just created → age is 0h < ttl_hours=24 → must be skipped.
1864        assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1865
1866        // Run the TTL guard check directly (without a full engine).
1867        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1868        assert!(
1869            age < 24,
1870            "freshly created worktree should be younger than 24h (age={age}h)"
1871        );
1872
1873        // Explicitly verify: with ttl_hours=24 the worktree stays.
1874        let opts = JanitorOptions {
1875            ttl_hours: 24,
1876            dry_run: true,
1877        };
1878        // Age < ttl → should skip (worktree_age_hours < opts.ttl_hours).
1879        assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1880
1881        drop(tmp); // cleanup tempdir
1882    }
1883
1884    #[tokio::test]
1885    async fn janitor_eligible_when_ttl_zero() {
1886        let Some((tmp, wt_dir)) = setup_test_repo() else {
1887            eprintln!("skipping test: git not available");
1888            return;
1889        };
1890
1891        // With ttl_hours=0 even a brand-new worktree is eligible.
1892        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1893        let opts = JanitorOptions {
1894            ttl_hours: 0,
1895            dry_run: true,
1896        };
1897        assert!(
1898            age >= opts.ttl_hours,
1899            "worktree should be eligible when ttl=0 (age={age}h)"
1900        );
1901
1902        drop(tmp);
1903    }
1904
1905    #[tokio::test]
1906    async fn dry_run_does_not_remove_worktree() {
1907        // Verify that with dry_run=true the worktree directory is NOT removed.
1908        let Some((tmp, wt_dir)) = setup_test_repo() else {
1909            eprintln!("skipping test: git not available");
1910            return;
1911        };
1912
1913        assert!(wt_dir.exists(), "worktree must exist before test");
1914
1915        // With dry_run=true and ttl_hours=0 the janitor would normally remove the
1916        // worktree, but dry_run prevents it.
1917        let opts = JanitorOptions {
1918            ttl_hours: 0,
1919            dry_run: true,
1920        };
1921
1922        // The janitor needs a repo_root to run git commands.  We point it at the
1923        // bare repo we created inside tmp (even though it's not registered in config).
1924        // Because resolve_repo_root will fail for our synthetic repo slug, we test the
1925        // inner helpers directly here.
1926        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1927        let in_use = false; // no tmux running in tests
1928        let skip = age < opts.ttl_hours || in_use;
1929
1930        if !skip && opts.dry_run {
1931            // dry-run: log only, don't touch filesystem.
1932            tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1933        } else if !skip {
1934            // This branch would do actual removal — but we're in dry_run mode above.
1935        }
1936
1937        // Worktree must still be there.
1938        assert!(
1939            wt_dir.exists(),
1940            "dry-run must not remove the worktree directory"
1941        );
1942
1943        drop(tmp);
1944    }
1945
1946    #[test]
1947    fn fallback_branch_path_construction() {
1948        // Verify the new fallback logic: when worktree path is absent but
1949        // branch is known, we should construct the path correctly.
1950        let tmp = tempfile::tempdir().unwrap();
1951        let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1952        std_fs::create_dir_all(&wt_dir).unwrap();
1953
1954        // Simulate: worktree=None, branch=Some("gh-issue-7-fix"), repo="owner/myrepo"
1955        let branch = "gh-issue-7-fix";
1956        let repo = "owner/myrepo";
1957        let worktrees_base = tmp.path();
1958
1959        let project = repo
1960            .rsplit('/')
1961            .next()
1962            .unwrap_or(repo)
1963            .trim_end_matches(".git");
1964        let candidate = worktrees_base.join(project).join(branch);
1965
1966        assert_eq!(candidate, wt_dir);
1967        assert!(
1968            candidate.exists(),
1969            "fallback path should point to existing dir"
1970        );
1971    }
1972
1973    /// A task with no worktree and no branch should return Ok(false) —
1974    /// nothing was cleaned, so git pull should NOT be triggered.
1975    #[tokio::test]
1976    async fn cleanup_returns_false_when_nothing_to_clean() {
1977        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1978
1979        // Create a done task with no worktree or branch fields set
1980        let id = store
1981            .create(&NewTask {
1982                external_id: Some("999".to_string()),
1983                repo: "owner/repo".to_string(),
1984                origin: "github".to_string(),
1985                title: "Already cleaned".to_string(),
1986                ..Default::default()
1987            })
1988            .await
1989            .unwrap();
1990        store.update_status(id, TaskStatus::Done).await.unwrap();
1991
1992        let opts = JanitorOptions {
1993            ttl_hours: 0,
1994            dry_run: false,
1995        };
1996
1997        let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
1998        // resolve_repo_root will fail for synthetic repo — that's OK, the point is
1999        // that when nothing needs cleaning the function should not report true.
2000        // In production this means no git pull is triggered.
2001        if let Ok(cleaned) = result {
2002            assert!(
2003                !cleaned,
2004                "cleanup should return false when there is no worktree or branch to clean"
2005            );
2006        } else {
2007            // resolve_repo_root fails for test repo — acceptable.
2008            // The bug was that even with no worktree/branch, Ok(true) was returned.
2009        }
2010    }
2011
2012    /// When the stored worktree path no longer exists on disk and there is no
2013    /// branch recorded, cleanup must mark the task as cleaned so subsequent
2014    /// runs do not keep retrying (idempotency regression test for #1021).
2015    #[tokio::test]
2016    async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
2017        let tmp = tempfile::tempdir().unwrap();
2018        // A path that does NOT exist on disk.
2019        let stale_wt = tmp.path().join("stale-worktree");
2020        assert!(!stale_wt.exists(), "stale path must not exist");
2021
2022        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2023
2024        let id = store
2025            .create(&NewTask {
2026                external_id: Some("1005".to_string()),
2027                repo: "owner/repo".to_string(),
2028                origin: "github".to_string(),
2029                title: "Stale worktree task".to_string(),
2030                ..Default::default()
2031            })
2032            .await
2033            .unwrap();
2034        store.update_status(id, TaskStatus::Done).await.unwrap();
2035        // Set the stale worktree path on the task.
2036        store
2037            .set_fields(
2038                id,
2039                &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
2040            )
2041            .await
2042            .unwrap();
2043
2044        // Verify the task starts out NOT cleaned.
2045        let before = store.get(id).await.unwrap();
2046        assert!(!before.worktree_cleaned, "task should not be cleaned yet");
2047        assert!(
2048            !before.worktree.is_empty(),
2049            "task must have a worktree path set"
2050        );
2051
2052        let opts = JanitorOptions {
2053            ttl_hours: 0,
2054            dry_run: false,
2055        };
2056
2057        let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
2058
2059        // The function should succeed (Ok) — stale path is not an error.
2060        // It returns Ok(false) because there was nothing to actively remove.
2061        match result {
2062            Ok(_) => {}
2063            Err(e) => panic!("cleanup should not error for stale path: {e}"),
2064        }
2065
2066        // The task must now be marked cleaned so the next cycle skips it.
2067        let after = store.get(id).await.unwrap();
2068        assert!(
2069            after.worktree_cleaned,
2070            "task must be marked cleaned when stored worktree path is already gone"
2071        );
2072    }
2073
2074    /// `remove_worktree_and_branch` returns true when the worktree directory
2075    /// is successfully removed (regression test for #1143).
2076    #[tokio::test]
2077    async fn remove_worktree_returns_true_on_success() {
2078        let Some((tmp, wt_dir)) = setup_test_repo() else {
2079            eprintln!("skipping test: git not available");
2080            return;
2081        };
2082
2083        let repo_dir = tmp.path().join("repo.git");
2084        assert!(wt_dir.exists(), "worktree must exist before removal");
2085
2086        let removed =
2087            remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
2088                .await;
2089
2090        assert!(
2091            removed,
2092            "remove_worktree_and_branch should return true when directory is gone"
2093        );
2094        assert!(!wt_dir.exists(), "worktree directory should be removed");
2095    }
2096
2097    /// Successful worktree cleanup must still delete the local branch.
2098    /// This guards against early-return regressions in `remove_worktree_and_branch`.
2099    #[tokio::test]
2100    async fn remove_worktree_success_still_deletes_local_branch() {
2101        let Some((tmp, wt_dir)) = setup_test_repo() else {
2102            eprintln!("skipping test: git not available");
2103            return;
2104        };
2105
2106        let repo_dir = tmp.path().join("repo.git");
2107        let repo_dir_str = repo_dir
2108            .to_str()
2109            .expect("test repo path contains non-UTF-8 characters");
2110        let branch = "gh-issue-42-test";
2111
2112        let before = process::Command::new("git")
2113            .args([
2114                "-C",
2115                repo_dir_str,
2116                "show-ref",
2117                "--verify",
2118                "--quiet",
2119                &format!("refs/heads/{branch}"),
2120            ])
2121            .status()
2122            .unwrap();
2123        assert!(
2124            before.success(),
2125            "test setup must create local branch before cleanup"
2126        );
2127
2128        let removed =
2129            remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
2130        assert!(removed, "worktree should be removed");
2131
2132        let after = process::Command::new("git")
2133            .args([
2134                "-C",
2135                repo_dir_str,
2136                "show-ref",
2137                "--verify",
2138                "--quiet",
2139                &format!("refs/heads/{branch}"),
2140            ])
2141            .status()
2142            .unwrap();
2143        assert!(
2144            !after.success(),
2145            "local branch should be deleted after successful worktree cleanup"
2146        );
2147    }
2148
2149    /// `remove_worktree_and_branch` returns false when the worktree directory
2150    /// cannot be removed (regression test for #1143 — prevents orphaned worktrees
2151    /// from being permanently marked as cleaned).
2152    #[tokio::test]
2153    async fn remove_worktree_returns_false_when_dir_persists() {
2154        let tmp = tempfile::tempdir().unwrap();
2155        // Create a plain directory (not a real git worktree) — git will fail
2156        // to remove it, so the directory stays on disk.
2157        let fake_wt = tmp.path().join("not-a-worktree");
2158        std_fs::create_dir_all(&fake_wt).unwrap();
2159
2160        // repo_root doesn't matter much — it just needs to exist for the -C flag.
2161        // Using tmp itself; git commands will fail but that's the point.
2162        let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
2163
2164        // The directory still exists because git couldn't remove it.
2165        // Before the fix, this would still have been marked as cleaned.
2166        assert!(
2167            !removed,
2168            "remove_worktree_and_branch should return false when directory still exists"
2169        );
2170        assert!(fake_wt.exists(), "directory should still be on disk");
2171    }
2172
2173    struct MergeCheckMockBackend {
2174        batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
2175        single_results: HashMap<String, anyhow::Result<bool>>,
2176        posted_comments: Arc<Mutex<Vec<(String, String)>>>,
2177        single_checked_branches: Arc<Mutex<Vec<String>>>,
2178    }
2179
2180    impl MergeCheckMockBackend {
2181        fn with_batch_error(
2182            error: anyhow::Error,
2183            single_results: HashMap<String, anyhow::Result<bool>>,
2184        ) -> Self {
2185            Self {
2186                batch_result: Mutex::new(Err(error)),
2187                single_results,
2188                posted_comments: Arc::new(Mutex::new(Vec::new())),
2189                single_checked_branches: Arc::new(Mutex::new(Vec::new())),
2190            }
2191        }
2192    }
2193
2194    #[async_trait]
2195    impl ExternalBackend for MergeCheckMockBackend {
2196        fn name(&self) -> &'static str {
2197            "mock"
2198        }
2199
2200        async fn create_task(
2201            &self,
2202            _title: &str,
2203            _body: &str,
2204            _labels: &[String],
2205        ) -> anyhow::Result<ExternalId> {
2206            anyhow::bail!("not implemented")
2207        }
2208
2209        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2210            anyhow::bail!("not implemented")
2211        }
2212
2213        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2214            Ok(vec![])
2215        }
2216
2217        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2218            Ok(vec![])
2219        }
2220
2221        async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2222            self.posted_comments
2223                .lock()
2224                .unwrap()
2225                .push((id.0.clone(), body.to_string()));
2226            Ok(())
2227        }
2228
2229        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2230            Ok(())
2231        }
2232
2233        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2234            Ok(())
2235        }
2236
2237        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2238            Ok(vec![])
2239        }
2240
2241        async fn create_sub_task(
2242            &self,
2243            _parent: &ExternalId,
2244            _title: &str,
2245            _body: &str,
2246            _labels: &[String],
2247        ) -> anyhow::Result<ExternalId> {
2248            anyhow::bail!("not implemented")
2249        }
2250
2251        async fn health_check(&self) -> anyhow::Result<()> {
2252            Ok(())
2253        }
2254
2255        async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2256            self.single_checked_branches
2257                .lock()
2258                .unwrap()
2259                .push(branch.to_string());
2260            match self.single_results.get(branch) {
2261                Some(Ok(is_merged)) => Ok(*is_merged),
2262                Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2263                None => Ok(false),
2264            }
2265        }
2266
2267        async fn batch_is_pr_merged(
2268            &self,
2269            _branches: &[String],
2270        ) -> anyhow::Result<HashMap<String, bool>> {
2271            match &*self.batch_result.lock().unwrap() {
2272                Ok(map) => Ok(map.clone()),
2273                Err(err) => Err(anyhow::anyhow!(err.to_string())),
2274            }
2275        }
2276    }
2277
2278    #[tokio::test]
2279    async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2280        let single_results = HashMap::from([
2281            ("branch-1".to_string(), Ok(true)),
2282            ("branch-2".to_string(), Ok(false)),
2283        ]);
2284        let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2285            anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2286            single_results,
2287        ));
2288        let backend_dyn: Arc<dyn ExternalBackend> = Arc::<MergeCheckMockBackend>::clone(&backend);
2289
2290        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2291        let task1 = store
2292            .create(&NewTask {
2293                external_id: Some("101".to_string()),
2294                repo: "owner/repo".to_string(),
2295                origin: "github".to_string(),
2296                title: "Task 101".to_string(),
2297                ..Default::default()
2298            })
2299            .await
2300            .unwrap();
2301        store
2302            .update_status(task1, TaskStatus::NeedsReview)
2303            .await
2304            .unwrap();
2305        store
2306            .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2307            .await
2308            .unwrap();
2309        // No .git file, so cleanup force-removes the directory
2310        let wt_dir = tempfile::tempdir().unwrap();
2311        let wt_path = wt_dir.path().join("branch-1");
2312        std_fs::create_dir_all(&wt_path).unwrap();
2313        store
2314            .set_fields(
2315                task1,
2316                &[("worktree", serde_json::json!(wt_path.to_string_lossy()))],
2317            )
2318            .await
2319            .unwrap();
2320
2321        let task2 = store
2322            .create(&NewTask {
2323                external_id: Some("102".to_string()),
2324                repo: "owner/repo".to_string(),
2325                origin: "github".to_string(),
2326                title: "Task 102".to_string(),
2327                ..Default::default()
2328            })
2329            .await
2330            .unwrap();
2331        store
2332            .update_status(task2, TaskStatus::InReview)
2333            .await
2334            .unwrap();
2335        store
2336            .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2337            .await
2338            .unwrap();
2339
2340        let task_manager = Arc::new(TaskManager::with_store(
2341            Arc::clone(&backend_dyn),
2342            Arc::clone(&store),
2343            "owner/repo".to_string(),
2344        ));
2345
2346        let in_review_tasks = store
2347            .list_by_status("owner/repo", TaskStatus::InReview)
2348            .await
2349            .unwrap()
2350            .into_iter()
2351            .map(|stored| ReviewTaskSnapshot {
2352                external: tasks::store_task_to_external(&stored),
2353                stored,
2354            })
2355            .collect::<Vec<_>>();
2356        let needs_review_tasks = store
2357            .list_by_status("owner/repo", TaskStatus::NeedsReview)
2358            .await
2359            .unwrap()
2360            .into_iter()
2361            .map(|stored| ReviewTaskSnapshot {
2362                external: tasks::store_task_to_external(&stored),
2363                stored,
2364            })
2365            .collect::<Vec<_>>();
2366
2367        check_merged_prs(
2368            &backend_dyn,
2369            "owner/repo",
2370            &store,
2371            &task_manager,
2372            &in_review_tasks,
2373            &needs_review_tasks,
2374        )
2375        .await
2376        .unwrap();
2377
2378        let checked = backend.single_checked_branches.lock().unwrap().clone();
2379        assert_eq!(
2380            checked,
2381            vec!["branch-2".to_string(), "branch-1".to_string()]
2382        );
2383
2384        let updated_task1 = store.get(task1).await.unwrap();
2385        let updated_task2 = store.get(task2).await.unwrap();
2386        assert_eq!(updated_task1.status, TaskStatus::Done);
2387        assert!(updated_task1.worktree_cleaned);
2388        assert!(!wt_path.exists());
2389        assert!(!updated_task2.worktree_cleaned);
2390        assert_eq!(updated_task2.status, TaskStatus::InReview);
2391
2392        let comments = backend.posted_comments.lock().unwrap().clone();
2393        assert_eq!(comments.len(), 1);
2394        assert_eq!(comments[0].0, "101");
2395        assert!(comments[0].1.contains("PR merged, marking task complete"));
2396    }
2397
2398    struct ReconciliationMockBackend {
2399        reconciliation_calls: Arc<AtomicUsize>,
2400    }
2401
2402    #[async_trait]
2403    impl ExternalBackend for ReconciliationMockBackend {
2404        fn name(&self) -> &'static str {
2405            "mock"
2406        }
2407
2408        async fn create_task(
2409            &self,
2410            _title: &str,
2411            _body: &str,
2412            _labels: &[String],
2413        ) -> anyhow::Result<ExternalId> {
2414            anyhow::bail!("not implemented")
2415        }
2416
2417        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2418            anyhow::bail!("not implemented")
2419        }
2420
2421        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2422            Ok(vec![])
2423        }
2424
2425        async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2426            self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2427            Ok(vec![])
2428        }
2429
2430        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2431            Ok(vec![])
2432        }
2433
2434        async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2435            Ok(())
2436        }
2437
2438        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2439            Ok(())
2440        }
2441
2442        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2443            Ok(())
2444        }
2445
2446        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2447            Ok(vec![])
2448        }
2449
2450        async fn create_sub_task(
2451            &self,
2452            _parent: &ExternalId,
2453            _title: &str,
2454            _body: &str,
2455            _labels: &[String],
2456        ) -> anyhow::Result<ExternalId> {
2457            anyhow::bail!("not implemented")
2458        }
2459
2460        async fn health_check(&self) -> anyhow::Result<()> {
2461            Ok(())
2462        }
2463    }
2464
2465    #[tokio::test]
2466    async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2467        let repo = "owner/repo-cleanup-rate-limit";
2468        RECONCILIATION_LAST_RUN.remove(repo);
2469
2470        let backend = Arc::new(ReconciliationMockBackend {
2471            reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2472        });
2473        let backend_dyn: Arc<dyn ExternalBackend> =
2474            Arc::<ReconciliationMockBackend>::clone(&backend);
2475        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2476        let task_manager = Arc::new(TaskManager::with_store(
2477            Arc::clone(&backend_dyn),
2478            Arc::clone(&store),
2479            repo.to_string(),
2480        ));
2481        let opts = JanitorOptions {
2482            ttl_hours: 0,
2483            dry_run: true,
2484        };
2485
2486        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2487            .await
2488            .unwrap();
2489        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2490            .await
2491            .unwrap();
2492
2493        assert_eq!(
2494            backend.reconciliation_calls.load(Ordering::SeqCst),
2495            1,
2496            "cleanup should not re-list reconciliation candidates on every sync pass"
2497        );
2498
2499        RECONCILIATION_LAST_RUN.remove(repo);
2500    }
2501
2502    /// Cleanup only collects done tasks. A blocked task waits on a person and resumes from its
2503    /// worktree and branch, so it must not even be considered (#3781)
2504    #[tokio::test]
2505    async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2506        let repo = "owner/repo-cleanup-blocked";
2507        RECONCILIATION_LAST_RUN.remove(repo);
2508        let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2509            reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2510        });
2511        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2512        let task_manager = Arc::new(TaskManager::with_store(
2513            Arc::clone(&backend_dyn),
2514            Arc::clone(&store),
2515            repo.to_string(),
2516        ));
2517
2518        let mut ids = Vec::new();
2519        for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2520            let id = store
2521                .create_internal(repo, title, "", "test", title, None)
2522                .await
2523                .unwrap();
2524            store
2525                .set_fields(
2526                    id,
2527                    &[
2528                        (
2529                            "worktree",
2530                            serde_json::json!(format!("/nonexistent/{title}")),
2531                        ),
2532                        ("branch", serde_json::json!("")),
2533                    ],
2534                )
2535                .await
2536                .unwrap();
2537            store.update_status(id, status).await.unwrap();
2538            ids.push(id);
2539        }
2540
2541        let opts = JanitorOptions {
2542            ttl_hours: 0,
2543            dry_run: false,
2544        };
2545        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2546            .await
2547            .unwrap();
2548
2549        assert!(
2550            !store.get(ids[0]).await.unwrap().worktree_cleaned,
2551            "blocked task must not be considered for cleanup"
2552        );
2553        assert!(
2554            store.get(ids[1]).await.unwrap().worktree_cleaned,
2555            "done task is cleaned, which proves the pass ran"
2556        );
2557        RECONCILIATION_LAST_RUN.remove(repo);
2558    }
2559
2560    fn git_ok(dir: &Path, args: &[&str]) -> String {
2561        let out = process::Command::new("git")
2562            .args([
2563                "-c",
2564                "commit.gpgsign=false",
2565                "-c",
2566                "user.name=T",
2567                "-c",
2568                "user.email=t@t",
2569            ])
2570            .args(args)
2571            .current_dir(dir)
2572            .output()
2573            .unwrap();
2574        assert!(
2575            out.status.success(),
2576            "git {args:?}: {}",
2577            String::from_utf8_lossy(&out.stderr)
2578        );
2579        String::from_utf8_lossy(&out.stdout).to_string()
2580    }
2581
2582    /// A clone of a bare origin with main pushed and branch `feat` one commit ahead
2583    fn origin_and_clone_with_feat() -> (tempfile::TempDir, PathBuf) {
2584        let tmp = tempfile::tempdir().unwrap();
2585        let bare = tmp.path().join("origin.git");
2586        std_fs::create_dir_all(&bare).unwrap();
2587        git_ok(&bare, &["init", "--bare", "-b", "main"]);
2588        git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2589        let clone = tmp.path().join("clone");
2590        std_fs::write(clone.join("base.txt"), "base").unwrap();
2591        git_ok(&clone, &["add", "."]);
2592        git_ok(&clone, &["commit", "-m", "base"]);
2593        git_ok(&clone, &["push", "origin", "main"]);
2594        git_ok(&clone, &["checkout", "-b", "feat"]);
2595        std_fs::write(clone.join("work.txt"), "work").unwrap();
2596        git_ok(&clone, &["add", "."]);
2597        git_ok(&clone, &["commit", "-m", "work"]);
2598        git_ok(&clone, &["checkout", "main"]);
2599        (tmp, clone)
2600    }
2601
2602    fn remote_has(clone: &Path, branch: &str) -> bool {
2603        !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2604            .trim()
2605            .is_empty()
2606    }
2607
2608    fn local_has(clone: &Path, branch: &str) -> bool {
2609        process::Command::new("git")
2610            .args([
2611                "show-ref",
2612                "--verify",
2613                "--quiet",
2614                &format!("refs/heads/{branch}"),
2615            ])
2616            .current_dir(clone)
2617            .status()
2618            .unwrap()
2619            .success()
2620    }
2621
2622    /// Deleting a pushed branch whose commits main lacks would destroy the only copy of the
2623    /// work, as startup cleanup did to a blocked task (#3781)
2624    #[tokio::test]
2625    async fn delete_branches_keeps_an_unmerged_remote_branch() {
2626        let (_tmp, clone) = origin_and_clone_with_feat();
2627        git_ok(&clone, &["push", "origin", "feat"]);
2628
2629        delete_branches("1", "feat", &clone).await;
2630
2631        assert!(
2632            remote_has(&clone, "feat"),
2633            "unmerged remote branch must survive"
2634        );
2635        assert!(
2636            !local_has(&clone, "feat"),
2637            "local copy is on origin, so it can go"
2638        );
2639    }
2640
2641    #[tokio::test]
2642    async fn delete_branches_removes_a_branch_main_already_contains() {
2643        let (_tmp, clone) = origin_and_clone_with_feat();
2644        git_ok(&clone, &["push", "origin", "feat"]);
2645        git_ok(&clone, &["merge", "--ff-only", "feat"]);
2646        git_ok(&clone, &["push", "origin", "main"]);
2647
2648        delete_branches("1", "feat", &clone).await;
2649
2650        assert!(!remote_has(&clone, "feat"));
2651        assert!(!local_has(&clone, "feat"));
2652    }
2653
2654    fn add_worktree(clone: &Path, tmp: &Path, branch: &str) -> PathBuf {
2655        let wt = tmp.join("wt");
2656        git_ok(clone, &["worktree", "add", wt.to_str().unwrap(), branch]);
2657        wt
2658    }
2659
2660    #[tokio::test]
2661    async fn cleanup_pushes_unpushed_commits_to_a_rescue_branch() {
2662        let (tmp, clone) = origin_and_clone_with_feat();
2663        let wt = add_worktree(&clone, tmp.path(), "feat");
2664
2665        let sha = git_ok(&clone, &["rev-parse", "--short", "feat"]);
2666        let sha = sha.trim();
2667        match rescue_unpushed_work("1", Some(&wt), "feat", &clone).await {
2668            Rescue::Pushed { branch, commits } => {
2669                assert_eq!(branch, "rescue/feat");
2670                assert!(rescue_comment(&branch, &commits).contains(sha));
2671            }
2672            other => panic!("expected a rescue push, got {other:?}"),
2673        }
2674
2675        let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2676
2677        assert!(removed);
2678        assert!(remote_has(&clone, "rescue/feat"));
2679        assert!(!local_has(&clone, "feat"));
2680    }
2681
2682    #[tokio::test]
2683    async fn cleanup_commits_uncommitted_changes_before_the_rescue() {
2684        let (tmp, clone) = origin_and_clone_with_feat();
2685        git_ok(&clone, &["push", "origin", "feat"]);
2686        let wt = add_worktree(&clone, tmp.path(), "feat");
2687        std_fs::write(wt.join("dirty.txt"), "dirty").unwrap();
2688
2689        let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2690
2691        assert!(removed);
2692        assert!(remote_has(&clone, "rescue/feat"));
2693    }
2694
2695    #[tokio::test]
2696    async fn cleanup_makes_no_rescue_branch_for_a_pushed_branch() {
2697        let (tmp, clone) = origin_and_clone_with_feat();
2698        git_ok(&clone, &["push", "origin", "feat"]);
2699        let wt = add_worktree(&clone, tmp.path(), "feat");
2700
2701        let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2702
2703        assert!(removed);
2704        assert!(!remote_has(&clone, "rescue/feat"));
2705        assert!(!local_has(&clone, "feat"));
2706    }
2707
2708    #[tokio::test]
2709    async fn rescue_skips_a_branch_on_the_remote_without_a_tracking_ref() {
2710        let (_tmp, clone) = origin_and_clone_with_feat();
2711        git_ok(&clone, &["push", "origin", "feat"]);
2712        git_ok(&clone, &["update-ref", "-d", "refs/remotes/origin/feat"]);
2713
2714        let rescue = rescue_unpushed_work("1", None, "feat", &clone).await;
2715
2716        assert_eq!(rescue, Rescue::Clean);
2717        assert!(!remote_has(&clone, "rescue/feat"));
2718    }
2719
2720    #[tokio::test]
2721    async fn cleanup_keeps_worktree_and_branch_when_the_rescue_push_fails() {
2722        let (tmp, clone) = origin_and_clone_with_feat();
2723        let wt = add_worktree(&clone, tmp.path(), "feat");
2724        git_ok(
2725            &clone,
2726            &["remote", "set-url", "origin", "/nonexistent/origin.git"],
2727        );
2728
2729        let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2730
2731        assert!(!removed);
2732        assert!(wt.exists());
2733        assert!(local_has(&clone, "feat"));
2734    }
2735
2736    #[tokio::test]
2737    async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2738        let (_tmp, clone) = origin_and_clone_with_feat();
2739
2740        delete_branches("1", "feat", &clone).await;
2741
2742        assert!(
2743            local_has(&clone, "feat"),
2744            "unpushed commits must not be deleted"
2745        );
2746    }
2747}