diff --git a/lib/cli/src/crewai_cli/deploy/archive.py b/lib/cli/src/crewai_cli/deploy/archive.py index bdd4b0d47..3013b6c9b 100644 --- a/lib/cli/src/crewai_cli/deploy/archive.py +++ b/lib/cli/src/crewai_cli/deploy/archive.py @@ -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 [ diff --git a/lib/cli/src/crewai_cli/deploy/main.py b/lib/cli/src/crewai_cli/deploy/main.py index 6ed0a7ab8..820296b24 100644 --- a/lib/cli/src/crewai_cli/deploy/main.py +++ b/lib/cli/src/crewai_cli/deploy/main.py @@ -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 diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index 4f4e60a3b..549a8c035 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -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: diff --git a/lib/cli/tests/deploy/test_archive.py b/lib/cli/tests/deploy/test_archive.py index a40cfdd88..afa312e24 100644 --- a/lib/cli/tests/deploy/test_archive.py +++ b/lib/cli/tests/deploy/test_archive.py @@ -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, ): diff --git a/lib/cli/tests/deploy/test_deploy_main.py b/lib/cli/tests/deploy/test_deploy_main.py index 7a71ecef8..85c2cef0a 100644 --- a/lib/cli/tests/deploy/test_deploy_main.py +++ b/lib/cli/tests/deploy/test_deploy_main.py @@ -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") diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index 945836a19..49f2310a0 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -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 == []