Skip to content
Open
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
16 changes: 16 additions & 0 deletions samcli/commands/init/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ def wrapped(*args, **kwargs):
""" """,
required=False,
)
@click.option(
"--checkout",
default=None,
help="Branch, tag or commit to checkout after git clone.",
required=False,
cls=ClickMutex,
required_param_lists=[["location"]],
required_params_hint="The --checkout option requires --location to specify a git repository.",
incompatible_params=["package_type", "runtime", "base_image", "dependency_manager", "app_template"],
incompatible_params_hint=INCOMPATIBLE_PARAMS_HINT,
)
@click.option(
"--tracing/--no-tracing",
default=None,
Expand Down Expand Up @@ -259,6 +270,7 @@ def cli(
save_params,
config_file,
config_env,
checkout,
):
"""
`sam init` command entry point
Expand All @@ -281,6 +293,7 @@ def cli(
tracing,
application_insights,
structured_logging,
checkout,
) # pragma: no cover


Expand All @@ -303,6 +316,7 @@ def do_cli(
tracing,
application_insights,
structured_logging,
checkout,
):
"""
Implementation of the ``cli`` method
Expand Down Expand Up @@ -356,6 +370,7 @@ def do_cli(
tracing,
application_insights,
structured_logging,
checkout,
)
else:
if not (pt_explicit or runtime or dependency_manager or base_image or architecture):
Expand All @@ -377,6 +392,7 @@ def do_cli(
tracing,
application_insights,
structured_logging,
checkout,
)


Expand Down
2 changes: 1 addition & 1 deletion samcli/commands/init/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
]

# Can be used instead of the options in the first list
NON_INTERACTIVE_OPTIONS: List[str] = ["no_interactive", "no_input", "extra_context"]
NON_INTERACTIVE_OPTIONS: List[str] = ["no_interactive", "no_input", "extra_context", "checkout"]

CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"] + SAVE_PARAMS_OPTIONS

