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