Skip to content
Merged
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
27 changes: 23 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pytest = "^8.0.0"
pytest-vcr = "^1.0.2"
python-dotenv = "1.0.0"
pytest-asyncio = "^0.23.7"
pytest-subprocess = "^1.5.2"

[tool.poetry.scripts]
crewai = "crewai.cli.cli:crewai"
Expand Down
13 changes: 7 additions & 6 deletions src/crewai/cli/deploy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@

from rich.console import Console

from crewai.cli import git
from crewai.cli.command import BaseCommand, PlusAPIMixin
from crewai.cli.utils import (
fetch_and_json_env_file,
get_git_remote_url,
get_project_name,
)
from crewai.cli.utils import fetch_and_json_env_file, get_project_name

console = Console()

Expand Down Expand Up @@ -91,7 +88,11 @@ def create_crew(self, confirm: bool = False) -> None:
)
console.print("Creating deployment...", style="bold blue")
env_vars = fetch_and_json_env_file()
remote_repo_url = get_git_remote_url()

try:
remote_repo_url = git.Repository().origin_url()
except ValueError:
remote_repo_url = None

if remote_repo_url is None:
console.print("No remote repository URL found.", style="bold red")
Expand Down
80 changes: 80 additions & 0 deletions src/crewai/cli/git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import subprocess


class Repository:
def __init__(self, path="."):
self.path = path

if not self.is_git_installed():
raise ValueError("Git is not installed or not found in your PATH.")

if not self.is_git_repo():
raise ValueError(f"{self.path} is not a Git repository.")

self.fetch()

