1414EXECUTABLE_VALUES = frozenset (('1' , '3' , '5' , '7' ))
1515
1616
17- def check_executables (paths : list [str ]) -> int :
17+ def check_executables (
18+ paths : list [str ], * , require_env : bool = False , fix : bool = False ,
19+ ) -> int :
1820 fs_tracks_executable_bit = cmd_output (
1921 'git' , 'config' , 'core.fileMode' , retcode = None ,
2022 ).strip ()
2123 if fs_tracks_executable_bit == 'false' : # pragma: win32 cover
22- return _check_git_filemode (paths )
24+ return _check_git_filemode (
25+ paths , require_env = require_env , fix = fix ,
26+ )
2327 else : # pragma: win32 no cover
2428 retv = 0
2529 for path in paths :
26- if not has_shebang ( path ):
27- _message ( path )
28- retv = 1
30+ retv |= _check_executable (
31+ path , require_env = require_env , fix = fix ,
32+ )
2933
3034 return retv
3135
@@ -43,42 +47,128 @@ def git_ls_files(paths: Sequence[str]) -> Generator[GitLsFile]:
4347 yield GitLsFile (mode , filename )
4448
4549
46- def _check_git_filemode (paths : Sequence [str ]) -> int :
50+ def _check_git_filemode (
51+ paths : Sequence [str ], * , require_env : bool = False , fix : bool = False ,
52+ ) -> int :
4753 seen : set [str ] = set ()
4854 for ls_file in git_ls_files (paths ):
4955 is_executable = any (b in EXECUTABLE_VALUES for b in ls_file .mode [- 3 :])
50- if is_executable and not has_shebang (ls_file .filename ):
51- _message (ls_file .filename )
56+ if is_executable and _check_executable (
57+ ls_file .filename , require_env = require_env , fix = fix ,
58+ ):
5259 seen .add (ls_file .filename )
5360
5461 return int (bool (seen ))
5562
5663
57- def has_shebang (path : str ) -> int :
64+ def has_shebang (path : str , * , require_env : bool = False ) -> bool :
5865 with open (path , 'rb' ) as f :
5966 first_bytes = f .read (2 )
67+ if first_bytes != b'#!' :
68+ return False
69+ elif not require_env :
70+ return True
71+ else :
72+ cmd = f .readline ().split ()
73+
74+ return (
75+ len (cmd ) >= 2 and
76+ cmd [0 ] == b'/usr/bin/env'
77+ )
6078
61- return first_bytes == b'#!'
6279
80+ def _fix_shebang (path : str ) -> bool :
81+ with open (path , 'rb+' ) as f :
82+ first_line = f .readline ()
83+ if not first_line .startswith (b'#!' ):
84+ return False
85+
86+ line = first_line .rstrip (b'\r \n ' )
87+ newline = first_line [len (line ):]
88+ command = line [2 :].strip ()
89+ cmd = command .split (maxsplit = 1 )
90+ if not cmd :
91+ return False
92+
93+ executable = cmd [0 ].rsplit (b'/' , 1 )[- 1 ]
94+ if not executable or (executable == b'env' and len (cmd ) == 1 ):
95+ return False
96+
97+ if executable == b'env' :
98+ new_first_line = b'#!/usr/bin/env ' + cmd [1 ] + newline
99+ elif len (cmd ) == 2 :
100+ new_first_line = (
101+ b'#!/usr/bin/env -S ' + executable + b' ' + cmd [1 ] + newline
102+ )
103+ else :
104+ new_first_line = b'#!/usr/bin/env ' + executable + newline
105+
106+ rest = f .read ()
107+ f .seek (0 )
108+ f .write (new_first_line )
109+ f .write (rest )
110+ f .truncate ()
111+
112+ return True
113+
114+
115+ def _check_executable (
116+ path : str , * , require_env : bool , fix : bool ,
117+ ) -> int :
118+ if has_shebang (path , require_env = require_env ):
119+ return 0
120+ elif fix and _fix_shebang (path ):
121+ print (f'Fixing { path } ' )
122+ else :
123+ _message (path , require_env = require_env )
124+
125+ return 1
126+
127+
128+ def _message (path : str , * , require_env : bool = False ) -> None :
129+ if require_env :
130+ problem = 'does not have a /usr/bin/env shebang'
131+ suggestion = (
132+ 'use a /usr/bin/env shebang (e.g. `#!/usr/bin/env python`)'
133+ )
134+ else :
135+ problem = 'has no (or invalid) shebang'
136+ suggestion = 'double-check its shebang'
63137
64- def _message (path : str ) -> None :
65138 print (
66- f'{ path } : marked executable but has no (or invalid) shebang !\n '
139+ f'{ path } : marked executable but { problem } !\n '
67140 f" If it isn't supposed to be executable, try: "
68141 f'`chmod -x { shlex .quote (path )} `\n '
69142 f' If on Windows, you may also need to: '
70143 f'`git add --chmod=-x { shlex .quote (path )} `\n '
71- f' If it is supposed to be executable, double-check its shebang .' ,
144+ f' If it is supposed to be executable, { suggestion } .' ,
72145 file = sys .stderr ,
73146 )
74147
75148
76149def main (argv : Sequence [str ] | None = None ) -> int :
77150 parser = argparse .ArgumentParser (description = __doc__ )
151+ parser .add_argument (
152+ '--require-env' , action = 'store_true' ,
153+ help = 'Require shebangs to invoke an interpreter through /usr/bin/env' ,
154+ )
155+ parser .add_argument (
156+ '--fix' , action = 'store_true' ,
157+ help = (
158+ 'Rewrite existing shebangs to use /usr/bin/env '
159+ '(requires --require-env)'
160+ ),
161+ )
78162 parser .add_argument ('filenames' , nargs = '*' )
79163 args = parser .parse_args (argv )
164+ if args .fix and not args .require_env :
165+ parser .error ('--fix requires --require-env' )
80166
81- return check_executables (args .filenames )
167+ return check_executables (
168+ args .filenames ,
169+ require_env = args .require_env ,
170+ fix = args .fix ,
171+ )
82172
83173
84174if __name__ == '__main__' :
0 commit comments