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