Skip to content

Commit fc529df

Browse files
authored
Add check-shebang-scripts-are-executable builtin hook (#1847)
1 parent addddb8 commit fc529df

File tree

9 files changed

+357
-137
lines changed

9 files changed

+357
-137
lines changed

crates/prek/src/hooks/builtin_hooks/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pub(crate) enum BuiltinHooks {
3535
CheckJson,
3636
CheckJson5,
3737
CheckMergeConflict,
38+
CheckShebangScriptsAreExecutable,
3839
CheckSymlinks,
3940
CheckToml,
4041
CheckVcsPermalinks,
@@ -74,6 +75,9 @@ impl BuiltinHooks {
7475
Self::CheckMergeConflict => {
7576
pre_commit_hooks::check_merge_conflict(hook, filenames).await
7677
}
78+
Self::CheckShebangScriptsAreExecutable => {
79+
pre_commit_hooks::check_shebang_scripts_are_executable(hook, filenames).await
80+
}
7781
Self::CheckSymlinks => pre_commit_hooks::check_symlinks(hook, filenames).await,
7882
Self::CheckToml => pre_commit_hooks::check_toml(hook, filenames).await,
7983
Self::CheckVcsPermalinks => {
@@ -195,6 +199,21 @@ impl BuiltinHook {
195199
..Default::default()
196200
},
197201
},
202+
BuiltinHooks::CheckShebangScriptsAreExecutable => BuiltinHook {
203+
id: "check-shebang-scripts-are-executable".to_string(),
204+
name: "check that scripts with shebangs are executable".to_string(),
205+
entry: "check-shebang-scripts-are-executable".to_string(),
206+
priority: None,
207+
options: HookOptions {
208+
description: Some(
209+
"ensures that (non-binary) files with a shebang are executable."
210+
.to_string(),
211+
),
212+
types: Some(tags::TAG_SET_TEXT),
213+
stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()),
214+
..Default::default()
215+
},
216+
},
198217
BuiltinHooks::CheckSymlinks => BuiltinHook {
199218
id: "check-symlinks".to_string(),
200219
name: "check for broken symlinks".to_string(),

crates/prek/src/hooks/pre_commit_hooks/check_executables_have_shebangs.rs

Lines changed: 39 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
use std::fmt::Write as _;
12
use std::path::Path;
23

3-
use futures::StreamExt;
44
use owo_colors::OwoColorize;
5-
use rustc_hash::FxHashSet;
6-
use tokio::io::AsyncReadExt;
75

86
use crate::git;
97
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+
};
1011
use crate::hooks::run_concurrent_file_checks;
1112
use crate::run::CONCURRENCY;
13+
use rustc_hash::FxHashSet;
1214

1315
pub(crate) async fn check_executables_have_shebangs(
1416
hook: &Hook,
@@ -47,166 +49,67 @@ async fn os_check_shebangs(
4749
if has_shebang {
4850
anyhow::Ok((0, Vec::new()))
4951
} else {
50-
let msg = print_shebang_warning(file);
52+
let msg = build_missing_shebang_warning(file)?;
5153
Ok((1, msg.into_bytes()))
5254
}
5355
})
5456
.await
5557
}
5658

57-
fn print_shebang_warning(path: &Path) -> String {
59+
fn build_missing_shebang_warning(path: &Path) -> Result<String, std::fmt::Error> {
5860
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+
"{}",
6565
format!(
6666
"{} marked executable but has no (or invalid) shebang!",
6767
path_str.yellow()
6868
)
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)
7487
}
7588

7689
async fn git_check_shebangs(
7790
file_base: &Path,
7891
filenames: &[&Path],
7992
) -> 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);
8196

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()))
90101
} 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()))
101103
}
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
152106
}
153107

