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