Expand Down
2 changes: 2 additions & 0 deletions samcli/commands/init/init_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def do_generate(
tracing,
application_insights,
structured_logging,
checkout=None,
):
try:
generate_project(
Expand All @@ -33,6 +34,7 @@ def do_generate(
tracing,
application_insights,
structured_logging,
checkout,
)
except InitErrorException as e:
raise UserException(str(e), wrapped_from=e.__class__.__name__) from e
9 changes: 9 additions & 0 deletions samcli/commands/init/interactive_init_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def do_interactive(
tracing,
application_insights,
structured_logging,
checkout=None,
):
"""
Implementation of the ``cli`` method when --interactive is provided.
Expand Down Expand Up @@ -85,6 +86,7 @@ def do_interactive(
tracing,
application_insights,
structured_logging,
checkout,
)


Expand All @@ -104,6 +106,7 @@ def generate_application(
tracing,
application_insights,
structured_logging,
checkout=None,
): # pylint: disable=too-many-arguments
"""
The method holds the decision logic for generating an application
Expand Down Expand Up @@ -156,6 +159,7 @@ def generate_application(
tracing,
application_insights,
structured_logging,
checkout,
)

else:
Expand All @@ -170,6 +174,7 @@ def generate_application(
tracing,
application_insights,
structured_logging,
checkout,
)


Expand All @@ -185,6 +190,7 @@ def _generate_from_location(
tracing,
application_insights,
structured_logging,
checkout=None,
):
location = click.prompt("\nTemplate location (git, mercurial, http(s), zip, path)", type=str)
summary_msg = """
Expand All @@ -207,6 +213,7 @@ def _generate_from_location(
tracing,
application_insights,
structured_logging,
checkout,
)


Expand All @@ -225,6 +232,7 @@ def _generate_from_use_case(
tracing: Optional[bool],
application_insights: Optional[bool],
structured_logging: Optional[bool],
checkout: Optional[str] = None,
) -> None:
templates = InitTemplates()
runtime_or_base_image = runtime if runtime else base_image
Expand Down Expand Up @@ -315,6 +323,7 @@ def _generate_from_use_case(
tracing,
application_insights,
structured_logging,
checkout,
)
# executing event_bridge logic if call is for Schema dynamic template
if is_dynamic_schemas_template:
Expand Down
6 changes: 6 additions & 0 deletions samcli/lib/init/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def generate_project(
tracing=False,
application_insights=False,
structured_logging=False,
checkout=None,
):
"""Generates project using cookiecutter and options given

Expand Down Expand Up @@ -74,6 +75,8 @@ def generate_project(
Enable or disable AppInsights Monitoring
structured_logging: Optional[bool]
boolean value to determine if Json structured logging should be enabled or not
checkout: Optional[str]
Branch, tag or commit to checkout after git clone

Raises
------
Expand All @@ -98,6 +101,9 @@ def generate_project(
if extra_context:
params["extra_context"] = extra_context

if checkout:
params["checkout"] = checkout

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] The --checkout flag is silently ignored when the target repository does not contain a cookiecutter.json. Cookiecutter clones the repo and applies the checkout, but then raises RepositoryNotFound, which is caught below and dispatched to generate_non_cookiecutter_project():

except RepositoryNotFound:
   ...
   project_output_dir = str(Path(output_dir, name)) if name else output_dir
   generate_non_cookiecutter_project(location=params["template"], output_dir=project_output_dir)

generate_non_cookiecutter_project() in samcli/lib/init/arbitrary_project.py does not accept a checkout parameter and calls repository.clone(repo_url=location, no_input=no_input) without one. As a result, a user running sam init --location <git-repo-without-cookiecutter.json> --checkout my-branch will get code from the repository's default branch instead of my-branch, with no error or warning.

Propagate checkout to the fallback path so the ref is honored consistently (cookiecutter's repository.clone accepts a checkout kwarg):

except RepositoryNotFound:
   ...
   project_output_dir = str(Path(output_dir, name)) if name else output_dir
   generate_non_cookiecutter_project(
       location=params["template"],
       output_dir=project_output_dir,
       checkout=checkout,
   )

…and update generate_non_cookiecutter_project to accept checkout and forward it to repository.clone. It would also be worth adding a unit test that mocks cookiecutter to raise RepositoryNotFound and asserts the checkout is propagated to the fallback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] The --checkout value is added to the cookiecutter params here, but it is silently dropped on the fallback path used when the template repository does not contain a cookiecutter.json.

Flow:

  1. cookiecutter(**params) is invoked with checkout. Cookiecutter clones the repo, checks out the requested ref, then looks for cookiecutter.json. If it is missing, cookiecutter raises RepositoryNotFound.
  2. The except RepositoryNotFound handler a few lines below calls:
project_output_dir = str(Path(output_dir, name)) if name else output_dir
generate_non_cookiecutter_project(location=params["template"], output_dir=project_output_dir)
  1. generate_non_cookiecutter_project (in samcli/lib/init/arbitrary_project.py) re-clones the repo without any ref:
elif repository.is_repo_url(location):
   LOG.debug("%s location is a source control repository", location)
   download_fn = functools.partial(repository.clone, repo_url=location, no_input=no_input)

Result: a user running sam init --location <git-url> --checkout <ref> against a repository that isn't a cookiecutter template gets the default branch back, with no error or warning — the --checkout flag is effectively ignored on this path.

This was raised in the earlier automated review and has not been explicitly dismissed by the author. Please propagate checkout through to generate_non_cookiecutter_project and down to the underlying repository.clone call (which accepts a checkout argument) so the requested ref is honored for non-cookiecutter repositories as well. New unit/integration coverage for a non-cookiecutter repo with --checkout would also prevent regressions here.


LOG.debug("Parameters dict created with input given")
LOG.debug("%s", params)

Expand Down
7 changes: 6 additions & 1 deletion schema/samcli.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"properties": {
"parameters": {
"title": "Parameters for the init command",
"description": "Available parameters for the init command:\n* no_interactive:\nDisable interactive prompting for init parameters. (fail if any required values are missing)\n* architecture:\nArchitectures for Lambda functions.\n\nArchitectures: ['arm64', 'x86_64']\n* location:\nTemplate location (git, mercurial, http(s), zip, path).\n* runtime:\nLambda runtime for application.\n\nRuntimes: dotnet10, dotnet8, dotnet6, go1.x, java25, java21, java17, java11, java8.al2, nodejs24.x, nodejs22.x, nodejs20.x, nodejs18.x, nodejs16.x, provided, provided.al2, provided.al2023, python3.9, python3.8, python3.14, python3.13, python3.12, python3.11, python3.10, ruby4.0, ruby3.4, ruby3.3, ruby3.2\n* package_type:\nLambda deployment package type.\n\nPackage Types: Zip, Image\n* base_image:\nLambda base image for deploying IMAGE based package type.\n\nBase images: amazon/dotnet10-base, amazon/dotnet6-base, amazon/dotnet8-base, amazon/go-provided.al2-base, amazon/go-provided.al2023-base, amazon/go1.x-base, amazon/java11-base, amazon/java17-base, amazon/java21-base, amazon/java25-base, amazon/java8.al2-base, amazon/nodejs16.x-base, amazon/nodejs18.x-base, amazon/nodejs20.x-base, amazon/nodejs22.x-base, amazon/nodejs24.x-base, amazon/python3.10-base, amazon/python3.11-base, amazon/python3.12-base, amazon/python3.13-base, amazon/python3.14-base, amazon/python3.8-base, amazon/python3.9-base, amazon/ruby3.2-base, amazon/ruby3.3-base, amazon/ruby3.4-base, amazon/ruby4.0-base\n* dependency_manager:\nDependency manager for Lambda runtime.\n\nDependency managers: bundler, cli-package, gradle, maven, mod, npm, pip\n* output_dir:\nDirectory to initialize AWS SAM application.\n* name:\nName of AWS SAM Application.\n* app_template:\nIdentifier of the managed application template to be used. Alternatively, run '$ sam init' without options for an interactive workflow.\n* no_input:\nDisable Cookiecutter prompting and accept default values defined in the cookiecutter config.\n* extra_context:\nOverride custom parameters in the template's cookiecutter.json configuration e.g. {\"customParam1\": \"customValue1\", \"customParam2\":\"customValue2\"}\n* tracing:\nEnable AWS X-Ray tracing for application.\n* application_insights:\nEnable CloudWatch Application Insights monitoring for application.\n* structured_logging:\nEnable Structured Logging for application.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* save_params:\nSave the parameters provided via the command line to the configuration file.",
"description": "Available parameters for the init command:\n* no_interactive:\nDisable interactive prompting for init parameters. (fail if any required values are missing)\n* architecture:\nArchitectures for Lambda functions.\n\nArchitectures: ['arm64', 'x86_64']\n* location:\nTemplate location (git, mercurial, http(s), zip, path).\n* runtime:\nLambda runtime for application.\n\nRuntimes: dotnet10, dotnet8, dotnet6, go1.x, java25, java21, java17, java11, java8.al2, nodejs24.x, nodejs22.x, nodejs20.x, nodejs18.x, nodejs16.x, provided, provided.al2, provided.al2023, python3.9, python3.8, python3.14, python3.13, python3.12, python3.11, python3.10, ruby4.0, ruby3.4, ruby3.3, ruby3.2\n* package_type:\nLambda deployment package type.\n\nPackage Types: Zip, Image\n* base_image:\nLambda base image for deploying IMAGE based package type.\n\nBase images: amazon/dotnet10-base, amazon/dotnet6-base, amazon/dotnet8-base, amazon/go-provided.al2-base, amazon/go-provided.al2023-base, amazon/go1.x-base, amazon/java11-base, amazon/java17-base, amazon/java21-base, amazon/java25-base, amazon/java8.al2-base, amazon/nodejs16.x-base, amazon/nodejs18.x-base, amazon/nodejs20.x-base, amazon/nodejs22.x-base, amazon/nodejs24.x-base, amazon/python3.10-base, amazon/python3.11-base, amazon/python3.12-base, amazon/python3.13-base, amazon/python3.14-base, amazon/python3.8-base, amazon/python3.9-base, amazon/ruby3.2-base, amazon/ruby3.3-base, amazon/ruby3.4-base, amazon/ruby4.0-base\n* dependency_manager:\nDependency manager for Lambda runtime.\n\nDependency managers: bundler, cli-package, gradle, maven, mod, npm, pip\n* output_dir:\nDirectory to initialize AWS SAM application.\n* name:\nName of AWS SAM Application.\n* app_template:\nIdentifier of the managed application template to be used. Alternatively, run '$ sam init' without options for an interactive workflow.\n* no_input:\nDisable Cookiecutter prompting and accept default values defined in the cookiecutter config.\n* extra_context:\nOverride custom parameters in the template's cookiecutter.json configuration e.g. {\"customParam1\": \"customValue1\", \"customParam2\":\"customValue2\"}\n* checkout:\nBranch, tag or commit to checkout after git clone.\n* tracing:\nEnable AWS X-Ray tracing for application.\n* application_insights:\nEnable CloudWatch Application Insights monitoring for application.\n* structured_logging:\nEnable Structured Logging for application.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* save_params:\nSave the parameters provided via the command line to the configuration file.",
"type": "object",
"properties": {
"no_interactive": {
Expand Down Expand Up @@ -194,6 +194,11 @@
"type": "string",
"description": "Override custom parameters in the template's cookiecutter.json configuration e.g. {\"customParam1\": \"customValue1\", \"customParam2\":\"customValue2\"}"
},
"checkout": {
"title": "checkout",
"type": "string",
"description": "Branch, tag or commit to checkout after git clone."
},
"tracing": {
"title": "tracing",
"type": "boolean",
Expand Down
92 changes: 92 additions & 0 deletions tests/integration/init/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,98 @@ def test_init_command_wrong_packagetype(self):

self.assertIn(errmsg.strip(), "\n".join(stderr.strip().splitlines()))

def test_init_command_checkout_without_location(self):
stderr = None
with tempfile.TemporaryDirectory() as temp:
process = Popen(
[
get_sam_command(),
"init",
"--checkout",
"some-branch",
"--no-interactive",
"--name",
"sam-app",
"-o",
temp,
],
stdout=PIPE,
stderr=PIPE,
)
try:
stdout_data, stderr_data = process.communicate(timeout=TIMEOUT)
stderr = stderr_data.decode("utf-8")
except TimeoutExpired:
process.kill()
raise

self.assertEqual(process.returncode, 2)

self.assertIn("Missing required parameters, with --checkout set", stderr)


@pytest.mark.xdist_group(name="sam_init")
class TestInitWithCheckoutOption(TestCase):
@pytest.mark.tier2
def test_init_command_checkout_with_location(self):
with tempfile.TemporaryDirectory() as temp:
process = Popen(
[
get_sam_command(),
"init",
"--location",
"https://github.com/aws/aws-sam-cli-app-templates.git",
"--checkout",
"develop",
"--name",
"test-checkout-project",
"-o",
temp,
],
stdout=PIPE,
stderr=PIPE,
)
try:
stdout_data, stderr_data = process.communicate(timeout=TIMEOUT)
except TimeoutExpired:
process.kill()
raise

self.assertEqual(process.returncode, 0)
self.assertTrue(Path(temp, "test-checkout-project").exists())

@pytest.mark.tier2
def test_init_command_checkout_with_incompatible_params(self):
stderr = None
with tempfile.TemporaryDirectory() as temp:
process = Popen(
[
get_sam_command(),
"init",
"--checkout",
"some-branch",
"--runtime",
"nodejs18.x",
"-o",
temp,
],
stdout=PIPE,
stderr=PIPE,
)
try:
stdout_data, stderr_data = process.communicate(timeout=TIMEOUT)
stderr = stderr_data.decode("utf-8")
except TimeoutExpired:
process.kill()
raise

self.assertEqual(process.returncode, 2)

self.assertIn(
INCOMPATIBLE_PARAM_MESSAGE.strip().format("checkout", "runtime"),
"\n".join(stderr.strip().splitlines()),
)


@pytest.mark.xdist_group(name="sam_init")
class TestInitWithArbitraryProject(TestCase):
Expand Down
Loading