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 mut args = vec!["rev-list", "--oneline", &local_ref, "--not", "--remotes"];
926 if let Some(tip) = remote_tip.as_deref() {
927 args.push(tip);
928 }
929 let default_exists = default_ref != local_ref
930 && git_in(
931 repo_root,
932 &["rev-parse", "--verify", "--quiet", &default_ref],
933 )
934 .await
935 .is_some_and(|o| o.status.success());
936 if default_exists {
937 args.push(&default_ref);
938 }
939 let Some(list) = git_in(repo_root, &args)
940 .await
941 .filter(|o| o.status.success())
942 else {
943 tracing::warn!(task_id, branch = %br, "cannot list unpushed commits, keeping worktree and branch");
944 return Rescue::Failed;
945 };
946 let commits: Vec<String> = String::from_utf8_lossy(&list.stdout)
947 .lines()
948 .map(str::to_string)
949 .collect();
950 if commits.is_empty() {
951 return Rescue::Clean;
952 }
953
954 let rescue = format!("rescue/{br}");
955 let refspec = format!("{local_ref}:refs/heads/{rescue}");
956 let pushed = git_in(
957 repo_root,
958 &["-c", "lfs.locksverify=false", "push", "origin", &refspec],
959 )
960 .await;
961 if pushed.is_some_and(|o| o.status.success()) {
962 tracing::warn!(task_id, branch = %br, rescue = %rescue, commits = commits.len(), "pushed unpushed commits to rescue branch");
963 Rescue::Pushed {
964 branch: rescue,
965 commits,
966 }
967 } else {
968 tracing::warn!(task_id, branch = %br, rescue = %rescue, "rescue push failed, keeping worktree and branch");
969 Rescue::Failed
970 }
971}
972
973fn rescue_comment(rescue_branch: &str, commits: &[String]) -> String {
974 let list = commits
975 .iter()
976 .map(|c| format!("- `{c}`"))
977 .collect::<Vec<_>>()
978 .join("\n");
979 format!(
980 "Cleanup found commits on this task's branch that were never pushed. They are saved on `{rescue_branch}`:\n\n{list}"
981 )
982}
983
984async fn rescue_and_notify(
986 task_id: &str,
987 repo: &str,
988 external_id: Option<&str>,
989 wt: Option<&Path>,
990 br: &str,
991 repo_root: &Path,
992) -> bool {
993 match rescue_unpushed_work(task_id, wt, br, repo_root).await {
994 Rescue::Clean => true,
995 Rescue::Failed => false,
996 Rescue::Pushed { branch, commits } => {
997 if let Some(number) = external_id.filter(|n| n.parse::<u64>().is_ok()) {
998 match GhHttp::new() {
999 Ok(gh) => {
1000 if let Err(e) = gh
1001 .add_comment(repo, number, &rescue_comment(&branch, &commits))
1002 .await
1003 {
1004 tracing::warn!(task_id, err = %e, "failed to comment on rescue branch");
1005 }
1006 }
1007 Err(e) => {
1008 tracing::warn!(task_id, err = %e, "cannot comment on rescue branch");
1009 }
1010 }
1011 }
1012 true
1013 }
1014 }
1015}
1016
1017pub(crate) async fn remove_worktree_and_branch(
1024 task_id: &str,
1025 wt: &Path,
1026 branch: Option<&str>,
1027 repo_root: &Path,
1028 keep_remote_branch: bool,
1029) -> bool {
1030 if let Some(br) = branch.filter(|b| !b.is_empty())
1031 && rescue_unpushed_work(task_id, Some(wt), br, repo_root).await == Rescue::Failed
1032 {
1033 return false;
1034 }
1035
1036 remove_worktree_and_branch_unchecked(task_id, wt, branch, repo_root, keep_remote_branch).await
1037}
1038
1039async fn remove_worktree_and_branch_unchecked(
1041 task_id: &str,
1042 wt: &Path,
1043 branch: Option<&str>,
1044 repo_root: &Path,
1045 keep_remote_branch: bool,
1046) -> bool {
1047 let wt_str = wt.to_string_lossy().to_string();
1048 let repo_root_str = repo_root.to_string_lossy();
1049
1050 let remove_result = Command::new("git")
1053 .args([
1054 "-C",
1055 repo_root_str.as_ref(),
1056 "worktree",
1057 "remove",
1058 &wt_str,
1059 "--force",
1060 ])
1061 .output_with_context()
1062 .await;
1063
1064 match remove_result {
1065 Ok(output) if output.status.success() => {
1066 const MAX_RETRIES: usize = 3;
1067 const RETRY_DELAY_MS: u64 = 2000;
1068 tracing::info!(task_id, "worktree removed (git metadata)");
1069 let mut removed = false;
1074 for attempt in 0..MAX_RETRIES {
1075 match fs::remove_dir_all(wt).await {
1076 Ok(()) => {
1077 removed = true;
1078 break;
1079 }
1080 Err(e) if e.kind() == ErrorKind::NotFound => {
1081 removed = true;
1083 break;
1084 }
1085 Err(e) if e.kind() == ErrorKind::DirectoryNotEmpty => {
1086 if attempt < MAX_RETRIES - 1 {
1087 tracing::debug!(
1088 task_id,
1089 path = %wt.display(),
1090 attempt = attempt + 1,
1091 "worktree directory not empty — retrying after delay"
1092 );
1093 sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
1094 } else {
1095 tracing::warn!(
1096 task_id,
1097 path = %wt.display(),
1098 err = %e,
1099 "failed to remove worktree directory after {} attempts (Directory not empty)",
1100 MAX_RETRIES
1101 );
1102 }
1103 }
1104 Err(e) => {
1105 tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
1106 break;
1107 }
1108 }
1109 }
1110 if removed {
1111 tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
1112 } else {
1113 tracing::warn!(
1114 task_id,
1115 path = %wt.display(),
1116 "worktree directory still present after git worktree remove"
1117 );
1118 }
1119 }
1120 Ok(output) => {
1121 let stderr = String::from_utf8_lossy(&output.stderr);
1122 if stderr.contains("is not a working tree") {
1128 tracing::debug!(
1129 task_id,
1130 path = %wt.display(),
1131 "path is not a registered worktree (stale metadata) — removing directory and pruning"
1132 );
1133 if let Err(e) = fs::remove_dir_all(wt).await {
1134 if match fs::try_exists(wt).await {
1136 Ok(true) => true,
1137 Ok(false) => false,
1138 Err(e) => {
1139 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
1140 false
1141 }
1142 } {
1143 tracing::warn!(
1144 task_id,
1145 path = %wt.display(),
1146 err = %e,
1147 "failed to remove stale worktree directory"
1148 );
1149 }
1150 } else {
1151 tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
1152 }
1153 if let Err(e) = Command::new("git")
1156 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
1157 .output_with_context()
1158 .await
1159 {
1160 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
1161 }
1162 } else {
1163 tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
1164 }
1165 }
1166 Err(e) => {
1167 tracing::warn!(task_id, err = %e, "failed to remove worktree");
1168 }
1169 }
1170
1171 if let Err(e) = Command::new("git")
1173 .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
1174 .output_with_context()
1175 .await
1176 {
1177 tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
1178 }
1179
1180 if let Some(br) = branch {
1184 if keep_remote_branch {
1185 delete_local_branch(task_id, br, repo_root).await;
1186 } else {
1187 delete_branches(task_id, br, repo_root).await;
1188 }
1189 }
1190
1191 match fs::try_exists(wt).await {
1193 Ok(false) => true, Ok(true) => false, Err(e) => {
1196 tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
1197 false }
1199 }
1200}
1201
1202async fn commits_outside(repo_root: &Path, rev: &str, others: &[&str]) -> Option<u64> {
1204 let mut args = vec![
1205 "rev-list".to_string(),
1206 "--count".to_string(),
1207 rev.to_string(),
1208 ];
1209 for other in others {
1210 let exists = Command::new("git")
1211 .args([
1212 "-C",
1213 &repo_root.to_string_lossy(),
1214 "rev-parse",
1215 "--verify",
1216 "--quiet",
1217 other,
1218 ])
1219 .output_with_context()
1220 .await
1221 .is_ok_and(|o| o.status.success());
1222 if exists {
1223 args.push("--not".to_string());
1224 args.push(other.to_string());
1225 }
1226 }
1227 let output = Command::new("git")
1228 .arg("-C")
1229 .arg(repo_root)
1230 .args(&args)
1231 .output_with_context()
1232 .await
1233 .ok()?;
1234 if !output.status.success() {
1235 return None;
1236 }
1237 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
1238}
1239
1240async fn delete_local_branch(task_id: &str, br: &str, repo_root: &Path) {
1242 let default_branch = worktree::detect_default_branch(repo_root).await;
1243 let remote_ref = format!("refs/remotes/origin/{br}");
1244 let default_ref = format!("refs/remotes/origin/{default_branch}");
1245 let local_ref = format!("refs/heads/{br}");
1246 let local_default_ref = format!("refs/heads/{default_branch}");
1247 let rescue_ref = format!("refs/remotes/origin/rescue/{br}");
1248 match commits_outside(
1249 repo_root,
1250 &local_ref,
1251 &[&remote_ref, &default_ref, &local_default_ref, &rescue_ref],
1252 )
1253 .await
1254 {
1255 Some(0) => {}
1256 Some(n) => {
1257 tracing::warn!(task_id, branch = %br, unpushed = n, "local branch holds commits origin does not have, keeping it");
1258 return;
1259 }
1260 None => {
1261 tracing::debug!(task_id, branch = %br, "local branch missing or unreadable, nothing to delete");
1262 return;
1263 }
1264 }
1265 let output = Command::new("git")
1266 .args(["-C", &repo_root.to_string_lossy(), "branch", "-D", br])
1267 .output_with_context()
1268 .await;
1269 match output {
1270 Ok(o) if o.status.success() => {
1271 tracing::info!(task_id, branch = %br, "local branch deleted");
1272 }
1273 Ok(o) => {
1274 let stderr = String::from_utf8_lossy(&o.stderr);
1275 tracing::debug!(task_id, err = %stderr, "local branch delete skipped (may not exist)");
1276 }
1277 Err(e) => {
1278 tracing::warn!(task_id, err = %e, "failed to delete local branch");
1279 }
1280 }
1281}
1282
1283pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &Path) {
1291 let default_branch = worktree::detect_default_branch(repo_root).await;
1292 let _ = git_ops::git()
1293 .await
1294 .args([
1295 "-C",
1296 &repo_root.to_string_lossy(),
1297 "fetch",
1298 "origin",
1299 &default_branch,
1300 br,
1301 ])
1302 .output_with_context()
1303 .await;
1304
1305 delete_local_branch(task_id, br, repo_root).await;
1306
1307 let remote_ref = format!("refs/remotes/origin/{br}");
1308 let default_ref = format!("refs/remotes/origin/{default_branch}");
1309 match commits_outside(repo_root, &remote_ref, &[&default_ref]).await {
1310 Some(0) => {}
1311 Some(n) => {
1312 tracing::info!(task_id, branch = %br, unmerged = n, "remote branch has commits the default branch lacks, keeping it");
1313 return;
1314 }
1315 None => {
1316 tracing::debug!(task_id, branch = %br, "remote branch missing or unreadable, nothing to delete");
1317 return;
1318 }
1319 }
1320
1321 let remote_delete = git_ops::git()
1322 .await
1323 .args([
1324 "-C",
1325 &repo_root.to_string_lossy(),
1326 "-c",
1327 "lfs.locksverify=false",
1328 "push",
1329 "origin",
1330 "--delete",
1331 br,
1332 ])
1333 .output_with_context()
1334 .await;
1335
1336 match remote_delete {
1337 Ok(output) if output.status.success() => {
1338 tracing::info!(task_id, branch = %br, "remote branch deleted");
1339 git_ops::clear_push_lease(repo_root, br).await;
1340 }
1341 Ok(output) => {
1342 let stderr = String::from_utf8_lossy(&output.stderr);
1343 tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1344 }
1345 Err(e) => {
1346 tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1347 }
1348 }
1349}
1350
1351async fn is_worktree_in_active_session(worktree: &Path) -> bool {
1357 let output = Command::new("tmux")
1358 .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1359 .output()
1360 .await;
1361
1362 match output {
1363 Ok(o) if o.status.success() => {
1364 let worktree_str = worktree.to_string_lossy();
1365 String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1366 let pane_path = line.trim();
1367 pane_path == worktree_str.as_ref()
1369 || pane_path.starts_with(&format!("{worktree_str}/"))
1370 })
1371 }
1372 _ => false,
1374 }
1375}
1376
1377async fn worktree_age_hours(worktree: &Path) -> Option<u64> {
1381 let metadata = fs::metadata(worktree).await.ok()?;
1383 let modified = metadata.modified().ok()?;
1384 let age = SystemTime::now().duration_since(modified).ok()?;
1385 Some(age.as_secs() / 3600)
1386}
1387
1388pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &Path) -> bool {
1394 let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1395 Ok(root) => root,
1396 Err(e) => {
1397 tracing::warn!(
1398 worktree = %wt.display(),
1399 error = %e,
1400 "cannot resolve repo root for orphaned worktree"
1401 );
1402 return false;
1403 }
1404 };
1405 remove_worktree_and_branch(task_id, wt, None, Path::new(&repo_root), false).await
1406}
1407
1408pub(crate) async fn resolve_repo_root_for_orphaned_worktree(wt: &Path) -> anyhow::Result<String> {
1415 let git_file = wt.join(".git");
1416 if fs::try_exists(&git_file).await.unwrap_or(false)
1417 && let Ok(content) = fs::read_to_string(&git_file).await
1418 {
1419 for line in content.lines() {
1420 if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1421 let gitdir_path = PathBuf::from(gitdir);
1425 if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1426 return Ok(repo_root.display().to_string());
1427 }
1428 }
1429 }
1430 }
1431
1432 let worktrees_dir = home::worktrees_dir()?;
1435 if let Ok(relative) = wt.strip_prefix(&worktrees_dir)
1436 && let Some(project_name) = relative.components().next()
1437 {
1438 let project_name = project_name.as_os_str().to_string_lossy();
1439
1440 if let Ok(paths) = config::get_project_paths() {
1441 for path_str in &paths {
1442 let path = Path::new(path_str);
1443 if let Some(name) = path.file_name()
1444 && name.to_string_lossy() == project_name
1445 {
1446 return Ok(path_str.clone());
1447 }
1448 }
1449 }
1450
1451 let parts: Vec<&str> = project_name.split('/').collect();
1453 let bare = if parts.len() == 2 {
1454 home::projects_dir()
1455 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1456 .unwrap_or_default()
1457 } else {
1458 return Err(anyhow::anyhow!(
1459 "cannot determine owner for single-segment project name '{project_name}' — \
1460 bare repo location is ambiguous; register the project with 'owner/repo' format"
1461 ));
1462 };
1463 if fs::try_exists(&bare).await.unwrap_or(false) {
1464 return Ok(bare.display().to_string());
1465 }
1466 }
1467
1468 anyhow::bail!(
1469 "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1470 wt.display()
1471 )
1472}
1473
1474pub async fn resolve_repo_root_from_worktree(wt: &Path) -> anyhow::Result<String> {
1481 let git_file = wt.join(".git");
1483 if !fs::try_exists(&git_file).await.unwrap_or(false) {
1484 anyhow::bail!(".git file not found in worktree at {}", wt.display());
1485 }
1486
1487 let content = fs::read_to_string(&git_file)
1488 .await
1489 .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1490
1491 for line in content.lines() {
1493 if let Some(gitdir) = line.strip_prefix("gitdir:") {
1494 let gitdir = gitdir.trim();
1495 let gitdir_path = Path::new(gitdir);
1499 if let Some(repo_root) = gitdir_path
1500 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent())
1503 {
1505 return Ok(repo_root.display().to_string());
1506 }
1507 }
1508 }
1509
1510 anyhow::bail!(
1511 "cannot resolve repo root from worktree .git file at {}",
1512 wt.display()
1513 )
1514}
1515
1516pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1521 let paths = config::get_project_paths().unwrap_or_default();
1523 for path_str in &paths {
1524 let path = Path::new(path_str);
1525 let orch_yml = path.join(".orch.yml");
1526 let legacy = path.join(".orchestrator.yml");
1527 let config_file = if fs::try_exists(&orch_yml).await.unwrap_or(false) {
1528 orch_yml
1529 } else if fs::try_exists(&legacy).await.unwrap_or(false) {
1530 legacy
1531 } else {
1532 continue;
1533 };
1534 if let Ok(content) = fs::read_to_string(&config_file).await
1535 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1536 && let Some(r) = doc
1537 .get("gh")
1538 .and_then(|gh| gh.get("repo"))
1539 .and_then(|r| r.as_str())
1540 && r == repo
1541 {
1542 return Ok(path_str.clone());
1543 }
1544 }
1545
1546 let parts: Vec<&str> = repo.split('/').collect();
1548 let bare = if parts.len() == 2 {
1549 home::projects_dir()
1550 .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1551 .unwrap_or_default()
1552 } else {
1553 PathBuf::new()
1554 };
1555 if fs::try_exists(&bare).await.unwrap_or(false) {
1556 return Ok(bare.display().to_string());
1557 }
1558
1559 anyhow::bail!(
1560 "cannot find local path for project {repo} — \
1561 checked {} registered project(s) and bare clone at {}",
1562 paths.len(),
1563 bare.display()
1564 )
1565}
1566
1567fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<PathBuf> {
1572 if let Ok(dir) = env::var("PROJECT_DIR")
1574 && !dir.is_empty()
1575 {
1576 return Ok(PathBuf::from(dir));
1577 }
1578
1579 if let Ok(paths) = config::get_project_paths() {
1581 for path_str in &paths {
1582 let path = Path::new(path_str);
1583 let orch_yml = path.join(".orch.yml");
1584 let legacy = path.join(".orchestrator.yml");
1585 let config_file = if Path::new(&orch_yml).exists() {
1586 orch_yml
1587 } else if Path::new(&legacy).exists() {
1588 legacy
1589 } else {
1590 continue;
1591 };
1592 if let Ok(content) = std_fs::read_to_string(&config_file)
1593 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
1594 && let Some(r) = doc
1595 .get("gh")
1596 .and_then(|gh| gh.get("repo"))
1597 .and_then(|r| r.as_str())
1598 && r == repo
1599 {
1600 return Ok(path_str.clone().into());
1601 }
1602 }
1603 }
1604
1605 anyhow::bail!("project dir not found for repo: {repo}")
1606}
1607
1608pub(crate) async fn check_merged_prs(
1613 backend: &Arc<dyn ExternalBackend>,
1614 repo: &str,
1615 store: &Arc<TaskStore>,
1616 task_manager: &Arc<TaskManager>,
1617 in_review_tasks: &[ReviewTaskSnapshot],
1618 needs_review_tasks: &[ReviewTaskSnapshot],
1619) -> anyhow::Result<()> {
1620 let mut branch_map: HashMap<String, String> = HashMap::new();
1621 let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1622 .iter()
1623 .chain(needs_review_tasks.iter())
1624 .collect();
1625
1626 for task in &all_review_tasks {
1627 if !task.stored.branch.is_empty() {
1628 branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1629 }
1630 }
1631
1632 tracing::debug!(
1633 count = all_review_tasks.len(),
1634 "checking review tasks for merged PRs"
1635 );
1636
1637 let mut task_branches: Vec<(String, String)> = Vec::new();
1641 for task in all_review_tasks {
1642 let task_id = &task.external.id.0;
1643 let branch = if let Some(b) = branch_map.get(task_id) {
1644 b.clone()
1645 } else {
1646 match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1647 .await
1648 .map(|t| t.branch)
1649 {
1650 Some(b) if !b.is_empty() => b,
1651 _ => {
1652 tracing::debug!(task_id, "no branch info, skipping PR check");
1653 continue;
1654 }
1655 }
1656 };
1657 task_branches.push((task_id.clone(), branch));
1658 }
1659
1660 if task_branches.is_empty() {
1661 return Ok(());
1662 }
1663
1664 let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1666 let merged_map = match backend.batch_is_pr_merged(&branches).await {
1667 Ok(map) => map,
1668 Err(e) => {
1669 tracing::warn!(
1670 err = %e,
1671 branch_count = branches.len(),
1672 "batch PR merge check failed, retrying affected branches individually"
1673 );
1674 fallback_is_pr_merged_by_branch(backend, &branches).await
1675 }
1676 };
1677
1678 for (task_id, branch) in task_branches {
1679 let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1680 if !is_merged {
1681 continue;
1682 }
1683
1684 tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1685
1686 let id = ExternalId(task_id.clone());
1687 if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1688 tracing::warn!(task_id, err = %e, "failed to update status to done");
1689 continue;
1690 }
1691
1692 if let Err(e) = cleanup_task_worktree(&task_id, repo, store).await {
1693 tracing::warn!(task_id, err = %e, "worktree cleanup after merge failed");
1694 }
1695
1696 let comment = format!("PR merged, marking task complete{}", engine::orch_footer());
1697 if let Err(e) = backend.post_comment(&id, &comment).await {
1698 tracing::warn!(task_id, err = %e, "failed to post comment");
1699 }
1700 }
1701
1702 Ok(())
1703}
1704
1705async fn fallback_is_pr_merged_by_branch(
1706 backend: &Arc<dyn ExternalBackend>,
1707 branches: &[String],
1708) -> HashMap<String, bool> {
1709 let futs: Vec<_> = branches
1710 .iter()
1711 .map(|branch| {
1712 let backend = Arc::clone(backend);
1713 let branch = branch.clone();
1714 async move {
1715 match backend.is_pr_merged(&branch).await {
1716 Ok(is_merged) => Some((branch, is_merged)),
1717 Err(err) => {
1718 tracing::warn!(
1719 branch,
1720 err = %err,
1721 "fallback PR merge check failed for branch"
1722 );
1723 None
1724 }
1725 }
1726 }
1727 })
1728 .collect();
1729
1730 future::join_all(futs).await.into_iter().flatten().collect()
1731}
1732
1733#[cfg(test)]
1734mod tests {
1735 use super::*;
1736 use crate::engine::tasks;
1737 use crate::store::{NewTask, TaskStore};
1738 use async_trait::async_trait;
1739 use std::collections::HashMap;
1740 use std::process;
1741 use std::sync::Mutex;
1742 use std::sync::atomic::{AtomicUsize, Ordering};
1743
1744 #[tokio::test]
1747 async fn worktree_age_hours_returns_none_for_missing_dir() {
1748 let tmp = tempfile::tempdir().unwrap();
1749 let missing = tmp.path().join("does-not-exist");
1750 assert!(worktree_age_hours(&missing).await.is_none());
1751 }
1752
1753 #[tokio::test]
1754 async fn worktree_age_hours_returns_some_for_existing_dir() {
1755 let tmp = tempfile::tempdir().unwrap();
1756 let age = worktree_age_hours(tmp.path()).await;
1758 assert!(age.is_some());
1759 assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1760 }
1761
1762 #[test]
1765 fn janitor_options_default_values() {
1766 let opts = JanitorOptions::default();
1767 assert_eq!(opts.ttl_hours, 24);
1768 assert!(!opts.dry_run);
1769 }
1770
1771 fn setup_test_repo() -> Option<(tempfile::TempDir, PathBuf)> {
1775 let tmp = tempfile::tempdir().ok()?;
1776
1777 let repo_dir = tmp.path().join("repo.git");
1779 std_fs::create_dir_all(&repo_dir).ok()?;
1780
1781 let init = process::Command::new("git")
1782 .args([
1783 "init",
1784 "--initial-branch=main",
1785 repo_dir
1786 .to_str()
1787 .expect("test repo path contains non-UTF-8 characters"),
1788 ])
1789 .output()
1790 .ok()?;
1791 if !init.status.success() {
1792 let _ = process::Command::new("git")
1794 .args([
1795 "init",
1796 repo_dir
1797 .to_str()
1798 .expect("test repo path contains non-UTF-8 characters"),
1799 ])
1800 .output()
1801 .ok()?;
1802 }
1803
1804 let readme = repo_dir.join("README.md");
1806 std_fs::write(&readme, "# test").ok()?;
1807 process::Command::new("git")
1808 .args(["-C", repo_dir.to_str()?, "add", "."])
1809 .output()
1810 .ok()?;
1811 process::Command::new("git")
1812 .args([
1813 "-C",
1814 repo_dir.to_str()?,
1815 "-c",
1816 "user.email=test@test.com",
1817 "-c",
1818 "user.name=Test",
1819 "-c",
1820 "commit.gpgsign=false",
1821 "commit",
1822 "--allow-empty",
1823 "-m",
1824 "init",
1825 ])
1826 .output()
1827 .ok()?;
1828
1829 let wt_dir = tmp
1831 .path()
1832 .join("worktrees")
1833 .join("repo.git")
1834 .join("gh-issue-42-test");
1835 std_fs::create_dir_all(wt_dir.parent()?).ok()?;
1836 let wt_out = process::Command::new("git")
1837 .args([
1838 "-C",
1839 repo_dir.to_str()?,
1840 "worktree",
1841 "add",
1842 "-b",
1843 "gh-issue-42-test",
1844 wt_dir.to_str()?,
1845 ])
1846 .output()
1847 .ok()?;
1848
1849 if !wt_out.status.success() {
1850 return None;
1851 }
1852
1853 Some((tmp, wt_dir))
1854 }
1855
1856 #[tokio::test]
1857 async fn janitor_skips_young_worktree() {
1858 let Some((tmp, wt_dir)) = setup_test_repo() else {
1859 eprintln!("skipping test: git not available");
1860 return;
1861 };
1862
1863 assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1865
1866 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1868 assert!(
1869 age < 24,
1870 "freshly created worktree should be younger than 24h (age={age}h)"
1871 );
1872
1873 let opts = JanitorOptions {
1875 ttl_hours: 24,
1876 dry_run: true,
1877 };
1878 assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1880
1881 drop(tmp); }
1883
1884 #[tokio::test]
1885 async fn janitor_eligible_when_ttl_zero() {
1886 let Some((tmp, wt_dir)) = setup_test_repo() else {
1887 eprintln!("skipping test: git not available");
1888 return;
1889 };
1890
1891 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1893 let opts = JanitorOptions {
1894 ttl_hours: 0,
1895 dry_run: true,
1896 };
1897 assert!(
1898 age >= opts.ttl_hours,
1899 "worktree should be eligible when ttl=0 (age={age}h)"
1900 );
1901
1902 drop(tmp);
1903 }
1904
1905 #[tokio::test]
1906 async fn dry_run_does_not_remove_worktree() {
1907 let Some((tmp, wt_dir)) = setup_test_repo() else {
1909 eprintln!("skipping test: git not available");
1910 return;
1911 };
1912
1913 assert!(wt_dir.exists(), "worktree must exist before test");
1914
1915 let opts = JanitorOptions {
1918 ttl_hours: 0,
1919 dry_run: true,
1920 };
1921
1922 let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1927 let in_use = false; let skip = age < opts.ttl_hours || in_use;
1929
1930 if !skip && opts.dry_run {
1931 tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1933 } else if !skip {
1934 }
1936
1937 assert!(
1939 wt_dir.exists(),
1940 "dry-run must not remove the worktree directory"
1941 );
1942
1943 drop(tmp);
1944 }
1945
1946 #[test]
1947 fn fallback_branch_path_construction() {
1948 let tmp = tempfile::tempdir().unwrap();
1951 let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1952 std_fs::create_dir_all(&wt_dir).unwrap();
1953
1954 let branch = "gh-issue-7-fix";
1956 let repo = "owner/myrepo";
1957 let worktrees_base = tmp.path();
1958
1959 let project = repo
1960 .rsplit('/')
1961 .next()
1962 .unwrap_or(repo)
1963 .trim_end_matches(".git");
1964 let candidate = worktrees_base.join(project).join(branch);
1965
1966 assert_eq!(candidate, wt_dir);
1967 assert!(
1968 candidate.exists(),
1969 "fallback path should point to existing dir"
1970 );
1971 }
1972
1973 #[tokio::test]
1976 async fn cleanup_returns_false_when_nothing_to_clean() {
1977 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1978
1979 let id = store
1981 .create(&NewTask {
1982 external_id: Some("999".to_string()),
1983 repo: "owner/repo".to_string(),
1984 origin: "github".to_string(),
1985 title: "Already cleaned".to_string(),
1986 ..Default::default()
1987 })
1988 .await
1989 .unwrap();
1990 store.update_status(id, TaskStatus::Done).await.unwrap();
1991
1992 let opts = JanitorOptions {
1993 ttl_hours: 0,
1994 dry_run: false,
1995 };
1996
1997 let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
1998 if let Ok(cleaned) = result {
2002 assert!(
2003 !cleaned,
2004 "cleanup should return false when there is no worktree or branch to clean"
2005 );
2006 } else {
2007 }
2010 }
2011
2012 #[tokio::test]
2016 async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
2017 let tmp = tempfile::tempdir().unwrap();
2018 let stale_wt = tmp.path().join("stale-worktree");
2020 assert!(!stale_wt.exists(), "stale path must not exist");
2021
2022 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2023
2024 let id = store
2025 .create(&NewTask {
2026 external_id: Some("1005".to_string()),
2027 repo: "owner/repo".to_string(),
2028 origin: "github".to_string(),
2029 title: "Stale worktree task".to_string(),
2030 ..Default::default()
2031 })
2032 .await
2033 .unwrap();
2034 store.update_status(id, TaskStatus::Done).await.unwrap();
2035 store
2037 .set_fields(
2038 id,
2039 &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
2040 )
2041 .await
2042 .unwrap();
2043
2044 let before = store.get(id).await.unwrap();
2046 assert!(!before.worktree_cleaned, "task should not be cleaned yet");
2047 assert!(
2048 !before.worktree.is_empty(),
2049 "task must have a worktree path set"
2050 );
2051
2052 let opts = JanitorOptions {
2053 ttl_hours: 0,
2054 dry_run: false,
2055 };
2056
2057 let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
2058
2059 match result {
2062 Ok(_) => {}
2063 Err(e) => panic!("cleanup should not error for stale path: {e}"),
2064 }
2065
2066 let after = store.get(id).await.unwrap();
2068 assert!(
2069 after.worktree_cleaned,
2070 "task must be marked cleaned when stored worktree path is already gone"
2071 );
2072 }
2073
2074 #[tokio::test]
2077 async fn remove_worktree_returns_true_on_success() {
2078 let Some((tmp, wt_dir)) = setup_test_repo() else {
2079 eprintln!("skipping test: git not available");
2080 return;
2081 };
2082
2083 let repo_dir = tmp.path().join("repo.git");
2084 assert!(wt_dir.exists(), "worktree must exist before removal");
2085
2086 let removed =
2087 remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
2088 .await;
2089
2090 assert!(
2091 removed,
2092 "remove_worktree_and_branch should return true when directory is gone"
2093 );
2094 assert!(!wt_dir.exists(), "worktree directory should be removed");
2095 }
2096
2097 #[tokio::test]
2100 async fn remove_worktree_success_still_deletes_local_branch() {
2101 let Some((tmp, wt_dir)) = setup_test_repo() else {
2102 eprintln!("skipping test: git not available");
2103 return;
2104 };
2105
2106 let repo_dir = tmp.path().join("repo.git");
2107 let repo_dir_str = repo_dir
2108 .to_str()
2109 .expect("test repo path contains non-UTF-8 characters");
2110 let branch = "gh-issue-42-test";
2111
2112 let before = process::Command::new("git")
2113 .args([
2114 "-C",
2115 repo_dir_str,
2116 "show-ref",
2117 "--verify",
2118 "--quiet",
2119 &format!("refs/heads/{branch}"),
2120 ])
2121 .status()
2122 .unwrap();
2123 assert!(
2124 before.success(),
2125 "test setup must create local branch before cleanup"
2126 );
2127
2128 let removed =
2129 remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
2130 assert!(removed, "worktree should be removed");
2131
2132 let after = process::Command::new("git")
2133 .args([
2134 "-C",
2135 repo_dir_str,
2136 "show-ref",
2137 "--verify",
2138 "--quiet",
2139 &format!("refs/heads/{branch}"),
2140 ])
2141 .status()
2142 .unwrap();
2143 assert!(
2144 !after.success(),
2145 "local branch should be deleted after successful worktree cleanup"
2146 );
2147 }
2148
2149 #[tokio::test]
2153 async fn remove_worktree_returns_false_when_dir_persists() {
2154 let tmp = tempfile::tempdir().unwrap();
2155 let fake_wt = tmp.path().join("not-a-worktree");
2158 std_fs::create_dir_all(&fake_wt).unwrap();
2159
2160 let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
2163
2164 assert!(
2167 !removed,
2168 "remove_worktree_and_branch should return false when directory still exists"
2169 );
2170 assert!(fake_wt.exists(), "directory should still be on disk");
2171 }
2172
2173 struct MergeCheckMockBackend {
2174 batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
2175 single_results: HashMap<String, anyhow::Result<bool>>,
2176 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
2177 single_checked_branches: Arc<Mutex<Vec<String>>>,
2178 }
2179
2180 impl MergeCheckMockBackend {
2181 fn with_batch_error(
2182 error: anyhow::Error,
2183 single_results: HashMap<String, anyhow::Result<bool>>,
2184 ) -> Self {
2185 Self {
2186 batch_result: Mutex::new(Err(error)),
2187 single_results,
2188 posted_comments: Arc::new(Mutex::new(Vec::new())),
2189 single_checked_branches: Arc::new(Mutex::new(Vec::new())),
2190 }
2191 }
2192 }
2193
2194 #[async_trait]
2195 impl ExternalBackend for MergeCheckMockBackend {
2196 fn name(&self) -> &'static str {
2197 "mock"
2198 }
2199
2200 async fn create_task(
2201 &self,
2202 _title: &str,
2203 _body: &str,
2204 _labels: &[String],
2205 ) -> anyhow::Result<ExternalId> {
2206 anyhow::bail!("not implemented")
2207 }
2208
2209 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2210 anyhow::bail!("not implemented")
2211 }
2212
2213 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2214 Ok(vec![])
2215 }
2216
2217 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2218 Ok(vec![])
2219 }
2220
2221 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2222 self.posted_comments
2223 .lock()
2224 .unwrap()
2225 .push((id.0.clone(), body.to_string()));
2226 Ok(())
2227 }
2228
2229 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2230 Ok(())
2231 }
2232
2233 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2234 Ok(())
2235 }
2236
2237 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2238 Ok(vec![])
2239 }
2240
2241 async fn create_sub_task(
2242 &self,
2243 _parent: &ExternalId,
2244 _title: &str,
2245 _body: &str,
2246 _labels: &[String],
2247 ) -> anyhow::Result<ExternalId> {
2248 anyhow::bail!("not implemented")
2249 }
2250
2251 async fn health_check(&self) -> anyhow::Result<()> {
2252 Ok(())
2253 }
2254
2255 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
2256 self.single_checked_branches
2257 .lock()
2258 .unwrap()
2259 .push(branch.to_string());
2260 match self.single_results.get(branch) {
2261 Some(Ok(is_merged)) => Ok(*is_merged),
2262 Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
2263 None => Ok(false),
2264 }
2265 }
2266
2267 async fn batch_is_pr_merged(
2268 &self,
2269 _branches: &[String],
2270 ) -> anyhow::Result<HashMap<String, bool>> {
2271 match &*self.batch_result.lock().unwrap() {
2272 Ok(map) => Ok(map.clone()),
2273 Err(err) => Err(anyhow::anyhow!(err.to_string())),
2274 }
2275 }
2276 }
2277
2278 #[tokio::test]
2279 async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
2280 let single_results = HashMap::from([
2281 ("branch-1".to_string(), Ok(true)),
2282 ("branch-2".to_string(), Ok(false)),
2283 ]);
2284 let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
2285 anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
2286 single_results,
2287 ));
2288 let backend_dyn: Arc<dyn ExternalBackend> = Arc::<MergeCheckMockBackend>::clone(&backend);
2289
2290 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2291 let task1 = store
2292 .create(&NewTask {
2293 external_id: Some("101".to_string()),
2294 repo: "owner/repo".to_string(),
2295 origin: "github".to_string(),
2296 title: "Task 101".to_string(),
2297 ..Default::default()
2298 })
2299 .await
2300 .unwrap();
2301 store
2302 .update_status(task1, TaskStatus::NeedsReview)
2303 .await
2304 .unwrap();
2305 store
2306 .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2307 .await
2308 .unwrap();
2309 let wt_dir = tempfile::tempdir().unwrap();
2311 let wt_path = wt_dir.path().join("branch-1");
2312 std_fs::create_dir_all(&wt_path).unwrap();
2313 store
2314 .set_fields(
2315 task1,
2316 &[("worktree", serde_json::json!(wt_path.to_string_lossy()))],
2317 )
2318 .await
2319 .unwrap();
2320
2321 let task2 = store
2322 .create(&NewTask {
2323 external_id: Some("102".to_string()),
2324 repo: "owner/repo".to_string(),
2325 origin: "github".to_string(),
2326 title: "Task 102".to_string(),
2327 ..Default::default()
2328 })
2329 .await
2330 .unwrap();
2331 store
2332 .update_status(task2, TaskStatus::InReview)
2333 .await
2334 .unwrap();
2335 store
2336 .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2337 .await
2338 .unwrap();
2339
2340 let task_manager = Arc::new(TaskManager::with_store(
2341 Arc::clone(&backend_dyn),
2342 Arc::clone(&store),
2343 "owner/repo".to_string(),
2344 ));
2345
2346 let in_review_tasks = store
2347 .list_by_status("owner/repo", TaskStatus::InReview)
2348 .await
2349 .unwrap()
2350 .into_iter()
2351 .map(|stored| ReviewTaskSnapshot {
2352 external: tasks::store_task_to_external(&stored),
2353 stored,
2354 })
2355 .collect::<Vec<_>>();
2356 let needs_review_tasks = store
2357 .list_by_status("owner/repo", TaskStatus::NeedsReview)
2358 .await
2359 .unwrap()
2360 .into_iter()
2361 .map(|stored| ReviewTaskSnapshot {
2362 external: tasks::store_task_to_external(&stored),
2363 stored,
2364 })
2365 .collect::<Vec<_>>();
2366
2367 check_merged_prs(
2368 &backend_dyn,
2369 "owner/repo",
2370 &store,
2371 &task_manager,
2372 &in_review_tasks,
2373 &needs_review_tasks,
2374 )
2375 .await
2376 .unwrap();
2377
2378 let checked = backend.single_checked_branches.lock().unwrap().clone();
2379 assert_eq!(
2380 checked,
2381 vec!["branch-2".to_string(), "branch-1".to_string()]
2382 );
2383
2384 let updated_task1 = store.get(task1).await.unwrap();
2385 let updated_task2 = store.get(task2).await.unwrap();
2386 assert_eq!(updated_task1.status, TaskStatus::Done);
2387 assert!(updated_task1.worktree_cleaned);
2388 assert!(!wt_path.exists());
2389 assert!(!updated_task2.worktree_cleaned);
2390 assert_eq!(updated_task2.status, TaskStatus::InReview);
2391
2392 let comments = backend.posted_comments.lock().unwrap().clone();
2393 assert_eq!(comments.len(), 1);
2394 assert_eq!(comments[0].0, "101");
2395 assert!(comments[0].1.contains("PR merged, marking task complete"));
2396 }
2397
2398 struct ReconciliationMockBackend {
2399 reconciliation_calls: Arc<AtomicUsize>,
2400 }
2401
2402 #[async_trait]
2403 impl ExternalBackend for ReconciliationMockBackend {
2404 fn name(&self) -> &'static str {
2405 "mock"
2406 }
2407
2408 async fn create_task(
2409 &self,
2410 _title: &str,
2411 _body: &str,
2412 _labels: &[String],
2413 ) -> anyhow::Result<ExternalId> {
2414 anyhow::bail!("not implemented")
2415 }
2416
2417 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2418 anyhow::bail!("not implemented")
2419 }
2420
2421 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2422 Ok(vec![])
2423 }
2424
2425 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2426 self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2427 Ok(vec![])
2428 }
2429
2430 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2431 Ok(vec![])
2432 }
2433
2434 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2435 Ok(())
2436 }
2437
2438 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2439 Ok(())
2440 }
2441
2442 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2443 Ok(())
2444 }
2445
2446 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2447 Ok(vec![])
2448 }
2449
2450 async fn create_sub_task(
2451 &self,
2452 _parent: &ExternalId,
2453 _title: &str,
2454 _body: &str,
2455 _labels: &[String],
2456 ) -> anyhow::Result<ExternalId> {
2457 anyhow::bail!("not implemented")
2458 }
2459
2460 async fn health_check(&self) -> anyhow::Result<()> {
2461 Ok(())
2462 }
2463 }
2464
2465 #[tokio::test]
2466 async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2467 let repo = "owner/repo-cleanup-rate-limit";
2468 RECONCILIATION_LAST_RUN.remove(repo);
2469
2470 let backend = Arc::new(ReconciliationMockBackend {
2471 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2472 });
2473 let backend_dyn: Arc<dyn ExternalBackend> =
2474 Arc::<ReconciliationMockBackend>::clone(&backend);
2475 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2476 let task_manager = Arc::new(TaskManager::with_store(
2477 Arc::clone(&backend_dyn),
2478 Arc::clone(&store),
2479 repo.to_string(),
2480 ));
2481 let opts = JanitorOptions {
2482 ttl_hours: 0,
2483 dry_run: true,
2484 };
2485
2486 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2487 .await
2488 .unwrap();
2489 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2490 .await
2491 .unwrap();
2492
2493 assert_eq!(
2494 backend.reconciliation_calls.load(Ordering::SeqCst),
2495 1,
2496 "cleanup should not re-list reconciliation candidates on every sync pass"
2497 );
2498
2499 RECONCILIATION_LAST_RUN.remove(repo);
2500 }
2501
2502 #[tokio::test]
2505 async fn cleanup_skips_blocked_internal_tasks_and_cleans_done_ones() {
2506 let repo = "owner/repo-cleanup-blocked";
2507 RECONCILIATION_LAST_RUN.remove(repo);
2508 let backend_dyn: Arc<dyn ExternalBackend> = Arc::new(ReconciliationMockBackend {
2509 reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2510 });
2511 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2512 let task_manager = Arc::new(TaskManager::with_store(
2513 Arc::clone(&backend_dyn),
2514 Arc::clone(&store),
2515 repo.to_string(),
2516 ));
2517
2518 let mut ids = Vec::new();
2519 for (title, status) in [("blocked", TaskStatus::Blocked), ("done", TaskStatus::Done)] {
2520 let id = store
2521 .create_internal(repo, title, "", "test", title, None)
2522 .await
2523 .unwrap();
2524 store
2525 .set_fields(
2526 id,
2527 &[
2528 (
2529 "worktree",
2530 serde_json::json!(format!("/nonexistent/{title}")),
2531 ),
2532 ("branch", serde_json::json!("")),
2533 ],
2534 )
2535 .await
2536 .unwrap();
2537 store.update_status(id, status).await.unwrap();
2538 ids.push(id);
2539 }
2540
2541 let opts = JanitorOptions {
2542 ttl_hours: 0,
2543 dry_run: false,
2544 };
2545 cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2546 .await
2547 .unwrap();
2548
2549 assert!(
2550 !store.get(ids[0]).await.unwrap().worktree_cleaned,
2551 "blocked task must not be considered for cleanup"
2552 );
2553 assert!(
2554 store.get(ids[1]).await.unwrap().worktree_cleaned,
2555 "done task is cleaned, which proves the pass ran"
2556 );
2557 RECONCILIATION_LAST_RUN.remove(repo);
2558 }
2559
2560 fn git_ok(dir: &Path, args: &[&str]) -> String {
2561 let out = process::Command::new("git")
2562 .args([
2563 "-c",
2564 "commit.gpgsign=false",
2565 "-c",
2566 "user.name=T",
2567 "-c",
2568 "user.email=t@t",
2569 ])
2570 .args(args)
2571 .current_dir(dir)
2572 .output()
2573 .unwrap();
2574 assert!(
2575 out.status.success(),
2576 "git {args:?}: {}",
2577 String::from_utf8_lossy(&out.stderr)
2578 );
2579 String::from_utf8_lossy(&out.stdout).to_string()
2580 }
2581
2582 fn origin_and_clone_with_feat() -> (tempfile::TempDir, PathBuf) {
2584 let tmp = tempfile::tempdir().unwrap();
2585 let bare = tmp.path().join("origin.git");
2586 std_fs::create_dir_all(&bare).unwrap();
2587 git_ok(&bare, &["init", "--bare", "-b", "main"]);
2588 git_ok(tmp.path(), &["clone", bare.to_str().unwrap(), "clone"]);
2589 let clone = tmp.path().join("clone");
2590 std_fs::write(clone.join("base.txt"), "base").unwrap();
2591 git_ok(&clone, &["add", "."]);
2592 git_ok(&clone, &["commit", "-m", "base"]);
2593 git_ok(&clone, &["push", "origin", "main"]);
2594 git_ok(&clone, &["checkout", "-b", "feat"]);
2595 std_fs::write(clone.join("work.txt"), "work").unwrap();
2596 git_ok(&clone, &["add", "."]);
2597 git_ok(&clone, &["commit", "-m", "work"]);
2598 git_ok(&clone, &["checkout", "main"]);
2599 (tmp, clone)
2600 }
2601
2602 fn remote_has(clone: &Path, branch: &str) -> bool {
2603 !git_ok(clone, &["ls-remote", "--heads", "origin", branch])
2604 .trim()
2605 .is_empty()
2606 }
2607
2608 fn local_has(clone: &Path, branch: &str) -> bool {
2609 process::Command::new("git")
2610 .args([
2611 "show-ref",
2612 "--verify",
2613 "--quiet",
2614 &format!("refs/heads/{branch}"),
2615 ])
2616 .current_dir(clone)
2617 .status()
2618 .unwrap()
2619 .success()
2620 }
2621
2622 #[tokio::test]
2625 async fn delete_branches_keeps_an_unmerged_remote_branch() {
2626 let (_tmp, clone) = origin_and_clone_with_feat();
2627 git_ok(&clone, &["push", "origin", "feat"]);
2628
2629 delete_branches("1", "feat", &clone).await;
2630
2631 assert!(
2632 remote_has(&clone, "feat"),
2633 "unmerged remote branch must survive"
2634 );
2635 assert!(
2636 !local_has(&clone, "feat"),
2637 "local copy is on origin, so it can go"
2638 );
2639 }
2640
2641 #[tokio::test]
2642 async fn delete_branches_removes_a_branch_main_already_contains() {
2643 let (_tmp, clone) = origin_and_clone_with_feat();
2644 git_ok(&clone, &["push", "origin", "feat"]);
2645 git_ok(&clone, &["merge", "--ff-only", "feat"]);
2646 git_ok(&clone, &["push", "origin", "main"]);
2647
2648 delete_branches("1", "feat", &clone).await;
2649
2650 assert!(!remote_has(&clone, "feat"));
2651 assert!(!local_has(&clone, "feat"));
2652 }
2653
2654 fn add_worktree(clone: &Path, tmp: &Path, branch: &str) -> PathBuf {
2655 let wt = tmp.join("wt");
2656 git_ok(clone, &["worktree", "add", wt.to_str().unwrap(), branch]);
2657 wt
2658 }
2659
2660 #[tokio::test]
2661 async fn cleanup_pushes_unpushed_commits_to_a_rescue_branch() {
2662 let (tmp, clone) = origin_and_clone_with_feat();
2663 let wt = add_worktree(&clone, tmp.path(), "feat");
2664
2665 let sha = git_ok(&clone, &["rev-parse", "--short", "feat"]);
2666 let sha = sha.trim();
2667 match rescue_unpushed_work("1", Some(&wt), "feat", &clone).await {
2668 Rescue::Pushed { branch, commits } => {
2669 assert_eq!(branch, "rescue/feat");
2670 assert!(rescue_comment(&branch, &commits).contains(sha));
2671 }
2672 other => panic!("expected a rescue push, got {other:?}"),
2673 }
2674
2675 let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2676
2677 assert!(removed);
2678 assert!(remote_has(&clone, "rescue/feat"));
2679 assert!(!local_has(&clone, "feat"));
2680 }
2681
2682 #[tokio::test]
2683 async fn cleanup_commits_uncommitted_changes_before_the_rescue() {
2684 let (tmp, clone) = origin_and_clone_with_feat();
2685 git_ok(&clone, &["push", "origin", "feat"]);
2686 let wt = add_worktree(&clone, tmp.path(), "feat");
2687 std_fs::write(wt.join("dirty.txt"), "dirty").unwrap();
2688
2689 let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2690
2691 assert!(removed);
2692 assert!(remote_has(&clone, "rescue/feat"));
2693 }
2694
2695 #[tokio::test]
2696 async fn cleanup_makes_no_rescue_branch_for_a_pushed_branch() {
2697 let (tmp, clone) = origin_and_clone_with_feat();
2698 git_ok(&clone, &["push", "origin", "feat"]);
2699 let wt = add_worktree(&clone, tmp.path(), "feat");
2700
2701 let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2702
2703 assert!(removed);
2704 assert!(!remote_has(&clone, "rescue/feat"));
2705 assert!(!local_has(&clone, "feat"));
2706 }
2707
2708 #[tokio::test]
2709 async fn rescue_skips_a_branch_on_the_remote_without_a_tracking_ref() {
2710 let (_tmp, clone) = origin_and_clone_with_feat();
2711 git_ok(&clone, &["push", "origin", "feat"]);
2712 git_ok(&clone, &["update-ref", "-d", "refs/remotes/origin/feat"]);
2713
2714 let rescue = rescue_unpushed_work("1", None, "feat", &clone).await;
2715
2716 assert_eq!(rescue, Rescue::Clean);
2717 assert!(!remote_has(&clone, "rescue/feat"));
2718 }
2719
2720 #[tokio::test]
2721 async fn cleanup_keeps_worktree_and_branch_when_the_rescue_push_fails() {
2722 let (tmp, clone) = origin_and_clone_with_feat();
2723 let wt = add_worktree(&clone, tmp.path(), "feat");
2724 git_ok(
2725 &clone,
2726 &["remote", "set-url", "origin", "/nonexistent/origin.git"],
2727 );
2728
2729 let removed = remove_worktree_and_branch("1", &wt, Some("feat"), &clone, false).await;
2730
2731 assert!(!removed);
2732 assert!(wt.exists());
2733 assert!(local_has(&clone, "feat"));
2734 }
2735
2736 #[tokio::test]
2737 async fn delete_branches_keeps_a_local_branch_with_unpushed_commits() {
2738 let (_tmp, clone) = origin_and_clone_with_feat();
2739
2740 delete_branches("1", "feat", &clone).await;
2741
2742 assert!(
2743 local_has(&clone, "feat"),
2744 "unpushed commits must not be deleted"
2745 );
2746 }
2747}