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}