From de173ce0780fdd8342781b8dac2a6ab671cb922a Mon Sep 17 00:00:00 2001 From: MischaPanch Date: Thu, 14 Aug 2025 12:11:24 +0200 Subject: [PATCH] Fix gitignore pattern scoping bug for **/pattern in subdirectories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when a subdirectory .gitignore file contained patterns starting with "**/", these patterns were incorrectly applied globally instead of being scoped to that subdirectory's tree. For example, if foo/.gitignore contained "**/test.txt", it would incorrectly ignore test.txt files everywhere in the repository, including at the root level and in other subdirectories like bar/test.txt. The bug was in _parse_gitignore_content() where patterns starting with "**/" were left unchanged (line 228: adjusted_pattern = line), making them global instead of scoped. Fixed by ensuring that even "**/pattern" gets the subdirectory prefix: - Before: "**/test.txt" → "**/test.txt" (global) - After: "**/test.txt" → "foo/**/test.txt" (scoped to foo/ subtree) Added comprehensive test cases covering all gitignore pattern scenarios: - Non-anchored patterns (foo.txt) - already worked correctly - Anchored patterns (/foo.txt) - already worked correctly - Double-star patterns (**/foo.txt) - was broken, now fixed - Anchored double-star (/**/foo.txt) - already worked correctly 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/serena/util/file_system.py | 3 +- test/serena/util/test_file_system.py | 119 +++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/serena/util/file_system.py b/src/serena/util/file_system.py index 5f8ca826..1869402c 100644 --- a/src/serena/util/file_system.py +++ b/src/serena/util/file_system.py @@ -225,7 +225,8 @@ class GitignoreParser: # Non-anchored patterns can match anywhere below the gitignore directory # We need to preserve this behavior if line.startswith("**/"): - adjusted_pattern = line + # Even if pattern starts with **, it should still be scoped to the subdirectory + adjusted_pattern = os.path.join(rel_dir, line) else: # Add the directory prefix but also allow matching in subdirectories adjusted_pattern = os.path.join(rel_dir, "**", line) diff --git a/test/serena/util/test_file_system.py b/test/serena/util/test_file_system.py index 984deb6e..b47dd34c 100644 --- a/test/serena/util/test_file_system.py +++ b/test/serena/util/test_file_system.py @@ -587,3 +587,122 @@ src/*.o # Note: Negation patterns in pathspec work differently than in git # This is a limitation of the pathspec library + + def test_subdirectory_gitignore_pattern_scoping(self): + """Test that subdirectory .gitignore patterns are scoped correctly.""" + # Create test structure: foo/ with subdirectory bar/ + test_dir = self.repo_path / "test_subdir_scoping" + test_dir.mkdir() + (test_dir / "foo").mkdir() + (test_dir / "foo" / "bar").mkdir() + + # Create files in various locations + (test_dir / "foo.txt").touch() # root level + (test_dir / "foo" / "foo.txt").touch() # in foo/ + (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ + + # Test case 1: foo.txt in foo/.gitignore should only ignore in foo/ subtree + gitignore = test_dir / "foo" / ".gitignore" + gitignore.write_text("foo.txt\n") + + parser = GitignoreParser(str(test_dir)) + + # foo.txt at root should NOT be ignored by foo/.gitignore + assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored by foo/.gitignore" + + # foo.txt in foo/ should be ignored + assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" + + # foo.txt in foo/bar/ should be ignored (within foo/ subtree) + assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" + + def test_anchored_pattern_in_subdirectory(self): + """Test that anchored patterns in subdirectory only match immediate children.""" + test_dir = self.repo_path / "test_anchored_subdir" + test_dir.mkdir() + (test_dir / "foo").mkdir() + (test_dir / "foo" / "bar").mkdir() + + # Create files + (test_dir / "foo.txt").touch() # root level + (test_dir / "foo" / "foo.txt").touch() # in foo/ + (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ + + # Test case 2: /foo.txt in foo/.gitignore should only match foo/foo.txt + gitignore = test_dir / "foo" / ".gitignore" + gitignore.write_text("/foo.txt\n") + + parser = GitignoreParser(str(test_dir)) + + # foo.txt at root should NOT be ignored + assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored" + + # foo.txt directly in foo/ should be ignored + assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored by /foo.txt pattern" + + # foo.txt in foo/bar/ should NOT be ignored (anchored pattern only matches immediate children) + assert not parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should NOT be ignored by /foo.txt pattern" + + def test_double_star_pattern_scoping(self): + """Test that **/pattern in subdirectory only applies within that subtree.""" + test_dir = self.repo_path / "test_doublestar_scope" + test_dir.mkdir() + (test_dir / "foo").mkdir() + (test_dir / "foo" / "bar").mkdir() + (test_dir / "other").mkdir() + + # Create files + (test_dir / "foo.txt").touch() # root level + (test_dir / "foo" / "foo.txt").touch() # in foo/ + (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ + (test_dir / "other" / "foo.txt").touch() # in other/ + + # Test case 3: **/foo.txt in foo/.gitignore should only ignore within foo/ subtree + gitignore = test_dir / "foo" / ".gitignore" + gitignore.write_text("**/foo.txt\n") + + parser = GitignoreParser(str(test_dir)) + + # foo.txt at root should NOT be ignored + assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored by foo/.gitignore" + + # foo.txt in foo/ should be ignored + assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" + + # foo.txt in foo/bar/ should be ignored (within foo/ subtree) + assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" + + # foo.txt in other/ should NOT be ignored (outside foo/ subtree) + assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" + + def test_anchored_double_star_pattern(self): + """Test that /**/pattern in subdirectory works correctly.""" + test_dir = self.repo_path / "test_anchored_doublestar" + test_dir.mkdir() + (test_dir / "foo").mkdir() + (test_dir / "foo" / "bar").mkdir() + (test_dir / "other").mkdir() + + # Create files + (test_dir / "foo.txt").touch() # root level + (test_dir / "foo" / "foo.txt").touch() # in foo/ + (test_dir / "foo" / "bar" / "foo.txt").touch() # in foo/bar/ + (test_dir / "other" / "foo.txt").touch() # in other/ + + # Test case 4: /**/foo.txt in foo/.gitignore should correctly ignore only within foo/ subtree + gitignore = test_dir / "foo" / ".gitignore" + gitignore.write_text("/**/foo.txt\n") + + parser = GitignoreParser(str(test_dir)) + + # foo.txt at root should NOT be ignored + assert not parser.should_ignore("foo.txt"), "Root foo.txt should not be ignored" + + # foo.txt in foo/ should be ignored + assert parser.should_ignore("foo/foo.txt"), "foo/foo.txt should be ignored" + + # foo.txt in foo/bar/ should be ignored (within foo/ subtree) + assert parser.should_ignore("foo/bar/foo.txt"), "foo/bar/foo.txt should be ignored" + + # foo.txt in other/ should NOT be ignored (outside foo/ subtree) + assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore"