fix(cli): harden json run and zip fallback

This commit is contained in:
Joao Moura
2026-06-15 11:43:10 -07:00
parent df712e079a
commit 664b3a0174
6 changed files with 123 additions and 14 deletions

View File

@@ -75,12 +75,15 @@ def create_project_zip(
def _project_files(root: Path, repository: git.Repository | None = None) -> list[Path]:
"""Return project-relative files to include in the archive."""
if repository is not None:
files = [Path(path) for path in repository.deployable_files()]
return [
path
for path in files
if not _is_excluded(path) and _is_regular_file(root / path)
]
return _repository_project_files(root, repository)
try:
repository = git.Repository(path=str(root), fetch=False)
except ValueError:
repository = None
if repository is not None:
return _repository_project_files(root, repository)
return [
path
@@ -89,6 +92,16 @@ def _project_files(root: Path, repository: git.Repository | None = None) -> list
]
def _repository_project_files(root: Path, repository: git.Repository) -> list[Path]:
"""Return deployable files from Git while applying local safety excludes."""
files = [Path(path) for path in repository.deployable_files()]
return [
path
for path in files
if not _is_excluded(path) and _is_regular_file(root / path)
]
def _walk_files(root: Path) -> list[Path]:
"""List regular files below root as project-relative paths."""
return [

View File

@@ -234,7 +234,11 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
style="yellow",
)
console.print(str(init_error), style="dim")
return None
try:
return git.Repository(fetch=False)
except Exception as repository_error:
console.print(str(repository_error), style="dim")
return None
_display_git_repository_help()
return repository

View File

@@ -262,17 +262,31 @@ def _has_lockfile(project_root: Path | None = None) -> bool:
return (root / "uv.lock").is_file() or (root / "poetry.lock").is_file()
def _has_project_venv(project_root: Path | None = None) -> bool:
"""Return True when the project already has a local uv environment."""
root = project_root or Path.cwd()
return (root / ".venv").is_dir()
def _install_json_crew_dependencies_if_needed() -> None:
"""Lock and sync JSON crew projects only when no lockfile exists."""
"""Prepare JSON crew dependencies without mutating existing lockfiles."""
project_root = Path.cwd()
if not (project_root / "pyproject.toml").is_file() or _has_lockfile(project_root):
if not (project_root / "pyproject.toml").is_file():
return
has_lockfile = _has_lockfile(project_root)
if has_lockfile and _has_project_venv(project_root):
return
from crewai_cli.install_crew import install_crew
try:
click.echo("Installing dependencies...")
install_crew([], raise_on_error=True, install_project=False)
if has_lockfile:
click.echo("Syncing dependencies from lockfile...")
install_crew(["--frozen"], raise_on_error=True)
else:
click.echo("Installing dependencies...")
install_crew([], raise_on_error=True)
except subprocess.CalledProcessError as e:
raise SystemExit(e.returncode) from e
except Exception as e:

View File

@@ -1,4 +1,5 @@
from pathlib import Path
import subprocess
import zipfile
import pytest
@@ -56,6 +57,42 @@ def test_create_project_zip_uses_repository_file_list(tmp_path: Path):
assert names == {"pyproject.toml", "uv.lock"}
def test_create_project_zip_without_repository_uses_git_ignore_rules(
tmp_path: Path,
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / ".gitignore").write_text("node_modules/\nsecret.txt\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "package.json").write_text("{}\n")
(tmp_path / "secret.txt").write_text("secret\n")
try:
subprocess.run(
["git", "init"],
cwd=tmp_path,
capture_output=True,
check=True,
text=True,
)
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
pytest.skip(f"git is not available in this environment: {exc}")
archive_path = create_project_zip("demo", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {
".gitignore",
"pyproject.toml",
"src/main.py",
}
def test_create_project_zip_does_not_fallback_when_repository_listing_fails(
tmp_path: Path,
):

View File

@@ -414,6 +414,25 @@ class TestDeployCommand(unittest.TestCase):
)
self.mock_client.create_crew.assert_not_called()
@patch("crewai_cli.deploy.main.git.Repository")
def test_prepare_git_repository_returns_repo_when_init_commit_fails(
self, mock_repository
):
recovered_repository = MagicMock()
mock_repository.side_effect = [
ValueError("not a Git repository"),
recovered_repository,
]
mock_repository.initialize.side_effect = RuntimeError("commit failed")
with patch("sys.stdout", new=StringIO()) as fake_out:
repository = self.deploy_command._prepare_git_repository()
self.assertIs(repository, recovered_repository)
self.assertIn("Git auto-setup did not complete", fake_out.getvalue())
mock_repository.initialize.assert_called_once_with()
self.assertEqual(mock_repository.call_count, 2)
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")

View File

@@ -182,7 +182,7 @@ def test_json_project_env_run_failure_exits_nonzero(monkeypatch, tmp_path: Path)
def test_json_run_installs_dependencies_when_pyproject_has_no_lockfile(
monkeypatch, tmp_path: Path
):
"""JSON crew runs should lock/sync project dependencies only once."""
"""JSON crew runs should lock/sync project dependencies only when needed."""
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
calls = []
@@ -196,11 +196,11 @@ def test_json_run_installs_dependencies_when_pyproject_has_no_lockfile(
run_crew_module._install_json_crew_dependencies_if_needed()
assert calls == [([], True, False)]
assert calls == [([], True, None)]
@pytest.mark.parametrize("lockfile", ["uv.lock", "poetry.lock"])
def test_json_run_skips_dependency_install_when_lockfile_exists(
def test_json_run_syncs_frozen_when_lockfile_exists_without_venv(
monkeypatch, tmp_path: Path, lockfile: str
):
monkeypatch.chdir(tmp_path)
@@ -217,6 +217,28 @@ def test_json_run_skips_dependency_install_when_lockfile_exists(
run_crew_module._install_json_crew_dependencies_if_needed()
assert calls == [(["--frozen"], True, None)]
@pytest.mark.parametrize("lockfile", ["uv.lock", "poetry.lock"])
def test_json_run_skips_dependency_install_when_lockfile_and_venv_exist(
monkeypatch, tmp_path: Path, lockfile: str
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / lockfile).write_text("# lock\n")
(tmp_path / ".venv").mkdir()
calls = []
def fake_install_crew(
proxy_options, *, raise_on_error=False, install_project=None
):
calls.append((proxy_options, raise_on_error, install_project))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
run_crew_module._install_json_crew_dependencies_if_needed()
assert calls == []