Skip to main content

gch/
main.rs

1//! Stage or discard selected Git changes interactively.
2//!
3//! # Errors
4//! - Git operations or user interaction fails.
5
6use std::fmt::Display;
7use std::fmt::Formatter;
8use std::ops::Deref;
9use std::path::Path;
10
11use owo_colors::OwoColorize;
12use strum::EnumIter;
13use strum::IntoEnumIterator;
14use ytil_git::GitStatusEntry;
15use ytil_git::IndexState;
16use ytil_git::WorktreeState;
17use ytil_sys::cli::Args;
18
19/// Stage or discard selected Git changes interactively.
20///
21/// # Errors
22/// - Status enumeration via [`ytil_git::get_status`] fails.
23/// - User interaction (selection prompts via [`ytil_tui::minimal_multi_select`] and [`ytil_tui::minimal_select`])
24///   fails.
25/// - File / directory removal for new entries fails.
26/// - Unstaging new index entries via [`ytil_git::unstage`] fails.
27/// - Restore command construction / execution via [`ytil_git::restore`] fails.
28/// - Opening repository via [`ytil_git::repo::discover`] or adding paths to index via [`ytil_git::add_to_index`] fails.
29#[ytil_sys::main]
30fn main() -> rootcause::Result<()> {
31    let args = ytil_sys::cli::get();
32    if args.has_help() {
33        println!("{}", include_str!("../help.txt"));
34        return Ok(());
35    }
36    let args: Vec<_> = args.iter().map(String::as_str).collect();
37
38    let git_status_entries = ytil_git::get_status()?;
39    if git_status_entries.is_empty() {
40        println!("Working tree clean");
41        return Ok(());
42    }
43
44    let renderable_entries = git_status_entries.into_iter().map(RenderableGitStatusEntry).collect();
45
46    let Some(selected_entries) =
47        ytil_tui::minimal_multi_select(renderable_entries, ToString::to_string, ToString::to_string)?
48    else {
49        println!("\n\nNo entries selected");
50        return Ok(());
51    };
52
53    let Some(selected_op) = ytil_tui::minimal_select::<Op>(Op::iter().collect())? else {
54        println!("\n\nNothing operation selected");
55        return Ok(());
56    };
57
58    let selected_entries = selected_entries.iter().map(Deref::deref).collect::<Vec<_>>();
59    match selected_op {
60        Op::Discard => restore_entries(&selected_entries, args.first().copied())?,
61        Op::Add => add_entries(&selected_entries)?,
62    }
63
64    Ok(())
65}
66
67/// Newtype wrapper adding colored [`Display`] for a [`ytil_git::GitStatusEntry`].
68///
69/// Renders two status columns (index + worktree) plus the path.
70pub struct RenderableGitStatusEntry(pub GitStatusEntry);
71
72impl Deref for RenderableGitStatusEntry {
73    type Target = GitStatusEntry;
74
75    fn deref(&self) -> &Self::Target {
76        &self.0
77    }
78}
79
80impl Display for RenderableGitStatusEntry {
81    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82        // Conflict overrides everything
83        if self.conflicted {
84            return write!(f, "{} {}", "CC".red().bold(), self.path.display());
85        }
86
87        // Write index symbol directly to formatter, avoiding intermediate String allocations.
88        write_index_symbol(f, self.index_state.as_ref(), self.ignored)?;
89        write_worktree_symbol(f, self.worktree_state.as_ref(), self.ignored)?;
90
91        write!(f, " {}", self.path.display())
92    }
93}
94
95/// High-level Git working tree/index operations exposed by the UI.
96#[derive(EnumIter)]
97pub enum Op {
98    /// Add path contents to the index similar to `git add <path>`.
99    Add,
100    /// Discard changes in the worktree and/or reset the index for a path
101    /// similar in spirit to `git restore` / `git checkout -- <path>`.
102    Discard,
103}
104
105impl Display for Op {
106    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
107        match self {
108            Self::Discard => write!(f, "{}", "Discard".red().bold()),
109            Self::Add => write!(f, "{}", "Add".green().bold()),
110        }
111    }
112}
113
114/// Delete newly created paths then restore modified paths.
115///
116/// # Errors
117/// - File removal, unstaging, or restore command fails.
118fn restore_entries(entries: &[&GitStatusEntry], branch: Option<&str>) -> rootcause::Result<()> {
119    // Avoid creating &&GitStatusEntry by copying the slice of &GitStatusEntry directly.
120    let (new_entries, changed_entries): (Vec<&GitStatusEntry>, Vec<&GitStatusEntry>) =
121        entries.iter().copied().partition(|entry| entry.is_new());
122
123    let mut new_entries_in_index = vec![];
124    for new_entry in &new_entries {
125        let absolute_path = new_entry.absolute_path();
126        if absolute_path.is_file() || absolute_path.is_symlink() {
127            std::fs::remove_file(&absolute_path)?;
128        } else if absolute_path.is_dir() {
129            std::fs::remove_dir_all(&absolute_path)?;
130        }
131        println!("{} {}", "Discarded".red().bold(), new_entry.path.display());
132        if new_entry.is_new_in_index() {
133            new_entries_in_index.push(absolute_path.display().to_string());
134        }
135    }
136    // Use repo-relative paths for unstaging so we *only* touch the index.
137    ytil_git::unstage(new_entries_in_index.iter().map(String::as_str))?;
138
139    // Exit early in case of no changes to avoid break `git restore` cmd.
140    if changed_entries.is_empty() {
141        return Ok(());
142    }
143
144    // Unstage changed entries before restoring the worktree. Conflicted entries
145    // are included here because git reports then as unmerged but they still
146    // need to be reset by the discard flog.
147    let staged_changed_paths: Vec<String> = changed_entries
148        .iter()
149        .filter(|entry| entry.is_staged() || entry.conflicted)
150        .map(|entry| entry.absolute_path().to_string_lossy().into_owned())
151        .collect();
152    ytil_git::unstage(staged_changed_paths.iter().map(String::as_str))?;
153
154    let changed_entries_paths = changed_entries
155        .iter()
156        .map(|changed_entry| changed_entry.absolute_path().to_string_lossy().into_owned());
157
158    ytil_git::restore(changed_entries_paths, branch)?;
159
160    for changed_entry in changed_entries {
161        let from_branch = branch.map(|b| format!(" from {b}")).unwrap_or_default();
162        println!(
163            "{} {}{from_branch}",
164            "Restored".yellow().bold(),
165            changed_entry.path.display()
166        );
167    }
168    Ok(())
169}
170
171/// Add the provided entries to the Git index (equivalent to `git add` on each path).
172///
173/// # Errors
174/// - Opening the repository via [`ytil_git::repo::discover`] fails.
175/// - Adding any path to the index via [`ytil_git::add_to_index`] fails.
176fn add_entries(entries: &[&GitStatusEntry]) -> rootcause::Result<()> {
177    let mut repo = ytil_git::repo::discover(Path::new("."))?;
178    ytil_git::add_to_index(&mut repo, entries.iter().map(|entry| entry.path.as_path()))?;
179    for entry in entries {
180        println!("{} {}", "Added".green().bold(), entry.path.display());
181    }
182    Ok(())
183}
184
185/// Write an index state symbol directly to the formatter, avoiding `.to_string()` allocations.
186fn write_index_symbol(f: &mut Formatter<'_>, state: Option<&IndexState>, dimmed: bool) -> std::fmt::Result {
187    match state {
188        None => write!(f, " "),
189        Some(IndexState::New) if dimmed => write!(f, "{}", "A".green().bold().dimmed()),
190        Some(IndexState::New) => write!(f, "{}", "A".green().bold()),
191        Some(IndexState::Modified) if dimmed => write!(f, "{}", "M".yellow().bold().dimmed()),
192        Some(IndexState::Modified) => write!(f, "{}", "M".yellow().bold()),
193        Some(IndexState::Deleted) if dimmed => write!(f, "{}", "D".red().bold().dimmed()),
194        Some(IndexState::Deleted) => write!(f, "{}", "D".red().bold()),
195        Some(IndexState::Renamed) if dimmed => write!(f, "{}", "R".cyan().bold().dimmed()),
196        Some(IndexState::Renamed) => write!(f, "{}", "R".cyan().bold()),
197        Some(IndexState::Typechange) if dimmed => write!(f, "{}", "T".magenta().bold().dimmed()),
198        Some(IndexState::Typechange) => write!(f, "{}", "T".magenta().bold()),
199    }
200}
201
202/// Write a worktree state symbol directly to the formatter, avoiding `.to_string()` allocations.
203fn write_worktree_symbol(f: &mut Formatter<'_>, state: Option<&WorktreeState>, dimmed: bool) -> std::fmt::Result {
204    match state {
205        None => write!(f, " "),
206        Some(WorktreeState::New) if dimmed => write!(f, "{}", "A".green().bold().dimmed()),
207        Some(WorktreeState::New) => write!(f, "{}", "A".green().bold()),
208        Some(WorktreeState::Modified) if dimmed => write!(f, "{}", "M".yellow().bold().dimmed()),
209        Some(WorktreeState::Modified) => write!(f, "{}", "M".yellow().bold()),
210        Some(WorktreeState::Deleted) if dimmed => write!(f, "{}", "D".red().bold().dimmed()),
211        Some(WorktreeState::Deleted) => write!(f, "{}", "D".red().bold()),
212        Some(WorktreeState::Renamed) if dimmed => write!(f, "{}", "R".cyan().bold().dimmed()),
213        Some(WorktreeState::Renamed) => write!(f, "{}", "R".cyan().bold()),
214        Some(WorktreeState::Typechange) if dimmed => write!(f, "{}", "T".magenta().bold().dimmed()),
215        Some(WorktreeState::Typechange) => write!(f, "{}", "T".magenta().bold()),
216        Some(WorktreeState::Unreadable) if dimmed => write!(f, "{}", "U".red().bold().dimmed()),
217        Some(WorktreeState::Unreadable) => write!(f, "{}", "U".red().bold()),
218    }
219}