from __future__ import annotations import argparse import shlex import subprocess from typing import Sequence from pre_commit_hooks.util import cmd_output from pre_commit_hooks.util import zsplit ORDINARY_CHANGED_ENTRIES_MARKER = '1' PERMS_LINK = '120000' PERMS_NONEXIST = '000000' def find_destroyed_symlinks(files: Sequence[str]) -> list[str]: destroyed_links: list[str] = [] if not files: return destroyed_links for line in zsplit( cmd_output('git', 'status', '--porcelain=v2', '-z', '--', *files), ): splitted = line.split(' ') if splitted and splitted[0] == ORDINARY_CHANGED_ENTRIES_MARKER: # https://git-scm.com/docs/git-status#_changed_tracked_entries ( _, _, _, mode_HEAD, mode_index, _, hash_HEAD, hash_index, *path_splitted, ) = splitted path = ' '.join(path_splitted) if ( mode_HEAD == PERMS_LINK and mode_index != PERMS_LINK and mode_index != PERMS_NONEXIST ): if hash_HEAD == hash_index: # if old and new hashes are equal, it's not needed to check # anything more, we've found a destroyed symlink for sure destroyed_links.append(path) else: # if old and new hashes are *not* equal, it doesn't mean # that everything is OK - new file may be altered # by something like trailing-whitespace and/or # mixed-line-ending hooks so we need to go deeper SIZE_CMD = ('git', 'cat-file', '-s') size_index = int(cmd_output(*SIZE_CMD, hash_index).strip()) size_HEAD = int(cmd_output(*SIZE_CMD, hash_HEAD).strip()) # in the worst case new file may have CRLF added # so check content only if new file is bigger # not more than 2 bytes compared to the old one if size_index <= size_HEAD + 2: head_content = subprocess.check_output( ('git', 'cat-file', '-p', hash_HEAD), ).rstrip() index_content = subprocess.check_output( ('git', 'cat-file', '-p', hash_index), ).rstrip() if head_content == index_content: destroyed_links.append(path) return destroyed_links def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument('filenames', nargs='*', help='Filenames to check.') args = parser.parse_args(argv) destroyed_links = find_destroyed_symlinks(files=args.filenames) if destroyed_links: print('Destroyed symlinks:') for destroyed_link in destroyed_links: print(f'- {destroyed_link}') print('You should unstage affected files:') print( '\tgit reset HEAD -- {}'.format( ' '.join(shlex.quote(link) for link in destroyed_links), ), ) print( 'And retry commit. As a long term solution ' 'you may try to explicitly tell git that your ' 'environment does not support symlinks:', ) print('\tgit config core.symlinks false') return 1 else: return 0 if __name__ == '__main__': raise SystemExit(main())