1use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
8use crate::cmd::CommandErrorContext;
9use crate::engine::tasks::TaskManager;
10use crate::store;
11use crate::store::TaskStatus;
12use crate::store::TaskStore;
13use crate::store::store_log_activity;
14use dashmap::{DashMap, DashSet};
15use futures::future;
16use std::collections::HashMap;
17use std::collections::HashSet;
18use std::env;
19use std::fs as std_fs;
20use std::io::ErrorKind;
21use std::path::Path;
22use std::path::PathBuf;
23use std::process::Output;
24use std::sync::Arc;
25use std::sync::LazyLock;
26use std::time::SystemTime;
27use std::time::{Duration, Instant};
28use tokio::fs;
29use tokio::process::Command;
30use tokio::time::sleep;
31use tokio::time::timeout;
32
33use super::sync::ReviewTaskSnapshot;
34use crate::config;
35use crate::engine;
36use crate::engine::auto_merge;
37use crate::engine::runner::cow;
38use crate::engine::runner::git_ops;
39use crate::engine::runner::worktree;
40use crate::github::http::GhHttp;
41use crate::home;
42
43const RECONCILIATION_LIST_TIMEOUT: Duration = Duration::from_secs(30);
48const RECONCILIATION_INTERVAL: Duration = Duration::from_mins(15);
51
52static CLEANUP_IN_FLIGHT: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
55static RECONCILIATION_LAST_RUN: LazyLock<DashMap<String, Instant>> = LazyLock::new(DashMap::new);
57
58struct CleanupInFlightGuard {
59 repo: String,
60}
61
62impl CleanupInFlightGuard {
63 fn try_acquire(repo: &str) -> Option<Self> {
64 if CLEANUP_IN_FLIGHT.insert(repo.to_string()) {
65 Some(Self {
66 repo: repo.to_string(),
67 })
68 } else {
69 None
70 }
71 }
72}
73
74impl Drop for CleanupInFlightGuard {
75 fn drop(&mut self) {
76 CLEANUP_IN_FLIGHT.remove(&self.repo);
77 }
78}
79
80fn should_run_reconciliation(repo: &str) -> bool {
81 let now = Instant::now();
82 if let Some(mut last_run) = RECONCILIATION_LAST_RUN.get_mut(repo) {
83 if last_run.elapsed() < RECONCILIATION_INTERVAL {
84 return false;
85 }
86 *last_run = now;
87 true
88 } else {
89 RECONCILIATION_LAST_RUN.insert(repo.to_string(), now);
90 true
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct JanitorOptions {
97 pub ttl_hours: u64,
105
106 pub dry_run: bool,
110}
111
112impl Default for JanitorOptions {
113 fn default() -> Self {
114 Self {
115 ttl_hours: 24,
116 dry_run: false,
117 }
118 }
119}
120
121impl JanitorOptions {
122 pub fn from_config() -> Self {
124 let ttl_hours = config::get("workflow.worktree_janitor_ttl_hours")
125 .ok()
126 .and_then(|v| v.parse::<u64>().ok())
127 .unwrap_or(24);
128
129 let dry_run = config::get("workflow.worktree_janitor_dry_run").is_ok_and(|v| v == "true");
130
131 Self { ttl_hours, dry_run }
132 }
133}
134
135pub(crate) async fn cleanup_done_worktrees(
144 backend: &Arc<dyn ExternalBackend>,
145 repo: &str,
146 task_manager: &Arc<TaskManager>,
147 store: &Arc<TaskStore>,
148) -> anyhow::Result<()> {
149 let opts = JanitorOptions::from_config();
150 cleanup_done_worktrees_with_opts(backend, repo, task_manager, store, &opts).await
151}
152
153async fn reconcile_closed_tasks(
154 task_ids: &mut Vec<String>,
155 all_tasks: &[ExternalTask],
156 task_manager: &TaskManager,
157) {
158 const MAX_RECONCILE_PER_CYCLE: usize = 10;
159
160 let done_set: HashSet<String> = task_ids.iter().cloned().collect();
161 let closed_not_in_store: Vec<_> = all_tasks
162 .iter()
163 .filter(|t| t.state == "closed" && !done_set.contains(&t.id.0))
164 .collect();
165
166 let mut already_labeled: Vec<String> = Vec::new();
169 let mut needs_reconcile: Vec<&ExternalTask> = Vec::new();
170 for task in &closed_not_in_store {
171 if task.labels.iter().any(|l| l == "status:done") {
172 already_labeled.push(task.id.0.clone());
173 } else {
174 needs_reconcile.push(task);
175 }
176 }
177
178 if !already_labeled.is_empty() {
180 tracing::debug!(
181 count = already_labeled.len(),
182 "closed issues already labeled status:done — adding to cleanup set"
183 );
184 task_ids.extend(already_labeled);
185 }
186
187 if needs_reconcile.len() > MAX_RECONCILE_PER_CYCLE {
190 tracing::info!(
191 total = needs_reconcile.len(),
192 batch = MAX_RECONCILE_PER_CYCLE,
193 "capping closed-issue reconciliation to avoid rate-limit exhaustion"
194 );
195 }
196 let mut futs = Vec::new();
197 for task in needs_reconcile.iter().take(MAX_RECONCILE_PER_CYCLE) {
198 let id = task.id.clone();
199 let task_id_str = task.id.0.clone();
200 let labels = task.labels.clone();
201 futs.push(async move {
202 tracing::info!(
203 task_id = task_id_str,
204 labels = ?labels,
205 "closed issue with stale status label — reconciling to done and cleaning up"
206 );
207 match task_manager.update_task_status(&id, Status::Done).await {
208 Ok(()) => Some(task_id_str),
209 Err(e) => {
210 tracing::warn!(
211 task_id = task_id_str,
212 err = %e,
213 "failed to reconcile closed issue status to done"
214 );
215 None
216 }
217 }
218 });
219 }
220 let results = future::join_all(futs).await;
221 task_ids.extend(results.into_iter().flatten());
222}
223
224pub(crate) async fn cleanup_done_worktrees_with_opts(
230 backend: &Arc<dyn ExternalBackend>,
231 repo: &str,
232 task_manager: &Arc<TaskManager>,
233 store: &Arc<TaskStore>,
234 opts: &JanitorOptions,
235) -> anyhow::Result<()> {
236 let Some(_guard) = CleanupInFlightGuard::try_acquire(repo) else {
237 tracing::debug!(
238 repo = %repo,
239 "cleanup already in progress for repo — skipping overlapping run"
240 );
241 return Ok(());
242 };
243
244 let done_tasks = task_manager.list_all_by_status(Status::Done).await?;
246 tracing::debug!(count = done_tasks.len(), "checking done tasks for cleanup");
247
248 let mut task_ids: Vec<String> = done_tasks.iter().map(|t| t.id.0.clone()).collect();
250
251 if should_run_reconciliation(repo) {
263 match timeout(
264 RECONCILIATION_LIST_TIMEOUT,
265 backend.list_reconciliation_candidates(),
266 )
267 .await
268 {
269 Ok(Ok(candidates)) if !candidates.is_empty() => {
270 tracing::debug!(
271 count = candidates.len(),
272 "reconciling closed issues from candidates"
273 );
274 reconcile_closed_tasks(&mut task_ids, &candidates, task_manager).await;
275 }
276 Ok(Ok(_)) => {
277 tracing::debug!("no reconciliation candidates found");
278 }
279 Ok(Err(e)) => {
280 tracing::warn!(err = %e, "failed to list reconciliation candidates");
281 }
282 Err(_) => {
283 tracing::warn!(
284 timeout_secs = RECONCILIATION_LIST_TIMEOUT.as_secs(),
285 retry_after_secs = RECONCILIATION_INTERVAL.as_secs(),
286 "timed out listing reconciliation candidates"
287 );
288 }
289 }
290 } else {
291 tracing::debug!(
292 retry_after_secs = RECONCILIATION_INTERVAL.as_secs(),
293 "skipping closed-issue reconciliation until interval elapses"
294 );
295 }
296
297 if let Ok(internal_done) = task_manager.list_internal_by_status(TaskStatus::Done).await {
299 task_ids.extend(internal_done.iter().map(|t| t.id.0.clone()));
300 }
301
302 tracing::debug!(
303 count = task_ids.len(),
304 "checking all terminal tasks for cleanup"
305 );
306
307 if let Err(e) = auto_merge::sweep_ci_timer_keys(store, repo, &task_ids).await {
308 tracing::warn!(err = %e, "failed to sweep CI timer keys");
309 }
310
311 let already_cleaned = store.cleaned_external_ids(repo).await.unwrap_or_default();
316
317 let mut cleaned_any = false;
318 for task_id in &task_ids {
319 if already_cleaned.contains(task_id) {
321 continue;
322 }
323
324 match cleanup_task_worktree_with_opts(task_id, repo, store, opts).await {
325 Ok(true) => cleaned_any = true,
326 Ok(false) => {
327 }
330 Err(e) => {
331 tracing::warn!(task_id, err = %e, "worktree cleanup failed for task");
332 }
333 }
334 }
335
336 let worktrees_base = match home::worktrees_dir() {
349 Ok(base) => Some(base),
350 Err(e) => {
351 tracing::debug!(err = %e, "worktrees_dir() unavailable — skipping orphan scan");
352 None
353 }
354 };
355
356 if let Some(base) = worktrees_base {
357 let project = repo
358 .rsplit('/')
359 .next()
360 .unwrap_or(repo)
361 .trim_end_matches(".git");
362 let repo_worktrees = base.join(project);
363
364 let mut combined: HashSet<String> = HashSet::new();
366 if let Ok(tasks) = store.list_all(repo).await {
367 for t in tasks {
368 if !t.worktree.is_empty() {
369 combined.insert(t.worktree);
370 }
371 }
372 }
373
374 if let Ok(project_dir) = resolve_project_dir_for_repo(repo)
376 && let Ok(active_wts) = worktree::list_project_worktrees(&project_dir).await
377 {
378 for wt in active_wts {
379 combined.insert(wt.to_string_lossy().to_string());
380 }
381 }
382
383 if let Ok(mut entries) = fs::read_dir(&repo_worktrees).await {
385 while let Ok(Some(entry)) = entries.next_entry().await {
386 let Ok(entry_meta) = entry.metadata().await else {
387 continue;
388 };
389 if !entry_meta.is_dir() {
390 continue;
391 }
392 let wt_path = entry.path().to_string_lossy().to_string();
393 if combined.contains(&wt_path) {
394 continue;
395 }
396 tracing::info!(worktree = %wt_path, "found orphaned worktree — attempting cleanup");
397 match resolve_repo_root_for_orphaned_worktree(&entry.path()).await {
398 Ok(repo_root) => {
399 let removed = remove_worktree_and_branch(
400 "orphan-scan",
401 &entry.path(),
402 None,
403 Path::new(&repo_root),
404 false,
405 )
406 .await;
407 if removed {
408 tracing::info!(worktree = %wt_path, "orphaned worktree removed");
409 } else {
410 tracing::warn!(worktree = %wt_path, "orphaned worktree could not be removed");
411 }
412 }
413 Err(e) => {
414 tracing::warn!(worktree = %wt_path, err = %e, "could not resolve repo root for orphaned worktree");
415 }
416 }
417 }
418 }
419 }
420
421 if cleaned_any
423 && !opts.dry_run
424 && let Ok(repo_root) = resolve_repo_root(repo).await
425 {
426 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
427
428 let prune_result = timeout(
430 GIT_TIMEOUT,
431 Command::new("git")
432 .args(["-C", &repo_root, "worktree", "prune"])
433 .output_with_context(),
434 )
435 .await;
436 match prune_result {
437 Ok(Ok(output)) if output.status.success() => {
438 tracing::debug!(%repo, "pruned stale worktree metadata");
439 }
440 Ok(Ok(output)) => {
441 let stderr = String::from_utf8_lossy(&output.stderr);
442 tracing::warn!(%repo, err = %stderr, "git worktree prune failed");
443 }
444 Ok(Err(e)) => {
445 tracing::warn!(%repo, err = %e, "git worktree prune failed");
446 }
447 Err(_) => {
448 tracing::warn!(%repo, "git worktree prune timed out after 60s");
449 }
450 }
451
452 let fetch_result = timeout(
457 GIT_TIMEOUT,
458 git_ops::git()
459 .await
460 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
461 .output_with_context(),
462 )
463 .await;
464 match fetch_result {
465 Ok(Ok(output)) if output.status.success() => {
466 tracing::info!(%repo, "fetched origin after worktree cleanup");
467 }
468 Ok(Ok(output)) => {
469 let stderr = String::from_utf8_lossy(&output.stderr);
470 tracing::debug!(%repo, err = %stderr, "git fetch skipped after cleanup");
471 }
472 Ok(Err(e)) => {
473 tracing::debug!(%repo, err = %e, "git fetch failed after cleanup");
474 }
475 Err(_) => {
476 tracing::debug!(%repo, "git fetch timed out after 60s");
477 }
478 }
479 }
480
481 Ok(())
482}
483
484pub(crate) async fn cleanup_task_worktree(
494 task_id: &str,
495 repo: &str,
496 store: &Arc<TaskStore>,
497) -> anyhow::Result<()> {
498 let opts = JanitorOptions {
499 ttl_hours: 0,
500 ..Default::default()
501 };
502 let cleaned = cleanup_task_worktree_with_opts(task_id, repo, store, &opts).await?;
503
504 if cleaned && let Ok(repo_root) = resolve_repo_root(repo).await {
509 const GIT_TIMEOUT: Duration = Duration::from_secs(60);
510 let fetch_result = timeout(
511 GIT_TIMEOUT,
512 git_ops::git()
513 .await
514 .args(["-C", &repo_root, "fetch", "--prune", "origin"])
515 .output_with_context(),
516 )
517 .await;
518 match fetch_result {
519 Ok(Ok(output)) if output.status.success() => {
520 tracing::debug!(%repo, "fetched origin after task cleanup");
521 }
522 Ok(Ok(output)) => {
523 let stderr = String::from_utf8_lossy(&output.stderr);
524 tracing::debug!(%repo, err = %stderr, "git fetch skipped after task cleanup");
525 }
526 Ok(Err(e)) => {
527 tracing::debug!(%repo, err = %e, "git fetch failed after task cleanup");
528 }
529 Err(_) => {
530 tracing::debug!(%repo, "git fetch timed out after 60s");
531 }
532 }
533 }
534
535 Ok(())
536}
537
538pub(crate) async fn cleanup_task_worktree_with_opts(
545 task_id: &str,
546 repo: &str,
547 store: &Arc<TaskStore>,
548 opts: &JanitorOptions,
549) -> anyhow::Result<bool> {
550 let (worktree, branch, keep_remote_branch, merged, external_id, pr_merged) =
551 store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
552 .await
553 .map_or((None, None, false, false, None, false), |t| {
554 let keep = t.status == TaskStatus::Blocked && t.pr_number.is_some();
557 let merged = t.status == TaskStatus::Done;
558 (
559 Some(t.worktree),
560 Some(t.branch),
561 keep,
562 merged,
563 t.external_id,
564 merged && t.pr_number.is_some(),
566 )
567 });
568
569 let worktree_path = worktree.as_ref().map(PathBuf::from);
570
571 let worktrees_base = home::worktrees_dir().unwrap_or_else(|_| PathBuf::from(".orch/worktrees"));
573
574 let worktree_to_remove = if let Some(ref wt) = worktree_path {
580 if match fs::try_exists(wt).await {
581 Ok(true) => true,
582 Ok(false) => false,
583 Err(e) => {
584 tracing::warn!(task_id, worktree = %wt.display(), err = %e, "try_exists failed on stored worktree path — trying branch-based fallback");
585 false
586 }
587 } {
588 Some(wt.clone())
589 } else {
590 if let Some(ref b) = branch {
592 let project = repo
593 .rsplit('/')
594 .next()
595 .unwrap_or(repo)
596 .trim_end_matches(".git");
597 let candidate = worktrees_base.join(project).join(b);
598 if match fs::try_exists(&candidate).await {
599 Ok(true) => true,
600 Ok(false) => false,
601 Err(e) => {
602 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
603 false
604 }
605 } {
606 Some(candidate)
607 } else {
608 None
609 }
610 } else {
611 None
612 }
613 }
614 } else if let Some(ref b) = branch {
615 let project = repo
617 .rsplit('/')
618 .next()
619 .unwrap_or(repo)
620 .trim_end_matches(".git");
621 let candidate = worktrees_base.join(project).join(b);
622 if match fs::try_exists(&candidate).await {
623 Ok(true) => true,
624 Ok(false) => false,
625 Err(e) => {
626 tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
627 false
628 }
629 } {
630 Some(candidate)
631 } else {
632 None
633 }
634 } else {
635 None
636 };
637
638 let mut did_clean = false;
639
640 let branch_nonempty = branch.as_ref().filter(|b| !b.is_empty());
646 if worktree_to_remove.is_none() && branch_nonempty.is_none() {
647 if worktree_path
650 .as_ref()
651 .is_some_and(|p| !p.as_os_str().is_empty())
652 {
653 tracing::debug!(
654 task_id,
655 worktree = ?worktree_path,
656 "stored worktree path no longer exists on disk — marking cleaned"
657 );
658 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await
659 && let Err(e) = store.mark_cleaned(store_id).await
660 {
661 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned in store — will retry");
662 }
663 }
664 return Ok(false);
665 }
666
667 if let Some(wt) = worktree_to_remove {
668 if let Some(age_hours) = worktree_age_hours(&wt).await
670 && age_hours < opts.ttl_hours
671 {
672 tracing::debug!(
673 task_id,
674 worktree = %wt.display(),
675 age_hours,
676 ttl_hours = opts.ttl_hours,
677 "worktree too young for cleanup, skipping"
678 );
679 return Ok(false);
680 }
681
682 if is_worktree_in_active_session(&wt).await {
684 tracing::warn!(
685 task_id,
686 worktree = %wt.display(),
687 "worktree is referenced by an active tmux session — skipping cleanup"
688 );
689 return Ok(false);
690 }
691
692 let gitdir_valid = worktree::validate_worktree_gitdir(&wt).await;
696
697 if opts.dry_run {
698 tracing::info!(
699 task_id,
700 worktree = %wt.display(),
701 branch = ?branch,
702 gitdir_valid,
703 "[dry-run] would remove worktree and branch"
704 );
705 } else if !gitdir_valid {
706 tracing::warn!(
707 task_id,
708 worktree = %wt.display(),
709 "worktree has broken git metadata — force-removing directory without git operations"
710 );
711 match fs::remove_dir_all(&wt).await {
712 Ok(()) => {
713 did_clean = true;
714 }
715 Err(e) => {
716 tracing::warn!(
717 task_id,
718 worktree = %wt.display(),
719 err = %e,
720 "failed to force-remove broken worktree directory"
721 );
722 }
723 }
724 } else {
725 tracing::info!(task_id, worktree = %wt.display(), "removing worktree");
726 let repo_root = match resolve_repo_root_from_worktree(&wt).await {
731 Ok(root) => root,
732 Err(_) => resolve_repo_root(repo).await?,
733 };
734 if let Some(br) = branch.as_deref().filter(|b| !b.is_empty())
735 && !pr_merged
736 && !rescue_and_notify(
737 task_id,
738 repo,
739 external_id.as_deref(),
740 Some(&wt),
741 br,
742 Path::new(&repo_root),
743 )
744 .await
745 {
746 return Ok(false);
747 }
748 if merged {
749 cow::warm_from_merged(
750 Path::new(&repo_root),
751 &worktree::project_name(Path::new(&repo_root)),
752 &wt,
753 )
754 .await;
755 }
756 let removed = remove_worktree_and_branch_unchecked(
757 task_id,
758 &wt,
759 branch.as_deref(),
760 Path::new(&repo_root),
761 keep_remote_branch,
762 )
763 .await;
764 if removed {
765 did_clean = true;
766 } else {
767 tracing::warn!(
768 task_id,
769 worktree = %wt.display(),
770 "worktree directory still exists after removal attempt — not marking as cleaned"
771 );
772 }
773 }
774 } else if let Some(ref br) = branch {
775 if !opts.dry_run && !keep_remote_branch {
778 tracing::debug!(task_id, branch = %br, "no worktree on disk, cleaning up branch only");
779 let repo_root = resolve_repo_root(repo).await?;
781 if !pr_merged
782 && !rescue_and_notify(
783 task_id,
784 repo,
785 external_id.as_deref(),
786 None,
787 br,
788 Path::new(&repo_root),
789 )
790 .await
791 {
792 return Ok(false);
793 }
794 delete_branches(task_id, br, Path::new(&repo_root)).await;
795 did_clean = true;
796 }
797 }
798
799 if did_clean {
800 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await
804 && let Err(e) = store.mark_cleaned(store_id).await
805 {
806 tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned — will retry");
807 return Ok(did_clean);
809 }
810 store_log_activity(
811 &Some(Arc::clone(store)),
812 repo,
813 task_id,
814 "branch_delete",
815 None,
816 None,
817 None,
818 None,
819 Some(&serde_json::json!({
820 "worktree": worktree_path.map(|p| p.display().to_string()),
821 "branch": branch,
822 "keep_remote_branch": keep_remote_branch,
823 })),
824 )
825 .await;
826 }
827
828 Ok(did_clean)
829}
830
831#[derive(Debug, PartialEq, Eq)]
833pub(crate) enum Rescue {
834 Clean,
836 Pushed {
838 branch: String,
839 commits: Vec<String>,
840 },
841 Failed,
843}
844
845async fn git_in(dir: &Path, args: &[&str]) -> Option<Output> {
846 git_ops::git()
847 .await
848 .arg("-C")
849 .arg(dir)
850 .args(args)
851 .output_with_context()
852 .await
853 .ok()
854}
855
856pub(crate) async fn rescue_unpushed_work(
862 task_id: &str,
863 wt: Option<&Path>,
864 br: &str,
865 repo_root: &Path,
866) -> Rescue {
867 if let Some(wt) = wt
868 && fs::try_exists(wt.join(".git")).await.unwrap_or(false)
869 {
870 let Some(status) = git_in(wt, &["status", "--porcelain"]).await else {
871 tracing::warn!(task_id, branch = %br, "cannot read worktree status, keeping worktree");
872 return Rescue::Failed;
873 };
874 if !status.status.success() {
875 tracing::warn!(task_id, branch = %br, "git status failed, keeping worktree");
876 return Rescue::Failed;
877 }
878 if !status.stdout.is_empty() {
879 let committed = git_in(wt, &["add", "-A"])
880 .await
881 .is_some_and(|o| o.status.success())
882 && git_in(
883 wt,
884 &[
885 "-c",
886 "user.name=orch",
887 "-c",
888 "user.email=orch@localhost",
889 "commit",
890 "-m",
891 "rescue: uncommitted changes saved by orch cleanup",
892 ],
893 )
894 .await
895 .is_some_and(|o| o.status.success());
896 if !committed {
897 tracing::warn!(task_id, branch = %br, "cannot commit uncommitted changes, keeping worktree");
898 return Rescue::Failed;
899 }
900 }
901 }
902
903 let local_ref = format!("refs/heads/{br}");
904 let exists = git_in(repo_root, &["rev-parse", "--verify", "--quiet", &local_ref])
905 .await
906 .is_some_and(|o| o.status.success());
907 if !exists {
908 return Rescue::Clean;
909 }
910 let default_ref = format!(
912 "refs/heads/{}",
913 worktree::detect_default_branch(repo_root).await
914 );
915 let remote_tip = git_in(repo_root, &["ls-remote", "origin", &local_ref])
917 .await
918 .filter(|o| o.status.success())
919 .and_then(|o| {
920 String::from_utf8_lossy(&o.stdout)
921 .split_whitespace()
922 .next()
923 .map(str::to_string)
924 });
925 let remote_tip = match remote_tip {
927 Some(tip) => {
928 let known = git_in(repo_root, &["cat-file", "-e", &format!("{tip}^{{commit}}")])
929 .await
930 .is_some_and(|o| o.status.success());
931 known.then_some(tip)
932 }
933 None => None,
934 };
935 let mut args = vec!["rev-list", "--oneline", &local_ref, "--not", "--remotes"];
936 if let Some(tip) = remote_tip.as_deref() {
937 args.push(tip);
938 }
939 let default_exists = default_ref != local_ref
940 && git_in(
941 repo_root,
942 &["rev-parse", "--verify", "--quiet", &default_ref],
943 )
944 .await
945 .is_some_and(|o| o.status.success());
946 if default_exists {
947 args.push(&default_ref);
948 }
949 let Some(list) = git_in(repo_root, &args)
950 .await
951 .filter(|o| o.status.success())
952 else {
953 tracing::warn!(task_id, branch = %br, "cannot list unpushed commits, keeping worktree and branch");
954 return Rescue::Failed;
955 };
956 let commits: Vec<String> = String::from_utf8_lossy(&list.stdout)
957 .lines()
958 .map(str::to_string)
959 .collect();
960 if commits.is_empty() {
961 return Rescue::Clean;
962 }
963
964 let rescue = format!("rescue/{br}");
965 let refspec = format!("{local_ref}:refs/heads/{rescue}");
966 let pushed = git_in(
967 repo_root,
968 &["-c", "lfs.locksverify=false", "push", "origin", &refspec],
969 )
970 .await;
971 if pushed.is_some_and(|o| o.status.success()) {
972 tracing::warn!(task_id, branch = %br, rescue = %rescue, commits = commits.len(), "pushed unpushed commits to rescue branch");
973 Rescue::Pushed {
974 branch: rescue,
975 commits,
976 }
977 } else {
978 tracing::warn!(task_id, branch = %br, rescue = %rescue, "rescue push failed, keeping worktree and branch");
979 Rescue::Failed
980 }
981}
982
983fn rescue_comment(rescue_branch: &str, commits: &[String]) -> String {
984 let list = commits
985 .iter()
986 .map(|c| format!("- `{c}`"))
987 .collect::<Vec<_>>()
988 .join("\n");
989 format!(
990 "Cleanup found commits on this task's branch that were never pushed. They are saved on `{rescue_branch}`:\n\n{list}"
991 )
992}
993
994async fn rescue_and_notify(
996 task_id: &str,
997 repo: &str,
998 external_id: Option<&str>,
999 wt: Option<&Path>,
1000 br: &str,
1001 repo_root: &Path,
1002) -> bool {
1003 match rescue_unpushed_work(task_id, wt, br, repo_root).await {
1004 Rescue::Clean => true,
1005 Rescue::Failed => false,
1006 Rescue::Pushed { branch, commits } => {
1007 if let Some(number) = external_id.filter(|n| n.parse::<u64>().is_ok()) {
1008 match GhHttp::new() {
1009 Ok(gh) => {
1010 if let Err(e) = gh
1011 .add_comment(repo, number, &rescue_comment(&branch, &commits))
1012 .await
1013 {
1014 tracing::warn!(task_id, err = %e, "failed to comment on rescue branch");
1015 }
1016 }
1017 Err(e) => {
1018 tracing::warn!(task_id, err = %e, "cannot comment on rescue branch");
1019 }
1020 }
1021 }
1022 true
1023 }
1024 }
1025}
1026
1027pub(crate) async fn remove_worktree_and_branch(
1034 task_id: &str,
1035 wt: &Path,
1036 branch: Option<&str>,
1037 repo_root: &Path,
1038 keep_remote_branch: bool,
1039) -> bool {
1040 remove_worktree_and_branch_for(task_id, wt, branch, repo_root, keep_remote_branch, false).await
1041}
1042
1043pub(crate) async fn remove_worktree_and_branch_for(
1046 task_id: &str,
1047 wt: &Path,
1048 branch: Option<&str>,
1049 repo_root: &Path,
1050 keep_remote_branch: bool,
1051 pr_merged: bool,
1052) -> bool {
1053 if !pr_merged
1054 && let Some(br) = branch.filter(|b| !b.is_empty())
1055 && rescue_unpushed_work(task_id, Some(wt), br, repo_root).await == Rescue::Failed
1056 {
1057 return false;
1058 }
1059
1060 remove_worktree_and_branch_unchecked(task_id, wt, branch, repo_root, keep_remote_branch).await
1061}
1062
1063async fn remove_worktree_and_branch_unchecked(
1065 task_id: &str,
1066 wt: &Path,
1067 branch: Option<&str>,
1068 repo_root: &Path,
1069 keep_remote_branch: bool,
1070) -> bool {
1071 let wt_str = wt.to_string_lossy().to_string();
1072 let repo_root_str = repo_root.to_string_lossy();
1073
1074 let remove_result = Command::new("git")
1077 .args([
1078 "-C",
1079 repo_root_str.as_ref(),
1080 "worktree",
1081 "remove",
1082 &wt_str,
1083 "--force",
1084 ])
1085 .output_with_context()
1086 .await;
1087
1088 match remove_result {
1089 Ok(output) if output.status.success() => {
1090 const MAX_RETRIES: usize = 3;
1091 const RETRY_DELAY_MS: u64 = 2000;
1092 tracing::info!(task_id, "worktree removed (git metadata)");
1093 let mut removed = false;
1098 for attempt in 0..MAX_RETRIES {
1099 match fs::remove_dir_all(wt).await {
1100 Ok(()) => {
1101 removed = true;
1102 break;
1103 }
1104 Err(e) if e.kind() == ErrorKind::NotFound => {
1105 removed = true;
1107 break;
1108 }
1109 Err(e) if e.kind() == ErrorKind::DirectoryNotEmpty => {
1110 if attempt < MAX_RETRIES - 1 {
1111 tracing::debug!(
1112 task_id,
1113 path = %wt.display(),
1114 attempt = attempt + 1,
1115 "worktree directory not empty — retrying after delay"
1116 );
1117 sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
1118 } else {
1119 tracing::warn!(
1120 task_id,
1121 path = %wt.display(),
1122 err = %e,
1123 "failed to remove worktree directory after {} attempts (Directory not empty)",
1124 MAX_RETRIES
1125 );
1126 }
1127 }
1128 Err(e) => {
1129 tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
1130 break;
1131 }
1132 }
1133 }
1134 if removed {
1135 tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
1136 } else {
1137 tracing::warn!(
1138 task_id,
1139 path = %wt.display(),
1140 "worktree directory still present after git worktree remove"
1141 );
1142 }
1143 }
1144 Ok(output) => {
1145 let stderr = String::from_utf8_lossy(&output.stderr);
1146 if stderr.contains("is not a working tree") {
1152 tracing::debug!(
1153 task_id,
1154 path = %wt.display(),
1155 "path is not a registered worktree (stale metadata) — removing directory and pruning"
1156 );
1157 if let Err(e) = fs::remove_dir_all(wt).await {
1158 if match fs::try_exists(wt).await {
1160 Ok(true) => true,
1161 Ok(false) => false,
1162 Err(e) => {
1163 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
1164 false
1165 }
1166 } {
1167 tracing::warn!(
1168 task_id,
1169 path = %wt.display(),
1170 err = %e,
1171 "failed to remove stale worktree directory"
1172 );
1173 }
1174 } else {
1175 tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
1176 }
1177 if let Err(e) = Command::new("git")
1180 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
1181 .output_with_context()
1182 .await
1183 {
1184 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
1185 }
1186 } else {
1187 tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
1188 }
1189 }
1190 Err(e) => {
1191 tracing::warn!(task_id, err = %e, "failed to remove worktree");
1192 }
1193 }
1194
1195 if let Err(e) = Command::new("git")
1197 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
1198 .output_with_context()
1199 .await
1200 {
1201 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
1202 }
1203
1204 if let Some(br) = branch {
1208 if keep_remote_branch {
1209 delete_local_branch(task_id, br, repo_root).await;
1210 } else {
1211 delete_branches(task_id, br, repo_root).await;
1212 }
1213 }
1214
1215 match fs::try_exists(wt).await {
1217 Ok(false) => true, Ok(true) => false, Err(e) => {
1220 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
1221 false }
1223 }
1224}
1225
1226async fn commits_outside(repo_root: &Path, rev: &str, others: &[&str]) -> Option<u64> {
1228 let mut args = vec![
1229 "rev-list".to_string(),
1230 "--count".to_string(),
1231 rev.to_string(),
1232 ];
1233 for other in others {
1234 let exists = Command::new("git")
1235 .args([
1236 "-C",
1237 &repo_root.to_string_lossy(),
1238 "rev-parse",
1239 "--verify",
1240 "--quiet",
1241 other,
1242 ])
1243 .output_with_context()
1244 .await
1245 .is_ok_and(|o| o.status.success());
1246 if exists {
1247 args.push("--not".to_string());
1248 args.push(other.to_string());
1249 }
1250 }
1251 let output = Command::new("git")
1252 .arg("-C")
1253 .arg(repo_root)
1254 .args(&args)
1255 .output_with_context()
1256 .await
1257 .ok()?;
1258 if !output.status.success() {
1259 return None;
1260 }
1261 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
1262}
1263
1264async fn delete_local_branch(task_id: &str, br: &str, repo_root: &Path) {
1266 let default_branch = worktree::detect_default_branch(repo_root).await;
1267 let remote_ref = format!("refs/remotes/origin/{br}");
1268 let default_ref = format!("refs/remotes/origin/{default_branch}");
1269 let local_ref = format!("refs/heads/{br}");
1270 let local_default_ref = format!("refs/heads/{default_branch}");
1271 let rescue_ref = format!("refs/remotes/origin/rescue/{br}");
1272 match commits_outside(
1273 repo_root,
1274 &local_ref,
1275 &[&remote_ref, &default_ref, &local_default_ref, &rescue_ref],
1276 )
1277 .await
1278 {
1279 Some(0) => {}
1280 Some(n) => {
1281 tracing::warn!(task_id, branch = %br, unpushed = n, "local branch holds commits origin does not have, keeping it");
1282 return;
1283 }
1284 None => {
1285 tracing::debug!(task_id, branch = %br, "local branch missing or unreadable, nothing to delete");
1286 return;
1287 }
1288 }
1289 let output = Command::new("git")
1290 .args(["-C", &repo_root.to_string_lossy(), "branch", "-D", br])
1291 .output_with_context()
1292 .await;
1293 match output {
1294 Ok(o) if o.status.success() => {
1295 tracing::info!(task_id, branch = %br, "local branch deleted");
1296 }
1297 Ok(o) => {
1298 let stderr = String::from_utf8_lossy(&o.stderr);
1299 tracing::debug!(task_id, err = %stderr, "local branch delete skipped (may not exist)");
1300 }
1301 Err(e) => {
1302 tracing::warn!(task_id, err = %e, "failed to delete local branch");
1303 }
1304 }
1305}
1306
1307pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &Path) {
1315 let default_branch = worktree::detect_default_branch(repo_root).await;
1316 let _ = git_ops::git()
1317 .await
1318 .args([
1319 "-C",
1320 &repo_root.to_string_lossy(),
1321 "fetch",
1322 "origin",
1323 &default_branch,
1324 br,
1325 ])
1326 .output_with_context()
1327 .await;
1328
1329 delete_local_branch(task_id, br, repo_root).await;
1330
1331 let remote_ref = format!("refs/remotes/origin/{br}");
1332 let default_ref = format!("refs/remotes/origin/{default_branch}");
1333 match commits_outside(repo_root, &remote_ref, &[&default_ref]).await {
1334 Some(0) => {}
1335 Some(n) => {
1336 tracing::info!(task_id, branch = %br, unmerged = n, "remote branch has commits the default branch lacks, keeping it");
1337 return;
1338 }
1339 None => {
1340 tracing::debug!(task_id, branch = %br, "remote branch missing or unreadable, nothing to delete");
1341 return;
1342 }
1343 }
1344
1345 let remote_delete = git_ops::git()
1346 .await
1347 .args([
1348 "-C",
1349 &repo_root.to_string_lossy(),
1350 "-c",
1351 "lfs.locksverify=false",
1352 "push",
1353 "origin",
1354 "--delete",
1355 br,
1356 ])
1357 .output_with_context()
1358 .await;
1359
1360 match remote_delete {
1361 Ok(output) if output.status.success() => {
1362 tracing::info!(task_id, branch = %br, "remote branch deleted");
1363 git_ops::clear_push_lease(repo_root, br).await;
1364 }
1365 Ok(output) => {
1366 let stderr = String::from_utf8_lossy(&output.stderr);
1367 tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1368 }
1369 Err(e) => {
1370 tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1371 }
1372 }
1373}
1374
1375async fn is_worktree_in_active_session(worktree: &Path) -> bool {
1381 let output = Command::new("tmux")
1382 .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1383 .output()
1384 .await;
1385
1386 match output {
1387 Ok(o) if o.status.success() => {
1388 let worktree_str = worktree.to_string_lossy();
1389 String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1390 let pane_path = line.trim();
1391 pane_path == worktree_str.as_ref()
1393 || pane_path.starts_with(&format!("{worktree_str}/"))
1394 })
1395 }
1396 _ => false,
1398 }
1399}
1400
1401async fn worktree_age_hours(worktree: &Path) -> Option<u64> {
1405 let metadata = fs::metadata(worktree).await.ok()?;
1407 let modified = metadata.modified().ok()?;
1408 let age = SystemTime::now().duration_since(modified).ok()?;
1409 Some(age.as_secs() / 3600)
1410}
1411
1412pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &Path) -> bool {
1418 let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1419 Ok(root) => root,
1420 Err(e) => {
1421 tracing::warn!(
1422 worktree = %wt.display(),
1423 error = %e,
1424 "cannot resolve repo root for orphaned worktree"
1425 );
1426 return false;
1427 }
1428 };
1429 remove_worktree_and_branch(task_id, wt, None, Path::new(&repo_root), false).await
1430}
1431
1432pub(crate) async fn resolve_repo_root_for_orphaned_worktree(wt: &Path) -> anyhow::Result<String> {
1439 let git_file = wt.join(".git");
1440 if fs::try_exists(&git_file).await.unwrap_or(false)
1441 && let Ok(content) = fs::read_to_string(&git_file).await
1442 {
1443 for line in content.lines() {
1444 if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1445 let gitdir_path = PathBuf::from(gitdir);
1449 if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1450 return Ok(repo_root.display().to_string());
1451 }
1452 }
1453 }
1454 }
1455
1456 let worktrees_dir = home::worktrees_dir()?;
1459 if let Ok(relative) = wt.strip_prefix(&worktrees_dir)
1460 && let Some(project_name) = relative.components().next()
1461 {
1462 let project_name = project_name.as_os_str().to_string_lossy();
1463
1464 if let Ok(paths) = config::get_project_paths() {
1465 for path_str in &paths {
1466 let path = Path::new(path_str);
1467 if let Some(name) = path.file_name()
1468 && name.to_string_lossy() == project_name
1469 {
1470 return Ok(path_str.clone());
1471 }
1472 }
1473 }
1474
1475 let parts: Vec<&str> = project_name.split('/').collect();
1477 let bare = if parts.len() == 2 {
1478 home::projects_dir()
1479 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1480 .unwrap_or_default()
1481 } else {
1482 return Err(anyhow::anyhow!(
1483 "cannot determine owner for single-segment project name '{project_name}' — \
1484 bare repo location is ambiguous; register the project with 'owner/repo' format"
1485 ));
1486 };
1487 if fs::try_exists(&bare).await.unwrap_or(false) {
1488 return Ok(bare.display().to_string());
1489 }
1490 }
1491
1492 anyhow::bail!(
1493 "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1494 wt.display()
1495 )
1496}
1497
1498pub async fn resolve_repo_root_from_worktree(wt: &Path) -> anyhow::Result<String> {
1505 let git_file = wt.join(".git");
1507 if !fs::try_exists(&git_file).await.unwrap_or(false) {
1508 anyhow::bail!(".git file not found in worktree at {}", wt.display());
1509 }
1510
1511 let content = fs::read_to_string(&git_file)
1512 .await
1513 .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1514
1515 for line in content.lines() {
1517 if let Some(gitdir) = line.strip_prefix("gitdir:") {
1518 let gitdir = gitdir.trim();
1519 let gitdir_path = Path::new(gitdir);
1523 if let Some(repo_root) = gitdir_path
1524 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
1527 {
1529 return Ok(repo_root.display().to_string());
1530 }
1531 }
1532 }
1533
1534 anyhow::bail!(
1535 "cannot resolve repo root from worktree .git file at {}",
1536 wt.display()
1537 )
1538}
1539
1540pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1545 let paths = config::get_project_paths().unwrap_or_default();
1547 for path_str in &paths {
1548 let path = Path::new(path_str);
1549 let orch_yml = path.join(".orch.yml");
1550 let legacy = path.join(".orchestrator.yml");
1551 let config_file = if fs::try_exists(&orch_yml).await.unwrap_or(false) {
1552 orch_yml
1553 } else if fs::try_exists(&legacy).await.unwrap_or(false) {
1554 legacy
1555 } else {
1556 continue;
1557 };
1558 if let Ok(content) = fs::read_to_string(&config_file).await
1559 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1560 && let Some(r) = doc
1561 .get("gh")
1562 .and_then(|gh| gh.get("repo"))
1563 .and_then(|r| r.as_str())
1564 && r == repo
1565 {
1566 return Ok(path_str.clone());
1567 }
1568 }
1569
1570 let parts: Vec<&str> = repo.split('/').collect();
1572 let bare = if parts.len() == 2 {
1573 home::projects_dir()
1574 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1575 .unwrap_or_default()
1576 } else {
1577 PathBuf::new()
1578 };
1579 if fs::try_exists(&bare).await.unwrap_or(false) {
1580 return Ok(bare.display().to_string());
1581 }
1582
1583 anyhow::bail!(
1584 "cannot find local path for project {repo} — \
1585 checked {} registered project(s) and bare clone at {}",
1586 paths.len(),
1587 bare.display()
1588 )
1589}
1590
1591fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<PathBuf> {
1596 if let Ok(dir) = env::var("PROJECT_DIR")
1598 && !dir.is_empty()
1599 {
1600 return Ok(PathBuf::from(dir));
1601 }
1602
1603 if let Ok(paths) = config::get_project_paths() {
1605 for path_str in &paths {
1606 let path = Path::new(path_str);
1607 let orch_yml = path.join(".orch.yml");
1608 let legacy = path.join(".orchestrator.yml");
1609 let config_file = if Path::new(&orch_yml).exists() {
1610 orch_yml
1611 } else if Path::new(&legacy).exists() {
1612 legacy
1613 } else {
1614 continue;
1615 };
1616 if let Ok(content) = std_fs::read_to_string(&config_file)
1617 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1618 && let Some(r) = doc
1619 .get("gh")
1620 .and_then(|gh| gh.get("repo"))
1621 .and_then(|r| r.as_str())
1622 && r == repo
1623 {
1624 return Ok(path_str.clone().into());
1625 }
1626 }
1627 }
1628
1629 anyhow::bail!("project dir not found for repo: {repo}")
1630}
1631
1632pub(crate) async fn check_merged_prs(
1637 backend: &Arc<dyn ExternalBackend>,
1638 repo: &str,
1639 store: &Arc<TaskStore>,
1640 task_manager: &Arc<TaskManager>,
1641 in_review_tasks: &[ReviewTaskSnapshot],
1642 needs_review_tasks: &[ReviewTaskSnapshot],
1643) -> anyhow::Result<()> {
1644 let mut branch_map: HashMap<String, String> = HashMap::new();
1645 let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1646 .iter()
1647 .chain(needs_review_tasks.iter())
1648 .collect();
1649
1650 for task in &all_review_tasks {
1651 if !task.stored.branch.is_empty() {
1652 branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1653 }
1654 }
1655
1656 tracing::debug!(
1657 count = all_review_tasks.len(),
1658 "checking review tasks for merged PRs"
1659 );
1660
1661 let mut task_branches: Vec<(String, String)> = Vec::new();
1665 for task in all_review_tasks {
1666 let task_id = &task.external.id.0;
1667 let branch = if let Some(b) = branch_map.get(task_id) {
1668 b.clone()
1669 } else {
1670 match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1671 .await
1672 .map(|t| t.branch)
1673 {
1674 Some(b) if !b.is_empty() => b,
1675 _ => {
1676 tracing::debug!(task_id, "no branch info, skipping PR check");
1677 continue;
1678 }
1679 }
1680 };
1681 task_branches.push((task_id.clone(), branch));
1682 }
1683
1684 if task_branches.is_empty() {
1685 return Ok(());
1686 }
1687
1688 let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1690 let merged_map = match backend.batch_is_pr_merged(&branches).await {
1691 Ok(map) => map,
1692 Err(e) => {
1693 tracing::warn!(
1694 err = %e,
1695 branch_count = branches.len(),
1696 "batch PR merge check failed, retrying affected branches individually"
1697 );
1698 fallback_is_pr_merged_by_branch(backend, &branches).await
1699 }
1700 };
1701
1702 for (task_id, branch) in task_branches {
1703 let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1704 if !is_merged {
1705 continue;
1706 }
1707
1708 tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1709
1710 let id = ExternalId(task_id.clone());
1711 if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1712 tracing::warn!(task_id, err = %e, "failed to update status to done");
1713 continue;
1714 }
1715
1716 if let Err(e) = cleanup_task_worktree(&task_id, repo, store).await {
1717 tracing::warn!(task_id, err = %e, "worktree cleanup after merge failed");
1718 }
1719
1720 let comment = format!("PR merged, marking task complete{}", engine::orch_footer());
1721 if let Err(e) = backend.post_comment(&id, &comment).await {
1722 tracing::warn!(task_id, err = %e, "failed to post comment");
1723 }
1724 }
1725
1726 Ok(())
1727}
1728
1729async fn fallback_is_pr_merged_by_branch(
1730 backend: &Arc<dyn ExternalBackend>,
1731 branches: &[String],
1732) -> HashMap<String, bool> {
1733 let futs: Vec<_> = branches
1734 .iter()
1735 .map(|branch| {
1736 let backend = Arc::clone(backend);
1737 let branch = branch.clone();
1738 async move {
1739 match backend.is_pr_merged(&branch).await {
1740 Ok(is_merged) => Some((branch, is_merged)),
1741 Err(err) => {
1742 tracing::warn!(
1743 branch,
1744 err = %err,
1745 "fallback PR merge check failed for branch"
1746 );
1747 None
1748 }
1749 }
1750 }
1751 })
1752 .collect();
1753
1754 future::join_all(futs).await.into_iter().flatten().collect()
1755}
1756
1757#[cfg(test)]
1758mod tests {
1759 use super::*;
1760 use crate::engine::tasks;
1761 use crate::store::{NewTask, TaskStore};
1762 use async_trait::async_trait;
1763 use std::collections::HashMap;
1764 use std::process;
1765 use std::sync::Mutex;
1766 use std::sync::atomic::{AtomicUsize, Ordering};
1767
1768 #[tokio::test]
1771 async fn worktree_age_hours_returns_none_for_missing_dir() {
1772 let tmp = tempfile::tempdir().unwrap();
1773 let missing = tmp.path().join("does-not-exist");
1774 assert!(worktree_age_hours(&missing).await.is_none());
1775 }
1776
1777 #[tokio::test]
1778 async fn worktree_age_hours_returns_some_for_existing_dir() {
1779 let tmp = tempfile::tempdir().unwrap();
1780 let age = worktree_age_hours(tmp.path()).await;
1782 assert!(age.is_some());
1783 assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1784 }
1785
1786 #[test]
1789 fn janitor_options_default_values() {
1790 let opts = JanitorOptions::default();
1791 assert_eq!(opts.ttl_hours, 24);
1792 assert!(!opts.dry_run);
1793 }
1794
1795 fn setup_test_repo() -> Option<(tempfile::TempDir, PathBuf)> {
1799 let tmp = tempfile::tempdir().ok()?;
1800
1801 let repo_dir = tmp.path().join("repo.git");
1803 std_fs::create_dir_all(&repo_dir).ok()?;
1804
1805 let init = process::Command::new("git")
1806 .args([
1807 "init",
1808 "--initial-branch=main",
1809 repo_dir
1810 .to_str()
1811 .expect("test repo path contains non-UTF-8 characters"),
1812 ])
1813 .output()
1814 .ok()?;
1815 if !init.status.success() {
1816 let _ = process::Command::new("git")
1818 .args([
1819 "init",
1820 repo_dir
1821 .to_str()
1822 .expect("test repo path contains non-UTF-8 characters"),
1823 ])
1824 .output()
1825 .ok()?;
1826 }
1827
1828 let readme = repo_dir.join("README.md");
1830 std_fs::write(&readme, "# test").ok()?;
1831 process::Command::new("git")
1832 .args(["-C", repo_dir.to_str()?, "add", "."])
1833 .output()
1834 .ok()?;
1835 process::Command::new("git")
1836 .args([
1837 "-C",
1838 repo_dir.to_str()?,
1839 "-c",
1840 "user.email=test@test.com",
1841 "-c",
1842 "user.name=Test",
1843 "-c",
1844 "commit.gpgsign=false",
1845 "commit",
1846 "--allow-empty",
1847 "-m",
1848 "init",
1849 ])
1850 .output()
1851 .ok()?;
1852
1853 let wt_dir = tmp
1855 .path()
1856 .join("worktrees")
1857 .join("repo.git")
1858 .join("gh-issue-42-test");
1859 std_fs::create_dir_all(wt_dir.parent()?).ok()?;
1860 let wt_out = process::Command::new("git")
1861 .args([
1862 "-C",
1863 repo_dir.to_str()?,
1864 "worktree",
1865 "add",
1866 "-b",
1867 "gh-issue-42-test",
1868 wt_dir.to_str()?,
1869 ])
1870 .output()
1871 .ok()?;
1872
1873 if !wt_out.status.success() {
1874 return None;
1875 }
1876
1877 Some((tmp, wt_dir))
1878 }
1879
1880 #[tokio::test]
1881 async fn janitor_skips_young_worktree() {
1882 let Some((tmp, wt_dir)) = setup_test_repo() else {
1883 eprintln!("skipping test: git not available");
1884 return;
1885 };
1886
1887 assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1889
1890 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1892 assert!(
1893 age < 24,
1894 "freshly created worktree should be younger than 24h (age={age}h)"
1895 );
1896
1897 let opts = JanitorOptions {
1899 ttl_hours: 24,
1900 dry_run: true,
1901 };
1902 assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1904
1905 drop(tmp); }
1907
1908 #[tokio::test]
1909 async fn janitor_eligible_when_ttl_zero() {
1910 let Some((tmp, wt_dir)) = setup_test_repo() else {
1911 eprintln!("skipping test: git not available");
1912 return;
1913 };
1914
1915 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1917 let opts = JanitorOptions {
1918 ttl_hours: 0,
1919 dry_run: true,
1920 };
1921 assert!(
1922 age >= opts.ttl_hours,
1923 "worktree should be eligible when ttl=0 (age={age}h)"
1924 );
1925
1926 drop(tmp);
1927 }
1928
1929 #[tokio::test]
1930 async fn dry_run_does_not_remove_worktree() {
1931 let Some((tmp, wt_dir)) = setup_test_repo() else {
1933 eprintln!("skipping test: git not available");
1934 return;
1935 };
1936
1937 assert!(wt_dir.exists(), "worktree must exist before test");
1938
1939 let opts = JanitorOptions {
1942 ttl_hours: 0,
1943 dry_run: true,
1944 };
1945
1946 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1951 let in_use = false; let skip = age < opts.ttl_hours || in_use;
1953
1954 if !skip && opts.dry_run {
1955 tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1957 } else if !skip {
1958 }
1960
1961 assert!(
1963 wt_dir.exists(),
1964 "dry-run must not remove the worktree directory"
1965 );
1966
1967 drop(tmp);
1968 }
1969
1970 #[test]
1971 fn fallback_branch_path_construction() {
1972 let tmp = tempfile::tempdir().unwrap();
1975 let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1976 std_fs::create_dir_all(&wt_dir).unwrap();
1977
1978 let branch = "gh-issue-7-fix";
1980 let repo = "owner/myrepo";
1981 let worktrees_base = tmp.path();
1982
1983 let project = repo
1984 .rsplit('/')
1985 .next()
1986 .unwrap_or(repo)
1987 .trim_end_matches(".git");
1988 let candidate = worktrees_base.join(project).join(branch);
1989
1990 assert_eq!(candidate, wt_dir);
1991 assert!(
1992 candidate.exists(),
1993 "fallback path should point to existing dir"
1994 );
1995 }
1996
1997 #[tokio::test]
2000 async fn cleanup_returns_false_when_nothing_to_clean() {
2001 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2002
2003 let id = store
2005 .create(&NewTask {
2006 external_id: Some("999".to_string()),
2007 repo: "owner/repo".to_string(),
2008 origin: "github".to_string(),
2009 title: "Already cleaned".to_string(),
2010 ..Default::default()
2011 })
2012 .await
2013 .unwrap();
2014 store.update_status(id, TaskStatus::Done).await.unwrap();
2015
2016 let opts = JanitorOptions {
2017 ttl_hours: 0,
2018 dry_run: false,
2019 };
2020
2021 let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
2022 if let Ok(cleaned) = result {
2026 assert!(
2027 !cleaned,
2028 "cleanup should return false when there is no worktree or branch to clean"
2029 );
2030 } else {
2031 }
2034 }
2035
2036 #[tokio::test]
2040 async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
2041 let tmp = tempfile::tempdir().unwrap();
2042 let stale_wt = tmp.path().join("stale-worktree");
2044 assert!(!stale_wt.exists(), "stale path must not exist");
2045
2046 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2047
2048 let id = store
2049 .create(&NewTask {
2050 external_id: Some("1005".to_string()),
2051 repo: "owner/repo".to_string(),
2052 origin: "github".to_string(),
2053 title: "Stale worktree task".to_string(),
2054 ..Default::default()
2055 })
2056 .await
2057 .unwrap();
2058 store.update_status(id, TaskStatus::Done).await.unwrap();
2059 store
2061 .set_fields(
2062 id,
2063 &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
2064 )
2065 .await
2066 .unwrap();
2067
2068 let before = store.get(id).await.unwrap();
2070 assert!(!before.worktree_cleaned, "task should not be cleaned yet");
2071 assert!(
2072 !before.worktree.is_empty(),
2073 "task must have a worktree path set"
2074 );
2075
2076 let opts = JanitorOptions {
2077 ttl_hours: 0,
2078 dry_run: false,
2079 };
2080
2081 let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
2082
2083 match result {
2086 Ok(_) => {}
2087 Err(e) => panic!("cleanup should not error for stale path: {e}"),
2088 }
2089
2090 let after = store.get(id).await.unwrap();
2092 assert!(
2093 after.worktree_cleaned,
2094 "task must be marked cleaned when stored worktree path is already gone"
2095 );
2096 }
2097
2098 #[tokio::test]
2101 async fn remove_worktree_returns_true_on_success() {
2102 let Some((tmp, wt_dir)) = setup_test_repo() else {
2103 eprintln!("skipping test: git not available");
2104 return;
2105 };
2106
2107 let repo_dir = tmp.path().join("repo.git");
2108 assert!(wt_dir.exists(), "worktree must exist before removal");
2109
2110 let removed =
2111 remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
2112 .await;
2113
2114 assert!(
2115 removed,
2116 "remove_worktree_and_branch should return true when directory is gone"
2117 );
2118 assert!(!wt_dir.exists(), "worktree directory should be removed");
2119 }
2120
2121 #[tokio::test]
2124 async fn remove_worktree_success_still_deletes_local_branch() {
2125 let Some((tmp, wt_dir)) = setup_test_repo() else {
2126 eprintln!("skipping test: git not available");
2127 return;
2128 };
2129
2130 let repo_dir = tmp.path().join("repo.git");
2131 let repo_dir_str = repo_dir
2132 .to_str()
2133 .expect("test repo path contains non-UTF-8 characters");
2134 let branch = "gh-issue-42-test";
2135
2136 let before = process::Command::new("git")
2137 .args([
2138 "-C",
2139 repo_dir_str,
2140 "show-ref",
2141 "--verify",
2142 "--quiet",
2143 &format!("refs/heads/{branch}"),
2144 ])
2145 .status()
2146 .unwrap();
2147 assert!(
2148 before.success(),
2149 "test setup must create local branch before cleanup"
2150 );
2151
2152 let removed =
2153 remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
2154 assert!(removed, "worktree should be removed");
2155
2156 let after = process::Command::new("git")
2157 .args([
2158 "-C",
2159 repo_dir_str,
2160 "show-ref",
2161 "--verify",
2162 "--quiet",
2163 &format!("refs/heads/{branch}"),
2164 ])
2165 .status()
2166 .unwrap();
2167 assert!(
2168 !after.success(),
2169 "local branch should be deleted after successful worktree cleanup"
2170 );
2171 }
2172
2173 #[tokio::test]
2177 async fn remove_worktree_returns_false_when_dir_persists() {
2178 let tmp = tempfile::tempdir().unwrap();
2179 let fake_wt = tmp.path().join("not-a-worktree");
2182 std_fs::create_dir_all(&fake_wt).unwrap();
2183
2184 let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
2187
2188 assert!(
2191 !removed,
2192 "remove_worktree_and_branch should return false when directory still exists"
2193 );
2194 assert!(fake_wt.exists(), "directory should still be on disk");
2195 }
2196
2197 struct MergeCheckMockBackend {
2198 batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
2199 single_results: HashMap<String, anyhow::Result<bool>>,
2200 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
2201 single_checked_branches: Arc<Mutex<Vec<String>>>,
2202 }
2203
2204 impl MergeCheckMockBackend {
2205 fn with_batch_error(
2206 error: anyhow::Error,
2207 single_results: HashMap<String, anyhow::Result<bool>>,
2208 ) -> Self {
2209 Self {
2210 batch_result: Mutex::new(Err(error)),
2211 single_results,
2212 posted_comments: Arc::new(Mutex::new(Vec::new())),
2213 single_checked_branches: Arc::new(Mutex::new(Vec::new())),
2214 }
2215 }
2216 }
2217
2218 #[async_trait]
2219 impl ExternalBackend for MergeCheckMockBackend {
2220 fn name(&self) -> &'static str {
2221 "mock"
2222 }
2223
2224 async fn create_task(
2225 &self,
2226 _title: &str,
2227 _body: &str,
2228 _labels: &[String],
2229 ) -> anyhow::Result<ExternalId> {
2230 anyhow::bail!("not implemented")
2231 }
2232
2233 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2234 anyhow::bail!("not implemented")
2235 }
2236
2237 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2238 Ok(vec![])
2239 }
2240
2241 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2242 Ok(vec![])
2243 }
2244
2245 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2246 self.posted_comments
2247 .lock()
2248 .unwrap()
2249 .push((id.0.clone(), body.to_string()));
2250 Ok(())
2251 }
2252
2253 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2254 Ok(())
2255 }
2256
2257 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2258 Ok(())
2259 }
2260
2261 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2262 Ok(vec![])
2263 }
2264
2265 async fn create_sub_task(
2266 &self,
2267 _parent: &ExternalId,
2268 _title: &str,
2269 _body: &str,
2270 _labels: &[String],
2271 ) -> anyhow::Result<ExternalId> {
2272 anyhow::bail!("not implemented")
2273 }
2274
2275 async fn health_check(&self) -> anyhow::Result<()> {
2276 Ok(())
2277 }
2278
2279 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2280 self.single_checked_branches
2281 .lock()
2282 .unwrap()
2283 .push(branch.to_string());
2284 match self.single_results.get(branch) {
2285 Some(Ok(is_merged)) => Ok(*is_merged),
2286 Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2287 None => Ok(false),
2288 }
2289 }
2290
2291 async fn batch_is_pr_merged(
2292 &self,
2293 _branches: &[String],
2294 ) -> anyhow::Result<HashMap<String, bool>> {
2295 match &*self.batch_result.lock().unwrap() {
2296 Ok(map) => Ok(map.clone()),
2297 Err(err) => Err(anyhow::anyhow!(err.to_string())),
2298 }
2299 }
2300 }
2301
2302 #[tokio::test]
2303 async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2304 let single_results = HashMap::from([
2305 ("branch-1".to_string(), Ok(true)),
2306 ("branch-2".to_string(), Ok(false)),
2307 ]);
2308 let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2309 anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2310 single_results,
2311 ));
2312 let backend_dyn: Arc<dyn ExternalBackend> = Arc::<MergeCheckMockBackend>::clone(&backend);
2313
2314 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2315 let task1 = store
2316 .create(&NewTask {
2317 external_id: Some("101".to_string()),
2318 repo: "owner/repo".to_string(),
2319 origin: "github".to_string(),
2320 title: "Task 101".to_string(),
2321 ..Default::default()
2322 })
2323 .await
2324 .unwrap();
2325 store
2326 .update_status(task1, TaskStatus::NeedsReview)
2327 .await
2328 .unwrap();
2329 store
2330 .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2331 .await
2332 .unwrap();
2333 let wt_dir = tempfile::tempdir().unwrap();
2335 let wt_path = wt_dir.path().join("branch-1");
2336 std_fs::create_dir_all(&wt_path).unwrap();
2337 store
2338 .set_fields(
2339 task1,
2340 &[("worktree", serde_json::json!(wt_path.to_string_lossy()))],
2341 )
2342 .await
2343 .unwrap();
2344
2345 let task2 = store
2346 .create(&NewTask {
2347 external_id: Some("102".to_string()),
2348 repo: "owner/repo".to_string(),
2349 origin: "github".to_string(),
2350 title: "Task 102".to_string(),
2351 ..Default::default()
2352 })
2353 .await
2354 .unwrap();
2355 store
2356 .update_status(task2, TaskStatus::InReview)
2357 .await
2358 .unwrap();
2359 store
2360 .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2361 .await
2362 .unwrap();
2363
2364 let task_manager = Arc::new(TaskManager::with_store(
2365 Arc::clone(&backend_dyn),
2366 Arc::clone(&store),
2367 "owner/repo".to_string(),
2368 ));
2369
2370 let in_review_tasks = store
2371 .list_by_status("owner/repo", TaskStatus::InReview)
2372 .await
2373 .unwrap()
2374 .into_iter()
2375 .map(|stored| ReviewTaskSnapshot {
2376 external: tasks::store_task_to_external(&stored),
2377 stored,
2378 })
2379 .collect::<Vec<_>>();
2380 let needs_review_tasks = store
2381 .list_by_status("owner/repo", TaskStatus::NeedsReview)
2382 .await
2383 .unwrap()
2384 .into_iter()
2385 .map(|stored| ReviewTaskSnapshot {
2386 external: tasks::store_task_to_external(&stored),
2387 stored,
2388 })
2389 .collect::<Vec<_>>();
2390
2391 check_merged_prs(
2392 &backend_dyn,
2393 "owner/repo",
2394 &store,
2395 &task_manager,
2396 &in_review_tasks,
2397 &needs_review_tasks,
2398 )
2399 .await
2400 .unwrap();
2401
2402 let checked = backend.single_checked_branches.lock().unwrap().clone();
2403 assert_eq!(
2404 checked,
2405 vec!["branch-2".to_string(), "branch-1".to_string()]
2406 );
2407
2408 let updated_task1 = store.get(task1).await.unwrap();
2409 let updated_task2 = store.get(task2).await.unwrap();
2410 assert_eq!(updated_task1.status, TaskStatus::Done);
2411 assert!(updated_task1.worktree_cleaned);
2412 assert!(!wt_path.exists());
2413 assert!(!updated_task2.worktree_cleaned);
2414 assert_eq!(updated_task2.status, TaskStatus::InReview);
2415
2416 let comments = backend.posted_comments.lock().unwrap().clone();
2417 assert_eq!(comments.len(), 1);
2418 assert_eq!(comments[0].0, "101");
2419 assert!(comments[0].1.contains("PR merged, marking task complete"));
2420 }
2421
2422 struct ReconciliationMockBackend {
2423 reconciliation_calls: Arc<AtomicUsize>,
2424 }
2425
2426 #[async_trait]
2427 impl ExternalBackend for ReconciliationMockBackend {
2428 fn name(&self) -> &'static str {
2429 "mock"
2430 }
2431
2432 async fn create_task(
2433 &self,
2434 _title: &str,
2435 _body: &str,
2436 _labels: &[String],
2437 ) -> anyhow::Result<ExternalId> {
2438 anyhow::bail!("not implemented")
2439 }
2440
2441 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2442 anyhow::bail!("not implemented")
2443 }
2444
2445 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2446 Ok(vec![])
2447 }
2448
2449 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2450 self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2451 Ok(vec![])
2452 }
2453
2454 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2455 Ok(vec![])
2456 }
2457
2458 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2459 Ok(())
2460 }
2461
2462 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2463 Ok(())
2464 }
2465
2466 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2467 Ok(())
2468 }
2469
2470 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2471 Ok(vec![])
2472 }
2473
2474 async fn create_sub_task(
2475 &self,
2476 _parent: &ExternalId,
2477 _title: &str,
2478 _body: &str,
2479 _labels: &[String],
2480 ) -> anyhow::Result<ExternalId> {
2481 anyhow::bail!("not implemented")
2482 }
2483
2484 async fn health_check(&self) -> anyhow::Result<()> {
2485 Ok(())
2486 }
2487 }
2488
2489 #[tokio::test]
2490 async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2491 let repo = "owner/repo-cleanup-rate-limit";
2492 RECONCILIATION_LAST_RUN.remove(repo);
2493
2494 let backend = Arc::new(ReconciliationMockBackend {
2495 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2496 });
2497 let backend_dyn: Arc<dyn ExternalBackend> =
2498 Arc::<ReconciliationMockBackend>::clone(&backend);
2499 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2500 let task_manager = Arc::new(TaskManager::with_store(
2501 Arc::clone(&backend_dyn),
2502 Arc::clone(&store),
2503 repo.to_string(),
2504 ));
2505 let opts = JanitorOptions {
2506 ttl_hours: 0,
2507 dry_run: true,
2508 };
2509
2510 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2511 .await
2512 .unwrap();
2513 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2514 .await
2515 .unwrap();
2516
2517 assert_eq!(
2518 backend.reconciliation_calls.load(Ordering::SeqCst),
2519 1,
2520 "cleanup should not re-list reconciliation candidates on every sync pass"
2521 );
2522
2523 RECONCILIATION_LAST_RUN.remove(repo);
2524 }
2525
2526 #[tokio::test]
2529 async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2530 let repo = "owner/repo-cleanup-blocked";
2531 RECONCILIATION_LAST_RUN.remove(repo);
2532 let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2533 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2534 });
2535 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2536 let task_manager = Arc::new(TaskManager::with_store(
2537 Arc::clone(&backend_dyn),
2538 Arc::clone(&store),
2539 repo.to_string(),
2540 ));
2541
2542 let mut ids = Vec::new();
2543 for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2544 let id = store
2545 .create_internal(repo, title, "", "test", title, None)
2546 .await
2547 .unwrap();
2548 store
2549 .set_fields(
2550 id,
2551 &[
2552 (
2553 "worktree",
2554 serde_json::json!(format!("/nonexistent/{title}")),
2555 ),
2556 ("branch", serde_json::json!("")),
2557 ],
2558 )
2559 .await
2560 .unwrap();
2561 store.update_status(id, status).await.unwrap();
2562 ids.push(id);
2563 }
2564
2565 let opts = JanitorOptions {
2566 ttl_hours: 0,
2567 dry_run: false,
2568 };
2569 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2570 .await
2571 .unwrap();
2572
2573 assert!(
2574 !store.get(ids[0]).await.unwrap().worktree_cleaned,
2575 "blocked task must not be considered for cleanup"
2576 );
2577 assert!(
2578 store.get(ids[1]).await.unwrap().worktree_cleaned,
2579 "done task is cleaned, which proves the pass ran"
2580 );
2581 RECONCILIATION_LAST_RUN.remove(repo);
2582 }
2583
2584 fn git_ok(dir: &Path, args: &[&str]) -> String {
2585 let out = process::Command::new("git")
2586 .args([
2587 "-c",
2588 "commit.gpgsign=false",
2589 "-c",
2590 "user.name=T",
2591 "-c",
2592 "user.email=t@t",
2593 ])
2594 .args(args)
2595 .current_dir(dir)
2596 .output()
2597 .unwrap();
2598 assert!(
2599 out.status.success(),
2600 "git {args:?}: {}",
2601 String::from_utf8_lossy(&out.stderr)
2602 );
2603 String::from_utf8_lossy(&out.stdout).to_string()
2604 }
2605
2606 fn origin_and_clone_with_feat() -> (tempfile::TempDir, PathBuf) {
2608 let tmp = tempfile::tempdir().unwrap();
2609 let bare = tmp.path().join("origin.git");
2610 std_fs::create_dir_all(&bare).unwrap();
2611 git_ok(&bare, &["init", "--bare", "-b", "main"]);
2612 git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2613 let clone = tmp.path().join("clone");
2614 std_fs::write(clone.join("base.txt"), "base").unwrap();
2615 git_ok(&clone, &["add", "."]);
2616 git_ok(&clone, &["commit", "-m", "base"]);
2617 git_ok(&clone, &["push", "origin", "main"]);
2618 git_ok(&clone, &["checkout", "-b", "feat"]);
2619 std_fs::write(clone.join("work.txt"), "work").unwrap();
2620 git_ok(&clone, &["add", "."]);
2621 git_ok(&clone, &["commit", "-m", "work"]);
2622 git_ok(&clone, &["checkout", "main"]);
2623 (tmp, clone)
2624 }
2625
2626 fn remote_has(clone: &Path, branch: &str) -> bool {
2627 !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2628 .trim()
2629 .is_empty()
2630 }
2631
2632 fn local_has(clone: &Path, branch: &str) -> bool {
2633 process::Command::new("git")
2634 .args([
2635 "show-ref",
2636 "--verify",
2637 "--quiet",
2638 &format!("refs/heads/{branch}"),
2639 ])
2640 .current_dir(clone)
2641 .status()
2642 .unwrap()
2643 .success()
2644 }
2645
2646 #[tokio::test]
2649 async fn delete_branches_keeps_an_unmerged_remote_branch() {
2650 let (_tmp, clone) = origin_and_clone_with_feat();
2651 git_ok(&clone, &["push", "origin", "feat"]);
2652
2653 delete_branches("1", "feat", &clone).await;
2654
2655 assert!(
2656 remote_has(&clone, "feat"),
2657 "unmerged remote branch must survive"
2658 );
2659 assert!(
2660 !local_has(&clone, "feat"),
2661 "local copy is on origin, so it can go"
2662 );
2663 }
2664
2665 #[tokio::test]
2666 async fn delete_branches_removes_a_branch_main_already_contains() {
2667 let (_tmp, clone) = origin_and_clone_with_feat();
2668 git_ok(&clone, &["push", "origin", "feat"]);
2669 git_ok(&clone, &["merge", "--ff-only", "feat"]);
2670 git_ok(&clone, &["push", "origin", "main"]);
2671
2672 delete_branches("1", "feat", &clone).await;
2673
2674 assert!(!remote_has(&clone, "feat"));
2675 assert!(!local_has(&clone, "feat"));
2676 }
2677
2678 fn add_worktree(clone: &Path, tmp: &Path, branch: &str) -> PathBuf {
2679 let wt = tmp.join("wt");
2680 git_ok(clone, &["worktree", "add", wt.to_str().unwrap(), branch]);
2681 wt
2682 }
2683
2684 #[tokio::test]
2685 async fn cleanup_pushes_unpushed_commits_to_a_rescue_branch() {
2686 let (tmp, clone) = origin_and_clone_with_feat();
2687 let wt = add_worktree(&clone, tmp.path(), "feat");
2688
2689 let sha = git_ok(&clone, &["rev-parse", "--short", "feat"]);
2690 let sha = sha.trim();
2691 match rescue_unpushed_work("1", Some(&wt), "feat", &clone).await {
2692 Rescue::Pushed { branch, commits } => {
2693 assert_eq!(branch, "rescue/feat");
2694 assert!(rescue_comment(&branch, &commits).contains(sha));
2695 }
2696 other => panic!("expected a rescue push, got {other:?}"),
2697 }
2698
2699 let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2700
2701 assert!(removed);
2702 assert!(remote_has(&clone, "rescue/feat"));
2703 assert!(!local_has(&clone, "feat"));
2704 }
2705
2706 #[tokio::test]
2707 async fn cleanup_commits_uncommitted_changes_before_the_rescue() {
2708 let (tmp, clone) = origin_and_clone_with_feat();
2709 git_ok(&clone, &["push", "origin", "feat"]);
2710 let wt = add_worktree(&clone, tmp.path(), "feat");
2711 std_fs::write(wt.join("dirty.txt"), "dirty").unwrap();
2712
2713 let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2714
2715 assert!(removed);
2716 assert!(remote_has(&clone, "rescue/feat"));
2717 }
2718
2719 #[tokio::test]
2720 async fn cleanup_makes_no_rescue_branch_for_a_pushed_branch() {
2721 let (tmp, clone) = origin_and_clone_with_feat();
2722 git_ok(&clone, &["push", "origin", "feat"]);
2723 let wt = add_worktree(&clone, tmp.path(), "feat");
2724
2725 let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2726
2727 assert!(removed);
2728 assert!(!remote_has(&clone, "rescue/feat"));
2729 assert!(!local_has(&clone, "feat"));
2730 }
2731
2732 #[tokio::test]
2733 async fn rescue_skips_a_branch_on_the_remote_without_a_tracking_ref() {
2734 let (_tmp, clone) = origin_and_clone_with_feat();
2735 git_ok(&clone, &["push", "origin", "feat"]);
2736 git_ok(&clone, &["update-ref", "-d", "refs/remotes/origin/feat"]);
2737
2738 let rescue = rescue_unpushed_work("1", None, "feat", &clone).await;
2739
2740 assert_eq!(rescue, Rescue::Clean);
2741 assert!(!remote_has(&clone, "rescue/feat"));
2742 }
2743
2744 #[tokio::test]
2745 async fn merged_pr_cleanup_pushes_no_rescue_branch() {
2746 let (tmp, clone) = origin_and_clone_with_feat();
2747 let wt = add_worktree(&clone, tmp.path(), "feat");
2748
2749 let removed =
2750 remove_worktree_and_branch_for("1", &wt, Some("feat"), &clone, false, true).await;
2751
2752 assert!(removed);
2753 assert!(!wt.exists());
2754 assert!(!remote_has(&clone, "rescue/feat"));
2755 }
2756
2757 #[tokio::test]
2758 async fn unmerged_cleanup_still_pushes_a_rescue_branch() {
2759 let (tmp, clone) = origin_and_clone_with_feat();
2760 let wt = add_worktree(&clone, tmp.path(), "feat");
2761
2762 let removed =
2763 remove_worktree_and_branch_for("1", &wt, Some("feat"), &clone, false, false).await;
2764
2765 assert!(removed);
2766 assert!(remote_has(&clone, "rescue/feat"));
2767 }
2768
2769 #[tokio::test]
2770 async fn rescue_ignores_a_remote_tip_missing_locally() {
2771 let (tmp, clone) = origin_and_clone_with_feat();
2772 git_ok(&clone, &["push", "origin", "feat"]);
2773 let other = tmp.path().join("other");
2774 git_ok(
2775 tmp.path(),
2776 &["clone", "-b", "feat", "origin.git", other.to_str().unwrap()],
2777 );
2778 std_fs::write(other.join("extra.txt"), "extra").unwrap();
2779 git_ok(&other, &["add", "."]);
2780 git_ok(&other, &["commit", "-m", "extra"]);
2781 git_ok(&other, &["push", "origin", "feat"]);
2782
2783 let rescue = rescue_unpushed_work("1", None, "feat", &clone).await;
2784
2785 assert_ne!(rescue, Rescue::Failed);
2786 }
2787
2788 #[tokio::test]
2789 async fn cleanup_keeps_worktree_and_branch_when_the_rescue_push_fails() {
2790 let (tmp, clone) = origin_and_clone_with_feat();
2791 let wt = add_worktree(&clone, tmp.path(), "feat");
2792 git_ok(
2793 &clone,
2794 &["remote", "set-url", "origin", "/nonexistent/origin.git"],
2795 );
2796
2797 let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2798
2799 assert!(!removed);
2800 assert!(wt.exists());
2801 assert!(local_has(&clone, "feat"));
2802 }
2803
2804 #[tokio::test]
2805 async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2806 let (_tmp, clone) = origin_and_clone_with_feat();
2807
2808 delete_branches("1", "feat", &clone).await;
2809
2810 assert!(
2811 local_has(&clone, "feat"),
2812 "unpushed commits must not be deleted"
2813 );
2814 }
2815}