From 0de81361c0ab068db44a222ca0f9a66c11b73598 Mon Sep 17 00:00:00 2001 From: "kotaro.saito" Date: Sat, 17 Jan 2026 16:15:25 +0900 Subject: [PATCH 1/4] fix(cli): respect ignore files in adk deploy commands The adk deploy commands (cloud_run, agent_engine, gke) were not properly respecting .gitignore, .gcloudignore, or .ae_ignore files, causing unwanted files (like venv, .git, etc.) to be uploaded. This change: - Adds a unified _get_ignore_patterns_func helper that reads all three ignore files. - Updates to_cloud_run, to_agent_engine, and to_gke to use this helper. - Removes hardcoded ignore patterns to strictly follow user configuration. - Adds comprehensive unit tests to verify the fix. Fixes #4183 --- src/google/adk/cli/cli_deploy.py | 39 +++- .../cli/utils/test_cli_deploy_ignore.py | 197 ++++++++++++++++++ 2 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 tests/unittests/cli/utils/test_cli_deploy_ignore.py diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index 45dce7fda6..c49bb90f42 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -464,6 +464,29 @@ def _get_service_option_by_adk_version( return f'--session_db_url={session_uri}' if session_uri else '' +def _get_ignore_patterns_func(agent_folder: str): + """Returns a shutil.ignore_patterns function with combined patterns from .gitignore, .gcloudignore and .ae_ignore.""" + patterns = set() + + for filename in ['.gitignore', '.gcloudignore', '.ae_ignore']: + filepath = os.path.join(agent_folder, filename) + if os.path.exists(filepath): + click.echo(f'Reading ignore patterns from {filename}...') + try: + with open(filepath, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + # If it ends with /, remove it for fnmatch compatibility + if line.endswith('/'): + line = line[:-1] + patterns.add(line) + except Exception as e: + click.secho(f'Warning: Failed to read {filename}: {e}', fg='yellow') + + return shutil.ignore_patterns(*patterns) + + def to_cloud_run( *, agent_folder: str, @@ -530,7 +553,8 @@ def to_cloud_run( # copy agent source code click.echo('Copying agent source code...') agent_src_path = os.path.join(temp_folder, 'agents', app_name) - shutil.copytree(agent_folder, agent_src_path) + ignore_func = _get_ignore_patterns_func(agent_folder) + shutil.copytree(agent_folder, agent_src_path, ignore=ignore_func) requirements_txt_path = os.path.join(agent_src_path, 'requirements.txt') install_agent_deps = ( f'RUN pip install -r "/app/agents/{app_name}/requirements.txt"' @@ -730,15 +754,9 @@ def to_agent_engine( shutil.rmtree(agent_src_path) try: - ignore_patterns = None - ae_ignore_path = os.path.join(agent_folder, '.ae_ignore') - if os.path.exists(ae_ignore_path): - click.echo(f'Ignoring files matching the patterns in {ae_ignore_path}') - with open(ae_ignore_path, 'r') as f: - patterns = [pattern.strip() for pattern in f.readlines()] - ignore_patterns = shutil.ignore_patterns(*patterns) + ignore_func = _get_ignore_patterns_func(agent_folder) click.echo('Copying agent source code...') - shutil.copytree(agent_folder, agent_src_path, ignore=ignore_patterns) + shutil.copytree(agent_folder, agent_src_path, ignore=ignore_func) click.echo('Copying agent source code complete.') project = _resolve_project(project) @@ -991,7 +1009,8 @@ def to_gke( # copy agent source code click.echo(' - Copying agent source code...') agent_src_path = os.path.join(temp_folder, 'agents', app_name) - shutil.copytree(agent_folder, agent_src_path) + ignore_func = _get_ignore_patterns_func(agent_folder) + shutil.copytree(agent_folder, agent_src_path, ignore=ignore_func) requirements_txt_path = os.path.join(agent_src_path, 'requirements.txt') install_agent_deps = ( f'RUN pip install -r "/app/agents/{app_name}/requirements.txt"' diff --git a/tests/unittests/cli/utils/test_cli_deploy_ignore.py b/tests/unittests/cli/utils/test_cli_deploy_ignore.py new file mode 100644 index 0000000000..365ac2da88 --- /dev/null +++ b/tests/unittests/cli/utils/test_cli_deploy_ignore.py @@ -0,0 +1,197 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for ignore file support in cli_deploy.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +from unittest import mock + +import click +import pytest + +import src.google.adk.cli.cli_deploy as cli_deploy + + +@pytest.fixture(autouse=True) +def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None: + """Suppress click.echo to keep test output clean.""" + monkeypatch.setattr(click, "echo", lambda *_a, **_k: None) + monkeypatch.setattr(click, "secho", lambda *_a, **_k: None) + + +def test_to_cloud_run_respects_ignore_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test that to_cloud_run respects .gitignore and .gcloudignore.""" + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "agent.py").write_text("# agent") + (agent_dir / "__init__.py").write_text("") + (agent_dir / "ignored_by_git.txt").write_text("ignored") + (agent_dir / "ignored_by_gcloud.txt").write_text("ignored") + (agent_dir / "not_ignored.txt").write_text("keep") + + (agent_dir / ".gitignore").write_text("ignored_by_git.txt\n") + (agent_dir / ".gcloudignore").write_text("ignored_by_gcloud.txt\n") + + temp_deploy_dir = tmp_path / "temp_deploy" + + # Mock subprocess.run to avoid actual gcloud call + monkeypatch.setattr(subprocess, "run", mock.Mock()) + # Mock shutil.rmtree to keep the temp folder for verification + monkeypatch.setattr( + shutil, + "rmtree", + lambda path, **kwargs: None + if "temp_deploy" in str(path) + else shutil.rmtree(path, **kwargs), + ) + + cli_deploy.to_cloud_run( + agent_folder=str(agent_dir), + project="proj", + region="us-central1", + service_name="svc", + app_name="app", + temp_folder=str(temp_deploy_dir), + port=8080, + trace_to_cloud=False, + with_ui=False, + log_level="info", + verbosity="info", + adk_version="1.0.0", + ) + + agent_src_path = temp_deploy_dir / "agents" / "app" + + assert (agent_src_path / "agent.py").exists() + assert (agent_src_path / "not_ignored.txt").exists() + + # These should be ignored + assert not ( + agent_src_path / "ignored_by_git.txt" + ).exists(), "Should respect .gitignore" + assert not ( + agent_src_path / "ignored_by_gcloud.txt" + ).exists(), "Should respect .gcloudignore" + + +def test_to_agent_engine_respects_multiple_ignore_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test that to_agent_engine respects .gitignore, .gcloudignore and .ae_ignore.""" + # We need to be in the project dir for to_agent_engine + project_dir = tmp_path / "project" + project_dir.mkdir() + monkeypatch.chdir(project_dir) + + agent_dir = project_dir / "my_agent" + agent_dir.mkdir() + (agent_dir / "agent.py").write_text("root_agent = None") + (agent_dir / "__init__.py").write_text("from . import agent") + (agent_dir / "ignored_by_git.txt").write_text("ignored") + (agent_dir / "ignored_by_ae.txt").write_text("ignored") + + (agent_dir / ".gitignore").write_text("ignored_by_git.txt\n") + (agent_dir / ".ae_ignore").write_text("ignored_by_ae.txt\n") + + # Mock vertexai.Client and other things to avoid network/complex setup + monkeypatch.setattr("vertexai.Client", mock.Mock()) + # Mock shutil.rmtree to keep the temp folder for verification + original_rmtree = shutil.rmtree + + def mock_rmtree(path, **kwargs): + if "_tmp" in str(path): + return None + return original_rmtree(path, **kwargs) + + monkeypatch.setattr(shutil, "rmtree", mock_rmtree) + + cli_deploy.to_agent_engine( + agent_folder=str(agent_dir), + staging_bucket="gs://test", + adk_app="adk_app", + ) + + # Find the temp folder created by to_agent_engine + temp_folders = [ + d for d in project_dir.iterdir() if d.is_dir() and "_tmp" in d.name + ] + assert len(temp_folders) == 1 + agent_src_path = temp_folders[0] + + assert (agent_src_path / "agent.py").exists() + assert not ( + agent_src_path / "ignored_by_git.txt" + ).exists(), "Should respect .gitignore" + assert not ( + agent_src_path / "ignored_by_ae.txt" + ).exists(), "Should respect .ae_ignore" + + +def test_to_gke_respects_ignore_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test that to_gke respects ignore files.""" + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "agent.py").write_text("# agent") + (agent_dir / "__init__.py").write_text("") + (agent_dir / "ignored.txt").write_text("ignored") + (agent_dir / ".gitignore").write_text("ignored.txt\n") + + temp_deploy_dir = tmp_path / "temp_deploy" + + # Mock subprocess.run to avoid actual gcloud call + mock_run = mock.Mock() + mock_run.return_value.stdout = "deployment created" + monkeypatch.setattr(subprocess, "run", mock_run) + # Mock shutil.rmtree to keep the temp folder for verification + monkeypatch.setattr( + shutil, + "rmtree", + lambda path, **kwargs: None + if "temp_deploy" in str(path) + else shutil.rmtree(path, **kwargs), + ) + + cli_deploy.to_gke( + agent_folder=str(agent_dir), + project="proj", + region="us-central1", + cluster_name="cluster", + service_name="svc", + app_name="app", + temp_folder=str(temp_deploy_dir), + port=8080, + trace_to_cloud=False, + with_ui=False, + log_level="info", + adk_version="1.0.0", + ) + + agent_src_path = temp_deploy_dir / "agents" / "app" + + assert (agent_src_path / "agent.py").exists() + assert not ( + agent_src_path / "ignored.txt" + ).exists(), "Should respect .gitignore" From 04d56e26febe56f9bf32022c6961dd0a9a7b00a4 Mon Sep 17 00:00:00 2001 From: "kotaro.saito" Date: Sat, 17 Jan 2026 16:28:43 +0900 Subject: [PATCH 2/4] chore(cli): fix syntax errors and reformat after merge --- src/google/adk/cli/cli_deploy.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index 6ef76bc3e3..d3d4242794 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -321,7 +321,7 @@ def _ensure_agent_engine_dependency(requirements_txt_path: str) -> None: ' (Optional[Dict[str, Any]]):\n Optional. The run' ' config to use for the query. If you want to\n pass' ' in a `run_config` pydantic object, you can pass in a dict\n ' - ' representing it as' + ' representing it as' ' `run_config.model_dump(mode="json")`.\n **kwargs' ' (dict[str, Any]):\n Optional. Additional keyword' ' arguments to pass to the\n runner.\n\n ' @@ -385,10 +385,10 @@ def _ensure_agent_engine_dependency(requirements_txt_path: str) -> None: 'name': 'streaming_agent_run_with_events', 'description': ( 'Streams responses asynchronously from the ADK application.\n\n ' - ' In general, you should use `async_stream_query` instead, as it' has a' - ' more structured API and works with the respective ADK services that' - ' you have defined for the AdkApp. This method is primarily meant for' - ' invocation from' + ' In general, you should use `async_stream_query` instead, as it' + ' has a more structured API and works with the respective' + ' ADK services that you have defined for the AdkApp. This' + ' method is primarily meant for invocation from' ' AgentSpace.\n\n Args:\n request_json (str):\n ' ' Required. The request to stream responses for.\n ' ' ' @@ -615,7 +615,7 @@ def to_cloud_run( click.echo('Creating Dockerfile...') host_option = '--host=0.0.0.0' if adk_version > '0.5.0' else '' allow_origins_option = ( - f'--allow_origins={','.join(allow_origins)}' if allow_origins else '' + f"--allow_origins={','.join(allow_origins)}" if allow_origins else '' ) a2a_option = '--a2a' if a2a else '' dockerfile_content = _DOCKERFILE_TEMPLATE.format( @@ -1101,7 +1101,7 @@ def to_gke( click.secho('āœ… Environment prepared.', fg='green') allow_origins_option = ( - f'--allow_origins={','.join(allow_origins)}' if allow_origins else '' + f"--allow_origins={','.join(allow_origins)}" if allow_origins else '' ) # create Dockerfile @@ -1252,4 +1252,4 @@ def to_gke( shutil.rmtree(temp_folder) click.secho( '\nšŸŽ‰ Deployment to GKE finished successfully!', fg='cyan', bold=True - ) \ No newline at end of file + ) From b871d21a0c6b0042af6e9985feea6f500b8d7285 Mon Sep 17 00:00:00 2001 From: "kotaro.saito" Date: Sat, 17 Jan 2026 16:30:13 +0900 Subject: [PATCH 3/4] test(cli): update unit tests for ignore files after upstream merge --- tests/unittests/cli/utils/test_cli_deploy_ignore.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unittests/cli/utils/test_cli_deploy_ignore.py b/tests/unittests/cli/utils/test_cli_deploy_ignore.py index 365ac2da88..8b7831fbca 100644 --- a/tests/unittests/cli/utils/test_cli_deploy_ignore.py +++ b/tests/unittests/cli/utils/test_cli_deploy_ignore.py @@ -73,6 +73,7 @@ def test_to_cloud_run_respects_ignore_files( temp_folder=str(temp_deploy_dir), port=8080, trace_to_cloud=False, + otel_to_cloud=False, with_ui=False, log_level="info", verbosity="info", @@ -184,6 +185,7 @@ def test_to_gke_respects_ignore_files( temp_folder=str(temp_deploy_dir), port=8080, trace_to_cloud=False, + otel_to_cloud=False, with_ui=False, log_level="info", adk_version="1.0.0", From fc259c35264915a2d78b6df270e8b917d5a28746 Mon Sep 17 00:00:00 2001 From: "kotaro.saito" Date: Sat, 17 Jan 2026 16:32:01 +0900 Subject: [PATCH 4/4] chore(cli): revert accidental docstring changes --- src/google/adk/cli/cli_deploy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index d3d4242794..c4220517da 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -196,7 +196,7 @@ def _ensure_agent_engine_dependency(requirements_txt_path: str) -> None: ' Optional. Additional keyword arguments to pass to the\n ' ' session service.\n\n Returns:\n ' ' Session: The session instance (if any). It returns None if the\n ' - ' session is not found.\n\n Raises:\n ' + ' session is not found.\n\n Raises:\n ' ' RuntimeError: If the session is not found.\n ' ), 'parameters': { @@ -386,9 +386,9 @@ def _ensure_agent_engine_dependency(requirements_txt_path: str) -> None: 'description': ( 'Streams responses asynchronously from the ADK application.\n\n ' ' In general, you should use `async_stream_query` instead, as it' - ' has a more structured API and works with the respective' - ' ADK services that you have defined for the AdkApp. This' - ' method is primarily meant for invocation from' + ' has a\n more structured API and works with the respective' + ' ADK services that\n you have defined for the AdkApp. This' + ' method is primarily meant for\n invocation from' ' AgentSpace.\n\n Args:\n request_json (str):\n ' ' Required. The request to stream responses for.\n ' ' '