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 }
1088 Ok(output) => {
1089 let stderr = String::from_utf8_lossy(&output.stderr);
1090 tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1091 }
1092 Err(e) => {
1093 tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1094 }
1095 }
1096}
1097
1098async fn is_worktree_in_active_session(worktree: &std::path::Path) -> bool {
1104 let output = Command::new("tmux")
1105 .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1106 .output()
1107 .await;
1108
1109 match output {
1110 Ok(o) if o.status.success() => {
1111 let worktree_str = worktree.to_string_lossy();
1112 String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1113 let pane_path = line.trim();
1114 pane_path == worktree_str.as_ref()
1116 || pane_path.starts_with(&format!("{worktree_str}/"))
1117 })
1118 }
1119 _ => false,
1121 }
1122}
1123
1124async fn worktree_age_hours(worktree: &std::path::Path) -> Option<u64> {
1128 let metadata = tokio::fs::metadata(worktree).await.ok()?;
1130 let modified = metadata.modified().ok()?;
1131 let age = std::time::SystemTime::now().duration_since(modified).ok()?;
1132 Some(age.as_secs() / 3600)
1133}
1134
1135pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &std::path::Path) -> bool {
1141 let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1142 Ok(root) => root,
1143 Err(e) => {
1144 tracing::warn!(
1145 worktree = %wt.display(),
1146 error = %e,
1147 "cannot resolve repo root for orphaned worktree"
1148 );
1149 return false;
1150 }
1151 };
1152 remove_worktree_and_branch(task_id, wt, None, std::path::Path::new(&repo_root), false).await
1153}
1154
1155pub(crate) async fn resolve_repo_root_for_orphaned_worktree(
1162 wt: &std::path::Path,
1163) -> anyhow::Result<String> {
1164 let git_file = wt.join(".git");
1165 if tokio::fs::try_exists(&git_file).await.unwrap_or(false)
1166 && let Ok(content) = tokio::fs::read_to_string(&git_file).await
1167 {
1168 for line in content.lines() {
1169 if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1170 let gitdir_path = std::path::PathBuf::from(gitdir);
1174 if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1175 return Ok(repo_root.display().to_string());
1176 }
1177 }
1178 }
1179 }
1180
1181 let worktrees_dir = crate::home::worktrees_dir()?;
1184 if let Ok(relative) = wt.strip_prefix(&worktrees_dir)
1185 && let Some(project_name) = relative.components().next()
1186 {
1187 let project_name = project_name.as_os_str().to_string_lossy();
1188
1189 if let Ok(paths) = crate::config::get_project_paths() {
1190 for path_str in &paths {
1191 let path = std::path::Path::new(path_str);
1192 if let Some(name) = path.file_name()
1193 && name.to_string_lossy() == project_name
1194 {
1195 return Ok(path_str.clone());
1196 }
1197 }
1198 }
1199
1200 let parts: Vec<&str> = project_name.split('/').collect();
1202 let bare = if parts.len() == 2 {
1203 crate::home::projects_dir()
1204 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1205 .unwrap_or_default()
1206 } else {
1207 return Err(anyhow::anyhow!(
1208 "cannot determine owner for single-segment project name '{project_name}' — \
1209 bare repo location is ambiguous; register the project with 'owner/repo' format"
1210 ));
1211 };
1212 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1213 return Ok(bare.display().to_string());
1214 }
1215 }
1216
1217 anyhow::bail!(
1218 "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1219 wt.display()
1220 )
1221}
1222
1223pub async fn resolve_repo_root_from_worktree(wt: &std::path::Path) -> anyhow::Result<String> {
1230 let git_file = wt.join(".git");
1232 if !tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1233 anyhow::bail!(".git file not found in worktree at {}", wt.display());
1234 }
1235
1236 let content = tokio::fs::read_to_string(&git_file)
1237 .await
1238 .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1239
1240 for line in content.lines() {
1242 if let Some(gitdir) = line.strip_prefix("gitdir:") {
1243 let gitdir = gitdir.trim();
1244 let gitdir_path = std::path::Path::new(gitdir);
1248 if let Some(repo_root) = gitdir_path
1249 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
1252 {
1254 return Ok(repo_root.display().to_string());
1255 }
1256 }
1257 }
1258
1259 anyhow::bail!(
1260 "cannot resolve repo root from worktree .git file at {}",
1261 wt.display()
1262 )
1263}
1264
1265pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1270 let paths = crate::config::get_project_paths().unwrap_or_default();
1272 for path_str in &paths {
1273 let path = std::path::Path::new(path_str);
1274 let orch_yml = path.join(".orch.yml");
1275 let legacy = path.join(".orchestrator.yml");
1276 let config_file = if tokio::fs::try_exists(&orch_yml).await.unwrap_or(false) {
1277 orch_yml
1278 } else if tokio::fs::try_exists(&legacy).await.unwrap_or(false) {
1279 legacy
1280 } else {
1281 continue;
1282 };
1283 if let Ok(content) = tokio::fs::read_to_string(&config_file).await
1284 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1285 && let Some(r) = doc
1286 .get("gh")
1287 .and_then(|gh| gh.get("repo"))
1288 .and_then(|r| r.as_str())
1289 && r == repo
1290 {
1291 return Ok(path_str.clone());
1292 }
1293 }
1294
1295 let parts: Vec<&str> = repo.split('/').collect();
1297 let bare = if parts.len() == 2 {
1298 crate::home::projects_dir()
1299 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1300 .unwrap_or_default()
1301 } else {
1302 std::path::PathBuf::new()
1303 };
1304 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1305 return Ok(bare.display().to_string());
1306 }
1307
1308 anyhow::bail!(
1309 "cannot find local path for project {repo} — \
1310 checked {} registered project(s) and bare clone at {}",
1311 paths.len(),
1312 bare.display()
1313 )
1314}
1315
1316fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<std::path::PathBuf> {
1321 if let Ok(dir) = std::env::var("PROJECT_DIR")
1323 && !dir.is_empty()
1324 {
1325 return Ok(std::path::PathBuf::from(dir));
1326 }
1327
1328 if let Ok(paths) = crate::config::get_project_paths() {
1330 for path_str in &paths {
1331 let path = std::path::Path::new(path_str);
1332 let orch_yml = path.join(".orch.yml");
1333 let legacy = path.join(".orchestrator.yml");
1334 let config_file = if std::path::Path::new(&orch_yml).exists() {
1335 orch_yml
1336 } else if std::path::Path::new(&legacy).exists() {
1337 legacy
1338 } else {
1339 continue;
1340 };
1341 if let Ok(content) = std::fs::read_to_string(&config_file)
1342 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1343 && let Some(r) = doc
1344 .get("gh")
1345 .and_then(|gh| gh.get("repo"))
1346 .and_then(|r| r.as_str())
1347 && r == repo
1348 {
1349 return Ok(path_str.clone().into());
1350 }
1351 }
1352 }
1353
1354 anyhow::bail!("project dir not found for repo: {repo}")
1355}
1356
1357pub(crate) async fn check_merged_prs(
1362 backend: &Arc<dyn ExternalBackend>,
1363 repo: &str,
1364 store: &Arc<TaskStore>,
1365 task_manager: &Arc<TaskManager>,
1366 in_review_tasks: &[ReviewTaskSnapshot],
1367 needs_review_tasks: &[ReviewTaskSnapshot],
1368) -> anyhow::Result<()> {
1369 let mut branch_map: std::collections::HashMap<String, String> =
1370 std::collections::HashMap::new();
1371 let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1372 .iter()
1373 .chain(needs_review_tasks.iter())
1374 .collect();
1375
1376 for task in &all_review_tasks {
1377 if !task.stored.branch.is_empty() {
1378 branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1379 }
1380 }
1381
1382 tracing::debug!(
1383 count = all_review_tasks.len(),
1384 "checking review tasks for merged PRs"
1385 );
1386
1387 let mut task_branches: Vec<(String, String)> = Vec::new();
1391 for task in all_review_tasks {
1392 let task_id = &task.external.id.0;
1393 let branch = if let Some(b) = branch_map.get(task_id) {
1394 b.clone()
1395 } else {
1396 match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1397 .await
1398 .map(|t| t.branch)
1399 {
1400 Some(b) if !b.is_empty() => b,
1401 _ => {
1402 tracing::debug!(task_id, "no branch info, skipping PR check");
1403 continue;
1404 }
1405 }
1406 };
1407 task_branches.push((task_id.clone(), branch));
1408 }
1409
1410 if task_branches.is_empty() {
1411 return Ok(());
1412 }
1413
1414 let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1416 let merged_map = match backend.batch_is_pr_merged(&branches).await {
1417 Ok(map) => map,
1418 Err(e) => {
1419 tracing::warn!(
1420 err = %e,
1421 branch_count = branches.len(),
1422 "batch PR merge check failed, retrying affected branches individually"
1423 );
1424 fallback_is_pr_merged_by_branch(backend, &branches).await
1425 }
1426 };
1427
1428 for (task_id, branch) in task_branches {
1429 let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1430 if !is_merged {
1431 continue;
1432 }
1433
1434 tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1435
1436 let id = ExternalId(task_id.clone());
1437 if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1438 tracing::warn!(task_id, err = %e, "failed to update status to done");
1439 continue;
1440 }
1441
1442 if let Err(e) = cleanup_task_worktree(&task_id, repo, store).await {
1443 tracing::warn!(task_id, err = %e, "worktree cleanup after merge failed");
1444 }
1445
1446 let comment = format!(
1447 "PR merged, marking task complete{}",
1448 crate::engine::orch_footer()
1449 );
1450 if let Err(e) = backend.post_comment(&id, &comment).await {
1451 tracing::warn!(task_id, err = %e, "failed to post comment");
1452 }
1453 }
1454
1455 Ok(())
1456}
1457
1458async fn fallback_is_pr_merged_by_branch(
1459 backend: &Arc<dyn ExternalBackend>,
1460 branches: &[String],
1461) -> HashMap<String, bool> {
1462 let futs: Vec<_> = branches
1463 .iter()
1464 .map(|branch| {
1465 let backend = Arc::clone(backend);
1466 let branch = branch.clone();
1467 async move {
1468 match backend.is_pr_merged(&branch).await {
1469 Ok(is_merged) => Some((branch, is_merged)),
1470 Err(err) => {
1471 tracing::warn!(
1472 branch,
1473 err = %err,
1474 "fallback PR merge check failed for branch"
1475 );
1476 None
1477 }
1478 }
1479 }
1480 })
1481 .collect();
1482
1483 futures::future::join_all(futs)
1484 .await
1485 .into_iter()
1486 .flatten()
1487 .collect()
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492 use super::*;
1493 use crate::store::{NewTask, TaskStore};
1494 use async_trait::async_trait;
1495 use std::collections::HashMap;
1496 use std::sync::Mutex;
1497 use std::sync::atomic::{AtomicUsize, Ordering};
1498
1499 #[tokio::test]
1502 async fn worktree_age_hours_returns_none_for_missing_dir() {
1503 let tmp = tempfile::tempdir().unwrap();
1504 let missing = tmp.path().join("does-not-exist");
1505 assert!(worktree_age_hours(&missing).await.is_none());
1506 }
1507
1508 #[tokio::test]
1509 async fn worktree_age_hours_returns_some_for_existing_dir() {
1510 let tmp = tempfile::tempdir().unwrap();
1511 let age = worktree_age_hours(tmp.path()).await;
1513 assert!(age.is_some());
1514 assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1515 }
1516
1517 #[test]
1520 fn janitor_options_default_values() {
1521 let opts = JanitorOptions::default();
1522 assert_eq!(opts.ttl_hours, 24);
1523 assert!(!opts.dry_run);
1524 }
1525
1526 fn setup_test_repo() -> Option<(tempfile::TempDir, std::path::PathBuf)> {
1530 let tmp = tempfile::tempdir().ok()?;
1531
1532 let repo_dir = tmp.path().join("repo.git");
1534 std::fs::create_dir_all(&repo_dir).ok()?;
1535
1536 let init = std::process::Command::new("git")
1537 .args([
1538 "init",
1539 "--initial-branch=main",
1540 repo_dir
1541 .to_str()
1542 .expect("test repo path contains non-UTF-8 characters"),
1543 ])
1544 .output()
1545 .ok()?;
1546 if !init.status.success() {
1547 let _ = std::process::Command::new("git")
1549 .args([
1550 "init",
1551 repo_dir
1552 .to_str()
1553 .expect("test repo path contains non-UTF-8 characters"),
1554 ])
1555 .output()
1556 .ok()?;
1557 }
1558
1559 let readme = repo_dir.join("README.md");
1561 std::fs::write(&readme, "# test").ok()?;
1562 std::process::Command::new("git")
1563 .args(["-C", repo_dir.to_str()?, "add", "."])
1564 .output()
1565 .ok()?;
1566 std::process::Command::new("git")
1567 .args([
1568 "-C",
1569 repo_dir.to_str()?,
1570 "-c",
1571 "user.email=test@test.com",
1572 "-c",
1573 "user.name=Test",
1574 "-c",
1575 "commit.gpgsign=false",
1576 "commit",
1577 "--allow-empty",
1578 "-m",
1579 "init",
1580 ])
1581 .output()
1582 .ok()?;
1583
1584 let wt_dir = tmp
1586 .path()
1587 .join("worktrees")
1588 .join("repo.git")
1589 .join("gh-issue-42-test");
1590 std::fs::create_dir_all(wt_dir.parent()?).ok()?;
1591 let wt_out = std::process::Command::new("git")
1592 .args([
1593 "-C",
1594 repo_dir.to_str()?,
1595 "worktree",
1596 "add",
1597 "-b",
1598 "gh-issue-42-test",
1599 wt_dir.to_str()?,
1600 ])
1601 .output()
1602 .ok()?;
1603
1604 if !wt_out.status.success() {
1605 return None;
1606 }
1607
1608 Some((tmp, wt_dir))
1609 }
1610
1611 #[tokio::test]
1612 async fn janitor_skips_young_worktree() {
1613 let Some((tmp, wt_dir)) = setup_test_repo() else {
1614 eprintln!("skipping test: git not available");
1615 return;
1616 };
1617
1618 assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1620
1621 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1623 assert!(
1624 age < 24,
1625 "freshly created worktree should be younger than 24h (age={age}h)"
1626 );
1627
1628 let opts = JanitorOptions {
1630 ttl_hours: 24,
1631 dry_run: true,
1632 };
1633 assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1635
1636 drop(tmp); }
1638
1639 #[tokio::test]
1640 async fn janitor_eligible_when_ttl_zero() {
1641 let Some((tmp, wt_dir)) = setup_test_repo() else {
1642 eprintln!("skipping test: git not available");
1643 return;
1644 };
1645
1646 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1648 let opts = JanitorOptions {
1649 ttl_hours: 0,
1650 dry_run: true,
1651 };
1652 assert!(
1653 age >= opts.ttl_hours,
1654 "worktree should be eligible when ttl=0 (age={age}h)"
1655 );
1656
1657 drop(tmp);
1658 }
1659
1660 #[tokio::test]
1661 async fn dry_run_does_not_remove_worktree() {
1662 let Some((tmp, wt_dir)) = setup_test_repo() else {
1664 eprintln!("skipping test: git not available");
1665 return;
1666 };
1667
1668 assert!(wt_dir.exists(), "worktree must exist before test");
1669
1670 let opts = JanitorOptions {
1673 ttl_hours: 0,
1674 dry_run: true,
1675 };
1676
1677 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1682 let in_use = false; let skip = age < opts.ttl_hours || in_use;
1684
1685 if !skip && opts.dry_run {
1686 tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1688 } else if !skip {
1689 }
1691
1692 assert!(
1694 wt_dir.exists(),
1695 "dry-run must not remove the worktree directory"
1696 );
1697
1698 drop(tmp);
1699 }
1700
1701 #[test]
1702 fn fallback_branch_path_construction() {
1703 let tmp = tempfile::tempdir().unwrap();
1706 let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1707 std::fs::create_dir_all(&wt_dir).unwrap();
1708
1709 let branch = "gh-issue-7-fix";
1711 let repo = "owner/myrepo";
1712 let worktrees_base = tmp.path();
1713
1714 let project = repo
1715 .rsplit('/')
1716 .next()
1717 .unwrap_or(repo)
1718 .trim_end_matches(".git");
1719 let candidate = worktrees_base.join(project).join(branch);
1720
1721 assert_eq!(candidate, wt_dir);
1722 assert!(
1723 candidate.exists(),
1724 "fallback path should point to existing dir"
1725 );
1726 }
1727
1728 #[tokio::test]
1731 async fn cleanup_returns_false_when_nothing_to_clean() {
1732 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1733
1734 let id = store
1736 .create(&NewTask {
1737 external_id: Some("999".to_string()),
1738 repo: "owner/repo".to_string(),
1739 origin: "github".to_string(),
1740 title: "Already cleaned".to_string(),
1741 ..Default::default()
1742 })
1743 .await
1744 .unwrap();
1745 store
1746 .update_status(id, crate::store::TaskStatus::Done)
1747 .await
1748 .unwrap();
1749
1750 let opts = JanitorOptions {
1751 ttl_hours: 0,
1752 dry_run: false,
1753 };
1754
1755 let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
1756 if let Ok(cleaned) = result {
1760 assert!(
1761 !cleaned,
1762 "cleanup should return false when there is no worktree or branch to clean"
1763 );
1764 } else {
1765 }
1768 }
1769
1770 #[tokio::test]
1774 async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
1775 let tmp = tempfile::tempdir().unwrap();
1776 let stale_wt = tmp.path().join("stale-worktree");
1778 assert!(!stale_wt.exists(), "stale path must not exist");
1779
1780 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1781
1782 let id = store
1783 .create(&NewTask {
1784 external_id: Some("1005".to_string()),
1785 repo: "owner/repo".to_string(),
1786 origin: "github".to_string(),
1787 title: "Stale worktree task".to_string(),
1788 ..Default::default()
1789 })
1790 .await
1791 .unwrap();
1792 store
1793 .update_status(id, crate::store::TaskStatus::Done)
1794 .await
1795 .unwrap();
1796 store
1798 .set_fields(
1799 id,
1800 &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
1801 )
1802 .await
1803 .unwrap();
1804
1805 let before = store.get(id).await.unwrap();
1807 assert!(!before.worktree_cleaned, "task should not be cleaned yet");
1808 assert!(
1809 !before.worktree.is_empty(),
1810 "task must have a worktree path set"
1811 );
1812
1813 let opts = JanitorOptions {
1814 ttl_hours: 0,
1815 dry_run: false,
1816 };
1817
1818 let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
1819
1820 match result {
1823 Ok(_) => {}
1824 Err(e) => panic!("cleanup should not error for stale path: {e}"),
1825 }
1826
1827 let after = store.get(id).await.unwrap();
1829 assert!(
1830 after.worktree_cleaned,
1831 "task must be marked cleaned when stored worktree path is already gone"
1832 );
1833 }
1834
1835 #[tokio::test]
1838 async fn remove_worktree_returns_true_on_success() {
1839 let Some((tmp, wt_dir)) = setup_test_repo() else {
1840 eprintln!("skipping test: git not available");
1841 return;
1842 };
1843
1844 let repo_dir = tmp.path().join("repo.git");
1845 assert!(wt_dir.exists(), "worktree must exist before removal");
1846
1847 let removed =
1848 remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
1849 .await;
1850
1851 assert!(
1852 removed,
1853 "remove_worktree_and_branch should return true when directory is gone"
1854 );
1855 assert!(!wt_dir.exists(), "worktree directory should be removed");
1856 }
1857
1858 #[tokio::test]
1861 async fn remove_worktree_success_still_deletes_local_branch() {
1862 let Some((tmp, wt_dir)) = setup_test_repo() else {
1863 eprintln!("skipping test: git not available");
1864 return;
1865 };
1866
1867 let repo_dir = tmp.path().join("repo.git");
1868 let repo_dir_str = repo_dir
1869 .to_str()
1870 .expect("test repo path contains non-UTF-8 characters");
1871 let branch = "gh-issue-42-test";
1872
1873 let before = std::process::Command::new("git")
1874 .args([
1875 "-C",
1876 repo_dir_str,
1877 "show-ref",
1878 "--verify",
1879 "--quiet",
1880 &format!("refs/heads/{branch}"),
1881 ])
1882 .status()
1883 .unwrap();
1884 assert!(
1885 before.success(),
1886 "test setup must create local branch before cleanup"
1887 );
1888
1889 let removed =
1890 remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
1891 assert!(removed, "worktree should be removed");
1892
1893 let after = std::process::Command::new("git")
1894 .args([
1895 "-C",
1896 repo_dir_str,
1897 "show-ref",
1898 "--verify",
1899 "--quiet",
1900 &format!("refs/heads/{branch}"),
1901 ])
1902 .status()
1903 .unwrap();
1904 assert!(
1905 !after.success(),
1906 "local branch should be deleted after successful worktree cleanup"
1907 );
1908 }
1909
1910 #[tokio::test]
1914 async fn remove_worktree_returns_false_when_dir_persists() {
1915 let tmp = tempfile::tempdir().unwrap();
1916 let fake_wt = tmp.path().join("not-a-worktree");
1919 std::fs::create_dir_all(&fake_wt).unwrap();
1920
1921 let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
1924
1925 assert!(
1928 !removed,
1929 "remove_worktree_and_branch should return false when directory still exists"
1930 );
1931 assert!(fake_wt.exists(), "directory should still be on disk");
1932 }
1933
1934 struct MergeCheckMockBackend {
1935 batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
1936 single_results: HashMap<String, anyhow::Result<bool>>,
1937 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
1938 single_checked_branches: Arc<Mutex<Vec<String>>>,
1939 }
1940
1941 impl MergeCheckMockBackend {
1942 fn with_batch_error(
1943 error: anyhow::Error,
1944 single_results: HashMap<String, anyhow::Result<bool>>,
1945 ) -> Self {
1946 Self {
1947 batch_result: Mutex::new(Err(error)),
1948 single_results,
1949 posted_comments: Arc::new(Mutex::new(Vec::new())),
1950 single_checked_branches: Arc::new(Mutex::new(Vec::new())),
1951 }
1952 }
1953 }
1954
1955 #[async_trait]
1956 impl ExternalBackend for MergeCheckMockBackend {
1957 fn name(&self) -> &'static str {
1958 "mock"
1959 }
1960
1961 async fn create_task(
1962 &self,
1963 _title: &str,
1964 _body: &str,
1965 _labels: &[String],
1966 ) -> anyhow::Result<ExternalId> {
1967 anyhow::bail!("not implemented")
1968 }
1969
1970 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
1971 anyhow::bail!("not implemented")
1972 }
1973
1974 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1975 Ok(vec![])
1976 }
1977
1978 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1979 Ok(vec![])
1980 }
1981
1982 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
1983 self.posted_comments
1984 .lock()
1985 .unwrap()
1986 .push((id.0.clone(), body.to_string()));
1987 Ok(())
1988 }
1989
1990 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
1991 Ok(())
1992 }
1993
1994 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
1995 Ok(())
1996 }
1997
1998 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1999 Ok(vec![])
2000 }
2001
2002 async fn create_sub_task(
2003 &self,
2004 _parent: &ExternalId,
2005 _title: &str,
2006 _body: &str,
2007 _labels: &[String],
2008 ) -> anyhow::Result<ExternalId> {
2009 anyhow::bail!("not implemented")
2010 }
2011
2012 async fn health_check(&self) -> anyhow::Result<()> {
2013 Ok(())
2014 }
2015
2016 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2017 self.single_checked_branches
2018 .lock()
2019 .unwrap()
2020 .push(branch.to_string());
2021 match self.single_results.get(branch) {
2022 Some(Ok(is_merged)) => Ok(*is_merged),
2023 Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2024 None => Ok(false),
2025 }
2026 }
2027
2028 async fn batch_is_pr_merged(
2029 &self,
2030 _branches: &[String],
2031 ) -> anyhow::Result<HashMap<String, bool>> {
2032 match &*self.batch_result.lock().unwrap() {
2033 Ok(map) => Ok(map.clone()),
2034 Err(err) => Err(anyhow::anyhow!(err.to_string())),
2035 }
2036 }
2037 }
2038
2039 #[tokio::test]
2040 async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2041 let single_results = HashMap::from([
2042 ("branch-1".to_string(), Ok(true)),
2043 ("branch-2".to_string(), Ok(false)),
2044 ]);
2045 let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2046 anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2047 single_results,
2048 ));
2049 let backend_dyn: Arc<dyn ExternalBackend> = Arc::<MergeCheckMockBackend>::clone(&backend);
2050
2051 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2052 let task1 = store
2053 .create(&NewTask {
2054 external_id: Some("101".to_string()),
2055 repo: "owner/repo".to_string(),
2056 origin: "github".to_string(),
2057 title: "Task 101".to_string(),
2058 ..Default::default()
2059 })
2060 .await
2061 .unwrap();
2062 store
2063 .update_status(task1, crate::store::TaskStatus::NeedsReview)
2064 .await
2065 .unwrap();
2066 store
2067 .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2068 .await
2069 .unwrap();
2070 let wt_dir = tempfile::tempdir().unwrap();
2072 let wt_path = wt_dir.path().join("branch-1");
2073 std::fs::create_dir_all(&wt_path).unwrap();
2074 store
2075 .set_fields(
2076 task1,
2077 &[("worktree", serde_json::json!(wt_path.to_string_lossy()))],
2078 )
2079 .await
2080 .unwrap();
2081
2082 let task2 = store
2083 .create(&NewTask {
2084 external_id: Some("102".to_string()),
2085 repo: "owner/repo".to_string(),
2086 origin: "github".to_string(),
2087 title: "Task 102".to_string(),
2088 ..Default::default()
2089 })
2090 .await
2091 .unwrap();
2092 store
2093 .update_status(task2, crate::store::TaskStatus::InReview)
2094 .await
2095 .unwrap();
2096 store
2097 .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2098 .await
2099 .unwrap();
2100
2101 let task_manager = Arc::new(TaskManager::with_store(
2102 std::sync::Arc::clone(&backend_dyn),
2103 std::sync::Arc::clone(&store),
2104 "owner/repo".to_string(),
2105 ));
2106
2107 let in_review_tasks = store
2108 .list_by_status("owner/repo", crate::store::TaskStatus::InReview)
2109 .await
2110 .unwrap()
2111 .into_iter()
2112 .map(|stored| ReviewTaskSnapshot {
2113 external: crate::engine::tasks::store_task_to_external(&stored),
2114 stored,
2115 })
2116 .collect::<Vec<_>>();
2117 let needs_review_tasks = store
2118 .list_by_status("owner/repo", crate::store::TaskStatus::NeedsReview)
2119 .await
2120 .unwrap()
2121 .into_iter()
2122 .map(|stored| ReviewTaskSnapshot {
2123 external: crate::engine::tasks::store_task_to_external(&stored),
2124 stored,
2125 })
2126 .collect::<Vec<_>>();
2127
2128 check_merged_prs(
2129 &backend_dyn,
2130 "owner/repo",
2131 &store,
2132 &task_manager,
2133 &in_review_tasks,
2134 &needs_review_tasks,
2135 )
2136 .await
2137 .unwrap();
2138
2139 let checked = backend.single_checked_branches.lock().unwrap().clone();
2140 assert_eq!(
2141 checked,
2142 vec!["branch-2".to_string(), "branch-1".to_string()]
2143 );
2144
2145 let updated_task1 = store.get(task1).await.unwrap();
2146 let updated_task2 = store.get(task2).await.unwrap();
2147 assert_eq!(updated_task1.status, crate::store::TaskStatus::Done);
2148 assert!(updated_task1.worktree_cleaned);
2149 assert!(!wt_path.exists());
2150 assert!(!updated_task2.worktree_cleaned);
2151 assert_eq!(updated_task2.status, crate::store::TaskStatus::InReview);
2152
2153 let comments = backend.posted_comments.lock().unwrap().clone();
2154 assert_eq!(comments.len(), 1);
2155 assert_eq!(comments[0].0, "101");
2156 assert!(comments[0].1.contains("PR merged, marking task complete"));
2157 }
2158
2159 struct ReconciliationMockBackend {
2160 reconciliation_calls: Arc<AtomicUsize>,
2161 }
2162
2163 #[async_trait]
2164 impl ExternalBackend for ReconciliationMockBackend {
2165 fn name(&self) -> &'static str {
2166 "mock"
2167 }
2168
2169 async fn create_task(
2170 &self,
2171 _title: &str,
2172 _body: &str,
2173 _labels: &[String],
2174 ) -> anyhow::Result<ExternalId> {
2175 anyhow::bail!("not implemented")
2176 }
2177
2178 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2179 anyhow::bail!("not implemented")
2180 }
2181
2182 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2183 Ok(vec![])
2184 }
2185
2186 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2187 self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2188 Ok(vec![])
2189 }
2190
2191 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2192 Ok(vec![])
2193 }
2194
2195 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2196 Ok(())
2197 }
2198
2199 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2200 Ok(())
2201 }
2202
2203 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2204 Ok(())
2205 }
2206
2207 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2208 Ok(vec![])
2209 }
2210
2211 async fn create_sub_task(
2212 &self,
2213 _parent: &ExternalId,
2214 _title: &str,
2215 _body: &str,
2216 _labels: &[String],
2217 ) -> anyhow::Result<ExternalId> {
2218 anyhow::bail!("not implemented")
2219 }
2220
2221 async fn health_check(&self) -> anyhow::Result<()> {
2222 Ok(())
2223 }
2224 }
2225
2226 #[tokio::test]
2227 async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2228 let repo = "owner/repo-cleanup-rate-limit";
2229 RECONCILIATION_LAST_RUN.remove(repo);
2230
2231 let backend = Arc::new(ReconciliationMockBackend {
2232 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2233 });
2234 let backend_dyn: Arc<dyn ExternalBackend> =
2235 Arc::<ReconciliationMockBackend>::clone(&backend);
2236 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2237 let task_manager = Arc::new(TaskManager::with_store(
2238 std::sync::Arc::clone(&backend_dyn),
2239 std::sync::Arc::clone(&store),
2240 repo.to_string(),
2241 ));
2242 let opts = JanitorOptions {
2243 ttl_hours: 0,
2244 dry_run: true,
2245 };
2246
2247 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2248 .await
2249 .unwrap();
2250 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2251 .await
2252 .unwrap();
2253
2254 assert_eq!(
2255 backend.reconciliation_calls.load(Ordering::SeqCst),
2256 1,
2257 "cleanup should not re-list reconciliation candidates on every sync pass"
2258 );
2259
2260 RECONCILIATION_LAST_RUN.remove(repo);
2261 }
2262
2263 #[tokio::test]
2266 async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2267 let repo = "owner/repo-cleanup-blocked";
2268 RECONCILIATION_LAST_RUN.remove(repo);
2269 let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2270 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2271 });
2272 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2273 let task_manager = Arc::new(TaskManager::with_store(
2274 std::sync::Arc::clone(&backend_dyn),
2275 std::sync::Arc::clone(&store),
2276 repo.to_string(),
2277 ));
2278
2279 let mut ids = Vec::new();
2280 for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2281 let id = store
2282 .create_internal(repo, title, "", "test", title, None)
2283 .await
2284 .unwrap();
2285 store
2286 .set_fields(
2287 id,
2288 &[
2289 (
2290 "worktree",
2291 serde_json::json!(format!("/nonexistent/{title}")),
2292 ),
2293 ("branch", serde_json::json!("")),
2294 ],
2295 )
2296 .await
2297 .unwrap();
2298 store.update_status(id, status).await.unwrap();
2299 ids.push(id);
2300 }
2301
2302 let opts = JanitorOptions {
2303 ttl_hours: 0,
2304 dry_run: false,
2305 };
2306 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2307 .await
2308 .unwrap();
2309
2310 assert!(
2311 !store.get(ids[0]).await.unwrap().worktree_cleaned,
2312 "blocked task must not be considered for cleanup"
2313 );
2314 assert!(
2315 store.get(ids[1]).await.unwrap().worktree_cleaned,
2316 "done task is cleaned, which proves the pass ran"
2317 );
2318 RECONCILIATION_LAST_RUN.remove(repo);
2319 }
2320
2321 fn git_ok(dir: &std::path::Path, args: &[&str]) -> String {
2322 let out = std::process::Command::new("git")
2323 .args([
2324 "-c",
2325 "commit.gpgsign=false",
2326 "-c",
2327 "user.name=T",
2328 "-c",
2329 "user.email=t@t",
2330 ])
2331 .args(args)
2332 .current_dir(dir)
2333 .output()
2334 .unwrap();
2335 assert!(
2336 out.status.success(),
2337 "git {args:?}: {}",
2338 String::from_utf8_lossy(&out.stderr)
2339 );
2340 String::from_utf8_lossy(&out.stdout).to_string()
2341 }
2342
2343 fn origin_and_clone_with_feat() -> (tempfile::TempDir, std::path::PathBuf) {
2345 let tmp = tempfile::tempdir().unwrap();
2346 let bare = tmp.path().join("origin.git");
2347 std::fs::create_dir_all(&bare).unwrap();
2348 git_ok(&bare, &["init", "--bare", "-b", "main"]);
2349 git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2350 let clone = tmp.path().join("clone");
2351 std::fs::write(clone.join("base.txt"), "base").unwrap();
2352 git_ok(&clone, &["add", "."]);
2353 git_ok(&clone, &["commit", "-m", "base"]);
2354 git_ok(&clone, &["push", "origin", "main"]);
2355 git_ok(&clone, &["checkout", "-b", "feat"]);
2356 std::fs::write(clone.join("work.txt"), "work").unwrap();
2357 git_ok(&clone, &["add", "."]);
2358 git_ok(&clone, &["commit", "-m", "work"]);
2359 git_ok(&clone, &["checkout", "main"]);
2360 (tmp, clone)
2361 }
2362
2363 fn remote_has(clone: &std::path::Path, branch: &str) -> bool {
2364 !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2365 .trim()
2366 .is_empty()
2367 }
2368
2369 fn local_has(clone: &std::path::Path, branch: &str) -> bool {
2370 std::process::Command::new("git")
2371 .args([
2372 "show-ref",
2373 "--verify",
2374 "--quiet",
2375 &format!("refs/heads/{branch}"),
2376 ])
2377 .current_dir(clone)
2378 .status()
2379 .unwrap()
2380 .success()
2381 }
2382
2383 #[tokio::test]
2386 async fn delete_branches_keeps_an_unmerged_remote_branch() {
2387 let (_tmp, clone) = origin_and_clone_with_feat();
2388 git_ok(&clone, &["push", "origin", "feat"]);
2389
2390 delete_branches("1", "feat", &clone).await;
2391
2392 assert!(
2393 remote_has(&clone, "feat"),
2394 "unmerged remote branch must survive"
2395 );
2396 assert!(
2397 !local_has(&clone, "feat"),
2398 "local copy is on origin, so it can go"
2399 );
2400 }
2401
2402 #[tokio::test]
2403 async fn delete_branches_removes_a_branch_main_already_contains() {
2404 let (_tmp, clone) = origin_and_clone_with_feat();
2405 git_ok(&clone, &["push", "origin", "feat"]);
2406 git_ok(&clone, &["merge", "--ff-only", "feat"]);
2407 git_ok(&clone, &["push", "origin", "main"]);
2408
2409 delete_branches("1", "feat", &clone).await;
2410
2411 assert!(!remote_has(&clone, "feat"));
2412 assert!(!local_has(&clone, "feat"));
2413 }
2414
2415 #[tokio::test]
2416 async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2417 let (_tmp, clone) = origin_and_clone_with_feat();
2418
2419 delete_branches("1", "feat", &clone).await;
2420
2421 assert!(
2422 local_has(&clone, "feat"),
2423 "unpushed commits must not be deleted"
2424 );
2425 }
2426}