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