1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use std::process::{Command, Output, Stdio};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, Result, anyhow, bail};
7use serde::Deserialize;
8
9use crate::keep_store::KeepStore;
10use crate::pr_cache::{CachedPullRequestRecord, PrCache, PrCacheRemoteEntry};
11
12const FIELD_SEPARATOR: char = '\u{1f}';
13const PR_CACHE_TTL_SECONDS: i64 = 10 * 60;
14
15pub const DEFAULT_PREVIEW_GROUPS: [CleanupMode; 6] = [
16 CleanupMode::Gone,
17 CleanupMode::Unpushed,
18 CleanupMode::Pr,
19 CleanupMode::NoPr,
20 CleanupMode::Closed,
21 CleanupMode::Merged,
22];
23
24pub const DEFAULT_CLEAN_GROUPS: [CleanupMode; 5] = [
25 CleanupMode::Gone,
26 CleanupMode::Unpushed,
27 CleanupMode::NoPr,
28 CleanupMode::Closed,
29 CleanupMode::Merged,
30];
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ScanIntent {
34 Preview,
35 Clean,
36}
37
38#[derive(Debug, Clone, Copy)]
39pub struct ScanOptions<'a> {
40 pub modes: &'a [CleanupMode],
41 pub remote: &'a str,
42 pub intent: ScanIntent,
43}
44
45impl<'a> ScanOptions<'a> {
46 pub fn preview(modes: &'a [CleanupMode], remote: &'a str) -> Self {
47 Self {
48 modes,
49 remote,
50 intent: ScanIntent::Preview,
51 }
52 }
53
54 pub fn clean(modes: &'a [CleanupMode], remote: &'a str) -> Self {
55 Self {
56 modes,
57 remote,
58 intent: ScanIntent::Clean,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default)]
64pub struct ScanOutcome {
65 pub groups: Vec<CleanupGroup>,
66 pub notes: Vec<String>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum CleanupMode {
71 Gone,
72 Unpushed,
73 Pr,
74 NoPr,
75 Closed,
76 Merged,
77}
78
79impl CleanupMode {
80 pub fn from_arg(value: &str) -> Option<Self> {
81 match value {
82 "gone" => Some(Self::Gone),
83 "unpushed" => Some(Self::Unpushed),
84 "pr" => Some(Self::Pr),
85 "nopr" => Some(Self::NoPr),
86 "closed" => Some(Self::Closed),
87 "merged" => Some(Self::Merged),
88 _ => None,
89 }
90 }
91
92 pub fn key(self) -> &'static str {
93 match self {
94 Self::Gone => "gone",
95 Self::Unpushed => "unpushed",
96 Self::Pr => "pr",
97 Self::NoPr => "nopr",
98 Self::Closed => "closed",
99 Self::Merged => "merged",
100 }
101 }
102
103 pub fn display_name(self) -> &'static str {
104 match self {
105 Self::Gone => "gone",
106 Self::Unpushed => "unpushed",
107 Self::Pr => "PR",
108 Self::NoPr => "No PR",
109 Self::Closed => "closed",
110 Self::Merged => "merged",
111 }
112 }
113
114 pub fn description(self) -> &'static str {
115 match self {
116 Self::Gone => "upstream branch no longer exists",
117 Self::Unpushed => "no upstream tracking branch is configured",
118 Self::Pr => "open pull request on GitHub",
119 Self::NoPr => "no pull request found on GitHub",
120 Self::Closed => "pull request closed on GitHub",
121 Self::Merged => "pull request merged but remote branch still exists",
122 }
123 }
124
125 pub fn no_matches_message(self) -> &'static str {
126 match self {
127 Self::Gone => "No gone branches found.",
128 Self::Unpushed => "No unpushed branches found.",
129 Self::Pr => "No open PR branches found.",
130 Self::NoPr => "No branches without PRs found.",
131 Self::Closed => "No closed branches found.",
132 Self::Merged => "No merged branches found.",
133 }
134 }
135
136 pub fn uses_pr_metadata(self) -> bool {
137 matches!(self, Self::Pr | Self::NoPr | Self::Closed | Self::Merged)
138 }
139
140 pub fn is_cleanable(self) -> bool {
141 !matches!(self, Self::Pr)
142 }
143
144 fn matches(self, branch: &Branch) -> bool {
145 match self {
146 Self::Gone => branch.upstream_track.contains("[gone]"),
147 Self::Unpushed => branch.upstream.is_none(),
148 Self::Pr | Self::NoPr | Self::Closed | Self::Merged => false,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum Decision {
155 Undecided,
156 Delete,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
160pub enum BranchSection {
161 Protected,
162 Saved,
163 Regular,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum Protection {
168 Current,
169 Worktree,
170 Main,
171 Master,
172 DefaultBranch,
173}
174
175impl Protection {
176 pub fn label(self) -> &'static str {
177 match self {
178 Self::Current => "current branch",
179 Self::Worktree => "worktree",
180 Self::Main => "main",
181 Self::Master => "master",
182 Self::DefaultBranch => "default branch",
183 }
184 }
185
186 pub fn badge_label(self) -> &'static str {
187 match self {
188 Self::Current => "current",
189 Self::Worktree => "worktree",
190 Self::Main => "main",
191 Self::Master => "master",
192 Self::DefaultBranch => "default",
193 }
194 }
195
196 pub fn ineligible_message(self) -> &'static str {
197 match self {
198 Self::Current => "Current branch is ineligible for cleanup.",
199 Self::Worktree => {
200 "This branch is checked out in another worktree and is ineligible for cleanup."
201 }
202 Self::Main => "The main branch is ineligible for cleanup.",
203 Self::Master => "The master branch is ineligible for cleanup.",
204 Self::DefaultBranch => "The default branch is ineligible for cleanup.",
205 }
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct Branch {
211 pub name: String,
212 pub upstream: Option<String>,
213 pub upstream_track: String,
214 pub committed_at: i64,
215 pub relative_date: String,
216 pub subject: String,
217 pub pr_url: Option<String>,
218 pub detail: Option<String>,
219 pub saved: bool,
220 pub protections: Vec<Protection>,
221 pub decision: Decision,
222}
223
224impl Branch {
225 pub fn is_protected(&self) -> bool {
226 !self.protections.is_empty()
227 }
228
229 pub fn is_deletable(&self) -> bool {
230 self.section() == BranchSection::Regular
231 }
232
233 pub fn section(&self) -> BranchSection {
234 if self.is_protected() {
235 BranchSection::Protected
236 } else if self.saved {
237 BranchSection::Saved
238 } else {
239 BranchSection::Regular
240 }
241 }
242
243 pub fn display_name(&self) -> String {
244 let mut labels = self
245 .protections
246 .iter()
247 .map(|protection| format!("[{}]", protection.badge_label()))
248 .collect::<Vec<_>>();
249 if self.saved {
250 labels.push(String::from("(saved)"));
251 }
252
253 if labels.is_empty() {
254 return self.name.clone();
255 }
256
257 format!("{} {}", self.name, labels.join(" "))
258 }
259
260 pub fn upstream_remote(&self) -> Option<&str> {
261 self.upstream
262 .as_deref()
263 .and_then(|upstream| upstream.split_once('/').map(|(remote, _)| remote))
264 }
265
266 pub fn upstream_branch_name(&self) -> Option<&str> {
267 self.upstream
268 .as_deref()
269 .and_then(|upstream| upstream.split_once('/').map(|(_, branch)| branch))
270 }
271}
272
273#[derive(Debug, Clone)]
274pub struct CleanupGroup {
275 pub mode: CleanupMode,
276 pub name: String,
277 pub description: String,
278 pub show_empty_message: bool,
279 pub branches: Vec<Branch>,
280}
281
282impl CleanupGroup {
283 pub fn from_mode(mode: CleanupMode, branches: Vec<Branch>) -> Self {
284 Self {
285 mode,
286 name: mode.display_name().to_string(),
287 description: mode.description().to_string(),
288 show_empty_message: true,
289 branches,
290 }
291 }
292
293 pub fn named(
294 mode: CleanupMode,
295 name: impl Into<String>,
296 description: impl Into<String>,
297 branches: Vec<Branch>,
298 ) -> Self {
299 Self {
300 mode,
301 name: name.into(),
302 description: description.into(),
303 show_empty_message: false,
304 branches,
305 }
306 }
307}
308
309#[derive(Debug, Clone)]
310pub struct App {
311 pub mode: CleanupMode,
312 pub remote: String,
313 pub group_name: String,
314 pub group_description: String,
315 pub step_index: usize,
316 pub step_count: usize,
317 pub branches: Vec<Branch>,
318 pub selected: usize,
319 pub screen: AppScreen,
320 pub modal: Option<Modal>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct Modal {
325 pub title: &'static str,
326 pub message: String,
327}
328
329#[derive(Debug, Clone)]
330pub enum AppScreen {
331 Triage,
332 Review(ReviewState),
333 Executing(ExecutionState),
334}
335
336#[derive(Debug, Clone)]
337pub struct ReviewState {
338 pub items: Vec<CommandPlanItem>,
339 pub require_explicit_choice: bool,
340}
341
342#[derive(Debug, Clone)]
343pub struct ExecutionState {
344 pub items: Vec<CommandPlanItem>,
345 pub failure: Option<ExecutionFailure>,
346 pub running_index: Option<usize>,
347 pub spinner_frame: usize,
348}
349
350#[derive(Debug, Clone)]
351pub struct CommandPlanItem {
352 pub branch: Branch,
353 pub remote_command: Option<String>,
354 pub local_command: String,
355 pub state: CommandLineState,
356}
357
358#[derive(Debug, Clone)]
359pub struct ExecutionFailure {
360 pub branch: String,
361 pub command: String,
362 pub output: String,
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub enum CommandLineState {
367 Pending,
368 Success,
369 Failed,
370 Skipped,
371}
372
373impl App {
374 pub fn from_group(
375 group: CleanupGroup,
376 remote: impl Into<String>,
377 step_index: usize,
378 step_count: usize,
379 ) -> Self {
380 let selected = initial_selection(&group.branches);
381 Self {
382 mode: group.mode,
383 remote: remote.into(),
384 group_name: group.name,
385 group_description: group.description,
386 step_index,
387 step_count,
388 branches: group.branches,
389 selected,
390 screen: AppScreen::Triage,
391 modal: None,
392 }
393 }
394
395 pub fn is_empty(&self) -> bool {
396 self.branches.is_empty()
397 }
398
399 pub fn next(&mut self) {
400 if self.branches.is_empty() {
401 return;
402 }
403
404 self.selected = (self.selected + 1) % self.branches.len();
405 }
406
407 pub fn previous(&mut self) {
408 if self.branches.is_empty() {
409 return;
410 }
411
412 if self.selected == 0 {
413 self.selected = self.branches.len() - 1;
414 } else {
415 self.selected -= 1;
416 }
417 }
418
419 pub fn toggle_delete(&mut self) {
420 let Some(branch) = self.branches.get_mut(self.selected) else {
421 return;
422 };
423
424 if let Some(protection) = branch.protections.first().copied() {
425 self.modal = Some(Modal {
426 title: "Branch Ineligible",
427 message: format!(
428 "{} Press Enter to return to branch triage.",
429 protection.ineligible_message()
430 ),
431 });
432 return;
433 }
434 if branch.saved {
435 self.modal = Some(Modal {
436 title: "Branch Saved",
437 message: String::from(
438 "Saved branches must be unsaved before deletion. Press s to remove the saved label, then press Enter to return to branch triage.",
439 ),
440 });
441 return;
442 }
443
444 branch.decision = match branch.decision {
445 Decision::Undecided => Decision::Delete,
446 Decision::Delete => Decision::Undecided,
447 };
448 }
449
450 pub fn toggle_save(&mut self) {
451 let old_selected = self.selected;
452 let Some(selected_name) = self
453 .branches
454 .get(self.selected)
455 .map(|branch| branch.name.clone())
456 else {
457 return;
458 };
459 let Some(branch) = self.branches.get_mut(self.selected) else {
460 return;
461 };
462 let was_saved = branch.saved;
463
464 branch.saved = !branch.saved;
465 branch.decision = Decision::Undecided;
466
467 reorder_branches(&mut self.branches);
468 if was_saved {
469 if let Some(index) = self
470 .branches
471 .iter()
472 .position(|branch| branch.name == selected_name)
473 {
474 self.selected = index;
475 } else {
476 self.selected = initial_selection(&self.branches);
477 }
478 return;
479 }
480
481 if let Some(index) = first_regular_from(&self.branches, old_selected)
482 .or_else(|| first_regular_from(&self.branches, 0))
483 {
484 self.selected = index;
485 } else {
486 self.selected = self
487 .branches
488 .iter()
489 .position(|branch| branch.name == selected_name)
490 .unwrap_or_else(|| initial_selection(&self.branches));
491 }
492 }
493
494 pub fn mark_all_delete(&mut self) {
495 for branch in &mut self.branches {
496 if branch.is_deletable() {
497 branch.decision = Decision::Delete;
498 }
499 }
500 }
501
502 pub fn unmark_all(&mut self) {
503 for branch in &mut self.branches {
504 if branch.is_deletable() {
505 branch.decision = Decision::Undecided;
506 }
507 }
508 }
509
510 pub fn delete_candidates(&self) -> Vec<&Branch> {
511 self.branches
512 .iter()
513 .filter(|branch| branch.decision == Decision::Delete)
514 .collect()
515 }
516
517 pub fn deletable_branches(&self) -> Vec<&Branch> {
518 self.branches
519 .iter()
520 .filter(|branch| branch.is_deletable())
521 .collect()
522 }
523
524 pub fn delete_count(&self) -> usize {
525 self.delete_candidates().len()
526 }
527
528 pub fn saved_branch_names(&self) -> Vec<String> {
529 self.branches
530 .iter()
531 .filter(|branch| branch.saved)
532 .map(|branch| branch.name.clone())
533 .collect()
534 }
535
536 pub fn dismiss_modal(&mut self) {
537 self.modal = None;
538 }
539
540 pub fn in_triage(&self) -> bool {
541 matches!(self.screen, AppScreen::Triage)
542 }
543
544 pub fn review_items(&self) -> Option<&[CommandPlanItem]> {
545 match &self.screen {
546 AppScreen::Review(review) => Some(&review.items),
547 _ => None,
548 }
549 }
550
551 pub fn review_requires_explicit_choice(&self) -> bool {
552 match &self.screen {
553 AppScreen::Review(review) => review.require_explicit_choice,
554 _ => false,
555 }
556 }
557
558 pub fn execution_items(&self) -> Option<&[CommandPlanItem]> {
559 match &self.screen {
560 AppScreen::Executing(execution) => Some(&execution.items),
561 _ => None,
562 }
563 }
564
565 pub fn execution_failure(&self) -> Option<&ExecutionFailure> {
566 match &self.screen {
567 AppScreen::Executing(execution) => execution.failure.as_ref(),
568 _ => None,
569 }
570 }
571
572 pub fn execution_running_index(&self) -> Option<usize> {
573 match &self.screen {
574 AppScreen::Executing(execution) => execution.running_index,
575 _ => None,
576 }
577 }
578
579 pub fn execution_spinner_frame(&self) -> usize {
580 match &self.screen {
581 AppScreen::Executing(execution) => execution.spinner_frame,
582 _ => 0,
583 }
584 }
585
586 pub fn enter_review(&mut self) -> bool {
587 let items = self
588 .delete_candidates()
589 .into_iter()
590 .map(|branch| CommandPlanItem::new(self.mode, &self.remote, branch))
591 .collect::<Vec<_>>();
592
593 if items.is_empty() {
594 return false;
595 }
596
597 self.screen = AppScreen::Review(ReviewState {
598 items,
599 require_explicit_choice: false,
600 });
601 true
602 }
603
604 pub fn exit_review(&mut self) {
605 self.screen = AppScreen::Triage;
606 }
607
608 pub fn require_review_confirmation(&mut self) {
609 if let AppScreen::Review(review) = &mut self.screen {
610 review.require_explicit_choice = true;
611 }
612 }
613
614 pub fn begin_execution(&mut self) {
615 let items = match &self.screen {
616 AppScreen::Review(review) => review.items.clone(),
617 _ => return,
618 };
619 self.screen = AppScreen::Executing(ExecutionState {
620 items,
621 failure: None,
622 running_index: None,
623 spinner_frame: 0,
624 });
625 }
626
627 pub fn next_pending_execution_index(&self) -> Option<usize> {
628 match &self.screen {
629 AppScreen::Executing(execution) if execution.failure.is_none() => execution
630 .items
631 .iter()
632 .position(|item| item.state == CommandLineState::Pending),
633 _ => None,
634 }
635 }
636
637 pub fn execution_branch(&self, index: usize) -> Option<&Branch> {
638 match &self.screen {
639 AppScreen::Executing(execution) => execution.items.get(index).map(|item| &item.branch),
640 _ => None,
641 }
642 }
643
644 pub fn mark_execution_result(&mut self, index: usize, success: bool) {
645 if let AppScreen::Executing(execution) = &mut self.screen
646 && let Some(item) = execution.items.get_mut(index)
647 {
648 execution.running_index = None;
649 item.state = if success {
650 CommandLineState::Success
651 } else {
652 CommandLineState::Failed
653 };
654 }
655 }
656
657 pub fn mark_execution_skipped_from(&mut self, start: usize) {
658 if let AppScreen::Executing(execution) = &mut self.screen {
659 for item in execution.items.iter_mut().skip(start) {
660 if item.state == CommandLineState::Pending {
661 item.state = CommandLineState::Skipped;
662 }
663 }
664 }
665 }
666
667 pub fn set_execution_failure(&mut self, index: usize, output: impl Into<String>) {
668 if let AppScreen::Executing(execution) = &mut self.screen
669 && let Some(item) = execution.items.get(index)
670 {
671 execution.running_index = None;
672 execution.failure = Some(ExecutionFailure {
673 branch: item.branch.name.clone(),
674 command: item.plain_command(),
675 output: output.into(),
676 });
677 }
678 }
679
680 pub fn start_execution(&mut self, index: usize) {
681 if let AppScreen::Executing(execution) = &mut self.screen {
682 execution.running_index = Some(index);
683 execution.spinner_frame = 0;
684 }
685 }
686
687 pub fn advance_execution_spinner(&mut self) {
688 if let AppScreen::Executing(execution) = &mut self.screen
689 && execution.running_index.is_some()
690 {
691 execution.spinner_frame = (execution.spinner_frame + 1) % 4;
692 }
693 }
694}
695
696impl CommandPlanItem {
697 pub fn new(mode: CleanupMode, remote: &str, branch: &Branch) -> Self {
698 let local_command = format!("git branch -D {}", shell_quote(&branch.name));
699 let remote_command = if mode.uses_pr_metadata() && mode.is_cleanable() {
700 branch.upstream_branch_name().map(|remote_branch| {
701 format!(
702 "git push {} :refs/heads/{}",
703 shell_quote(remote),
704 shell_quote(remote_branch)
705 )
706 })
707 } else {
708 None
709 };
710
711 Self {
712 branch: branch.clone(),
713 remote_command,
714 local_command,
715 state: CommandLineState::Pending,
716 }
717 }
718
719 pub fn plain_command(&self) -> String {
720 match &self.remote_command {
721 Some(remote_command) => format!("{remote_command} && {}", self.local_command),
722 None => self.local_command.clone(),
723 }
724 }
725}
726
727pub fn shell_quote(value: &str) -> String {
728 if value.is_empty() {
729 return String::from("''");
730 }
731
732 if value
733 .chars()
734 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '-' | '_' | '.' | ':'))
735 {
736 return value.to_string();
737 }
738
739 format!("'{}'", value.replace('\'', "'\"'\"'"))
740}
741
742#[derive(Debug, Clone)]
743pub struct DeleteResult {
744 pub branch: String,
745 pub success: bool,
746 pub message: String,
747 pub output: String,
748}
749
750#[derive(Debug, Clone, Deserialize)]
751struct PullRequestRef {
752 name: String,
753}
754
755#[derive(Debug, Clone, Deserialize)]
756struct RepoView {
757 #[serde(rename = "defaultBranchRef")]
758 default_branch_ref: PullRequestRef,
759}
760
761#[derive(Debug, Clone, Deserialize)]
762struct PullRequestRecord {
763 state: String,
764 #[serde(rename = "headRefName")]
765 head_ref_name: String,
766 url: String,
767}
768
769#[derive(Debug, Clone)]
770struct ClosedModeData {
771 default_branch: String,
772 pull_requests: HashMap<String, PullRequestRecord>,
773}
774
775#[derive(Debug, Clone)]
776struct ClosedModeResolution {
777 data: Option<ClosedModeData>,
778 notes: Vec<String>,
779}
780
781const GH_HEAD_SEARCH_CHUNK_SIZE: usize = 20;
782
783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub enum ScanProgress {
785 ValidatingRepository,
786 ReadingCurrentBranch,
787 ReadingWorktrees,
788 SyncingRemoteRefs,
789 LoadingLocalBranches,
790 LoadingGithubData,
791 MatchingClosedBranches,
792}
793
794impl ScanProgress {
795 pub const TOTAL_STEPS: usize = 7;
796
797 pub fn step(self) -> usize {
798 match self {
799 Self::ValidatingRepository => 1,
800 Self::ReadingCurrentBranch => 2,
801 Self::ReadingWorktrees => 3,
802 Self::SyncingRemoteRefs => 4,
803 Self::LoadingLocalBranches => 5,
804 Self::LoadingGithubData => 6,
805 Self::MatchingClosedBranches => 7,
806 }
807 }
808
809 pub fn message(self) -> &'static str {
810 match self {
811 Self::ValidatingRepository => "validating repository",
812 Self::ReadingCurrentBranch => "reading current branch",
813 Self::ReadingWorktrees => "reading linked worktrees",
814 Self::SyncingRemoteRefs => "syncing remote refs",
815 Self::LoadingLocalBranches => "loading local branches",
816 Self::LoadingGithubData => "loading GitHub data",
817 Self::MatchingClosedBranches => "matching branches against GitHub state",
818 }
819 }
820}
821
822pub fn scan_selected_modes(
823 repo: &Path,
824 modes: &[CleanupMode],
825 remote: &str,
826) -> Result<Vec<CleanupGroup>> {
827 Ok(scan_with_options(repo, ScanOptions::preview(modes, remote), |_, _| {})?.groups)
828}
829
830pub fn scan_selected_modes_with_progress<F>(
831 repo: &Path,
832 modes: &[CleanupMode],
833 remote: &str,
834 mut progress: F,
835) -> Result<Vec<CleanupGroup>>
836where
837 F: FnMut(ScanProgress, Option<&str>),
838{
839 Ok(scan_with_options(
840 repo,
841 ScanOptions::preview(modes, remote),
842 |stage, detail| {
843 progress(stage, detail);
844 },
845 )?
846 .groups)
847}
848
849pub fn scan_with_options<F>(
850 repo: &Path,
851 options: ScanOptions<'_>,
852 mut progress: F,
853) -> Result<ScanOutcome>
854where
855 F: FnMut(ScanProgress, Option<&str>),
856{
857 progress(ScanProgress::ValidatingRepository, None);
858 ensure_work_tree(repo)?;
859 let keep_store = KeepStore::load(repo)?;
860
861 progress(ScanProgress::ReadingCurrentBranch, None);
862 let current_branch = current_branch(repo)?;
863
864 progress(ScanProgress::ReadingWorktrees, None);
865 let worktree_branches = other_worktree_branches(repo, current_branch.as_deref())?;
866
867 if options.intent == ScanIntent::Clean && modes_need_pr_metadata(options.modes) {
868 progress(ScanProgress::SyncingRemoteRefs, Some(options.remote));
869 fetch_prune_remote(repo, options.remote)?;
870 }
871
872 progress(ScanProgress::LoadingLocalBranches, None);
873 let mut all_branches =
874 load_branch_inventory(repo, current_branch.as_deref(), None, &worktree_branches)?;
875
876 let closed_candidate_heads = closed_candidate_heads(&all_branches, options.remote);
877
878 let closed_resolution = if modes_need_pr_metadata(options.modes) {
879 let resolution = resolve_closed_mode_data(
880 repo,
881 options.remote,
882 &closed_candidate_heads,
883 options.intent,
884 &mut progress,
885 )?;
886 if let Some(closed_mode_data) = &resolution.data {
887 apply_default_branch_protection(&mut all_branches, &closed_mode_data.default_branch);
888 }
889 Some(resolution)
890 } else {
891 None
892 };
893
894 let mut groups = Vec::new();
895 let mut pr_groups_added = false;
896 for mode in options.modes.iter().copied() {
897 match mode {
898 mode if mode.uses_pr_metadata() => {
899 if pr_groups_added {
900 continue;
901 }
902 pr_groups_added = true;
903
904 if let Some(closed_mode_data) = closed_resolution
905 .as_ref()
906 .and_then(|resolution| resolution.data.as_ref())
907 {
908 groups.extend(
909 build_pr_groups(
910 options.modes,
911 &all_branches,
912 closed_mode_data,
913 options.remote,
914 &mut progress,
915 )
916 .into_iter()
917 .map(|mut group| {
918 group.branches =
919 apply_keep_labels(group.mode, group.branches, &keep_store);
920 group
921 }),
922 );
923 }
924 }
925 _ => {
926 let branches = all_branches
927 .iter()
928 .filter(|branch| mode.matches(branch))
929 .cloned()
930 .collect::<Vec<_>>();
931 groups.push(CleanupGroup::from_mode(
932 mode,
933 apply_keep_labels(mode, branches, &keep_store),
934 ));
935 }
936 }
937 }
938
939 let notes = closed_resolution
940 .map(|resolution| resolution.notes)
941 .unwrap_or_default();
942
943 Ok(ScanOutcome { groups, notes })
944}
945
946pub fn delete_branches(
947 repo: &Path,
948 mode: CleanupMode,
949 remote: &str,
950 branches: &[&Branch],
951) -> Vec<DeleteResult> {
952 let mut results = Vec::new();
953 for branch in branches {
954 let result = delete_branch(repo, mode, remote, branch);
955 let should_stop = !result.success;
956 results.push(result);
957 if should_stop {
958 break;
959 }
960 }
961
962 results
963}
964
965pub fn delete_branch(
966 repo: &Path,
967 mode: CleanupMode,
968 remote: &str,
969 branch: &Branch,
970) -> DeleteResult {
971 if mode.uses_pr_metadata() && mode.is_cleanable() {
972 delete_closed_branch(repo, remote, branch)
973 } else {
974 delete_local_branch(repo, branch)
975 }
976}
977
978fn load_branch_inventory(
979 repo: &Path,
980 current_branch: Option<&str>,
981 default_branch: Option<&str>,
982 worktree_branches: &HashSet<String>,
983) -> Result<Vec<Branch>> {
984 let lines = git_output(
985 repo,
986 &[
987 "for-each-ref",
988 "--format=%(refname:short)\u{1f}%(upstream:short)\u{1f}%(upstream:track)\u{1f}%(committerdate:unix)\u{1f}%(subject)",
989 "refs/heads/",
990 ],
991 )?;
992
993 let mut branches = Vec::new();
994 for line in lines.lines().filter(|line| !line.trim().is_empty()) {
995 let Some(branch) =
996 parse_branch_line(line, current_branch, default_branch, worktree_branches)
997 else {
998 continue;
999 };
1000
1001 branches.push(branch);
1002 }
1003
1004 branches.sort_by(|left, right| {
1005 right
1006 .committed_at
1007 .cmp(&left.committed_at)
1008 .then_with(|| left.name.cmp(&right.name))
1009 });
1010 Ok(branches)
1011}
1012
1013fn apply_default_branch_protection(branches: &mut [Branch], default_branch: &str) {
1014 if matches!(default_branch, "main" | "master") {
1015 return;
1016 }
1017
1018 for branch in branches {
1019 if branch.name == default_branch && !branch.protections.contains(&Protection::DefaultBranch)
1020 {
1021 branch.protections.push(Protection::DefaultBranch);
1022 }
1023 }
1024}
1025
1026fn closed_candidate_heads(branches: &[Branch], remote: &str) -> Vec<String> {
1027 branches
1028 .iter()
1029 .filter(|branch| {
1030 branch.upstream_remote() == Some(remote)
1031 && !branch.upstream_track.contains("[gone]")
1032 && !branch
1033 .protections
1034 .iter()
1035 .any(|protection| matches!(protection, Protection::Main | Protection::Master))
1036 })
1037 .filter_map(|branch| branch.upstream_branch_name().map(ToOwned::to_owned))
1038 .collect()
1039}
1040
1041fn modes_need_pr_metadata(modes: &[CleanupMode]) -> bool {
1042 modes.iter().copied().any(CleanupMode::uses_pr_metadata)
1043}
1044
1045fn chunk_summary(heads: &[String]) -> Option<String> {
1046 if heads.is_empty() {
1047 return None;
1048 }
1049
1050 let preview = heads
1051 .iter()
1052 .take(3)
1053 .map(String::as_str)
1054 .collect::<Vec<_>>()
1055 .join(", ");
1056 let remaining = heads.len().saturating_sub(3);
1057
1058 if remaining == 0 {
1059 Some(preview)
1060 } else {
1061 Some(format!("{preview} (+{remaining} more)"))
1062 }
1063}
1064
1065fn build_pr_groups<F>(
1066 selected_modes: &[CleanupMode],
1067 all_branches: &[Branch],
1068 closed_mode_data: &ClosedModeData,
1069 remote: &str,
1070 progress: &mut F,
1071) -> Vec<CleanupGroup>
1072where
1073 F: FnMut(ScanProgress, Option<&str>),
1074{
1075 let mut pr = Vec::new();
1076 let mut closed = Vec::new();
1077 let mut no_pr = Vec::new();
1078 let mut merged = Vec::new();
1079
1080 for branch in all_branches {
1081 progress(
1082 ScanProgress::MatchingClosedBranches,
1083 Some(branch.name.as_str()),
1084 );
1085
1086 if branch.protections.iter().any(|protection| {
1087 matches!(
1088 protection,
1089 Protection::Main | Protection::Master | Protection::DefaultBranch
1090 )
1091 }) {
1092 continue;
1093 }
1094
1095 if branch.upstream_remote() != Some(remote) {
1096 continue;
1097 }
1098 if branch.upstream_track.contains("[gone]") {
1099 continue;
1100 }
1101
1102 let Some(head_ref_name) = branch.upstream_branch_name() else {
1103 continue;
1104 };
1105
1106 let mut branch = branch.clone();
1107 match closed_mode_data.pull_requests.get(head_ref_name) {
1108 Some(record) if record.state == "OPEN" => {
1109 branch.pr_url = Some(record.url.clone());
1110 pr.push(branch);
1111 }
1112 Some(record) if record.state == "MERGED" => {
1113 branch.pr_url = Some(record.url.clone());
1114 merged.push(branch);
1115 }
1116 Some(record) if record.state == "CLOSED" => {
1117 branch.pr_url = Some(record.url.clone());
1118 closed.push(branch);
1119 }
1120 Some(record) => {
1121 branch.pr_url = Some(record.url.clone());
1122 closed.push(branch);
1123 }
1124 None => no_pr.push(branch),
1125 }
1126 }
1127
1128 let mut groups = Vec::new();
1129 for mode in selected_modes
1130 .iter()
1131 .copied()
1132 .filter(|mode| mode.uses_pr_metadata())
1133 {
1134 let branches = match mode {
1135 CleanupMode::Pr => pr.clone(),
1136 CleanupMode::NoPr => no_pr.clone(),
1137 CleanupMode::Closed => closed.clone(),
1138 CleanupMode::Merged => merged.clone(),
1139 CleanupMode::Gone | CleanupMode::Unpushed => continue,
1140 };
1141 groups.push(CleanupGroup::from_mode(mode, branches));
1142 }
1143
1144 groups
1145}
1146
1147fn apply_keep_labels(
1148 mode: CleanupMode,
1149 mut branches: Vec<Branch>,
1150 keep_store: &KeepStore,
1151) -> Vec<Branch> {
1152 for branch in &mut branches {
1153 branch.saved = keep_store.is_saved(mode, &branch.name);
1154 branch.decision = Decision::Undecided;
1155 }
1156
1157 reorder_branches(&mut branches);
1158 branches
1159}
1160
1161fn reorder_branches(branches: &mut [Branch]) {
1162 branches.sort_by_key(|branch| branch.section());
1163}
1164
1165fn initial_selection(branches: &[Branch]) -> usize {
1166 first_regular_from(branches, 0)
1167 .or_else(|| {
1168 branches
1169 .iter()
1170 .position(|branch| branch.section() == BranchSection::Saved)
1171 })
1172 .or_else(|| {
1173 branches
1174 .iter()
1175 .position(|branch| branch.section() == BranchSection::Protected)
1176 })
1177 .unwrap_or(0)
1178}
1179
1180fn first_regular_from(branches: &[Branch], start: usize) -> Option<usize> {
1181 branches
1182 .iter()
1183 .enumerate()
1184 .skip(start)
1185 .find(|(_, branch)| branch.section() == BranchSection::Regular)
1186 .map(|(index, _)| index)
1187}
1188
1189fn resolve_closed_mode_data<F>(
1190 repo: &Path,
1191 remote: &str,
1192 candidate_heads: &[String],
1193 intent: ScanIntent,
1194 progress: &mut F,
1195) -> Result<ClosedModeResolution>
1196where
1197 F: FnMut(ScanProgress, Option<&str>),
1198{
1199 match intent {
1200 ScanIntent::Clean => {
1201 let closed_mode_data =
1202 refresh_closed_mode_data(repo, remote, candidate_heads, progress)?;
1203 let mut notes = Vec::new();
1204 if let Err(error) = persist_closed_mode_cache(repo, remote, &closed_mode_data) {
1205 notes.push(format!("failed to update GitHub metadata cache: {error:#}"));
1206 }
1207 Ok(ClosedModeResolution {
1208 data: Some(closed_mode_data),
1209 notes,
1210 })
1211 }
1212 ScanIntent::Preview => {
1213 let mut notes = Vec::new();
1214 match load_fresh_closed_mode_cache(repo, remote)? {
1215 CacheLoad::Fresh(closed_mode_data) => {
1216 return Ok(ClosedModeResolution {
1217 data: Some(closed_mode_data),
1218 notes,
1219 });
1220 }
1221 CacheLoad::Unavailable(note) => notes.push(note),
1222 CacheLoad::Missing => {}
1223 }
1224
1225 match refresh_closed_mode_data(repo, remote, candidate_heads, progress) {
1226 Ok(closed_mode_data) => {
1227 if let Err(error) = persist_closed_mode_cache(repo, remote, &closed_mode_data) {
1228 notes.push(format!("failed to update GitHub metadata cache: {error:#}"));
1229 }
1230 Ok(ClosedModeResolution {
1231 data: Some(closed_mode_data),
1232 notes,
1233 })
1234 }
1235 Err(error) => {
1236 notes.push(format!("GitHub metadata unavailable: {error:#}"));
1237 Ok(ClosedModeResolution { data: None, notes })
1238 }
1239 }
1240 }
1241 }
1242}
1243
1244enum CacheLoad {
1245 Fresh(ClosedModeData),
1246 Missing,
1247 Unavailable(String),
1248}
1249
1250fn load_fresh_closed_mode_cache(repo: &Path, remote: &str) -> Result<CacheLoad> {
1251 let remote_url = remote_url(repo, remote)?;
1252 let cache = match PrCache::load(repo) {
1253 Ok(cache) => cache,
1254 Err(error) => {
1255 return Ok(CacheLoad::Unavailable(format!(
1256 "ignoring unreadable GitHub metadata cache: {error:#}"
1257 )));
1258 }
1259 };
1260 let Some(entry) = cache.remote_entry(remote, &remote_url) else {
1261 return Ok(CacheLoad::Missing);
1262 };
1263
1264 if !pr_cache_is_fresh(entry.refreshed_at) {
1265 return Ok(CacheLoad::Missing);
1266 }
1267
1268 Ok(CacheLoad::Fresh(closed_mode_data_from_cache(entry)))
1269}
1270
1271fn persist_closed_mode_cache(repo: &Path, remote: &str, data: &ClosedModeData) -> Result<()> {
1272 let remote_url = remote_url(repo, remote)?;
1273 let mut cache = match PrCache::load(repo) {
1274 Ok(cache) => cache,
1275 Err(_) => PrCache::new(repo)?,
1276 };
1277 let entry = PrCacheRemoteEntry {
1278 remote_url,
1279 refreshed_at: current_unix_timestamp(),
1280 default_branch: data.default_branch.clone(),
1281 pull_requests_by_head: data
1282 .pull_requests
1283 .iter()
1284 .map(|(head_ref_name, record)| {
1285 (
1286 head_ref_name.clone(),
1287 CachedPullRequestRecord {
1288 state: record.state.clone(),
1289 url: record.url.clone(),
1290 },
1291 )
1292 })
1293 .collect(),
1294 };
1295 cache.replace_remote(remote, entry)
1296}
1297
1298fn closed_mode_data_from_cache(entry: &PrCacheRemoteEntry) -> ClosedModeData {
1299 ClosedModeData {
1300 default_branch: entry.default_branch.clone(),
1301 pull_requests: entry
1302 .pull_requests_by_head
1303 .iter()
1304 .map(|(head_ref_name, record)| {
1305 (
1306 head_ref_name.clone(),
1307 PullRequestRecord {
1308 state: record.state.clone(),
1309 head_ref_name: head_ref_name.clone(),
1310 url: record.url.clone(),
1311 },
1312 )
1313 })
1314 .collect(),
1315 }
1316}
1317
1318fn pr_cache_is_fresh(refreshed_at: i64) -> bool {
1319 current_unix_timestamp().saturating_sub(refreshed_at) <= PR_CACHE_TTL_SECONDS
1320}
1321
1322fn refresh_closed_mode_data<F>(
1323 repo: &Path,
1324 remote: &str,
1325 candidate_heads: &[String],
1326 progress: &mut F,
1327) -> Result<ClosedModeData>
1328where
1329 F: FnMut(ScanProgress, Option<&str>),
1330{
1331 ensure_remote_exists(repo, remote)?;
1332 ensure_gh_installed()?;
1333 ensure_gh_authenticated(repo)?;
1334 let repo_view: RepoView = serde_json::from_str(&gh_output(
1335 repo,
1336 &["repo", "view", "--json", "defaultBranchRef"],
1337 )?)?;
1338
1339 let mut pull_requests = HashMap::new();
1340 for chunk in candidate_heads.chunks(GH_HEAD_SEARCH_CHUNK_SIZE) {
1341 let summary = chunk_summary(chunk);
1342 progress(ScanProgress::LoadingGithubData, summary.as_deref());
1343
1344 let search = chunk
1345 .iter()
1346 .map(|head| format!("head:{head}"))
1347 .collect::<Vec<_>>()
1348 .join(" OR ");
1349 let limit = (chunk.len() * 5).max(20).to_string();
1350 let args = vec![
1351 String::from("pr"),
1352 String::from("list"),
1353 String::from("--state"),
1354 String::from("all"),
1355 String::from("--search"),
1356 search,
1357 String::from("--json"),
1358 String::from("state,headRefName,url"),
1359 String::from("--limit"),
1360 limit,
1361 ];
1362 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
1363 let records = serde_json::from_str::<Vec<PullRequestRecord>>(&gh_output(repo, &arg_refs)?)?;
1364
1365 for pull_request in records {
1366 let head_ref_name = pull_request.head_ref_name.clone();
1367 match pull_requests.get(&head_ref_name) {
1368 Some(existing)
1369 if pull_request_rank(existing) >= pull_request_rank(&pull_request) => {}
1370 _ => {
1371 pull_requests.insert(head_ref_name, pull_request);
1372 }
1373 }
1374 }
1375 }
1376
1377 Ok(ClosedModeData {
1378 default_branch: repo_view.default_branch_ref.name,
1379 pull_requests,
1380 })
1381}
1382
1383fn pull_request_rank(pull_request: &PullRequestRecord) -> usize {
1384 match pull_request.state.as_str() {
1385 "OPEN" => 3,
1386 "MERGED" => 2,
1387 "CLOSED" => 1,
1388 _ => 0,
1389 }
1390}
1391
1392fn delete_closed_branch(repo: &Path, remote: &str, branch: &Branch) -> DeleteResult {
1393 let Some(remote_branch) = branch.upstream_branch_name() else {
1394 return DeleteResult {
1395 branch: branch.name.clone(),
1396 success: false,
1397 message: String::from("branch has no remote tracking branch"),
1398 output: String::from("branch has no remote tracking branch"),
1399 };
1400 };
1401
1402 let remote_ref = format!(":refs/heads/{remote_branch}");
1403 match Command::new("git")
1404 .args(["push", remote, &remote_ref])
1405 .current_dir(repo)
1406 .output()
1407 {
1408 Ok(output) if output.status.success() => delete_local_branch(repo, branch),
1409 Ok(output) => {
1410 let output_text = command_message(&output);
1411 DeleteResult {
1412 branch: branch.name.clone(),
1413 success: false,
1414 message: format!("git push {remote} {remote_ref} failed"),
1415 output: output_text,
1416 }
1417 }
1418 Err(error) => DeleteResult {
1419 branch: branch.name.clone(),
1420 success: false,
1421 message: error.to_string(),
1422 output: error.to_string(),
1423 },
1424 }
1425}
1426
1427fn delete_local_branch(repo: &Path, branch: &Branch) -> DeleteResult {
1428 match Command::new("git")
1429 .args(["branch", "-D", &branch.name])
1430 .current_dir(repo)
1431 .output()
1432 {
1433 Ok(output) if output.status.success() => DeleteResult {
1434 branch: branch.name.clone(),
1435 success: true,
1436 message: command_message(&output),
1437 output: command_message(&output),
1438 },
1439 Ok(output) => {
1440 let output_text = command_message(&output);
1441 DeleteResult {
1442 branch: branch.name.clone(),
1443 success: false,
1444 message: format!("git branch -D {} failed", branch.name),
1445 output: output_text,
1446 }
1447 }
1448 Err(error) => DeleteResult {
1449 branch: branch.name.clone(),
1450 success: false,
1451 message: error.to_string(),
1452 output: error.to_string(),
1453 },
1454 }
1455}
1456
1457fn command_message(output: &Output) -> String {
1458 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1459 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1460
1461 match (stdout.is_empty(), stderr.is_empty()) {
1462 (true, true) => String::from("command failed with no output"),
1463 (false, true) => stdout,
1464 (true, false) => stderr,
1465 (false, false) => format!("{stderr}\n{stdout}"),
1466 }
1467}
1468
1469fn parse_branch_line(
1470 line: &str,
1471 current_branch: Option<&str>,
1472 default_branch: Option<&str>,
1473 worktree_branches: &HashSet<String>,
1474) -> Option<Branch> {
1475 let mut fields = line.split(FIELD_SEPARATOR);
1476 let name = fields.next()?.to_string();
1477 let upstream = fields
1478 .next()
1479 .map(str::trim)
1480 .filter(|value| !value.is_empty())
1481 .map(ToOwned::to_owned);
1482 let upstream_track = fields.next()?.trim().to_string();
1483 let committed_at = fields.next()?.trim().parse::<i64>().ok()?;
1484 let relative_date = format_relative_age(committed_at);
1485 let subject = fields.next()?.trim().to_string();
1486
1487 let mut protections = Vec::new();
1488 if current_branch == Some(name.as_str()) {
1489 protections.push(Protection::Current);
1490 } else if worktree_branches.contains(&name) {
1491 protections.push(Protection::Worktree);
1492 }
1493
1494 if name == "main" {
1495 protections.push(Protection::Main);
1496 }
1497 if name == "master" {
1498 protections.push(Protection::Master);
1499 }
1500 if default_branch == Some(name.as_str()) && name != "main" && name != "master" {
1501 protections.push(Protection::DefaultBranch);
1502 }
1503
1504 Some(Branch {
1505 name,
1506 upstream,
1507 upstream_track,
1508 committed_at,
1509 relative_date,
1510 subject,
1511 pr_url: None,
1512 detail: None,
1513 saved: false,
1514 protections,
1515 decision: Decision::Undecided,
1516 })
1517}
1518
1519fn ensure_work_tree(repo: &Path) -> Result<()> {
1520 let inside_work_tree = git_output(repo, &["rev-parse", "--is-inside-work-tree"])?;
1521 if inside_work_tree.trim() != "true" {
1522 bail!("git-broom must be run inside a git working tree");
1523 }
1524
1525 let is_bare = git_output(repo, &["rev-parse", "--is-bare-repository"])?;
1526 if is_bare.trim() == "true" {
1527 bail!("git-broom does not support bare repositories");
1528 }
1529
1530 Ok(())
1531}
1532
1533fn format_relative_age(committed_at: i64) -> String {
1534 format_age_from_seconds(current_unix_timestamp().saturating_sub(committed_at).max(0) as u64)
1535}
1536
1537fn format_age_from_seconds(seconds: u64) -> String {
1538 const MINUTE: u64 = 60;
1539 const HOUR: u64 = 60 * MINUTE;
1540 const DAY: u64 = 24 * HOUR;
1541 const WEEK: u64 = 7 * DAY;
1542 const MONTH: u64 = 30 * DAY;
1543
1544 if seconds < MINUTE {
1545 return unit_label(seconds.max(1), "second");
1546 }
1547 if seconds < HOUR {
1548 return unit_label(seconds / MINUTE, "minute");
1549 }
1550 if seconds < DAY {
1551 return unit_label(seconds / HOUR, "hour");
1552 }
1553 if seconds < WEEK {
1554 return unit_label(seconds / DAY, "day");
1555 }
1556 if seconds < MONTH {
1557 return unit_label(seconds / WEEK, "week");
1558 }
1559
1560 unit_label(seconds / MONTH, "month")
1561}
1562
1563fn unit_label(value: u64, unit: &str) -> String {
1564 if value == 1 {
1565 format!("1 {unit} ago")
1566 } else {
1567 format!("{value} {unit}s ago")
1568 }
1569}
1570
1571fn current_unix_timestamp() -> i64 {
1572 SystemTime::now()
1573 .duration_since(UNIX_EPOCH)
1574 .expect("system time after unix epoch")
1575 .as_secs() as i64
1576}
1577
1578fn current_branch(repo: &Path) -> Result<Option<String>> {
1579 let output = Command::new("git")
1580 .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
1581 .current_dir(repo)
1582 .output()
1583 .context("failed to determine current branch")?;
1584
1585 if output.status.success() {
1586 let branch = String::from_utf8(output.stdout)
1587 .context("git symbolic-ref returned non-utf8 output")?
1588 .trim()
1589 .to_string();
1590 return Ok(Some(branch));
1591 }
1592
1593 if output.status.code() == Some(1) {
1594 return Ok(None);
1595 }
1596
1597 Err(anyhow!(
1598 "failed to determine current branch: {}",
1599 String::from_utf8_lossy(&output.stderr).trim()
1600 ))
1601}
1602
1603fn other_worktree_branches(repo: &Path, current_branch: Option<&str>) -> Result<HashSet<String>> {
1604 let output = git_output(repo, &["worktree", "list", "--porcelain"])?;
1605 let mut branches = HashSet::new();
1606
1607 for line in output.lines() {
1608 let Some(branch) = line.strip_prefix("branch refs/heads/") else {
1609 continue;
1610 };
1611
1612 if Some(branch) != current_branch {
1613 branches.insert(branch.to_string());
1614 }
1615 }
1616
1617 Ok(branches)
1618}
1619
1620fn ensure_remote_exists(repo: &Path, remote: &str) -> Result<()> {
1621 remote_url(repo, remote).map(|_| ())
1622}
1623
1624fn remote_url(repo: &Path, remote: &str) -> Result<String> {
1625 let output = Command::new("git")
1626 .args(["remote", "get-url", remote])
1627 .current_dir(repo)
1628 .output()
1629 .context("failed to inspect git remotes")?;
1630
1631 if output.status.success() {
1632 return String::from_utf8(output.stdout)
1633 .context("git remote get-url returned non-utf8 output")
1634 .map(|url| url.trim().to_string());
1635 }
1636
1637 bail!("remote `{remote}` does not exist")
1638}
1639
1640fn fetch_prune_remote(repo: &Path, remote: &str) -> Result<()> {
1641 let output = Command::new("git")
1642 .args(["fetch", remote, "--prune"])
1643 .current_dir(repo)
1644 .output()
1645 .context("failed to sync remote refs")?;
1646
1647 if output.status.success() {
1648 return Ok(());
1649 }
1650
1651 bail!(
1652 "git fetch {} --prune failed: {}",
1653 remote,
1654 command_message(&output)
1655 )
1656}
1657
1658fn ensure_gh_installed() -> Result<()> {
1659 match Command::new("gh").args(["--version"]).output() {
1660 Ok(output) if output.status.success() => Ok(()),
1661 Ok(_) => {
1662 bail!("gh CLI required for GitHub-backed groups. Install from https://cli.github.com")
1663 }
1664 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1665 bail!("gh CLI required for GitHub-backed groups. Install from https://cli.github.com")
1666 }
1667 Err(error) => Err(error).context("failed to run gh --version"),
1668 }
1669}
1670
1671fn ensure_gh_authenticated(repo: &Path) -> Result<()> {
1672 let output = Command::new("gh")
1673 .args(["auth", "status"])
1674 .current_dir(repo)
1675 .output()
1676 .context("failed to run gh auth status")?;
1677
1678 if output.status.success() {
1679 return Ok(());
1680 }
1681
1682 bail!("Run `gh auth login` first")
1683}
1684
1685fn git_output(repo: &Path, args: &[&str]) -> Result<String> {
1686 let output = git_output_raw(repo, args)?;
1687 String::from_utf8(output.stdout).context("git returned non-utf8 output")
1688}
1689
1690fn gh_output(repo: &Path, args: &[&str]) -> Result<String> {
1691 let output = Command::new("gh")
1692 .args(args)
1693 .current_dir(repo)
1694 .stdout(Stdio::piped())
1695 .stderr(Stdio::piped())
1696 .output()
1697 .with_context(|| format!("failed to run gh {}", args.join(" ")))?;
1698
1699 if !output.status.success() {
1700 bail!(
1701 "gh {} failed: {}",
1702 args.join(" "),
1703 String::from_utf8_lossy(&output.stderr).trim()
1704 );
1705 }
1706
1707 String::from_utf8(output.stdout).context("gh returned non-utf8 output")
1708}
1709
1710fn git_output_raw(repo: &Path, args: &[&str]) -> Result<Output> {
1711 let output = Command::new("git")
1712 .args(args)
1713 .current_dir(repo)
1714 .stdout(Stdio::piped())
1715 .stderr(Stdio::piped())
1716 .output()
1717 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
1718
1719 if !output.status.success() {
1720 bail!(
1721 "git {} failed: {}",
1722 args.join(" "),
1723 String::from_utf8_lossy(&output.stderr).trim()
1724 );
1725 }
1726
1727 Ok(output)
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732 use std::collections::HashSet;
1733
1734 use super::{
1735 App, AppScreen, Branch, BranchSection, CleanupGroup, CleanupMode, Decision,
1736 FIELD_SEPARATOR, Protection, chunk_summary, closed_candidate_heads,
1737 format_age_from_seconds, parse_branch_line,
1738 };
1739
1740 const SAMPLE_TIMESTAMP: &str = "1700000000";
1741
1742 #[test]
1743 fn parse_branch_line_marks_current_branch_as_protected() {
1744 let branch = parse_branch_line(
1745 &format!(
1746 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1747 ),
1748 Some("feature/foo"),
1749 None,
1750 &HashSet::new(),
1751 )
1752 .expect("branch parsed");
1753
1754 assert_eq!(branch.decision, Decision::Undecided);
1755 assert_eq!(branch.protections, vec![Protection::Current]);
1756 assert!(branch.display_name().contains("[current]"));
1757 }
1758
1759 #[test]
1760 fn parse_branch_line_marks_other_worktree_branch_as_protected() {
1761 let worktree_branches = HashSet::from([String::from("feature/foo")]);
1762 let branch = parse_branch_line(
1763 &format!(
1764 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1765 ),
1766 Some("main"),
1767 None,
1768 &worktree_branches,
1769 )
1770 .expect("branch parsed");
1771
1772 assert_eq!(branch.decision, Decision::Undecided);
1773 assert_eq!(branch.protections, vec![Protection::Worktree]);
1774 }
1775
1776 #[test]
1777 fn parse_branch_line_marks_main_branch_as_protected() {
1778 let branch = parse_branch_line(
1779 &format!(
1780 "main{FIELD_SEPARATOR}origin/main{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1781 ),
1782 None,
1783 None,
1784 &HashSet::new(),
1785 )
1786 .expect("branch parsed");
1787
1788 assert_eq!(branch.protections, vec![Protection::Main]);
1789 assert_eq!(branch.decision, Decision::Undecided);
1790 }
1791
1792 #[test]
1793 fn cleanup_mode_matches_gone_branches() {
1794 let branch = parse_branch_line(
1795 &format!(
1796 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1797 ),
1798 None,
1799 None,
1800 &HashSet::new(),
1801 )
1802 .expect("branch parsed");
1803
1804 assert!(CleanupMode::Gone.matches(&branch));
1805 assert!(!CleanupMode::Unpushed.matches(&branch));
1806 }
1807
1808 #[test]
1809 fn cleanup_mode_matches_unpushed_branches() {
1810 let branch = parse_branch_line(
1811 &format!(
1812 "feature/foo{FIELD_SEPARATOR}{FIELD_SEPARATOR}{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1813 ),
1814 None,
1815 None,
1816 &HashSet::new(),
1817 )
1818 .expect("branch parsed");
1819
1820 assert!(CleanupMode::Unpushed.matches(&branch));
1821 assert!(!CleanupMode::Gone.matches(&branch));
1822 }
1823
1824 #[test]
1825 fn toggle_delete_marks_branch_for_deletion() {
1826 let branch = parse_branch_line(
1827 &format!(
1828 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1829 ),
1830 None,
1831 None,
1832 &HashSet::new(),
1833 )
1834 .expect("branch parsed");
1835 let mut app = App::from_group(
1836 CleanupGroup::from_mode(CleanupMode::Gone, vec![branch]),
1837 "origin",
1838 1,
1839 1,
1840 );
1841
1842 app.toggle_delete();
1843 assert_eq!(app.branches[0].decision, Decision::Delete);
1844
1845 app.toggle_delete();
1846 assert_eq!(app.branches[0].decision, Decision::Undecided);
1847 }
1848
1849 #[test]
1850 fn toggle_delete_shows_modal_for_protected_branch() {
1851 let branch = parse_branch_line(
1852 &format!(
1853 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1854 ),
1855 Some("feature/foo"),
1856 None,
1857 &HashSet::new(),
1858 )
1859 .expect("branch parsed");
1860 let mut app = App::from_group(
1861 CleanupGroup::from_mode(CleanupMode::Gone, vec![branch]),
1862 "origin",
1863 1,
1864 1,
1865 );
1866
1867 app.toggle_delete();
1868
1869 let modal = app.modal.expect("modal shown");
1870 assert_eq!(modal.title, "Branch Ineligible");
1871 assert!(
1872 modal
1873 .message
1874 .contains("Current branch is ineligible for cleanup.")
1875 );
1876 assert_eq!(app.branches[0].decision, Decision::Undecided);
1877 }
1878
1879 #[test]
1880 fn toggle_delete_shows_modal_for_saved_branch() {
1881 let mut branch = parse_branch_line(
1882 &format!(
1883 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1884 ),
1885 None,
1886 None,
1887 &HashSet::new(),
1888 )
1889 .expect("branch parsed");
1890 branch.saved = true;
1891 let mut app = App::from_group(
1892 CleanupGroup::from_mode(CleanupMode::Gone, vec![branch]),
1893 "origin",
1894 1,
1895 1,
1896 );
1897
1898 app.toggle_delete();
1899
1900 let modal = app.modal.expect("modal shown");
1901 assert_eq!(modal.title, "Branch Saved");
1902 assert!(modal.message.contains("must be unsaved before deletion"));
1903 assert_eq!(app.branches[0].decision, Decision::Undecided);
1904 }
1905
1906 #[test]
1907 fn from_group_starts_selection_at_first_regular_branch() {
1908 let mut protected = parse_branch_line(
1909 &format!(
1910 "feature/protected{FIELD_SEPARATOR}origin/feature/protected{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1911 ),
1912 Some("feature/protected"),
1913 None,
1914 &HashSet::new(),
1915 )
1916 .expect("branch parsed");
1917 protected.saved = true;
1918
1919 let mut saved = parse_branch_line(
1920 &format!(
1921 "feature/saved{FIELD_SEPARATOR}origin/feature/saved{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1922 ),
1923 None,
1924 None,
1925 &HashSet::new(),
1926 )
1927 .expect("branch parsed");
1928 saved.saved = true;
1929
1930 let regular = parse_branch_line(
1931 &format!(
1932 "feature/regular{FIELD_SEPARATOR}origin/feature/regular{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1933 ),
1934 None,
1935 None,
1936 &HashSet::new(),
1937 )
1938 .expect("branch parsed");
1939
1940 let app = App::from_group(
1941 CleanupGroup::from_mode(CleanupMode::Gone, vec![protected, saved, regular]),
1942 "origin",
1943 1,
1944 1,
1945 );
1946
1947 assert_eq!(app.selected, 2);
1948 assert_eq!(app.branches[app.selected].section(), BranchSection::Regular);
1949 }
1950
1951 #[test]
1952 fn toggle_save_moves_branch_into_saved_section() {
1953 let first = parse_branch_line(
1954 &format!(
1955 "feature/first{FIELD_SEPARATOR}origin/feature/first{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1956 ),
1957 None,
1958 None,
1959 &HashSet::new(),
1960 )
1961 .expect("branch parsed");
1962 let second = parse_branch_line(
1963 &format!(
1964 "feature/second{FIELD_SEPARATOR}origin/feature/second{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1965 ),
1966 None,
1967 None,
1968 &HashSet::new(),
1969 )
1970 .expect("branch parsed");
1971 let mut app = App::from_group(
1972 CleanupGroup::from_mode(CleanupMode::Gone, vec![first, second]),
1973 "origin",
1974 1,
1975 1,
1976 );
1977
1978 app.selected = 1;
1979 app.toggle_save();
1980
1981 assert_eq!(app.branches[0].name, "feature/second");
1982 assert!(app.branches[0].saved);
1983 assert_eq!(app.selected, 1);
1984 assert_eq!(app.branches[app.selected].name, "feature/first");
1985 assert_eq!(
1986 app.saved_branch_names(),
1987 vec![String::from("feature/second")]
1988 );
1989 }
1990
1991 #[test]
1992 fn enter_review_requires_explicit_y_or_n_after_enter() {
1993 let mut branch = parse_branch_line(
1994 &format!(
1995 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}[gone]{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
1996 ),
1997 None,
1998 None,
1999 &HashSet::new(),
2000 )
2001 .expect("branch parsed");
2002 branch.decision = Decision::Delete;
2003
2004 let mut app = App::from_group(
2005 CleanupGroup::from_mode(CleanupMode::Gone, vec![branch]),
2006 "origin",
2007 1,
2008 1,
2009 );
2010
2011 assert!(app.enter_review());
2012 assert!(matches!(app.screen, AppScreen::Review(_)));
2013 assert!(!app.review_requires_explicit_choice());
2014
2015 app.require_review_confirmation();
2016 assert!(app.review_requires_explicit_choice());
2017 }
2018
2019 #[test]
2020 fn begin_execution_builds_command_plan_for_deleted_branch() {
2021 let mut branch = parse_branch_line(
2022 &format!(
2023 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
2024 ),
2025 None,
2026 None,
2027 &HashSet::new(),
2028 )
2029 .expect("branch parsed");
2030 branch.decision = Decision::Delete;
2031
2032 let mut app = App::from_group(
2033 CleanupGroup::from_mode(CleanupMode::Closed, vec![branch]),
2034 "origin",
2035 1,
2036 1,
2037 );
2038
2039 assert!(app.enter_review());
2040 app.begin_execution();
2041
2042 let items = app.execution_items().expect("execution items available");
2043 assert_eq!(items.len(), 1);
2044 assert_eq!(
2045 items[0].plain_command(),
2046 "git push origin :refs/heads/feature/foo && git branch -D feature/foo"
2047 );
2048 }
2049
2050 #[test]
2051 fn set_execution_failure_records_output_and_stops_progression() {
2052 let mut branch = parse_branch_line(
2053 &format!(
2054 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
2055 ),
2056 None,
2057 None,
2058 &HashSet::new(),
2059 )
2060 .expect("branch parsed");
2061 branch.decision = Decision::Delete;
2062
2063 let mut app = App::from_group(
2064 CleanupGroup::from_mode(CleanupMode::Closed, vec![branch]),
2065 "origin",
2066 1,
2067 1,
2068 );
2069
2070 assert!(app.enter_review());
2071 app.begin_execution();
2072 app.set_execution_failure(0, "fatal: remote ref does not exist");
2073
2074 let failure = app.execution_failure().expect("failure captured");
2075 assert_eq!(failure.branch, "feature/foo");
2076 assert_eq!(
2077 failure.command,
2078 "git push origin :refs/heads/feature/foo && git branch -D feature/foo"
2079 );
2080 assert_eq!(failure.output, "fatal: remote ref does not exist");
2081 assert_eq!(app.next_pending_execution_index(), None);
2082 }
2083
2084 #[test]
2085 fn execution_spinner_tracks_running_item() {
2086 let mut branch = parse_branch_line(
2087 &format!(
2088 "feature/foo{FIELD_SEPARATOR}origin/feature/foo{FIELD_SEPARATOR}{FIELD_SEPARATOR}{SAMPLE_TIMESTAMP}{FIELD_SEPARATOR}test subject"
2089 ),
2090 None,
2091 None,
2092 &HashSet::new(),
2093 )
2094 .expect("branch parsed");
2095 branch.decision = Decision::Delete;
2096
2097 let mut app = App::from_group(
2098 CleanupGroup::from_mode(CleanupMode::Gone, vec![branch]),
2099 "origin",
2100 1,
2101 1,
2102 );
2103
2104 assert!(app.enter_review());
2105 app.begin_execution();
2106 app.start_execution(0);
2107 app.advance_execution_spinner();
2108
2109 assert_eq!(app.execution_running_index(), Some(0));
2110 assert_eq!(app.execution_spinner_frame(), 1);
2111
2112 app.mark_execution_result(0, true);
2113
2114 assert_eq!(app.execution_running_index(), None);
2115 }
2116
2117 #[test]
2118 fn format_age_uses_months_for_long_durations() {
2119 assert_eq!(
2120 format_age_from_seconds(18 * 30 * 24 * 60 * 60),
2121 "18 months ago"
2122 );
2123 }
2124
2125 #[test]
2126 fn closed_candidate_heads_filters_to_remote_tracked_branches() {
2127 let branches = vec![
2128 Branch {
2129 name: String::from("feature/one"),
2130 upstream: Some(String::from("origin/feature/one")),
2131 upstream_track: String::new(),
2132 committed_at: 1_700_000_001,
2133 relative_date: String::from("1 day ago"),
2134 subject: String::from("first"),
2135 pr_url: None,
2136 detail: None,
2137 saved: false,
2138 protections: Vec::new(),
2139 decision: Decision::Undecided,
2140 },
2141 Branch {
2142 name: String::from("feature/two"),
2143 upstream: Some(String::from("origin/feature/two")),
2144 upstream_track: String::new(),
2145 committed_at: 1_700_000_000,
2146 relative_date: String::from("2 days ago"),
2147 subject: String::from("second"),
2148 pr_url: None,
2149 detail: None,
2150 saved: false,
2151 protections: Vec::new(),
2152 decision: Decision::Undecided,
2153 },
2154 Branch {
2155 name: String::from("main"),
2156 upstream: Some(String::from("origin/main")),
2157 upstream_track: String::new(),
2158 committed_at: 1_699_999_999,
2159 relative_date: String::from("3 days ago"),
2160 subject: String::from("main"),
2161 pr_url: None,
2162 detail: None,
2163 saved: false,
2164 protections: vec![Protection::Main],
2165 decision: Decision::Undecided,
2166 },
2167 Branch {
2168 name: String::from("feature/gone"),
2169 upstream: Some(String::from("origin/feature/gone")),
2170 upstream_track: String::from("[gone]"),
2171 committed_at: 1_699_999_998,
2172 relative_date: String::from("4 days ago"),
2173 subject: String::from("gone"),
2174 pr_url: None,
2175 detail: None,
2176 saved: false,
2177 protections: Vec::new(),
2178 decision: Decision::Undecided,
2179 },
2180 ];
2181
2182 assert_eq!(
2183 closed_candidate_heads(&branches, "origin"),
2184 vec![String::from("feature/one"), String::from("feature/two")]
2185 );
2186 }
2187
2188 #[test]
2189 fn chunk_summary_shows_head_preview_and_remaining_count() {
2190 let heads = vec![
2191 String::from("feature/one"),
2192 String::from("feature/two"),
2193 String::from("feature/three"),
2194 String::from("feature/four"),
2195 ];
2196
2197 assert_eq!(
2198 chunk_summary(&heads),
2199 Some(String::from(
2200 "feature/one, feature/two, feature/three (+1 more)"
2201 ))
2202 );
2203 }
2204}