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    // A tip someone else pushed may be missing locally, and rev-list fails on an unknown object
926    let remote_tip = match remote_tip {
927        Some(tip) => {
928            let known = git_in(repo_root, &["cat-file", "-e", &format!("{tip}^{{commit}}")])
929                .await
930                .is_some_and(|o| o.status.success());
931            known.then_some(tip)
932        }
933        None => None,
934    };
935    let mut args = vec!["rev-list", "--oneline", &local_ref, "--not", "--remotes"];
936    if let Some(tip) = remote_tip.as_deref() {
937        args.push(tip);
938    }
939    let default_exists = default_ref != local_ref
940        && git_in(
941            repo_root,
942            &["rev-parse", "--verify", "--quiet", &default_ref],
943        )
944        .await
945        .is_some_and(|o| o.status.success());
946    if default_exists {
947        args.push(&default_ref);
948    }
949    let Some(list) = git_in(repo_root, &args)
950        .await
951        .filter(|o| o.status.success())
952    else {
953        tracing::warn!(task_id, branch = %br, "cannot list unpushed commits, keeping worktree and branch");
954        return Rescue::Failed;
955    };
956    let commits: Vec<String> = String::from_utf8_lossy(&list.stdout)
957        .lines()
958        .map(str::to_string)
959        .collect();
960    if commits.is_empty() {
961        return Rescue::Clean;
962    }
963
964    let rescue = format!("rescue/{br}");
965    let refspec = format!("{local_ref}:refs/heads/{rescue}");
966    let pushed = git_in(
967        repo_root,
968        &["-c", "lfs.locksverify=false", "push", "origin", &refspec],
969    )
970    .await;
971    if pushed.is_some_and(|o| o.status.success()) {
972        tracing::warn!(task_id, branch = %br, rescue = %rescue, commits = commits.len(), "pushed unpushed commits to rescue branch");
973        Rescue::Pushed {
974            branch: rescue,
975            commits,
976        }
977    } else {
978        tracing::warn!(task_id, branch = %br, rescue = %rescue, "rescue push failed, keeping worktree and branch");
979        Rescue::Failed
980    }
981}
982
983fn rescue_comment(rescue_branch: &str, commits: &[String]) -> String {
984    let list = commits
985        .iter()
986        .map(|c| format!("- `{c}`"))
987        .collect::<Vec<_>>()
988        .join("\n");
989    format!(
990        "Cleanup found commits on this task's branch that were never pushed. They are saved on `{rescue_branch}`:\n\n{list}"
991    )
992}
993
994/// Save unpushed work and tell the task about it. Returns false when cleanup must not go on
995async fn rescue_and_notify(
996    task_id: &str,
997    repo: &str,
998    external_id: Option<&str>,
999    wt: Option<&Path>,
1000    br: &str,
1001    repo_root: &Path,
1002) -> bool {
1003    match rescue_unpushed_work(task_id, wt, br, repo_root).await {
1004        Rescue::Clean => true,
1005        Rescue::Failed => false,
1006        Rescue::Pushed { branch, commits } => {
1007            if let Some(number) = external_id.filter(|n| n.parse::<u64>().is_ok()) {
1008                match GhHttp::new() {
1009                    Ok(gh) => {
1010                        if let Err(e) = gh
1011                            .add_comment(repo, number, &rescue_comment(&branch, &commits))
1012                            .await
1013                        {
1014                            tracing::warn!(task_id, err = %e, "failed to comment on rescue branch");
1015                        }
1016                    }
1017                    Err(e) => {
1018                        tracing::warn!(task_id, err = %e, "cannot comment on rescue branch");
1019                    }
1020                }
1021            }
1022            true
1023        }
1024    }
1025}
1026
1027/// Remove a git worktree directory and its local + remote branches.
1028/// When `keep_remote_branch` is true, only the worktree and local branch are
1029/// removed — the remote branch stays so that any linked PR remains open
1030/// (used for blocked tasks awaiting human review).
1031/// Returns `true` if the worktree directory is gone after the call
1032/// (either we removed it or it was already absent).
1033pub(crate) async fn remove_worktree_and_branch(
1034    task_id: &str,
1035    wt: &Path,
1036    branch: Option<&str>,
1037    repo_root: &Path,
1038    keep_remote_branch: bool,
1039) -> bool {
1040    remove_worktree_and_branch_for(task_id, wt, branch, repo_root, keep_remote_branch, false).await
1041}
1042
1043/// Same as `remove_worktree_and_branch`, but a merged PR skips the rescue. A squash leaves the
1044/// branch commits on no ref, so the rescue would push a branch for work that already landed
1045pub(crate) async fn remove_worktree_and_branch_for(
1046    task_id: &str,
1047    wt: &Path,
1048    branch: Option<&str>,
1049    repo_root: &Path,
1050    keep_remote_branch: bool,
1051    pr_merged: bool,
1052) -> bool {
1053    if !pr_merged
1054        && let Some(br) = branch.filter(|b| !b.is_empty())
1055        && rescue_unpushed_work(task_id, Some(wt), br, repo_root).await == Rescue::Failed
1056    {
1057        return false;
1058    }
1059
1060    remove_worktree_and_branch_unchecked(task_id, wt, branch, repo_root, keep_remote_branch).await
1061}
1062
1063/// The caller has already rescued the branch or knows its PR merged
1064async fn remove_worktree_and_branch_unchecked(
1065    task_id: &str,
1066    wt: &Path,
1067    branch: Option<&str>,
1068    repo_root: &Path,
1069    keep_remote_branch: bool,
1070) -> bool {
1071    let wt_str = wt.to_string_lossy().to_string();
1072    let repo_root_str = repo_root.to_string_lossy();
1073
1074    // Remove worktree FIRST, then delete the branch.
1075    // Git refuses to remove a worktree if its branch is already deleted.
1076    let remove_result = Command::new("git")
1077        .args([
1078            "-C",
1079            repo_root_str.as_ref(),
1080            "worktree",
1081            "remove",
1082            &wt_str,
1083            "--force",
1084        ])
1085        .output_with_context()
1086        .await;
1087
1088    match remove_result {
1089        Ok(output) if output.status.success() => {
1090            const MAX_RETRIES: usize = 3;
1091            const RETRY_DELAY_MS: u64 = 2000;
1092            tracing::info!(task_id, "worktree removed (git metadata)");
1093            // `git worktree remove` only removes git metadata — the physical directory
1094            // with working files may remain. Clean it up with retry logic, since macOS
1095            // sometimes needs a moment to release file handles from a recently-exited
1096            // process. Retrying with a delay addresses "Directory not empty" errors.
1097            let mut removed = false;
1098            for attempt in 0..MAX_RETRIES {
1099                match fs::remove_dir_all(wt).await {
1100                    Ok(()) => {
1101                        removed = true;
1102                        break;
1103                    }
1104                    Err(e) if e.kind() == ErrorKind::NotFound => {
1105                        // Directory was already removed by git or another cleanup pass.
1106                        removed = true;
1107                        break;
1108                    }
1109                    Err(e) if e.kind() == ErrorKind::DirectoryNotEmpty => {
1110                        if attempt < MAX_RETRIES - 1 {
1111                            tracing::debug!(
1112                                task_id,
1113                                path = %wt.display(),
1114                                attempt = attempt + 1,
1115                                "worktree directory not empty — retrying after delay"
1116                            );
1117                            sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
1118                        } else {
1119                            tracing::warn!(
1120                                task_id,
1121                                path = %wt.display(),
1122                                err = %e,
1123                                "failed to remove worktree directory after {} attempts (Directory not empty)",
1124                                MAX_RETRIES
1125                            );
1126                        }
1127                    }
1128                    Err(e) => {
1129                        tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
1130                        break;
1131                    }
1132                }
1133            }
1134            if removed {
1135                tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
1136            } else {
1137                tracing::warn!(
1138                    task_id,
1139                    path = %wt.display(),
1140                    "worktree directory still present after git worktree remove"
1141                );
1142            }
1143        }
1144        Ok(output) => {
1145            let stderr = String::from_utf8_lossy(&output.stderr);
1146            // "is not a working tree" means git's metadata is stale — the path
1147            // exists on disk but is no longer registered as a worktree. Treat
1148            // this as an already-gone worktree: physically remove the directory
1149            // and prune stale git metadata. Downgrade to debug — this is an
1150            // expected idempotency case, not a real error.
1151            if stderr.contains("is not a working tree") {
1152                tracing::debug!(
1153                    task_id,
1154                    path = %wt.display(),
1155                    "path is not a registered worktree (stale metadata) — removing directory and pruning"
1156                );
1157                if let Err(e) = fs::remove_dir_all(wt).await {
1158                    // Only warn if the directory actually still exists after removal attempt.
1159                    if match fs::try_exists(wt).await {
1160                        Ok(true) => true,
1161                        Ok(false) => false,
1162                        Err(e) => {
1163                            tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
1164                            false
1165                        }
1166                    } {
1167                        tracing::warn!(
1168                            task_id,
1169                            path = %wt.display(),
1170                            err = %e,
1171                            "failed to remove stale worktree directory"
1172                        );
1173                    }
1174                } else {
1175                    tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
1176                }
1177                // Prune stale worktree entries from git metadata so the warning
1178                // stops recurring on future `git worktree list` / cleanup cycles.
1179                if let Err(e) = Command::new("git")
1180                    .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
1181                    .output_with_context()
1182                    .await
1183                {
1184                    tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
1185                }
1186            } else {
1187                tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
1188            }
1189        }
1190        Err(e) => {
1191            tracing::warn!(task_id, err = %e, "failed to remove worktree");
1192        }
1193    }
1194
1195    // Prune stale .git/worktrees/ entries left behind after removal.
1196    if let Err(e) = Command::new("git")
1197        .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
1198        .output_with_context()
1199        .await
1200    {
1201        tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
1202    }
1203
1204    // Delete local and remote branch from the main repo root (worktree is already gone).
1205    // When keep_remote_branch is set, only delete the local branch — the remote stays
1206    // so any linked PR remains open for human review.
1207    if let Some(br) = branch {
1208        if keep_remote_branch {
1209            delete_local_branch(task_id, br, repo_root).await;
1210        } else {
1211            delete_branches(task_id, br, repo_root).await;
1212        }
1213    }
1214
1215    // Definitively check: is the worktree directory actually gone?
1216    match fs::try_exists(wt).await {
1217        Ok(false) => true, // confirmed gone
1218        Ok(true) => false, // still there
1219        Err(e) => {
1220            tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
1221            false // conservative: don't claim success
1222        }
1223    }
1224}
1225
1226/// Commits reachable from `rev` and from none of `others`. `None` when git cannot answer
1227async fn commits_outside(repo_root: &Path, rev: &str, others: &[&str]) -> Option<u64> {
1228    let mut args = vec![
1229        "rev-list".to_string(),
1230        "--count".to_string(),
1231        rev.to_string(),
1232    ];
1233    for other in others {
1234        let exists = Command::new("git")
1235            .args([
1236                "-C",
1237                &repo_root.to_string_lossy(),
1238                "rev-parse",
1239                "--verify",
1240                "--quiet",
1241                other,
1242            ])
1243            .output_with_context()
1244            .await
1245            .is_ok_and(|o| o.status.success());
1246        if exists {
1247            args.push("--not".to_string());
1248            args.push(other.to_string());
1249        }
1250    }
1251    let output = Command::new("git")
1252        .arg("-C")
1253        .arg(repo_root)
1254        .args(&args)
1255        .output_with_context()
1256        .await
1257        .ok()?;
1258    if !output.status.success() {
1259        return None;
1260    }
1261    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
1262}
1263
1264/// Delete the local branch only when origin or the default branch holds every commit on it
1265async fn delete_local_branch(task_id: &str, br: &str, repo_root: &Path) {
1266    let default_branch = worktree::detect_default_branch(repo_root).await;
1267    let remote_ref = format!("refs/remotes/origin/{br}");
1268    let default_ref = format!("refs/remotes/origin/{default_branch}");
1269    let local_ref = format!("refs/heads/{br}");
1270    let local_default_ref = format!("refs/heads/{default_branch}");
1271    let rescue_ref = format!("refs/remotes/origin/rescue/{br}");
1272    match commits_outside(
1273        repo_root,
1274        &local_ref,
1275        &[&remote_ref, &default_ref, &local_default_ref, &rescue_ref],
1276    )
1277    .await
1278    {
1279        Some(0) => {}
1280        Some(n) => {
1281            tracing::warn!(task_id, branch = %br, unpushed = n, "local branch holds commits origin does not have, keeping it");
1282            return;
1283        }
1284        None => {
1285            tracing::debug!(task_id, branch = %br, "local branch missing or unreadable, nothing to delete");
1286            return;
1287        }
1288    }
1289    let output = Command::new("git")
1290        .args(["-C", &repo_root.to_string_lossy(), "branch", "-D", br])
1291        .output_with_context()
1292        .await;
1293    match output {
1294        Ok(o) if o.status.success() => {
1295            tracing::info!(task_id, branch = %br, "local branch deleted");
1296        }
1297        Ok(o) => {
1298            let stderr = String::from_utf8_lossy(&o.stderr);
1299            tracing::debug!(task_id, err = %stderr, "local branch delete skipped (may not exist)");
1300        }
1301        Err(e) => {
1302            tracing::warn!(task_id, err = %e, "failed to delete local branch");
1303        }
1304    }
1305}
1306
1307/**
1308 * Delete local and remote branches for a task, but never the only copy of a commit.
1309 * The remote branch goes only when the default branch already contains all of it, which
1310 * covers fast-forward and merge-commit merges. A squash-merged branch stays, and GitHub's
1311 * "automatically delete head branches" removes it. Every other caller (startup rebase
1312 * failure, closed-issue reconciliation, orphan sweeps) may hold unmerged work (#3781)
1313 **/
1314pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &Path) {
1315    let default_branch = worktree::detect_default_branch(repo_root).await;
1316    let _ = git_ops::git()
1317        .await
1318        .args([
1319            "-C",
1320            &repo_root.to_string_lossy(),
1321            "fetch",
1322            "origin",
1323            &default_branch,
1324            br,
1325        ])
1326        .output_with_context()
1327        .await;
1328
1329    delete_local_branch(task_id, br, repo_root).await;
1330
1331    let remote_ref = format!("refs/remotes/origin/{br}");
1332    let default_ref = format!("refs/remotes/origin/{default_branch}");
1333    match commits_outside(repo_root, &remote_ref, &[&default_ref]).await {
1334        Some(0) => {}
1335        Some(n) => {
1336            tracing::info!(task_id, branch = %br, unmerged = n, "remote branch has commits the default branch lacks, keeping it");
1337            return;
1338        }
1339        None => {
1340            tracing::debug!(task_id, branch = %br, "remote branch missing or unreadable, nothing to delete");
1341            return;
1342        }
1343    }
1344
1345    let remote_delete = git_ops::git()
1346        .await
1347        .args([
1348            "-C",
1349            &repo_root.to_string_lossy(),
1350            "-c",
1351            "lfs.locksverify=false",
1352            "push",
1353            "origin",
1354            "--delete",
1355            br,
1356        ])
1357        .output_with_context()
1358        .await;
1359
1360    match remote_delete {
1361        Ok(output) if output.status.success() => {
1362            tracing::info!(task_id, branch = %br, "remote branch deleted");
1363            git_ops::clear_push_lease(repo_root, br).await;
1364        }
1365        Ok(output) => {
1366            let stderr = String::from_utf8_lossy(&output.stderr);
1367            tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1368        }
1369        Err(e) => {
1370            tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1371        }
1372    }
1373}
1374
1375/// Returns `true` if any active tmux pane has its cwd inside `worktree`.
1376///
1377/// Uses `tmux list-panes -a -F '#{pane_current_path}'` to enumerate all
1378/// pane working directories across every session. If tmux is not running
1379/// (command fails), we conservatively return `false` so cleanup can proceed.
1380async fn is_worktree_in_active_session(worktree: &Path) -> bool {
1381    let output = Command::new("tmux")
1382        .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1383        .output()
1384        .await;
1385
1386    match output {
1387        Ok(o) if o.status.success() => {
1388            let worktree_str = worktree.to_string_lossy();
1389            String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1390                let pane_path = line.trim();
1391                // Match exact path or any path that starts with worktree + separator.
1392                pane_path == worktree_str.as_ref()
1393                    || pane_path.starts_with(&format!("{worktree_str}/"))
1394            })
1395        }
1396        // tmux not running or no sessions — treat as safe to clean.
1397        _ => false,
1398    }
1399}
1400
1401/// Returns how many hours old the worktree directory is, based on its mtime.
1402///
1403/// Returns `None` if the directory does not exist or the mtime cannot be read.
1404async fn worktree_age_hours(worktree: &Path) -> Option<u64> {
1405    // Use tokio::fs to avoid blocking the async runtime when reading metadata.
1406    let metadata = fs::metadata(worktree).await.ok()?;
1407    let modified = metadata.modified().ok()?;
1408    let age = SystemTime::now().duration_since(modified).ok()?;
1409    Some(age.as_secs() / 3600)
1410}
1411
1412/// Resolve the repo root for an orphaned worktree and remove it.
1413///
1414/// Used by `orch service doctor --fix` and `orch service prune` to clean up worktrees that are
1415/// no longer owned by any task. Returns `true` if the worktree directory is gone
1416/// after the call.
1417pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &Path) -> bool {
1418    let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1419        Ok(root) => root,
1420        Err(e) => {
1421            tracing::warn!(
1422                worktree = %wt.display(),
1423                error = %e,
1424                "cannot resolve repo root for orphaned worktree"
1425            );
1426            return false;
1427        }
1428    };
1429    remove_worktree_and_branch(task_id, wt, None, Path::new(&repo_root), false).await
1430}
1431
1432/// Resolve the main git repository root for an orphaned worktree.
1433///
1434/// First tries to parse the `.git` file's `gitdir:` pointer (two levels up from
1435/// the pointer gives the bare repo root, which is the orch convention). Falls back
1436/// to looking up registered projects by directory name when the `.git` file is
1437/// absent or unparseable.
1438pub(crate) async fn resolve_repo_root_for_orphaned_worktree(wt: &Path) -> anyhow::Result<String> {
1439    let git_file = wt.join(".git");
1440    if fs::try_exists(&git_file).await.unwrap_or(false)
1441        && let Ok(content) = fs::read_to_string(&git_file).await
1442    {
1443        for line in content.lines() {
1444            if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1445                // gitdir points to: <repo>/.git/worktrees/<name> (non-bare)
1446                //                or <repo>.git/worktrees/<name>   (bare)
1447                // Two levels up reaches the .git dir / bare repo root.
1448                let gitdir_path = PathBuf::from(gitdir);
1449                if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1450                    return Ok(repo_root.display().to_string());
1451                }
1452            }
1453        }
1454    }
1455
1456    // Fallback: look up registered projects by the project-name component of the
1457    // worktree path (format: ~/.orch/worktrees/<project>/<branch>).
1458    let worktrees_dir = home::worktrees_dir()?;
1459    if let Ok(relative) = wt.strip_prefix(&worktrees_dir)
1460        && let Some(project_name) = relative.components().next()
1461    {
1462        let project_name = project_name.as_os_str().to_string_lossy();
1463
1464        if let Ok(paths) = config::get_project_paths() {
1465            for path_str in &paths {
1466                let path = Path::new(path_str);
1467                if let Some(name) = path.file_name()
1468                    && name.to_string_lossy() == project_name
1469                {
1470                    return Ok(path_str.clone());
1471                }
1472            }
1473        }
1474
1475        // Try bare clone at ~/.orch/projects/<owner>/<project>.git
1476        let parts: Vec<&str> = project_name.split('/').collect();
1477        let bare = if parts.len() == 2 {
1478            home::projects_dir()
1479                .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1480                .unwrap_or_default()
1481        } else {
1482            return Err(anyhow::anyhow!(
1483                "cannot determine owner for single-segment project name '{project_name}' — \
1484                     bare repo location is ambiguous; register the project with 'owner/repo' format"
1485            ));
1486        };
1487        if fs::try_exists(&bare).await.unwrap_or(false) {
1488            return Ok(bare.display().to_string());
1489        }
1490    }
1491
1492    anyhow::bail!(
1493        "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1494        wt.display()
1495    )
1496}
1497
1498/// Resolve the main git repository root from a worktree's .git file.
1499///
1500/// For cross-project worktree cleanup, we cannot rely on `resolve_repo_root` because
1501/// the worktree may belong to a different project than the current repo context.
1502/// Instead, we extract the repo root from the worktree's `.git` file which contains
1503/// a `gitdir:` pointer to the main repo's git directory.
1504pub async fn resolve_repo_root_from_worktree(wt: &Path) -> anyhow::Result<String> {
1505    // For worktrees, .git is a file (not a directory) containing gitdir path
1506    let git_file = wt.join(".git");
1507    if !fs::try_exists(&git_file).await.unwrap_or(false) {
1508        anyhow::bail!(".git file not found in worktree at {}", wt.display());
1509    }
1510
1511    let content = fs::read_to_string(&git_file)
1512        .await
1513        .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1514
1515    // Parse gitdir line: "gitdir: /path/to/repo/.git/worktrees/<name>"
1516    for line in content.lines() {
1517        if let Some(gitdir) = line.strip_prefix("gitdir:") {
1518            let gitdir = gitdir.trim();
1519            // The main repo is the parent of the .git directory
1520            // gitdir points to: <repo>/.git/worktrees/<name>
1521            // We need to go up 3 levels: worktrees/<name> -> .git -> repo
1522            let gitdir_path = Path::new(gitdir);
1523            if let Some(repo_root) = gitdir_path
1524                .parent() // worktrees/<name>
1525                .and_then(|p| p.parent()) // .git
1526                .and_then(|p| p.parent())
1527            // repo root
1528            {
1529                return Ok(repo_root.display().to_string());
1530            }
1531        }
1532    }
1533
1534    anyhow::bail!(
1535        "cannot resolve repo root from worktree .git file at {}",
1536        wt.display()
1537    )
1538}
1539
1540/// Resolve the main git repository root path for a project.
1541///
1542/// Looks up the local project path from config, then verifies it's a git repo.
1543/// This avoids relying on cwd (which is undefined under launchd services).
1544pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1545    // Look up the local path from registered projects
1546    let paths = config::get_project_paths().unwrap_or_default();
1547    for path_str in &paths {
1548        let path = Path::new(path_str);
1549        let orch_yml = path.join(".orch.yml");
1550        let legacy = path.join(".orchestrator.yml");
1551        let config_file = if fs::try_exists(&orch_yml).await.unwrap_or(false) {
1552            orch_yml
1553        } else if fs::try_exists(&legacy).await.unwrap_or(false) {
1554            legacy
1555        } else {
1556            continue;
1557        };
1558        if let Ok(content) = fs::read_to_string(&config_file).await
1559            && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1560            && let Some(r) = doc
1561                .get("gh")
1562                .and_then(|gh| gh.get("repo"))
1563                .and_then(|r| r.as_str())
1564            && r == repo
1565        {
1566            return Ok(path_str.clone());
1567        }
1568    }
1569
1570    // Fallback: try bare clone in ~/.orch/projects/<owner>/<repo>.git
1571    let parts: Vec<&str> = repo.split('/').collect();
1572    let bare = if parts.len() == 2 {
1573        home::projects_dir()
1574            .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1575            .unwrap_or_default()
1576    } else {
1577        PathBuf::new()
1578    };
1579    if fs::try_exists(&bare).await.unwrap_or(false) {
1580        return Ok(bare.display().to_string());
1581    }
1582
1583    anyhow::bail!(
1584        "cannot find local path for project {repo} — \
1585         checked {} registered project(s) and bare clone at {}",
1586        paths.len(),
1587        bare.display()
1588    )
1589}
1590
1591/// Resolve the project directory for a given repo string.
1592///
1593/// Replicates the logic from `TaskRunner::resolve_project_dir()` without requiring
1594/// a Runner instance. Used by the orphan scanner to enumerate active worktrees.
1595fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<PathBuf> {
1596    // Check PROJECT_DIR env var first
1597    if let Ok(dir) = env::var("PROJECT_DIR")
1598        && !dir.is_empty()
1599    {
1600        return Ok(PathBuf::from(dir));
1601    }
1602
1603    // Look up registered projects in config
1604    if let Ok(paths) = config::get_project_paths() {
1605        for path_str in &paths {
1606            let path = Path::new(path_str);
1607            let orch_yml = path.join(".orch.yml");
1608            let legacy = path.join(".orchestrator.yml");
1609            let config_file = if Path::new(&orch_yml).exists() {
1610                orch_yml
1611            } else if Path::new(&legacy).exists() {
1612                legacy
1613            } else {
1614                continue;
1615            };
1616            if let Ok(content) = std_fs::read_to_string(&config_file)
1617                && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1618                && let Some(r) = doc
1619                    .get("gh")
1620                    .and_then(|gh| gh.get("repo"))
1621                    .and_then(|r| r.as_str())
1622                && r == repo
1623            {
1624                return Ok(path_str.clone().into());
1625            }
1626        }
1627    }
1628
1629    anyhow::bail!("project dir not found for repo: {repo}")
1630}
1631
1632/// Check for merged PRs and update task status accordingly.
1633///
1634/// Queries status:in_review and status:needs_review tasks, checks if their PR
1635/// is merged, and updates status to done if merged.
1636pub(crate) async fn check_merged_prs(
1637    backend: &Arc<dyn ExternalBackend>,
1638    repo: &str,
1639    store: &Arc<TaskStore>,
1640    task_manager: &Arc<TaskManager>,
1641    in_review_tasks: &[ReviewTaskSnapshot],
1642    needs_review_tasks: &[ReviewTaskSnapshot],
1643) -> anyhow::Result<()> {
1644    let mut branch_map: HashMap<String, String> = HashMap::new();
1645    let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1646        .iter()
1647        .chain(needs_review_tasks.iter())
1648        .collect();
1649
1650    for task in &all_review_tasks {
1651        if !task.stored.branch.is_empty() {
1652            branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1653        }
1654    }
1655
1656    tracing::debug!(
1657        count = all_review_tasks.len(),
1658        "checking review tasks for merged PRs"
1659    );
1660
1661    // Collect (task_id, branch) pairs — skip tasks with no branch recorded.
1662    // Use the pre-built branch map; fall back to store lookup only for tasks
1663    // not in the map (e.g. internal tasks, backend fallback path).
1664    let mut task_branches: Vec<(String, String)> = Vec::new();
1665    for task in all_review_tasks {
1666        let task_id = &task.external.id.0;
1667        let branch = if let Some(b) = branch_map.get(task_id) {
1668            b.clone()
1669        } else {
1670            match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1671                .await
1672                .map(|t| t.branch)
1673            {
1674                Some(b) if !b.is_empty() => b,
1675                _ => {
1676                    tracing::debug!(task_id, "no branch info, skipping PR check");
1677                    continue;
1678                }
1679            }
1680        };
1681        task_branches.push((task_id.clone(), branch));
1682    }
1683
1684    if task_branches.is_empty() {
1685        return Ok(());
1686    }
1687
1688    // Single GraphQL call for all branches — N REST calls → 1 GraphQL call.
1689    let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1690    let merged_map = match backend.batch_is_pr_merged(&branches).await {
1691        Ok(map) => map,
1692        Err(e) => {
1693            tracing::warn!(
1694                err = %e,
1695                branch_count = branches.len(),
1696                "batch PR merge check failed, retrying affected branches individually"
1697            );
1698            fallback_is_pr_merged_by_branch(backend, &branches).await
1699        }
1700    };
1701
1702    for (task_id, branch) in task_branches {
1703        let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1704        if !is_merged {
1705            continue;
1706        }
1707
1708        tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1709
1710        let id = ExternalId(task_id.clone());
1711        if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1712            tracing::warn!(task_id, err = %e, "failed to update status to done");
1713            continue;
1714        }
1715
1716        if let Err(e) = cleanup_task_worktree(&task_id, repo, store).await {
1717            tracing::warn!(task_id, err = %e, "worktree cleanup after merge failed");
1718        }
1719
1720        let comment = format!("PR merged, marking task complete{}", engine::orch_footer());
1721        if let Err(e) = backend.post_comment(&id, &comment).await {
1722            tracing::warn!(task_id, err = %e, "failed to post comment");
1723        }
1724    }
1725
1726    Ok(())
1727}
1728
1729async fn fallback_is_pr_merged_by_branch(
1730    backend: &Arc<dyn ExternalBackend>,
1731    branches: &[String],
1732) -> HashMap<String, bool> {
1733    let futs: Vec<_> = branches
1734        .iter()
1735        .map(|branch| {
1736            let backend = Arc::clone(backend);
1737            let branch = branch.clone();
1738            async move {
1739                match backend.is_pr_merged(&branch).await {
1740                    Ok(is_merged) => Some((branch, is_merged)),
1741                    Err(err) => {
1742                        tracing::warn!(
1743                            branch,
1744                            err = %err,
1745                            "fallback PR merge check failed for branch"
1746                        );
1747                        None
1748                    }
1749                }
1750            }
1751        })
1752        .collect();
1753
1754    future::join_all(futs).await.into_iter().flatten().collect()
1755}
1756
1757#[cfg(test)]
1758mod tests {
1759    use super::*;
1760    use crate::engine::tasks;
1761    use crate::store::{NewTask, TaskStore};
1762    use async_trait::async_trait;
1763    use std::collections::HashMap;
1764    use std::process;
1765    use std::sync::Mutex;
1766    use std::sync::atomic::{AtomicUsize, Ordering};
1767
1768    // worktree_age_hours
1769
1770    #[tokio::test]
1771    async fn worktree_age_hours_returns_none_for_missing_dir() {
1772        let tmp = tempfile::tempdir().unwrap();
1773        let missing = tmp.path().join("does-not-exist");
1774        assert!(worktree_age_hours(&missing).await.is_none());
1775    }
1776
1777    #[tokio::test]
1778    async fn worktree_age_hours_returns_some_for_existing_dir() {
1779        let tmp = tempfile::tempdir().unwrap();
1780        // A freshly created directory should have age ~0 hours.
1781        let age = worktree_age_hours(tmp.path()).await;
1782        assert!(age.is_some());
1783        assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1784    }
1785
1786    // JanitorOptions::from_config defaults
1787
1788    #[test]
1789    fn janitor_options_default_values() {
1790        let opts = JanitorOptions::default();
1791        assert_eq!(opts.ttl_hours, 24);
1792        assert!(!opts.dry_run);
1793    }
1794
1795    // cleanup_task_worktree_with_opts TTL guard (skips if git missing)
1796
1797    /// Create a minimal git repo + worktree; return (tmp, worktree_dir).
1798    fn setup_test_repo() -> Option<(tempfile::TempDir, PathBuf)> {
1799        let tmp = tempfile::tempdir().ok()?;
1800
1801        // Create a real git repo (not bare) so we can add worktrees.
1802        let repo_dir = tmp.path().join("repo.git");
1803        std_fs::create_dir_all(&repo_dir).ok()?;
1804
1805        let init = process::Command::new("git")
1806            .args([
1807                "init",
1808                "--initial-branch=main",
1809                repo_dir
1810                    .to_str()
1811                    .expect("test repo path contains non-UTF-8 characters"),
1812            ])
1813            .output()
1814            .ok()?;
1815        if !init.status.success() {
1816            // Older git may not have --initial-branch; fall back.
1817            let _ = process::Command::new("git")
1818                .args([
1819                    "init",
1820                    repo_dir
1821                        .to_str()
1822                        .expect("test repo path contains non-UTF-8 characters"),
1823                ])
1824                .output()
1825                .ok()?;
1826        }
1827
1828        // Create an initial commit so the repo has a HEAD.
1829        let readme = repo_dir.join("README.md");
1830        std_fs::write(&readme, "# test").ok()?;
1831        process::Command::new("git")
1832            .args(["-C", repo_dir.to_str()?, "add", "."])
1833            .output()
1834            .ok()?;
1835        process::Command::new("git")
1836            .args([
1837                "-C",
1838                repo_dir.to_str()?,
1839                "-c",
1840                "user.email=test@test.com",
1841                "-c",
1842                "user.name=Test",
1843                "-c",
1844                "commit.gpgsign=false",
1845                "commit",
1846                "--allow-empty",
1847                "-m",
1848                "init",
1849            ])
1850            .output()
1851            .ok()?;
1852
1853        // Create a worktree branch.
1854        let wt_dir = tmp
1855            .path()
1856            .join("worktrees")
1857            .join("repo.git")
1858            .join("gh-issue-42-test");
1859        std_fs::create_dir_all(wt_dir.parent()?).ok()?;
1860        let wt_out = process::Command::new("git")
1861            .args([
1862                "-C",
1863                repo_dir.to_str()?,
1864                "worktree",
1865                "add",
1866                "-b",
1867                "gh-issue-42-test",
1868                wt_dir.to_str()?,
1869            ])
1870            .output()
1871            .ok()?;
1872
1873        if !wt_out.status.success() {
1874            return None;
1875        }
1876
1877        Some((tmp, wt_dir))
1878    }
1879
1880    #[tokio::test]
1881    async fn janitor_skips_young_worktree() {
1882        let Some((tmp, wt_dir)) = setup_test_repo() else {
1883            eprintln!("skipping test: git not available");
1884            return;
1885        };
1886
1887        // The worktree was just created → age is 0h < ttl_hours=24 → must be skipped.
1888        assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1889
1890        // Run the TTL guard check directly (without a full engine).
1891        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1892        assert!(
1893            age < 24,
1894            "freshly created worktree should be younger than 24h (age={age}h)"
1895        );
1896
1897        // Explicitly verify: with ttl_hours=24 the worktree stays.
1898        let opts = JanitorOptions {
1899            ttl_hours: 24,
1900            dry_run: true,
1901        };
1902        // Age < ttl → should skip (worktree_age_hours < opts.ttl_hours).
1903        assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1904
1905        drop(tmp); // cleanup tempdir
1906    }
1907
1908    #[tokio::test]
1909    async fn janitor_eligible_when_ttl_zero() {
1910        let Some((tmp, wt_dir)) = setup_test_repo() else {
1911            eprintln!("skipping test: git not available");
1912            return;
1913        };
1914
1915        // With ttl_hours=0 even a brand-new worktree is eligible.
1916        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1917        let opts = JanitorOptions {
1918            ttl_hours: 0,
1919            dry_run: true,
1920        };
1921        assert!(
1922            age >= opts.ttl_hours,
1923            "worktree should be eligible when ttl=0 (age={age}h)"
1924        );
1925
1926        drop(tmp);
1927    }
1928
1929    #[tokio::test]
1930    async fn dry_run_does_not_remove_worktree() {
1931        // Verify that with dry_run=true the worktree directory is NOT removed.
1932        let Some((tmp, wt_dir)) = setup_test_repo() else {
1933            eprintln!("skipping test: git not available");
1934            return;
1935        };
1936
1937        assert!(wt_dir.exists(), "worktree must exist before test");
1938
1939        // With dry_run=true and ttl_hours=0 the janitor would normally remove the
1940        // worktree, but dry_run prevents it.
1941        let opts = JanitorOptions {
1942            ttl_hours: 0,
1943            dry_run: true,
1944        };
1945
1946        // The janitor needs a repo_root to run git commands.  We point it at the
1947        // bare repo we created inside tmp (even though it's not registered in config).
1948        // Because resolve_repo_root will fail for our synthetic repo slug, we test the
1949        // inner helpers directly here.
1950        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1951        let in_use = false; // no tmux running in tests
1952        let skip = age < opts.ttl_hours || in_use;
1953
1954        if !skip && opts.dry_run {
1955            // dry-run: log only, don't touch filesystem.
1956            tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1957        } else if !skip {
1958            // This branch would do actual removal — but we're in dry_run mode above.
1959        }
1960
1961        // Worktree must still be there.
1962        assert!(
1963            wt_dir.exists(),
1964            "dry-run must not remove the worktree directory"
1965        );
1966
1967        drop(tmp);
1968    }
1969
1970    #[test]
1971    fn fallback_branch_path_construction() {
1972        // Verify the new fallback logic: when worktree path is absent but
1973        // branch is known, we should construct the path correctly.
1974        let tmp = tempfile::tempdir().unwrap();
1975        let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1976        std_fs::create_dir_all(&wt_dir).unwrap();
1977
1978        // Simulate: worktree=None, branch=Some("gh-issue-7-fix"), repo="owner/myrepo"
1979        let branch = "gh-issue-7-fix";
1980        let repo = "owner/myrepo";
1981        let worktrees_base = tmp.path();
1982
1983        let project = repo
1984            .rsplit('/')
1985            .next()
1986            .unwrap_or(repo)
1987            .trim_end_matches(".git");
1988        let candidate = worktrees_base.join(project).join(branch);
1989
1990        assert_eq!(candidate, wt_dir);
1991        assert!(
1992            candidate.exists(),
1993            "fallback path should point to existing dir"
1994        );
1995    }
1996
1997    /// A task with no worktree and no branch should return Ok(false) —
1998    /// nothing was cleaned, so git pull should NOT be triggered.
1999    #[tokio::test]
2000    async fn cleanup_returns_false_when_nothing_to_clean() {
2001        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2002
2003        // Create a done task with no worktree or branch fields set
2004        let id = store
2005            .create(&NewTask {
2006                external_id: Some("999".to_string()),
2007                repo: "owner/repo".to_string(),
2008                origin: "github".to_string(),
2009                title: "Already cleaned".to_string(),
2010                ..Default::default()
2011            })
2012            .await
2013            .unwrap();
2014        store.update_status(id, TaskStatus::Done).await.unwrap();
2015
2016        let opts = JanitorOptions {
2017            ttl_hours: 0,
2018            dry_run: false,
2019        };
2020
2021        let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
2022        // resolve_repo_root will fail for synthetic repo — that's OK, the point is
2023        // that when nothing needs cleaning the function should not report true.
2024        // In production this means no git pull is triggered.
2025        if let Ok(cleaned) = result {
2026            assert!(
2027                !cleaned,
2028                "cleanup should return false when there is no worktree or branch to clean"
2029            );
2030        } else {
2031            // resolve_repo_root fails for test repo — acceptable.
2032            // The bug was that even with no worktree/branch, Ok(true) was returned.
2033        }
2034    }
2035
2036    /// When the stored worktree path no longer exists on disk and there is no
2037    /// branch recorded, cleanup must mark the task as cleaned so subsequent
2038    /// runs do not keep retrying (idempotency regression test for #1021).
2039    #[tokio::test]
2040    async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
2041        let tmp = tempfile::tempdir().unwrap();
2042        // A path that does NOT exist on disk.
2043        let stale_wt = tmp.path().join("stale-worktree");
2044        assert!(!stale_wt.exists(), "stale path must not exist");
2045
2046        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2047
2048        let id = store
2049            .create(&NewTask {
2050                external_id: Some("1005".to_string()),
2051                repo: "owner/repo".to_string(),
2052                origin: "github".to_string(),
2053                title: "Stale worktree task".to_string(),
2054                ..Default::default()
2055            })
2056            .await
2057            .unwrap();
2058        store.update_status(id, TaskStatus::Done).await.unwrap();
2059        // Set the stale worktree path on the task.
2060        store
2061            .set_fields(
2062                id,
2063                &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
2064            )
2065            .await
2066            .unwrap();
2067
2068        // Verify the task starts out NOT cleaned.
2069        let before = store.get(id).await.unwrap();
2070        assert!(!before.worktree_cleaned, "task should not be cleaned yet");
2071        assert!(
2072            !before.worktree.is_empty(),
2073            "task must have a worktree path set"
2074        );
2075
2076        let opts = JanitorOptions {
2077            ttl_hours: 0,
2078            dry_run: false,
2079        };
2080
2081        let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
2082
2083        // The function should succeed (Ok) — stale path is not an error.
2084        // It returns Ok(false) because there was nothing to actively remove.
2085        match result {
2086            Ok(_) => {}
2087            Err(e) => panic!("cleanup should not error for stale path: {e}"),
2088        }
2089
2090        // The task must now be marked cleaned so the next cycle skips it.
2091        let after = store.get(id).await.unwrap();
2092        assert!(
2093            after.worktree_cleaned,
2094            "task must be marked cleaned when stored worktree path is already gone"
2095        );
2096    }
2097
2098    /// `remove_worktree_and_branch` returns true when the worktree directory
2099    /// is successfully removed (regression test for #1143).
2100    #[tokio::test]
2101    async fn remove_worktree_returns_true_on_success() {
2102        let Some((tmp, wt_dir)) = setup_test_repo() else {
2103            eprintln!("skipping test: git not available");
2104            return;
2105        };
2106
2107        let repo_dir = tmp.path().join("repo.git");
2108        assert!(wt_dir.exists(), "worktree must exist before removal");
2109
2110        let removed =
2111            remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
2112                .await;
2113
2114        assert!(
2115            removed,
2116            "remove_worktree_and_branch should return true when directory is gone"
2117        );
2118        assert!(!wt_dir.exists(), "worktree directory should be removed");
2119    }
2120
2121    /// Successful worktree cleanup must still delete the local branch.
2122    /// This guards against early-return regressions in `remove_worktree_and_branch`.
2123    #[tokio::test]
2124    async fn remove_worktree_success_still_deletes_local_branch() {
2125        let Some((tmp, wt_dir)) = setup_test_repo() else {
2126            eprintln!("skipping test: git not available");
2127            return;
2128        };
2129
2130        let repo_dir = tmp.path().join("repo.git");
2131        let repo_dir_str = repo_dir
2132            .to_str()
2133            .expect("test repo path contains non-UTF-8 characters");
2134        let branch = "gh-issue-42-test";
2135
2136        let before = process::Command::new("git")
2137            .args([
2138                "-C",
2139                repo_dir_str,
2140                "show-ref",
2141                "--verify",
2142                "--quiet",
2143                &format!("refs/heads/{branch}"),
2144            ])
2145            .status()
2146            .unwrap();
2147        assert!(
2148            before.success(),
2149            "test setup must create local branch before cleanup"
2150        );
2151
2152        let removed =
2153            remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
2154        assert!(removed, "worktree should be removed");
2155
2156        let after = process::Command::new("git")
2157            .args([
2158                "-C",
2159                repo_dir_str,
2160                "show-ref",
2161                "--verify",
2162                "--quiet",
2163                &format!("refs/heads/{branch}"),
2164            ])
2165            .status()
2166            .unwrap();
2167        assert!(
2168            !after.success(),
2169            "local branch should be deleted after successful worktree cleanup"
2170        );
2171    }
2172
2173    /// `remove_worktree_and_branch` returns false when the worktree directory
2174    /// cannot be removed (regression test for #1143 — prevents orphaned worktrees
2175    /// from being permanently marked as cleaned).
2176    #[tokio::test]
2177    async fn remove_worktree_returns_false_when_dir_persists() {
2178        let tmp = tempfile::tempdir().unwrap();
2179        // Create a plain directory (not a real git worktree) — git will fail
2180        // to remove it, so the directory stays on disk.
2181        let fake_wt = tmp.path().join("not-a-worktree");
2182        std_fs::create_dir_all(&fake_wt).unwrap();
2183
2184        // repo_root doesn't matter much — it just needs to exist for the -C flag.
2185        // Using tmp itself; git commands will fail but that's the point.
2186        let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
2187
2188        // The directory still exists because git couldn't remove it.
2189        // Before the fix, this would still have been marked as cleaned.
2190        assert!(
2191            !removed,
2192            "remove_worktree_and_branch should return false when directory still exists"
2193        );
2194        assert!(fake_wt.exists(), "directory should still be on disk");
2195    }
2196
2197    struct MergeCheckMockBackend {
2198        batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
2199        single_results: HashMap<String, anyhow::Result<bool>>,
2200        posted_comments: Arc<Mutex<Vec<(String, String)>>>,
2201        single_checked_branches: Arc<Mutex<Vec<String>>>,
2202    }
2203
2204    impl MergeCheckMockBackend {
2205        fn with_batch_error(
2206            error: anyhow::Error,
2207            single_results: HashMap<String, anyhow::Result<bool>>,
2208        ) -> Self {
2209            Self {
2210                batch_result: Mutex::new(Err(error)),
2211                single_results,
2212                posted_comments: Arc::new(Mutex::new(Vec::new())),
2213                single_checked_branches: Arc::new(Mutex::new(Vec::new())),
2214            }
2215        }
2216    }
2217
2218    #[async_trait]
2219    impl ExternalBackend for MergeCheckMockBackend {
2220        fn name(&self) -> &'static str {
2221            "mock"
2222        }
2223
2224        async fn create_task(
2225            &self,
2226            _title: &str,
2227            _body: &str,
2228            _labels: &[String],
2229        ) -> anyhow::Result<ExternalId> {
2230            anyhow::bail!("not implemented")
2231        }
2232
2233        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2234            anyhow::bail!("not implemented")
2235        }
2236
2237        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2238            Ok(vec![])
2239        }
2240
2241        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2242            Ok(vec![])
2243        }
2244
2245        async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2246            self.posted_comments
2247                .lock()
2248                .unwrap()
2249                .push((id.0.clone(), body.to_string()));
2250            Ok(())
2251        }
2252
2253        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2254            Ok(())
2255        }
2256
2257        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2258            Ok(())
2259        }
2260
2261        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2262            Ok(vec![])
2263        }
2264
2265        async fn create_sub_task(
2266            &self,
2267            _parent: &ExternalId,
2268            _title: &str,
2269            _body: &str,
2270            _labels: &[String],
2271        ) -> anyhow::Result<ExternalId> {
2272            anyhow::bail!("not implemented")
2273        }
2274
2275        async fn health_check(&self) -> anyhow::Result<()> {
2276            Ok(())
2277        }
2278
2279        async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2280            self.single_checked_branches
2281                .lock()
2282                .unwrap()
2283                .push(branch.to_string());
2284            match self.single_results.get(branch) {
2285                Some(Ok(is_merged)) => Ok(*is_merged),
2286                Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2287                None => Ok(false),
2288            }
2289        }
2290
2291        async fn batch_is_pr_merged(
2292            &self,
2293            _branches: &[String],
2294        ) -> anyhow::Result<HashMap<String, bool>> {
2295            match &*self.batch_result.lock().unwrap() {
2296                Ok(map) => Ok(map.clone()),
2297                Err(err) => Err(anyhow::anyhow!(err.to_string())),
2298            }
2299        }
2300    }
2301
2302    #[tokio::test]
2303    async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2304        let single_results = HashMap::from([
2305            ("branch-1".to_string(), Ok(true)),
2306            ("branch-2".to_string(), Ok(false)),
2307        ]);
2308        let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2309            anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2310            single_results,
2311        ));
2312        let backend_dyn: Arc<dyn ExternalBackend> = Arc::<MergeCheckMockBackend>::clone(&backend);
2313
2314        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2315        let task1 = store
2316            .create(&NewTask {
2317                external_id: Some("101".to_string()),
2318                repo: "owner/repo".to_string(),
2319                origin: "github".to_string(),
2320                title: "Task 101".to_string(),
2321                ..Default::default()
2322            })
2323            .await
2324            .unwrap();
2325        store
2326            .update_status(task1, TaskStatus::NeedsReview)
2327            .await
2328            .unwrap();
2329        store
2330            .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2331            .await
2332            .unwrap();
2333        // No .git file, so cleanup force-removes the directory
2334        let wt_dir = tempfile::tempdir().unwrap();
2335        let wt_path = wt_dir.path().join("branch-1");
2336        std_fs::create_dir_all(&wt_path).unwrap();
2337        store
2338            .set_fields(
2339                task1,
2340                &[("worktree", serde_json::json!(wt_path.to_string_lossy()))],
2341            )
2342            .await
2343            .unwrap();
2344
2345        let task2 = store
2346            .create(&NewTask {
2347                external_id: Some("102".to_string()),
2348                repo: "owner/repo".to_string(),
2349                origin: "github".to_string(),
2350                title: "Task 102".to_string(),
2351                ..Default::default()
2352            })
2353            .await
2354            .unwrap();
2355        store
2356            .update_status(task2, TaskStatus::InReview)
2357            .await
2358            .unwrap();
2359        store
2360            .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2361            .await
2362            .unwrap();
2363
2364        let task_manager = Arc::new(TaskManager::with_store(
2365            Arc::clone(&backend_dyn),
2366            Arc::clone(&store),
2367            "owner/repo".to_string(),
2368        ));
2369
2370        let in_review_tasks = store
2371            .list_by_status("owner/repo", TaskStatus::InReview)
2372            .await
2373            .unwrap()
2374            .into_iter()
2375            .map(|stored| ReviewTaskSnapshot {
2376                external: tasks::store_task_to_external(&stored),
2377                stored,
2378            })
2379            .collect::<Vec<_>>();
2380        let needs_review_tasks = store
2381            .list_by_status("owner/repo", TaskStatus::NeedsReview)
2382            .await
2383            .unwrap()
2384            .into_iter()
2385            .map(|stored| ReviewTaskSnapshot {
2386                external: tasks::store_task_to_external(&stored),
2387                stored,
2388            })
2389            .collect::<Vec<_>>();
2390
2391        check_merged_prs(
2392            &backend_dyn,
2393            "owner/repo",
2394            &store,
2395            &task_manager,
2396            &in_review_tasks,
2397            &needs_review_tasks,
2398        )
2399        .await
2400        .unwrap();
2401
2402        let checked = backend.single_checked_branches.lock().unwrap().clone();
2403        assert_eq!(
2404            checked,
2405            vec!["branch-2".to_string(), "branch-1".to_string()]
2406        );
2407
2408        let updated_task1 = store.get(task1).await.unwrap();
2409        let updated_task2 = store.get(task2).await.unwrap();
2410        assert_eq!(updated_task1.status, TaskStatus::Done);
2411        assert!(updated_task1.worktree_cleaned);
2412        assert!(!wt_path.exists());
2413        assert!(!updated_task2.worktree_cleaned);
2414        assert_eq!(updated_task2.status, TaskStatus::InReview);
2415
2416        let comments = backend.posted_comments.lock().unwrap().clone();
2417        assert_eq!(comments.len(), 1);
2418        assert_eq!(comments[0].0, "101");
2419        assert!(comments[0].1.contains("PR merged, marking task complete"));
2420    }
2421
2422    struct ReconciliationMockBackend {
2423        reconciliation_calls: Arc<AtomicUsize>,
2424    }
2425
2426    #[async_trait]
2427    impl ExternalBackend for ReconciliationMockBackend {
2428        fn name(&self) -> &'static str {
2429            "mock"
2430        }
2431
2432        async fn create_task(
2433            &self,
2434            _title: &str,
2435            _body: &str,
2436            _labels: &[String],
2437        ) -> anyhow::Result<ExternalId> {
2438            anyhow::bail!("not implemented")
2439        }
2440
2441        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2442            anyhow::bail!("not implemented")
2443        }
2444
2445        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2446            Ok(vec![])
2447        }
2448
2449        async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2450            self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2451            Ok(vec![])
2452        }
2453
2454        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2455            Ok(vec![])
2456        }
2457
2458        async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2459            Ok(())
2460        }
2461
2462        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2463            Ok(())
2464        }
2465
2466        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2467            Ok(())
2468        }
2469
2470        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2471            Ok(vec![])
2472        }
2473
2474        async fn create_sub_task(
2475            &self,
2476            _parent: &ExternalId,
2477            _title: &str,
2478            _body: &str,
2479            _labels: &[String],
2480        ) -> anyhow::Result<ExternalId> {
2481            anyhow::bail!("not implemented")
2482        }
2483
2484        async fn health_check(&self) -> anyhow::Result<()> {
2485            Ok(())
2486        }
2487    }
2488
2489    #[tokio::test]
2490    async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2491        let repo = "owner/repo-cleanup-rate-limit";
2492        RECONCILIATION_LAST_RUN.remove(repo);
2493
2494        let backend = Arc::new(ReconciliationMockBackend {
2495            reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2496        });
2497        let backend_dyn: Arc<dyn ExternalBackend> =
2498            Arc::<ReconciliationMockBackend>::clone(&backend);
2499        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2500        let task_manager = Arc::new(TaskManager::with_store(
2501            Arc::clone(&backend_dyn),
2502            Arc::clone(&store),
2503            repo.to_string(),
2504        ));
2505        let opts = JanitorOptions {
2506            ttl_hours: 0,
2507            dry_run: true,
2508        };
2509
2510        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2511            .await
2512            .unwrap();
2513        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2514            .await
2515            .unwrap();
2516
2517        assert_eq!(
2518            backend.reconciliation_calls.load(Ordering::SeqCst),
2519            1,
2520            "cleanup should not re-list reconciliation candidates on every sync pass"
2521        );
2522
2523        RECONCILIATION_LAST_RUN.remove(repo);
2524    }
2525
2526    /// Cleanup only collects done tasks. A blocked task waits on a person and resumes from its
2527    /// worktree and branch, so it must not even be considered (#3781)
2528    #[tokio::test]
2529    async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2530        let repo = "owner/repo-cleanup-blocked";
2531        RECONCILIATION_LAST_RUN.remove(repo);
2532        let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2533            reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2534        });
2535        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2536        let task_manager = Arc::new(TaskManager::with_store(
2537            Arc::clone(&backend_dyn),
2538            Arc::clone(&store),
2539            repo.to_string(),
2540        ));
2541
2542        let mut ids = Vec::new();
2543        for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2544            let id = store
2545                .create_internal(repo, title, "", "test", title, None)
2546                .await
2547                .unwrap();
2548            store
2549                .set_fields(
2550                    id,
2551                    &[
2552                        (
2553                            "worktree",
2554                            serde_json::json!(format!("/nonexistent/{title}")),
2555                        ),
2556                        ("branch", serde_json::json!("")),
2557                    ],
2558                )
2559                .await
2560                .unwrap();
2561            store.update_status(id, status).await.unwrap();
2562            ids.push(id);
2563        }
2564
2565        let opts = JanitorOptions {
2566            ttl_hours: 0,
2567            dry_run: false,
2568        };
2569        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2570            .await
2571            .unwrap();
2572
2573        assert!(
2574            !store.get(ids[0]).await.unwrap().worktree_cleaned,
2575            "blocked task must not be considered for cleanup"
2576        );
2577        assert!(
2578            store.get(ids[1]).await.unwrap().worktree_cleaned,
2579            "done task is cleaned, which proves the pass ran"
2580        );
2581        RECONCILIATION_LAST_RUN.remove(repo);
2582    }
2583
2584    fn git_ok(dir: &Path, args: &[&str]) -> String {
2585        let out = process::Command::new("git")
2586            .args([
2587                "-c",
2588                "commit.gpgsign=false",
2589                "-c",
2590                "user.name=T",
2591                "-c",
2592                "user.email=t@t",
2593            ])
2594            .args(args)
2595            .current_dir(dir)
2596            .output()
2597            .unwrap();
2598        assert!(
2599            out.status.success(),
2600            "git {args:?}: {}",
2601            String::from_utf8_lossy(&out.stderr)
2602        );
2603        String::from_utf8_lossy(&out.stdout).to_string()
2604    }
2605
2606    /// A clone of a bare origin with main pushed and branch `feat` one commit ahead
2607    fn origin_and_clone_with_feat() -> (tempfile::TempDir, PathBuf) {
2608        let tmp = tempfile::tempdir().unwrap();
2609        let bare = tmp.path().join("origin.git");
2610        std_fs::create_dir_all(&bare).unwrap();
2611        git_ok(&bare, &["init", "--bare", "-b", "main"]);
2612        git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2613        let clone = tmp.path().join("clone");
2614        std_fs::write(clone.join("base.txt"), "base").unwrap();
2615        git_ok(&clone, &["add", "."]);
2616        git_ok(&clone, &["commit", "-m", "base"]);
2617        git_ok(&clone, &["push", "origin", "main"]);
2618        git_ok(&clone, &["checkout", "-b", "feat"]);
2619        std_fs::write(clone.join("work.txt"), "work").unwrap();
2620        git_ok(&clone, &["add", "."]);
2621        git_ok(&clone, &["commit", "-m", "work"]);
2622        git_ok(&clone, &["checkout", "main"]);
2623        (tmp, clone)
2624    }
2625
2626    fn remote_has(clone: &Path, branch: &str) -> bool {
2627        !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2628            .trim()
2629            .is_empty()
2630    }
2631
2632    fn local_has(clone: &Path, branch: &str) -> bool {
2633        process::Command::new("git")
2634            .args([
2635                "show-ref",
2636                "--verify",
2637                "--quiet",
2638                &format!("refs/heads/{branch}"),
2639            ])
2640            .current_dir(clone)
2641            .status()
2642            .unwrap()
2643            .success()
2644    }
2645
2646    /// Deleting a pushed branch whose commits main lacks would destroy the only copy of the
2647    /// work, as startup cleanup did to a blocked task (#3781)
2648    #[tokio::test]
2649    async fn delete_branches_keeps_an_unmerged_remote_branch() {
2650        let (_tmp, clone) = origin_and_clone_with_feat();
2651        git_ok(&clone, &["push", "origin", "feat"]);
2652
2653        delete_branches("1", "feat", &clone).await;
2654
2655        assert!(
2656            remote_has(&clone, "feat"),
2657            "unmerged remote branch must survive"
2658        );
2659        assert!(
2660            !local_has(&clone, "feat"),
2661            "local copy is on origin, so it can go"
2662        );
2663    }
2664
2665    #[tokio::test]
2666    async fn delete_branches_removes_a_branch_main_already_contains() {
2667        let (_tmp, clone) = origin_and_clone_with_feat();
2668        git_ok(&clone, &["push", "origin", "feat"]);
2669        git_ok(&clone, &["merge", "--ff-only", "feat"]);
2670        git_ok(&clone, &["push", "origin", "main"]);
2671
2672        delete_branches("1", "feat", &clone).await;
2673
2674        assert!(!remote_has(&clone, "feat"));
2675        assert!(!local_has(&clone, "feat"));
2676    }
2677
2678    fn add_worktree(clone: &Path, tmp: &Path, branch: &str) -> PathBuf {
2679        let wt = tmp.join("wt");
2680        git_ok(clone, &["worktree", "add", wt.to_str().unwrap(), branch]);
2681        wt
2682    }
2683
2684    #[tokio::test]
2685    async fn cleanup_pushes_unpushed_commits_to_a_rescue_branch() {
2686        let (tmp, clone) = origin_and_clone_with_feat();
2687        let wt = add_worktree(&clone, tmp.path(), "feat");
2688
2689        let sha = git_ok(&clone, &["rev-parse", "--short", "feat"]);
2690        let sha = sha.trim();
2691        match rescue_unpushed_work("1", Some(&wt), "feat", &clone).await {
2692            Rescue::Pushed { branch, commits } => {
2693                assert_eq!(branch, "rescue/feat");
2694                assert!(rescue_comment(&branch, &commits).contains(sha));
2695            }
2696            other => panic!("expected a rescue push, got {other:?}"),
2697        }
2698
2699        let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2700
2701        assert!(removed);
2702        assert!(remote_has(&clone, "rescue/feat"));
2703        assert!(!local_has(&clone, "feat"));
2704    }
2705
2706    #[tokio::test]
2707    async fn cleanup_commits_uncommitted_changes_before_the_rescue() {
2708        let (tmp, clone) = origin_and_clone_with_feat();
2709        git_ok(&clone, &["push", "origin", "feat"]);
2710        let wt = add_worktree(&clone, tmp.path(), "feat");
2711        std_fs::write(wt.join("dirty.txt"), "dirty").unwrap();
2712
2713        let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2714
2715        assert!(removed);
2716        assert!(remote_has(&clone, "rescue/feat"));
2717    }
2718
2719    #[tokio::test]
2720    async fn cleanup_makes_no_rescue_branch_for_a_pushed_branch() {
2721        let (tmp, clone) = origin_and_clone_with_feat();
2722        git_ok(&clone, &["push", "origin", "feat"]);
2723        let wt = add_worktree(&clone, tmp.path(), "feat");
2724
2725        let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2726
2727        assert!(removed);
2728        assert!(!remote_has(&clone, "rescue/feat"));
2729        assert!(!local_has(&clone, "feat"));
2730    }
2731
2732    #[tokio::test]
2733    async fn rescue_skips_a_branch_on_the_remote_without_a_tracking_ref() {
2734        let (_tmp, clone) = origin_and_clone_with_feat();
2735        git_ok(&clone, &["push", "origin", "feat"]);
2736        git_ok(&clone, &["update-ref", "-d", "refs/remotes/origin/feat"]);
2737
2738        let rescue = rescue_unpushed_work("1", None, "feat", &clone).await;
2739
2740        assert_eq!(rescue, Rescue::Clean);
2741        assert!(!remote_has(&clone, "rescue/feat"));
2742    }
2743
2744    #[tokio::test]
2745    async fn merged_pr_cleanup_pushes_no_rescue_branch() {
2746        let (tmp, clone) = origin_and_clone_with_feat();
2747        let wt = add_worktree(&clone, tmp.path(), "feat");
2748
2749        let removed =
2750            remove_worktree_and_branch_for("1", &wt, Some("feat"), &clone, false, true).await;
2751
2752        assert!(removed);
2753        assert!(!wt.exists());
2754        assert!(!remote_has(&clone, "rescue/feat"));
2755    }
2756
2757    #[tokio::test]
2758    async fn unmerged_cleanup_still_pushes_a_rescue_branch() {
2759        let (tmp, clone) = origin_and_clone_with_feat();
2760        let wt = add_worktree(&clone, tmp.path(), "feat");
2761
2762        let removed =
2763            remove_worktree_and_branch_for("1", &wt, Some("feat"), &clone, false, false).await;
2764
2765        assert!(removed);
2766        assert!(remote_has(&clone, "rescue/feat"));
2767    }
2768
2769    #[tokio::test]
2770    async fn rescue_ignores_a_remote_tip_missing_locally() {
2771        let (tmp, clone) = origin_and_clone_with_feat();
2772        git_ok(&clone, &["push", "origin", "feat"]);
2773        let other = tmp.path().join("other");
2774        git_ok(
2775            tmp.path(),
2776            &["clone", "-b", "feat", "origin.git", other.to_str().unwrap()],
2777        );
2778        std_fs::write(other.join("extra.txt"), "extra").unwrap();
2779        git_ok(&other, &["add", "."]);
2780        git_ok(&other, &["commit", "-m", "extra"]);
2781        git_ok(&other, &["push", "origin", "feat"]);
2782
2783        let rescue = rescue_unpushed_work("1", None, "feat", &clone).await;
2784
2785        assert_ne!(rescue, Rescue::Failed);
2786    }
2787
2788    #[tokio::test]
2789    async fn cleanup_keeps_worktree_and_branch_when_the_rescue_push_fails() {
2790        let (tmp, clone) = origin_and_clone_with_feat();
2791        let wt = add_worktree(&clone, tmp.path(), "feat");
2792        git_ok(
2793            &clone,
2794            &["remote", "set-url", "origin", "/nonexistent/origin.git"],
2795        );
2796
2797        let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2798
2799        assert!(!removed);
2800        assert!(wt.exists());
2801        assert!(local_has(&clone, "feat"));
2802    }
2803
2804    #[tokio::test]
2805    async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2806        let (_tmp, clone) = origin_and_clone_with_feat();
2807
2808        delete_branches("1", "feat", &clone).await;
2809
2810        assert!(
2811            local_has(&clone, "feat"),
2812            "unpushed commits must not be deleted"
2813        );
2814    }
2815}