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 Command::new("git")
440 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
441 .output_with_context(),
442 )
443 .await;
444 match fetch_result {
445 Ok(Ok(output)) if output.status.success() => {
446 tracing::info!(%repo, "fetched origin after worktree cleanup");
447 }
448 Ok(Ok(output)) => {
449 let stderr = String::from_utf8_lossy(&output.stderr);
450 tracing::debug!(%repo, err = %stderr, "git fetch skipped after cleanup");
451 }
452 Ok(Err(e)) => {
453 tracing::debug!(%repo, err = %e, "git fetch failed after cleanup");
454 }
455 Err(_) => {
456 tracing::debug!(%repo, "git fetch timed out after 60s");
457 }
458 }
459 }
460 }
461
462 Ok(())
463}
464
465pub(crate) async fn cleanup_task_worktree(
475 task_id: &str,
476 repo: &str,
477 store: &Arc<TaskStore>,
478) -> anyhow::Result<()> {
479 let opts = JanitorOptions {
480 ttl_hours: 0,
481 ..Default::default()
482 };
483 let cleaned = cleanup_task_worktree_with_opts(task_id, repo, store, &opts).await?;
484
485 if cleaned {
490 if let Ok(repo_root) = resolve_repo_root(repo).await {
491 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
492 let fetch_result = timeout(
493 GIT_TIMEOUT,
494 Command::new("git")
495 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
496 .output_with_context(),
497 )
498 .await;
499 match fetch_result {
500 Ok(Ok(output)) if output.status.success() => {
501 tracing::debug!(%repo, "fetched origin after task cleanup");
502 }
503 Ok(Ok(output)) => {
504 let stderr = String::from_utf8_lossy(&output.stderr);
505 tracing::debug!(%repo, err = %stderr, "git fetch skipped after task cleanup");
506 }
507 Ok(Err(e)) => {
508 tracing::debug!(%repo, err = %e, "git fetch failed after task cleanup");
509 }
510 Err(_) => {
511 tracing::debug!(%repo, "git fetch timed out after 60s");
512 }
513 }
514 }
515 }
516
517 Ok(())
518}
519
520pub(crate) async fn cleanup_task_worktree_with_opts(
527 task_id: &str,
528 repo: &str,
529 store: &Arc<TaskStore>,
530 opts: &JanitorOptions,
531) -> anyhow::Result<bool> {
532 let (worktree, branch, keep_remote_branch, merged) =
533 store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
534 .await
535 .map(|t| {
536 let keep = t.status == TaskStatus::Blocked && t.pr_number.is_some();
539 let merged = t.status == TaskStatus::Done;
540 (Some(t.worktree), Some(t.branch), keep, merged)
541 })
542 .unwrap_or((None, None, false, false));
543
544 let worktree_path = worktree.as_ref().map(std::path::PathBuf::from);
545
546 let worktrees_base = crate::home::worktrees_dir()
548 .unwrap_or_else(|_| std::path::PathBuf::from(".orch/worktrees"));
549
550 let worktree_to_remove = if let Some(ref wt) = worktree_path {
556 if match tokio::fs::try_exists(wt).await {
557 Ok(true) => true,
558 Ok(false) => false,
559 Err(e) => {
560 tracing::warn!(task_id, worktree = %wt.display(), err = %e, "try_exists failed on stored worktree path — trying branch-based fallback");
561 false
562 }
563 } {
564 Some(wt.clone())
565 } else {
566 if let Some(ref b) = branch {
568 let project = repo
569 .rsplit('/')
570 .next()
571 .unwrap_or(repo)
572 .trim_end_matches(".git");
573 let candidate = worktrees_base.join(project).join(b);
574 if match tokio::fs::try_exists(&candidate).await {
575 Ok(true) => true,
576 Ok(false) => false,
577 Err(e) => {
578 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
579 false
580 }
581 } {
582 Some(candidate)
583 } else {
584 None
585 }
586 } else {
587 None
588 }
589 }
590 } else if let Some(ref b) = branch {
591 let project = repo
593 .rsplit('/')
594 .next()
595 .unwrap_or(repo)
596 .trim_end_matches(".git");
597 let candidate = worktrees_base.join(project).join(b);
598 if match tokio::fs::try_exists(&candidate).await {
599 Ok(true) => true,
600 Ok(false) => false,
601 Err(e) => {
602 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
603 false
604 }
605 } {
606 Some(candidate)
607 } else {
608 None
609 }
610 } else {
611 None
612 };
613
614 let mut did_clean = false;
615
616 let branch_nonempty = branch.as_ref().filter(|b| !b.is_empty());
622 if worktree_to_remove.is_none() && branch_nonempty.is_none() {
623 if worktree_path
626 .as_ref()
627 .is_some_and(|p| !p.as_os_str().is_empty())
628 {
629 tracing::debug!(
630 task_id,
631 worktree = ?worktree_path,
632 "stored worktree path no longer exists on disk — marking cleaned"
633 );
634 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
635 if let Err(e) = store.mark_cleaned(store_id).await {
636 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned in store — will retry");
637 }
638 }
639 }
640 return Ok(false);
641 }
642
643 if let Some(wt) = worktree_to_remove {
644 if let Some(age_hours) = worktree_age_hours(&wt).await {
646 if age_hours < opts.ttl_hours {
647 tracing::debug!(
648 task_id,
649 worktree = %wt.display(),
650 age_hours,
651 ttl_hours = opts.ttl_hours,
652 "worktree too young for cleanup, skipping"
653 );
654 return Ok(false);
655 }
656 }
657
658 if is_worktree_in_active_session(&wt).await {
660 tracing::warn!(
661 task_id,
662 worktree = %wt.display(),
663 "worktree is referenced by an active tmux session — skipping cleanup"
664 );
665 return Ok(false);
666 }
667
668 let gitdir_valid = crate::engine::runner::worktree::validate_worktree_gitdir(&wt).await;
672
673 if opts.dry_run {
674 tracing::info!(
675 task_id,
676 worktree = %wt.display(),
677 branch = ?branch,
678 gitdir_valid,
679 "[dry-run] would remove worktree and branch"
680 );
681 } else if !gitdir_valid {
682 tracing::warn!(
683 task_id,
684 worktree = %wt.display(),
685 "worktree has broken git metadata — force-removing directory without git operations"
686 );
687 match tokio::fs::remove_dir_all(&wt).await {
688 Ok(()) => {
689 did_clean = true;
690 }
691 Err(e) => {
692 tracing::warn!(
693 task_id,
694 worktree = %wt.display(),
695 err = %e,
696 "failed to force-remove broken worktree directory"
697 );
698 }
699 }
700 } else {
701 tracing::info!(task_id, worktree = %wt.display(), "removing worktree");
702 let repo_root = match resolve_repo_root_from_worktree(&wt).await {
707 Ok(root) => root,
708 Err(_) => resolve_repo_root(repo).await?,
709 };
710 if merged {
711 crate::engine::runner::cow::warm_from_merged(
712 std::path::Path::new(&repo_root),
713 &crate::engine::runner::worktree::project_name(std::path::Path::new(
714 &repo_root,
715 )),
716 &wt,
717 )
718 .await;
719 }
720 let removed = remove_worktree_and_branch(
721 task_id,
722 &wt,
723 branch.as_deref(),
724 std::path::Path::new(&repo_root),
725 keep_remote_branch,
726 )
727 .await;
728 if removed {
729 did_clean = true;
730 } else {
731 tracing::warn!(
732 task_id,
733 worktree = %wt.display(),
734 "worktree directory still exists after removal attempt — not marking as cleaned"
735 );
736 }
737 }
738 } else if let Some(ref br) = branch {
739 if !opts.dry_run && !keep_remote_branch {
742 tracing::debug!(task_id, branch = %br, "no worktree on disk, cleaning up branch only");
743 let repo_root = resolve_repo_root(repo).await?;
745 delete_branches(task_id, br, std::path::Path::new(&repo_root)).await;
746 did_clean = true;
747 }
748 }
749
750 if did_clean {
751 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
755 if let Err(e) = store.mark_cleaned(store_id).await {
756 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned — will retry");
757 return Ok(did_clean);
759 }
760 }
761 store_log_activity(
762 &Some(Arc::clone(store)),
763 repo,
764 task_id,
765 "branch_delete",
766 None,
767 None,
768 None,
769 None,
770 Some(&serde_json::json!({
771 "worktree": worktree_path.map(|p| p.display().to_string()),
772 "branch": branch,
773 "keep_remote_branch": keep_remote_branch,
774 })),
775 )
776 .await;
777 }
778
779 Ok(did_clean)
780}
781
782pub(crate) async fn remove_worktree_and_branch(
789 task_id: &str,
790 wt: &std::path::Path,
791 branch: Option<&str>,
792 repo_root: &std::path::Path,
793 keep_remote_branch: bool,
794) -> bool {
795 let wt_str = wt.to_string_lossy().to_string();
796 let repo_root_str = repo_root.to_string_lossy();
797
798 let remove_result = Command::new("git")
801 .args([
802 "-C",
803 repo_root_str.as_ref(),
804 "worktree",
805 "remove",
806 &wt_str,
807 "--force",
808 ])
809 .output_with_context()
810 .await;
811
812 match remove_result {
813 Ok(output) if output.status.success() => {
814 tracing::info!(task_id, "worktree removed (git metadata)");
815 const MAX_RETRIES: usize = 3;
820 const RETRY_DELAY_MS: u64 = 2000;
821 let mut removed = false;
822 for attempt in 0..MAX_RETRIES {
823 match tokio::fs::remove_dir_all(wt).await {
824 Ok(()) => {
825 removed = true;
826 break;
827 }
828 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
829 removed = true;
831 break;
832 }
833 Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
834 if attempt < MAX_RETRIES - 1 {
835 tracing::debug!(
836 task_id,
837 path = %wt.display(),
838 attempt = attempt + 1,
839 "worktree directory not empty — retrying after delay"
840 );
841 sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
842 } else {
843 tracing::warn!(
844 task_id,
845 path = %wt.display(),
846 err = %e,
847 "failed to remove worktree directory after {} attempts (Directory not empty)",
848 MAX_RETRIES
849 );
850 }
851 }
852 Err(e) => {
853 tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
854 break;
855 }
856 }
857 }
858 if removed {
859 tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
860 } else {
861 tracing::warn!(
862 task_id,
863 path = %wt.display(),
864 "worktree directory still present after git worktree remove"
865 );
866 }
867 }
868 Ok(output) => {
869 let stderr = String::from_utf8_lossy(&output.stderr);
870 if stderr.contains("is not a working tree") {
876 tracing::debug!(
877 task_id,
878 path = %wt.display(),
879 "path is not a registered worktree (stale metadata) — removing directory and pruning"
880 );
881 if let Err(e) = tokio::fs::remove_dir_all(wt).await {
882 if match tokio::fs::try_exists(wt).await {
884 Ok(true) => true,
885 Ok(false) => false,
886 Err(e) => {
887 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
888 false
889 }
890 } {
891 tracing::warn!(
892 task_id,
893 path = %wt.display(),
894 err = %e,
895 "failed to remove stale worktree directory"
896 );
897 }
898 } else {
899 tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
900 }
901 if let Err(e) = Command::new("git")
904 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
905 .output_with_context()
906 .await
907 {
908 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
909 }
910 } else {
911 tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
912 }
913 }
914 Err(e) => {
915 tracing::warn!(task_id, err = %e, "failed to remove worktree");
916 }
917 }
918
919 if let Err(e) = Command::new("git")
921 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
922 .output_with_context()
923 .await
924 {
925 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
926 }
927
928 if let Some(br) = branch {
932 if keep_remote_branch {
933 delete_local_branch(task_id, br, repo_root).await;
934 } else {
935 delete_branches(task_id, br, repo_root).await;
936 }
937 }
938
939 match tokio::fs::try_exists(wt).await {
941 Ok(false) => true, Ok(true) => false, Err(e) => {
944 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
945 false }
947 }
948}
949
950async fn commits_outside(repo_root: &std::path::Path, rev: &str, others: &[&str]) -> Option<u64> {
952 let mut args = vec![
953 "rev-list".to_string(),
954 "--count".to_string(),
955 rev.to_string(),
956 ];
957 for other in others {
958 let exists = Command::new("git")
959 .args([
960 "-C",
961 &repo_root.to_string_lossy(),
962 "rev-parse",
963 "--verify",
964 "--quiet",
965 other,
966 ])
967 .output_with_context()
968 .await
969 .is_ok_and(|o| o.status.success());
970 if exists {
971 args.push("--not".to_string());
972 args.push(other.to_string());
973 }
974 }
975 let output = Command::new("git")
976 .arg("-C")
977 .arg(repo_root)
978 .args(&args)
979 .output_with_context()
980 .await
981 .ok()?;
982 if !output.status.success() {
983 return None;
984 }
985 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
986}
987
988async fn delete_local_branch(task_id: &str, br: &str, repo_root: &std::path::Path) {
990 let default_branch = crate::engine::runner::worktree::detect_default_branch(repo_root).await;
991 let remote_ref = format!("refs/remotes/origin/{br}");
992 let default_ref = format!("refs/remotes/origin/{default_branch}");
993 let local_ref = format!("refs/heads/{br}");
994 let local_default_ref = format!("refs/heads/{default_branch}");
995 match commits_outside(
996 repo_root,
997 &local_ref,
998 &[&remote_ref, &default_ref, &local_default_ref],
999 )
1000 .await
1001 {
1002 Some(0) => {}
1003 Some(n) => {
1004 tracing::warn!(task_id, branch = %br, unpushed = n, "local branch holds commits origin does not have, keeping it");
1005 return;
1006 }
1007 None => {
1008 tracing::debug!(task_id, branch = %br, "local branch missing or unreadable, nothing to delete");
1009 return;
1010 }
1011 }
1012 let output = Command::new("git")
1013 .args(["-C", &repo_root.to_string_lossy(), "branch", "-D", br])
1014 .output_with_context()
1015 .await;
1016 match output {
1017 Ok(o) if o.status.success() => {
1018 tracing::info!(task_id, branch = %br, "local branch deleted");
1019 }
1020 Ok(o) => {
1021 let stderr = String::from_utf8_lossy(&o.stderr);
1022 tracing::debug!(task_id, err = %stderr, "local branch delete skipped (may not exist)");
1023 }
1024 Err(e) => {
1025 tracing::warn!(task_id, err = %e, "failed to delete local branch");
1026 }
1027 }
1028}
1029
1030pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &std::path::Path) {
1038 let default_branch = crate::engine::runner::worktree::detect_default_branch(repo_root).await;
1039 let auth_env = crate::engine::runner::git_ops::build_git_auth_env();
1040 let _ = Command::new("git")
1041 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
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 = Command::new("git")
1070 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
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 if let Ok(content) = tokio::fs::read_to_string(&git_file).await {
1167 for line in content.lines() {
1168 if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1169 let gitdir_path = std::path::PathBuf::from(gitdir);
1173 if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1174 return Ok(repo_root.display().to_string());
1175 }
1176 }
1177 }
1178 }
1179 }
1180
1181 let worktrees_dir = crate::home::worktrees_dir()?;
1184 if let Ok(relative) = wt.strip_prefix(&worktrees_dir) {
1185 if let Some(project_name) = relative.components().next() {
1186 let project_name = project_name.as_os_str().to_string_lossy();
1187
1188 if let Ok(paths) = crate::config::get_project_paths() {
1189 for path_str in &paths {
1190 let path = std::path::Path::new(path_str);
1191 if let Some(name) = path.file_name() {
1192 if name.to_string_lossy() == project_name {
1193 return Ok(path_str.clone());
1194 }
1195 }
1196 }
1197 }
1198
1199 let parts: Vec<&str> = project_name.split('/').collect();
1201 let bare = if parts.len() == 2 {
1202 crate::home::projects_dir()
1203 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1204 .unwrap_or_default()
1205 } else {
1206 return Err(anyhow::anyhow!(
1207 "cannot determine owner for single-segment project name '{}' — \
1208 bare repo location is ambiguous; register the project with 'owner/repo' format",
1209 project_name
1210 ));
1211 };
1212 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1213 return Ok(bare.display().to_string());
1214 }
1215 }
1216 }
1217
1218 anyhow::bail!(
1219 "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1220 wt.display()
1221 )
1222}
1223
1224pub async fn resolve_repo_root_from_worktree(wt: &std::path::Path) -> anyhow::Result<String> {
1231 let git_file = wt.join(".git");
1233 if !tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1234 anyhow::bail!(".git file not found in worktree at {}", wt.display());
1235 }
1236
1237 let content = tokio::fs::read_to_string(&git_file)
1238 .await
1239 .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1240
1241 for line in content.lines() {
1243 if let Some(gitdir) = line.strip_prefix("gitdir:") {
1244 let gitdir = gitdir.trim();
1245 let gitdir_path = std::path::Path::new(gitdir);
1249 if let Some(repo_root) = gitdir_path
1250 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
1253 {
1255 return Ok(repo_root.display().to_string());
1256 }
1257 }
1258 }
1259
1260 anyhow::bail!(
1261 "cannot resolve repo root from worktree .git file at {}",
1262 wt.display()
1263 )
1264}
1265
1266pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1271 let paths = crate::config::get_project_paths().unwrap_or_default();
1273 for path_str in &paths {
1274 let path = std::path::Path::new(path_str);
1275 let orch_yml = path.join(".orch.yml");
1276 let legacy = path.join(".orchestrator.yml");
1277 let config_file = if tokio::fs::try_exists(&orch_yml).await.unwrap_or(false) {
1278 orch_yml
1279 } else if tokio::fs::try_exists(&legacy).await.unwrap_or(false) {
1280 legacy
1281 } else {
1282 continue;
1283 };
1284 if let Ok(content) = tokio::fs::read_to_string(&config_file).await {
1285 if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
1286 if let Some(r) = doc
1287 .get("gh")
1288 .and_then(|gh| gh.get("repo"))
1289 .and_then(|r| r.as_str())
1290 {
1291 if r == repo {
1292 return Ok(path_str.clone());
1293 }
1294 }
1295 }
1296 }
1297 }
1298
1299 let parts: Vec<&str> = repo.split('/').collect();
1301 let bare = if parts.len() == 2 {
1302 crate::home::projects_dir()
1303 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1304 .unwrap_or_default()
1305 } else {
1306 std::path::PathBuf::new()
1307 };
1308 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1309 return Ok(bare.display().to_string());
1310 }
1311
1312 anyhow::bail!(
1313 "cannot find local path for project {repo} — \
1314 checked {} registered project(s) and bare clone at {}",
1315 paths.len(),
1316 bare.display()
1317 )
1318}
1319
1320fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<std::path::PathBuf> {
1325 if let Ok(dir) = std::env::var("PROJECT_DIR") {
1327 if !dir.is_empty() {
1328 return Ok(std::path::PathBuf::from(dir));
1329 }
1330 }
1331
1332 if let Ok(paths) = crate::config::get_project_paths() {
1334 for path_str in &paths {
1335 let path = std::path::Path::new(path_str);
1336 let orch_yml = path.join(".orch.yml");
1337 let legacy = path.join(".orchestrator.yml");
1338 let config_file = if std::path::Path::new(&orch_yml).exists() {
1339 orch_yml
1340 } else if std::path::Path::new(&legacy).exists() {
1341 legacy
1342 } else {
1343 continue;
1344 };
1345 if let Ok(content) = std::fs::read_to_string(&config_file) {
1346 if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
1347 if let Some(r) = doc
1348 .get("gh")
1349 .and_then(|gh| gh.get("repo"))
1350 .and_then(|r| r.as_str())
1351 {
1352 if r == repo {
1353 return Ok(path_str.clone().into());
1354 }
1355 }
1356 }
1357 }
1358 }
1359 }
1360
1361 anyhow::bail!("project dir not found for repo: {repo}")
1362}
1363
1364pub(crate) async fn check_merged_prs(
1369 backend: &Arc<dyn ExternalBackend>,
1370 repo: &str,
1371 store: &Arc<TaskStore>,
1372 task_manager: &Arc<TaskManager>,
1373 in_review_tasks: &[ReviewTaskSnapshot],
1374 needs_review_tasks: &[ReviewTaskSnapshot],
1375) -> anyhow::Result<()> {
1376 let mut branch_map: std::collections::HashMap<String, String> =
1377 std::collections::HashMap::new();
1378 let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1379 .iter()
1380 .chain(needs_review_tasks.iter())
1381 .collect();
1382
1383 for task in &all_review_tasks {
1384 if !task.stored.branch.is_empty() {
1385 branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1386 }
1387 }
1388
1389 tracing::debug!(
1390 count = all_review_tasks.len(),
1391 "checking review tasks for merged PRs"
1392 );
1393
1394 let mut task_branches: Vec<(String, String)> = Vec::new();
1398 for task in all_review_tasks {
1399 let task_id = &task.external.id.0;
1400 let branch = if let Some(b) = branch_map.get(task_id) {
1401 b.clone()
1402 } else {
1403 match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1404 .await
1405 .map(|t| t.branch)
1406 {
1407 Some(b) if !b.is_empty() => b,
1408 _ => {
1409 tracing::debug!(task_id, "no branch info, skipping PR check");
1410 continue;
1411 }
1412 }
1413 };
1414 task_branches.push((task_id.clone(), branch));
1415 }
1416
1417 if task_branches.is_empty() {
1418 return Ok(());
1419 }
1420
1421 let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1423 let merged_map = match backend.batch_is_pr_merged(&branches).await {
1424 Ok(map) => map,
1425 Err(e) => {
1426 tracing::warn!(
1427 err = %e,
1428 branch_count = branches.len(),
1429 "batch PR merge check failed, retrying affected branches individually"
1430 );
1431 fallback_is_pr_merged_by_branch(backend, &branches).await
1432 }
1433 };
1434
1435 for (task_id, branch) in task_branches {
1436 let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1437 if !is_merged {
1438 continue;
1439 }
1440
1441 tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1442
1443 let id = ExternalId(task_id.clone());
1444 if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1445 tracing::warn!(task_id, err = %e, "failed to update status to done");
1446 continue;
1447 }
1448
1449 let comment = format!(
1450 "PR merged, marking task complete{}",
1451 crate::engine::orch_footer()
1452 );
1453 if let Err(e) = backend.post_comment(&id, &comment).await {
1454 tracing::warn!(task_id, err = %e, "failed to post comment");
1455 }
1456 }
1457
1458 Ok(())
1459}
1460
1461async fn fallback_is_pr_merged_by_branch(
1462 backend: &Arc<dyn ExternalBackend>,
1463 branches: &[String],
1464) -> HashMap<String, bool> {
1465 let futs: Vec<_> = branches
1466 .iter()
1467 .map(|branch| {
1468 let backend = Arc::clone(backend);
1469 let branch = branch.clone();
1470 async move {
1471 match backend.is_pr_merged(&branch).await {
1472 Ok(is_merged) => Some((branch, is_merged)),
1473 Err(err) => {
1474 tracing::warn!(
1475 branch,
1476 err = %err,
1477 "fallback PR merge check failed for branch"
1478 );
1479 None
1480 }
1481 }
1482 }
1483 })
1484 .collect();
1485
1486 futures::future::join_all(futs)
1487 .await
1488 .into_iter()
1489 .flatten()
1490 .collect()
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use super::*;
1496 use crate::store::{NewTask, TaskStore};
1497 use async_trait::async_trait;
1498 use std::collections::HashMap;
1499 use std::sync::atomic::{AtomicUsize, Ordering};
1500 use std::sync::Mutex;
1501
1502 #[tokio::test]
1505 async fn worktree_age_hours_returns_none_for_missing_dir() {
1506 let tmp = tempfile::tempdir().unwrap();
1507 let missing = tmp.path().join("does-not-exist");
1508 assert!(worktree_age_hours(&missing).await.is_none());
1509 }
1510
1511 #[tokio::test]
1512 async fn worktree_age_hours_returns_some_for_existing_dir() {
1513 let tmp = tempfile::tempdir().unwrap();
1514 let age = worktree_age_hours(tmp.path()).await;
1516 assert!(age.is_some());
1517 assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1518 }
1519
1520 #[test]
1523 fn janitor_options_default_values() {
1524 let opts = JanitorOptions::default();
1525 assert_eq!(opts.ttl_hours, 24);
1526 assert!(!opts.dry_run);
1527 }
1528
1529 fn setup_test_repo() -> Option<(tempfile::TempDir, std::path::PathBuf)> {
1533 let tmp = tempfile::tempdir().ok()?;
1534
1535 let repo_dir = tmp.path().join("repo.git");
1537 std::fs::create_dir_all(&repo_dir).ok()?;
1538
1539 let init = std::process::Command::new("git")
1540 .args([
1541 "init",
1542 "--initial-branch=main",
1543 repo_dir
1544 .to_str()
1545 .expect("test repo path contains non-UTF-8 characters"),
1546 ])
1547 .output()
1548 .ok()?;
1549 if !init.status.success() {
1550 let _ = std::process::Command::new("git")
1552 .args([
1553 "init",
1554 repo_dir
1555 .to_str()
1556 .expect("test repo path contains non-UTF-8 characters"),
1557 ])
1558 .output()
1559 .ok()?;
1560 }
1561
1562 let readme = repo_dir.join("README.md");
1564 std::fs::write(&readme, "# test").ok()?;
1565 std::process::Command::new("git")
1566 .args(["-C", repo_dir.to_str()?, "add", "."])
1567 .output()
1568 .ok()?;
1569 std::process::Command::new("git")
1570 .args([
1571 "-C",
1572 repo_dir.to_str()?,
1573 "-c",
1574 "user.email=test@test.com",
1575 "-c",
1576 "user.name=Test",
1577 "-c",
1578 "commit.gpgsign=false",
1579 "commit",
1580 "--allow-empty",
1581 "-m",
1582 "init",
1583 ])
1584 .output()
1585 .ok()?;
1586
1587 let wt_dir = tmp
1589 .path()
1590 .join("worktrees")
1591 .join("repo.git")
1592 .join("gh-issue-42-test");
1593 std::fs::create_dir_all(wt_dir.parent()?).ok()?;
1594 let wt_out = std::process::Command::new("git")
1595 .args([
1596 "-C",
1597 repo_dir.to_str()?,
1598 "worktree",
1599 "add",
1600 "-b",
1601 "gh-issue-42-test",
1602 wt_dir.to_str()?,
1603 ])
1604 .output()
1605 .ok()?;
1606
1607 if !wt_out.status.success() {
1608 return None;
1609 }
1610
1611 Some((tmp, wt_dir))
1612 }
1613
1614 #[tokio::test]
1615 async fn janitor_skips_young_worktree() {
1616 let Some((tmp, wt_dir)) = setup_test_repo() else {
1617 eprintln!("skipping test: git not available");
1618 return;
1619 };
1620
1621 assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1623
1624 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1626 assert!(
1627 age < 24,
1628 "freshly created worktree should be younger than 24h (age={age}h)"
1629 );
1630
1631 let opts = JanitorOptions {
1633 ttl_hours: 24,
1634 dry_run: true,
1635 };
1636 assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1638
1639 drop(tmp); }
1641
1642 #[tokio::test]
1643 async fn janitor_eligible_when_ttl_zero() {
1644 let Some((tmp, wt_dir)) = setup_test_repo() else {
1645 eprintln!("skipping test: git not available");
1646 return;
1647 };
1648
1649 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1651 let opts = JanitorOptions {
1652 ttl_hours: 0,
1653 dry_run: true,
1654 };
1655 assert!(
1656 age >= opts.ttl_hours,
1657 "worktree should be eligible when ttl=0 (age={age}h)"
1658 );
1659
1660 drop(tmp);
1661 }
1662
1663 #[tokio::test]
1664 async fn dry_run_does_not_remove_worktree() {
1665 let Some((tmp, wt_dir)) = setup_test_repo() else {
1667 eprintln!("skipping test: git not available");
1668 return;
1669 };
1670
1671 assert!(wt_dir.exists(), "worktree must exist before test");
1672
1673 let opts = JanitorOptions {
1676 ttl_hours: 0,
1677 dry_run: true,
1678 };
1679
1680 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1685 let in_use = false; let skip = age < opts.ttl_hours || in_use;
1687
1688 if !skip && opts.dry_run {
1689 tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1691 } else if !skip {
1692 }
1694
1695 assert!(
1697 wt_dir.exists(),
1698 "dry-run must not remove the worktree directory"
1699 );
1700
1701 drop(tmp);
1702 }
1703
1704 #[test]
1705 fn fallback_branch_path_construction() {
1706 let tmp = tempfile::tempdir().unwrap();
1709 let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1710 std::fs::create_dir_all(&wt_dir).unwrap();
1711
1712 let branch = "gh-issue-7-fix";
1714 let repo = "owner/myrepo";
1715 let worktrees_base = tmp.path();
1716
1717 let project = repo
1718 .rsplit('/')
1719 .next()
1720 .unwrap_or(repo)
1721 .trim_end_matches(".git");
1722 let candidate = worktrees_base.join(project).join(branch);
1723
1724 assert_eq!(candidate, wt_dir);
1725 assert!(
1726 candidate.exists(),
1727 "fallback path should point to existing dir"
1728 );
1729 }
1730
1731 #[tokio::test]
1734 async fn cleanup_returns_false_when_nothing_to_clean() {
1735 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1736
1737 let id = store
1739 .create(&NewTask {
1740 external_id: Some("999".to_string()),
1741 repo: "owner/repo".to_string(),
1742 origin: "github".to_string(),
1743 title: "Already cleaned".to_string(),
1744 ..Default::default()
1745 })
1746 .await
1747 .unwrap();
1748 store
1749 .update_status(id, crate::store::TaskStatus::Done)
1750 .await
1751 .unwrap();
1752
1753 let opts = JanitorOptions {
1754 ttl_hours: 0,
1755 dry_run: false,
1756 };
1757
1758 let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
1759 match result {
1763 Ok(cleaned) => assert!(
1764 !cleaned,
1765 "cleanup should return false when there is no worktree or branch to clean"
1766 ),
1767 Err(_) => {
1768 }
1771 }
1772 }
1773
1774 #[tokio::test]
1778 async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
1779 let tmp = tempfile::tempdir().unwrap();
1780 let stale_wt = tmp.path().join("stale-worktree");
1782 assert!(!stale_wt.exists(), "stale path must not exist");
1783
1784 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1785
1786 let id = store
1787 .create(&NewTask {
1788 external_id: Some("1005".to_string()),
1789 repo: "owner/repo".to_string(),
1790 origin: "github".to_string(),
1791 title: "Stale worktree task".to_string(),
1792 ..Default::default()
1793 })
1794 .await
1795 .unwrap();
1796 store
1797 .update_status(id, crate::store::TaskStatus::Done)
1798 .await
1799 .unwrap();
1800 store
1802 .set_fields(
1803 id,
1804 &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
1805 )
1806 .await
1807 .unwrap();
1808
1809 let before = store.get(id).await.unwrap();
1811 assert!(!before.worktree_cleaned, "task should not be cleaned yet");
1812 assert!(
1813 !before.worktree.is_empty(),
1814 "task must have a worktree path set"
1815 );
1816
1817 let opts = JanitorOptions {
1818 ttl_hours: 0,
1819 dry_run: false,
1820 };
1821
1822 let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
1823
1824 match result {
1827 Ok(_) => {}
1828 Err(e) => panic!("cleanup should not error for stale path: {e}"),
1829 }
1830
1831 let after = store.get(id).await.unwrap();
1833 assert!(
1834 after.worktree_cleaned,
1835 "task must be marked cleaned when stored worktree path is already gone"
1836 );
1837 }
1838
1839 #[tokio::test]
1842 async fn remove_worktree_returns_true_on_success() {
1843 let Some((tmp, wt_dir)) = setup_test_repo() else {
1844 eprintln!("skipping test: git not available");
1845 return;
1846 };
1847
1848 let repo_dir = tmp.path().join("repo.git");
1849 assert!(wt_dir.exists(), "worktree must exist before removal");
1850
1851 let removed =
1852 remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
1853 .await;
1854
1855 assert!(
1856 removed,
1857 "remove_worktree_and_branch should return true when directory is gone"
1858 );
1859 assert!(!wt_dir.exists(), "worktree directory should be removed");
1860 }
1861
1862 #[tokio::test]
1865 async fn remove_worktree_success_still_deletes_local_branch() {
1866 let Some((tmp, wt_dir)) = setup_test_repo() else {
1867 eprintln!("skipping test: git not available");
1868 return;
1869 };
1870
1871 let repo_dir = tmp.path().join("repo.git");
1872 let repo_dir_str = repo_dir
1873 .to_str()
1874 .expect("test repo path contains non-UTF-8 characters");
1875 let branch = "gh-issue-42-test";
1876
1877 let before = std::process::Command::new("git")
1878 .args([
1879 "-C",
1880 repo_dir_str,
1881 "show-ref",
1882 "--verify",
1883 "--quiet",
1884 &format!("refs/heads/{branch}"),
1885 ])
1886 .status()
1887 .unwrap();
1888 assert!(
1889 before.success(),
1890 "test setup must create local branch before cleanup"
1891 );
1892
1893 let removed =
1894 remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
1895 assert!(removed, "worktree should be removed");
1896
1897 let after = std::process::Command::new("git")
1898 .args([
1899 "-C",
1900 repo_dir_str,
1901 "show-ref",
1902 "--verify",
1903 "--quiet",
1904 &format!("refs/heads/{branch}"),
1905 ])
1906 .status()
1907 .unwrap();
1908 assert!(
1909 !after.success(),
1910 "local branch should be deleted after successful worktree cleanup"
1911 );
1912 }
1913
1914 #[tokio::test]
1918 async fn remove_worktree_returns_false_when_dir_persists() {
1919 let tmp = tempfile::tempdir().unwrap();
1920 let fake_wt = tmp.path().join("not-a-worktree");
1923 std::fs::create_dir_all(&fake_wt).unwrap();
1924
1925 let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
1928
1929 assert!(
1932 !removed,
1933 "remove_worktree_and_branch should return false when directory still exists"
1934 );
1935 assert!(fake_wt.exists(), "directory should still be on disk");
1936 }
1937
1938 struct MergeCheckMockBackend {
1939 batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
1940 single_results: HashMap<String, anyhow::Result<bool>>,
1941 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
1942 single_checked_branches: Arc<Mutex<Vec<String>>>,
1943 }
1944
1945 impl MergeCheckMockBackend {
1946 fn with_batch_error(
1947 error: anyhow::Error,
1948 single_results: HashMap<String, anyhow::Result<bool>>,
1949 ) -> Self {
1950 Self {
1951 batch_result: Mutex::new(Err(error)),
1952 single_results,
1953 posted_comments: Arc::new(Mutex::new(Vec::new())),
1954 single_checked_branches: Arc::new(Mutex::new(Vec::new())),
1955 }
1956 }
1957 }
1958
1959 #[async_trait]
1960 impl ExternalBackend for MergeCheckMockBackend {
1961 fn name(&self) -> &str {
1962 "mock"
1963 }
1964
1965 async fn create_task(
1966 &self,
1967 _title: &str,
1968 _body: &str,
1969 _labels: &[String],
1970 ) -> anyhow::Result<ExternalId> {
1971 anyhow::bail!("not implemented")
1972 }
1973
1974 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
1975 anyhow::bail!("not implemented")
1976 }
1977
1978 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1979 Ok(vec![])
1980 }
1981
1982 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1983 Ok(vec![])
1984 }
1985
1986 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
1987 self.posted_comments
1988 .lock()
1989 .unwrap()
1990 .push((id.0.clone(), body.to_string()));
1991 Ok(())
1992 }
1993
1994 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
1995 Ok(())
1996 }
1997
1998 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
1999 Ok(())
2000 }
2001
2002 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2003 Ok(vec![])
2004 }
2005
2006 async fn create_sub_task(
2007 &self,
2008 _parent: &ExternalId,
2009 _title: &str,
2010 _body: &str,
2011 _labels: &[String],
2012 ) -> anyhow::Result<ExternalId> {
2013 anyhow::bail!("not implemented")
2014 }
2015
2016 async fn health_check(&self) -> anyhow::Result<()> {
2017 Ok(())
2018 }
2019
2020 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2021 self.single_checked_branches
2022 .lock()
2023 .unwrap()
2024 .push(branch.to_string());
2025 match self.single_results.get(branch) {
2026 Some(Ok(is_merged)) => Ok(*is_merged),
2027 Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2028 None => Ok(false),
2029 }
2030 }
2031
2032 async fn batch_is_pr_merged(
2033 &self,
2034 _branches: &[String],
2035 ) -> anyhow::Result<HashMap<String, bool>> {
2036 match &*self.batch_result.lock().unwrap() {
2037 Ok(map) => Ok(map.clone()),
2038 Err(err) => Err(anyhow::anyhow!(err.to_string())),
2039 }
2040 }
2041 }
2042
2043 #[tokio::test]
2044 async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2045 let single_results = HashMap::from([
2046 ("branch-1".to_string(), Ok(true)),
2047 ("branch-2".to_string(), Ok(false)),
2048 ]);
2049 let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2050 anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2051 single_results,
2052 ));
2053 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
2054
2055 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2056 let task1 = store
2057 .create(&NewTask {
2058 external_id: Some("101".to_string()),
2059 repo: "owner/repo".to_string(),
2060 origin: "github".to_string(),
2061 title: "Task 101".to_string(),
2062 ..Default::default()
2063 })
2064 .await
2065 .unwrap();
2066 store
2067 .update_status(task1, crate::store::TaskStatus::NeedsReview)
2068 .await
2069 .unwrap();
2070 store
2071 .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2072 .await
2073 .unwrap();
2074
2075 let task2 = store
2076 .create(&NewTask {
2077 external_id: Some("102".to_string()),
2078 repo: "owner/repo".to_string(),
2079 origin: "github".to_string(),
2080 title: "Task 102".to_string(),
2081 ..Default::default()
2082 })
2083 .await
2084 .unwrap();
2085 store
2086 .update_status(task2, crate::store::TaskStatus::InReview)
2087 .await
2088 .unwrap();
2089 store
2090 .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2091 .await
2092 .unwrap();
2093
2094 let task_manager = Arc::new(TaskManager::with_store(
2095 backend_dyn.clone(),
2096 store.clone(),
2097 "owner/repo".to_string(),
2098 ));
2099
2100 let in_review_tasks = store
2101 .list_by_status("owner/repo", crate::store::TaskStatus::InReview)
2102 .await
2103 .unwrap()
2104 .into_iter()
2105 .map(|stored| ReviewTaskSnapshot {
2106 external: crate::engine::tasks::store_task_to_external(&stored),
2107 stored,
2108 })
2109 .collect::<Vec<_>>();
2110 let needs_review_tasks = store
2111 .list_by_status("owner/repo", crate::store::TaskStatus::NeedsReview)
2112 .await
2113 .unwrap()
2114 .into_iter()
2115 .map(|stored| ReviewTaskSnapshot {
2116 external: crate::engine::tasks::store_task_to_external(&stored),
2117 stored,
2118 })
2119 .collect::<Vec<_>>();
2120
2121 check_merged_prs(
2122 &backend_dyn,
2123 "owner/repo",
2124 &store,
2125 &task_manager,
2126 &in_review_tasks,
2127 &needs_review_tasks,
2128 )
2129 .await
2130 .unwrap();
2131
2132 let checked = backend.single_checked_branches.lock().unwrap().clone();
2133 assert_eq!(
2134 checked,
2135 vec!["branch-2".to_string(), "branch-1".to_string()]
2136 );
2137
2138 let updated_task1 = store.get(task1).await.unwrap();
2139 let updated_task2 = store.get(task2).await.unwrap();
2140 assert_eq!(updated_task1.status, crate::store::TaskStatus::Done);
2141 assert_eq!(updated_task2.status, crate::store::TaskStatus::InReview);
2142
2143 let comments = backend.posted_comments.lock().unwrap().clone();
2144 assert_eq!(comments.len(), 1);
2145 assert_eq!(comments[0].0, "101");
2146 assert!(comments[0].1.contains("PR merged, marking task complete"));
2147 }
2148
2149 struct ReconciliationMockBackend {
2150 reconciliation_calls: Arc<AtomicUsize>,
2151 }
2152
2153 #[async_trait]
2154 impl ExternalBackend for ReconciliationMockBackend {
2155 fn name(&self) -> &str {
2156 "mock"
2157 }
2158
2159 async fn create_task(
2160 &self,
2161 _title: &str,
2162 _body: &str,
2163 _labels: &[String],
2164 ) -> anyhow::Result<ExternalId> {
2165 anyhow::bail!("not implemented")
2166 }
2167
2168 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2169 anyhow::bail!("not implemented")
2170 }
2171
2172 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2173 Ok(vec![])
2174 }
2175
2176 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2177 self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2178 Ok(vec![])
2179 }
2180
2181 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2182 Ok(vec![])
2183 }
2184
2185 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2186 Ok(())
2187 }
2188
2189 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2190 Ok(())
2191 }
2192
2193 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2194 Ok(())
2195 }
2196
2197 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2198 Ok(vec![])
2199 }
2200
2201 async fn create_sub_task(
2202 &self,
2203 _parent: &ExternalId,
2204 _title: &str,
2205 _body: &str,
2206 _labels: &[String],
2207 ) -> anyhow::Result<ExternalId> {
2208 anyhow::bail!("not implemented")
2209 }
2210
2211 async fn health_check(&self) -> anyhow::Result<()> {
2212 Ok(())
2213 }
2214 }
2215
2216 #[tokio::test]
2217 async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2218 let repo = "owner/repo-cleanup-rate-limit";
2219 RECONCILIATION_LAST_RUN.remove(repo);
2220
2221 let backend = Arc::new(ReconciliationMockBackend {
2222 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2223 });
2224 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
2225 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2226 let task_manager = Arc::new(TaskManager::with_store(
2227 backend_dyn.clone(),
2228 store.clone(),
2229 repo.to_string(),
2230 ));
2231 let opts = JanitorOptions {
2232 ttl_hours: 0,
2233 dry_run: true,
2234 };
2235
2236 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2237 .await
2238 .unwrap();
2239 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2240 .await
2241 .unwrap();
2242
2243 assert_eq!(
2244 backend.reconciliation_calls.load(Ordering::SeqCst),
2245 1,
2246 "cleanup should not re-list reconciliation candidates on every sync pass"
2247 );
2248
2249 RECONCILIATION_LAST_RUN.remove(repo);
2250 }
2251
2252 #[tokio::test]
2255 async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2256 let repo = "owner/repo-cleanup-blocked";
2257 RECONCILIATION_LAST_RUN.remove(repo);
2258 let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2259 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2260 });
2261 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2262 let task_manager = Arc::new(TaskManager::with_store(
2263 backend_dyn.clone(),
2264 store.clone(),
2265 repo.to_string(),
2266 ));
2267
2268 let mut ids = Vec::new();
2269 for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2270 let id = store
2271 .create_internal(repo, title, "", "test", title, None)
2272 .await
2273 .unwrap();
2274 store
2275 .set_fields(
2276 id,
2277 &[
2278 (
2279 "worktree",
2280 serde_json::json!(format!("/nonexistent/{title}")),
2281 ),
2282 ("branch", serde_json::json!("")),
2283 ],
2284 )
2285 .await
2286 .unwrap();
2287 store.update_status(id, status).await.unwrap();
2288 ids.push(id);
2289 }
2290
2291 let opts = JanitorOptions {
2292 ttl_hours: 0,
2293 dry_run: false,
2294 };
2295 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2296 .await
2297 .unwrap();
2298
2299 assert!(
2300 !store.get(ids[0]).await.unwrap().worktree_cleaned,
2301 "blocked task must not be considered for cleanup"
2302 );
2303 assert!(
2304 store.get(ids[1]).await.unwrap().worktree_cleaned,
2305 "done task is cleaned, which proves the pass ran"
2306 );
2307 RECONCILIATION_LAST_RUN.remove(repo);
2308 }
2309
2310 fn git_ok(dir: &std::path::Path, args: &[&str]) -> String {
2311 let out = std::process::Command::new("git")
2312 .args([
2313 "-c",
2314 "commit.gpgsign=false",
2315 "-c",
2316 "user.name=T",
2317 "-c",
2318 "user.email=t@t",
2319 ])
2320 .args(args)
2321 .current_dir(dir)
2322 .output()
2323 .unwrap();
2324 assert!(
2325 out.status.success(),
2326 "git {args:?}: {}",
2327 String::from_utf8_lossy(&out.stderr)
2328 );
2329 String::from_utf8_lossy(&out.stdout).to_string()
2330 }
2331
2332 fn origin_and_clone_with_feat() -> (tempfile::TempDir, std::path::PathBuf) {
2334 let tmp = tempfile::tempdir().unwrap();
2335 let bare = tmp.path().join("origin.git");
2336 std::fs::create_dir_all(&bare).unwrap();
2337 git_ok(&bare, &["init", "--bare", "-b", "main"]);
2338 git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2339 let clone = tmp.path().join("clone");
2340 std::fs::write(clone.join("base.txt"), "base").unwrap();
2341 git_ok(&clone, &["add", "."]);
2342 git_ok(&clone, &["commit", "-m", "base"]);
2343 git_ok(&clone, &["push", "origin", "main"]);
2344 git_ok(&clone, &["checkout", "-b", "feat"]);
2345 std::fs::write(clone.join("work.txt"), "work").unwrap();
2346 git_ok(&clone, &["add", "."]);
2347 git_ok(&clone, &["commit", "-m", "work"]);
2348 git_ok(&clone, &["checkout", "main"]);
2349 (tmp, clone)
2350 }
2351
2352 fn remote_has(clone: &std::path::Path, branch: &str) -> bool {
2353 !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2354 .trim()
2355 .is_empty()
2356 }
2357
2358 fn local_has(clone: &std::path::Path, branch: &str) -> bool {
2359 std::process::Command::new("git")
2360 .args([
2361 "show-ref",
2362 "--verify",
2363 "--quiet",
2364 &format!("refs/heads/{branch}"),
2365 ])
2366 .current_dir(clone)
2367 .status()
2368 .unwrap()
2369 .success()
2370 }
2371
2372 #[tokio::test]
2375 async fn delete_branches_keeps_an_unmerged_remote_branch() {
2376 let (_tmp, clone) = origin_and_clone_with_feat();
2377 git_ok(&clone, &["push", "origin", "feat"]);
2378
2379 delete_branches("1", "feat", &clone).await;
2380
2381 assert!(
2382 remote_has(&clone, "feat"),
2383 "unmerged remote branch must survive"
2384 );
2385 assert!(
2386 !local_has(&clone, "feat"),
2387 "local copy is on origin, so it can go"
2388 );
2389 }
2390
2391 #[tokio::test]
2392 async fn delete_branches_removes_a_branch_main_already_contains() {
2393 let (_tmp, clone) = origin_and_clone_with_feat();
2394 git_ok(&clone, &["push", "origin", "feat"]);
2395 git_ok(&clone, &["merge", "--ff-only", "feat"]);
2396 git_ok(&clone, &["push", "origin", "main"]);
2397
2398 delete_branches("1", "feat", &clone).await;
2399
2400 assert!(!remote_has(&clone, "feat"));
2401 assert!(!local_has(&clone, "feat"));
2402 }
2403
2404 #[tokio::test]
2405 async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2406 let (_tmp, clone) = origin_and_clone_with_feat();
2407
2408 delete_branches("1", "feat", &clone).await;
2409
2410 assert!(
2411 local_has(&clone, "feat"),
2412 "unpushed commits must not be deleted"
2413 );
2414 }
2415}