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
474fn has_cmd(cmd: &str) -> bool {
475 Command::new("which")
476 .arg(cmd)
477 .stdout(Stdio::null())
478 .stderr(Stdio::null())
479 .status()
480 .map(|s| s.success())
481 .unwrap_or(false)
482}
483
484fn self_exe() -> PathBuf {
485 std::env::current_exe().unwrap_or_else(|_| PathBuf::from("resq"))
486}
487
488struct StepResult {
489 status: StepStatus,
490 detail: Option<String>,
491 sub_lines: Vec<String>,
492}
493
494fn step_copyright(root: &Path) -> StepResult {
497 let exe = self_exe();
498 let ok = Command::new(&exe)
499 .arg("copyright")
500 .current_dir(root)
501 .stdout(Stdio::null())
502 .stderr(Stdio::null())
503 .status()
504 .map(|s| s.success())
505 .unwrap_or(false);
506 if !ok {
507 return StepResult {
508 status: StepStatus::Fail,
509 detail: Some("write failed".into()),
510 sub_lines: vec![],
511 };
512 }
513 let _ = Command::new("git")
514 .args(["add", "-u"])
515 .current_dir(root)
516 .status();
517 let ok = Command::new(&exe)
518 .args(["copyright", "--check"])
519 .current_dir(root)
520 .stdout(Stdio::null())
521 .stderr(Stdio::null())
522 .status()
523 .map(|s| s.success())
524 .unwrap_or(false);
525 StepResult {
526 status: if ok {
527 StepStatus::Pass
528 } else {
529 StepStatus::Fail
530 },
531 detail: if ok {
532 None
533 } else {
534 Some("headers missing".into())
535 },
536 sub_lines: vec![],
537 }
538}
539
540fn step_large_files(max_size: u64) -> StepResult {
541 let output = Command::new("git")
542 .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"])
543 .output();
544 let Ok(output) = output else {
545 return StepResult {
546 status: StepStatus::Skip,
547 detail: None,
548 sub_lines: vec![],
549 };
550 };
551 let files = String::from_utf8_lossy(&output.stdout);
552 let mut large = Vec::new();
553 for f in files.lines() {
554 if let Ok(m) = std::fs::metadata(f) {
555 if m.len() > max_size {
556 large.push(format!("{f} ({:.1} MiB)", m.len() as f64 / 1048576.0));
557 }
558 }
559 }
560 if large.is_empty() {
561 StepResult {
562 status: StepStatus::Pass,
563 detail: None,
564 sub_lines: vec![],
565 }
566 } else {
567 StepResult {
568 status: StepStatus::Fail,
569 detail: Some(format!("{} files too large", large.len())),
570 sub_lines: large,
571 }
572 }
573}
574
575fn step_debug_statements() -> StepResult {
576 let files = staged_files(&[".rs", ".ts", ".tsx", ".js", ".jsx", ".py"]);
577 let mut warnings = Vec::new();
578 for file in &files {
579 let out = Command::new("git")
580 .args(["diff", "--cached", "--", file])
581 .output();
582 let Ok(out) = out else { continue };
583 let diff = String::from_utf8_lossy(&out.stdout);
584 let patterns = if file.ends_with(".py") {
585 vec!["print(", "breakpoint(", "import pdb"]
586 } else {
587 vec!["console.log", "dbg!", "debugger;"]
588 };
589 for line in diff
590 .lines()
591 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
592 {
593 if patterns.iter().any(|p| line.contains(p)) {
594 warnings.push(file.clone());
595 break;
596 }
597 }
598 }
599 if warnings.is_empty() {
600 StepResult {
601 status: StepStatus::Pass,
602 detail: None,
603 sub_lines: vec![],
604 }
605 } else {
606 StepResult {
607 status: StepStatus::Warn,
608 detail: Some(format!("{} debug stmts", warnings.len())),
609 sub_lines: warnings,
610 }
611 }
612}
613
614fn step_secrets() -> StepResult {
615 let exe = self_exe();
616 let ok = Command::new(&exe)
617 .args(["secrets", "--staged"])
618 .stdout(Stdio::null())
619 .stderr(Stdio::null())
620 .status()
621 .map(|s| s.success())
622 .unwrap_or(false);
623 StepResult {
624 status: if ok {
625 StepStatus::Pass
626 } else {
627 StepStatus::Fail
628 },
629 detail: if ok {
630 None
631 } else {
632 Some("secrets detected".into())
633 },
634 sub_lines: vec![],
635 }
636}
637
638fn step_audit(root: &Path, tx: &mpsc::Sender<StepMsg>, idx: usize) -> StepResult {
639 let exe = self_exe();
640 let mut args = vec!["audit", "--skip-react"];
641 if staged_files(&["package.json", "bun.lockb", "bun.lock"]).is_empty() {
642 args.push("--skip-npm");
643 }
644 let child = Command::new(&exe)
645 .args(&args)
646 .current_dir(root)
647 .stdin(Stdio::null())
648 .stdout(Stdio::piped())
649 .stderr(Stdio::piped())
650 .spawn();
651 let Ok(child) = child else {
652 return StepResult {
653 status: StepStatus::Fail,
654 detail: Some("spawn failed".into()),
655 sub_lines: vec![],
656 };
657 };
658 let output = child.wait_with_output();
659 let Ok(output) = output else {
660 return StepResult {
661 status: StepStatus::Fail,
662 detail: Some("exec failed".into()),
663 sub_lines: vec![],
664 };
665 };
666 let combined = format!(
667 "{}{}",
668 String::from_utf8_lossy(&output.stdout),
669 String::from_utf8_lossy(&output.stderr)
670 );
671 let interesting: Vec<String> = combined
672 .lines()
673 .filter(|l| l.contains("✅") || l.contains("❌") || l.contains("Vulnerabilities"))
674 .map(|l| l.trim().to_string())
675 .take(5)
676 .collect();
677 for line in &interesting {
678 let _ = tx.send(StepMsg::Output(idx, line.clone()));
679 }
680 StepResult {
681 status: if output.status.success() {
682 StepStatus::Pass
683 } else {
684 StepStatus::Fail
685 },
686 detail: None,
687 sub_lines: interesting,
688 }
689}
690
691fn step_format_rust(root: &Path) -> StepResult {
692 if staged_files(&[".rs"]).is_empty() {
693 return StepResult {
694 status: StepStatus::Skip,
695 detail: Some("no files".into()),
696 sub_lines: vec![],
697 };
698 }
699 if !has_cmd("cargo") {
700 return StepResult {
701 status: StepStatus::Skip,
702 detail: Some("no cargo".into()),
703 sub_lines: vec![],
704 };
705 }
706 let output = Command::new("cargo")
707 .args(["fmt", "--all"])
708 .current_dir(root)
709 .stdout(Stdio::null())
710 .stderr(Stdio::piped())
711 .output();
712 let ok = output.as_ref().map(|o| o.status.success()).unwrap_or(false);
713 if ok {
714 let _ = Command::new("git")
715 .args(["add", "-u"])
716 .current_dir(root)
717 .status();
718 } else if let Ok(ref o) = output {
719 let stderr = String::from_utf8_lossy(&o.stderr);
721 if !stderr.is_empty() {
722 eprintln!("{stderr}");
723 }
724 }
725 StepResult {
726 status: if ok {
727 StepStatus::Pass
728 } else {
729 StepStatus::Fail
730 },
731 detail: None,
732 sub_lines: vec![],
733 }
734}
735
736fn step_format_ts(root: &Path) -> StepResult {
737 let files = staged_files(&[".ts", ".tsx", ".js", ".jsx", ".json", ".css"]);
738 if files.is_empty() {
739 return StepResult {
740 status: StepStatus::Skip,
741 detail: Some("no files".into()),
742 sub_lines: vec![],
743 };
744 }
745 let biome = if has_cmd("biome") {
746 Some(("biome", vec![]))
747 } else if has_cmd("bunx") {
748 Some(("bunx", vec!["--bun", "biome"]))
749 } else {
750 None
751 };
752 let Some((cmd, prefix)) = biome else {
753 return StepResult {
754 status: StepStatus::Skip,
755 detail: Some("no biome".into()),
756 sub_lines: vec![],
757 };
758 };
759 let mut args = prefix.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
760 args.extend(["format".into(), "--write".into()]);
761 args.extend(files.iter().cloned());
762 let ok = Command::new(cmd)
763 .args(&args)
764 .current_dir(root)
765 .status()
766 .map(|s| s.success())
767 .unwrap_or(false);
768 if ok {
769 restage(&files);
770 }
771 StepResult {
772 status: if ok {
773 StepStatus::Pass
774 } else {
775 StepStatus::Fail
776 },
777 detail: None,
778 sub_lines: vec![],
779 }
780}
781
782fn step_format_python(root: &Path) -> StepResult {
783 let files = staged_files(&[".py"]);
784 if files.is_empty() {
785 return StepResult {
786 status: StepStatus::Skip,
787 detail: Some("no files".into()),
788 sub_lines: vec![],
789 };
790 }
791 if !has_cmd("ruff") {
792 return StepResult {
793 status: StepStatus::Skip,
794 detail: Some("no ruff".into()),
795 sub_lines: vec![],
796 };
797 }
798 let ok = Command::new("ruff")
799 .args(["format"])
800 .args(&files)
801 .current_dir(root)
802 .status()
803 .map(|s| s.success())
804 .unwrap_or(false);
805 if ok {
806 restage(&files);
807 }
808 StepResult {
809 status: if ok {
810 StepStatus::Pass
811 } else {
812 StepStatus::Fail
813 },
814 detail: None,
815 sub_lines: vec![],
816 }
817}
818
819fn step_format_cpp(root: &Path) -> StepResult {
820 let files = staged_files(&[".cpp", ".cc", ".h", ".hpp"]);
821 if files.is_empty() {
822 return StepResult {
823 status: StepStatus::Skip,
824 detail: Some("no files".into()),
825 sub_lines: vec![],
826 };
827 }
828 if !has_cmd("clang-format") {
829 return StepResult {
830 status: StepStatus::Skip,
831 detail: Some("no clang-format".into()),
832 sub_lines: vec![],
833 };
834 }
835 let ok = Command::new("clang-format")
836 .arg("-i")
837 .args(&files)
838 .current_dir(root)
839 .status()
840 .map(|s| s.success())
841 .unwrap_or(false);
842 if ok {
843 restage(&files);
844 }
845 StepResult {
846 status: if ok {
847 StepStatus::Pass
848 } else {
849 StepStatus::Fail
850 },
851 detail: None,
852 sub_lines: vec![],
853 }
854}
855
856fn step_format_csharp(root: &Path) -> StepResult {
857 let files = staged_files(&[".cs"]);
858 if files.is_empty() {
859 return StepResult {
860 status: StepStatus::Skip,
861 detail: Some("no files".into()),
862 sub_lines: vec![],
863 };
864 }
865 if !has_cmd("dotnet") {
866 return StepResult {
867 status: StepStatus::Skip,
868 detail: Some("no dotnet".into()),
869 sub_lines: vec![],
870 };
871 }
872 let ok = Command::new("dotnet")
873 .args([
874 "format",
875 "libs/dotnet/ResQ.Packages.sln",
876 "--verbosity",
877 "quiet",
878 ])
879 .current_dir(root)
880 .status()
881 .map(|s| s.success())
882 .unwrap_or(false);
883 if ok {
884 restage(&files);
885 }
886 StepResult {
887 status: if ok {
888 StepStatus::Pass
889 } else {
890 StepStatus::Fail
891 },
892 detail: None,
893 sub_lines: vec![],
894 }
895}
896
897fn build_step_list(skip_audit: bool, skip_format: bool, skip_versioning: bool) -> Vec<StepState> {
900 let mut steps = vec![
901 StepState {
902 name: "Copyright headers".into(),
903 emoji: "📝".into(),
904 status: StepStatus::Pending,
905 detail: None,
906 sub_lines: vec![],
907 elapsed: None,
908 is_formatter: false,
909 is_versioning: false,
910 },
911 StepState {
912 name: "Large file check".into(),
913 emoji: "📦".into(),
914 status: StepStatus::Pending,
915 detail: None,
916 sub_lines: vec![],
917 elapsed: None,
918 is_formatter: false,
919 is_versioning: false,
920 },
921 StepState {
922 name: "Debug statements".into(),
923 emoji: "🐛".into(),
924 status: StepStatus::Pending,
925 detail: None,
926 sub_lines: vec![],
927 elapsed: None,
928 is_formatter: false,
929 is_versioning: false,
930 },
931 StepState {
932 name: "Secrets scan".into(),
933 emoji: "🔐".into(),
934 status: StepStatus::Pending,
935 detail: None,
936 sub_lines: vec![],
937 elapsed: None,
938 is_formatter: false,
939 is_versioning: false,
940 },
941 StepState {
942 name: "Security audit".into(),
943 emoji: "🔒".into(),
944 status: if skip_audit {
945 StepStatus::Skip
946 } else {
947 StepStatus::Pending
948 },
949 detail: None,
950 sub_lines: vec![],
951 elapsed: None,
952 is_formatter: false,
953 is_versioning: false,
954 },
955 ];
956 if !skip_format {
957 steps.extend(vec![
958 StepState {
959 name: "Format Rust".into(),
960 emoji: "🦀".into(),
961 status: StepStatus::Pending,
962 detail: None,
963 sub_lines: vec![],
964 elapsed: None,
965 is_formatter: true,
966 is_versioning: false,
967 },
968 StepState {
969 name: "Format TS/JS".into(),
970 emoji: "🎨".into(),
971 status: StepStatus::Pending,
972 detail: None,
973 sub_lines: vec![],
974 elapsed: None,
975 is_formatter: true,
976 is_versioning: false,
977 },
978 StepState {
979 name: "Format Python".into(),
980 emoji: "🐍".into(),
981 status: StepStatus::Pending,
982 detail: None,
983 sub_lines: vec![],
984 elapsed: None,
985 is_formatter: true,
986 is_versioning: false,
987 },
988 StepState {
989 name: "Format C++".into(),
990 emoji: "⚙️ ".into(),
991 status: StepStatus::Pending,
992 detail: None,
993 sub_lines: vec![],
994 elapsed: None,
995 is_formatter: true,
996 is_versioning: false,
997 },
998 StepState {
999 name: "Format C#".into(),
1000 emoji: "🔷".into(),
1001 status: StepStatus::Pending,
1002 detail: None,
1003 sub_lines: vec![],
1004 elapsed: None,
1005 is_formatter: true,
1006 is_versioning: false,
1007 },
1008 ]);
1009 }
1010 if !skip_versioning {
1011 steps.push(StepState {
1012 name: "Versioning / Changeset".into(),
1013 emoji: "🏷️".into(),
1014 status: StepStatus::Pending,
1015 detail: None,
1016 sub_lines: vec![],
1017 elapsed: None,
1018 is_formatter: false,
1019 is_versioning: true,
1020 });
1021 }
1022 steps
1023}
1024
1025fn run_worker(
1026 tx: mpsc::Sender<StepMsg>,
1027 res_rx: mpsc::Receiver<ChangesetResponse>,
1028 root: PathBuf,
1029 skip_audit: bool,
1030 skip_format: bool,
1031 skip_versioning: bool,
1032 max_file_size: u64,
1033) {
1034 let mut idx = 0;
1035 macro_rules! run {
1036 ($step_fn:expr) => {{
1037 let _ = tx.send(StepMsg::Started(idx));
1038 let start = Instant::now();
1039 let res = $step_fn;
1040 let _ = tx.send(StepMsg::Finished(
1041 idx,
1042 res.status,
1043 res.detail,
1044 res.sub_lines,
1045 start.elapsed(),
1046 ));
1047 idx += 1;
1048 }};
1049 }
1050
1051 run!(step_copyright(&root));
1052 run!(step_large_files(max_file_size));
1053 run!(step_debug_statements());
1054 run!(step_secrets());
1055 if skip_audit {
1056 idx += 1;
1057 } else {
1058 run!(step_audit(&root, &tx, idx));
1059 }
1060
1061 if !skip_format {
1062 run!(step_format_rust(&root));
1063 run!(step_format_ts(&root));
1064 run!(step_format_python(&root));
1065 run!(step_format_cpp(&root));
1066 run!(step_format_csharp(&root));
1067 }
1068
1069 if !skip_versioning {
1070 let _ = tx.send(StepMsg::PromptChangeset(idx));
1071 if let Ok(resp) = res_rx.recv() {
1072 let (status, detail) = match resp {
1073 ChangesetResponse::None => (StepStatus::Skip, Some("no bump".into())),
1074 _ => (StepStatus::Pass, Some("bump recorded".into())),
1075 };
1076 let _ = tx.send(StepMsg::Finished(
1077 idx,
1078 status,
1079 detail,
1080 vec![],
1081 Duration::ZERO,
1082 ));
1083 }
1084 }
1085 let _ = tx.send(StepMsg::AllDone);
1086}
1087
1088pub async fn run(args: PreCommitArgs) -> Result<()> {
1092 let root = if args.root == Path::new(".") {
1093 crate::utils::find_project_root()
1094 } else {
1095 args.root.clone()
1096 };
1097 if args.no_tui
1098 || !crossterm::tty::IsTty::is_tty(&io::stderr())
1099 || std::env::var_os("GIT_INDEX_FILE").is_some()
1100 {
1101 return run_plain(&root, args.skip_audit, args.skip_format, args.max_file_size);
1102 }
1103
1104 let steps = build_step_list(args.skip_audit, args.skip_format, args.skip_versioning);
1105 let mut app = App::new(steps);
1106 let (tx, rx) = mpsc::channel();
1107 let (res_tx, res_rx) = mpsc::channel();
1108 let worker_root = root.clone();
1109
1110 std::thread::spawn(move || {
1111 run_worker(
1112 tx,
1113 res_rx,
1114 worker_root,
1115 args.skip_audit,
1116 args.skip_format,
1117 args.skip_versioning,
1118 args.max_file_size,
1119 );
1120 });
1121
1122 enable_raw_mode()?;
1123 io::stderr().execute(EnterAlternateScreen)?;
1124 let mut terminal =
1125 ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(io::stderr()))?;
1126 let mut done_at: Option<Instant> = None;
1127
1128 loop {
1129 while let Ok(msg) = rx.try_recv() {
1130 app.apply(msg);
1131 }
1132 if app.done && done_at.is_none() {
1133 done_at = Some(Instant::now());
1134 }
1135 if let Some(t) = done_at {
1136 if t.elapsed() > Duration::from_millis(500)
1137 && !app.aborted
1138 && app.prompting_idx.is_none()
1139 {
1140 break;
1141 }
1142 }
1143 app.spinner_tick = app.spinner_tick.wrapping_add(1);
1144 terminal.draw(|f| draw(f, &app))?;
1145
1146 if event::poll(Duration::from_millis(50))? {
1147 if let Event::Key(key) = event::read()? {
1148 if key.kind != KeyEventKind::Press {
1149 continue;
1150 }
1151 if app.prompting_idx.is_some() {
1152 if app.entering_message {
1153 match key.code {
1154 KeyCode::Char(c) => app.changeset_message.push(c),
1155 KeyCode::Backspace => {
1156 app.changeset_message.pop();
1157 }
1158 KeyCode::Enter => app.entering_message = false,
1159 KeyCode::Esc => {
1160 app.entering_message = false;
1161 app.changeset_message.clear();
1162 }
1163 _ => {}
1164 }
1165 } else {
1166 match key.code {
1167 KeyCode::Left => {
1168 app.changeset_selector = app.changeset_selector.saturating_sub(1);
1169 }
1170 KeyCode::Right => {
1171 if app.changeset_selector < 3 {
1172 app.changeset_selector += 1;
1173 }
1174 }
1175 KeyCode::Char('m') => {
1176 if app.changeset_selector > 0 {
1177 app.entering_message = true;
1178 }
1179 }
1180 KeyCode::Enter => {
1181 let resp = match app.changeset_selector {
1182 1 => ChangesetResponse::Patch,
1183 2 => ChangesetResponse::Minor,
1184 3 => ChangesetResponse::Major,
1185 _ => ChangesetResponse::None,
1186 };
1187 if app.changeset_selector > 0 {
1188 let bump = match resp {
1189 ChangesetResponse::Patch => "patch",
1190 ChangesetResponse::Minor => "minor",
1191 _ => "major",
1192 };
1193 let _ = Command::new(self_exe())
1194 .args([
1195 "version",
1196 "add",
1197 "--bump",
1198 bump,
1199 "--message",
1200 &app.changeset_message,
1201 ])
1202 .current_dir(&root)
1203 .status();
1204 }
1205 let _ = res_tx.send(resp);
1206 }
1207 KeyCode::Esc => {
1208 app.aborted = true;
1209 break;
1210 }
1211 _ => {}
1212 }
1213 }
1214 continue;
1215 }
1216 match key.code {
1217 KeyCode::Char('q') | KeyCode::Esc => {
1218 app.aborted = true;
1219 break;
1220 }
1221 KeyCode::Enter if app.done => break,
1222 KeyCode::Up => app.scroll_offset = app.scroll_offset.saturating_sub(1),
1223 KeyCode::Down => app.scroll_offset = app.scroll_offset.saturating_add(1),
1224 _ => {}
1225 }
1226 }
1227 }
1228 }
1229
1230 disable_raw_mode()?;
1231 io::stderr().execute(LeaveAlternateScreen)?;
1232 if app.aborted {
1233 bail!("pre-commit aborted");
1234 }
1235 let (_, fail, _, _) = app.counts();
1236 if fail > 0 {
1237 bail!("pre-commit checks failed");
1238 }
1239 Ok(())
1240}
1241
1242fn run_plain(root: &Path, skip_audit: bool, skip_format: bool, max_file_size: u64) -> Result<()> {
1243 eprintln!("🔍 ResQ Pre-commit Checks\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1244 let mut fail = false;
1245 macro_rules! run {
1246 ($name:expr, $fn:expr) => {{
1247 eprint!(" {}...", $name);
1248 let res = $fn;
1249 eprintln!("\r {} {}", res.status.icon(), $name);
1250 if res.status == StepStatus::Fail {
1251 fail = true;
1252 }
1253 }};
1254 }
1255 run!("Copyright", step_copyright(root));
1256 run!("Large Files", step_large_files(max_file_size));
1257 run!("Debug Stmts", step_debug_statements());
1258 run!("Secrets", step_secrets());
1259 if !skip_audit {
1260 run!("Audit", step_audit(root, &mpsc::channel().0, 0));
1261 }
1262 if !skip_format {
1263 run!("Format Rust", step_format_rust(root));
1264 run!("Format TS", step_format_ts(root));
1265 run!("Format Python", step_format_python(root));
1266 run!("Format C++", step_format_cpp(root));
1267 run!("Format C#", step_format_csharp(root));
1268 }
1269 if fail {
1270 bail!("checks failed");
1271 }
1272 Ok(())
1273}