gch/
main.rs

1//! Stage or discard selected Git changes interactively
2//!
3//! Presents a compact TUI to multi‑select working tree entries and apply a bulk
4//! operation (stage or discard) with colorized progress output. Canceling any
5//! prompt safely results in no changes.
6//!
7//! # Arguments
8//! - `<branch>` Optional branch used as blob source during restore in Discard; if omitted, `git restore` falls back to
9//!   index / HEAD.
10//!
11//! # Usage
12//! ```bash
13//! gch # select changes -> choose Add or Discard
14//! gch main # use 'main' as blob source when discarding
15//! ```
16//!
17//! # Exit Codes
18//! - `0` Success (includes user cancellations performing no changes).
19//! - Non‑zero: bubbled I/O, subprocess, or git operation failure (reported via `color_eyre`).
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//!
30//! # Rationale
31//! - Delegates semantics to porcelain (`git restore`, `git add`) to inherit nuanced Git behavior.
32//! - Minimal two‑prompt UX optimizes rapid iterative staging / discarding.
33
34use core::fmt::Display;
35use std::ops::Deref;
36use std::path::Path;
37
38use color_eyre::owo_colors::OwoColorize;
39use strum::EnumIter;
40use strum::IntoEnumIterator;
41use ytil_git::GitStatusEntry;
42use ytil_git::IndexState;
43use ytil_git::WorktreeState;
44use ytil_sys::cli::Args;
45
46/// Newtype wrapper adding colored [`Display`] for a [`ytil_git::GitStatusEntry`].
47///
48/// Renders two status columns (index + worktree) plus the path, dimming ignored entries
49/// and prioritizing conflict markers.
50///
51/// # Examples
52/// ```no_run
53/// # fn show(e: &RenderableGitStatusEntry) {
54/// println!("{e}");
55/// # }
56/// ```
57///
58/// # Rationale
59/// Needed to implement [`Display`] without modifying an external type (orphan rule).
60///
61/// # Performance
62/// Only constructs small colored string fragments per render.
63///
64/// # Future Work
65/// - Provide a structured render method (symbols + path) for alternative UIs.
66pub struct RenderableGitStatusEntry(pub GitStatusEntry);
67
68impl Deref for RenderableGitStatusEntry {
69    type Target = GitStatusEntry;
70
71    fn deref(&self) -> &Self::Target {
72        &self.0
73    }
74}
75
76impl Display for RenderableGitStatusEntry {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        // Conflict overrides everything
79        if self.conflicted {
80            return write!(f, "{} {}", "CC".red().bold(), self.path.display());
81        }
82
83        let index_symbol = self.index_state.as_ref().map_or_else(
84            || " ".to_string(),
85            |s| match s {
86                IndexState::New => "A".green().bold().to_string(),
87                IndexState::Modified => "M".yellow().bold().to_string(),
88                IndexState::Deleted => "D".red().bold().to_string(),
89                IndexState::Renamed => "R".cyan().bold().to_string(),
90                IndexState::Typechange => "T".magenta().bold().to_string(),
91            },
92        );
93
94        let worktree_symbol = self.worktree_state.as_ref().map_or_else(
95            || " ".to_string(),
96            |s| match s {
97                WorktreeState::New => "A".green().bold().to_string(),
98                WorktreeState::Modified => "M".yellow().bold().to_string(),
99                WorktreeState::Deleted => "D".red().bold().to_string(),
100                WorktreeState::Renamed => "R".cyan().bold().to_string(),
101                WorktreeState::Typechange => "T".magenta().bold().to_string(),
102                WorktreeState::Unreadable => "U".red().bold().to_string(),
103            },
104        );
105
106        // Ignored marks as dimmed
107        let (index_symbol, worktree_symbol) = if self.ignored {
108            (index_symbol.dimmed().to_string(), worktree_symbol.dimmed().to_string())
109        } else {
110            (index_symbol, worktree_symbol)
111        };
112
113        write!(f, "{}{} {}", index_symbol, worktree_symbol, self.path.display())
114    }
115}
116
117/// High-level Git working tree/index operations exposed by the UI.
118#[derive(EnumIter)]
119pub enum Op {
120    /// Add path contents to the index similar to `git add <path>`.
121    Add,
122    /// Discard changes in the worktree and/or reset the index for a path
123    /// similar in spirit to `git restore` / `git checkout -- <path>`.
124    Discard,
125}
126
127impl Display for Op {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        let str_repr = match self {
130            Self::Discard => format!("{}", "Discard".red().bold()),
131            Self::Add => "Add".green().bold().to_string(),
132        };
133        write!(f, "{str_repr}")
134    }
135}
136
137/// Delete newly created paths then restore modified paths (optionally from a branch)
138///
139/// Performs a two‑phase operation over the provided `entries`:
140/// 1) Physically removes any paths that are newly added (worktree and/or index). For paths that were staged as new,
141///    their repo‑relative paths are collected and subsequently unstaged via [`ytil_git::unstage`], ensuring only the
142///    index is touched (no accidental content resurrection).
143/// 2) Invokes [`ytil_git::restore`] for any remaining changed (non‑new) entries, optionally specifying `branch` so
144///    contents are restored from that branch rather than the index / HEAD.
145///
146/// Early exit: if after deleting new entries there are no remaining changed entries, the
147/// restore phase is skipped.
148///
149/// # Errors
150/// - Removing a file or directory for a new entry fails (I/O error from `std::fs`).
151/// - Unstaging staged new entries via [`ytil_git::unstage`] fails.
152/// - Building or executing the underlying `git restore` command via [`ytil_git::restore`] fails.
153///
154/// # Rationale
155/// Using the porcelain `git restore` preserves nuanced semantics (e.g. respect for sparse
156/// checkout, renames) without re‑implementing them atop libgit2.
157///
158/// # Future Work
159/// - Detect & report partial failures (continue deletion on best‑effort then aggregate errors).
160/// - Parallelize deletions if ever shown to be a bottleneck (likely unnecessary for typical counts).
161fn restore_entries(entries: &[&GitStatusEntry], branch: Option<&str>) -> color_eyre::Result<()> {
162    // Avoid creating &&GitStatusEntry by copying the slice of &GitStatusEntry directly.
163    let (new_entries, changed_entries): (Vec<&GitStatusEntry>, Vec<&GitStatusEntry>) =
164        entries.iter().copied().partition(|entry| entry.is_new());
165
166    let mut new_entries_in_index = vec![];
167    for new_entry in &new_entries {
168        let absolute_path = new_entry.absolute_path();
169        if absolute_path.is_file() || absolute_path.is_symlink() {
170            std::fs::remove_file(&absolute_path)?;
171        } else if absolute_path.is_dir() {
172            std::fs::remove_dir_all(&absolute_path)?;
173        }
174        println!("{} {}", "Discarded".red().bold(), new_entry.path.display());
175        if new_entry.is_new_in_index() {
176            new_entries_in_index.push(absolute_path.display().to_string());
177        }
178    }
179    // Use repo-relative paths for unstaging so we *only* touch the index.
180    ytil_git::unstage(&new_entries_in_index.iter().map(String::as_str).collect::<Vec<_>>())?;
181
182    // Exit early in case of no changes to avoid break `git restore` cmd.
183    if changed_entries.is_empty() {
184        return Ok(());
185    }
186
187    let changed_entries_paths = changed_entries
188        .iter()
189        .map(|changed_entry| changed_entry.absolute_path().to_string_lossy().into_owned());
190
191    ytil_git::restore(changed_entries_paths, branch)?;
192
193    for changed_entry in changed_entries {
194        let from_branch = branch.map(|b| format!(" from {b}")).unwrap_or_default();
195        println!(
196            "{} {}{from_branch}",
197            "Restored".yellow().bold(),
198            changed_entry.path.display()
199        );
200    }
201    Ok(())
202}
203
204/// Add the provided entries to the Git index (equivalent to `git add` on each path).
205///
206/// # Errors
207/// - Opening the repository via [`ytil_git::repo::discover`] fails.
208/// - Adding any path to the index via [`ytil_git::add_to_index`] fails.
209fn add_entries(entries: &[&GitStatusEntry]) -> color_eyre::Result<()> {
210    let mut repo = ytil_git::repo::discover(Path::new("."))?;
211    ytil_git::add_to_index(&mut repo, entries.iter().map(|entry| entry.path.as_path()))?;
212    for entry in entries {
213        println!("{} {}", "Added".green().bold(), entry.path.display());
214    }
215    Ok(())
216}
217
218/// Stage or discard selected Git changes interactively.
219///
220/// # Errors
221/// - Status enumeration via [`ytil_git::get_status`] fails.
222/// - User interaction (selection prompts via [`ytil_tui::minimal_multi_select`] and [`ytil_tui::minimal_select`])
223///   fails.
224/// - File / directory removal for new entries fails.
225/// - Unstaging new index entries via [`ytil_git::unstage`] fails.
226/// - Restore command construction / execution via [`ytil_git::restore`] fails.
227/// - Opening repository via [`ytil_git::repo::discover`] or adding paths to index via [`ytil_git::add_to_index`] fails.
228fn main() -> color_eyre::Result<()> {
229    color_eyre::install()?;
230
231    let args = ytil_sys::cli::get();
232    if args.has_help() {
233        println!("{}", include_str!("../help.txt"));
234        return Ok(());
235    }
236    let args: Vec<_> = args.iter().map(String::as_str).collect();
237
238    let git_status_entries = ytil_git::get_status()?;
239    if git_status_entries.is_empty() {
240        println!("Working tree clean");
241        return Ok(());
242    }
243
244    let renderable_entries = git_status_entries.into_iter().map(RenderableGitStatusEntry).collect();
245
246    let Some(selected_entries) = ytil_tui::minimal_multi_select::<RenderableGitStatusEntry>(renderable_entries)? else {
247        println!("\n\nNo entries selected");
248        return Ok(());
249    };
250
251    let Some(selected_op) = ytil_tui::minimal_select::<Op>(Op::iter().collect())? else {
252        println!("\n\nNothing operation selected");
253        return Ok(());
254    };
255
256    let selected_entries = selected_entries.iter().map(Deref::deref).collect::<Vec<_>>();
257    match selected_op {
258        Op::Discard => restore_entries(&selected_entries, args.first().copied())?,
259        Op::Add => add_entries(&selected_entries)?,
260    }
261
262    Ok(())
263}