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 if let Ok(internal_blocked) = task_manager
288 .list_internal_by_status(TaskStatus::Blocked)
289 .await
290 {
291 task_ids.extend(internal_blocked.iter().map(|t| t.id.0.clone()));
292 }
293
294 tracing::debug!(
295 count = task_ids.len(),
296 "checking all terminal tasks for cleanup"
297 );
298
299 let already_cleaned = store.cleaned_external_ids(repo).await.unwrap_or_default();
304
305 let mut cleaned_any = false;
306 for task_id in &task_ids {
307 if already_cleaned.contains(task_id) {
309 continue;
310 }
311
312 match cleanup_task_worktree_with_opts(task_id, repo, store, opts).await {
313 Ok(true) => cleaned_any = true,
314 Ok(false) => {
315 }
318 Err(e) => {
319 tracing::warn!(task_id, err = %e, "worktree cleanup failed for task");
320 }
321 }
322 }
323
324 let worktrees_base = match crate::home::worktrees_dir() {
337 Ok(base) => Some(base),
338 Err(e) => {
339 tracing::debug!(err = %e, "worktrees_dir() unavailable — skipping orphan scan");
340 None
341 }
342 };
343
344 if let Some(base) = worktrees_base {
345 let project = repo
346 .rsplit('/')
347 .next()
348 .unwrap_or(repo)
349 .trim_end_matches(".git");
350 let repo_worktrees = base.join(project);
351
352 let mut combined: std::collections::HashSet<String> = std::collections::HashSet::new();
354 if let Ok(tasks) = store.list_all(repo).await {
355 for t in tasks {
356 if !t.worktree.is_empty() {
357 combined.insert(t.worktree);
358 }
359 }
360 }
361
362 if let Ok(project_dir) = resolve_project_dir_for_repo(repo) {
364 if let Ok(active_wts) =
365 crate::engine::runner::worktree::list_project_worktrees(&project_dir).await
366 {
367 for wt in active_wts {
368 combined.insert(wt.to_string_lossy().to_string());
369 }
370 }
371 }
372
373 if let Ok(mut entries) = tokio::fs::read_dir(&repo_worktrees).await {
375 while let Ok(Some(entry)) = entries.next_entry().await {
376 let entry_meta = match entry.metadata().await {
377 Ok(m) => m,
378 Err(_) => continue,
379 };
380 if !entry_meta.is_dir() {
381 continue;
382 }
383 let wt_path = entry.path().to_string_lossy().to_string();
384 if combined.contains(&wt_path) {
385 continue;
386 }
387 tracing::info!(worktree = %wt_path, "found orphaned worktree — attempting cleanup");
388 match resolve_repo_root_for_orphaned_worktree(&entry.path()).await {
389 Ok(repo_root) => {
390 let removed = remove_worktree_and_branch(
391 "orphan-scan",
392 &entry.path(),
393 None,
394 std::path::Path::new(&repo_root),
395 false,
396 )
397 .await;
398 if removed {
399 tracing::info!(worktree = %wt_path, "orphaned worktree removed");
400 } else {
401 tracing::warn!(worktree = %wt_path, "orphaned worktree could not be removed");
402 }
403 }
404 Err(e) => {
405 tracing::warn!(worktree = %wt_path, err = %e, "could not resolve repo root for orphaned worktree");
406 }
407 }
408 }
409 }
410 }
411
412 if cleaned_any && !opts.dry_run {
414 if let Ok(repo_root) = resolve_repo_root(repo).await {
415 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
416
417 let prune_result = timeout(
419 GIT_TIMEOUT,
420 Command::new("git")
421 .args(["-C", &repo_root, "worktree", "prune"])
422 .output_with_context(),
423 )
424 .await;
425 match prune_result {
426 Ok(Ok(output)) if output.status.success() => {
427 tracing::debug!(%repo, "pruned stale worktree metadata");
428 }
429 Ok(Ok(output)) => {
430 let stderr = String::from_utf8_lossy(&output.stderr);
431 tracing::warn!(%repo, err = %stderr, "git worktree prune failed");
432 }
433 Ok(Err(e)) => {
434 tracing::warn!(%repo, err = %e, "git worktree prune failed");
435 }
436 Err(_) => {
437 tracing::warn!(%repo, "git worktree prune timed out after 60s");
438 }
439 }
440
441 let fetch_result = timeout(
446 GIT_TIMEOUT,
447 Command::new("git")
448 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
449 .output_with_context(),
450 )
451 .await;
452 match fetch_result {
453 Ok(Ok(output)) if output.status.success() => {
454 tracing::info!(%repo, "fetched origin after worktree cleanup");
455 }
456 Ok(Ok(output)) => {
457 let stderr = String::from_utf8_lossy(&output.stderr);
458 tracing::debug!(%repo, err = %stderr, "git fetch skipped after cleanup");
459 }
460 Ok(Err(e)) => {
461 tracing::debug!(%repo, err = %e, "git fetch failed after cleanup");
462 }
463 Err(_) => {
464 tracing::debug!(%repo, "git fetch timed out after 60s");
465 }
466 }
467 }
468 }
469
470 Ok(())
471}
472
473pub(crate) async fn cleanup_task_worktree(
483 task_id: &str,
484 repo: &str,
485 store: &Arc<TaskStore>,
486) -> anyhow::Result<()> {
487 let opts = JanitorOptions {
488 ttl_hours: 0,
489 ..Default::default()
490 };
491 let cleaned = cleanup_task_worktree_with_opts(task_id, repo, store, &opts).await?;
492
493 if cleaned {
498 if let Ok(repo_root) = resolve_repo_root(repo).await {
499 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
500 let fetch_result = timeout(
501 GIT_TIMEOUT,
502 Command::new("git")
503 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
504 .output_with_context(),
505 )
506 .await;
507 match fetch_result {
508 Ok(Ok(output)) if output.status.success() => {
509 tracing::debug!(%repo, "fetched origin after task cleanup");
510 }
511 Ok(Ok(output)) => {
512 let stderr = String::from_utf8_lossy(&output.stderr);
513 tracing::debug!(%repo, err = %stderr, "git fetch skipped after task cleanup");
514 }
515 Ok(Err(e)) => {
516 tracing::debug!(%repo, err = %e, "git fetch failed after task cleanup");
517 }
518 Err(_) => {
519 tracing::debug!(%repo, "git fetch timed out after 60s");
520 }
521 }
522 }
523 }
524
525 Ok(())
526}
527
528pub(crate) async fn cleanup_task_worktree_with_opts(
535 task_id: &str,
536 repo: &str,
537 store: &Arc<TaskStore>,
538 opts: &JanitorOptions,
539) -> anyhow::Result<bool> {
540 let (worktree, branch, keep_remote_branch, merged) =
541 store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
542 .await
543 .map(|t| {
544 let keep = t.status == TaskStatus::Blocked && t.pr_number.is_some();
547 let merged = t.status == TaskStatus::Done;
548 (Some(t.worktree), Some(t.branch), keep, merged)
549 })
550 .unwrap_or((None, None, false, false));
551
552 let worktree_path = worktree.as_ref().map(std::path::PathBuf::from);
553
554 let worktrees_base = crate::home::worktrees_dir()
556 .unwrap_or_else(|_| std::path::PathBuf::from(".orch/worktrees"));
557
558 let worktree_to_remove = if let Some(ref wt) = worktree_path {
564 if match tokio::fs::try_exists(wt).await {
565 Ok(true) => true,
566 Ok(false) => false,
567 Err(e) => {
568 tracing::warn!(task_id, worktree = %wt.display(), err = %e, "try_exists failed on stored worktree path — trying branch-based fallback");
569 false
570 }
571 } {
572 Some(wt.clone())
573 } else {
574 if let Some(ref b) = branch {
576 let project = repo
577 .rsplit('/')
578 .next()
579 .unwrap_or(repo)
580 .trim_end_matches(".git");
581 let candidate = worktrees_base.join(project).join(b);
582 if match tokio::fs::try_exists(&candidate).await {
583 Ok(true) => true,
584 Ok(false) => false,
585 Err(e) => {
586 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
587 false
588 }
589 } {
590 Some(candidate)
591 } else {
592 None
593 }
594 } else {
595 None
596 }
597 }
598 } else if let Some(ref b) = branch {
599 let project = repo
601 .rsplit('/')
602 .next()
603 .unwrap_or(repo)
604 .trim_end_matches(".git");
605 let candidate = worktrees_base.join(project).join(b);
606 if match tokio::fs::try_exists(&candidate).await {
607 Ok(true) => true,
608 Ok(false) => false,
609 Err(e) => {
610 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
611 false
612 }
613 } {
614 Some(candidate)
615 } else {
616 None
617 }
618 } else {
619 None
620 };
621
622 let mut did_clean = false;
623
624 let branch_nonempty = branch.as_ref().filter(|b| !b.is_empty());
630 if worktree_to_remove.is_none() && branch_nonempty.is_none() {
631 if worktree_path
634 .as_ref()
635 .is_some_and(|p| !p.as_os_str().is_empty())
636 {
637 tracing::debug!(
638 task_id,
639 worktree = ?worktree_path,
640 "stored worktree path no longer exists on disk — marking cleaned"
641 );
642 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
643 if let Err(e) = store.mark_cleaned(store_id).await {
644 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned in store — will retry");
645 }
646 }
647 }
648 return Ok(false);
649 }
650
651 if let Some(wt) = worktree_to_remove {
652 if let Some(age_hours) = worktree_age_hours(&wt).await {
654 if age_hours < opts.ttl_hours {
655 tracing::debug!(
656 task_id,
657 worktree = %wt.display(),
658 age_hours,
659 ttl_hours = opts.ttl_hours,
660 "worktree too young for cleanup, skipping"
661 );
662 return Ok(false);
663 }
664 }
665
666 if is_worktree_in_active_session(&wt).await {
668 tracing::warn!(
669 task_id,
670 worktree = %wt.display(),
671 "worktree is referenced by an active tmux session — skipping cleanup"
672 );
673 return Ok(false);
674 }
675
676 let gitdir_valid = crate::engine::runner::worktree::validate_worktree_gitdir(&wt).await;
680
681 if opts.dry_run {
682 tracing::info!(
683 task_id,
684 worktree = %wt.display(),
685 branch = ?branch,
686 gitdir_valid,
687 "[dry-run] would remove worktree and branch"
688 );
689 } else if !gitdir_valid {
690 tracing::warn!(
691 task_id,
692 worktree = %wt.display(),
693 "worktree has broken git metadata — force-removing directory without git operations"
694 );
695 match tokio::fs::remove_dir_all(&wt).await {
696 Ok(()) => {
697 did_clean = true;
698 }
699 Err(e) => {
700 tracing::warn!(
701 task_id,
702 worktree = %wt.display(),
703 err = %e,
704 "failed to force-remove broken worktree directory"
705 );
706 }
707 }
708 } else {
709 tracing::info!(task_id, worktree = %wt.display(), "removing worktree");
710 let repo_root = match resolve_repo_root_from_worktree(&wt).await {
715 Ok(root) => root,
716 Err(_) => resolve_repo_root(repo).await?,
717 };
718 if merged {
719 crate::engine::runner::cow::warm_from_merged(
720 std::path::Path::new(&repo_root),
721 &crate::engine::runner::worktree::project_name(std::path::Path::new(
722 &repo_root,
723 )),
724 &wt,
725 )
726 .await;
727 }
728 let removed = remove_worktree_and_branch(
729 task_id,
730 &wt,
731 branch.as_deref(),
732 std::path::Path::new(&repo_root),
733 keep_remote_branch,
734 )
735 .await;
736 if removed {
737 did_clean = true;
738 } else {
739 tracing::warn!(
740 task_id,
741 worktree = %wt.display(),
742 "worktree directory still exists after removal attempt — not marking as cleaned"
743 );
744 }
745 }
746 } else if let Some(ref br) = branch {
747 if !opts.dry_run && !keep_remote_branch {
750 tracing::debug!(task_id, branch = %br, "no worktree on disk, cleaning up branch only");
751 let repo_root = resolve_repo_root(repo).await?;
753 delete_branches(task_id, br, std::path::Path::new(&repo_root)).await;
754 did_clean = true;
755 }
756 }
757
758 if did_clean {
759 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
763 if let Err(e) = store.mark_cleaned(store_id).await {
764 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned — will retry");
765 return Ok(did_clean);
767 }
768 }
769 store_log_activity(
770 &Some(Arc::clone(store)),
771 repo,
772 task_id,
773 "branch_delete",
774 None,
775 None,
776 None,
777 None,
778 Some(&serde_json::json!({
779 "worktree": worktree_path.map(|p| p.display().to_string()),
780 "branch": branch,
781 "keep_remote_branch": keep_remote_branch,
782 })),
783 )
784 .await;
785 }
786
787 Ok(did_clean)
788}
789
790pub(crate) async fn remove_worktree_and_branch(
797 task_id: &str,
798 wt: &std::path::Path,
799 branch: Option<&str>,
800 repo_root: &std::path::Path,
801 keep_remote_branch: bool,
802) -> bool {
803 let wt_str = wt.to_string_lossy().to_string();
804 let repo_root_str = repo_root.to_string_lossy();
805
806 let remove_result = Command::new("git")
809 .args([
810 "-C",
811 repo_root_str.as_ref(),
812 "worktree",
813 "remove",
814 &wt_str,
815 "--force",
816 ])
817 .output_with_context()
818 .await;
819
820 match remove_result {
821 Ok(output) if output.status.success() => {
822 tracing::info!(task_id, "worktree removed (git metadata)");
823 const MAX_RETRIES: usize = 3;
828 const RETRY_DELAY_MS: u64 = 2000;
829 let mut removed = false;
830 for attempt in 0..MAX_RETRIES {
831 match tokio::fs::remove_dir_all(wt).await {
832 Ok(()) => {
833 removed = true;
834 break;
835 }
836 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
837 removed = true;
839 break;
840 }
841 Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
842 if attempt < MAX_RETRIES - 1 {
843 tracing::debug!(
844 task_id,
845 path = %wt.display(),
846 attempt = attempt + 1,
847 "worktree directory not empty — retrying after delay"
848 );
849 sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
850 } else {
851 tracing::warn!(
852 task_id,
853 path = %wt.display(),
854 err = %e,
855 "failed to remove worktree directory after {} attempts (Directory not empty)",
856 MAX_RETRIES
857 );
858 }
859 }
860 Err(e) => {
861 tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
862 break;
863 }
864 }
865 }
866 if removed {
867 tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
868 } else {
869 tracing::warn!(
870 task_id,
871 path = %wt.display(),
872 "worktree directory still present after git worktree remove"
873 );
874 }
875 }
876 Ok(output) => {
877 let stderr = String::from_utf8_lossy(&output.stderr);
878 if stderr.contains("is not a working tree") {
884 tracing::debug!(
885 task_id,
886 path = %wt.display(),
887 "path is not a registered worktree (stale metadata) — removing directory and pruning"
888 );
889 if let Err(e) = tokio::fs::remove_dir_all(wt).await {
890 if match tokio::fs::try_exists(wt).await {
892 Ok(true) => true,
893 Ok(false) => false,
894 Err(e) => {
895 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
896 false
897 }
898 } {
899 tracing::warn!(
900 task_id,
901 path = %wt.display(),
902 err = %e,
903 "failed to remove stale worktree directory"
904 );
905 }
906 } else {
907 tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
908 }
909 if let Err(e) = Command::new("git")
912 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
913 .output_with_context()
914 .await
915 {
916 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
917 }
918 } else {
919 tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
920 }
921 }
922 Err(e) => {
923 tracing::warn!(task_id, err = %e, "failed to remove worktree");
924 }
925 }
926
927 if let Err(e) = Command::new("git")
929 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
930 .output_with_context()
931 .await
932 {
933 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
934 }
935
936 if let Some(br) = branch {
940 if keep_remote_branch {
941 let branch_delete_result = Command::new("git")
943 .args(["-C", repo_root_str.as_ref(), "branch", "-D", br])
944 .output_with_context()
945 .await;
946 match branch_delete_result {
947 Ok(output) if output.status.success() => {
948 tracing::info!(
949 task_id,
950 branch = br,
951 "local branch deleted (keeping remote for open PR)"
952 );
953 }
954 Ok(output) => {
955 let stderr = String::from_utf8_lossy(&output.stderr);
956 if !stderr.contains("not found") {
957 tracing::debug!(task_id, branch = br, err = %stderr, "local branch delete failed (may already be gone)");
958 }
959 }
960 Err(e) => {
961 tracing::debug!(task_id, branch = br, err = %e, "local branch delete failed");
962 }
963 }
964 } else {
965 delete_branches(task_id, br, repo_root).await;
966 }
967 }
968
969 match tokio::fs::try_exists(wt).await {
971 Ok(false) => true, Ok(true) => false, Err(e) => {
974 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
975 false }
977 }
978}
979
980pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &std::path::Path) {
982 let repo_root_str = repo_root.to_string_lossy();
983 let branch_delete_result = Command::new("git")
984 .args(["-C", repo_root_str.as_ref(), "branch", "-D", br])
985 .output_with_context()
986 .await;
987
988 match branch_delete_result {
989 Ok(output) if output.status.success() => {
990 tracing::info!(task_id, branch = %br, "local branch deleted");
991 }
992 Ok(output) => {
993 let stderr = String::from_utf8_lossy(&output.stderr);
994 tracing::debug!(
995 task_id,
996 err = %stderr,
997 "local branch delete skipped (may not exist)"
998 );
999 }
1000 Err(e) => {
1001 tracing::warn!(task_id, err = %e, "failed to delete local branch");
1002 }
1003 }
1004
1005 let auth_env = crate::engine::runner::git_ops::build_git_auth_env();
1007 let remote_delete = Command::new("git")
1008 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1009 .args([
1010 "-C",
1011 repo_root_str.as_ref(),
1012 "-c",
1013 "lfs.locksverify=false",
1014 "push",
1015 "origin",
1016 "--delete",
1017 br,
1018 ])
1019 .output_with_context()
1020 .await;
1021
1022 match remote_delete {
1023 Ok(output) if output.status.success() => {
1024 tracing::info!(task_id, branch = %br, "remote branch deleted");
1025 }
1026 Ok(output) => {
1027 let stderr = String::from_utf8_lossy(&output.stderr);
1028 tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1029 }
1030 Err(e) => {
1031 tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1032 }
1033 }
1034}
1035
1036async fn is_worktree_in_active_session(worktree: &std::path::Path) -> bool {
1042 let output = Command::new("tmux")
1043 .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1044 .output()
1045 .await;
1046
1047 match output {
1048 Ok(o) if o.status.success() => {
1049 let worktree_str = worktree.to_string_lossy();
1050 String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1051 let pane_path = line.trim();
1052 pane_path == worktree_str.as_ref()
1054 || pane_path.starts_with(&format!("{}/", worktree_str))
1055 })
1056 }
1057 _ => false,
1059 }
1060}
1061
1062async fn worktree_age_hours(worktree: &std::path::Path) -> Option<u64> {
1066 let metadata = tokio::fs::metadata(worktree).await.ok()?;
1068 let modified = metadata.modified().ok()?;
1069 let age = std::time::SystemTime::now().duration_since(modified).ok()?;
1070 Some(age.as_secs() / 3600)
1071}
1072
1073pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &std::path::Path) -> bool {
1079 let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1080 Ok(root) => root,
1081 Err(e) => {
1082 tracing::warn!(
1083 worktree = %wt.display(),
1084 error = %e,
1085 "cannot resolve repo root for orphaned worktree"
1086 );
1087 return false;
1088 }
1089 };
1090 remove_worktree_and_branch(task_id, wt, None, std::path::Path::new(&repo_root), false).await
1091}
1092
1093pub(crate) async fn resolve_repo_root_for_orphaned_worktree(
1100 wt: &std::path::Path,
1101) -> anyhow::Result<String> {
1102 let git_file = wt.join(".git");
1103 if tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1104 if let Ok(content) = tokio::fs::read_to_string(&git_file).await {
1105 for line in content.lines() {
1106 if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1107 let gitdir_path = std::path::PathBuf::from(gitdir);
1111 if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1112 return Ok(repo_root.display().to_string());
1113 }
1114 }
1115 }
1116 }
1117 }
1118
1119 let worktrees_dir = crate::home::worktrees_dir()?;
1122 if let Ok(relative) = wt.strip_prefix(&worktrees_dir) {
1123 if let Some(project_name) = relative.components().next() {
1124 let project_name = project_name.as_os_str().to_string_lossy();
1125
1126 if let Ok(paths) = crate::config::get_project_paths() {
1127 for path_str in &paths {
1128 let path = std::path::Path::new(path_str);
1129 if let Some(name) = path.file_name() {
1130 if name.to_string_lossy() == project_name {
1131 return Ok(path_str.clone());
1132 }
1133 }
1134 }
1135 }
1136
1137 let parts: Vec<&str> = project_name.split('/').collect();
1139 let bare = if parts.len() == 2 {
1140 crate::home::projects_dir()
1141 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1142 .unwrap_or_default()
1143 } else {
1144 return Err(anyhow::anyhow!(
1145 "cannot determine owner for single-segment project name '{}' — \
1146 bare repo location is ambiguous; register the project with 'owner/repo' format",
1147 project_name
1148 ));
1149 };
1150 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1151 return Ok(bare.display().to_string());
1152 }
1153 }
1154 }
1155
1156 anyhow::bail!(
1157 "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1158 wt.display()
1159 )
1160}
1161
1162pub async fn resolve_repo_root_from_worktree(wt: &std::path::Path) -> anyhow::Result<String> {
1169 let git_file = wt.join(".git");
1171 if !tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1172 anyhow::bail!(".git file not found in worktree at {}", wt.display());
1173 }
1174
1175 let content = tokio::fs::read_to_string(&git_file)
1176 .await
1177 .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1178
1179 for line in content.lines() {
1181 if let Some(gitdir) = line.strip_prefix("gitdir:") {
1182 let gitdir = gitdir.trim();
1183 let gitdir_path = std::path::Path::new(gitdir);
1187 if let Some(repo_root) = gitdir_path
1188 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
1191 {
1193 return Ok(repo_root.display().to_string());
1194 }
1195 }
1196 }
1197
1198 anyhow::bail!(
1199 "cannot resolve repo root from worktree .git file at {}",
1200 wt.display()
1201 )
1202}
1203
1204pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1209 let paths = crate::config::get_project_paths().unwrap_or_default();
1211 for path_str in &paths {
1212 let path = std::path::Path::new(path_str);
1213 let orch_yml = path.join(".orch.yml");
1214 let legacy = path.join(".orchestrator.yml");
1215 let config_file = if tokio::fs::try_exists(&orch_yml).await.unwrap_or(false) {
1216 orch_yml
1217 } else if tokio::fs::try_exists(&legacy).await.unwrap_or(false) {
1218 legacy
1219 } else {
1220 continue;
1221 };
1222 if let Ok(content) = tokio::fs::read_to_string(&config_file).await {
1223 if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
1224 if let Some(r) = doc
1225 .get("gh")
1226 .and_then(|gh| gh.get("repo"))
1227 .and_then(|r| r.as_str())
1228 {
1229 if r == repo {
1230 return Ok(path_str.clone());
1231 }
1232 }
1233 }
1234 }
1235 }
1236
1237 let parts: Vec<&str> = repo.split('/').collect();
1239 let bare = if parts.len() == 2 {
1240 crate::home::projects_dir()
1241 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1242 .unwrap_or_default()
1243 } else {
1244 std::path::PathBuf::new()
1245 };
1246 if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1247 return Ok(bare.display().to_string());
1248 }
1249
1250 anyhow::bail!(
1251 "cannot find local path for project {repo} — \
1252 checked {} registered project(s) and bare clone at {}",
1253 paths.len(),
1254 bare.display()
1255 )
1256}
1257
1258fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<std::path::PathBuf> {
1263 if let Ok(dir) = std::env::var("PROJECT_DIR") {
1265 if !dir.is_empty() {
1266 return Ok(std::path::PathBuf::from(dir));
1267 }
1268 }
1269
1270 if let Ok(paths) = crate::config::get_project_paths() {
1272 for path_str in &paths {
1273 let path = std::path::Path::new(path_str);
1274 let orch_yml = path.join(".orch.yml");
1275 let legacy = path.join(".orchestrator.yml");
1276 let config_file = if std::path::Path::new(&orch_yml).exists() {
1277 orch_yml
1278 } else if std::path::Path::new(&legacy).exists() {
1279 legacy
1280 } else {
1281 continue;
1282 };
1283 if let Ok(content) = std::fs::read_to_string(&config_file) {
1284 if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
1285 if let Some(r) = doc
1286 .get("gh")
1287 .and_then(|gh| gh.get("repo"))
1288 .and_then(|r| r.as_str())
1289 {
1290 if r == repo {
1291 return Ok(path_str.clone().into());
1292 }
1293 }
1294 }
1295 }
1296 }
1297 }
1298
1299 anyhow::bail!("project dir not found for repo: {repo}")
1300}
1301
1302pub(crate) async fn check_merged_prs(
1307 backend: &Arc<dyn ExternalBackend>,
1308 repo: &str,
1309 store: &Arc<TaskStore>,
1310 task_manager: &Arc<TaskManager>,
1311 in_review_tasks: &[ReviewTaskSnapshot],
1312 needs_review_tasks: &[ReviewTaskSnapshot],
1313) -> anyhow::Result<()> {
1314 let mut branch_map: std::collections::HashMap<String, String> =
1315 std::collections::HashMap::new();
1316 let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1317 .iter()
1318 .chain(needs_review_tasks.iter())
1319 .collect();
1320
1321 for task in &all_review_tasks {
1322 if !task.stored.branch.is_empty() {
1323 branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1324 }
1325 }
1326
1327 tracing::debug!(
1328 count = all_review_tasks.len(),
1329 "checking review tasks for merged PRs"
1330 );
1331
1332 let mut task_branches: Vec<(String, String)> = Vec::new();
1336 for task in all_review_tasks {
1337 let task_id = &task.external.id.0;
1338 let branch = if let Some(b) = branch_map.get(task_id) {
1339 b.clone()
1340 } else {
1341 match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1342 .await
1343 .map(|t| t.branch)
1344 {
1345 Some(b) if !b.is_empty() => b,
1346 _ => {
1347 tracing::debug!(task_id, "no branch info, skipping PR check");
1348 continue;
1349 }
1350 }
1351 };
1352 task_branches.push((task_id.clone(), branch));
1353 }
1354
1355 if task_branches.is_empty() {
1356 return Ok(());
1357 }
1358
1359 let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1361 let merged_map = match backend.batch_is_pr_merged(&branches).await {
1362 Ok(map) => map,
1363 Err(e) => {
1364 tracing::warn!(
1365 err = %e,
1366 branch_count = branches.len(),
1367 "batch PR merge check failed, retrying affected branches individually"
1368 );
1369 fallback_is_pr_merged_by_branch(backend, &branches).await
1370 }
1371 };
1372
1373 for (task_id, branch) in task_branches {
1374 let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1375 if !is_merged {
1376 continue;
1377 }
1378
1379 tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1380
1381 let id = ExternalId(task_id.clone());
1382 if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1383 tracing::warn!(task_id, err = %e, "failed to update status to done");
1384 continue;
1385 }
1386
1387 let comment = format!(
1388 "PR merged, marking task complete{}",
1389 crate::engine::orch_footer()
1390 );
1391 if let Err(e) = backend.post_comment(&id, &comment).await {
1392 tracing::warn!(task_id, err = %e, "failed to post comment");
1393 }
1394 }
1395
1396 Ok(())
1397}
1398
1399async fn fallback_is_pr_merged_by_branch(
1400 backend: &Arc<dyn ExternalBackend>,
1401 branches: &[String],
1402) -> HashMap<String, bool> {
1403 let futs: Vec<_> = branches
1404 .iter()
1405 .map(|branch| {
1406 let backend = Arc::clone(backend);
1407 let branch = branch.clone();
1408 async move {
1409 match backend.is_pr_merged(&branch).await {
1410 Ok(is_merged) => Some((branch, is_merged)),
1411 Err(err) => {
1412 tracing::warn!(
1413 branch,
1414 err = %err,
1415 "fallback PR merge check failed for branch"
1416 );
1417 None
1418 }
1419 }
1420 }
1421 })
1422 .collect();
1423
1424 futures::future::join_all(futs)
1425 .await
1426 .into_iter()
1427 .flatten()
1428 .collect()
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::*;
1434 use crate::store::{NewTask, TaskStore};
1435 use async_trait::async_trait;
1436 use std::collections::HashMap;
1437 use std::sync::atomic::{AtomicUsize, Ordering};
1438 use std::sync::Mutex;
1439
1440 #[tokio::test]
1443 async fn worktree_age_hours_returns_none_for_missing_dir() {
1444 let tmp = tempfile::tempdir().unwrap();
1445 let missing = tmp.path().join("does-not-exist");
1446 assert!(worktree_age_hours(&missing).await.is_none());
1447 }
1448
1449 #[tokio::test]
1450 async fn worktree_age_hours_returns_some_for_existing_dir() {
1451 let tmp = tempfile::tempdir().unwrap();
1452 let age = worktree_age_hours(tmp.path()).await;
1454 assert!(age.is_some());
1455 assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1456 }
1457
1458 #[test]
1461 fn janitor_options_default_values() {
1462 let opts = JanitorOptions::default();
1463 assert_eq!(opts.ttl_hours, 24);
1464 assert!(!opts.dry_run);
1465 }
1466
1467 fn setup_test_repo() -> Option<(tempfile::TempDir, std::path::PathBuf)> {
1471 let tmp = tempfile::tempdir().ok()?;
1472
1473 let repo_dir = tmp.path().join("repo.git");
1475 std::fs::create_dir_all(&repo_dir).ok()?;
1476
1477 let init = std::process::Command::new("git")
1478 .args([
1479 "init",
1480 "--initial-branch=main",
1481 repo_dir
1482 .to_str()
1483 .expect("test repo path contains non-UTF-8 characters"),
1484 ])
1485 .output()
1486 .ok()?;
1487 if !init.status.success() {
1488 let _ = std::process::Command::new("git")
1490 .args([
1491 "init",
1492 repo_dir
1493 .to_str()
1494 .expect("test repo path contains non-UTF-8 characters"),
1495 ])
1496 .output()
1497 .ok()?;
1498 }
1499
1500 let readme = repo_dir.join("README.md");
1502 std::fs::write(&readme, "# test").ok()?;
1503 std::process::Command::new("git")
1504 .args(["-C", repo_dir.to_str()?, "add", "."])
1505 .output()
1506 .ok()?;
1507 std::process::Command::new("git")
1508 .args([
1509 "-C",
1510 repo_dir.to_str()?,
1511 "-c",
1512 "user.email=test@test.com",
1513 "-c",
1514 "user.name=Test",
1515 "-c",
1516 "commit.gpgsign=false",
1517 "commit",
1518 "--allow-empty",
1519 "-m",
1520 "init",
1521 ])
1522 .output()
1523 .ok()?;
1524
1525 let wt_dir = tmp
1527 .path()
1528 .join("worktrees")
1529 .join("repo.git")
1530 .join("gh-issue-42-test");
1531 std::fs::create_dir_all(wt_dir.parent()?).ok()?;
1532 let wt_out = std::process::Command::new("git")
1533 .args([
1534 "-C",
1535 repo_dir.to_str()?,
1536 "worktree",
1537 "add",
1538 "-b",
1539 "gh-issue-42-test",
1540 wt_dir.to_str()?,
1541 ])
1542 .output()
1543 .ok()?;
1544
1545 if !wt_out.status.success() {
1546 return None;
1547 }
1548
1549 Some((tmp, wt_dir))
1550 }
1551
1552 #[tokio::test]
1553 async fn janitor_skips_young_worktree() {
1554 let Some((tmp, wt_dir)) = setup_test_repo() else {
1555 eprintln!("skipping test: git not available");
1556 return;
1557 };
1558
1559 assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1561
1562 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1564 assert!(
1565 age < 24,
1566 "freshly created worktree should be younger than 24h (age={age}h)"
1567 );
1568
1569 let opts = JanitorOptions {
1571 ttl_hours: 24,
1572 dry_run: true,
1573 };
1574 assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1576
1577 drop(tmp); }
1579
1580 #[tokio::test]
1581 async fn janitor_eligible_when_ttl_zero() {
1582 let Some((tmp, wt_dir)) = setup_test_repo() else {
1583 eprintln!("skipping test: git not available");
1584 return;
1585 };
1586
1587 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1589 let opts = JanitorOptions {
1590 ttl_hours: 0,
1591 dry_run: true,
1592 };
1593 assert!(
1594 age >= opts.ttl_hours,
1595 "worktree should be eligible when ttl=0 (age={age}h)"
1596 );
1597
1598 drop(tmp);
1599 }
1600
1601 #[tokio::test]
1602 async fn dry_run_does_not_remove_worktree() {
1603 let Some((tmp, wt_dir)) = setup_test_repo() else {
1605 eprintln!("skipping test: git not available");
1606 return;
1607 };
1608
1609 assert!(wt_dir.exists(), "worktree must exist before test");
1610
1611 let opts = JanitorOptions {
1614 ttl_hours: 0,
1615 dry_run: true,
1616 };
1617
1618 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1623 let in_use = false; let skip = age < opts.ttl_hours || in_use;
1625
1626 if !skip && opts.dry_run {
1627 tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1629 } else if !skip {
1630 }
1632
1633 assert!(
1635 wt_dir.exists(),
1636 "dry-run must not remove the worktree directory"
1637 );
1638
1639 drop(tmp);
1640 }
1641
1642 #[test]
1643 fn fallback_branch_path_construction() {
1644 let tmp = tempfile::tempdir().unwrap();
1647 let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1648 std::fs::create_dir_all(&wt_dir).unwrap();
1649
1650 let branch = "gh-issue-7-fix";
1652 let repo = "owner/myrepo";
1653 let worktrees_base = tmp.path();
1654
1655 let project = repo
1656 .rsplit('/')
1657 .next()
1658 .unwrap_or(repo)
1659 .trim_end_matches(".git");
1660 let candidate = worktrees_base.join(project).join(branch);
1661
1662 assert_eq!(candidate, wt_dir);
1663 assert!(
1664 candidate.exists(),
1665 "fallback path should point to existing dir"
1666 );
1667 }
1668
1669 #[tokio::test]
1672 async fn cleanup_returns_false_when_nothing_to_clean() {
1673 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1674
1675 let id = store
1677 .create(&NewTask {
1678 external_id: Some("999".to_string()),
1679 repo: "owner/repo".to_string(),
1680 origin: "github".to_string(),
1681 title: "Already cleaned".to_string(),
1682 ..Default::default()
1683 })
1684 .await
1685 .unwrap();
1686 store
1687 .update_status(id, crate::store::TaskStatus::Done)
1688 .await
1689 .unwrap();
1690
1691 let opts = JanitorOptions {
1692 ttl_hours: 0,
1693 dry_run: false,
1694 };
1695
1696 let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
1697 match result {
1701 Ok(cleaned) => assert!(
1702 !cleaned,
1703 "cleanup should return false when there is no worktree or branch to clean"
1704 ),
1705 Err(_) => {
1706 }
1709 }
1710 }
1711
1712 #[tokio::test]
1716 async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
1717 let tmp = tempfile::tempdir().unwrap();
1718 let stale_wt = tmp.path().join("stale-worktree");
1720 assert!(!stale_wt.exists(), "stale path must not exist");
1721
1722 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1723
1724 let id = store
1725 .create(&NewTask {
1726 external_id: Some("1005".to_string()),
1727 repo: "owner/repo".to_string(),
1728 origin: "github".to_string(),
1729 title: "Stale worktree task".to_string(),
1730 ..Default::default()
1731 })
1732 .await
1733 .unwrap();
1734 store
1735 .update_status(id, crate::store::TaskStatus::Done)
1736 .await
1737 .unwrap();
1738 store
1740 .set_fields(
1741 id,
1742 &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
1743 )
1744 .await
1745 .unwrap();
1746
1747 let before = store.get(id).await.unwrap();
1749 assert!(!before.worktree_cleaned, "task should not be cleaned yet");
1750 assert!(
1751 !before.worktree.is_empty(),
1752 "task must have a worktree path set"
1753 );
1754
1755 let opts = JanitorOptions {
1756 ttl_hours: 0,
1757 dry_run: false,
1758 };
1759
1760 let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
1761
1762 match result {
1765 Ok(_) => {}
1766 Err(e) => panic!("cleanup should not error for stale path: {e}"),
1767 }
1768
1769 let after = store.get(id).await.unwrap();
1771 assert!(
1772 after.worktree_cleaned,
1773 "task must be marked cleaned when stored worktree path is already gone"
1774 );
1775 }
1776
1777 #[tokio::test]
1780 async fn remove_worktree_returns_true_on_success() {
1781 let Some((tmp, wt_dir)) = setup_test_repo() else {
1782 eprintln!("skipping test: git not available");
1783 return;
1784 };
1785
1786 let repo_dir = tmp.path().join("repo.git");
1787 assert!(wt_dir.exists(), "worktree must exist before removal");
1788
1789 let removed =
1790 remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
1791 .await;
1792
1793 assert!(
1794 removed,
1795 "remove_worktree_and_branch should return true when directory is gone"
1796 );
1797 assert!(!wt_dir.exists(), "worktree directory should be removed");
1798 }
1799
1800 #[tokio::test]
1803 async fn remove_worktree_success_still_deletes_local_branch() {
1804 let Some((tmp, wt_dir)) = setup_test_repo() else {
1805 eprintln!("skipping test: git not available");
1806 return;
1807 };
1808
1809 let repo_dir = tmp.path().join("repo.git");
1810 let repo_dir_str = repo_dir
1811 .to_str()
1812 .expect("test repo path contains non-UTF-8 characters");
1813 let branch = "gh-issue-42-test";
1814
1815 let before = std::process::Command::new("git")
1816 .args([
1817 "-C",
1818 repo_dir_str,
1819 "show-ref",
1820 "--verify",
1821 "--quiet",
1822 &format!("refs/heads/{branch}"),
1823 ])
1824 .status()
1825 .unwrap();
1826 assert!(
1827 before.success(),
1828 "test setup must create local branch before cleanup"
1829 );
1830
1831 let removed =
1832 remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
1833 assert!(removed, "worktree should be removed");
1834
1835 let after = std::process::Command::new("git")
1836 .args([
1837 "-C",
1838 repo_dir_str,
1839 "show-ref",
1840 "--verify",
1841 "--quiet",
1842 &format!("refs/heads/{branch}"),
1843 ])
1844 .status()
1845 .unwrap();
1846 assert!(
1847 !after.success(),
1848 "local branch should be deleted after successful worktree cleanup"
1849 );
1850 }
1851
1852 #[tokio::test]
1856 async fn remove_worktree_returns_false_when_dir_persists() {
1857 let tmp = tempfile::tempdir().unwrap();
1858 let fake_wt = tmp.path().join("not-a-worktree");
1861 std::fs::create_dir_all(&fake_wt).unwrap();
1862
1863 let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
1866
1867 assert!(
1870 !removed,
1871 "remove_worktree_and_branch should return false when directory still exists"
1872 );
1873 assert!(fake_wt.exists(), "directory should still be on disk");
1874 }
1875
1876 struct MergeCheckMockBackend {
1877 batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
1878 single_results: HashMap<String, anyhow::Result<bool>>,
1879 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
1880 single_checked_branches: Arc<Mutex<Vec<String>>>,
1881 }
1882
1883 impl MergeCheckMockBackend {
1884 fn with_batch_error(
1885 error: anyhow::Error,
1886 single_results: HashMap<String, anyhow::Result<bool>>,
1887 ) -> Self {
1888 Self {
1889 batch_result: Mutex::new(Err(error)),
1890 single_results,
1891 posted_comments: Arc::new(Mutex::new(Vec::new())),
1892 single_checked_branches: Arc::new(Mutex::new(Vec::new())),
1893 }
1894 }
1895 }
1896
1897 #[async_trait]
1898 impl ExternalBackend for MergeCheckMockBackend {
1899 fn name(&self) -> &str {
1900 "mock"
1901 }
1902
1903 async fn create_task(
1904 &self,
1905 _title: &str,
1906 _body: &str,
1907 _labels: &[String],
1908 ) -> anyhow::Result<ExternalId> {
1909 anyhow::bail!("not implemented")
1910 }
1911
1912 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
1913 anyhow::bail!("not implemented")
1914 }
1915
1916 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1917 Ok(vec![])
1918 }
1919
1920 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1921 Ok(vec![])
1922 }
1923
1924 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
1925 self.posted_comments
1926 .lock()
1927 .unwrap()
1928 .push((id.0.clone(), body.to_string()));
1929 Ok(())
1930 }
1931
1932 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
1933 Ok(())
1934 }
1935
1936 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
1937 Ok(())
1938 }
1939
1940 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1941 Ok(vec![])
1942 }
1943
1944 async fn create_sub_task(
1945 &self,
1946 _parent: &ExternalId,
1947 _title: &str,
1948 _body: &str,
1949 _labels: &[String],
1950 ) -> anyhow::Result<ExternalId> {
1951 anyhow::bail!("not implemented")
1952 }
1953
1954 async fn health_check(&self) -> anyhow::Result<()> {
1955 Ok(())
1956 }
1957
1958 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
1959 self.single_checked_branches
1960 .lock()
1961 .unwrap()
1962 .push(branch.to_string());
1963 match self.single_results.get(branch) {
1964 Some(Ok(is_merged)) => Ok(*is_merged),
1965 Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
1966 None => Ok(false),
1967 }
1968 }
1969
1970 async fn batch_is_pr_merged(
1971 &self,
1972 _branches: &[String],
1973 ) -> anyhow::Result<HashMap<String, bool>> {
1974 match &*self.batch_result.lock().unwrap() {
1975 Ok(map) => Ok(map.clone()),
1976 Err(err) => Err(anyhow::anyhow!(err.to_string())),
1977 }
1978 }
1979 }
1980
1981 #[tokio::test]
1982 async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
1983 let single_results = HashMap::from([
1984 ("branch-1".to_string(), Ok(true)),
1985 ("branch-2".to_string(), Ok(false)),
1986 ]);
1987 let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
1988 anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
1989 single_results,
1990 ));
1991 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
1992
1993 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1994 let task1 = store
1995 .create(&NewTask {
1996 external_id: Some("101".to_string()),
1997 repo: "owner/repo".to_string(),
1998 origin: "github".to_string(),
1999 title: "Task 101".to_string(),
2000 ..Default::default()
2001 })
2002 .await
2003 .unwrap();
2004 store
2005 .update_status(task1, crate::store::TaskStatus::NeedsReview)
2006 .await
2007 .unwrap();
2008 store
2009 .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2010 .await
2011 .unwrap();
2012
2013 let task2 = store
2014 .create(&NewTask {
2015 external_id: Some("102".to_string()),
2016 repo: "owner/repo".to_string(),
2017 origin: "github".to_string(),
2018 title: "Task 102".to_string(),
2019 ..Default::default()
2020 })
2021 .await
2022 .unwrap();
2023 store
2024 .update_status(task2, crate::store::TaskStatus::InReview)
2025 .await
2026 .unwrap();
2027 store
2028 .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2029 .await
2030 .unwrap();
2031
2032 let task_manager = Arc::new(TaskManager::with_store(
2033 backend_dyn.clone(),
2034 store.clone(),
2035 "owner/repo".to_string(),
2036 ));
2037
2038 let in_review_tasks = store
2039 .list_by_status("owner/repo", crate::store::TaskStatus::InReview)
2040 .await
2041 .unwrap()
2042 .into_iter()
2043 .map(|stored| ReviewTaskSnapshot {
2044 external: crate::engine::tasks::store_task_to_external(&stored),
2045 stored,
2046 })
2047 .collect::<Vec<_>>();
2048 let needs_review_tasks = store
2049 .list_by_status("owner/repo", crate::store::TaskStatus::NeedsReview)
2050 .await
2051 .unwrap()
2052 .into_iter()
2053 .map(|stored| ReviewTaskSnapshot {
2054 external: crate::engine::tasks::store_task_to_external(&stored),
2055 stored,
2056 })
2057 .collect::<Vec<_>>();
2058
2059 check_merged_prs(
2060 &backend_dyn,
2061 "owner/repo",
2062 &store,
2063 &task_manager,
2064 &in_review_tasks,
2065 &needs_review_tasks,
2066 )
2067 .await
2068 .unwrap();
2069
2070 let checked = backend.single_checked_branches.lock().unwrap().clone();
2071 assert_eq!(
2072 checked,
2073 vec!["branch-2".to_string(), "branch-1".to_string()]
2074 );
2075
2076 let updated_task1 = store.get(task1).await.unwrap();
2077 let updated_task2 = store.get(task2).await.unwrap();
2078 assert_eq!(updated_task1.status, crate::store::TaskStatus::Done);
2079 assert_eq!(updated_task2.status, crate::store::TaskStatus::InReview);
2080
2081 let comments = backend.posted_comments.lock().unwrap().clone();
2082 assert_eq!(comments.len(), 1);
2083 assert_eq!(comments[0].0, "101");
2084 assert!(comments[0].1.contains("PR merged, marking task complete"));
2085 }
2086
2087 struct ReconciliationMockBackend {
2088 reconciliation_calls: Arc<AtomicUsize>,
2089 }
2090
2091 #[async_trait]
2092 impl ExternalBackend for ReconciliationMockBackend {
2093 fn name(&self) -> &str {
2094 "mock"
2095 }
2096
2097 async fn create_task(
2098 &self,
2099 _title: &str,
2100 _body: &str,
2101 _labels: &[String],
2102 ) -> anyhow::Result<ExternalId> {
2103 anyhow::bail!("not implemented")
2104 }
2105
2106 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2107 anyhow::bail!("not implemented")
2108 }
2109
2110 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2111 Ok(vec![])
2112 }
2113
2114 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2115 self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2116 Ok(vec![])
2117 }
2118
2119 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2120 Ok(vec![])
2121 }
2122
2123 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2124 Ok(())
2125 }
2126
2127 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2128 Ok(())
2129 }
2130
2131 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2132 Ok(())
2133 }
2134
2135 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2136 Ok(vec![])
2137 }
2138
2139 async fn create_sub_task(
2140 &self,
2141 _parent: &ExternalId,
2142 _title: &str,
2143 _body: &str,
2144 _labels: &[String],
2145 ) -> anyhow::Result<ExternalId> {
2146 anyhow::bail!("not implemented")
2147 }
2148
2149 async fn health_check(&self) -> anyhow::Result<()> {
2150 Ok(())
2151 }
2152 }
2153
2154 #[tokio::test]
2155 async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2156 let repo = "owner/repo-cleanup-rate-limit";
2157 RECONCILIATION_LAST_RUN.remove(repo);
2158
2159 let backend = Arc::new(ReconciliationMockBackend {
2160 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2161 });
2162 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
2163 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2164 let task_manager = Arc::new(TaskManager::with_store(
2165 backend_dyn.clone(),
2166 store.clone(),
2167 repo.to_string(),
2168 ));
2169 let opts = JanitorOptions {
2170 ttl_hours: 0,
2171 dry_run: true,
2172 };
2173
2174 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2175 .await
2176 .unwrap();
2177 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2178 .await
2179 .unwrap();
2180
2181 assert_eq!(
2182 backend.reconciliation_calls.load(Ordering::SeqCst),
2183 1,
2184 "cleanup should not re-list reconciliation candidates on every sync pass"
2185 );
2186
2187 RECONCILIATION_LAST_RUN.remove(repo);
2188 }
2189}