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