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            crate::engine::runner::git_ops::clear_push_lease(repo_root, br).await;
1088        }
1089        Ok(output) => {
1090            let stderr = String::from_utf8_lossy(&output.stderr);
1091            tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1092        }
1093        Err(e) => {
1094            tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1095        }
1096    }
1097}
1098
1099/// Returns `true` if any active tmux pane has its cwd inside `worktree`.
1100///
1101/// Uses `tmux list-panes -a -F '#{pane_current_path}'` to enumerate all
1102/// pane working directories across every session. If tmux is not running
1103/// (command fails), we conservatively return `false` so cleanup can proceed.
1104async fn is_worktree_in_active_session(worktree: &std::path::Path) -> bool {
1105    let output = Command::new("tmux")
1106        .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1107        .output()
1108        .await;
1109
1110    match output {
1111        Ok(o) if o.status.success() => {
1112            let worktree_str = worktree.to_string_lossy();
1113            String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1114                let pane_path = line.trim();
1115                // Match exact path or any path that starts with worktree + separator.
1116                pane_path == worktree_str.as_ref()
1117                    || pane_path.starts_with(&format!("{worktree_str}/"))
1118            })
1119        }
1120        // tmux not running or no sessions — treat as safe to clean.
1121        _ => false,
1122    }
1123}
1124
1125/// Returns how many hours old the worktree directory is, based on its mtime.
1126///
1127/// Returns `None` if the directory does not exist or the mtime cannot be read.
1128async fn worktree_age_hours(worktree: &std::path::Path) -> Option<u64> {
1129    // Use tokio::fs to avoid blocking the async runtime when reading metadata.
1130    let metadata = tokio::fs::metadata(worktree).await.ok()?;
1131    let modified = metadata.modified().ok()?;
1132    let age = std::time::SystemTime::now().duration_since(modified).ok()?;
1133    Some(age.as_secs() / 3600)
1134}
1135
1136/// Resolve the repo root for an orphaned worktree and remove it.
1137///
1138/// Used by `orch service doctor --fix` and `orch service prune` to clean up worktrees that are
1139/// no longer owned by any task. Returns `true` if the worktree directory is gone
1140/// after the call.
1141pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &std::path::Path) -> bool {
1142    let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1143        Ok(root) => root,
1144        Err(e) => {
1145            tracing::warn!(
1146                worktree = %wt.display(),
1147                error = %e,
1148                "cannot resolve repo root for orphaned worktree"
1149            );
1150            return false;
1151        }
1152    };
1153    remove_worktree_and_branch(task_id, wt, None, std::path::Path::new(&repo_root), false).await
1154}
1155
1156/// Resolve the main git repository root for an orphaned worktree.
1157///
1158/// First tries to parse the `.git` file's `gitdir:` pointer (two levels up from
1159/// the pointer gives the bare repo root, which is the orch convention). Falls back
1160/// to looking up registered projects by directory name when the `.git` file is
1161/// absent or unparseable.
1162pub(crate) async fn resolve_repo_root_for_orphaned_worktree(
1163    wt: &std::path::Path,
1164) -> anyhow::Result<String> {
1165    let git_file = wt.join(".git");
1166    if tokio::fs::try_exists(&git_file).await.unwrap_or(false)
1167        && let Ok(content) = tokio::fs::read_to_string(&git_file).await
1168    {
1169        for line in content.lines() {
1170            if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1171                // gitdir points to: <repo>/.git/worktrees/<name> (non-bare)
1172                //                or <repo>.git/worktrees/<name>   (bare)
1173                // Two levels up reaches the .git dir / bare repo root.
1174                let gitdir_path = std::path::PathBuf::from(gitdir);
1175                if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1176                    return Ok(repo_root.display().to_string());
1177                }
1178            }
1179        }
1180    }
1181
1182    // Fallback: look up registered projects by the project-name component of the
1183    // worktree path (format: ~/.orch/worktrees/<project>/<branch>).
1184    let worktrees_dir = crate::home::worktrees_dir()?;
1185    if let Ok(relative) = wt.strip_prefix(&worktrees_dir)
1186        && let Some(project_name) = relative.components().next()
1187    {
1188        let project_name = project_name.as_os_str().to_string_lossy();
1189
1190        if let Ok(paths) = crate::config::get_project_paths() {
1191            for path_str in &paths {
1192                let path = std::path::Path::new(path_str);
1193                if let Some(name) = path.file_name()
1194                    && name.to_string_lossy() == project_name
1195                {
1196                    return Ok(path_str.clone());
1197                }
1198            }
1199        }
1200
1201        // Try bare clone at ~/.orch/projects/<owner>/<project>.git
1202        let parts: Vec<&str> = project_name.split('/').collect();
1203        let bare = if parts.len() == 2 {
1204            crate::home::projects_dir()
1205                .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1206                .unwrap_or_default()
1207        } else {
1208            return Err(anyhow::anyhow!(
1209                "cannot determine owner for single-segment project name '{project_name}' — \
1210                     bare repo location is ambiguous; register the project with 'owner/repo' format"
1211            ));
1212        };
1213        if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1214            return Ok(bare.display().to_string());
1215        }
1216    }
1217
1218    anyhow::bail!(
1219        "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1220        wt.display()
1221    )
1222}
1223
1224/// Resolve the main git repository root from a worktree's .git file.
1225///
1226/// For cross-project worktree cleanup, we cannot rely on `resolve_repo_root` because
1227/// the worktree may belong to a different project than the current repo context.
1228/// Instead, we extract the repo root from the worktree's `.git` file which contains
1229/// a `gitdir:` pointer to the main repo's git directory.
1230pub async fn resolve_repo_root_from_worktree(wt: &std::path::Path) -> anyhow::Result<String> {
1231    // For worktrees, .git is a file (not a directory) containing gitdir path
1232    let git_file = wt.join(".git");
1233    if !tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1234        anyhow::bail!(".git file not found in worktree at {}", wt.display());
1235    }
1236
1237    let content = tokio::fs::read_to_string(&git_file)
1238        .await
1239        .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1240
1241    // Parse gitdir line: "gitdir: /path/to/repo/.git/worktrees/<name>"
1242    for line in content.lines() {
1243        if let Some(gitdir) = line.strip_prefix("gitdir:") {
1244            let gitdir = gitdir.trim();
1245            // The main repo is the parent of the .git directory
1246            // gitdir points to: <repo>/.git/worktrees/<name>
1247            // We need to go up 3 levels: worktrees/<name> -> .git -> repo
1248            let gitdir_path = std::path::Path::new(gitdir);
1249            if let Some(repo_root) = gitdir_path
1250                .parent() // worktrees/<name>
1251                .and_then(|p| p.parent()) // .git
1252                .and_then(|p| p.parent())
1253            // repo root
1254            {
1255                return Ok(repo_root.display().to_string());
1256            }
1257        }
1258    }
1259
1260    anyhow::bail!(
1261        "cannot resolve repo root from worktree .git file at {}",
1262        wt.display()
1263    )
1264}
1265
1266/// Resolve the main git repository root path for a project.
1267///
1268/// Looks up the local project path from config, then verifies it's a git repo.
1269/// This avoids relying on cwd (which is undefined under launchd services).
1270pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1271    // Look up the local path from registered projects
1272    let paths = crate::config::get_project_paths().unwrap_or_default();
1273    for path_str in &paths {
1274        let path = std::path::Path::new(path_str);
1275        let orch_yml = path.join(".orch.yml");
1276        let legacy = path.join(".orchestrator.yml");
1277        let config_file = if tokio::fs::try_exists(&orch_yml).await.unwrap_or(false) {
1278            orch_yml
1279        } else if tokio::fs::try_exists(&legacy).await.unwrap_or(false) {
1280            legacy
1281        } else {
1282            continue;
1283        };
1284        if let Ok(content) = tokio::fs::read_to_string(&config_file).await
1285            && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1286            && let Some(r) = doc
1287                .get("gh")
1288                .and_then(|gh| gh.get("repo"))
1289                .and_then(|r| r.as_str())
1290            && r == repo
1291        {
1292            return Ok(path_str.clone());
1293        }
1294    }
1295
1296    // Fallback: try bare clone in ~/.orch/projects/<owner>/<repo>.git
1297    let parts: Vec<&str> = repo.split('/').collect();
1298    let bare = if parts.len() == 2 {
1299        crate::home::projects_dir()
1300            .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1301            .unwrap_or_default()
1302    } else {
1303        std::path::PathBuf::new()
1304    };
1305    if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1306        return Ok(bare.display().to_string());
1307    }
1308
1309    anyhow::bail!(
1310        "cannot find local path for project {repo} — \
1311         checked {} registered project(s) and bare clone at {}",
1312        paths.len(),
1313        bare.display()
1314    )
1315}
1316
1317/// Resolve the project directory for a given repo string.
1318///
1319/// Replicates the logic from `TaskRunner::resolve_project_dir()` without requiring
1320/// a Runner instance. Used by the orphan scanner to enumerate active worktrees.
1321fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<std::path::PathBuf> {
1322    // Check PROJECT_DIR env var first
1323    if let Ok(dir) = std::env::var("PROJECT_DIR")
1324        && !dir.is_empty()
1325    {
1326        return Ok(std::path::PathBuf::from(dir));
1327    }
1328
1329    // Look up registered projects in config
1330    if let Ok(paths) = crate::config::get_project_paths() {
1331        for path_str in &paths {
1332            let path = std::path::Path::new(path_str);
1333            let orch_yml = path.join(".orch.yml");
1334            let legacy = path.join(".orchestrator.yml");
1335            let config_file = if std::path::Path::new(&orch_yml).exists() {
1336                orch_yml
1337            } else if std::path::Path::new(&legacy).exists() {
1338                legacy
1339            } else {
1340                continue;
1341            };
1342            if let Ok(content) = std::fs::read_to_string(&config_file)
1343                && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1344                && let Some(r) = doc
1345                    .get("gh")
1346                    .and_then(|gh| gh.get("repo"))
1347                    .and_then(|r| r.as_str())
1348                && r == repo
1349            {
1350                return Ok(path_str.clone().into());
1351            }
1352        }
1353    }
1354
1355    anyhow::bail!("project dir not found for repo: {repo}")
1356}
1357
1358/// Check for merged PRs and update task status accordingly.
1359///
1360/// Queries status:in_review and status:needs_review tasks, checks if their PR
1361/// is merged, and updates status to done if merged.
1362pub(crate) async fn check_merged_prs(
1363    backend: &Arc<dyn ExternalBackend>,
1364    repo: &str,
1365    store: &Arc<TaskStore>,
1366    task_manager: &Arc<TaskManager>,
1367    in_review_tasks: &[ReviewTaskSnapshot],
1368    needs_review_tasks: &[ReviewTaskSnapshot],
1369) -> anyhow::Result<()> {
1370    let mut branch_map: std::collections::HashMap<String, String> =
1371        std::collections::HashMap::new();
1372    let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1373        .iter()
1374        .chain(needs_review_tasks.iter())
1375        .collect();
1376
1377    for task in &all_review_tasks {
1378        if !task.stored.branch.is_empty() {
1379            branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1380        }
1381    }
1382
1383    tracing::debug!(
1384        count = all_review_tasks.len(),
1385        "checking review tasks for merged PRs"
1386    );
1387
1388    // Collect (task_id, branch) pairs — skip tasks with no branch recorded.
1389    // Use the pre-built branch map; fall back to store lookup only for tasks
1390    // not in the map (e.g. internal tasks, backend fallback path).
1391    let mut task_branches: Vec<(String, String)> = Vec::new();
1392    for task in all_review_tasks {
1393        let task_id = &task.external.id.0;
1394        let branch = if let Some(b) = branch_map.get(task_id) {
1395            b.clone()
1396        } else {
1397            match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1398                .await
1399                .map(|t| t.branch)
1400            {
1401                Some(b) if !b.is_empty() => b,
1402                _ => {
1403                    tracing::debug!(task_id, "no branch info, skipping PR check");
1404                    continue;
1405                }
1406            }
1407        };
1408        task_branches.push((task_id.clone(), branch));
1409    }
1410
1411    if task_branches.is_empty() {
1412        return Ok(());
1413    }
1414
1415    // Single GraphQL call for all branches — N REST calls → 1 GraphQL call.
1416    let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1417    let merged_map = match backend.batch_is_pr_merged(&branches).await {
1418        Ok(map) => map,
1419        Err(e) => {
1420            tracing::warn!(
1421                err = %e,
1422                branch_count = branches.len(),
1423                "batch PR merge check failed, retrying affected branches individually"
1424            );
1425            fallback_is_pr_merged_by_branch(backend, &branches).await
1426        }
1427    };
1428
1429    for (task_id, branch) in task_branches {
1430        let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1431        if !is_merged {
1432            continue;
1433        }
1434
1435        tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1436
1437        let id = ExternalId(task_id.clone());
1438        if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1439            tracing::warn!(task_id, err = %e, "failed to update status to done");
1440            continue;
1441        }
1442
1443        if let Err(e) = cleanup_task_worktree(&task_id, repo, store).await {
1444            tracing::warn!(task_id, err = %e, "worktree cleanup after merge failed");
1445        }
1446
1447        let comment = format!(
1448            "PR merged, marking task complete{}",
1449            crate::engine::orch_footer()
1450        );
1451        if let Err(e) = backend.post_comment(&id, &comment).await {
1452            tracing::warn!(task_id, err = %e, "failed to post comment");
1453        }
1454    }
1455
1456    Ok(())
1457}
1458
1459async fn fallback_is_pr_merged_by_branch(
1460    backend: &Arc<dyn ExternalBackend>,
1461    branches: &[String],
1462) -> HashMap<String, bool> {
1463    let futs: Vec<_> = branches
1464        .iter()
1465        .map(|branch| {
1466            let backend = Arc::clone(backend);
1467            let branch = branch.clone();
1468            async move {
1469                match backend.is_pr_merged(&branch).await {
1470                    Ok(is_merged) => Some((branch, is_merged)),
1471                    Err(err) => {
1472                        tracing::warn!(
1473                            branch,
1474                            err = %err,
1475                            "fallback PR merge check failed for branch"
1476                        );
1477                        None
1478                    }
1479                }
1480            }
1481        })
1482        .collect();
1483
1484    futures::future::join_all(futs)
1485        .await
1486        .into_iter()
1487        .flatten()
1488        .collect()
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493    use super::*;
1494    use crate::store::{NewTask, TaskStore};
1495    use async_trait::async_trait;
1496    use std::collections::HashMap;
1497    use std::sync::Mutex;
1498    use std::sync::atomic::{AtomicUsize, Ordering};
1499
1500    // worktree_age_hours
1501
1502    #[tokio::test]
1503    async fn worktree_age_hours_returns_none_for_missing_dir() {
1504        let tmp = tempfile::tempdir().unwrap();
1505        let missing = tmp.path().join("does-not-exist");
1506        assert!(worktree_age_hours(&missing).await.is_none());
1507    }
1508
1509    #[tokio::test]
1510    async fn worktree_age_hours_returns_some_for_existing_dir() {
1511        let tmp = tempfile::tempdir().unwrap();
1512        // A freshly created directory should have age ~0 hours.
1513        let age = worktree_age_hours(tmp.path()).await;
1514        assert!(age.is_some());
1515        assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1516    }
1517
1518    // JanitorOptions::from_config defaults
1519
1520    #[test]
1521    fn janitor_options_default_values() {
1522        let opts = JanitorOptions::default();
1523        assert_eq!(opts.ttl_hours, 24);
1524        assert!(!opts.dry_run);
1525    }
1526
1527    // cleanup_task_worktree_with_opts TTL guard (skips if git missing)
1528
1529    /// Create a minimal git repo + worktree; return (tmp, worktree_dir).
1530    fn setup_test_repo() -> Option<(tempfile::TempDir, std::path::PathBuf)> {
1531        let tmp = tempfile::tempdir().ok()?;
1532
1533        // Create a real git repo (not bare) so we can add worktrees.
1534        let repo_dir = tmp.path().join("repo.git");
1535        std::fs::create_dir_all(&repo_dir).ok()?;
1536
1537        let init = std::process::Command::new("git")
1538            .args([
1539                "init",
1540                "--initial-branch=main",
1541                repo_dir
1542                    .to_str()
1543                    .expect("test repo path contains non-UTF-8 characters"),
1544            ])
1545            .output()
1546            .ok()?;
1547        if !init.status.success() {
1548            // Older git may not have --initial-branch; fall back.
1549            let _ = std::process::Command::new("git")
1550                .args([
1551                    "init",
1552                    repo_dir
1553                        .to_str()
1554                        .expect("test repo path contains non-UTF-8 characters"),
1555                ])
1556                .output()
1557                .ok()?;
1558        }
1559
1560        // Create an initial commit so the repo has a HEAD.
1561        let readme = repo_dir.join("README.md");
1562        std::fs::write(&readme, "# test").ok()?;
1563        std::process::Command::new("git")
1564            .args(["-C", repo_dir.to_str()?, "add", "."])
1565            .output()
1566            .ok()?;
1567        std::process::Command::new("git")
1568            .args([
1569                "-C",
1570                repo_dir.to_str()?,
1571                "-c",
1572                "user.email=test@test.com",
1573                "-c",
1574                "user.name=Test",
1575                "-c",
1576                "commit.gpgsign=false",
1577                "commit",
1578                "--allow-empty",
1579                "-m",
1580                "init",
1581            ])
1582            .output()
1583            .ok()?;
1584
1585        // Create a worktree branch.
1586        let wt_dir = tmp
1587            .path()
1588            .join("worktrees")
1589            .join("repo.git")
1590            .join("gh-issue-42-test");
1591        std::fs::create_dir_all(wt_dir.parent()?).ok()?;
1592        let wt_out = std::process::Command::new("git")
1593            .args([
1594                "-C",
1595                repo_dir.to_str()?,
1596                "worktree",
1597                "add",
1598                "-b",
1599                "gh-issue-42-test",
1600                wt_dir.to_str()?,
1601            ])
1602            .output()
1603            .ok()?;
1604
1605        if !wt_out.status.success() {
1606            return None;
1607        }
1608
1609        Some((tmp, wt_dir))
1610    }
1611
1612    #[tokio::test]
1613    async fn janitor_skips_young_worktree() {
1614        let Some((tmp, wt_dir)) = setup_test_repo() else {
1615            eprintln!("skipping test: git not available");
1616            return;
1617        };
1618
1619        // The worktree was just created → age is 0h < ttl_hours=24 → must be skipped.
1620        assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1621
1622        // Run the TTL guard check directly (without a full engine).
1623        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1624        assert!(
1625            age < 24,
1626            "freshly created worktree should be younger than 24h (age={age}h)"
1627        );
1628
1629        // Explicitly verify: with ttl_hours=24 the worktree stays.
1630        let opts = JanitorOptions {
1631            ttl_hours: 24,
1632            dry_run: true,
1633        };
1634        // Age < ttl → should skip (worktree_age_hours < opts.ttl_hours).
1635        assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1636
1637        drop(tmp); // cleanup tempdir
1638    }
1639
1640    #[tokio::test]
1641    async fn janitor_eligible_when_ttl_zero() {
1642        let Some((tmp, wt_dir)) = setup_test_repo() else {
1643            eprintln!("skipping test: git not available");
1644            return;
1645        };
1646
1647        // With ttl_hours=0 even a brand-new worktree is eligible.
1648        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1649        let opts = JanitorOptions {
1650            ttl_hours: 0,
1651            dry_run: true,
1652        };
1653        assert!(
1654            age >= opts.ttl_hours,
1655            "worktree should be eligible when ttl=0 (age={age}h)"
1656        );
1657
1658        drop(tmp);
1659    }
1660
1661    #[tokio::test]
1662    async fn dry_run_does_not_remove_worktree() {
1663        // Verify that with dry_run=true the worktree directory is NOT removed.
1664        let Some((tmp, wt_dir)) = setup_test_repo() else {
1665            eprintln!("skipping test: git not available");
1666            return;
1667        };
1668
1669        assert!(wt_dir.exists(), "worktree must exist before test");
1670
1671        // With dry_run=true and ttl_hours=0 the janitor would normally remove the
1672        // worktree, but dry_run prevents it.
1673        let opts = JanitorOptions {
1674            ttl_hours: 0,
1675            dry_run: true,
1676        };
1677
1678        // The janitor needs a repo_root to run git commands.  We point it at the
1679        // bare repo we created inside tmp (even though it's not registered in config).
1680        // Because resolve_repo_root will fail for our synthetic repo slug, we test the
1681        // inner helpers directly here.
1682        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1683        let in_use = false; // no tmux running in tests
1684        let skip = age < opts.ttl_hours || in_use;
1685
1686        if !skip && opts.dry_run {
1687            // dry-run: log only, don't touch filesystem.
1688            tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1689        } else if !skip {
1690            // This branch would do actual removal — but we're in dry_run mode above.
1691        }
1692
1693        // Worktree must still be there.
1694        assert!(
1695            wt_dir.exists(),
1696            "dry-run must not remove the worktree directory"
1697        );
1698
1699        drop(tmp);
1700    }
1701
1702    #[test]
1703    fn fallback_branch_path_construction() {
1704        // Verify the new fallback logic: when worktree path is absent but
1705        // branch is known, we should construct the path correctly.
1706        let tmp = tempfile::tempdir().unwrap();
1707        let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1708        std::fs::create_dir_all(&wt_dir).unwrap();
1709
1710        // Simulate: worktree=None, branch=Some("gh-issue-7-fix"), repo="owner/myrepo"
1711        let branch = "gh-issue-7-fix";
1712        let repo = "owner/myrepo";
1713        let worktrees_base = tmp.path();
1714
1715        let project = repo
1716            .rsplit('/')
1717            .next()
1718            .unwrap_or(repo)
1719            .trim_end_matches(".git");
1720        let candidate = worktrees_base.join(project).join(branch);
1721
1722        assert_eq!(candidate, wt_dir);
1723        assert!(
1724            candidate.exists(),
1725            "fallback path should point to existing dir"
1726        );
1727    }
1728
1729    /// A task with no worktree and no branch should return Ok(false) —
1730    /// nothing was cleaned, so git pull should NOT be triggered.
1731    #[tokio::test]
1732    async fn cleanup_returns_false_when_nothing_to_clean() {
1733        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1734
1735        // Create a done task with no worktree or branch fields set
1736        let id = store
1737            .create(&NewTask {
1738                external_id: Some("999".to_string()),
1739                repo: "owner/repo".to_string(),
1740                origin: "github".to_string(),
1741                title: "Already cleaned".to_string(),
1742                ..Default::default()
1743            })
1744            .await
1745            .unwrap();
1746        store
1747            .update_status(id, crate::store::TaskStatus::Done)
1748            .await
1749            .unwrap();
1750
1751        let opts = JanitorOptions {
1752            ttl_hours: 0,
1753            dry_run: false,
1754        };
1755
1756        let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
1757        // resolve_repo_root will fail for synthetic repo — that's OK, the point is
1758        // that when nothing needs cleaning the function should not report true.
1759        // In production this means no git pull is triggered.
1760        if let Ok(cleaned) = result {
1761            assert!(
1762                !cleaned,
1763                "cleanup should return false when there is no worktree or branch to clean"
1764            );
1765        } else {
1766            // resolve_repo_root fails for test repo — acceptable.
1767            // The bug was that even with no worktree/branch, Ok(true) was returned.
1768        }
1769    }
1770
1771    /// When the stored worktree path no longer exists on disk and there is no
1772    /// branch recorded, cleanup must mark the task as cleaned so subsequent
1773    /// runs do not keep retrying (idempotency regression test for #1021).
1774    #[tokio::test]
1775    async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
1776        let tmp = tempfile::tempdir().unwrap();
1777        // A path that does NOT exist on disk.
1778        let stale_wt = tmp.path().join("stale-worktree");
1779        assert!(!stale_wt.exists(), "stale path must not exist");
1780
1781        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1782
1783        let id = store
1784            .create(&NewTask {
1785                external_id: Some("1005".to_string()),
1786                repo: "owner/repo".to_string(),
1787                origin: "github".to_string(),
1788                title: "Stale worktree task".to_string(),
1789                ..Default::default()
1790            })
1791            .await
1792            .unwrap();
1793        store
1794            .update_status(id, crate::store::TaskStatus::Done)
1795            .await
1796            .unwrap();
1797        // Set the stale worktree path on the task.
1798        store
1799            .set_fields(
1800                id,
1801                &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
1802            )
1803            .await
1804            .unwrap();
1805
1806        // Verify the task starts out NOT cleaned.
1807        let before = store.get(id).await.unwrap();
1808        assert!(!before.worktree_cleaned, "task should not be cleaned yet");
1809        assert!(
1810            !before.worktree.is_empty(),
1811            "task must have a worktree path set"
1812        );
1813
1814        let opts = JanitorOptions {
1815            ttl_hours: 0,
1816            dry_run: false,
1817        };
1818
1819        let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
1820
1821        // The function should succeed (Ok) — stale path is not an error.
1822        // It returns Ok(false) because there was nothing to actively remove.
1823        match result {
1824            Ok(_) => {}
1825            Err(e) => panic!("cleanup should not error for stale path: {e}"),
1826        }
1827
1828        // The task must now be marked cleaned so the next cycle skips it.
1829        let after = store.get(id).await.unwrap();
1830        assert!(
1831            after.worktree_cleaned,
1832            "task must be marked cleaned when stored worktree path is already gone"
1833        );
1834    }
1835
1836    /// `remove_worktree_and_branch` returns true when the worktree directory
1837    /// is successfully removed (regression test for #1143).
1838    #[tokio::test]
1839    async fn remove_worktree_returns_true_on_success() {
1840        let Some((tmp, wt_dir)) = setup_test_repo() else {
1841            eprintln!("skipping test: git not available");
1842            return;
1843        };
1844
1845        let repo_dir = tmp.path().join("repo.git");
1846        assert!(wt_dir.exists(), "worktree must exist before removal");
1847
1848        let removed =
1849            remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
1850                .await;
1851
1852        assert!(
1853            removed,
1854            "remove_worktree_and_branch should return true when directory is gone"
1855        );
1856        assert!(!wt_dir.exists(), "worktree directory should be removed");
1857    }
1858
1859    /// Successful worktree cleanup must still delete the local branch.
1860    /// This guards against early-return regressions in `remove_worktree_and_branch`.
1861    #[tokio::test]
1862    async fn remove_worktree_success_still_deletes_local_branch() {
1863        let Some((tmp, wt_dir)) = setup_test_repo() else {
1864            eprintln!("skipping test: git not available");
1865            return;
1866        };
1867
1868        let repo_dir = tmp.path().join("repo.git");
1869        let repo_dir_str = repo_dir
1870            .to_str()
1871            .expect("test repo path contains non-UTF-8 characters");
1872        let branch = "gh-issue-42-test";
1873
1874        let before = std::process::Command::new("git")
1875            .args([
1876                "-C",
1877                repo_dir_str,
1878                "show-ref",
1879                "--verify",
1880                "--quiet",
1881                &format!("refs/heads/{branch}"),
1882            ])
1883            .status()
1884            .unwrap();
1885        assert!(
1886            before.success(),
1887            "test setup must create local branch before cleanup"
1888        );
1889
1890        let removed =
1891            remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
1892        assert!(removed, "worktree should be removed");
1893
1894        let after = std::process::Command::new("git")
1895            .args([
1896                "-C",
1897                repo_dir_str,
1898                "show-ref",
1899                "--verify",
1900                "--quiet",
1901                &format!("refs/heads/{branch}"),
1902            ])
1903            .status()
1904            .unwrap();
1905        assert!(
1906            !after.success(),
1907            "local branch should be deleted after successful worktree cleanup"
1908        );
1909    }
1910
1911    /// `remove_worktree_and_branch` returns false when the worktree directory
1912    /// cannot be removed (regression test for #1143 — prevents orphaned worktrees
1913    /// from being permanently marked as cleaned).
1914    #[tokio::test]
1915    async fn remove_worktree_returns_false_when_dir_persists() {
1916        let tmp = tempfile::tempdir().unwrap();
1917        // Create a plain directory (not a real git worktree) — git will fail
1918        // to remove it, so the directory stays on disk.
1919        let fake_wt = tmp.path().join("not-a-worktree");
1920        std::fs::create_dir_all(&fake_wt).unwrap();
1921
1922        // repo_root doesn't matter much — it just needs to exist for the -C flag.
1923        // Using tmp itself; git commands will fail but that's the point.
1924        let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
1925
1926        // The directory still exists because git couldn't remove it.
1927        // Before the fix, this would still have been marked as cleaned.
1928        assert!(
1929            !removed,
1930            "remove_worktree_and_branch should return false when directory still exists"
1931        );
1932        assert!(fake_wt.exists(), "directory should still be on disk");
1933    }
1934
1935    struct MergeCheckMockBackend {
1936        batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
1937        single_results: HashMap<String, anyhow::Result<bool>>,
1938        posted_comments: Arc<Mutex<Vec<(String, String)>>>,
1939        single_checked_branches: Arc<Mutex<Vec<String>>>,
1940    }
1941
1942    impl MergeCheckMockBackend {
1943        fn with_batch_error(
1944            error: anyhow::Error,
1945            single_results: HashMap<String, anyhow::Result<bool>>,
1946        ) -> Self {
1947            Self {
1948                batch_result: Mutex::new(Err(error)),
1949                single_results,
1950                posted_comments: Arc::new(Mutex::new(Vec::new())),
1951                single_checked_branches: Arc::new(Mutex::new(Vec::new())),
1952            }
1953        }
1954    }
1955
1956    #[async_trait]
1957    impl ExternalBackend for MergeCheckMockBackend {
1958        fn name(&self) -> &'static str {
1959            "mock"
1960        }
1961
1962        async fn create_task(
1963            &self,
1964            _title: &str,
1965            _body: &str,
1966            _labels: &[String],
1967        ) -> anyhow::Result<ExternalId> {
1968            anyhow::bail!("not implemented")
1969        }
1970
1971        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
1972            anyhow::bail!("not implemented")
1973        }
1974
1975        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1976            Ok(vec![])
1977        }
1978
1979        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1980            Ok(vec![])
1981        }
1982
1983        async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
1984            self.posted_comments
1985                .lock()
1986                .unwrap()
1987                .push((id.0.clone(), body.to_string()));
1988            Ok(())
1989        }
1990
1991        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
1992            Ok(())
1993        }
1994
1995        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
1996            Ok(())
1997        }
1998
1999        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2000            Ok(vec![])
2001        }
2002
2003        async fn create_sub_task(
2004            &self,
2005            _parent: &ExternalId,
2006            _title: &str,
2007            _body: &str,
2008            _labels: &[String],
2009        ) -> anyhow::Result<ExternalId> {
2010            anyhow::bail!("not implemented")
2011        }
2012
2013        async fn health_check(&self) -> anyhow::Result<()> {
2014            Ok(())
2015        }
2016
2017        async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2018            self.single_checked_branches
2019                .lock()
2020                .unwrap()
2021                .push(branch.to_string());
2022            match self.single_results.get(branch) {
2023                Some(Ok(is_merged)) => Ok(*is_merged),
2024                Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2025                None => Ok(false),
2026            }
2027        }
2028
2029        async fn batch_is_pr_merged(
2030            &self,
2031            _branches: &[String],
2032        ) -> anyhow::Result<HashMap<String, bool>> {
2033            match &*self.batch_result.lock().unwrap() {
2034                Ok(map) => Ok(map.clone()),
2035                Err(err) => Err(anyhow::anyhow!(err.to_string())),
2036            }
2037        }
2038    }
2039
2040    #[tokio::test]
2041    async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2042        let single_results = HashMap::from([
2043            ("branch-1".to_string(), Ok(true)),
2044            ("branch-2".to_string(), Ok(false)),
2045        ]);
2046        let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2047            anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2048            single_results,
2049        ));
2050        let backend_dyn: Arc<dyn ExternalBackend> = Arc::<MergeCheckMockBackend>::clone(&backend);
2051
2052        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2053        let task1 = store
2054            .create(&NewTask {
2055                external_id: Some("101".to_string()),
2056                repo: "owner/repo".to_string(),
2057                origin: "github".to_string(),
2058                title: "Task 101".to_string(),
2059                ..Default::default()
2060            })
2061            .await
2062            .unwrap();
2063        store
2064            .update_status(task1, crate::store::TaskStatus::NeedsReview)
2065            .await
2066            .unwrap();
2067        store
2068            .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2069            .await
2070            .unwrap();
2071        // No .git file, so cleanup force-removes the directory
2072        let wt_dir = tempfile::tempdir().unwrap();
2073        let wt_path = wt_dir.path().join("branch-1");
2074        std::fs::create_dir_all(&wt_path).unwrap();
2075        store
2076            .set_fields(
2077                task1,
2078                &[("worktree", serde_json::json!(wt_path.to_string_lossy()))],
2079            )
2080            .await
2081            .unwrap();
2082
2083        let task2 = store
2084            .create(&NewTask {
2085                external_id: Some("102".to_string()),
2086                repo: "owner/repo".to_string(),
2087                origin: "github".to_string(),
2088                title: "Task 102".to_string(),
2089                ..Default::default()
2090            })
2091            .await
2092            .unwrap();
2093        store
2094            .update_status(task2, crate::store::TaskStatus::InReview)
2095            .await
2096            .unwrap();
2097        store
2098            .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2099            .await
2100            .unwrap();
2101
2102        let task_manager = Arc::new(TaskManager::with_store(
2103            std::sync::Arc::clone(&backend_dyn),
2104            std::sync::Arc::clone(&store),
2105            "owner/repo".to_string(),
2106        ));
2107
2108        let in_review_tasks = store
2109            .list_by_status("owner/repo", crate::store::TaskStatus::InReview)
2110            .await
2111            .unwrap()
2112            .into_iter()
2113            .map(|stored| ReviewTaskSnapshot {
2114                external: crate::engine::tasks::store_task_to_external(&stored),
2115                stored,
2116            })
2117            .collect::<Vec<_>>();
2118        let needs_review_tasks = store
2119            .list_by_status("owner/repo", crate::store::TaskStatus::NeedsReview)
2120            .await
2121            .unwrap()
2122            .into_iter()
2123            .map(|stored| ReviewTaskSnapshot {
2124                external: crate::engine::tasks::store_task_to_external(&stored),
2125                stored,
2126            })
2127            .collect::<Vec<_>>();
2128
2129        check_merged_prs(
2130            &backend_dyn,
2131            "owner/repo",
2132            &store,
2133            &task_manager,
2134            &in_review_tasks,
2135            &needs_review_tasks,
2136        )
2137        .await
2138        .unwrap();
2139
2140        let checked = backend.single_checked_branches.lock().unwrap().clone();
2141        assert_eq!(
2142            checked,
2143            vec!["branch-2".to_string(), "branch-1".to_string()]
2144        );
2145
2146        let updated_task1 = store.get(task1).await.unwrap();
2147        let updated_task2 = store.get(task2).await.unwrap();
2148        assert_eq!(updated_task1.status, crate::store::TaskStatus::Done);
2149        assert!(updated_task1.worktree_cleaned);
2150        assert!(!wt_path.exists());
2151        assert!(!updated_task2.worktree_cleaned);
2152        assert_eq!(updated_task2.status, crate::store::TaskStatus::InReview);
2153
2154        let comments = backend.posted_comments.lock().unwrap().clone();
2155        assert_eq!(comments.len(), 1);
2156        assert_eq!(comments[0].0, "101");
2157        assert!(comments[0].1.contains("PR merged, marking task complete"));
2158    }
2159
2160    struct ReconciliationMockBackend {
2161        reconciliation_calls: Arc<AtomicUsize>,
2162    }
2163
2164    #[async_trait]
2165    impl ExternalBackend for ReconciliationMockBackend {
2166        fn name(&self) -> &'static str {
2167            "mock"
2168        }
2169
2170        async fn create_task(
2171            &self,
2172            _title: &str,
2173            _body: &str,
2174            _labels: &[String],
2175        ) -> anyhow::Result<ExternalId> {
2176            anyhow::bail!("not implemented")
2177        }
2178
2179        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2180            anyhow::bail!("not implemented")
2181        }
2182
2183        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2184            Ok(vec![])
2185        }
2186
2187        async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2188            self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2189            Ok(vec![])
2190        }
2191
2192        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2193            Ok(vec![])
2194        }
2195
2196        async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2197            Ok(())
2198        }
2199
2200        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2201            Ok(())
2202        }
2203
2204        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2205            Ok(())
2206        }
2207
2208        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2209            Ok(vec![])
2210        }
2211
2212        async fn create_sub_task(
2213            &self,
2214            _parent: &ExternalId,
2215            _title: &str,
2216            _body: &str,
2217            _labels: &[String],
2218        ) -> anyhow::Result<ExternalId> {
2219            anyhow::bail!("not implemented")
2220        }
2221
2222        async fn health_check(&self) -> anyhow::Result<()> {
2223            Ok(())
2224        }
2225    }
2226
2227    #[tokio::test]
2228    async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2229        let repo = "owner/repo-cleanup-rate-limit";
2230        RECONCILIATION_LAST_RUN.remove(repo);
2231
2232        let backend = Arc::new(ReconciliationMockBackend {
2233            reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2234        });
2235        let backend_dyn: Arc<dyn ExternalBackend> =
2236            Arc::<ReconciliationMockBackend>::clone(&backend);
2237        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2238        let task_manager = Arc::new(TaskManager::with_store(
2239            std::sync::Arc::clone(&backend_dyn),
2240            std::sync::Arc::clone(&store),
2241            repo.to_string(),
2242        ));
2243        let opts = JanitorOptions {
2244            ttl_hours: 0,
2245            dry_run: true,
2246        };
2247
2248        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2249            .await
2250            .unwrap();
2251        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2252            .await
2253            .unwrap();
2254
2255        assert_eq!(
2256            backend.reconciliation_calls.load(Ordering::SeqCst),
2257            1,
2258            "cleanup should not re-list reconciliation candidates on every sync pass"
2259        );
2260
2261        RECONCILIATION_LAST_RUN.remove(repo);
2262    }
2263
2264    /// Cleanup only collects done tasks. A blocked task waits on a person and resumes from its
2265    /// worktree and branch, so it must not even be considered (#3781)
2266    #[tokio::test]
2267    async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2268        let repo = "owner/repo-cleanup-blocked";
2269        RECONCILIATION_LAST_RUN.remove(repo);
2270        let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2271            reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2272        });
2273        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2274        let task_manager = Arc::new(TaskManager::with_store(
2275            std::sync::Arc::clone(&backend_dyn),
2276            std::sync::Arc::clone(&store),
2277            repo.to_string(),
2278        ));
2279
2280        let mut ids = Vec::new();
2281        for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2282            let id = store
2283                .create_internal(repo, title, "", "test", title, None)
2284                .await
2285                .unwrap();
2286            store
2287                .set_fields(
2288                    id,
2289                    &[
2290                        (
2291                            "worktree",
2292                            serde_json::json!(format!("/nonexistent/{title}")),
2293                        ),
2294                        ("branch", serde_json::json!("")),
2295                    ],
2296                )
2297                .await
2298                .unwrap();
2299            store.update_status(id, status).await.unwrap();
2300            ids.push(id);
2301        }
2302
2303        let opts = JanitorOptions {
2304            ttl_hours: 0,
2305            dry_run: false,
2306        };
2307        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2308            .await
2309            .unwrap();
2310
2311        assert!(
2312            !store.get(ids[0]).await.unwrap().worktree_cleaned,
2313            "blocked task must not be considered for cleanup"
2314        );
2315        assert!(
2316            store.get(ids[1]).await.unwrap().worktree_cleaned,
2317            "done task is cleaned, which proves the pass ran"
2318        );
2319        RECONCILIATION_LAST_RUN.remove(repo);
2320    }
2321
2322    fn git_ok(dir: &std::path::Path, args: &[&str]) -> String {
2323        let out = std::process::Command::new("git")
2324            .args([
2325                "-c",
2326                "commit.gpgsign=false",
2327                "-c",
2328                "user.name=T",
2329                "-c",
2330                "user.email=t@t",
2331            ])
2332            .args(args)
2333            .current_dir(dir)
2334            .output()
2335            .unwrap();
2336        assert!(
2337            out.status.success(),
2338            "git {args:?}: {}",
2339            String::from_utf8_lossy(&out.stderr)
2340        );
2341        String::from_utf8_lossy(&out.stdout).to_string()
2342    }
2343
2344    /// A clone of a bare origin with main pushed and branch `feat` one commit ahead
2345    fn origin_and_clone_with_feat() -> (tempfile::TempDir, std::path::PathBuf) {
2346        let tmp = tempfile::tempdir().unwrap();
2347        let bare = tmp.path().join("origin.git");
2348        std::fs::create_dir_all(&bare).unwrap();
2349        git_ok(&bare, &["init", "--bare", "-b", "main"]);
2350        git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2351        let clone = tmp.path().join("clone");
2352        std::fs::write(clone.join("base.txt"), "base").unwrap();
2353        git_ok(&clone, &["add", "."]);
2354        git_ok(&clone, &["commit", "-m", "base"]);
2355        git_ok(&clone, &["push", "origin", "main"]);
2356        git_ok(&clone, &["checkout", "-b", "feat"]);
2357        std::fs::write(clone.join("work.txt"), "work").unwrap();
2358        git_ok(&clone, &["add", "."]);
2359        git_ok(&clone, &["commit", "-m", "work"]);
2360        git_ok(&clone, &["checkout", "main"]);
2361        (tmp, clone)
2362    }
2363
2364    fn remote_has(clone: &std::path::Path, branch: &str) -> bool {
2365        !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2366            .trim()
2367            .is_empty()
2368    }
2369
2370    fn local_has(clone: &std::path::Path, branch: &str) -> bool {
2371        std::process::Command::new("git")
2372            .args([
2373                "show-ref",
2374                "--verify",
2375                "--quiet",
2376                &format!("refs/heads/{branch}"),
2377            ])
2378            .current_dir(clone)
2379            .status()
2380            .unwrap()
2381            .success()
2382    }
2383
2384    /// Deleting a pushed branch whose commits main lacks would destroy the only copy of the
2385    /// work, as startup cleanup did to a blocked task (#3781)
2386    #[tokio::test]
2387    async fn delete_branches_keeps_an_unmerged_remote_branch() {
2388        let (_tmp, clone) = origin_and_clone_with_feat();
2389        git_ok(&clone, &["push", "origin", "feat"]);
2390
2391        delete_branches("1", "feat", &clone).await;
2392
2393        assert!(
2394            remote_has(&clone, "feat"),
2395            "unmerged remote branch must survive"
2396        );
2397        assert!(
2398            !local_has(&clone, "feat"),
2399            "local copy is on origin, so it can go"
2400        );
2401    }
2402
2403    #[tokio::test]
2404    async fn delete_branches_removes_a_branch_main_already_contains() {
2405        let (_tmp, clone) = origin_and_clone_with_feat();
2406        git_ok(&clone, &["push", "origin", "feat"]);
2407        git_ok(&clone, &["merge", "--ff-only", "feat"]);
2408        git_ok(&clone, &["push", "origin", "main"]);
2409
2410        delete_branches("1", "feat", &clone).await;
2411
2412        assert!(!remote_has(&clone, "feat"));
2413        assert!(!local_has(&clone, "feat"));
2414    }
2415
2416    #[tokio::test]
2417    async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2418        let (_tmp, clone) = origin_and_clone_with_feat();
2419
2420        delete_branches("1", "feat", &clone).await;
2421
2422        assert!(
2423            local_has(&clone, "feat"),
2424            "unpushed commits must not be deleted"
2425        );
2426    }
2427}