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 if let Err(e) = crate::engine::auto_merge::sweep_ci_timer_keys(store, repo, &task_ids).await {
292 tracing::warn!(err = %e, "failed to sweep CI timer keys");
293 }
294
295 let already_cleaned = store.cleaned_external_ids(repo).await.unwrap_or_default();
300
301 let mut cleaned_any = false;
302 for task_id in &task_ids {
303 if already_cleaned.contains(task_id) {
305 continue;
306 }
307
308 match cleanup_task_worktree_with_opts(task_id, repo, store, opts).await {
309 Ok(true) => cleaned_any = true,
310 Ok(false) => {
311 }
314 Err(e) => {
315 tracing::warn!(task_id, err = %e, "worktree cleanup failed for task");
316 }
317 }
318 }
319
320 let worktrees_base = match crate::home::worktrees_dir() {
333 Ok(base) => Some(base),
334 Err(e) => {
335 tracing::debug!(err = %e, "worktrees_dir() unavailable — skipping orphan scan");
336 None
337 }
338 };
339
340 if let Some(base) = worktrees_base {
341 let project = repo
342 .rsplit('/')
343 .next()
344 .unwrap_or(repo)
345 .trim_end_matches(".git");
346 let repo_worktrees = base.join(project);
347
348 let mut combined: std::collections::HashSet<String> = std::collections::HashSet::new();
350 if let Ok(tasks) = store.list_all(repo).await {
351 for t in tasks {
352 if !t.worktree.is_empty() {
353 combined.insert(t.worktree);
354 }
355 }
356 }
357
358 if let Ok(project_dir) = resolve_project_dir_for_repo(repo) {
360 if let Ok(active_wts) =
361 crate::engine::runner::worktree::list_project_worktrees(&project_dir).await
362 {
363 for wt in active_wts {
364 combined.insert(wt.to_string_lossy().to_string());
365 }
366 }
367 }
368
369 if let Ok(mut entries) = tokio::fs::read_dir(&repo_worktrees).await {
371 while let Ok(Some(entry)) = entries.next_entry().await {
372 let entry_meta = match entry.metadata().await {
373 Ok(m) => m,
374 Err(_) => continue,
375 };
376 if !entry_meta.is_dir() {
377 continue;
378 }
379 let wt_path = entry.path().to_string_lossy().to_string();
380 if combined.contains(&wt_path) {
381 continue;
382 }
383 tracing::info!(worktree = %wt_path, "found orphaned worktree — attempting cleanup");
384 match resolve_repo_root_for_orphaned_worktree(&entry.path()).await {
385 Ok(repo_root) => {
386 let removed = remove_worktree_and_branch(
387 "orphan-scan",
388 &entry.path(),
389 None,
390 std::path::Path::new(&repo_root),
391 false,
392 )
393 .await;
394 if removed {
395 tracing::info!(worktree = %wt_path, "orphaned worktree removed");
396 } else {
397 tracing::warn!(worktree = %wt_path, "orphaned worktree could not be removed");
398 }
399 }
400 Err(e) => {
401 tracing::warn!(worktree = %wt_path, err = %e, "could not resolve repo root for orphaned worktree");
402 }
403 }
404 }
405 }
406 }
407
408 if cleaned_any && !opts.dry_run {
410 if let Ok(repo_root) = resolve_repo_root(repo).await {
411 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
412
413 let prune_result = timeout(
415 GIT_TIMEOUT,
416 Command::new("git")
417 .args(["-C", &repo_root, "worktree", "prune"])
418 .output_with_context(),
419 )
420 .await;
421 match prune_result {
422 Ok(Ok(output)) if output.status.success() => {
423 tracing::debug!(%repo, "pruned stale worktree metadata");
424 }
425 Ok(Ok(output)) => {
426 let stderr = String::from_utf8_lossy(&output.stderr);
427 tracing::warn!(%repo, err = %stderr, "git worktree prune failed");
428 }
429 Ok(Err(e)) => {
430 tracing::warn!(%repo, err = %e, "git worktree prune failed");
431 }
432 Err(_) => {
433 tracing::warn!(%repo, "git worktree prune timed out after 60s");
434 }
435 }
436
437 let fetch_result = timeout(
442 GIT_TIMEOUT,
443 crate::engine::runner::git_ops::git()
444 .await
445 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
446 .output_with_context(),
447 )
448 .await;
449 match fetch_result {
450 Ok(Ok(output)) if output.status.success() => {
451 tracing::info!(%repo, "fetched origin after worktree cleanup");
452 }
453 Ok(Ok(output)) => {
454 let stderr = String::from_utf8_lossy(&output.stderr);
455 tracing::debug!(%repo, err = %stderr, "git fetch skipped after cleanup");
456 }
457 Ok(Err(e)) => {
458 tracing::debug!(%repo, err = %e, "git fetch failed after cleanup");
459 }
460 Err(_) => {
461 tracing::debug!(%repo, "git fetch timed out after 60s");
462 }
463 }
464 }
465 }
466
467 Ok(())
468}
469
470pub(crate) async fn cleanup_task_worktree(
480 task_id: &str,
481 repo: &str,
482 store: &Arc<TaskStore>,
483) -> anyhow::Result<()> {
484 let opts = JanitorOptions {
485 ttl_hours: 0,
486 ..Default::default()
487 };
488 let cleaned = cleanup_task_worktree_with_opts(task_id, repo, store, &opts).await?;
489
490 if cleaned {
495 if let Ok(repo_root) = resolve_repo_root(repo).await {
496 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
497 let fetch_result = timeout(
498 GIT_TIMEOUT,
499 crate::engine::runner::git_ops::git()
500 .await
501 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
502 .output_with_context(),
503 )
504 .await;
505 match fetch_result {
506 Ok(Ok(output)) if output.status.success() => {
507 tracing::debug!(%repo, "fetched origin after task cleanup");
508 }
509 Ok(Ok(output)) => {
510 let stderr = String::from_utf8_lossy(&output.stderr);
511 tracing::debug!(%repo, err = %stderr, "git fetch skipped after task cleanup");
512 }
513 Ok(Err(e)) => {
514 tracing::debug!(%repo, err = %e, "git fetch failed after task cleanup");
515 }
516 Err(_) => {
517 tracing::debug!(%repo, "git fetch timed out after 60s");
518 }
519 }
520 }
521 }
522
523 Ok(())
524}
525
526pub(crate) async fn cleanup_task_worktree_with_opts(
533 task_id: &str,
534 repo: &str,
535 store: &Arc<TaskStore>,
536 opts: &JanitorOptions,
537) -> anyhow::Result<bool> {
538 let (worktree, branch, keep_remote_branch, merged) =
539 store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
540 .await
541 .map(|t| {
542 let keep = t.status == TaskStatus::Blocked && t.pr_number.is_some();
545 let merged = t.status == TaskStatus::Done;
546 (Some(t.worktree), Some(t.branch), keep, merged)
547 })
548 .unwrap_or((None, None, false, false));
549
550 let worktree_path = worktree.as_ref().map(std::path::PathBuf::from);
551
552 let worktrees_base = crate::home::worktrees_dir()
554 .unwrap_or_else(|_| std::path::PathBuf::from(".orch/worktrees"));
555
556 let worktree_to_remove = if let Some(ref wt) = worktree_path {
562 if match tokio::fs::try_exists(wt).await {
563 Ok(true) => true,
564 Ok(false) => false,
565 Err(e) => {
566 tracing::warn!(task_id, worktree = %wt.display(), err = %e, "try_exists failed on stored worktree path — trying branch-based fallback");
567 false
568 }
569 } {
570 Some(wt.clone())
571 } else {
572 if let Some(ref b) = branch {
574 let project = repo
575 .rsplit('/')
576 .next()
577 .unwrap_or(repo)
578 .trim_end_matches(".git");
579 let candidate = worktrees_base.join(project).join(b);
580 if match tokio::fs::try_exists(&candidate).await {
581 Ok(true) => true,
582 Ok(false) => false,
583 Err(e) => {
584 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
585 false
586 }
587 } {
588 Some(candidate)
589 } else {
590 None
591 }
592 } else {
593 None
594 }
595 }
596 } else if let Some(ref b) = branch {
597 let project = repo
599 .rsplit('/')
600 .next()
601 .unwrap_or(repo)
602 .trim_end_matches(".git");
603 let candidate = worktrees_base.join(project).join(b);
604 if match tokio::fs::try_exists(&candidate).await {
605 Ok(true) => true,
606 Ok(false) => false,
607 Err(e) => {
608 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
609 false
610 }
611 } {
612 Some(candidate)
613 } else {
614 None
615 }
616 } else {
617 None
618 };
619
620 let mut did_clean = false;
621
622 let branch_nonempty = branch.as_ref().filter(|b| !b.is_empty());
628 if worktree_to_remove.is_none() && branch_nonempty.is_none() {
629 if worktree_path
632 .as_ref()
633 .is_some_and(|p| !p.as_os_str().is_empty())
634 {
635 tracing::debug!(
636 task_id,
637 worktree = ?worktree_path,
638 "stored worktree path no longer exists on disk — marking cleaned"
639 );
640 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
641 if let Err(e) = store.mark_cleaned(store_id).await {
642 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned in store — will retry");
643 }
644 }
645 }
646 return Ok(false);
647 }
648
649 if let Some(wt) = worktree_to_remove {
650 if let Some(age_hours) = worktree_age_hours(&wt).await {
652 if age_hours < opts.ttl_hours {
653 tracing::debug!(
654 task_id,
655 worktree = %wt.display(),
656 age_hours,
657 ttl_hours = opts.ttl_hours,
658 "worktree too young for cleanup, skipping"
659 );
660 return Ok(false);
661 }
662 }
663
664 if is_worktree_in_active_session(&wt).await {
666 tracing::warn!(
667 task_id,
668 worktree = %wt.display(),
669 "worktree is referenced by an active tmux session — skipping cleanup"
670 );
671 return Ok(false);
672 }
673
674 let gitdir_valid = crate::engine::runner::worktree::validate_worktree_gitdir(&wt).await;
678
679 if opts.dry_run {
680 tracing::info!(
681 task_id,
682 worktree = %wt.display(),
683 branch = ?branch,
684 gitdir_valid,
685 "[dry-run] would remove worktree and branch"
686 );
687 } else if !gitdir_valid {
688 tracing::warn!(
689 task_id,
690 worktree = %wt.display(),
691 "worktree has broken git metadata — force-removing directory without git operations"
692 );
693 match tokio::fs::remove_dir_all(&wt).await {
694 Ok(()) => {
695 did_clean = true;
696 }
697 Err(e) => {
698 tracing::warn!(
699 task_id,
700 worktree = %wt.display(),
701 err = %e,
702 "failed to force-remove broken worktree directory"
703 );
704 }
705 }
706 } else {
707 tracing::info!(task_id, worktree = %wt.display(), "removing worktree");
708 let repo_root = match resolve_repo_root_from_worktree(&wt).await {
713 Ok(root) => root,
714 Err(_) => resolve_repo_root(repo).await?,
715 };
716 if merged {
717 crate::engine::runner::cow::warm_from_merged(
718 std::path::Path::new(&repo_root),
719 &crate::engine::runner::worktree::project_name(std::path::Path::new(
720 &repo_root,
721 )),
722 &wt,
723 )
724 .await;
725 }
726 let removed = remove_worktree_and_branch(
727 task_id,
728 &wt,
729 branch.as_deref(),
730 std::path::Path::new(&repo_root),
731 keep_remote_branch,
732 )
733 .await;
734 if removed {
735 did_clean = true;
736 } else {
737 tracing::warn!(
738 task_id,
739 worktree = %wt.display(),
740 "worktree directory still exists after removal attempt — not marking as cleaned"
741 );
742 }
743 }
744 } else if let Some(ref br) = branch {
745 if !opts.dry_run && !keep_remote_branch {
748 tracing::debug!(task_id, branch = %br, "no worktree on disk, cleaning up branch only");
749 let repo_root = resolve_repo_root(repo).await?;
751 delete_branches(task_id, br, std::path::Path::new(&repo_root)).await;
752 did_clean = true;
753 }
754 }
755
756 if did_clean {
757 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
761 if let Err(e) = store.mark_cleaned(store_id).await {
762 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned — will retry");
763 return Ok(did_clean);
765 }
766 }
767 store_log_activity(
768 &Some(Arc::clone(store)),
769 repo,
770 task_id,
771 "branch_delete",
772 None,
773 None,
774 None,
775 None,
776 Some(&serde_json::json!({
777 "worktree": worktree_path.map(|p| p.display().to_string()),
778 "branch": branch,
779 "keep_remote_branch": keep_remote_branch,
780 })),
781 )
782 .await;
783 }
784
785 Ok(did_clean)
786}
787
788pub(crate) async fn remove_worktree_and_branch(
795 task_id: &str,
796 wt: &std::path::Path,
797 branch: Option<&str>,
798 repo_root: &std::path::Path,
799 keep_remote_branch: bool,
800) -> bool {
801 let wt_str = wt.to_string_lossy().to_string();
802 let repo_root_str = repo_root.to_string_lossy();
803
804 let remove_result = Command::new("git")
807 .args([
808 "-C",
809 repo_root_str.as_ref(),
810 "worktree",
811 "remove",
812 &wt_str,
813 "--force",
814 ])
815 .output_with_context()
816 .await;
817
818 match remove_result {
819 Ok(output) if output.status.success() => {
820 tracing::info!(task_id, "worktree removed (git metadata)");
821 const MAX_RETRIES: usize = 3;
826 const RETRY_DELAY_MS: u64 = 2000;
827 let mut removed = false;
828 for attempt in 0..MAX_RETRIES {
829 match tokio::fs::remove_dir_all(wt).await {
830 Ok(()) => {
831 removed = true;
832 break;
833 }
834 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
835 removed = true;
837 break;
838 }
839 Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
840 if attempt < MAX_RETRIES - 1 {
841 tracing::debug!(
842 task_id,
843 path = %wt.display(),
844 attempt = attempt + 1,
845 "worktree directory not empty — retrying after delay"
846 );
847 sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
848 } else {
849 tracing::warn!(
850 task_id,
851 path = %wt.display(),
852 err = %e,
853 "failed to remove worktree directory after {} attempts (Directory not empty)",
854 MAX_RETRIES
855 );
856 }
857 }
858 Err(e) => {
859 tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
860 break;
861 }
862 }
863 }
864 if removed {
865 tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
866 } else {
867 tracing::warn!(
868 task_id,
869 path = %wt.display(),
870 "worktree directory still present after git worktree remove"
871 );
872 }
873 }
874 Ok(output) => {
875 let stderr = String::from_utf8_lossy(&output.stderr);
876 if stderr.contains("is not a working tree") {
882 tracing::debug!(
883 task_id,
884 path = %wt.display(),
885 "path is not a registered worktree (stale metadata) — removing directory and pruning"
886 );
887 if let Err(e) = tokio::fs::remove_dir_all(wt).await {
888 if match tokio::fs::try_exists(wt).await {
890 Ok(true) => true,
891 Ok(false) => false,
892 Err(e) => {
893 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
894 false
895 }
896 } {
897 tracing::warn!(
898 task_id,
899 path = %wt.display(),
900 err = %e,
901 "failed to remove stale worktree directory"
902 );
903 }
904 } else {
905 tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
906 }
907 if let Err(e) = Command::new("git")
910 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
911 .output_with_context()
912 .await
913 {
914 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
915 }
916 } else {
917 tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
918 }
919 }
920 Err(e) => {
921 tracing::warn!(task_id, err = %e, "failed to remove worktree");
922 }
923 }
924
925 if let Err(e) = Command::new("git")
927 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
928 .output_with_context()
929 .await
930 {
931 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
932 }
933
934 if let Some(br) = branch {
938 if keep_remote_branch {
939 delete_local_branch(task_id, br, repo_root).await;
940 } else {
941 delete_branches(task_id, br, repo_root).await;
942 }
943 }
944
945 match tokio::fs::try_exists(wt).await {
947 Ok(false) => true, Ok(true) => false, Err(e) => {
950 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
951 false }
953 }
954}
955
956async fn commits_outside(repo_root: &std::path::Path, rev: &str, others: &[&str]) -> Option<u64> {
958 let mut args = vec![
959 "rev-list".to_string(),
960 "--count".to_string(),
961 rev.to_string(),
962 ];
963 for other in others {
964 let exists = Command::new("git")
965 .args([
966 "-C",
967 &repo_root.to_string_lossy(),
968 "rev-parse",
969 "--verify",
970 "--quiet",
971 other,
972 ])
973 .output_with_context()
974 .await
975 .is_ok_and(|o| o.status.success());
976 if exists {
977 args.push("--not".to_string());
978 args.push(other.to_string());
979 }
980 }
981 let output = Command::new("git")
982 .arg("-C")
983 .arg(repo_root)
984 .args(&args)
985 .output_with_context()
986 .await
987 .ok()?;
988 if !output.status.success() {
989 return None;
990 }
991 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
992}
993
994async fn delete_local_branch(task_id: &str, br: &str, repo_root: &std::path::Path) {
996 let default_branch = crate::engine::runner::worktree::detect_default_branch(repo_root).await;
997 let remote_ref = format!("refs/remotes/origin/{br}");
998 let default_ref = format!("refs/remotes/origin/{default_branch}");
999 let local_ref = format!("refs/heads/{br}");
1000 let local_default_ref = format!("refs/heads/{default_branch}");
1001 match commits_outside(
1002 repo_root,
1003 &local_ref,
1004 &[&remote_ref, &default_ref, &local_default_ref],
1005 )
1006 .await
1007 {
1008 Some(0) => {}
1009 Some(n) => {
1010 tracing::warn!(task_id, branch = %br, unpushed = n, "local branch holds commits origin does not have, keeping it");
1011 return;
1012 }
1013 None => {
1014 tracing::debug!(task_id, branch = %br, "local branch missing or unreadable, nothing to delete");
1015 return;
1016 }
1017 }
1018 let output = Command::new("git")
1019 .args(["-C", &repo_root.to_string_lossy(), "branch", "-D", br])
1020 .output_with_context()
1021 .await;
1022 match output {
1023 Ok(o) if o.status.success() => {
1024 tracing::info!(task_id, branch = %br, "local branch deleted");
1025 }
1026 Ok(o) => {
1027 let stderr = String::from_utf8_lossy(&o.stderr);
1028 tracing::debug!(task_id, err = %stderr, "local branch delete skipped (may not exist)");
1029 }
1030 Err(e) => {
1031 tracing::warn!(task_id, err = %e, "failed to delete local branch");
1032 }
1033 }
1034}
1035
1036pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &std::path::Path) {
1044 let default_branch = crate::engine::runner::worktree::detect_default_branch(repo_root).await;
1045 let _ = crate::engine::runner::git_ops::git()
1046 .await
1047 .args([
1048 "-C",
1049 &repo_root.to_string_lossy(),
1050 "fetch",
1051 "origin",
1052 &default_branch,
1053 br,
1054 ])
1055 .output_with_context()
1056 .await;
1057
1058 delete_local_branch(task_id, br, repo_root).await;
1059
1060 let remote_ref = format!("refs/remotes/origin/{br}");
1061 let default_ref = format!("refs/remotes/origin/{default_branch}");
1062 match commits_outside(repo_root, &remote_ref, &[&default_ref]).await {
1063 Some(0) => {}
1064 Some(n) => {
1065 tracing::info!(task_id, branch = %br, unmerged = n, "remote branch has commits the default branch lacks, keeping it");
1066 return;
1067 }
1068 None => {
1069 tracing::debug!(task_id, branch = %br, "remote branch missing or unreadable, nothing to delete");
1070 return;
1071 }
1072 }
1073
1074 let remote_delete = crate::engine::runner::git_ops::git()
1075 .await
1076 .args([
1077 "-C",
1078 &repo_root.to_string_lossy(),
1079 "-c",
1080 "lfs.locksverify=false",
1081 "push",
1082 "origin",
1083 "--delete",
1084 br,
1085 ])
1086 .output_with_context()
1087 .await;
1088
1089 match remote_delete {
1090 Ok(output) if output.status.success() => {
1091 tracing::info!(task_id, branch = %br, "remote branch deleted");
1092 }
1093 Ok(output) => {
1094 let stderr = String::from_utf8_lossy(&output.stderr);
1095 tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1096 }
1097 Err(e) => {
1098 tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1099 }
1100 }
1101}
1102
1103async fn is_worktree_in_active_session(worktree: &std::path::Path) -> bool {
1109 let output = Command::new("tmux")
1110 .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1111 .output()
1112 .await;
1113
1114 match output {
1115 Ok(o) if o.status.success() => {
1116 let worktree_str = worktree.to_string_lossy();
1117 String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1118 let pane_path = line.trim();
1119 pane_path == worktree_str.as_ref()
1121 || pane_path.starts_with(&format!("{}/", worktree_str))
1122 })
1123 }
1124 _ => false,
1126 }
1127}
1128
1129async fn worktree_age_hours(worktree: &std::path::Path) -> Option<u64> {
1133 let metadata = tokio::fs::metadata(worktree).await.ok()?;
1135 let modified = metadata.modified().ok()?;
1136 let age = std::time::SystemTime::now().duration_since(modified).ok()?;
1137 Some(age.as_secs() / 3600)
1138}
1139
1140pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &std::path::Path) -> bool {
1146 let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1147 Ok(root) => root,
1148 Err(e) => {
1149 tracing::warn!(
1150 worktree = %wt.display(),
1151 error = %e,
1152 "cannot resolve repo root for orphaned worktree"
1153 );
1154 return false;
1155 }
1156 };
1157 remove_worktree_and_branch(task_id, wt, None, std::path::Path::new(&repo_root), false).await
1158}
1159
1160pub(crate) async fn resolve_repo_root_for_orphaned_worktree(
1167 wt: &std::path::Path,
1168) -> anyhow::Result<String> {
1169 let git_file = wt.join(".git");
1170 if tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1171 if let Ok(content) = tokio::fs::read_to_string(&git_file).await {
1172 for line in content.lines() {
1173 if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1174 let gitdir_path = std::path::PathBuf::from(gitdir);
1178 if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1179 return Ok(repo_root.display().to_string());
1180 }
1181 }
1182 }
1183 }
1184 }
1185
1186 let worktrees_dir = crate::home::worktrees_dir()?;
1189 if let Ok(relative) = wt.strip_prefix(&worktrees_dir) {
1190 if let Some(project_name) = relative.components().next() {
1191 let project_name = project_name.as_os_str().to_string_lossy();
1192
1193 if let Ok(paths) = crate::config::get_project_paths() {
1194 for path_str in &paths {
1195 let path = std::path::Path::new(path_str);
1196 if let Some(name) = path.file_name() {
1197 if name.to_string_lossy() == project_name {
1198 return Ok(path_str.clone());
1199 }
1200 }
1201 }
1202 }
1203
1204 let parts: Vec<&str> = project_name.split('/').collect();
1206 let bare = if parts.len() == 2 {
1207 crate::home::projects_dir()
1208 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1209 .unwrap_or_default()
1210 } else {
1211 return Err(anyhow::anyhow!(
1212 "cannot determine owner for single-segment project name '{}' — \
1213 bare repo location is ambiguous; register the project with 'owner/repo' format",
1214 project_name
1215 ));
1216 };
1217 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1218 return Ok(bare.display().to_string());
1219 }
1220 }
1221 }
1222
1223 anyhow::bail!(
1224 "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1225 wt.display()
1226 )
1227}
1228
1229pub async fn resolve_repo_root_from_worktree(wt: &std::path::Path) -> anyhow::Result<String> {
1236 let git_file = wt.join(".git");
1238 if !tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1239 anyhow::bail!(".git file not found in worktree at {}", wt.display());
1240 }
1241
1242 let content = tokio::fs::read_to_string(&git_file)
1243 .await
1244 .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1245
1246 for line in content.lines() {
1248 if let Some(gitdir) = line.strip_prefix("gitdir:") {
1249 let gitdir = gitdir.trim();
1250 let gitdir_path = std::path::Path::new(gitdir);
1254 if let Some(repo_root) = gitdir_path
1255 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
1258 {
1260 return Ok(repo_root.display().to_string());
1261 }
1262 }
1263 }
1264
1265 anyhow::bail!(
1266 "cannot resolve repo root from worktree .git file at {}",
1267 wt.display()
1268 )
1269}
1270
1271pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1276 let paths = crate::config::get_project_paths().unwrap_or_default();
1278 for path_str in &paths {
1279 let path = std::path::Path::new(path_str);
1280 let orch_yml = path.join(".orch.yml");
1281 let legacy = path.join(".orchestrator.yml");
1282 let config_file = if tokio::fs::try_exists(&orch_yml).await.unwrap_or(false) {
1283 orch_yml
1284 } else if tokio::fs::try_exists(&legacy).await.unwrap_or(false) {
1285 legacy
1286 } else {
1287 continue;
1288 };
1289 if let Ok(content) = tokio::fs::read_to_string(&config_file).await {
1290 if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
1291 if let Some(r) = doc
1292 .get("gh")
1293 .and_then(|gh| gh.get("repo"))
1294 .and_then(|r| r.as_str())
1295 {
1296 if r == repo {
1297 return Ok(path_str.clone());
1298 }
1299 }
1300 }
1301 }
1302 }
1303
1304 let parts: Vec<&str> = repo.split('/').collect();
1306 let bare = if parts.len() == 2 {
1307 crate::home::projects_dir()
1308 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1309 .unwrap_or_default()
1310 } else {
1311 std::path::PathBuf::new()
1312 };
1313 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1314 return Ok(bare.display().to_string());
1315 }
1316
1317 anyhow::bail!(
1318 "cannot find local path for project {repo} — \
1319 checked {} registered project(s) and bare clone at {}",
1320 paths.len(),
1321 bare.display()
1322 )
1323}
1324
1325fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<std::path::PathBuf> {
1330 if let Ok(dir) = std::env::var("PROJECT_DIR") {
1332 if !dir.is_empty() {
1333 return Ok(std::path::PathBuf::from(dir));
1334 }
1335 }
1336
1337 if let Ok(paths) = crate::config::get_project_paths() {
1339 for path_str in &paths {
1340 let path = std::path::Path::new(path_str);
1341 let orch_yml = path.join(".orch.yml");
1342 let legacy = path.join(".orchestrator.yml");
1343 let config_file = if std::path::Path::new(&orch_yml).exists() {
1344 orch_yml
1345 } else if std::path::Path::new(&legacy).exists() {
1346 legacy
1347 } else {
1348 continue;
1349 };
1350 if let Ok(content) = std::fs::read_to_string(&config_file) {
1351 if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
1352 if let Some(r) = doc
1353 .get("gh")
1354 .and_then(|gh| gh.get("repo"))
1355 .and_then(|r| r.as_str())
1356 {
1357 if r == repo {
1358 return Ok(path_str.clone().into());
1359 }
1360 }
1361 }
1362 }
1363 }
1364 }
1365
1366 anyhow::bail!("project dir not found for repo: {repo}")
1367}
1368
1369pub(crate) async fn check_merged_prs(
1374 backend: &Arc<dyn ExternalBackend>,
1375 repo: &str,
1376 store: &Arc<TaskStore>,
1377 task_manager: &Arc<TaskManager>,
1378 in_review_tasks: &[ReviewTaskSnapshot],
1379 needs_review_tasks: &[ReviewTaskSnapshot],
1380) -> anyhow::Result<()> {
1381 let mut branch_map: std::collections::HashMap<String, String> =
1382 std::collections::HashMap::new();
1383 let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1384 .iter()
1385 .chain(needs_review_tasks.iter())
1386 .collect();
1387
1388 for task in &all_review_tasks {
1389 if !task.stored.branch.is_empty() {
1390 branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1391 }
1392 }
1393
1394 tracing::debug!(
1395 count = all_review_tasks.len(),
1396 "checking review tasks for merged PRs"
1397 );
1398
1399 let mut task_branches: Vec<(String, String)> = Vec::new();
1403 for task in all_review_tasks {
1404 let task_id = &task.external.id.0;
1405 let branch = if let Some(b) = branch_map.get(task_id) {
1406 b.clone()
1407 } else {
1408 match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1409 .await
1410 .map(|t| t.branch)
1411 {
1412 Some(b) if !b.is_empty() => b,
1413 _ => {
1414 tracing::debug!(task_id, "no branch info, skipping PR check");
1415 continue;
1416 }
1417 }
1418 };
1419 task_branches.push((task_id.clone(), branch));
1420 }
1421
1422 if task_branches.is_empty() {
1423 return Ok(());
1424 }
1425
1426 let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1428 let merged_map = match backend.batch_is_pr_merged(&branches).await {
1429 Ok(map) => map,
1430 Err(e) => {
1431 tracing::warn!(
1432 err = %e,
1433 branch_count = branches.len(),
1434 "batch PR merge check failed, retrying affected branches individually"
1435 );
1436 fallback_is_pr_merged_by_branch(backend, &branches).await
1437 }
1438 };
1439
1440 for (task_id, branch) in task_branches {
1441 let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1442 if !is_merged {
1443 continue;
1444 }
1445
1446 tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1447
1448 let id = ExternalId(task_id.clone());
1449 if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1450 tracing::warn!(task_id, err = %e, "failed to update status to done");
1451 continue;
1452 }
1453
1454 if let Err(e) = cleanup_task_worktree(&task_id, repo, store).await {
1455 tracing::warn!(task_id, err = %e, "worktree cleanup after merge failed");
1456 }
1457
1458 let comment = format!(
1459 "PR merged, marking task complete{}",
1460 crate::engine::orch_footer()
1461 );
1462 if let Err(e) = backend.post_comment(&id, &comment).await {
1463 tracing::warn!(task_id, err = %e, "failed to post comment");
1464 }
1465 }
1466
1467 Ok(())
1468}
1469
1470async fn fallback_is_pr_merged_by_branch(
1471 backend: &Arc<dyn ExternalBackend>,
1472 branches: &[String],
1473) -> HashMap<String, bool> {
1474 let futs: Vec<_> = branches
1475 .iter()
1476 .map(|branch| {
1477 let backend = Arc::clone(backend);
1478 let branch = branch.clone();
1479 async move {
1480 match backend.is_pr_merged(&branch).await {
1481 Ok(is_merged) => Some((branch, is_merged)),
1482 Err(err) => {
1483 tracing::warn!(
1484 branch,
1485 err = %err,
1486 "fallback PR merge check failed for branch"
1487 );
1488 None
1489 }
1490 }
1491 }
1492 })
1493 .collect();
1494
1495 futures::future::join_all(futs)
1496 .await
1497 .into_iter()
1498 .flatten()
1499 .collect()
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504 use super::*;
1505 use crate::store::{NewTask, TaskStore};
1506 use async_trait::async_trait;
1507 use std::collections::HashMap;
1508 use std::sync::atomic::{AtomicUsize, Ordering};
1509 use std::sync::Mutex;
1510
1511 #[tokio::test]
1514 async fn worktree_age_hours_returns_none_for_missing_dir() {
1515 let tmp = tempfile::tempdir().unwrap();
1516 let missing = tmp.path().join("does-not-exist");
1517 assert!(worktree_age_hours(&missing).await.is_none());
1518 }
1519
1520 #[tokio::test]
1521 async fn worktree_age_hours_returns_some_for_existing_dir() {
1522 let tmp = tempfile::tempdir().unwrap();
1523 let age = worktree_age_hours(tmp.path()).await;
1525 assert!(age.is_some());
1526 assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1527 }
1528
1529 #[test]
1532 fn janitor_options_default_values() {
1533 let opts = JanitorOptions::default();
1534 assert_eq!(opts.ttl_hours, 24);
1535 assert!(!opts.dry_run);
1536 }
1537
1538 fn setup_test_repo() -> Option<(tempfile::TempDir, std::path::PathBuf)> {
1542 let tmp = tempfile::tempdir().ok()?;
1543
1544 let repo_dir = tmp.path().join("repo.git");
1546 std::fs::create_dir_all(&repo_dir).ok()?;
1547
1548 let init = std::process::Command::new("git")
1549 .args([
1550 "init",
1551 "--initial-branch=main",
1552 repo_dir
1553 .to_str()
1554 .expect("test repo path contains non-UTF-8 characters"),
1555 ])
1556 .output()
1557 .ok()?;
1558 if !init.status.success() {
1559 let _ = std::process::Command::new("git")
1561 .args([
1562 "init",
1563 repo_dir
1564 .to_str()
1565 .expect("test repo path contains non-UTF-8 characters"),
1566 ])
1567 .output()
1568 .ok()?;
1569 }
1570
1571 let readme = repo_dir.join("README.md");
1573 std::fs::write(&readme, "# test").ok()?;
1574 std::process::Command::new("git")
1575 .args(["-C", repo_dir.to_str()?, "add", "."])
1576 .output()
1577 .ok()?;
1578 std::process::Command::new("git")
1579 .args([
1580 "-C",
1581 repo_dir.to_str()?,
1582 "-c",
1583 "user.email=test@test.com",
1584 "-c",
1585 "user.name=Test",
1586 "-c",
1587 "commit.gpgsign=false",
1588 "commit",
1589 "--allow-empty",
1590 "-m",
1591 "init",
1592 ])
1593 .output()
1594 .ok()?;
1595
1596 let wt_dir = tmp
1598 .path()
1599 .join("worktrees")
1600 .join("repo.git")
1601 .join("gh-issue-42-test");
1602 std::fs::create_dir_all(wt_dir.parent()?).ok()?;
1603 let wt_out = std::process::Command::new("git")
1604 .args([
1605 "-C",
1606 repo_dir.to_str()?,
1607 "worktree",
1608 "add",
1609 "-b",
1610 "gh-issue-42-test",
1611 wt_dir.to_str()?,
1612 ])
1613 .output()
1614 .ok()?;
1615
1616 if !wt_out.status.success() {
1617 return None;
1618 }
1619
1620 Some((tmp, wt_dir))
1621 }
1622
1623 #[tokio::test]
1624 async fn janitor_skips_young_worktree() {
1625 let Some((tmp, wt_dir)) = setup_test_repo() else {
1626 eprintln!("skipping test: git not available");
1627 return;
1628 };
1629
1630 assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1632
1633 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1635 assert!(
1636 age < 24,
1637 "freshly created worktree should be younger than 24h (age={age}h)"
1638 );
1639
1640 let opts = JanitorOptions {
1642 ttl_hours: 24,
1643 dry_run: true,
1644 };
1645 assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1647
1648 drop(tmp); }
1650
1651 #[tokio::test]
1652 async fn janitor_eligible_when_ttl_zero() {
1653 let Some((tmp, wt_dir)) = setup_test_repo() else {
1654 eprintln!("skipping test: git not available");
1655 return;
1656 };
1657
1658 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1660 let opts = JanitorOptions {
1661 ttl_hours: 0,
1662 dry_run: true,
1663 };
1664 assert!(
1665 age >= opts.ttl_hours,
1666 "worktree should be eligible when ttl=0 (age={age}h)"
1667 );
1668
1669 drop(tmp);
1670 }
1671
1672 #[tokio::test]
1673 async fn dry_run_does_not_remove_worktree() {
1674 let Some((tmp, wt_dir)) = setup_test_repo() else {
1676 eprintln!("skipping test: git not available");
1677 return;
1678 };
1679
1680 assert!(wt_dir.exists(), "worktree must exist before test");
1681
1682 let opts = JanitorOptions {
1685 ttl_hours: 0,
1686 dry_run: true,
1687 };
1688
1689 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1694 let in_use = false; let skip = age < opts.ttl_hours || in_use;
1696
1697 if !skip && opts.dry_run {
1698 tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1700 } else if !skip {
1701 }
1703
1704 assert!(
1706 wt_dir.exists(),
1707 "dry-run must not remove the worktree directory"
1708 );
1709
1710 drop(tmp);
1711 }
1712
1713 #[test]
1714 fn fallback_branch_path_construction() {
1715 let tmp = tempfile::tempdir().unwrap();
1718 let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1719 std::fs::create_dir_all(&wt_dir).unwrap();
1720
1721 let branch = "gh-issue-7-fix";
1723 let repo = "owner/myrepo";
1724 let worktrees_base = tmp.path();
1725
1726 let project = repo
1727 .rsplit('/')
1728 .next()
1729 .unwrap_or(repo)
1730 .trim_end_matches(".git");
1731 let candidate = worktrees_base.join(project).join(branch);
1732
1733 assert_eq!(candidate, wt_dir);
1734 assert!(
1735 candidate.exists(),
1736 "fallback path should point to existing dir"
1737 );
1738 }
1739
1740 #[tokio::test]
1743 async fn cleanup_returns_false_when_nothing_to_clean() {
1744 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1745
1746 let id = store
1748 .create(&NewTask {
1749 external_id: Some("999".to_string()),
1750 repo: "owner/repo".to_string(),
1751 origin: "github".to_string(),
1752 title: "Already cleaned".to_string(),
1753 ..Default::default()
1754 })
1755 .await
1756 .unwrap();
1757 store
1758 .update_status(id, crate::store::TaskStatus::Done)
1759 .await
1760 .unwrap();
1761
1762 let opts = JanitorOptions {
1763 ttl_hours: 0,
1764 dry_run: false,
1765 };
1766
1767 let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
1768 match result {
1772 Ok(cleaned) => assert!(
1773 !cleaned,
1774 "cleanup should return false when there is no worktree or branch to clean"
1775 ),
1776 Err(_) => {
1777 }
1780 }
1781 }
1782
1783 #[tokio::test]
1787 async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
1788 let tmp = tempfile::tempdir().unwrap();
1789 let stale_wt = tmp.path().join("stale-worktree");
1791 assert!(!stale_wt.exists(), "stale path must not exist");
1792
1793 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1794
1795 let id = store
1796 .create(&NewTask {
1797 external_id: Some("1005".to_string()),
1798 repo: "owner/repo".to_string(),
1799 origin: "github".to_string(),
1800 title: "Stale worktree task".to_string(),
1801 ..Default::default()
1802 })
1803 .await
1804 .unwrap();
1805 store
1806 .update_status(id, crate::store::TaskStatus::Done)
1807 .await
1808 .unwrap();
1809 store
1811 .set_fields(
1812 id,
1813 &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
1814 )
1815 .await
1816 .unwrap();
1817
1818 let before = store.get(id).await.unwrap();
1820 assert!(!before.worktree_cleaned, "task should not be cleaned yet");
1821 assert!(
1822 !before.worktree.is_empty(),
1823 "task must have a worktree path set"
1824 );
1825
1826 let opts = JanitorOptions {
1827 ttl_hours: 0,
1828 dry_run: false,
1829 };
1830
1831 let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
1832
1833 match result {
1836 Ok(_) => {}
1837 Err(e) => panic!("cleanup should not error for stale path: {e}"),
1838 }
1839
1840 let after = store.get(id).await.unwrap();
1842 assert!(
1843 after.worktree_cleaned,
1844 "task must be marked cleaned when stored worktree path is already gone"
1845 );
1846 }
1847
1848 #[tokio::test]
1851 async fn remove_worktree_returns_true_on_success() {
1852 let Some((tmp, wt_dir)) = setup_test_repo() else {
1853 eprintln!("skipping test: git not available");
1854 return;
1855 };
1856
1857 let repo_dir = tmp.path().join("repo.git");
1858 assert!(wt_dir.exists(), "worktree must exist before removal");
1859
1860 let removed =
1861 remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
1862 .await;
1863
1864 assert!(
1865 removed,
1866 "remove_worktree_and_branch should return true when directory is gone"
1867 );
1868 assert!(!wt_dir.exists(), "worktree directory should be removed");
1869 }
1870
1871 #[tokio::test]
1874 async fn remove_worktree_success_still_deletes_local_branch() {
1875 let Some((tmp, wt_dir)) = setup_test_repo() else {
1876 eprintln!("skipping test: git not available");
1877 return;
1878 };
1879
1880 let repo_dir = tmp.path().join("repo.git");
1881 let repo_dir_str = repo_dir
1882 .to_str()
1883 .expect("test repo path contains non-UTF-8 characters");
1884 let branch = "gh-issue-42-test";
1885
1886 let before = std::process::Command::new("git")
1887 .args([
1888 "-C",
1889 repo_dir_str,
1890 "show-ref",
1891 "--verify",
1892 "--quiet",
1893 &format!("refs/heads/{branch}"),
1894 ])
1895 .status()
1896 .unwrap();
1897 assert!(
1898 before.success(),
1899 "test setup must create local branch before cleanup"
1900 );
1901
1902 let removed =
1903 remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
1904 assert!(removed, "worktree should be removed");
1905
1906 let after = std::process::Command::new("git")
1907 .args([
1908 "-C",
1909 repo_dir_str,
1910 "show-ref",
1911 "--verify",
1912 "--quiet",
1913 &format!("refs/heads/{branch}"),
1914 ])
1915 .status()
1916 .unwrap();
1917 assert!(
1918 !after.success(),
1919 "local branch should be deleted after successful worktree cleanup"
1920 );
1921 }
1922
1923 #[tokio::test]
1927 async fn remove_worktree_returns_false_when_dir_persists() {
1928 let tmp = tempfile::tempdir().unwrap();
1929 let fake_wt = tmp.path().join("not-a-worktree");
1932 std::fs::create_dir_all(&fake_wt).unwrap();
1933
1934 let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
1937
1938 assert!(
1941 !removed,
1942 "remove_worktree_and_branch should return false when directory still exists"
1943 );
1944 assert!(fake_wt.exists(), "directory should still be on disk");
1945 }
1946
1947 struct MergeCheckMockBackend {
1948 batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
1949 single_results: HashMap<String, anyhow::Result<bool>>,
1950 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
1951 single_checked_branches: Arc<Mutex<Vec<String>>>,
1952 }
1953
1954 impl MergeCheckMockBackend {
1955 fn with_batch_error(
1956 error: anyhow::Error,
1957 single_results: HashMap<String, anyhow::Result<bool>>,
1958 ) -> Self {
1959 Self {
1960 batch_result: Mutex::new(Err(error)),
1961 single_results,
1962 posted_comments: Arc::new(Mutex::new(Vec::new())),
1963 single_checked_branches: Arc::new(Mutex::new(Vec::new())),
1964 }
1965 }
1966 }
1967
1968 #[async_trait]
1969 impl ExternalBackend for MergeCheckMockBackend {
1970 fn name(&self) -> &str {
1971 "mock"
1972 }
1973
1974 async fn create_task(
1975 &self,
1976 _title: &str,
1977 _body: &str,
1978 _labels: &[String],
1979 ) -> anyhow::Result<ExternalId> {
1980 anyhow::bail!("not implemented")
1981 }
1982
1983 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
1984 anyhow::bail!("not implemented")
1985 }
1986
1987 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1988 Ok(vec![])
1989 }
1990
1991 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1992 Ok(vec![])
1993 }
1994
1995 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
1996 self.posted_comments
1997 .lock()
1998 .unwrap()
1999 .push((id.0.clone(), body.to_string()));
2000 Ok(())
2001 }
2002
2003 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2004 Ok(())
2005 }
2006
2007 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2008 Ok(())
2009 }
2010
2011 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2012 Ok(vec![])
2013 }
2014
2015 async fn create_sub_task(
2016 &self,
2017 _parent: &ExternalId,
2018 _title: &str,
2019 _body: &str,
2020 _labels: &[String],
2021 ) -> anyhow::Result<ExternalId> {
2022 anyhow::bail!("not implemented")
2023 }
2024
2025 async fn health_check(&self) -> anyhow::Result<()> {
2026 Ok(())
2027 }
2028
2029 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2030 self.single_checked_branches
2031 .lock()
2032 .unwrap()
2033 .push(branch.to_string());
2034 match self.single_results.get(branch) {
2035 Some(Ok(is_merged)) => Ok(*is_merged),
2036 Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2037 None => Ok(false),
2038 }
2039 }
2040
2041 async fn batch_is_pr_merged(
2042 &self,
2043 _branches: &[String],
2044 ) -> anyhow::Result<HashMap<String, bool>> {
2045 match &*self.batch_result.lock().unwrap() {
2046 Ok(map) => Ok(map.clone()),
2047 Err(err) => Err(anyhow::anyhow!(err.to_string())),
2048 }
2049 }
2050 }
2051
2052 #[tokio::test]
2053 async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2054 let single_results = HashMap::from([
2055 ("branch-1".to_string(), Ok(true)),
2056 ("branch-2".to_string(), Ok(false)),
2057 ]);
2058 let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2059 anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2060 single_results,
2061 ));
2062 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
2063
2064 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2065 let task1 = store
2066 .create(&NewTask {
2067 external_id: Some("101".to_string()),
2068 repo: "owner/repo".to_string(),
2069 origin: "github".to_string(),
2070 title: "Task 101".to_string(),
2071 ..Default::default()
2072 })
2073 .await
2074 .unwrap();
2075 store
2076 .update_status(task1, crate::store::TaskStatus::NeedsReview)
2077 .await
2078 .unwrap();
2079 store
2080 .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2081 .await
2082 .unwrap();
2083 let wt_dir = tempfile::tempdir().unwrap();
2085 let wt_path = wt_dir.path().join("branch-1");
2086 std::fs::create_dir_all(&wt_path).unwrap();
2087 store
2088 .set_fields(
2089 task1,
2090 &[("worktree", serde_json::json!(wt_path.to_string_lossy()))],
2091 )
2092 .await
2093 .unwrap();
2094
2095 let task2 = store
2096 .create(&NewTask {
2097 external_id: Some("102".to_string()),
2098 repo: "owner/repo".to_string(),
2099 origin: "github".to_string(),
2100 title: "Task 102".to_string(),
2101 ..Default::default()
2102 })
2103 .await
2104 .unwrap();
2105 store
2106 .update_status(task2, crate::store::TaskStatus::InReview)
2107 .await
2108 .unwrap();
2109 store
2110 .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2111 .await
2112 .unwrap();
2113
2114 let task_manager = Arc::new(TaskManager::with_store(
2115 backend_dyn.clone(),
2116 store.clone(),
2117 "owner/repo".to_string(),
2118 ));
2119
2120 let in_review_tasks = store
2121 .list_by_status("owner/repo", crate::store::TaskStatus::InReview)
2122 .await
2123 .unwrap()
2124 .into_iter()
2125 .map(|stored| ReviewTaskSnapshot {
2126 external: crate::engine::tasks::store_task_to_external(&stored),
2127 stored,
2128 })
2129 .collect::<Vec<_>>();
2130 let needs_review_tasks = store
2131 .list_by_status("owner/repo", crate::store::TaskStatus::NeedsReview)
2132 .await
2133 .unwrap()
2134 .into_iter()
2135 .map(|stored| ReviewTaskSnapshot {
2136 external: crate::engine::tasks::store_task_to_external(&stored),
2137 stored,
2138 })
2139 .collect::<Vec<_>>();
2140
2141 check_merged_prs(
2142 &backend_dyn,
2143 "owner/repo",
2144 &store,
2145 &task_manager,
2146 &in_review_tasks,
2147 &needs_review_tasks,
2148 )
2149 .await
2150 .unwrap();
2151
2152 let checked = backend.single_checked_branches.lock().unwrap().clone();
2153 assert_eq!(
2154 checked,
2155 vec!["branch-2".to_string(), "branch-1".to_string()]
2156 );
2157
2158 let updated_task1 = store.get(task1).await.unwrap();
2159 let updated_task2 = store.get(task2).await.unwrap();
2160 assert_eq!(updated_task1.status, crate::store::TaskStatus::Done);
2161 assert!(updated_task1.worktree_cleaned);
2162 assert!(!wt_path.exists());
2163 assert!(!updated_task2.worktree_cleaned);
2164 assert_eq!(updated_task2.status, crate::store::TaskStatus::InReview);
2165
2166 let comments = backend.posted_comments.lock().unwrap().clone();
2167 assert_eq!(comments.len(), 1);
2168 assert_eq!(comments[0].0, "101");
2169 assert!(comments[0].1.contains("PR merged, marking task complete"));
2170 }
2171
2172 struct ReconciliationMockBackend {
2173 reconciliation_calls: Arc<AtomicUsize>,
2174 }
2175
2176 #[async_trait]
2177 impl ExternalBackend for ReconciliationMockBackend {
2178 fn name(&self) -> &str {
2179 "mock"
2180 }
2181
2182 async fn create_task(
2183 &self,
2184 _title: &str,
2185 _body: &str,
2186 _labels: &[String],
2187 ) -> anyhow::Result<ExternalId> {
2188 anyhow::bail!("not implemented")
2189 }
2190
2191 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2192 anyhow::bail!("not implemented")
2193 }
2194
2195 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2196 Ok(vec![])
2197 }
2198
2199 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2200 self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2201 Ok(vec![])
2202 }
2203
2204 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2205 Ok(vec![])
2206 }
2207
2208 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2209 Ok(())
2210 }
2211
2212 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2213 Ok(())
2214 }
2215
2216 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2217 Ok(())
2218 }
2219
2220 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2221 Ok(vec![])
2222 }
2223
2224 async fn create_sub_task(
2225 &self,
2226 _parent: &ExternalId,
2227 _title: &str,
2228 _body: &str,
2229 _labels: &[String],
2230 ) -> anyhow::Result<ExternalId> {
2231 anyhow::bail!("not implemented")
2232 }
2233
2234 async fn health_check(&self) -> anyhow::Result<()> {
2235 Ok(())
2236 }
2237 }
2238
2239 #[tokio::test]
2240 async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2241 let repo = "owner/repo-cleanup-rate-limit";
2242 RECONCILIATION_LAST_RUN.remove(repo);
2243
2244 let backend = Arc::new(ReconciliationMockBackend {
2245 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2246 });
2247 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
2248 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2249 let task_manager = Arc::new(TaskManager::with_store(
2250 backend_dyn.clone(),
2251 store.clone(),
2252 repo.to_string(),
2253 ));
2254 let opts = JanitorOptions {
2255 ttl_hours: 0,
2256 dry_run: true,
2257 };
2258
2259 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2260 .await
2261 .unwrap();
2262 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2263 .await
2264 .unwrap();
2265
2266 assert_eq!(
2267 backend.reconciliation_calls.load(Ordering::SeqCst),
2268 1,
2269 "cleanup should not re-list reconciliation candidates on every sync pass"
2270 );
2271
2272 RECONCILIATION_LAST_RUN.remove(repo);
2273 }
2274
2275 #[tokio::test]
2278 async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2279 let repo = "owner/repo-cleanup-blocked";
2280 RECONCILIATION_LAST_RUN.remove(repo);
2281 let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2282 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2283 });
2284 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2285 let task_manager = Arc::new(TaskManager::with_store(
2286 backend_dyn.clone(),
2287 store.clone(),
2288 repo.to_string(),
2289 ));
2290
2291 let mut ids = Vec::new();
2292 for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2293 let id = store
2294 .create_internal(repo, title, "", "test", title, None)
2295 .await
2296 .unwrap();
2297 store
2298 .set_fields(
2299 id,
2300 &[
2301 (
2302 "worktree",
2303 serde_json::json!(format!("/nonexistent/{title}")),
2304 ),
2305 ("branch", serde_json::json!("")),
2306 ],
2307 )
2308 .await
2309 .unwrap();
2310 store.update_status(id, status).await.unwrap();
2311 ids.push(id);
2312 }
2313
2314 let opts = JanitorOptions {
2315 ttl_hours: 0,
2316 dry_run: false,
2317 };
2318 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2319 .await
2320 .unwrap();
2321
2322 assert!(
2323 !store.get(ids[0]).await.unwrap().worktree_cleaned,
2324 "blocked task must not be considered for cleanup"
2325 );
2326 assert!(
2327 store.get(ids[1]).await.unwrap().worktree_cleaned,
2328 "done task is cleaned, which proves the pass ran"
2329 );
2330 RECONCILIATION_LAST_RUN.remove(repo);
2331 }
2332
2333 fn git_ok(dir: &std::path::Path, args: &[&str]) -> String {
2334 let out = std::process::Command::new("git")
2335 .args([
2336 "-c",
2337 "commit.gpgsign=false",
2338 "-c",
2339 "user.name=T",
2340 "-c",
2341 "user.email=t@t",
2342 ])
2343 .args(args)
2344 .current_dir(dir)
2345 .output()
2346 .unwrap();
2347 assert!(
2348 out.status.success(),
2349 "git {args:?}: {}",
2350 String::from_utf8_lossy(&out.stderr)
2351 );
2352 String::from_utf8_lossy(&out.stdout).to_string()
2353 }
2354
2355 fn origin_and_clone_with_feat() -> (tempfile::TempDir, std::path::PathBuf) {
2357 let tmp = tempfile::tempdir().unwrap();
2358 let bare = tmp.path().join("origin.git");
2359 std::fs::create_dir_all(&bare).unwrap();
2360 git_ok(&bare, &["init", "--bare", "-b", "main"]);
2361 git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2362 let clone = tmp.path().join("clone");
2363 std::fs::write(clone.join("base.txt"), "base").unwrap();
2364 git_ok(&clone, &["add", "."]);
2365 git_ok(&clone, &["commit", "-m", "base"]);
2366 git_ok(&clone, &["push", "origin", "main"]);
2367 git_ok(&clone, &["checkout", "-b", "feat"]);
2368 std::fs::write(clone.join("work.txt"), "work").unwrap();
2369 git_ok(&clone, &["add", "."]);
2370 git_ok(&clone, &["commit", "-m", "work"]);
2371 git_ok(&clone, &["checkout", "main"]);
2372 (tmp, clone)
2373 }
2374
2375 fn remote_has(clone: &std::path::Path, branch: &str) -> bool {
2376 !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2377 .trim()
2378 .is_empty()
2379 }
2380
2381 fn local_has(clone: &std::path::Path, branch: &str) -> bool {
2382 std::process::Command::new("git")
2383 .args([
2384 "show-ref",
2385 "--verify",
2386 "--quiet",
2387 &format!("refs/heads/{branch}"),
2388 ])
2389 .current_dir(clone)
2390 .status()
2391 .unwrap()
2392 .success()
2393 }
2394
2395 #[tokio::test]
2398 async fn delete_branches_keeps_an_unmerged_remote_branch() {
2399 let (_tmp, clone) = origin_and_clone_with_feat();
2400 git_ok(&clone, &["push", "origin", "feat"]);
2401
2402 delete_branches("1", "feat", &clone).await;
2403
2404 assert!(
2405 remote_has(&clone, "feat"),
2406 "unmerged remote branch must survive"
2407 );
2408 assert!(
2409 !local_has(&clone, "feat"),
2410 "local copy is on origin, so it can go"
2411 );
2412 }
2413
2414 #[tokio::test]
2415 async fn delete_branches_removes_a_branch_main_already_contains() {
2416 let (_tmp, clone) = origin_and_clone_with_feat();
2417 git_ok(&clone, &["push", "origin", "feat"]);
2418 git_ok(&clone, &["merge", "--ff-only", "feat"]);
2419 git_ok(&clone, &["push", "origin", "main"]);
2420
2421 delete_branches("1", "feat", &clone).await;
2422
2423 assert!(!remote_has(&clone, "feat"));
2424 assert!(!local_has(&clone, "feat"));
2425 }
2426
2427 #[tokio::test]
2428 async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2429 let (_tmp, clone) = origin_and_clone_with_feat();
2430
2431 delete_branches("1", "feat", &clone).await;
2432
2433 assert!(
2434 local_has(&clone, "feat"),
2435 "unpushed commits must not be deleted"
2436 );
2437 }
2438}