Skip to content

Commit 4b863f1

Browse files
authored
Merge pull request #511 from m-khvoinitsky/master
New hook 'destroyed-symlinks'
2 parents 14e9f0e + 1e87d59 commit 4b863f1

9 files changed

+204
-18
lines changed

.pre-commit-hooks.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@
100100
entry: debug-statement-hook
101101
language: python
102102
types: [python]
103+
- id: destroyed-symlinks
104+
name: Detect Destroyed Symlinks
105+
description: Detects symlinks which are changed to regular files with a content of a path which that symlink was pointing to.
106+
entry: destroyed-symlinks
107+
language: python
108+
types: [file]
103109
- id: detect-aws-credentials
104110
name: Detect AWS Credentials
105111
description: Detects *your* aws credentials from the aws cli credentials file

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ Attempts to load all yaml files to verify syntax.
8787
#### `debug-statements`
8888
Check for debugger imports and py37+ `breakpoint()` calls in python source.
8989

90+
#### `destroyed-symlinks`
91+
Detects symlinks which are changed to regular files with a content of a path
92+
which that symlink was pointing to.
93+
This usually happens on Windows when a user clones a repository that has
94+
symlinks but they do not have the permission to create symlinks.
95+
9096
#### `detect-aws-credentials`
9197
Checks for the existence of AWS secrets that you have set up with the AWS CLI.
9298
The following arguments are available:

pre_commit_hooks/check_executables_have_shebangs.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,11 @@
88
from typing import Set
99

1010
from pre_commit_hooks.util import cmd_output
11+
from pre_commit_hooks.util import zsplit
1112

1213
EXECUTABLE_VALUES = frozenset(('1', '3', '5', '7'))
1314

1415

15-
def zsplit(s: str) -> List[str]:
16-
s = s.strip('\0')
17-
if s:
18-
return s.split('\0')
19-
else:
20-
return []
21-
22-
2316
def check_executables(paths: List[str]) -> int:
2417
if sys.platform == 'win32': # pragma: win32 cover
2518
return _check_git_filemode(paths)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import argparse
2+
import shlex
3+
import subprocess
4+
from typing import List
5+
from typing import Optional
6+
from typing import Sequence
7+
8+
from pre_commit_hooks.util import cmd_output
9+
from pre_commit_hooks.util import zsplit
10+
11+
ORDINARY_CHANGED_ENTRIES_MARKER = '1'
12+
PERMS_LINK = '120000'
13+
PERMS_NONEXIST = '000000'
14+
15+
16+
def find_destroyed_symlinks(files: Sequence[str]) -> List[str]:
17+
destroyed_links: List[str] = []
18+
if not files:
19+
return destroyed_links
20+
for line in zsplit(
21+
cmd_output('git', 'status', '--porcelain=v2', '-z', '--', *files),
22+
):
23+
splitted = line.split(' ')
24+
if splitted and splitted[0] == ORDINARY_CHANGED_ENTRIES_MARKER:
25+
# https://git-scm.com/docs/git-status#_changed_tracked_entries
26+
(
27+
_, _, _,
28+
mode_HEAD,
29+
mode_index,
30+
_,
31+
hash_HEAD,
32+
hash_index,
33+
*path_splitted,
34+
) = splitted
35+
path = ' '.join(path_splitted)
36+
if (
37+
mode_HEAD == PERMS_LINK and
38+
mode_index != PERMS_LINK and
39+
mode_index != PERMS_NONEXIST
40+
):
41+
if hash_HEAD == hash_index:
42+
# if old and new hashes are equal, it's not needed to check
43+
# anything more, we've found a destroyed symlink for sure
44+
destroyed_links.append(path)
45+
else:
46+
# if old and new hashes are *not* equal, it doesn't mean
47+
# that everything is OK - new file may be altered
48+
# by something like trailing-whitespace and/or
49+
# mixed-line-ending hooks so we need to go deeper
50+
SIZE_CMD = ('git', 'cat-file', '-s')
51+
size_index = int(cmd_output(*SIZE_CMD, hash_index).strip())
52+
size_HEAD = int(cmd_output(*SIZE_CMD, hash_HEAD).strip())
53+
54+
# in the worst case new file may have CRLF added
55+
# so check content only if new file is bigger
56+
# not more than 2 bytes compared to the old one
57+
if size_index <= size_HEAD + 2:
58+
head_content = subprocess.check_output(
59+
('git', 'cat-file', '-p', hash_HEAD),
60+
).rstrip()
61+
index_content = subprocess.check_output(
62+
('git', 'cat-file', '-p', hash_index),
63+
).rstrip()
64+
if head_content == index_content:
65+
destroyed_links.append(path)
66+
return destroyed_links
67+
68+
69+
def main(argv: Optional[Sequence[str]] = None) -> int:
70+
parser = argparse.ArgumentParser()
71+
parser.add_argument('filenames', nargs='*', help='Filenames to check.')
72+
args = parser.parse_args(argv)
73+
destroyed_links = find_destroyed_symlinks(files=args.filenames)
74+
if destroyed_links:
75+
print('Destroyed symlinks:')
76+
for destroyed_link in destroyed_links:
77+
print(f'- {destroyed_link}')
78+
print('You should unstage affected files:')
79+
print(
80+
'\tgit reset HEAD -- {}'.format(
81+
' '.join(shlex.quote(link) for link in destroyed_links),
82+
),
83+
)
84+
print(
85+
'And retry commit. As a long term solution '
86+
'you may try to explicitly tell git that your '
87+
'environment does not support symlinks:',
88+
)
89+
print('\tgit config core.symlinks false')
90+
return 1
91+
else:
92+
return 0
93+
94+
95+
if __name__ == '__main__':
96+
exit(main())