def is_git_installed(self) -> bool:
"""Check if Git is installed and available in the system."""
try:
subprocess.run(
["git", "--version"], capture_output=True, check=True, text=True
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False

def fetch(self) -> None:
"""Fetch latest updates from the remote."""
subprocess.run(["git", "fetch"], cwd=self.path, check=True)

def status(self) -> str:
"""Get the git status in porcelain format."""
return subprocess.check_output(
["git", "status", "--branch", "--porcelain"],
cwd=self.path,
encoding="utf-8",
).strip()

def is_git_repo(self) -> bool:
"""Check if the current directory is a git repository."""
try:
subprocess.check_output(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=self.path,
encoding="utf-8",
)
return True
except subprocess.CalledProcessError:
return False

def has_uncommitted_changes(self) -> bool:
"""Check if the repository has uncommitted changes."""
return len(self.status().splitlines()) > 1

def is_ahead_or_behind(self) -> bool:
"""Check if the repository is ahead or behind the remote."""
for line in self.status().splitlines():
if line.startswith("##") and ("ahead" in line or "behind" in line):
return True
return False

def is_synced(self) -> bool:
"""Return True if the Git repository is fully synced with the remote, False otherwise."""
if self.has_uncommitted_changes() or self.is_ahead_or_behind():
return False
else:
return True
Comment on lines +63 to +66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I like the expressiveness of a if...else.
it can be short tho. Your call.

Suggested change
if self.has_uncommitted_changes() or self.is_ahead_or_behind():
return False
else:
return True
return not (self.has_uncommitted_changes() or self.is_ahead_or_behind())

to avoid the negation, perhaps invert the logic like is_out_of_sync. Pure juice of nitpicking on my side :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks! I was wrapping my head around this, and I decided to go with the verbose way. not combined with or crashes my inner agent 😆


def origin_url(self) -> str | None:
"""Get the Git repository's remote URL."""
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=self.path,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
12 changes: 12 additions & 0 deletions src/crewai/cli/tools/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import tempfile

from crewai.cli.command import BaseCommand, PlusAPIMixin
from crewai.cli import git
from crewai.cli.utils import (
get_project_name,
get_project_description,
Expand Down Expand Up @@ -59,6 +60,17 @@ def create(self, handle: str):
os.chdir(old_directory)

def publish(self, is_public: bool):
if not git.Repository().is_synced():
console.print(
"[bold red]Failed to publish tool.[/bold red]\n"
"Local changes need to be resolved before publishing. Please do the following:\n"
"* [bold]Commit[/bold] your changes.\n"
"* [bold]Push[/bold] to sync with the remote.\n"
"* [bold]Pull[/bold] the latest changes from the remote.\n"
"\nOnce your repository is up-to-date, retry publishing the tool."
)
raise SystemExit()

project_name = get_project_name(require=True)
assert isinstance(project_name, str)

Expand Down
34 changes: 0 additions & 34 deletions src/crewai/cli/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import os
import shutil
import click
import re
import subprocess
import sys
import importlib.metadata

Expand Down Expand Up @@ -61,38 +59,6 @@ def parse_toml(content):
return simple_toml_parser(content)


def get_git_remote_url() -> str | None:
"""Get the Git repository's remote URL."""
try:
# Run the git remote -v command
result = subprocess.run(
["git", "remote", "-v"], capture_output=True, text=True, check=True
)

# Get the output
output = result.stdout

# Parse the output to find the origin URL
matches = re.findall(r"origin\s+(.*?)\s+\(fetch\)", output)

if matches:
return matches[0] # Return the first match (origin URL)
else:
console.print("No origin remote found.", style="bold red")

except subprocess.CalledProcessError as e:
console.print(
f"Error running trying to fetch the Git Repository: {e}", style="bold red"
)
except FileNotFoundError:
console.print(
"Git command not found. Make sure Git is installed and in your PATH.",
style="bold red",
)

return None


def get_project_name(
pyproject_path: str = "pyproject.toml", require: bool = False
) -> str | None:
Expand Down
6 changes: 3 additions & 3 deletions tests/cli/deploy/test_deploy_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,11 @@ def test_deploy_with_project_name(self, mock_display):
mock_display.assert_called_once_with({"uuid": "test-uuid"})

@patch("crewai.cli.deploy.main.fetch_and_json_env_file")
@patch("crewai.cli.deploy.main.get_git_remote_url")
@patch("crewai.cli.deploy.main.git.Repository.origin_url")
@patch("builtins.input")
def test_create_crew(self, mock_input, mock_get_git_remote_url, mock_fetch_env):
def test_create_crew(self, mock_input, mock_git_origin_url, mock_fetch_env):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_get_git_remote_url.return_value = "https://github.com/test/repo.git"
mock_git_origin_url.return_value = "https://github.com/test/repo.git"
mock_input.return_value = ""

mock_response = MagicMock()
Expand Down
101 changes: 101 additions & 0 deletions tests/cli/test_git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from crewai.cli.git import Repository
import pytest


@pytest.fixture()
def repository(fp):
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="")
return Repository(path=".")


def test_init_with_invalid_git_repo(fp):
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
fp.register(
["git", "rev-parse", "--is-inside-work-tree"],
returncode=1,
stderr="fatal: not a git repository\n",
)

with pytest.raises(ValueError):
Repository(path="invalid/path")


def test_is_git_not_installed(fp):
fp.register(["git", "--version"], returncode=1)

with pytest.raises(
ValueError, match="Git is not installed or not found in your PATH."
):
Repository(path=".")


def test_status(fp, repository):
fp.register(
["git", "status", "--branch", "--porcelain"],
stdout="## main...origin/main [ahead 1]\n",
)
assert repository.status() == "## main...origin/main [ahead 1]"


def test_has_uncommitted_changes(fp, repository):
fp.register(
["git", "status", "--branch", "--porcelain"],
stdout="## main...origin/main\n M somefile.txt\n",
)
assert repository.has_uncommitted_changes() is True


def test_is_ahead_or_behind(fp, repository):
fp.register(
["git", "status", "--branch", "--porcelain"],
stdout="## main...origin/main [ahead 1]\n",
)
assert repository.is_ahead_or_behind() is True


def test_is_synced_when_synced(fp, repository):
fp.register(
["git", "status", "--branch", "--porcelain"], stdout="## main...origin/main\n"
)
fp.register(
["git", "status", "--branch", "--porcelain"], stdout="## main...origin/main\n"
)
assert repository.is_synced() is True


def test_is_synced_with_uncommitted_changes(fp, repository):
fp.register(
["git", "status", "--branch", "--porcelain"],
stdout="## main...origin/main\n M somefile.txt\n",
)
assert repository.is_synced() is False


def test_is_synced_when_ahead_or_behind(fp, repository):
fp.register(
["git", "status", "--branch", "--porcelain"],
stdout="## main...origin/main [ahead 1]\n",
)
fp.register(
["git", "status", "--branch", "--porcelain"],
stdout="## main...origin/main [ahead 1]\n",
)
assert repository.is_synced() is False


def test_is_synced_with_uncommitted_changes_and_ahead(fp, repository):
fp.register(
["git", "status", "--branch", "--porcelain"],
stdout="## main...origin/main [ahead 1]\n M somefile.txt\n",
)
assert repository.is_synced() is False


def test_origin_url(fp, repository):
fp.register(
["git", "remote", "get-url", "origin"],
stdout="https://github.com/user/repo.git\n",
)
assert repository.origin_url() == "https://github.com/user/repo.git"
14 changes: 8 additions & 6 deletions tests/cli/tools/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ def test_install_api_error(self, mock_get):
read_data=b"sample tarball content",
)
@patch("crewai.cli.plus_api.PlusAPI.publish_tool")
@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True)
def test_publish_success(
self,
mock_is_synced,
mock_publish,
mock_open,
mock_listdir,
Expand All @@ -147,16 +149,16 @@ def test_publish_success(
tool_command = ToolCommand()
tool_command.publish(is_public=True)

mock_get_project_name.assert_called_once_with(require=True)
mock_get_project_version.assert_called_once_with(require=True)
mock_get_project_description.assert_called_once_with(require=False)
mock_subprocess_run.assert_called_once_with(
mock_get_project_name.assert_called_with(require=True)
mock_get_project_version.assert_called_with(require=True)
mock_get_project_description.assert_called_with(require=False)
mock_subprocess_run.assert_called_with(
["poetry", "build", "-f", "sdist", "--output", unittest.mock.ANY],
check=True,
capture_output=False,
)
mock_open.assert_called_once_with(unittest.mock.ANY, "rb")
mock_publish.assert_called_once_with(
mock_open.assert_called_with(unittest.mock.ANY, "rb")
mock_publish.assert_called_with(
handle="sample-tool",
is_public=True,
version="1.0.0",
Expand Down