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 std::collections::HashMap;
16use std::sync::Arc;
17use std::sync::LazyLock;
18use std::time::{Duration, Instant};
19use tokio::process::Command;
20use tokio::time::sleep;
21use tokio::time::timeout;
22
23use super::sync::ReviewTaskSnapshot;
24
25const RECONCILIATION_LIST_TIMEOUT: Duration = Duration::from_secs(30);
30const RECONCILIATION_INTERVAL: Duration = Duration::from_mins(15);
33
34static CLEANUP_IN_FLIGHT: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
37static RECONCILIATION_LAST_RUN: LazyLock<DashMap<String, Instant>> = LazyLock::new(DashMap::new);
39
40struct CleanupInFlightGuard {
41 repo: String,
42}
43
44impl CleanupInFlightGuard {
45 fn try_acquire(repo: &str) -> Option<Self> {
46 if CLEANUP_IN_FLIGHT.insert(repo.to_string()) {
47 Some(Self {
48 repo: repo.to_string(),
49 })
50 } else {
51 None
52 }
53 }
54}
55
56impl Drop for CleanupInFlightGuard {
57 fn drop(&mut self) {
58 CLEANUP_IN_FLIGHT.remove(&self.repo);
59 }
60}
61
62fn should_run_reconciliation(repo: &str) -> bool {
63 let now = Instant::now();
64 if let Some(mut last_run) = RECONCILIATION_LAST_RUN.get_mut(repo) {
65 if last_run.elapsed() < RECONCILIATION_INTERVAL {
66 return false;
67 }
68 *last_run = now;
69 true
70 } else {
71 RECONCILIATION_LAST_RUN.insert(repo.to_string(), now);
72 true
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct JanitorOptions {
79 pub ttl_hours: u64,
87
88 pub dry_run: bool,
92}
93
94impl Default for JanitorOptions {
95 fn default() -> Self {
96 Self {
97 ttl_hours: 24,
98 dry_run: false,
99 }
100 }
101}
102
103impl JanitorOptions {
104 pub fn from_config() -> Self {
106 let ttl_hours = crate::config::get("workflow.worktree_janitor_ttl_hours")
107 .ok()
108 .and_then(|v| v.parse::<u64>().ok())
109 .unwrap_or(24);
110
111 let dry_run =
112 crate::config::get("workflow.worktree_janitor_dry_run").is_ok_and(|v| v == "true");
113
114 Self { ttl_hours, dry_run }
115 }
116}
117
118pub(crate) async fn cleanup_done_worktrees(
127 backend: &Arc<dyn ExternalBackend>,
128 repo: &str,
129 task_manager: &Arc<TaskManager>,
130 store: &Arc<TaskStore>,
131) -> anyhow::Result<()> {
132 let opts = JanitorOptions::from_config();
133 cleanup_done_worktrees_with_opts(backend, repo, task_manager, store, &opts).await
134}
135
136async fn reconcile_closed_tasks(
137 task_ids: &mut Vec<String>,
138 all_tasks: &[ExternalTask],
139 task_manager: &TaskManager,
140) {
141 const MAX_RECONCILE_PER_CYCLE: usize = 10;
142
143 let done_set: std::collections::HashSet<String> = task_ids.iter().cloned().collect();
144 let closed_not_in_store: Vec<_> = all_tasks
145 .iter()
146 .filter(|t| t.state == "closed" && !done_set.contains(&t.id.0))
147 .collect();
148
149 let mut already_labeled: Vec<String> = Vec::new();
152 let mut needs_reconcile: Vec<&ExternalTask> = Vec::new();
153 for task in &closed_not_in_store {
154 if task.labels.iter().any(|l| l == "status:done") {
155 already_labeled.push(task.id.0.clone());
156 } else {
157 needs_reconcile.push(task);
158 }
159 }
160
161 if !already_labeled.is_empty() {
163 tracing::debug!(
164 count = already_labeled.len(),
165 "closed issues already labeled status:done — adding to cleanup set"
166 );
167 task_ids.extend(already_labeled);
168 }
169
170 if needs_reconcile.len() > MAX_RECONCILE_PER_CYCLE {
173 tracing::info!(
174 total = needs_reconcile.len(),
175 batch = MAX_RECONCILE_PER_CYCLE,
176 "capping closed-issue reconciliation to avoid rate-limit exhaustion"
177 );
178 }
179 let mut futs = Vec::new();
180 for task in needs_reconcile.iter().take(MAX_RECONCILE_PER_CYCLE) {
181 let id = task.id.clone();
182 let task_id_str = task.id.0.clone();
183 let labels = task.labels.clone();
184 futs.push(async move {
185 tracing::info!(
186 task_id = task_id_str,
187 labels = ?labels,
188 "closed issue with stale status label — reconciling to done and cleaning up"
189 );
190 match task_manager.update_task_status(&id, Status::Done).await {
191 Ok(()) => Some(task_id_str),
192 Err(e) => {
193 tracing::warn!(
194 task_id = task_id_str,
195 err = %e,
196 "failed to reconcile closed issue status to done"
197 );
198 None
199 }
200 }
201 });
202 }
203 let results = futures::future::join_all(futs).await;
204 task_ids.extend(results.into_iter().flatten());
205}
206
207pub(crate) async fn cleanup_done_worktrees_with_opts(
213 backend: &Arc<dyn ExternalBackend>,
214 repo: &str,
215 task_manager: &Arc<TaskManager>,
216 store: &Arc<TaskStore>,
217 opts: &JanitorOptions,
218) -> anyhow::Result<()> {
219 let Some(_guard) = CleanupInFlightGuard::try_acquire(repo) else {
220 tracing::debug!(
221 repo = %repo,
222 "cleanup already in progress for repo — skipping overlapping run"
223 );
224 return Ok(());
225 };
226
227 let done_tasks = task_manager.list_all_by_status(Status::Done).await?;
229 tracing::debug!(count = done_tasks.len(), "checking done tasks for cleanup");
230
231 let mut task_ids: Vec<String> = done_tasks.iter().map(|t| t.id.0.clone()).collect();
233
234 if should_run_reconciliation(repo) {
246 match timeout(
247 RECONCILIATION_LIST_TIMEOUT,
248 backend.list_reconciliation_candidates(),
249 )
250 .await
251 {
252 Ok(Ok(candidates)) if !candidates.is_empty() => {
253 tracing::debug!(
254 count = candidates.len(),
255 "reconciling closed issues from candidates"
256 );
257 reconcile_closed_tasks(&mut task_ids, &candidates, task_manager).await;
258 }
259 Ok(Ok(_)) => {
260 tracing::debug!("no reconciliation candidates found");
261 }
262 Ok(Err(e)) => {
263 tracing::warn!(err = %e, "failed to list reconciliation candidates");
264 }
265 Err(_) => {
266 tracing::warn!(
267 timeout_secs = RECONCILIATION_LIST_TIMEOUT.as_secs(),
268 retry_after_secs = RECONCILIATION_INTERVAL.as_secs(),
269 "timed out listing reconciliation candidates"
270 );
271 }
272 }
273 } else {
274 tracing::debug!(
275 retry_after_secs = RECONCILIATION_INTERVAL.as_secs(),
276 "skipping closed-issue reconciliation until interval elapses"
277 );
278 }
279
280 if let Ok(internal_done) = task_manager.list_internal_by_status(TaskStatus::Done).await {
282 task_ids.extend(internal_done.iter().map(|t| t.id.0.clone()));
283 }
284
285 tracing::debug!(
286 count = task_ids.len(),
287 "checking all terminal tasks for cleanup"
288 );
289
290 if let Err(e) = crate::engine::auto_merge::sweep_ci_timer_keys(store, repo, &task_ids).await {
291 tracing::warn!(err = %e, "failed to sweep CI timer keys");
292 }
293
294 let already_cleaned = store.cleaned_external_ids(repo).await.unwrap_or_default();
299
300 let mut cleaned_any = false;
301 for task_id in &task_ids {
302 if already_cleaned.contains(task_id) {
304 continue;
305 }
306
307 match cleanup_task_worktree_with_opts(task_id, repo, store, opts).await {
308 Ok(true) => cleaned_any = true,
309 Ok(false) => {
310 }
313 Err(e) => {
314 tracing::warn!(task_id, err = %e, "worktree cleanup failed for task");
315 }
316 }
317 }
318
319 let worktrees_base = match crate::home::worktrees_dir() {
332 Ok(base) => Some(base),
333 Err(e) => {
334 tracing::debug!(err = %e, "worktrees_dir() unavailable — skipping orphan scan");
335 None
336 }
337 };
338
339 if let Some(base) = worktrees_base {
340 let project = repo
341 .rsplit('/')
342 .next()
343 .unwrap_or(repo)
344 .trim_end_matches(".git");
345 let repo_worktrees = base.join(project);
346
347 let mut combined: std::collections::HashSet<String> = std::collections::HashSet::new();
349 if let Ok(tasks) = store.list_all(repo).await {
350 for t in tasks {
351 if !t.worktree.is_empty() {
352 combined.insert(t.worktree);
353 }
354 }
355 }
356
357 if let Ok(project_dir) = resolve_project_dir_for_repo(repo)
359 && let Ok(active_wts) =
360 crate::engine::runner::worktree::list_project_worktrees(&project_dir).await
361 {
362 for wt in active_wts {
363 combined.insert(wt.to_string_lossy().to_string());
364 }
365 }
366
367 if let Ok(mut entries) = tokio::fs::read_dir(&repo_worktrees).await {
369 while let Ok(Some(entry)) = entries.next_entry().await {
370 let Ok(entry_meta) = entry.metadata().await else {
371 continue;
372 };
373 if !entry_meta.is_dir() {
374 continue;
375 }
376 let wt_path = entry.path().to_string_lossy().to_string();
377 if combined.contains(&wt_path) {
378 continue;
379 }
380 tracing::info!(worktree = %wt_path, "found orphaned worktree — attempting cleanup");
381 match resolve_repo_root_for_orphaned_worktree(&entry.path()).await {
382 Ok(repo_root) => {
383 let removed = remove_worktree_and_branch(
384 "orphan-scan",
385 &entry.path(),
386 None,
387 std::path::Path::new(&repo_root),
388 false,
389 )
390 .await;
391 if removed {
392 tracing::info!(worktree = %wt_path, "orphaned worktree removed");
393 } else {
394 tracing::warn!(worktree = %wt_path, "orphaned worktree could not be removed");
395 }
396 }
397 Err(e) => {
398 tracing::warn!(worktree = %wt_path, err = %e, "could not resolve repo root for orphaned worktree");
399 }
400 }
401 }
402 }
403 }
404
405 if cleaned_any
407 && !opts.dry_run
408 && let Ok(repo_root) = resolve_repo_root(repo).await
409 {
410 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
411
412 let prune_result = timeout(
414 GIT_TIMEOUT,
415 Command::new("git")
416 .args(["-C", &repo_root, "worktree", "prune"])
417 .output_with_context(),
418 )
419 .await;
420 match prune_result {
421 Ok(Ok(output)) if output.status.success() => {
422 tracing::debug!(%repo, "pruned stale worktree metadata");
423 }
424 Ok(Ok(output)) => {
425 let stderr = String::from_utf8_lossy(&output.stderr);
426 tracing::warn!(%repo, err = %stderr, "git worktree prune failed");
427 }
428 Ok(Err(e)) => {
429 tracing::warn!(%repo, err = %e, "git worktree prune failed");
430 }
431 Err(_) => {
432 tracing::warn!(%repo, "git worktree prune timed out after 60s");
433 }
434 }
435
436 let fetch_result = timeout(
441 GIT_TIMEOUT,
442 crate::engine::runner::git_ops::git()
443 .await
444 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
445 .output_with_context(),
446 )
447 .await;
448 match fetch_result {
449 Ok(Ok(output)) if output.status.success() => {
450 tracing::info!(%repo, "fetched origin after worktree cleanup");
451 }
452 Ok(Ok(output)) => {
453 let stderr = String::from_utf8_lossy(&output.stderr);
454 tracing::debug!(%repo, err = %stderr, "git fetch skipped after cleanup");
455 }
456 Ok(Err(e)) => {
457 tracing::debug!(%repo, err = %e, "git fetch failed after cleanup");
458 }
459 Err(_) => {
460 tracing::debug!(%repo, "git fetch timed out after 60s");
461 }
462 }
463 }
464
465 Ok(())
466}
467
468pub(crate) async fn cleanup_task_worktree(
478 task_id: &str,
479 repo: &str,
480 store: &Arc<TaskStore>,
481) -> anyhow::Result<()> {
482 let opts = JanitorOptions {
483 ttl_hours: 0,
484 ..Default::default()
485 };
486 let cleaned = cleanup_task_worktree_with_opts(task_id, repo, store, &opts).await?;
487
488 if cleaned && let Ok(repo_root) = resolve_repo_root(repo).await {
493 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
494 let fetch_result = timeout(
495 GIT_TIMEOUT,
496 crate::engine::runner::git_ops::git()
497 .await
498 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
499 .output_with_context(),
500 )
501 .await;
502 match fetch_result {
503 Ok(Ok(output)) if output.status.success() => {
504 tracing::debug!(%repo, "fetched origin after task cleanup");
505 }
506 Ok(Ok(output)) => {
507 let stderr = String::from_utf8_lossy(&output.stderr);
508 tracing::debug!(%repo, err = %stderr, "git fetch skipped after task cleanup");
509 }
510 Ok(Err(e)) => {
511 tracing::debug!(%repo, err = %e, "git fetch failed after task cleanup");
512 }
513 Err(_) => {
514 tracing::debug!(%repo, "git fetch timed out after 60s");
515 }
516 }
517 }
518
519 Ok(())
520}
521
522pub(crate) async fn cleanup_task_worktree_with_opts(
529 task_id: &str,
530 repo: &str,
531 store: &Arc<TaskStore>,
532 opts: &JanitorOptions,
533) -> anyhow::Result<bool> {
534 let (worktree, branch, keep_remote_branch, merged) =
535 store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
536 .await
537 .map_or((None, None, false, false), |t| {
538 let keep = t.status == TaskStatus::Blocked && t.pr_number.is_some();
541 let merged = t.status == TaskStatus::Done;
542 (Some(t.worktree), Some(t.branch), keep, merged)
543 });
544
545 let worktree_path = worktree.as_ref().map(std::path::PathBuf::from);
546
547 let worktrees_base = crate::home::worktrees_dir()
549 .unwrap_or_else(|_| std::path::PathBuf::from(".orch/worktrees"));
550
551 let worktree_to_remove = if let Some(ref wt) = worktree_path {
557 if match tokio::fs::try_exists(wt).await {
558 Ok(true) => true,
559 Ok(false) => false,
560 Err(e) => {
561 tracing::warn!(task_id, worktree = %wt.display(), err = %e, "try_exists failed on stored worktree path — trying branch-based fallback");
562 false
563 }
564 } {
565 Some(wt.clone())
566 } else {
567 if let Some(ref b) = branch {
569 let project = repo
570 .rsplit('/')
571 .next()
572 .unwrap_or(repo)
573 .trim_end_matches(".git");
574 let candidate = worktrees_base.join(project).join(b);
575 if match tokio::fs::try_exists(&candidate).await {
576 Ok(true) => true,
577 Ok(false) => false,
578 Err(e) => {
579 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
580 false
581 }
582 } {
583 Some(candidate)
584 } else {
585 None
586 }
587 } else {
588 None
589 }
590 }
591 } else if let Some(ref b) = branch {
592 let project = repo
594 .rsplit('/')
595 .next()
596 .unwrap_or(repo)
597 .trim_end_matches(".git");
598 let candidate = worktrees_base.join(project).join(b);
599 if match tokio::fs::try_exists(&candidate).await {
600 Ok(true) => true,
601 Ok(false) => false,
602 Err(e) => {
603 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
604 false
605 }
606 } {
607 Some(candidate)
608 } else {
609 None
610 }
611 } else {
612 None
613 };
614
615 let mut did_clean = false;
616
617 let branch_nonempty = branch.as_ref().filter(|b| !b.is_empty());
623 if worktree_to_remove.is_none() && branch_nonempty.is_none() {
624 if worktree_path
627 .as_ref()
628 .is_some_and(|p| !p.as_os_str().is_empty())
629 {
630 tracing::debug!(
631 task_id,
632 worktree = ?worktree_path,
633 "stored worktree path no longer exists on disk — marking cleaned"
634 );
635 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await
636 && let Err(e) = store.mark_cleaned(store_id).await
637 {
638 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned in store — will retry");
639 }
640 }
641 return Ok(false);
642 }
643
644 if let Some(wt) = worktree_to_remove {
645 if let Some(age_hours) = worktree_age_hours(&wt).await
647 && age_hours < opts.ttl_hours
648 {
649 tracing::debug!(
650 task_id,
651 worktree = %wt.display(),
652 age_hours,
653 ttl_hours = opts.ttl_hours,
654 "worktree too young for cleanup, skipping"
655 );
656 return Ok(false);
657 }
658
659 if is_worktree_in_active_session(&wt).await {
661 tracing::warn!(
662 task_id,
663 worktree = %wt.display(),
664 "worktree is referenced by an active tmux session — skipping cleanup"
665 );
666 return Ok(false);
667 }
668
669 let gitdir_valid = crate::engine::runner::worktree::validate_worktree_gitdir(&wt).await;
673
674 if opts.dry_run {
675 tracing::info!(
676 task_id,
677 worktree = %wt.display(),
678 branch = ?branch,
679 gitdir_valid,
680 "[dry-run] would remove worktree and branch"
681 );
682 } else if !gitdir_valid {
683 tracing::warn!(
684 task_id,
685 worktree = %wt.display(),
686 "worktree has broken git metadata — force-removing directory without git operations"
687 );
688 match tokio::fs::remove_dir_all(&wt).await {
689 Ok(()) => {
690 did_clean = true;
691 }
692 Err(e) => {
693 tracing::warn!(
694 task_id,
695 worktree = %wt.display(),
696 err = %e,
697 "failed to force-remove broken worktree directory"
698 );
699 }
700 }
701 } else {
702 tracing::info!(task_id, worktree = %wt.display(), "removing worktree");
703 let repo_root = match resolve_repo_root_from_worktree(&wt).await {
708 Ok(root) => root,
709 Err(_) => resolve_repo_root(repo).await?,
710 };
711 if merged {
712 crate::engine::runner::cow::warm_from_merged(
713 std::path::Path::new(&repo_root),
714 &crate::engine::runner::worktree::project_name(std::path::Path::new(
715 &repo_root,
716 )),
717 &wt,
718 )
719 .await;
720 }
721 let removed = remove_worktree_and_branch(
722 task_id,
723 &wt,
724 branch.as_deref(),
725 std::path::Path::new(&repo_root),
726 keep_remote_branch,
727 )
728 .await;
729 if removed {
730 did_clean = true;
731 } else {
732 tracing::warn!(
733 task_id,
734 worktree = %wt.display(),
735 "worktree directory still exists after removal attempt — not marking as cleaned"
736 );
737 }
738 }
739 } else if let Some(ref br) = branch {
740 if !opts.dry_run && !keep_remote_branch {
743 tracing::debug!(task_id, branch = %br, "no worktree on disk, cleaning up branch only");
744 let repo_root = resolve_repo_root(repo).await?;
746 delete_branches(task_id, br, std::path::Path::new(&repo_root)).await;
747 did_clean = true;
748 }
749 }
750
751 if did_clean {
752 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await
756 && let Err(e) = store.mark_cleaned(store_id).await
757 {
758 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned — will retry");
759 return Ok(did_clean);
761 }
762 store_log_activity(
763 &Some(Arc::clone(store)),
764 repo,
765 task_id,
766 "branch_delete",
767 None,
768 None,
769 None,
770 None,
771 Some(&serde_json::json!({
772 "worktree": worktree_path.map(|p| p.display().to_string()),
773 "branch": branch,
774 "keep_remote_branch": keep_remote_branch,
775 })),
776 )
777 .await;
778 }
779
780 Ok(did_clean)
781}
782
783pub(crate) async fn remove_worktree_and_branch(
790 task_id: &str,
791 wt: &std::path::Path,
792 branch: Option<&str>,
793 repo_root: &std::path::Path,
794 keep_remote_branch: bool,
795) -> bool {
796 let wt_str = wt.to_string_lossy().to_string();
797 let repo_root_str = repo_root.to_string_lossy();
798
799 let remove_result = Command::new("git")
802 .args([
803 "-C",
804 repo_root_str.as_ref(),
805 "worktree",
806 "remove",
807 &wt_str,
808 "--force",
809 ])
810 .output_with_context()
811 .await;
812
813 match remove_result {
814 Ok(output) if output.status.success() => {
815 const MAX_RETRIES: usize = 3;
816 const RETRY_DELAY_MS: u64 = 2000;
817 tracing::info!(task_id, "worktree removed (git metadata)");
818 let mut removed = false;
823 for attempt in 0..MAX_RETRIES {
824 match tokio::fs::remove_dir_all(wt).await {
825 Ok(()) => {
826 removed = true;
827 break;
828 }
829 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
830 removed = true;
832 break;
833 }
834 Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
835 if attempt < MAX_RETRIES - 1 {
836 tracing::debug!(
837 task_id,
838 path = %wt.display(),
839 attempt = attempt + 1,
840 "worktree directory not empty — retrying after delay"
841 );
842 sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
843 } else {
844 tracing::warn!(
845 task_id,
846 path = %wt.display(),
847 err = %e,
848 "failed to remove worktree directory after {} attempts (Directory not empty)",
849 MAX_RETRIES
850 );
851 }
852 }
853 Err(e) => {
854 tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
855 break;
856 }
857 }
858 }
859 if removed {
860 tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
861 } else {
862 tracing::warn!(
863 task_id,
864 path = %wt.display(),
865 "worktree directory still present after git worktree remove"
866 );
867 }
868 }
869 Ok(output) => {
870 let stderr = String::from_utf8_lossy(&output.stderr);
871 if stderr.contains("is not a working tree") {
877 tracing::debug!(
878 task_id,
879 path = %wt.display(),
880 "path is not a registered worktree (stale metadata) — removing directory and pruning"
881 );
882 if let Err(e) = tokio::fs::remove_dir_all(wt).await {
883 if match tokio::fs::try_exists(wt).await {
885 Ok(true) => true,
886 Ok(false) => false,
887 Err(e) => {
888 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
889 false
890 }
891 } {
892 tracing::warn!(
893 task_id,
894 path = %wt.display(),
895 err = %e,
896 "failed to remove stale worktree directory"
897 );
898 }
899 } else {
900 tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
901 }
902 if let Err(e) = Command::new("git")
905 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
906 .output_with_context()
907 .await
908 {
909 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
910 }
911 } else {
912 tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
913 }
914 }
915 Err(e) => {
916 tracing::warn!(task_id, err = %e, "failed to remove worktree");
917 }
918 }
919
920 if let Err(e) = Command::new("git")
922 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
923 .output_with_context()
924 .await
925 {
926 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
927 }
928
929 if let Some(br) = branch {
933 if keep_remote_branch {
934 delete_local_branch(task_id, br, repo_root).await;
935 } else {
936 delete_branches(task_id, br, repo_root).await;
937 }
938 }
939
940 match tokio::fs::try_exists(wt).await {
942 Ok(false) => true, Ok(true) => false, Err(e) => {
945 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
946 false }
948 }
949}
950
951async fn commits_outside(repo_root: &std::path::Path, rev: &str, others: &[&str]) -> Option<u64> {
953 let mut args = vec![
954 "rev-list".to_string(),
955 "--count".to_string(),
956 rev.to_string(),
957 ];
958 for other in others {
959 let exists = Command::new("git")
960 .args([
961 "-C",
962 &repo_root.to_string_lossy(),
963 "rev-parse",
964 "--verify",
965 "--quiet",
966 other,
967 ])
968 .output_with_context()
969 .await
970 .is_ok_and(|o| o.status.success());
971 if exists {
972 args.push("--not".to_string());
973 args.push(other.to_string());
974 }
975 }
976 let output = Command::new("git")
977 .arg("-C")
978 .arg(repo_root)
979 .args(&args)
980 .output_with_context()
981 .await
982 .ok()?;
983 if !output.status.success() {
984 return None;
985 }
986 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
987}
988
989async fn delete_local_branch(task_id: &str, br: &str, repo_root: &std::path::Path) {
991 let default_branch = crate::engine::runner::worktree::detect_default_branch(repo_root).await;
992 let remote_ref = format!("refs/remotes/origin/{br}");
993 let default_ref = format!("refs/remotes/origin/{default_branch}");
994 let local_ref = format!("refs/heads/{br}");
995 let local_default_ref = format!("refs/heads/{default_branch}");
996 match commits_outside(
997 repo_root,
998 &local_ref,
999 &[&remote_ref, &default_ref, &local_default_ref],
1000 )
1001 .await
1002 {
1003 Some(0) => {}
1004 Some(n) => {
1005 tracing::warn!(task_id, branch = %br, unpushed = n, "local branch holds commits origin does not have, keeping it");
1006 return;
1007 }
1008 None => {
1009 tracing::debug!(task_id, branch = %br, "local branch missing or unreadable, nothing to delete");
1010 return;
1011 }
1012 }
1013 let output = Command::new("git")
1014 .args(["-C", &repo_root.to_string_lossy(), "branch", "-D", br])
1015 .output_with_context()
1016 .await;
1017 match output {
1018 Ok(o) if o.status.success() => {
1019 tracing::info!(task_id, branch = %br, "local branch deleted");
1020 }
1021 Ok(o) => {
1022 let stderr = String::from_utf8_lossy(&o.stderr);
1023 tracing::debug!(task_id, err = %stderr, "local branch delete skipped (may not exist)");
1024 }
1025 Err(e) => {
1026 tracing::warn!(task_id, err = %e, "failed to delete local branch");
1027 }
1028 }
1029}
1030
1031pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &std::path::Path) {
1039 let default_branch = crate::engine::runner::worktree::detect_default_branch(repo_root).await;
1040 let _ = crate::engine::runner::git_ops::git()
1041 .await
1042 .args([
1043 "-C",
1044 &repo_root.to_string_lossy(),
1045 "fetch",
1046 "origin",
1047 &default_branch,
1048 br,
1049 ])
1050 .output_with_context()
1051 .await;
1052
1053 delete_local_branch(task_id, br, repo_root).await;
1054
1055 let remote_ref = format!("refs/remotes/origin/{br}");
1056 let default_ref = format!("refs/remotes/origin/{default_branch}");
1057 match commits_outside(repo_root, &remote_ref, &[&default_ref]).await {
1058 Some(0) => {}
1059 Some(n) => {
1060 tracing::info!(task_id, branch = %br, unmerged = n, "remote branch has commits the default branch lacks, keeping it");
1061 return;
1062 }
1063 None => {
1064 tracing::debug!(task_id, branch = %br, "remote branch missing or unreadable, nothing to delete");
1065 return;
1066 }
1067 }
1068
1069 let remote_delete = crate::engine::runner::git_ops::git()
1070 .await
1071 .args([
1072 "-C",
1073 &repo_root.to_string_lossy(),
1074 "-c",
1075 "lfs.locksverify=false",
1076 "push",
1077 "origin",
1078 "--delete",
1079 br,
1080 ])
1081 .output_with_context()
1082 .await;
1083
1084 match remote_delete {
1085 Ok(output) if output.status.success() => {
1086 tracing::info!(task_id, branch = %br, "remote branch deleted");
1087 crate::engine::runner::git_ops::clear_push_lease(repo_root, br).await;
1088 }
1089 Ok(output) => {
1090 let stderr = String::from_utf8_lossy(&output.stderr);
1091 tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1092 }
1093 Err(e) => {
1094 tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1095 }
1096 }
1097}
1098
1099async fn is_worktree_in_active_session(worktree: &std::path::Path) -> bool {
1105 let output = Command::new("tmux")
1106 .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1107 .output()
1108 .await;
1109
1110 match output {
1111 Ok(o) if o.status.success() => {
1112 let worktree_str = worktree.to_string_lossy();
1113 String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1114 let pane_path = line.trim();
1115 pane_path == worktree_str.as_ref()
1117 || pane_path.starts_with(&format!("{worktree_str}/"))
1118 })
1119 }
1120 _ => false,
1122 }
1123}
1124
1125async fn worktree_age_hours(worktree: &std::path::Path) -> Option<u64> {
1129 let metadata = tokio::fs::metadata(worktree).await.ok()?;
1131 let modified = metadata.modified().ok()?;
1132 let age = std::time::SystemTime::now().duration_since(modified).ok()?;
1133 Some(age.as_secs() / 3600)
1134}
1135
1136pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &std::path::Path) -> bool {
1142 let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1143 Ok(root) => root,
1144 Err(e) => {
1145 tracing::warn!(
1146 worktree = %wt.display(),
1147 error = %e,
1148 "cannot resolve repo root for orphaned worktree"
1149 );
1150 return false;
1151 }
1152 };
1153 remove_worktree_and_branch(task_id, wt, None, std::path::Path::new(&repo_root), false).await
1154}
1155
1156pub(crate) async fn resolve_repo_root_for_orphaned_worktree(
1163 wt: &std::path::Path,
1164) -> anyhow::Result<String> {
1165 let git_file = wt.join(".git");
1166 if tokio::fs::try_exists(&git_file).await.unwrap_or(false)
1167 && let Ok(content) = tokio::fs::read_to_string(&git_file).await
1168 {
1169 for line in content.lines() {
1170 if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1171 let gitdir_path = std::path::PathBuf::from(gitdir);
1175 if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1176 return Ok(repo_root.display().to_string());
1177 }
1178 }
1179 }
1180 }
1181
1182 let worktrees_dir = crate::home::worktrees_dir()?;
1185 if let Ok(relative) = wt.strip_prefix(&worktrees_dir)
1186 && let Some(project_name) = relative.components().next()
1187 {
1188 let project_name = project_name.as_os_str().to_string_lossy();
1189
1190 if let Ok(paths) = crate::config::get_project_paths() {
1191 for path_str in &paths {
1192 let path = std::path::Path::new(path_str);
1193 if let Some(name) = path.file_name()
1194 && name.to_string_lossy() == project_name
1195 {
1196 return Ok(path_str.clone());
1197 }
1198 }
1199 }
1200
1201 let parts: Vec<&str> = project_name.split('/').collect();
1203 let bare = if parts.len() == 2 {
1204 crate::home::projects_dir()
1205 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1206 .unwrap_or_default()
1207 } else {
1208 return Err(anyhow::anyhow!(
1209 "cannot determine owner for single-segment project name '{project_name}' — \
1210 bare repo location is ambiguous; register the project with 'owner/repo' format"
1211 ));
1212 };
1213 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1214 return Ok(bare.display().to_string());
1215 }
1216 }
1217
1218 anyhow::bail!(
1219 "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1220 wt.display()
1221 )
1222}
1223
1224pub async fn resolve_repo_root_from_worktree(wt: &std::path::Path) -> anyhow::Result<String> {
1231 let git_file = wt.join(".git");
1233 if !tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1234 anyhow::bail!(".git file not found in worktree at {}", wt.display());
1235 }
1236
1237 let content = tokio::fs::read_to_string(&git_file)
1238 .await
1239 .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1240
1241 for line in content.lines() {
1243 if let Some(gitdir) = line.strip_prefix("gitdir:") {
1244 let gitdir = gitdir.trim();
1245 let gitdir_path = std::path::Path::new(gitdir);
1249 if let Some(repo_root) = gitdir_path
1250 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
1253 {
1255 return Ok(repo_root.display().to_string());
1256 }
1257 }
1258 }
1259
1260 anyhow::bail!(
1261 "cannot resolve repo root from worktree .git file at {}",
1262 wt.display()
1263 )
1264}
1265
1266pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1271 let paths = crate::config::get_project_paths().unwrap_or_default();
1273 for path_str in &paths {
1274 let path = std::path::Path::new(path_str);
1275 let orch_yml = path.join(".orch.yml");
1276 let legacy = path.join(".orchestrator.yml");
1277 let config_file = if tokio::fs::try_exists(&orch_yml).await.unwrap_or(false) {
1278 orch_yml
1279 } else if tokio::fs::try_exists(&legacy).await.unwrap_or(false) {
1280 legacy
1281 } else {
1282 continue;
1283 };
1284 if let Ok(content) = tokio::fs::read_to_string(&config_file).await
1285 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1286 && let Some(r) = doc
1287 .get("gh")
1288 .and_then(|gh| gh.get("repo"))
1289 .and_then(|r| r.as_str())
1290 && r == repo
1291 {
1292 return Ok(path_str.clone());
1293 }
1294 }
1295
1296 let parts: Vec<&str> = repo.split('/').collect();
1298 let bare = if parts.len() == 2 {
1299 crate::home::projects_dir()
1300 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1301 .unwrap_or_default()
1302 } else {
1303 std::path::PathBuf::new()
1304 };
1305 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1306 return Ok(bare.display().to_string());
1307 }
1308
1309 anyhow::bail!(
1310 "cannot find local path for project {repo} — \
1311 checked {} registered project(s) and bare clone at {}",
1312 paths.len(),
1313 bare.display()
1314 )
1315}
1316
1317fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<std::path::PathBuf> {
1322 if let Ok(dir) = std::env::var("PROJECT_DIR")
1324 && !dir.is_empty()
1325 {
1326 return Ok(std::path::PathBuf::from(dir));
1327 }
1328
1329 if let Ok(paths) = crate::config::get_project_paths() {
1331 for path_str in &paths {
1332 let path = std::path::Path::new(path_str);
1333 let orch_yml = path.join(".orch.yml");
1334 let legacy = path.join(".orchestrator.yml");
1335 let config_file = if std::path::Path::new(&orch_yml).exists() {
1336 orch_yml
1337 } else if std::path::Path::new(&legacy).exists() {
1338 legacy
1339 } else {
1340 continue;
1341 };
1342 if let Ok(content) = std::fs::read_to_string(&config_file)
1343 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1344 && let Some(r) = doc
1345 .get("gh")
1346 .and_then(|gh| gh.get("repo"))
1347 .and_then(|r| r.as_str())
1348 && r == repo
1349 {
1350 return Ok(path_str.clone().into());
1351 }
1352 }
1353 }
1354
1355 anyhow::bail!("project dir not found for repo: {repo}")
1356}
1357
1358pub(crate) async fn check_merged_prs(
1363 backend: &Arc<dyn ExternalBackend>,
1364 repo: &str,
1365 store: &Arc<TaskStore>,
1366 task_manager: &Arc<TaskManager>,
1367 in_review_tasks: &[ReviewTaskSnapshot],
1368 needs_review_tasks: &[ReviewTaskSnapshot],
1369) -> anyhow::Result<()> {
1370 let mut branch_map: std::collections::HashMap<String, String> =
1371 std::collections::HashMap::new();
1372 let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1373 .iter()
1374 .chain(needs_review_tasks.iter())
1375 .collect();
1376
1377 for task in &all_review_tasks {
1378 if !task.stored.branch.is_empty() {
1379 branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1380 }
1381 }
1382
1383 tracing::debug!(
1384 count = all_review_tasks.len(),
1385 "checking review tasks for merged PRs"
1386 );
1387
1388 let mut task_branches: Vec<(String, String)> = Vec::new();
1392 for task in all_review_tasks {
1393 let task_id = &task.external.id.0;
1394 let branch = if let Some(b) = branch_map.get(task_id) {
1395 b.clone()
1396 } else {
1397 match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1398 .await
1399 .map(|t| t.branch)
1400 {
1401 Some(b) if !b.is_empty() => b,
1402 _ => {
1403 tracing::debug!(task_id, "no branch info, skipping PR check");
1404 continue;
1405 }
1406 }
1407 };
1408 task_branches.push((task_id.clone(), branch));
1409 }
1410
1411 if task_branches.is_empty() {
1412 return Ok(());
1413 }
1414
1415 let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1417 let merged_map = match backend.batch_is_pr_merged(&branches).await {
1418 Ok(map) => map,
1419 Err(e) => {
1420 tracing::warn!(
1421 err = %e,
1422 branch_count = branches.len(),
1423 "batch PR merge check failed, retrying affected branches individually"
1424 );
1425 fallback_is_pr_merged_by_branch(backend, &branches).await
1426 }
1427 };
1428
1429 for (task_id, branch) in task_branches {
1430 let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1431 if !is_merged {
1432 continue;
1433 }
1434
1435 tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1436
1437 let id = ExternalId(task_id.clone());
1438 if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1439 tracing::warn!(task_id, err = %e, "failed to update status to done");
1440 continue;
1441 }
1442
1443 if let Err(e) = cleanup_task_worktree(&task_id, repo, store).await {
1444 tracing::warn!(task_id, err = %e, "worktree cleanup after merge failed");
1445 }
1446
1447 let comment = format!(
1448 "PR merged, marking task complete{}",
1449 crate::engine::orch_footer()
1450 );
1451 if let Err(e) = backend.post_comment(&id, &comment).await {
1452 tracing::warn!(task_id, err = %e, "failed to post comment");
1453 }
1454 }
1455
1456 Ok(())
1457}
1458
1459async fn fallback_is_pr_merged_by_branch(
1460 backend: &Arc<dyn ExternalBackend>,
1461 branches: &[String],
1462) -> HashMap<String, bool> {
1463 let futs: Vec<_> = branches
1464 .iter()
1465 .map(|branch| {
1466 let backend = Arc::clone(backend);
1467 let branch = branch.clone();
1468 async move {
1469 match backend.is_pr_merged(&branch).await {
1470 Ok(is_merged) => Some((branch, is_merged)),
1471 Err(err) => {
1472 tracing::warn!(
1473 branch,
1474 err = %err,
1475 "fallback PR merge check failed for branch"
1476 );
1477 None
1478 }
1479 }
1480 }
1481 })
1482 .collect();
1483
1484 futures::future::join_all(futs)
1485 .await
1486 .into_iter()
1487 .flatten()
1488 .collect()
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493 use super::*;
1494 use crate::store::{NewTask, TaskStore};
1495 use async_trait::async_trait;
1496 use std::collections::HashMap;
1497 use std::sync::Mutex;
1498 use std::sync::atomic::{AtomicUsize, Ordering};
1499
1500 #[tokio::test]
1503 async fn worktree_age_hours_returns_none_for_missing_dir() {
1504 let tmp = tempfile::tempdir().unwrap();
1505 let missing = tmp.path().join("does-not-exist");
1506 assert!(worktree_age_hours(&missing).await.is_none());
1507 }
1508
1509 #[tokio::test]
1510 async fn worktree_age_hours_returns_some_for_existing_dir() {
1511 let tmp = tempfile::tempdir().unwrap();
1512 let age = worktree_age_hours(tmp.path()).await;
1514 assert!(age.is_some());
1515 assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1516 }
1517
1518 #[test]
1521 fn janitor_options_default_values() {
1522 let opts = JanitorOptions::default();
1523 assert_eq!(opts.ttl_hours, 24);
1524 assert!(!opts.dry_run);
1525 }
1526
1527 fn setup_test_repo() -> Option<(tempfile::TempDir, std::path::PathBuf)> {
1531 let tmp = tempfile::tempdir().ok()?;
1532
1533 let repo_dir = tmp.path().join("repo.git");
1535 std::fs::create_dir_all(&repo_dir).ok()?;
1536
1537 let init = std::process::Command::new("git")
1538 .args([
1539 "init",
1540 "--initial-branch=main",
1541 repo_dir
1542 .to_str()
1543 .expect("test repo path contains non-UTF-8 characters"),
1544 ])
1545 .output()
1546 .ok()?;
1547 if !init.status.success() {
1548 let _ = std::process::Command::new("git")
1550 .args([
1551 "init",
1552 repo_dir
1553 .to_str()
1554 .expect("test repo path contains non-UTF-8 characters"),
1555 ])
1556 .output()
1557 .ok()?;
1558 }
1559
1560 let readme = repo_dir.join("README.md");
1562 std::fs::write(&readme, "# test").ok()?;
1563 std::process::Command::new("git")
1564 .args(["-C", repo_dir.to_str()?, "add", "."])
1565 .output()
1566 .ok()?;
1567 std::process::Command::new("git")
1568 .args([
1569 "-C",
1570 repo_dir.to_str()?,
1571 "-c",
1572 "user.email=test@test.com",
1573 "-c",
1574 "user.name=Test",
1575 "-c",
1576 "commit.gpgsign=false",
1577 "commit",
1578 "--allow-empty",
1579 "-m",
1580 "init",
1581 ])
1582 .output()
1583 .ok()?;
1584
1585 let wt_dir = tmp
1587 .path()
1588 .join("worktrees")
1589 .join("repo.git")
1590 .join("gh-issue-42-test");
1591 std::fs::create_dir_all(wt_dir.parent()?).ok()?;
1592 let wt_out = std::process::Command::new("git")
1593 .args([
1594 "-C",
1595 repo_dir.to_str()?,
1596 "worktree",
1597 "add",
1598 "-b",
1599 "gh-issue-42-test",
1600 wt_dir.to_str()?,
1601 ])
1602 .output()
1603 .ok()?;
1604
1605 if !wt_out.status.success() {
1606 return None;
1607 }
1608
1609 Some((tmp, wt_dir))
1610 }
1611
1612 #[tokio::test]
1613 async fn janitor_skips_young_worktree() {
1614 let Some((tmp, wt_dir)) = setup_test_repo() else {
1615 eprintln!("skipping test: git not available");
1616 return;
1617 };
1618
1619 assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1621
1622 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1624 assert!(
1625 age < 24,
1626 "freshly created worktree should be younger than 24h (age={age}h)"
1627 );
1628
1629 let opts = JanitorOptions {
1631 ttl_hours: 24,
1632 dry_run: true,
1633 };
1634 assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1636
1637 drop(tmp); }
1639
1640 #[tokio::test]
1641 async fn janitor_eligible_when_ttl_zero() {
1642 let Some((tmp, wt_dir)) = setup_test_repo() else {
1643 eprintln!("skipping test: git not available");
1644 return;
1645 };
1646
1647 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1649 let opts = JanitorOptions {
1650 ttl_hours: 0,
1651 dry_run: true,
1652 };
1653 assert!(
1654 age >= opts.ttl_hours,
1655 "worktree should be eligible when ttl=0 (age={age}h)"
1656 );
1657
1658 drop(tmp);
1659 }
1660
1661 #[tokio::test]
1662 async fn dry_run_does_not_remove_worktree() {
1663 let Some((tmp, wt_dir)) = setup_test_repo() else {
1665 eprintln!("skipping test: git not available");
1666 return;
1667 };
1668
1669 assert!(wt_dir.exists(), "worktree must exist before test");
1670
1671 let opts = JanitorOptions {
1674 ttl_hours: 0,
1675 dry_run: true,
1676 };
1677
1678 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1683 let in_use = false; let skip = age < opts.ttl_hours || in_use;
1685
1686 if !skip && opts.dry_run {
1687 tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1689 } else if !skip {
1690 }
1692
1693 assert!(
1695 wt_dir.exists(),
1696 "dry-run must not remove the worktree directory"
1697 );
1698
1699 drop(tmp);
1700 }
1701
1702 #[test]
1703 fn fallback_branch_path_construction() {
1704 let tmp = tempfile::tempdir().unwrap();
1707 let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1708 std::fs::create_dir_all(&wt_dir).unwrap();
1709
1710 let branch = "gh-issue-7-fix";
1712 let repo = "owner/myrepo";
1713 let worktrees_base = tmp.path();
1714
1715 let project = repo
1716 .rsplit('/')
1717 .next()
1718 .unwrap_or(repo)
1719 .trim_end_matches(".git");
1720 let candidate = worktrees_base.join(project).join(branch);
1721
1722 assert_eq!(candidate, wt_dir);
1723 assert!(
1724 candidate.exists(),
1725 "fallback path should point to existing dir"
1726 );
1727 }
1728
1729 #[tokio::test]
1732 async fn cleanup_returns_false_when_nothing_to_clean() {
1733 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1734
1735 let id = store
1737 .create(&NewTask {
1738 external_id: Some("999".to_string()),
1739 repo: "owner/repo".to_string(),
1740 origin: "github".to_string(),
1741 title: "Already cleaned".to_string(),
1742 ..Default::default()
1743 })
1744 .await
1745 .unwrap();
1746 store
1747 .update_status(id, crate::store::TaskStatus::Done)
1748 .await
1749 .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
1794 .update_status(id, crate::store::TaskStatus::Done)
1795 .await
1796 .unwrap();
1797 store
1799 .set_fields(
1800 id,
1801 &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
1802 )
1803 .await
1804 .unwrap();
1805
1806 let before = store.get(id).await.unwrap();
1808 assert!(!before.worktree_cleaned, "task should not be cleaned yet");
1809 assert!(
1810 !before.worktree.is_empty(),
1811 "task must have a worktree path set"
1812 );
1813
1814 let opts = JanitorOptions {
1815 ttl_hours: 0,
1816 dry_run: false,
1817 };
1818
1819 let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
1820
1821 match result {
1824 Ok(_) => {}
1825 Err(e) => panic!("cleanup should not error for stale path: {e}"),
1826 }
1827
1828 let after = store.get(id).await.unwrap();
1830 assert!(
1831 after.worktree_cleaned,
1832 "task must be marked cleaned when stored worktree path is already gone"
1833 );
1834 }
1835
1836 #[tokio::test]
1839 async fn remove_worktree_returns_true_on_success() {
1840 let Some((tmp, wt_dir)) = setup_test_repo() else {
1841 eprintln!("skipping test: git not available");
1842 return;
1843 };
1844
1845 let repo_dir = tmp.path().join("repo.git");
1846 assert!(wt_dir.exists(), "worktree must exist before removal");
1847
1848 let removed =
1849 remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
1850 .await;
1851
1852 assert!(
1853 removed,
1854 "remove_worktree_and_branch should return true when directory is gone"
1855 );
1856 assert!(!wt_dir.exists(), "worktree directory should be removed");
1857 }
1858
1859 #[tokio::test]
1862 async fn remove_worktree_success_still_deletes_local_branch() {
1863 let Some((tmp, wt_dir)) = setup_test_repo() else {
1864 eprintln!("skipping test: git not available");
1865 return;
1866 };
1867
1868 let repo_dir = tmp.path().join("repo.git");
1869 let repo_dir_str = repo_dir
1870 .to_str()
1871 .expect("test repo path contains non-UTF-8 characters");
1872 let branch = "gh-issue-42-test";
1873
1874 let before = std::process::Command::new("git")
1875 .args([
1876 "-C",
1877 repo_dir_str,
1878 "show-ref",
1879 "--verify",
1880 "--quiet",
1881 &format!("refs/heads/{branch}"),
1882 ])
1883 .status()
1884 .unwrap();
1885 assert!(
1886 before.success(),
1887 "test setup must create local branch before cleanup"
1888 );
1889
1890 let removed =
1891 remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
1892 assert!(removed, "worktree should be removed");
1893
1894 let after = std::process::Command::new("git")
1895 .args([
1896 "-C",
1897 repo_dir_str,
1898 "show-ref",
1899 "--verify",
1900 "--quiet",
1901 &format!("refs/heads/{branch}"),
1902 ])
1903 .status()
1904 .unwrap();
1905 assert!(
1906 !after.success(),
1907 "local branch should be deleted after successful worktree cleanup"
1908 );
1909 }
1910
1911 #[tokio::test]
1915 async fn remove_worktree_returns_false_when_dir_persists() {
1916 let tmp = tempfile::tempdir().unwrap();
1917 let fake_wt = tmp.path().join("not-a-worktree");
1920 std::fs::create_dir_all(&fake_wt).unwrap();
1921
1922 let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
1925
1926 assert!(
1929 !removed,
1930 "remove_worktree_and_branch should return false when directory still exists"
1931 );
1932 assert!(fake_wt.exists(), "directory should still be on disk");
1933 }
1934
1935 struct MergeCheckMockBackend {
1936 batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
1937 single_results: HashMap<String, anyhow::Result<bool>>,
1938 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
1939 single_checked_branches: Arc<Mutex<Vec<String>>>,
1940 }
1941
1942 impl MergeCheckMockBackend {
1943 fn with_batch_error(
1944 error: anyhow::Error,
1945 single_results: HashMap<String, anyhow::Result<bool>>,
1946 ) -> Self {
1947 Self {
1948 batch_result: Mutex::new(Err(error)),
1949 single_results,
1950 posted_comments: Arc::new(Mutex::new(Vec::new())),
1951 single_checked_branches: Arc::new(Mutex::new(Vec::new())),
1952 }
1953 }
1954 }
1955
1956 #[async_trait]
1957 impl ExternalBackend for MergeCheckMockBackend {
1958 fn name(&self) -> &'static str {
1959 "mock"
1960 }
1961
1962 async fn create_task(
1963 &self,
1964 _title: &str,
1965 _body: &str,
1966 _labels: &[String],
1967 ) -> anyhow::Result<ExternalId> {
1968 anyhow::bail!("not implemented")
1969 }
1970
1971 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
1972 anyhow::bail!("not implemented")
1973 }
1974
1975 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1976 Ok(vec![])
1977 }
1978
1979 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1980 Ok(vec![])
1981 }
1982
1983 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
1984 self.posted_comments
1985 .lock()
1986 .unwrap()
1987 .push((id.0.clone(), body.to_string()));
1988 Ok(())
1989 }
1990
1991 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
1992 Ok(())
1993 }
1994
1995 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
1996 Ok(())
1997 }
1998
1999 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2000 Ok(vec![])
2001 }
2002
2003 async fn create_sub_task(
2004 &self,
2005 _parent: &ExternalId,
2006 _title: &str,
2007 _body: &str,
2008 _labels: &[String],
2009 ) -> anyhow::Result<ExternalId> {
2010 anyhow::bail!("not implemented")
2011 }
2012
2013 async fn health_check(&self) -> anyhow::Result<()> {
2014 Ok(())
2015 }
2016
2017 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2018 self.single_checked_branches
2019 .lock()
2020 .unwrap()
2021 .push(branch.to_string());
2022 match self.single_results.get(branch) {
2023 Some(Ok(is_merged)) => Ok(*is_merged),
2024 Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2025 None => Ok(false),
2026 }
2027 }
2028
2029 async fn batch_is_pr_merged(
2030 &self,
2031 _branches: &[String],
2032 ) -> anyhow::Result<HashMap<String, bool>> {
2033 match &*self.batch_result.lock().unwrap() {
2034 Ok(map) => Ok(map.clone()),
2035 Err(err) => Err(anyhow::anyhow!(err.to_string())),
2036 }
2037 }
2038 }
2039
2040 #[tokio::test]
2041 async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2042 let single_results = HashMap::from([
2043 ("branch-1".to_string(), Ok(true)),
2044 ("branch-2".to_string(), Ok(false)),
2045 ]);
2046 let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2047 anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2048 single_results,
2049 ));
2050 let backend_dyn: Arc<dyn ExternalBackend> = Arc::<MergeCheckMockBackend>::clone(&backend);
2051
2052 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2053 let task1 = store
2054 .create(&NewTask {
2055 external_id: Some("101".to_string()),
2056 repo: "owner/repo".to_string(),
2057 origin: "github".to_string(),
2058 title: "Task 101".to_string(),
2059 ..Default::default()
2060 })
2061 .await
2062 .unwrap();
2063 store
2064 .update_status(task1, crate::store::TaskStatus::NeedsReview)
2065 .await
2066 .unwrap();
2067 store
2068 .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2069 .await
2070 .unwrap();
2071 let wt_dir = tempfile::tempdir().unwrap();
2073 let wt_path = wt_dir.path().join("branch-1");
2074 std::fs::create_dir_all(&wt_path).unwrap();
2075 store
2076 .set_fields(
2077 task1,
2078 &[("worktree", serde_json::json!(wt_path.to_string_lossy()))],
2079 )
2080 .await
2081 .unwrap();
2082
2083 let task2 = store
2084 .create(&NewTask {
2085 external_id: Some("102".to_string()),
2086 repo: "owner/repo".to_string(),
2087 origin: "github".to_string(),
2088 title: "Task 102".to_string(),
2089 ..Default::default()
2090 })
2091 .await
2092 .unwrap();
2093 store
2094 .update_status(task2, crate::store::TaskStatus::InReview)
2095 .await
2096 .unwrap();
2097 store
2098 .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2099 .await
2100 .unwrap();
2101
2102 let task_manager = Arc::new(TaskManager::with_store(
2103 std::sync::Arc::clone(&backend_dyn),
2104 std::sync::Arc::clone(&store),
2105 "owner/repo".to_string(),
2106 ));
2107
2108 let in_review_tasks = store
2109 .list_by_status("owner/repo", crate::store::TaskStatus::InReview)
2110 .await
2111 .unwrap()
2112 .into_iter()
2113 .map(|stored| ReviewTaskSnapshot {
2114 external: crate::engine::tasks::store_task_to_external(&stored),
2115 stored,
2116 })
2117 .collect::<Vec<_>>();
2118 let needs_review_tasks = store
2119 .list_by_status("owner/repo", crate::store::TaskStatus::NeedsReview)
2120 .await
2121 .unwrap()
2122 .into_iter()
2123 .map(|stored| ReviewTaskSnapshot {
2124 external: crate::engine::tasks::store_task_to_external(&stored),
2125 stored,
2126 })
2127 .collect::<Vec<_>>();
2128
2129 check_merged_prs(
2130 &backend_dyn,
2131 "owner/repo",
2132 &store,
2133 &task_manager,
2134 &in_review_tasks,
2135 &needs_review_tasks,
2136 )
2137 .await
2138 .unwrap();
2139
2140 let checked = backend.single_checked_branches.lock().unwrap().clone();
2141 assert_eq!(
2142 checked,
2143 vec!["branch-2".to_string(), "branch-1".to_string()]
2144 );
2145
2146 let updated_task1 = store.get(task1).await.unwrap();
2147 let updated_task2 = store.get(task2).await.unwrap();
2148 assert_eq!(updated_task1.status, crate::store::TaskStatus::Done);
2149 assert!(updated_task1.worktree_cleaned);
2150 assert!(!wt_path.exists());
2151 assert!(!updated_task2.worktree_cleaned);
2152 assert_eq!(updated_task2.status, crate::store::TaskStatus::InReview);
2153
2154 let comments = backend.posted_comments.lock().unwrap().clone();
2155 assert_eq!(comments.len(), 1);
2156 assert_eq!(comments[0].0, "101");
2157 assert!(comments[0].1.contains("PR merged, marking task complete"));
2158 }
2159
2160 struct ReconciliationMockBackend {
2161 reconciliation_calls: Arc<AtomicUsize>,
2162 }
2163
2164 #[async_trait]
2165 impl ExternalBackend for ReconciliationMockBackend {
2166 fn name(&self) -> &'static str {
2167 "mock"
2168 }
2169
2170 async fn create_task(
2171 &self,
2172 _title: &str,
2173 _body: &str,
2174 _labels: &[String],
2175 ) -> anyhow::Result<ExternalId> {
2176 anyhow::bail!("not implemented")
2177 }
2178
2179 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2180 anyhow::bail!("not implemented")
2181 }
2182
2183 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2184 Ok(vec![])
2185 }
2186
2187 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2188 self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2189 Ok(vec![])
2190 }
2191
2192 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2193 Ok(vec![])
2194 }
2195
2196 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2197 Ok(())
2198 }
2199
2200 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2201 Ok(())
2202 }
2203
2204 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2205 Ok(())
2206 }
2207
2208 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2209 Ok(vec![])
2210 }
2211
2212 async fn create_sub_task(
2213 &self,
2214 _parent: &ExternalId,
2215 _title: &str,
2216 _body: &str,
2217 _labels: &[String],
2218 ) -> anyhow::Result<ExternalId> {
2219 anyhow::bail!("not implemented")
2220 }
2221
2222 async fn health_check(&self) -> anyhow::Result<()> {
2223 Ok(())
2224 }
2225 }
2226
2227 #[tokio::test]
2228 async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2229 let repo = "owner/repo-cleanup-rate-limit";
2230 RECONCILIATION_LAST_RUN.remove(repo);
2231
2232 let backend = Arc::new(ReconciliationMockBackend {
2233 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2234 });
2235 let backend_dyn: Arc<dyn ExternalBackend> =
2236 Arc::<ReconciliationMockBackend>::clone(&backend);
2237 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2238 let task_manager = Arc::new(TaskManager::with_store(
2239 std::sync::Arc::clone(&backend_dyn),
2240 std::sync::Arc::clone(&store),
2241 repo.to_string(),
2242 ));
2243 let opts = JanitorOptions {
2244 ttl_hours: 0,
2245 dry_run: true,
2246 };
2247
2248 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2249 .await
2250 .unwrap();
2251 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2252 .await
2253 .unwrap();
2254
2255 assert_eq!(
2256 backend.reconciliation_calls.load(Ordering::SeqCst),
2257 1,
2258 "cleanup should not re-list reconciliation candidates on every sync pass"
2259 );
2260
2261 RECONCILIATION_LAST_RUN.remove(repo);
2262 }
2263
2264 #[tokio::test]
2267 async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2268 let repo = "owner/repo-cleanup-blocked";
2269 RECONCILIATION_LAST_RUN.remove(repo);
2270 let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2271 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2272 });
2273 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2274 let task_manager = Arc::new(TaskManager::with_store(
2275 std::sync::Arc::clone(&backend_dyn),
2276 std::sync::Arc::clone(&store),
2277 repo.to_string(),
2278 ));
2279
2280 let mut ids = Vec::new();
2281 for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2282 let id = store
2283 .create_internal(repo, title, "", "test", title, None)
2284 .await
2285 .unwrap();
2286 store
2287 .set_fields(
2288 id,
2289 &[
2290 (
2291 "worktree",
2292 serde_json::json!(format!("/nonexistent/{title}")),
2293 ),
2294 ("branch", serde_json::json!("")),
2295 ],
2296 )
2297 .await
2298 .unwrap();
2299 store.update_status(id, status).await.unwrap();
2300 ids.push(id);
2301 }
2302
2303 let opts = JanitorOptions {
2304 ttl_hours: 0,
2305 dry_run: false,
2306 };
2307 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2308 .await
2309 .unwrap();
2310
2311 assert!(
2312 !store.get(ids[0]).await.unwrap().worktree_cleaned,
2313 "blocked task must not be considered for cleanup"
2314 );
2315 assert!(
2316 store.get(ids[1]).await.unwrap().worktree_cleaned,
2317 "done task is cleaned, which proves the pass ran"
2318 );
2319 RECONCILIATION_LAST_RUN.remove(repo);
2320 }
2321
2322 fn git_ok(dir: &std::path::Path, args: &[&str]) -> String {
2323 let out = std::process::Command::new("git")
2324 .args([
2325 "-c",
2326 "commit.gpgsign=false",
2327 "-c",
2328 "user.name=T",
2329 "-c",
2330 "user.email=t@t",
2331 ])
2332 .args(args)
2333 .current_dir(dir)
2334 .output()
2335 .unwrap();
2336 assert!(
2337 out.status.success(),
2338 "git {args:?}: {}",
2339 String::from_utf8_lossy(&out.stderr)
2340 );
2341 String::from_utf8_lossy(&out.stdout).to_string()
2342 }
2343
2344 fn origin_and_clone_with_feat() -> (tempfile::TempDir, std::path::PathBuf) {
2346 let tmp = tempfile::tempdir().unwrap();
2347 let bare = tmp.path().join("origin.git");
2348 std::fs::create_dir_all(&bare).unwrap();
2349 git_ok(&bare, &["init", "--bare", "-b", "main"]);
2350 git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2351 let clone = tmp.path().join("clone");
2352 std::fs::write(clone.join("base.txt"), "base").unwrap();
2353 git_ok(&clone, &["add", "."]);
2354 git_ok(&clone, &["commit", "-m", "base"]);
2355 git_ok(&clone, &["push", "origin", "main"]);
2356 git_ok(&clone, &["checkout", "-b", "feat"]);
2357 std::fs::write(clone.join("work.txt"), "work").unwrap();
2358 git_ok(&clone, &["add", "."]);
2359 git_ok(&clone, &["commit", "-m", "work"]);
2360 git_ok(&clone, &["checkout", "main"]);
2361 (tmp, clone)
2362 }
2363
2364 fn remote_has(clone: &std::path::Path, branch: &str) -> bool {
2365 !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2366 .trim()
2367 .is_empty()
2368 }
2369
2370 fn local_has(clone: &std::path::Path, branch: &str) -> bool {
2371 std::process::Command::new("git")
2372 .args([
2373 "show-ref",
2374 "--verify",
2375 "--quiet",
2376 &format!("refs/heads/{branch}"),
2377 ])
2378 .current_dir(clone)
2379 .status()
2380 .unwrap()
2381 .success()
2382 }
2383
2384 #[tokio::test]
2387 async fn delete_branches_keeps_an_unmerged_remote_branch() {
2388 let (_tmp, clone) = origin_and_clone_with_feat();
2389 git_ok(&clone, &["push", "origin", "feat"]);
2390
2391 delete_branches("1", "feat", &clone).await;
2392
2393 assert!(
2394 remote_has(&clone, "feat"),
2395 "unmerged remote branch must survive"
2396 );
2397 assert!(
2398 !local_has(&clone, "feat"),
2399 "local copy is on origin, so it can go"
2400 );
2401 }
2402
2403 #[tokio::test]
2404 async fn delete_branches_removes_a_branch_main_already_contains() {
2405 let (_tmp, clone) = origin_and_clone_with_feat();
2406 git_ok(&clone, &["push", "origin", "feat"]);
2407 git_ok(&clone, &["merge", "--ff-only", "feat"]);
2408 git_ok(&clone, &["push", "origin", "main"]);
2409
2410 delete_branches("1", "feat", &clone).await;
2411
2412 assert!(!remote_has(&clone, "feat"));
2413 assert!(!local_has(&clone, "feat"));
2414 }
2415
2416 #[tokio::test]
2417 async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2418 let (_tmp, clone) = origin_and_clone_with_feat();
2419
2420 delete_branches("1", "feat", &clone).await;
2421
2422 assert!(
2423 local_has(&clone, "feat"),
2424 "unpushed commits must not be deleted"
2425 );
2426 }
2427}