1use 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
41const RECONCILIATION_LIST_TIMEOUT: Duration = Duration::from_secs(30);
46const RECONCILIATION_INTERVAL: Duration = Duration::from_mins(15);
49
50static CLEANUP_IN_FLIGHT: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
53static 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#[derive(Debug, Clone)]
94pub struct JanitorOptions {
95 pub ttl_hours: u64,
103
104 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 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
133pub(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 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 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 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
222pub(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 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 let mut task_ids: Vec<String> = done_tasks.iter().map(|t| t.id.0.clone()).collect();
248
249 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 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 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 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 }
328 Err(e) => {
329 tracing::warn!(task_id, err = %e, "worktree cleanup failed for task");
330 }
331 }
332 }
333
334 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 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 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 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 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 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 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
482pub(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 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
536pub(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 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 let worktrees_base = home::worktrees_dir().unwrap_or_else(|_| PathBuf::from(".orch/worktrees"));
563
564 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 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 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 let branch_nonempty = branch.as_ref().filter(|b| !b.is_empty());
636 if worktree_to_remove.is_none() && branch_nonempty.is_none() {
637 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 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 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 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 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 if !opts.dry_run && !keep_remote_branch {
754 tracing::debug!(task_id, branch = %br, "no worktree on disk, cleaning up branch only");
755 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 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 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
794pub(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 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 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 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 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 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 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 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 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 match fs::try_exists(wt).await {
953 Ok(false) => true, Ok(true) => false, Err(e) => {
956 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
957 false }
959 }
960}
961
962async 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
1000async 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
1042pub(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
1110async 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 pane_path == worktree_str.as_ref()
1128 || pane_path.starts_with(&format!("{worktree_str}/"))
1129 })
1130 }
1131 _ => false,
1133 }
1134}
1135
1136async fn worktree_age_hours(worktree: &Path) -> Option<u64> {
1140 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
1147pub(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
1167pub(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 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 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 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
1233pub async fn resolve_repo_root_from_worktree(wt: &Path) -> anyhow::Result<String> {
1240 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 for line in content.lines() {
1252 if let Some(gitdir) = line.strip_prefix("gitdir:") {
1253 let gitdir = gitdir.trim();
1254 let gitdir_path = Path::new(gitdir);
1258 if let Some(repo_root) = gitdir_path
1259 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
1262 {
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
1275pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1280 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 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
1326fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<PathBuf> {
1331 if let Ok(dir) = env::var("PROJECT_DIR")
1333 && !dir.is_empty()
1334 {
1335 return Ok(PathBuf::from(dir));
1336 }
1337
1338 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
1367pub(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 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 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 #[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 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 #[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 fn setup_test_repo() -> Option<(tempfile::TempDir, PathBuf)> {
1534 let tmp = tempfile::tempdir().ok()?;
1535
1536 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 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 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 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 assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1624
1625 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 let opts = JanitorOptions {
1634 ttl_hours: 24,
1635 dry_run: true,
1636 };
1637 assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1639
1640 drop(tmp); }
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 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 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 let opts = JanitorOptions {
1677 ttl_hours: 0,
1678 dry_run: true,
1679 };
1680
1681 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1686 let in_use = false; let skip = age < opts.ttl_hours || in_use;
1688
1689 if !skip && opts.dry_run {
1690 tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1692 } else if !skip {
1693 }
1695
1696 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 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 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 #[tokio::test]
1735 async fn cleanup_returns_false_when_nothing_to_clean() {
1736 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1737
1738 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 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 }
1769 }
1770
1771 #[tokio::test]
1775 async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
1776 let tmp = tempfile::tempdir().unwrap();
1777 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 store
1796 .set_fields(
1797 id,
1798 &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
1799 )
1800 .await
1801 .unwrap();
1802
1803 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 match result {
1821 Ok(_) => {}
1822 Err(e) => panic!("cleanup should not error for stale path: {e}"),
1823 }
1824
1825 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 #[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 #[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 #[tokio::test]
1912 async fn remove_worktree_returns_false_when_dir_persists() {
1913 let tmp = tempfile::tempdir().unwrap();
1914 let fake_wt = tmp.path().join("not-a-worktree");
1917 std_fs::create_dir_all(&fake_wt).unwrap();
1918
1919 let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
1922
1923 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 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 #[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 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 #[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}