Skip to main content

git_broom/
app.rs

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}