154108
#[cfg(test)]
155109
mod tests {
156110
use super::*;
157111
use tempfile::NamedTempFile;
158112

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-
210113
#[tokio::test]
211114
async fn test_os_check_shebangs_with_shebang() -> Result<(), anyhow::Error> {
212115
let file = NamedTempFile::new()?;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use std::fmt::Write as _;
2+
use std::path::Path;
3+
4+
use owo_colors::OwoColorize;
5+
6+
use crate::hook::Hook;
7+
use crate::hooks::pre_commit_hooks::shebangs::{
8+
file_has_shebang, git_index_stage_output, matching_git_index_paths_by_executable_bit,
9+
};
10+
use crate::hooks::run_concurrent_file_checks;
11+
use crate::run::CONCURRENCY;
12+
use rustc_hash::FxHashSet;
13+
14+
pub(crate) async fn check_shebang_scripts_are_executable(
15+
hook: &Hook,
16+
filenames: &[&Path],
17+
) -> Result<(i32, Vec<u8>), anyhow::Error> {
18+
let file_base = hook.project().relative_path();
19+
let stdout = git_index_stage_output(file_base).await?;
20+
let filenames: FxHashSet<_> = filenames.iter().copied().collect();
21+
let entries = matching_git_index_paths_by_executable_bit(&stdout, file_base, &filenames, false);
22+
23+
run_concurrent_file_checks(entries, *CONCURRENCY, |file| async move {
24+
let file_path = file_base.join(file);
25+
if file_has_shebang(&file_path).await? {
26+
Ok((1, build_non_executable_shebang_warning(file)?.into_bytes()))
27+
} else {
28+
Ok((0, Vec::new()))
29+
}
30+
})
31+
.await
32+
}
33+
34+
fn build_non_executable_shebang_warning(path: &Path) -> Result<String, std::fmt::Error> {
35+
let path_str = path.display();
36+
let mut warning = String::new();
37+
writeln!(
38+
warning,
39+
"{}",
40+
format!(
41+
"{} has a shebang but is not marked executable!",
42+
path_str.yellow()
43+
)
44+
.bold()
45+
)?;
46+
writeln!(
47+
warning,
48+
"{}",
49+
format!(" If it is supposed to be executable, try: 'chmod +x {path_str}'").dimmed()
50+
)?;
51+
writeln!(
52+
warning,
53+
"{}",
54+
format!(" If on Windows, you may also need to: 'git add --chmod=+x {path_str}'").dimmed()
55+
)?;
56+
writeln!(
57+
warning,
58+
"{}",
59+
" If it is not supposed to be executable, double-check its shebang is wanted.".dimmed()
60+
)?;
61+
Ok(warning)
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use super::*;
67+
68+
#[test]
69+
fn non_executable_warning_mentions_chmod_and_git_add() {
70+
let warning = build_non_executable_shebang_warning(Path::new("script.sh")).unwrap();
71+
72+
assert!(warning.contains("chmod +x script.sh"));
73+
assert!(warning.contains("git add --chmod=+x script.sh"));
74+
}
75+
}

crates/prek/src/hooks/pre_commit_hooks/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod check_case_conflict;
1010
mod check_executables_have_shebangs;
1111
pub(crate) mod check_json;
1212
mod check_merge_conflict;
13+
mod check_shebang_scripts_are_executable;
1314
mod check_symlinks;
1415
mod check_toml;
1516
mod check_vcs_permalinks;
@@ -22,12 +23,14 @@ mod fix_end_of_file;
2223
mod fix_trailing_whitespace;
2324
mod mixed_line_ending;
2425
mod no_commit_to_branch;
26+
mod shebangs;
2527

2628
pub(crate) use check_added_large_files::check_added_large_files;
2729
pub(crate) use check_case_conflict::check_case_conflict;
2830
pub(crate) use check_executables_have_shebangs::check_executables_have_shebangs;
2931
pub(crate) use check_json::check_json;
3032
pub(crate) use check_merge_conflict::check_merge_conflict;
33+
pub(crate) use check_shebang_scripts_are_executable::check_shebang_scripts_are_executable;
3134
pub(crate) use check_symlinks::check_symlinks;
3235
pub(crate) use check_toml::check_toml;
3336
pub(crate) use check_vcs_permalinks::check_vcs_permalinks;
@@ -48,6 +51,7 @@ pub(crate) enum PreCommitHooks {
4851
CheckAddedLargeFiles,
4952
CheckCaseConflict,
5053
CheckExecutablesHaveShebangs,
54+
CheckShebangScriptsAreExecutable,
5155
CheckVcsPermalinks,
5256
FileContentsSorter,
5357
EndOfFileFixer,
@@ -81,6 +85,9 @@ impl PreCommitHooks {
8185
Self::CheckExecutablesHaveShebangs => {
8286
check_executables_have_shebangs(hook, filenames).await
8387
}
88+
Self::CheckShebangScriptsAreExecutable => {
89+
check_shebang_scripts_are_executable(hook, filenames).await
90+
}
8491
Self::CheckVcsPermalinks => check_vcs_permalinks(hook, filenames).await,
8592
Self::FileContentsSorter => file_contents_sorter(hook, filenames).await,
8693
Self::EndOfFileFixer => fix_end_of_file(hook, filenames).await,

0 commit comments

Comments
 (0)