1use anyhow::{bail, Result};
20use crossterm::{
21 event::{self, Event, KeyCode, KeyEventKind},
22 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
23 ExecutableCommand,
24};
25use ratatui::{
26 layout::{Constraint, Layout, Rect},
27 style::{Color, Style, Stylize},
28 text::{Line, Span},
29 widgets::{Block, BorderType, Borders, Paragraph},
30 Frame,
31};
32use resq_tui::{self as tui, Theme};
33use std::io;
34use std::path::{Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::sync::mpsc;
37use std::time::{Duration, Instant};
38
39#[derive(clap::Args, Debug)]
43pub struct PreCommitArgs {
44 #[arg(long, default_value = ".")]
46 pub root: PathBuf,
47
48 #[arg(long)]
50 pub skip_audit: bool,
51
52 #[arg(long)]
54 pub skip_format: bool,
55
56 #[arg(long)]
58 pub skip_versioning: bool,
59
60 #[arg(long, default_value_t = 10_485_760)]
62 pub max_file_size: u64,
63
64 #[arg(long)]
66 pub no_tui: bool,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum StepStatus {
74 Pending,
75 Running,
76 Pass,
77 Warn,
78 Skip,
79 Fail,
80}
81
82impl StepStatus {
83 fn icon(self) -> &'static str {
84 match self {
85 Self::Pending => "○",
86 Self::Running => "●",
87 Self::Pass => "✅",
88 Self::Warn => "⚠️ ",
89 Self::Skip => "⏭️ ",
90 Self::Fail => "❌",
91 }
92 }
93
94 fn color(self, theme: &Theme) -> Color {
95 match self {
96 Self::Pending => theme.inactive,
97 Self::Running => theme.primary,
98 Self::Pass => theme.success,
99 Self::Warn => theme.warning,
100 Self::Skip => theme.inactive,
101 Self::Fail => theme.error,
102 }
103 }
104
105 fn is_terminal(self) -> bool {
106 matches!(self, Self::Pass | Self::Warn | Self::Skip | Self::Fail)
107 }
108}
109
110#[derive(Debug, Clone)]
112struct StepState {
113 name: String,
114 emoji: String,
115 status: StepStatus,
116 detail: Option<String>,
117 sub_lines: Vec<String>,
118 elapsed: Option<Duration>,
119 is_formatter: bool,
120 is_versioning: bool,
121}
122
123#[derive(Debug)]
125enum StepMsg {
126 Started(usize),
127 Finished(usize, StepStatus, Option<String>, Vec<String>, Duration),
128 Output(usize, String),
129 PromptChangeset(usize),
130 AllDone,
131}
132
133#[derive(Debug)]
134enum ChangesetResponse {
135 Patch,
136 Minor,
137 Major,
138 None,
139}
140
141struct App {
144 steps: Vec<StepState>,
145 start_time: Instant,
146 done: bool,
147 aborted: bool,
148 spinner_tick: usize,
149 scroll_offset: usize,
150 theme: Theme,
151
152 prompting_idx: Option<usize>,
154 changeset_selector: usize, changeset_message: String,
156 entering_message: bool,
157}
158
159impl App {
160 fn new(steps: Vec<StepState>) -> Self {
161 Self {
162 steps,
163 start_time: Instant::now(),
164 done: false,
165 aborted: false,
166 spinner_tick: 0,
167 scroll_offset: 0,
168 theme: Theme::default(),
169 prompting_idx: None,
170 changeset_selector: 0,
171 changeset_message: String::new(),
172 entering_message: false,
173 }
174 }
175
176 fn apply(&mut self, msg: StepMsg) {
177 match msg {
178 StepMsg::Started(i) => {
179 if let Some(s) = self.steps.get_mut(i) {
180 s.status = StepStatus::Running;
181 }
182 }
183 StepMsg::Finished(i, status, detail, sub_lines, elapsed) => {
184 if let Some(s) = self.steps.get_mut(i) {
185 s.status = status;
186 s.detail = detail;
187 s.sub_lines = sub_lines;
188 s.elapsed = Some(elapsed);
189 }
190 }
191 StepMsg::Output(i, line) => {
192 if let Some(s) = self.steps.get_mut(i) {
193 if s.sub_lines.len() > 10 {
194 s.sub_lines.remove(0);
195 }
196 s.sub_lines.push(line);
197 }
198 }
199 StepMsg::PromptChangeset(i) => {
200 self.prompting_idx = Some(i);
201 if let Some(s) = self.steps.get_mut(i) {
202 s.status = StepStatus::Running;
203 }
204 }
205 StepMsg::AllDone => {
206 self.done = true;
207 }
208 }
209 }
210
211 fn counts(&self) -> (usize, usize, usize, usize) {
212 let mut p = 0;
213 let mut f = 0;
214 let mut w = 0;
215 let mut s = 0;
216 for step in &self.steps {
217 match step.status {
218 StepStatus::Pass => p += 1,
219 StepStatus::Fail => f += 1,
220 StepStatus::Warn => w += 1,
221 StepStatus::Skip => s += 1,
222 _ => {}
223 }
224 }
225 (p, f, w, s)
226 }
227}
228
229fn draw(frame: &mut Frame, app: &App) {
232 let size = frame.area();
233 let chunks = Layout::vertical([
234 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ])
238 .split(size);
239
240 draw_header(frame, chunks[0], app);
241 draw_steps(frame, chunks[1], app);
242 draw_footer(frame, chunks[2], app);
243
244 if app.prompting_idx.is_some() {
245 draw_prompt(frame, size, app);
246 }
247}
248
249fn draw_header(frame: &mut Frame, area: Rect, app: &App) {
250 let (pass, fail, _warn, _skip) = app.counts();
251 let status_text = if app.done {
252 if fail > 0 {
253 format!("FAILED ({fail} ERROR)")
254 } else {
255 format!("PASSED ({pass} OK)")
256 }
257 } else if app.prompting_idx.is_some() {
258 "WAITING FOR INPUT".to_string()
259 } else {
260 let done = app.steps.iter().filter(|s| s.status.is_terminal()).count();
261 format!("RUNNING ({done}/{})", app.steps.len())
262 };
263
264 let status_color = if app.done {
265 if fail > 0 {
266 app.theme.error
267 } else {
268 app.theme.success
269 }
270 } else {
271 app.theme.warning
272 };
273
274 tui::draw_header(
275 frame,
276 area,
277 "Pre-commit",
278 &status_text,
279 status_color,
280 None,
281 &format!("{}s", app.start_time.elapsed().as_secs()),
282 &app.theme,
283 );
284}
285
286fn draw_steps(frame: &mut Frame, area: Rect, app: &App) {
287 let block = Block::default()
288 .borders(Borders::ALL)
289 .border_type(BorderType::Rounded)
290 .border_style(Style::default().fg(app.theme.inactive))
291 .title(" Verification Pipeline ");
292
293 let inner = block.inner(area);
294 frame.render_widget(block, area);
295
296 let mut lines = Vec::new();
297 let mut saw_formatter = false;
298 let mut saw_versioning = false;
299
300 for step in &app.steps {
301 if step.is_formatter && !saw_formatter {
302 saw_formatter = true;
303 lines.push(Line::from(
304 " ── Formatters ──────────────────────────────────────────".fg(app.theme.inactive),
305 ));
306 }
307 if step.is_versioning && !saw_versioning {
308 saw_versioning = true;
309 lines.push(Line::from(
310 " ── Versioning ──────────────────────────────────────────".fg(app.theme.inactive),
311 ));
312 }
313
314 let spinner = tui::SPINNER_FRAMES[app.spinner_tick % tui::SPINNER_FRAMES.len()];
315 let icon = if step.status == StepStatus::Running {
316 spinner
317 } else {
318 step.status.icon()
319 };
320
321 let mut spans = vec![
322 Span::raw(" "),
323 Span::styled(
324 format!("{icon} "),
325 Style::default().fg(step.status.color(&app.theme)),
326 ),
327 Span::styled(format!("{} ", step.emoji), Style::default()),
328 Span::styled(
329 step.name.clone(),
330 Style::default().fg(if step.status == StepStatus::Pending {
331 app.theme.inactive
332 } else {
333 app.theme.fg
334 }),
335 ),
336 ];
337
338 if let Some(ref d) = step.detail {
339 spans.push(Span::raw(format!(" {d}")).fg(app.theme.inactive));
340 }
341
342 lines.push(Line::from(spans));
343
344 if (step.status == StepStatus::Running
345 || step.status == StepStatus::Fail
346 || step.status == StepStatus::Warn)
347 && !step.sub_lines.is_empty()
348 {
349 for sub in step.sub_lines.iter().rev().take(3).rev() {
350 lines.push(Line::from(
351 format!(" └─ {sub}").fg(app.theme.inactive),
352 ));
353 }
354 }
355 }
356
357 frame.render_widget(
358 Paragraph::new(lines).scroll((app.scroll_offset as u16, 0)),
359 inner,
360 );
361}
362
363fn draw_prompt(frame: &mut Frame, area: Rect, app: &App) {
364 let prompt_area = tui::centered_rect(60, 40, area);
365 frame.render_widget(ratatui::widgets::Clear, prompt_area);
366
367 let block = Block::default()
368 .borders(Borders::ALL)
369 .border_type(BorderType::Double)
370 .border_style(Style::default().fg(app.theme.primary))
371 .title(" Version Bump Intent ")
372 .bg(Color::Black);
373
374 let inner = block.inner(prompt_area);
375 frame.render_widget(block, prompt_area);
376
377 let chunks = Layout::vertical([
378 Constraint::Length(2),
379 Constraint::Length(3),
380 Constraint::Length(1),
381 Constraint::Length(3),
382 Constraint::Min(1),
383 ])
384 .split(inner);
385
386 frame.render_widget(
387 Paragraph::new("Does this change require a version bump?").bold(),
388 chunks[0],
389 );
390
391 let options = [" None ", " Patch ", " Minor ", " Major "];
392 let spans: Vec<Span> = options
393 .iter()
394 .enumerate()
395 .map(|(i, &opt)| {
396 if i == app.changeset_selector {
397 Span::styled(
398 opt,
399 Style::default()
400 .bg(app.theme.primary)
401 .fg(app.theme.bg)
402 .bold(),
403 )
404 } else {
405 Span::raw(opt).fg(app.theme.fg)
406 }
407 })
408 .collect();
409
410 frame.render_widget(Paragraph::new(Line::from(spans)), chunks[1]);
411
412 if app.changeset_selector > 0 {
413 let msg_style = if app.entering_message {
414 app.theme.primary
415 } else {
416 app.theme.inactive
417 };
418 let msg_block = Block::default()
419 .borders(Borders::ALL)
420 .border_type(BorderType::Rounded)
421 .border_style(Style::default().fg(msg_style))
422 .title(" Change Summary ");
423 let msg = if app.changeset_message.is_empty() && !app.entering_message {
424 "Press 'm' to add a summary..."
425 } else {
426 &app.changeset_message
427 };
428 frame.render_widget(Paragraph::new(msg).block(msg_block), chunks[3]);
429 }
430
431 let help = if app.entering_message {
432 "Enter: confirm, Esc: cancel"
433 } else {
434 "←/→ Select, 'm' Message, Enter: Commit, Esc: Abort"
435 };
436 frame.render_widget(Paragraph::new(help).fg(app.theme.inactive), chunks[4]);
437}
438
439fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
440 let keys = if app.done {
441 vec![("Enter", "Exit"), ("q", "Quit")]
442 } else if app.prompting_idx.is_some() {
443 vec![("Enter", "Confirm"), ("Esc", "Abort")]
444 } else {
445 vec![("q", "Abort"), ("↑/↓", "Scroll")]
446 };
447 tui::draw_footer(frame, area, &keys, &app.theme);
448}
449
450fn staged_files(exts: &[&str]) -> Vec<String> {
453 let output = Command::new("git")
454 .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"])
455 .output();
456 let Ok(output) = output else { return vec![] };
457 if !output.status.success() {
458 return vec![];
459 }
460 String::from_utf8_lossy(&output.stdout)
461 .lines()
462 .filter(|f| exts.iter().any(|ext| f.ends_with(ext)))
463 .filter(|f| !f.contains("/vendor/"))
464 .map(String::from)
465 .collect()
466}
467
468fn restage(files: &[String]) {
469 if !files.is_empty() {
470 let _ = Command::new("git").arg("add").args(files).status();
471 }
472}
473
474#[allow(dead_code)]
475fn has_cmd(cmd: &str) -> bool {
476 Command::new("which")
477 .arg(cmd)
478 .stdout(Stdio::null())
479 .stderr(Stdio::null())
480 .status()
481 .map(|s| s.success())
482 .unwrap_or(false)
483}
484
485fn self_exe() -> PathBuf {
486 std::env::current_exe().unwrap_or_else(|_| PathBuf::from("resq"))
487}
488
489struct StepResult {
490 status: StepStatus,
491 detail: Option<String>,
492 sub_lines: Vec<String>,
493}
494
495fn step_copyright(root: &Path) -> StepResult {
498 let exe = self_exe();
499 let ok = Command::new(&exe)
500 .arg("copyright")
501 .current_dir(root)
502 .stdout(Stdio::null())
503 .stderr(Stdio::null())
504 .status()
505 .map(|s| s.success())
506 .unwrap_or(false);
507 if !ok {
508 return StepResult {
509 status: StepStatus::Fail,
510 detail: Some("write failed".into()),
511 sub_lines: vec![],
512 };
513 }
514 let _ = Command::new("git")
515 .args(["add", "-u"])
516 .current_dir(root)
517 .status();
518 let ok = Command::new(&exe)
519 .args(["copyright", "--check"])
520 .current_dir(root)
521 .stdout(Stdio::null())
522 .stderr(Stdio::null())
523 .status()
524 .map(|s| s.success())
525 .unwrap_or(false);
526 StepResult {
527 status: if ok {
528 StepStatus::Pass
529 } else {
530 StepStatus::Fail
531 },
532 detail: if ok {
533 None
534 } else {
535 Some("headers missing".into())
536 },
537 sub_lines: vec![],
538 }
539}
540
541fn step_large_files(max_size: u64) -> StepResult {
542 let output = Command::new("git")
543 .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"])
544 .output();
545 let Ok(output) = output else {
546 return StepResult {
547 status: StepStatus::Skip,
548 detail: None,
549 sub_lines: vec![],
550 };
551 };
552 let files = String::from_utf8_lossy(&output.stdout);
553 let mut large = Vec::new();
554 for f in files.lines() {
555 if let Ok(m) = std::fs::metadata(f) {
556 if m.len() > max_size {
557 large.push(format!("{f} ({:.1} MiB)", m.len() as f64 / 1048576.0));
558 }
559 }
560 }
561 if large.is_empty() {
562 StepResult {
563 status: StepStatus::Pass,
564 detail: None,
565 sub_lines: vec![],
566 }
567 } else {
568 StepResult {
569 status: StepStatus::Fail,
570 detail: Some(format!("{} files too large", large.len())),
571 sub_lines: large,
572 }
573 }
574}
575
576fn step_debug_statements() -> StepResult {
577 let files = staged_files(&[".rs", ".ts", ".tsx", ".js", ".jsx", ".py"]);
578 let mut warnings = Vec::new();
579 for file in &files {
580 let out = Command::new("git")
581 .args(["diff", "--cached", "--", file])
582 .output();
583 let Ok(out) = out else { continue };
584 let diff = String::from_utf8_lossy(&out.stdout);
585 let patterns = if file.ends_with(".py") {
586 vec!["print(", "breakpoint(", "import pdb"]
587 } else {
588 vec!["console.log", "dbg!", "debugger;"]
589 };
590 for line in diff
591 .lines()
592 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
593 {
594 if patterns.iter().any(|p| line.contains(p)) {
595 warnings.push(file.clone());
596 break;
597 }
598 }
599 }
600 if warnings.is_empty() {
601 StepResult {
602 status: StepStatus::Pass,
603 detail: None,
604 sub_lines: vec![],
605 }
606 } else {
607 StepResult {
608 status: StepStatus::Warn,
609 detail: Some(format!("{} debug stmts", warnings.len())),
610 sub_lines: warnings,
611 }
612 }
613}
614
615fn step_secrets() -> StepResult {
616 let exe = self_exe();
617 let ok = Command::new(&exe)
618 .args(["secrets", "--staged"])
619 .stdout(Stdio::null())
620 .stderr(Stdio::null())
621 .status()
622 .map(|s| s.success())
623 .unwrap_or(false);
624 StepResult {
625 status: if ok {
626 StepStatus::Pass
627 } else {
628 StepStatus::Fail
629 },
630 detail: if ok {
631 None
632 } else {
633 Some("secrets detected".into())
634 },
635 sub_lines: vec![],
636 }
637}
638
639fn step_audit(root: &Path, tx: &mpsc::Sender<StepMsg>, idx: usize) -> StepResult {
640 let exe = self_exe();
641 let mut args = vec!["audit", "--skip-react"];
642 if staged_files(&["package.json", "bun.lockb", "bun.lock"]).is_empty() {
643 args.push("--skip-npm");
644 }
645 let child = Command::new(&exe)
646 .args(&args)
647 .current_dir(root)
648 .stdin(Stdio::null())
649 .stdout(Stdio::piped())
650 .stderr(Stdio::piped())
651 .spawn();
652 let Ok(child) = child else {
653 return StepResult {
654 status: StepStatus::Fail,
655 detail: Some("spawn failed".into()),
656 sub_lines: vec![],
657 };
658 };
659 let output = child.wait_with_output();
660 let Ok(output) = output else {
661 return StepResult {
662 status: StepStatus::Fail,
663 detail: Some("exec failed".into()),
664 sub_lines: vec![],
665 };
666 };
667 let combined = format!(
668 "{}{}",
669 String::from_utf8_lossy(&output.stdout),
670 String::from_utf8_lossy(&output.stderr)
671 );
672 let interesting: Vec<String> = combined
673 .lines()
674 .filter(|l| l.contains("✅") || l.contains("❌") || l.contains("Vulnerabilities"))
675 .map(|l| l.trim().to_string())
676 .take(5)
677 .collect();
678 for line in &interesting {
679 let _ = tx.send(StepMsg::Output(idx, line.clone()));
680 }
681 StepResult {
682 status: if output.status.success() {
683 StepStatus::Pass
684 } else {
685 StepStatus::Fail
686 },
687 detail: None,
688 sub_lines: interesting,
689 }
690}
691
692fn run_format_step<F>(root: &Path, exts: &[&str], formatter: F) -> StepResult
697where
698 F: FnOnce(&Path, &[String], bool) -> anyhow::Result<crate::commands::format::FormatOutcome>,
699{
700 let files = staged_files(exts);
701 if files.is_empty() {
702 return StepResult {
703 status: StepStatus::Skip,
704 detail: Some("no files".into()),
705 sub_lines: vec![],
706 };
707 }
708 match formatter(root, &files, false) {
709 Ok(crate::commands::format::FormatOutcome::Clean)
710 | Ok(crate::commands::format::FormatOutcome::Formatted) => {
711 restage(&files);
712 StepResult {
713 status: StepStatus::Pass,
714 detail: None,
715 sub_lines: vec![],
716 }
717 }
718 Ok(crate::commands::format::FormatOutcome::Skipped(reason)) => StepResult {
719 status: StepStatus::Skip,
720 detail: Some(reason),
721 sub_lines: vec![],
722 },
723 Ok(crate::commands::format::FormatOutcome::Failed(stderr)) => {
724 if !stderr.trim().is_empty() {
725 eprintln!("{stderr}");
726 }
727 StepResult {
728 status: StepStatus::Fail,
729 detail: None,
730 sub_lines: vec![],
731 }
732 }
733 Err(e) => StepResult {
734 status: StepStatus::Fail,
735 detail: Some(e.to_string()),
736 sub_lines: vec![],
737 },
738 }
739}
740
741fn step_format_rust(root: &Path) -> StepResult {
742 run_format_step(root, &[".rs"], crate::commands::format::format_rust)
743}
744
745fn step_format_ts(root: &Path) -> StepResult {
746 run_format_step(
747 root,
748 &[".ts", ".tsx", ".js", ".jsx", ".json", ".css"],
749 crate::commands::format::format_ts,
750 )
751}
752
753fn step_format_python(root: &Path) -> StepResult {
754 run_format_step(root, &[".py"], crate::commands::format::format_python)
755}
756
757fn step_format_cpp(root: &Path) -> StepResult {
758 run_format_step(
759 root,
760 &[".cpp", ".cc", ".h", ".hpp"],
761 crate::commands::format::format_cpp,
762 )
763}
764
765fn step_format_csharp(root: &Path) -> StepResult {
766 run_format_step(root, &[".cs"], crate::commands::format::format_csharp)
767}
768
769fn build_step_list(skip_audit: bool, skip_format: bool, skip_versioning: bool) -> Vec<StepState> {
772 let mut steps = vec![
773 StepState {
774 name: "Copyright headers".into(),
775 emoji: "📝".into(),
776 status: StepStatus::Pending,
777 detail: None,
778 sub_lines: vec![],
779 elapsed: None,
780 is_formatter: false,
781 is_versioning: false,
782 },
783 StepState {
784 name: "Large file check".into(),
785 emoji: "📦".into(),
786 status: StepStatus::Pending,
787 detail: None,
788 sub_lines: vec![],
789 elapsed: None,
790 is_formatter: false,
791 is_versioning: false,
792 },
793 StepState {
794 name: "Debug statements".into(),
795 emoji: "🐛".into(),
796 status: StepStatus::Pending,
797 detail: None,
798 sub_lines: vec![],
799 elapsed: None,
800 is_formatter: false,
801 is_versioning: false,
802 },
803 StepState {
804 name: "Secrets scan".into(),
805 emoji: "🔐".into(),
806 status: StepStatus::Pending,
807 detail: None,
808 sub_lines: vec![],
809 elapsed: None,
810 is_formatter: false,
811 is_versioning: false,
812 },
813 StepState {
814 name: "Security audit".into(),
815 emoji: "🔒".into(),
816 status: if skip_audit {
817 StepStatus::Skip
818 } else {
819 StepStatus::Pending
820 },
821 detail: None,
822 sub_lines: vec![],
823 elapsed: None,
824 is_formatter: false,
825 is_versioning: false,
826 },
827 ];
828 if !skip_format {
829 steps.extend(vec![
830 StepState {
831 name: "Format Rust".into(),
832 emoji: "🦀".into(),
833 status: StepStatus::Pending,
834 detail: None,
835 sub_lines: vec![],
836 elapsed: None,
837 is_formatter: true,
838 is_versioning: false,
839 },
840 StepState {
841 name: "Format TS/JS".into(),
842 emoji: "🎨".into(),
843 status: StepStatus::Pending,
844 detail: None,
845 sub_lines: vec![],
846 elapsed: None,
847 is_formatter: true,
848 is_versioning: false,
849 },
850 StepState {
851 name: "Format Python".into(),
852 emoji: "🐍".into(),
853 status: StepStatus::Pending,
854 detail: None,
855 sub_lines: vec![],
856 elapsed: None,
857 is_formatter: true,
858 is_versioning: false,
859 },
860 StepState {
861 name: "Format C++".into(),
862 emoji: "⚙️ ".into(),
863 status: StepStatus::Pending,
864 detail: None,
865 sub_lines: vec![],
866 elapsed: None,
867 is_formatter: true,
868 is_versioning: false,
869 },
870 StepState {
871 name: "Format C#".into(),
872 emoji: "🔷".into(),
873 status: StepStatus::Pending,
874 detail: None,
875 sub_lines: vec![],
876 elapsed: None,
877 is_formatter: true,
878 is_versioning: false,
879 },
880 ]);
881 }
882 if !skip_versioning {
883 steps.push(StepState {
884 name: "Versioning / Changeset".into(),
885 emoji: "🏷️".into(),
886 status: StepStatus::Pending,
887 detail: None,
888 sub_lines: vec![],
889 elapsed: None,
890 is_formatter: false,
891 is_versioning: true,
892 });
893 }
894 steps
895}
896
897fn run_worker(
898 tx: mpsc::Sender<StepMsg>,
899 res_rx: mpsc::Receiver<ChangesetResponse>,
900 root: PathBuf,
901 skip_audit: bool,
902 skip_format: bool,
903 skip_versioning: bool,
904 max_file_size: u64,
905) {
906 let mut idx = 0;
907 macro_rules! run {
908 ($step_fn:expr) => {{
909 let _ = tx.send(StepMsg::Started(idx));
910 let start = Instant::now();
911 let res = $step_fn;
912 let _ = tx.send(StepMsg::Finished(
913 idx,
914 res.status,
915 res.detail,
916 res.sub_lines,
917 start.elapsed(),
918 ));
919 idx += 1;
920 }};
921 }
922
923 run!(step_copyright(&root));
924 run!(step_large_files(max_file_size));
925 run!(step_debug_statements());
926 run!(step_secrets());
927 if skip_audit {
928 idx += 1;
929 } else {
930 run!(step_audit(&root, &tx, idx));
931 }
932
933 if !skip_format {
934 run!(step_format_rust(&root));
935 run!(step_format_ts(&root));
936 run!(step_format_python(&root));
937 run!(step_format_cpp(&root));
938 run!(step_format_csharp(&root));
939 }
940
941 if !skip_versioning {
942 let _ = tx.send(StepMsg::PromptChangeset(idx));
943 if let Ok(resp) = res_rx.recv() {
944 let (status, detail) = match resp {
945 ChangesetResponse::None => (StepStatus::Skip, Some("no bump".into())),
946 _ => (StepStatus::Pass, Some("bump recorded".into())),
947 };
948 let _ = tx.send(StepMsg::Finished(
949 idx,
950 status,
951 detail,
952 vec![],
953 Duration::ZERO,
954 ));
955 }
956 }
957 let _ = tx.send(StepMsg::AllDone);
958}
959
960pub async fn run(args: PreCommitArgs) -> Result<()> {
964 let root = if args.root == Path::new(".") {
965 crate::utils::find_project_root()
966 } else {
967 args.root.clone()
968 };
969 if args.no_tui
970 || !crossterm::tty::IsTty::is_tty(&io::stderr())
971 || std::env::var_os("GIT_INDEX_FILE").is_some()
972 {
973 return run_plain(&root, args.skip_audit, args.skip_format, args.max_file_size);
974 }
975
976 let steps = build_step_list(args.skip_audit, args.skip_format, args.skip_versioning);
977 let mut app = App::new(steps);
978 let (tx, rx) = mpsc::channel();
979 let (res_tx, res_rx) = mpsc::channel();
980 let worker_root = root.clone();
981
982 std::thread::spawn(move || {
983 run_worker(
984 tx,
985 res_rx,
986 worker_root,
987 args.skip_audit,
988 args.skip_format,
989 args.skip_versioning,
990 args.max_file_size,
991 );
992 });
993
994 enable_raw_mode()?;
995 io::stderr().execute(EnterAlternateScreen)?;
996 let mut terminal =
997 ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(io::stderr()))?;
998 let mut done_at: Option<Instant> = None;
999
1000 loop {
1001 while let Ok(msg) = rx.try_recv() {
1002 app.apply(msg);
1003 }
1004 if app.done && done_at.is_none() {
1005 done_at = Some(Instant::now());
1006 }
1007 if let Some(t) = done_at {
1008 if t.elapsed() > Duration::from_millis(500)
1009 && !app.aborted
1010 && app.prompting_idx.is_none()
1011 {
1012 break;
1013 }
1014 }
1015 app.spinner_tick = app.spinner_tick.wrapping_add(1);
1016 terminal.draw(|f| draw(f, &app))?;
1017
1018 if event::poll(Duration::from_millis(50))? {
1019 if let Event::Key(key) = event::read()? {
1020 if key.kind != KeyEventKind::Press {
1021 continue;
1022 }
1023 if app.prompting_idx.is_some() {
1024 if app.entering_message {
1025 match key.code {
1026 KeyCode::Char(c) => app.changeset_message.push(c),
1027 KeyCode::Backspace => {
1028 app.changeset_message.pop();
1029 }
1030 KeyCode::Enter => app.entering_message = false,
1031 KeyCode::Esc => {
1032 app.entering_message = false;
1033 app.changeset_message.clear();
1034 }
1035 _ => {}
1036 }
1037 } else {
1038 match key.code {
1039 KeyCode::Left => {
1040 app.changeset_selector = app.changeset_selector.saturating_sub(1);
1041 }
1042 KeyCode::Right if app.changeset_selector < 3 => {
1043 app.changeset_selector += 1;
1044 }
1045 KeyCode::Char('m') if app.changeset_selector > 0 => {
1046 app.entering_message = true;
1047 }
1048 KeyCode::Enter => {
1049 let resp = match app.changeset_selector {
1050 1 => ChangesetResponse::Patch,
1051 2 => ChangesetResponse::Minor,
1052 3 => ChangesetResponse::Major,
1053 _ => ChangesetResponse::None,
1054 };
1055 if app.changeset_selector > 0 {
1056 let bump = match resp {
1057 ChangesetResponse::Patch => "patch",
1058 ChangesetResponse::Minor => "minor",
1059 _ => "major",
1060 };
1061 let _ = Command::new(self_exe())
1062 .args([
1063 "version",
1064 "add",
1065 "--bump",
1066 bump,
1067 "--message",
1068 &app.changeset_message,
1069 ])
1070 .current_dir(&root)
1071 .status();
1072 }
1073 let _ = res_tx.send(resp);
1074 }
1075 KeyCode::Esc => {
1076 app.aborted = true;
1077 break;
1078 }
1079 _ => {}
1080 }
1081 }
1082 continue;
1083 }
1084 match key.code {
1085 KeyCode::Char('q') | KeyCode::Esc => {
1086 app.aborted = true;
1087 break;
1088 }
1089 KeyCode::Enter if app.done => break,
1090 KeyCode::Up => app.scroll_offset = app.scroll_offset.saturating_sub(1),
1091 KeyCode::Down => app.scroll_offset = app.scroll_offset.saturating_add(1),
1092 _ => {}
1093 }
1094 }
1095 }
1096 }
1097
1098 disable_raw_mode()?;
1099 io::stderr().execute(LeaveAlternateScreen)?;
1100 if app.aborted {
1101 bail!("pre-commit aborted");
1102 }
1103 let (_, fail, _, _) = app.counts();
1104 if fail > 0 {
1105 bail!("pre-commit checks failed");
1106 }
1107 Ok(())
1108}
1109
1110fn run_plain(root: &Path, skip_audit: bool, skip_format: bool, max_file_size: u64) -> Result<()> {
1111 eprintln!("🔍 ResQ Pre-commit Checks\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1112 let mut fail = false;
1113 macro_rules! run {
1114 ($name:expr, $fn:expr) => {{
1115 eprint!(" {}...", $name);
1116 let res = $fn;
1117 eprintln!("\r {} {}", res.status.icon(), $name);
1118 if res.status == StepStatus::Fail {
1119 fail = true;
1120 }
1121 }};
1122 }
1123 run!("Copyright", step_copyright(root));
1124 run!("Large Files", step_large_files(max_file_size));
1125 run!("Debug Stmts", step_debug_statements());
1126 run!("Secrets", step_secrets());
1127 if !skip_audit {
1128 run!("Audit", step_audit(root, &mpsc::channel().0, 0));
1129 }
1130 if !skip_format {
1131 run!("Format Rust", step_format_rust(root));
1132 run!("Format TS", step_format_ts(root));
1133 run!("Format Python", step_format_python(root));
1134 run!("Format C++", step_format_cpp(root));
1135 run!("Format C#", step_format_csharp(root));
1136 }
1137 if fail {
1138 bail!("checks failed");
1139 }
1140 Ok(())
1141}