Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
elifarley committed Dec 20, 2024
1 parent b8d1872 commit c0cfc62
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 55 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ on:

jobs:
test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ['3.12', '3.11', '3.10', '3.9', '3.8', '3.7']
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -21,3 +22,4 @@ jobs:
- name: Run tests
run: |
python -m unittest
26 changes: 23 additions & 3 deletions gitignore_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from os.path import abspath, dirname
from pathlib import Path
import sys
from typing import Reversible, Union

def handle_negation(file_path, rules: Reversible["IgnoreRule"]):
Expand All @@ -21,7 +22,7 @@ def parse_gitignore(full_path, base_dir=None):
for line in ignore_file:
counter += 1
line = line.rstrip('\n')
rule = rule_from_pattern(line, base_path=_normalize_path(base_dir),
rule = rule_from_pattern(line, base_path=Path(base_dir).resolve(),
source=(full_path, counter))
if rule:
rules.append(rule)
Expand All @@ -41,6 +42,8 @@ def rule_from_pattern(pattern, base_path=None, source=None):
Because git allows for nested .gitignore files, a base_path value
is required for correct behavior. The base path should be absolute.
"""
if base_path and base_path != Path(base_path).resolve():
raise ValueError('base_path must be absolute')
# Store the exact pattern for our repr and string functions
orig_pattern = pattern
# Early returns follow
Expand Down Expand Up @@ -123,9 +126,14 @@ def __repr__(self):
def match(self, abs_path: Union[str, Path]):
matched = False
if self.base_path:
rel_path = str(_normalize_path(abs_path).relative_to(self.base_path))
rel_path = _normalize_path(abs_path).relative_to(self.base_path).as_posix()
else:
rel_path = str(_normalize_path(abs_path))
rel_path = _normalize_path(abs_path).as_posix()
# Path() strips the trailing following symbols on windows, so we need to
# preserve it: ' ', '.'
if sys.platform.startswith('win'):
rel_path += ' ' * _count_trailing_symbol(' ', abs_path)
rel_path += '.' * _count_trailing_symbol('.', abs_path)
# Path() strips the trailing slash, so we need to preserve it
# in case of directory-only negation
if self.negation and type(abs_path) == str and abs_path[-1] == '/':
Expand Down Expand Up @@ -216,3 +224,15 @@ def _normalize_path(path: Union[str, Path]) -> Path:
`Path.resolve()` does.
"""
return Path(abspath(path))


def _count_trailing_symbol(symbol: str, text: str) -> int:
"""Count the number of trailing characters in a string."""
count = 0
for char in reversed(str(text)):
if char == symbol:
count += 1
else:
break
return count

88 changes: 37 additions & 51 deletions tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from gitignore_parser import parse_gitignore

from unittest import TestCase, main
from unittest import TestCase, main, SkipTest


class Test(TestCase):
Expand Down Expand Up @@ -86,17 +86,15 @@ def test_comment(self):
self.assertTrue(matches('/home/michael/#imnocomment'))

def test_ignore_directory(self):
matches = \
_parse_gitignore_string('.venv/', fake_base_dir='/home/michael')
matches = _parse_gitignore_string('.venv/', fake_base_dir='/home/michael')
self.assertTrue(matches('/home/michael/.venv'))
self.assertTrue(matches('/home/michael/.venv/folder'))
self.assertTrue(matches('/home/michael/.venv/file.txt'))
self.assertFalse(matches('/home/michael/.venv_other_folder'))
self.assertFalse(matches('/home/michael/.venv_no_folder.py'))

def test_ignore_directory_asterisk(self):
matches = \
_parse_gitignore_string('.venv/*', fake_base_dir='/home/michael')
matches = _parse_gitignore_string('.venv/*', fake_base_dir='/home/michael')
self.assertFalse(matches('/home/michael/.venv'))
self.assertTrue(matches('/home/michael/.venv/folder'))
self.assertTrue(matches('/home/michael/.venv/file.txt'))
Expand All @@ -114,25 +112,20 @@ def test_negation(self):
self.assertTrue(matches('/home/michael/waste.ignore'))

def test_literal_exclamation_mark(self):
matches = _parse_gitignore_string(
'\\!ignore_me!', fake_base_dir='/home/michael'
)
matches = _parse_gitignore_string('\\!ignore_me!', fake_base_dir='/home/michael')
self.assertTrue(matches('/home/michael/!ignore_me!'))
self.assertFalse(matches('/home/michael/ignore_me!'))
self.assertFalse(matches('/home/michael/ignore_me'))

def test_double_asterisks(self):
matches = _parse_gitignore_string(
'foo/**/Bar', fake_base_dir='/home/michael'
)
matches = _parse_gitignore_string('foo/**/Bar', fake_base_dir='/home/michael')
self.assertTrue(matches('/home/michael/foo/hello/Bar'))
self.assertTrue(matches('/home/michael/foo/world/Bar'))
self.assertTrue(matches('/home/michael/foo/Bar'))
self.assertFalse(matches('/home/michael/foo/BarBar'))

def test_double_asterisk_without_slashes_handled_like_single_asterisk(self):
matches = \
_parse_gitignore_string('a/b**c/d', fake_base_dir='/home/michael')
matches = _parse_gitignore_string('a/b**c/d', fake_base_dir='/home/michael')
self.assertTrue(matches('/home/michael/a/bc/d'))
self.assertTrue(matches('/home/michael/a/bXc/d'))
self.assertTrue(matches('/home/michael/a/bbc/d'))
Expand All @@ -143,12 +136,10 @@ def test_double_asterisk_without_slashes_handled_like_single_asterisk(self):
self.assertFalse(matches('/home/michael/a/bb/XX/cc/d'))

def test_more_asterisks_handled_like_single_asterisk(self):
matches = \
_parse_gitignore_string('***a/b', fake_base_dir='/home/michael')
matches = _parse_gitignore_string('***a/b', fake_base_dir='/home/michael')
self.assertTrue(matches('/home/michael/XYZa/b'))
self.assertFalse(matches('/home/michael/foo/a/b'))
matches = \
_parse_gitignore_string('a/b***', fake_base_dir='/home/michael')
matches = _parse_gitignore_string('a/b***', fake_base_dir='/home/michael')
self.assertTrue(matches('/home/michael/a/bXYZ'))
self.assertFalse(matches('/home/michael/a/b/foo'))

Expand All @@ -166,9 +157,7 @@ def test_directory_only_negation(self):
self.assertFalse(matches('/home/michael/data/01_raw/raw_file.csv'))
self.assertFalse(matches('/home/michael/data/02_processed/'))
self.assertFalse(matches('/home/michael/data/02_processed/.gitkeep'))
self.assertTrue(
matches('/home/michael/data/02_processed/processed_file.csv')
)
self.assertTrue(matches('/home/michael/data/02_processed/processed_file.csv'))

def test_single_asterisk(self):
matches = _parse_gitignore_string('*', fake_base_dir='/home/michael')
Expand All @@ -177,16 +166,12 @@ def test_single_asterisk(self):
self.assertTrue(matches('/home/michael/directory-trailing/'))

def test_supports_path_type_argument(self):
matches = _parse_gitignore_string(
'file1\n!file2', fake_base_dir='/home/michael'
)
matches = _parse_gitignore_string('file1\n!file2', fake_base_dir='/home/michael')
self.assertTrue(matches(Path('/home/michael/file1')))
self.assertFalse(matches(Path('/home/michael/file2')))

def test_slash_in_range_does_not_match_dirs(self):
matches = _parse_gitignore_string(
'abc[X-Z/]def', fake_base_dir='/home/michael'
)
matches = _parse_gitignore_string('abc[X-Z/]def', fake_base_dir='/home/michael')
self.assertFalse(matches('/home/michael/abcdef'))
self.assertTrue(matches('/home/michael/abcXdef'))
self.assertTrue(matches('/home/michael/abcYdef'))
Expand All @@ -195,32 +180,32 @@ def test_slash_in_range_does_not_match_dirs(self):
self.assertFalse(matches('/home/michael/abcXYZdef'))

def test_symlink_to_another_directory(self):
with TemporaryDirectory() as project_dir:
with TemporaryDirectory() as another_dir:
matches = \
_parse_gitignore_string('link', fake_base_dir=project_dir)

# Create a symlink to another directory.
link = Path(project_dir, 'link')
target = Path(another_dir, 'target')
"""Test the behavior of a symlink to another directory.
The issue https://github.com/mherrmann/gitignore_parser/issues/29 describes how
a symlink to another directory caused an exception to be raised during matching.
This test ensures that the issue is now fixed.
"""
with TemporaryDirectory() as project_dir, TemporaryDirectory() as another_dir:
project_dir = Path(project_dir).resolve()
another_dir = Path(another_dir).resolve()
matches = _parse_gitignore_string('link', fake_base_dir=project_dir)

# Create a symlink to another directory.
link = project_dir / 'link'
target = another_dir / 'target'

try:
link.symlink_to(target)

# Check the intended behavior according to
# https://git-scm.com/docs/gitignore#_notes:
# Symbolic links are not followed and are matched as if they
# were regular files.
self.assertTrue(matches(link))

def test_symlink_to_symlink_directory(self):
with TemporaryDirectory() as project_dir:
with TemporaryDirectory() as link_dir:
link = Path(link_dir, 'link')
link.symlink_to(project_dir)
file = Path(link, 'file.txt')
matches = \
_parse_gitignore_string('file.txt', fake_base_dir=str(link))
self.assertTrue(matches(file))

except OSError:
e = "Current user does not have permissions to perform symlink."
raise SkipTest(e)
# Check the intended behavior according to
# https://git-scm.com/docs/gitignore#_notes:
# Symbolic links are not followed and are matched as if they were regular
# files.
self.assertTrue(matches(link))

def _parse_gitignore_string(data: str, fake_base_dir: str = None):
with patch('builtins.open', mock_open(read_data=data)):
Expand All @@ -229,3 +214,4 @@ def _parse_gitignore_string(data: str, fake_base_dir: str = None):

if __name__ == '__main__':
main()

0 comments on commit c0cfc62

Please sign in to comment.