Files
crewAI/lib/cli/src/crewai_cli/git.py
João Moura 53c2284484 Support ZIP deployment fallback and JSON crew project env runs (#6166)
* Update crewAI CLI with various enhancements and fixes

- Updated `create_json_crew.py` to require `crewai[tools]>=1.14.7`.
- Enhanced `git.py` with improved repository initialization, including automatic initial commit creation and exclusion patterns for initial commits.
- Modified `install_crew.py` to allow error handling during installation with an optional `raise_on_error` parameter.
- Expanded `plus_api.py` to include methods for creating and updating crews from ZIP files.
- Introduced a new `archive.py` for creating deployable ZIP archives of CrewAI projects, ensuring local artifacts are excluded.
- Updated `run_crew.py` to manage JSON crew dependencies and run crews in the project's environment.
- Enhanced deployment logic in `main.py` to handle ZIP uploads and improve user feedback during deployment processes.
- Added tests for new functionalities and ensured existing tests reflect recent changes in behavior and requirements.

* fix(cli): address deploy zip review feedback

* fix(cli): sync missing lockfile before deploy

* fix(cli): preserve remote deploy on git setup warnings

* test(cli): use single deploy main import style

* fix(cli): skip project install for json crew sync

* fix(cli): load json runner from source checkout

* fix(cli): skip json crew sync when locked

* fix(cli): address deploy zip review feedback

* fix(cli): pass env on zip redeploy

* fix(cli): harden json run and zip fallback

* fix(cli): validate before deploy lock install

* fix(cli): respect poetry lock for json runs

* fix(cli): align json zip wrapper detection

* fix(deps): bump starlette audit floor

* fix(cli): avoid auth retry for deploy exits

* fix(cli): update json zip script entrypoints
2026-06-15 18:46:54 -03:00

204 lines
6.5 KiB
Python

from __future__ import annotations
from functools import cached_property
from pathlib import Path
import subprocess
_INITIAL_COMMIT_EXCLUDE_PATTERNS = [
".crewai/",
".env",
".env.*",
"!.env.example",
"!.env.sample",
".mypy_cache/",
".pytest_cache/",
".ruff_cache/",
".tox/",
".venv/",
"__pycache__/",
"build/",
"dist/",
"env/",
"venv/",
]
class Repository:
def __init__(self, path: str = ".", fetch: bool = True) -> None:
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.")
if fetch:
self.fetch()
@staticmethod
def is_git_installed() -> bool:
"""Check if Git is installed and available in the system."""
try:
subprocess.run(
["git", "--version"], # noqa: S607
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."""
command = ["git", "fetch"]
result = subprocess.run( # noqa: S603
command,
cwd=self.path,
capture_output=True,
text=True,
)
if result.returncode == 0:
return
if "No remote repository specified" in result.stderr:
return
details = result.stderr.strip() or result.stdout.strip() or "no output"
raise ValueError(
f"Git fetch failed with exit code {result.returncode} "
f"for command {command!r}: {details}"
)
@classmethod
def initialize(cls, path: str = ".") -> Repository:
"""Initialize a Git repository and create an initial commit if needed."""
if not cls.is_git_installed():
raise ValueError("Git is not installed or not found in your PATH.")
subprocess.run(["git", "init"], cwd=path, check=True) # noqa: S607
repository = cls(path=path, fetch=False)
repository.create_initial_commit_if_needed()
return repository
def status(self) -> str:
"""Get the git status in porcelain format."""
return subprocess.check_output(
["git", "status", "--branch", "--porcelain"], # noqa: S607
cwd=self.path,
encoding="utf-8",
).strip()
@cached_property
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"], # noqa: S607
cwd=self.path,
encoding="utf-8",
stderr=subprocess.DEVNULL,
)
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
return True
def has_commits(self) -> bool:
"""Return True if the repository has at least one commit."""
try:
subprocess.run(
["git", "rev-parse", "--verify", "HEAD"], # noqa: S607
cwd=self.path,
capture_output=True,
check=True,
text=True,
)
return True
except subprocess.CalledProcessError:
return False
def create_initial_commit_if_needed(self) -> bool:
"""Create a local initial commit when the repository has no commits."""
if self.has_commits():
return False
self._ensure_initial_commit_excludes()
subprocess.run(["git", "add", "."], cwd=self.path, check=True) # noqa: S607
command = [
"git",
"-c",
"user.name=CrewAI",
"-c",
"user.email=deploy@crewai.com",
"commit",
"--allow-empty",
"-m",
"Initial crew",
]
subprocess.run( # noqa: S603
command,
cwd=self.path,
check=True,
)
return True
def _ensure_initial_commit_excludes(self) -> None:
"""Add local-only ignore patterns before auto-staging an initial commit."""
exclude_file = Path(self.path) / ".git" / "info" / "exclude"
exclude_file.parent.mkdir(parents=True, exist_ok=True)
existing = exclude_file.read_text() if exclude_file.exists() else ""
existing_lines = set(existing.splitlines())
missing_patterns = [
pattern
for pattern in _INITIAL_COMMIT_EXCLUDE_PATTERNS
if pattern not in existing_lines
]
if not missing_patterns:
return
prefix = "" if existing.endswith("\n") or not existing else "\n"
patterns = "\n".join(missing_patterns)
exclude_file.write_text(
f"{existing}{prefix}# CrewAI deploy auto-commit excludes\n{patterns}\n"
)
def deployable_files(self) -> list[str]:
"""Return files tracked by Git or untracked and not ignored."""
output = subprocess.check_output(
["git", "ls-files", "--cached", "--others", "--exclude-standard"], # noqa: S607
cwd=self.path,
encoding="utf-8",
)
return [line for line in output.splitlines() if line]
def origin_url(self) -> str | None:
"""Get the Git repository's remote URL."""
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"], # noqa: S607
cwd=self.path,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None