|
| 1 | +use std::fmt::Write as _; |
1 | 2 | use std::path::Path; |
2 | 3 |
|
3 | | -use futures::StreamExt; |
4 | 4 | use owo_colors::OwoColorize; |
5 | | -use rustc_hash::FxHashSet; |
6 | | -use tokio::io::AsyncReadExt; |
7 | 5 |
|
8 | 6 | use crate::git; |
9 | 7 | use crate::hook::Hook; |
| 8 | +use crate::hooks::pre_commit_hooks::shebangs::{ |
| 9 | + file_has_shebang, git_index_stage_output, matching_git_index_paths_by_executable_bit, |
| 10 | +}; |
10 | 11 | use crate::hooks::run_concurrent_file_checks; |
11 | 12 | use crate::run::CONCURRENCY; |
| 13 | +use rustc_hash::FxHashSet; |
12 | 14 |
|
13 | 15 | pub(crate) async fn check_executables_have_shebangs( |
14 | 16 | hook: &Hook, |
@@ -47,166 +49,67 @@ async fn os_check_shebangs( |
47 | 49 | if has_shebang { |
48 | 50 | anyhow::Ok((0, Vec::new())) |
49 | 51 | } else { |
50 | | - let msg = print_shebang_warning(file); |
| 52 | + let msg = build_missing_shebang_warning(file)?; |
51 | 53 | Ok((1, msg.into_bytes())) |
52 | 54 | } |
53 | 55 | }) |
54 | 56 | .await |
55 | 57 | } |
56 | 58 |
|
57 | | -fn print_shebang_warning(path: &Path) -> String { |
| 59 | +fn build_missing_shebang_warning(path: &Path) -> Result<String, std::fmt::Error> { |
58 | 60 | let path_str = path.display(); |
59 | | - |
60 | | - format!( |
61 | | - "{}\n\ |
62 | | - {}\n\ |
63 | | - {}\n\ |
64 | | - {}\n", |
| 61 | + let mut warning = String::new(); |
| 62 | + writeln!( |
| 63 | + warning, |
| 64 | + "{}", |
65 | 65 | format!( |
66 | 66 | "{} marked executable but has no (or invalid) shebang!", |
67 | 67 | path_str.yellow() |
68 | 68 | ) |
69 | | - .bold(), |
70 | | - format!(" If it isn't supposed to be executable, try: 'chmod -x {path_str}'").dimmed(), |
71 | | - format!(" If on Windows, you may also need to: 'git add --chmod=-x {path_str}'").dimmed(), |
72 | | - " If it is supposed to be executable, double-check its shebang.".dimmed(), |
73 | | - ) |
| 69 | + .bold() |
| 70 | + )?; |
| 71 | + writeln!( |
| 72 | + warning, |
| 73 | + "{}", |
| 74 | + format!(" If it isn't supposed to be executable, try: 'chmod -x {path_str}'").dimmed() |
| 75 | + )?; |
| 76 | + writeln!( |
| 77 | + warning, |
| 78 | + "{}", |
| 79 | + format!(" If on Windows, you may also need to: 'git add --chmod=-x {path_str}'").dimmed() |
| 80 | + )?; |
| 81 | + writeln!( |
| 82 | + warning, |
| 83 | + "{}", |
| 84 | + " If it is supposed to be executable, double-check its shebang.".dimmed() |
| 85 | + )?; |
| 86 | + Ok(warning) |
74 | 87 | } |
75 | 88 |
|
76 | 89 | async fn git_check_shebangs( |
77 | 90 | file_base: &Path, |
78 | 91 | filenames: &[&Path], |
79 | 92 | ) -> Result<(i32, Vec<u8>), anyhow::Error> { |
80 | | - let filenames: FxHashSet<_> = filenames.iter().collect(); |
| 93 | + let stdout = git_index_stage_output(file_base).await?; |
| 94 | + let filenames: FxHashSet<_> = filenames.iter().copied().collect(); |
| 95 | + let entries = matching_git_index_paths_by_executable_bit(&stdout, file_base, &filenames, true); |
81 | 96 |
|
82 | | - let output = git::git_cmd("git ls-files")? |
83 | | - .arg("ls-files") |
84 | | - // Show staged contents' mode bits, object name and stage number in the output. |
85 | | - .arg("--stage") |
86 | | - .arg("-z") |
87 | | - .arg("--") |
88 | | - .arg(if file_base.as_os_str().is_empty() { |
89 | | - Path::new(".") |
| 97 | + run_concurrent_file_checks(entries, *CONCURRENCY, |file| async move { |
| 98 | + let file_path = file_base.join(file); |
| 99 | + if file_has_shebang(&file_path).await? { |
| 100 | + Ok((0, Vec::new())) |
90 | 101 | } else { |
91 | | - file_base |
92 | | - }) |
93 | | - .check(true) |
94 | | - .output() |
95 | | - .await?; |
96 | | - |
97 | | - let entries = output.stdout.split(|&b| b == b'\0').filter_map(|entry| { |
98 | | - let entry = str::from_utf8(entry).ok()?; |
99 | | - if entry.is_empty() { |
100 | | - return None; |
| 102 | + Ok((1, build_missing_shebang_warning(file)?.into_bytes())) |
101 | 103 | } |
102 | | - |
103 | | - let mut parts = entry.split('\t'); |
104 | | - let metadata = parts.next()?; |
105 | | - let file_name = parts.next()?; |
106 | | - let file_name = Path::new(file_name); |
107 | | - if !filenames.contains(&file_name) { |
108 | | - return None; |
109 | | - } |
110 | | - |
111 | | - let mode_str = metadata.split_whitespace().next()?; |
112 | | - let mode_bits = u32::from_str_radix(mode_str, 8).ok()?; |
113 | | - let is_executable = (mode_bits & 0o111) != 0; |
114 | | - Some((file_name, is_executable)) |
115 | | - }); |
116 | | - |
117 | | - let mut tasks = futures::stream::iter(entries) |
118 | | - .map(async |(file_name, is_executable)| { |
119 | | - if is_executable { |
120 | | - let has_shebang = file_has_shebang(file_name).await?; |
121 | | - if has_shebang { |
122 | | - anyhow::Ok((0, Vec::new())) |
123 | | - } else { |
124 | | - let stripped = file_name.strip_prefix(file_base).unwrap_or(file_name); |
125 | | - let msg = print_shebang_warning(stripped); |
126 | | - Ok((1, msg.into_bytes())) |
127 | | - } |
128 | | - } else { |
129 | | - Ok((0, Vec::new())) |
130 | | - } |
131 | | - }) |
132 | | - .buffered(*CONCURRENCY); |
133 | | - |
134 | | - let mut code = 0; |
135 | | - let mut output = Vec::new(); |
136 | | - |
137 | | - while let Some(result) = tasks.next().await { |
138 | | - let (c, o) = result?; |
139 | | - code |= c; |
140 | | - output.extend(o); |
141 | | - } |
142 | | - |
143 | | - Ok((code, output)) |
144 | | -} |
145 | | - |
146 | | -/// Check first 2 bytes for shebang (#!) |
147 | | -async fn file_has_shebang(path: &Path) -> Result<bool, anyhow::Error> { |
148 | | - let mut file = fs_err::tokio::File::open(path).await?; |
149 | | - let mut buf = [0u8; 2]; |
150 | | - let n = file.read(&mut buf).await?; |
151 | | - Ok(n >= 2 && buf[0] == b'#' && buf[1] == b'!') |
| 104 | + }) |
| 105 | + .await |
152 | 106 | } |
153 | 107 |
|
154 | 108 | #[cfg(test)] |
155 | 109 | mod tests { |
156 | 110 | use super::*; |
157 | 111 | use tempfile::NamedTempFile; |
158 | 112 |
|
159 | | - #[tokio::test] |
160 | | - async fn test_file_with_shebang() -> Result<(), anyhow::Error> { |
161 | | - let file = NamedTempFile::new()?; |
162 | | - tokio::fs::write(file.path(), b"#!/bin/bash\necho Hello World\n").await?; |
163 | | - |
164 | | - assert!(file_has_shebang(file.path()).await?); |
165 | | - Ok(()) |
166 | | - } |
167 | | - |
168 | | - #[tokio::test] |
169 | | - async fn test_file_without_shebang() -> Result<(), anyhow::Error> { |
170 | | - let file = NamedTempFile::new()?; |
171 | | - tokio::fs::write(file.path(), b"echo Hello World\n").await?; |
172 | | - |
173 | | - assert!(!file_has_shebang(file.path()).await?); |
174 | | - Ok(()) |
175 | | - } |
176 | | - |
177 | | - #[tokio::test] |
178 | | - async fn test_empty_file() -> Result<(), anyhow::Error> { |
179 | | - let file = NamedTempFile::new()?; |
180 | | - tokio::fs::write(file.path(), b"").await?; |
181 | | - |
182 | | - assert!(!file_has_shebang(file.path()).await?); |
183 | | - Ok(()) |
184 | | - } |
185 | | - |
186 | | - #[tokio::test] |
187 | | - async fn test_file_with_partial_shebang() -> Result<(), anyhow::Error> { |
188 | | - let file = NamedTempFile::new()?; |
189 | | - tokio::fs::write(file.path(), b"#\n").await?; |
190 | | - assert!(!file_has_shebang(file.path()).await?); |
191 | | - Ok(()) |
192 | | - } |
193 | | - |
194 | | - #[tokio::test] |
195 | | - async fn test_file_with_shebang_and_spaces() -> Result<(), anyhow::Error> { |
196 | | - let file = NamedTempFile::new()?; |
197 | | - tokio::fs::write(file.path(), b"#! /bin/bash\necho Test\n").await?; |
198 | | - assert!(file_has_shebang(file.path()).await?); |
199 | | - Ok(()) |
200 | | - } |
201 | | - |
202 | | - #[tokio::test] |
203 | | - async fn test_file_with_non_shebang_start() -> Result<(), anyhow::Error> { |
204 | | - let file = NamedTempFile::new()?; |
205 | | - tokio::fs::write(file.path(), b"##!/bin/bash\n").await?; |
206 | | - assert!(!file_has_shebang(file.path()).await?); |
207 | | - Ok(()) |
208 | | - } |
209 | | - |
210 | 113 | #[tokio::test] |
211 | 114 | async fn test_os_check_shebangs_with_shebang() -> Result<(), anyhow::Error> { |
212 | 115 | let file = NamedTempFile::new()?; |
|
0 commit comments