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