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