Skip to main content

orch/engine/
cleanup.rs

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