Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions lib/crewai/src/crewai/cli/git.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from functools import lru_cache
import subprocess


class Repository:
def __init__(self, path: str = ".") -> None:
self.path = path
self._is_git_repo_cache: bool | None = None

if not self.is_git_installed():
raise ValueError("Git is not installed or not found in your PATH.")
Expand Down Expand Up @@ -40,22 +40,24 @@ def status(self) -> str:
encoding="utf-8",
).strip()

@lru_cache(maxsize=None) # noqa: B019
def is_git_repo(self) -> bool:
"""Check if the current directory is a git repository.

Notes:
- TODO: This method is cached to avoid redundant checks, but using lru_cache on methods can lead to memory leaks
Uses instance-level caching to avoid redundant subprocess calls
while preventing memory leaks.
"""
try:
subprocess.check_output(
["git", "rev-parse", "--is-inside-work-tree"], # noqa: S607
cwd=self.path,
encoding="utf-8",
)
return True
except subprocess.CalledProcessError:
return False
if self._is_git_repo_cache is None:
try:
subprocess.check_output(
["git", "rev-parse", "--is-inside-work-tree"], # noqa: S607
cwd=self.path,
encoding="utf-8",
)
self._is_git_repo_cache = True
except subprocess.CalledProcessError:
self._is_git_repo_cache = False

return self._is_git_repo_cache

def has_uncommitted_changes(self) -> bool:
"""Check if the repository has uncommitted changes."""
Expand Down
67 changes: 67 additions & 0 deletions lib/crewai/tests/cli/test_git.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import gc
import weakref

import pytest

from crewai.cli.git import Repository


Expand Down Expand Up @@ -99,3 +103,66 @@ def test_origin_url(fp, repository):
stdout="https://github.com/user/repo.git\n",
)
assert repository.origin_url() == "https://github.com/user/repo.git"


def test_repository_instances_are_garbage_collected(fp):
"""Test that Repository instances don't leak memory after going out of scope."""
# Register git commands
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
fp.register(["git", "rev-parse", "--is-inside-work-tree"], stdout="true\n")
fp.register(["git", "fetch"], stdout="")

# Track weak references
refs = []

# Create multiple instances
for i in range(100):
repo = Repository(path=".")
refs.append(weakref.ref(repo))
del repo

# Force garbage collection
gc.collect()

# Check that instances were garbage collected
alive = sum(1 for ref in refs if ref() is not None)
assert alive == 0, f"{alive} Repository instances still alive after gc"


def test_is_git_repo_caches_result(fp):
"""Test that is_git_repo caches its result per instance."""
# Register git commands
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
fp.register(["git", "rev-parse", "--is-inside-work-tree"], stdout="true\n")
fp.register(["git", "fetch"], stdout="")

repo = Repository(path=".")

# Cache should be populated after __init__ calls is_git_repo
assert repo._is_git_repo_cache is True

# Calling again should use cache (no additional subprocess calls)
result = repo.is_git_repo()
assert result is True

# Verify cache is being used
assert repo._is_git_repo_cache is True


def test_cache_is_per_instance(fp):
"""Test that cache is per-instance, not global."""
# Register git commands
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
fp.register(["git", "rev-parse", "--is-inside-work-tree"], stdout="true\n")
fp.register(["git", "fetch"], stdout="")

repo1 = Repository(path=".")
repo2 = Repository(path=".")

# Each instance should have its own cache
assert repo1._is_git_repo_cache is True
assert repo2._is_git_repo_cache is True

# Modifying one shouldn't affect the other
repo1._is_git_repo_cache = False
assert repo2._is_git_repo_cache is True
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests register insufficient subprocess calls for multiple instances

Medium Severity

The new tests test_cache_is_per_instance and test_repository_instances_are_garbage_collected only register subprocess commands once, but attempt to create multiple Repository instances. Each Repository.__init__ calls is_git_installed(), is_git_repo(), and fetch(), consuming 3 subprocess registrations. The first test creates 2 instances (needs 6 registrations), and the second creates 100 instances (needs 300 registrations), but both only register 3. Following the existing test patterns (e.g., test_is_synced_when_synced registers duplicate commands), each call consumes its registration. These tests will fail when pytest-subprocess finds no matching registration for the second instance.

🔬 Verification Test

Why verification test was not possible: The tests would require running pytest with the pytest-subprocess plugin installed in the project's test environment. The bug is evident from comparing the new tests against existing test patterns in the same file - test_is_synced_when_synced explicitly registers the same command twice because is_synced() calls status() twice, demonstrating that each fp.register() call is consumed once. The new tests fail to follow this established pattern when creating multiple Repository instances.

Additional Locations (1)

Fix in Cursor Fix in Web