pre_commit_hooks/util.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import subprocess
22
from typing import Any
3+
from typing import List
34
from typing import Optional
45
from typing import Set
56

@@ -22,3 +23,11 @@ def cmd_output(*cmd: str, retcode: Optional[int] = 0, **kwargs: Any) -> str:
2223
if retcode is not None and proc.returncode != retcode:
2324
raise CalledProcessError(cmd, retcode, proc.returncode, stdout, stderr)
2425
return stdout
26+
27+
28+
def zsplit(s: str) -> List[str]:
29+
s = s.strip('\0')
30+
if s:
31+
return s.split('\0')
32+
else:
33+
return []

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ console_scripts =
4444
check-xml = pre_commit_hooks.check_xml:main
4545
check-yaml = pre_commit_hooks.check_yaml:main
4646
debug-statement-hook = pre_commit_hooks.debug_statement_hook:main
47+
destroyed-symlinks = pre_commit_hooks.destroyed_symlinks:main
4748
detect-aws-credentials = pre_commit_hooks.detect_aws_credentials:main
4849
detect-private-key = pre_commit_hooks.detect_private_key:main
4950
double-quote-string-fixer = pre_commit_hooks.string_fixer:main

tests/check_executables_have_shebangs_test.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,6 @@ def test_check_git_filemode_failing(tmpdir):
102102
assert check_executables_have_shebangs._check_git_filemode(files) == 1
103103

104104

105-
@pytest.mark.parametrize('out', ('\0f1\0f2\0', '\0f1\0f2', 'f1\0f2\0'))
106-
def test_check_zsplits_correctly(out):
107-
assert check_executables_have_shebangs.zsplit(out) == ['f1', 'f2']
108-
109-
110-
@pytest.mark.parametrize('out', ('\0\0', '\0', ''))
111-
def test_check_zsplit_returns_empty(out):
112-
assert check_executables_have_shebangs.zsplit(out) == []
113-
114-
115105
@pytest.mark.parametrize(
116106
('content', 'mode', 'expected'),
117107
(

tests/destroyed_symlinks_test.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import os
2+
import subprocess
3+
4+
import pytest
5+
6+
from pre_commit_hooks.destroyed_symlinks import find_destroyed_symlinks
7+
from pre_commit_hooks.destroyed_symlinks import main
8+
9+
TEST_SYMLINK = 'test_symlink'
10+
TEST_SYMLINK_TARGET = '/doesnt/really/matters'
11+
TEST_FILE = 'test_file'
12+
TEST_FILE_RENAMED = f'{TEST_FILE}_renamed'
13+
14+
15+
@pytest.fixture
16+
def repo_with_destroyed_symlink(tmpdir):
17+
source_repo = tmpdir.join('src')
18+
os.makedirs(source_repo, exist_ok=True)
19+
test_repo = tmpdir.join('test')
20+
with source_repo.as_cwd():
21+
subprocess.check_call(('git', 'init'))
22+
os.symlink(TEST_SYMLINK_TARGET, TEST_SYMLINK)
23+
with open(TEST_FILE, 'w') as f:
24+
print('some random content', file=f)
25+
subprocess.check_call(('git', 'add', '.'))
26+
subprocess.check_call(
27+
('git', 'commit', '--no-gpg-sign', '-m', 'initial'),
28+
)
29+
assert b'120000 ' in subprocess.check_output(
30+
('git', 'cat-file', '-p', 'HEAD^{tree}'),
31+
)
32+
subprocess.check_call(
33+
('git', '-c', 'core.symlinks=false', 'clone', source_repo, test_repo),
34+
)
35+
with test_repo.as_cwd():
36+
subprocess.check_call(
37+
('git', 'config', '--local', 'core.symlinks', 'true'),
38+
)
39+
subprocess.check_call(('git', 'mv', TEST_FILE, TEST_FILE_RENAMED))
40+
assert not os.path.islink(test_repo.join(TEST_SYMLINK))
41+
yield test_repo
42+
43+
44+
def test_find_destroyed_symlinks(repo_with_destroyed_symlink):
45+
with repo_with_destroyed_symlink.as_cwd():
46+
assert find_destroyed_symlinks([]) == []
47+
assert main([]) == 0
48+
49+
subprocess.check_call(('git', 'add', TEST_SYMLINK))
50+
assert find_destroyed_symlinks([TEST_SYMLINK]) == [TEST_SYMLINK]
51+
assert find_destroyed_symlinks([]) == []
52+
assert main([]) == 0
53+
assert find_destroyed_symlinks([TEST_FILE_RENAMED, TEST_FILE]) == []
54+
ALL_STAGED = [TEST_SYMLINK, TEST_FILE_RENAMED]
55+
assert find_destroyed_symlinks(ALL_STAGED) == [TEST_SYMLINK]
56+
assert main(ALL_STAGED) != 0
57+
58+
with open(TEST_SYMLINK, 'a') as f:
59+
print(file=f) # add trailing newline
60+
subprocess.check_call(['git', 'add', TEST_SYMLINK])
61+
assert find_destroyed_symlinks(ALL_STAGED) == [TEST_SYMLINK]
62+
assert main(ALL_STAGED) != 0
63+
64+
with open(TEST_SYMLINK, 'w') as f:
65+
print('0' * len(TEST_SYMLINK_TARGET), file=f)
66+
subprocess.check_call(('git', 'add', TEST_SYMLINK))
67+
assert find_destroyed_symlinks(ALL_STAGED) == []
68+
assert main(ALL_STAGED) == 0
69+
70+
with open(TEST_SYMLINK, 'w') as f:
71+
print('0' * (len(TEST_SYMLINK_TARGET) + 3), file=f)
72+
subprocess.check_call(('git', 'add', TEST_SYMLINK))
73+
assert find_destroyed_symlinks(ALL_STAGED) == []
74+
assert main(ALL_STAGED) == 0

tests/util_test.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pre_commit_hooks.util import CalledProcessError
44
from pre_commit_hooks.util import cmd_output
5+
from pre_commit_hooks.util import zsplit
56

67

78
def test_raises_on_error():
@@ -12,3 +13,13 @@ def test_raises_on_error():
1213
def test_output():
1314
ret = cmd_output('sh', '-c', 'echo hi')
1415
assert ret == 'hi\n'
16+
17+
18+
@pytest.mark.parametrize('out', ('\0f1\0f2\0', '\0f1\0f2', 'f1\0f2\0'))
19+
def test_check_zsplits_str_correctly(out):
20+
assert zsplit(out) == ['f1', 'f2']
21+
22+
23+
@pytest.mark.parametrize('out', ('\0\0', '\0', ''))
24+
def test_check_zsplit_returns_empty(out):
25+
assert zsplit(out) == []

0 commit comments

Comments
 (0)