From 8b9a5375078b5eed8c69c4742d38700a9de9b658 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Mon, 3 Aug 2026 16:56:12 -0400 Subject: [PATCH 1/3] feat(init): git init features --- craft_application/commands/init.py | 66 ++++++++++++++++++++- craft_application/git/_git_repo.py | 4 +- testcraft/cli.py | 4 +- testcraft/commands/__init__.py | 2 + testcraft/commands/init.py | 28 +++++++++ tests/spread/testcraft/init-vcs/task.yaml | 20 +++++++ tests/unit/commands/test_init.py | 71 ++++++++++++++++++++++- tests/unit/git/test_git.py | 6 +- tests/unit/services/test_remotebuild.py | 8 +-- 9 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 testcraft/commands/init.py create mode 100644 tests/spread/testcraft/init-vcs/task.yaml diff --git a/craft_application/commands/init.py b/craft_application/commands/init.py index fa97a6bbd..a6377d833 100644 --- a/craft_application/commands/init.py +++ b/craft_application/commands/init.py @@ -1,4 +1,4 @@ -# Copyright 2024 Canonical Ltd. +# Copyright 2026 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License version 3, as @@ -90,7 +90,13 @@ def fill_parser(self, parser: argparse.ArgumentParser) -> None: f"choices are {humanize_list(self.profiles, 'and')})" ), ) - + parser.add_argument( + "--vcs", + type=str, + choices=["git", "none"], + default="git", + help="Initialize a version control system.", + ) parser.add_argument( "--base", type=str, @@ -122,6 +128,11 @@ def profiles(self) -> list[str]: ] return sorted([template.name for template in template_dirs]) + @property + def vcs_ignore_globs(self) -> list[str]: + """A list of globs that should be ignored when creating a .gitignore file.""" + return [] + def run(self, parsed_args: argparse.Namespace) -> None: """Run the command.""" # If the user provided a "name" and it's not valid, the command fails. @@ -149,8 +160,59 @@ def run(self, parsed_args: argparse.Namespace) -> None: project_name=project_name, template_dir=template_dir, ) + + self.initialize_vcs(parsed_args.vcs, project_dir) + craft_cli.emit.message("Successfully initialised project.") + def initialize_vcs( + self, + vcs: str, + project_dir: pathlib.Path, + ) -> None: + """Initialize VCS features for a project. + + Currently only supports Git. + + If the Git repository already exists, it will be reused. + + If a `.gitignore` file is already present, Craft-specific content will be + appended. + """ + if vcs == "none": + return + + craft_cli.emit.debug(f"Setting up VCS in {str(project_dir)!r}.") + + from craft_application.git import GitRepo # noqa: PLC0415 + + GitRepo(project_dir).init_repo() + + self._create_git_ignore(project_dir) + + def _create_git_ignore(self, project_dir: pathlib.Path) -> None: + ignore_lines = [ + f"# Added by {self._app.name.capitalize()}", + *self.vcs_ignore_globs, + ] + + # Nothing to ignore + if len(ignore_lines) == 1: + return + + ignore_content = "\n".join(ignore_lines) + + ignore = project_dir / ".gitignore" + ignore_existed = ignore.exists() + + with ignore.open("at") as ignore_f: + # Assuming the user leaves a trailing newline on their files, this will give a cleanly + # separated "Added by Craft" section + if ignore_existed: + ignore_f.write("\n") + + ignore_f.write(ignore_content) + def _get_template_dir(self, parsed_args: argparse.Namespace) -> pathlib.Path: """Get the template directory for the selected profile and base.""" base = getattr(parsed_args, "base", None) diff --git a/craft_application/git/_git_repo.py b/craft_application/git/_git_repo.py index 6b8da5460..b3040ff8c 100644 --- a/craft_application/git/_git_repo.py +++ b/craft_application/git/_git_repo.py @@ -164,7 +164,7 @@ def __init__(self, path: Path) -> None: ) if not is_repo(path): - self._init_repo() + self.init_repo() self._repo = pygit2.Repository(path.as_posix()) @@ -283,7 +283,7 @@ def is_clean(self) -> bool: f"Could not check if the git repository in {str(self.path)!r} is clean." ) from error - def _init_repo(self) -> None: + def init_repo(self) -> None: """Initialize a git repo. :raises GitError: if the repo cannot be initialized diff --git a/testcraft/cli.py b/testcraft/cli.py index e95f0fda0..ce767217f 100644 --- a/testcraft/cli.py +++ b/testcraft/cli.py @@ -25,14 +25,14 @@ from craft_providers.actions.snap_installer import Snap from testcraft.application import TESTCRAFT -from testcraft.commands import LintCommand, StateCommand +from testcraft.commands import InitCommand, LintCommand, StateCommand from testcraft.services import register_services def register_commands(app: craft_application.Application) -> None: """Register extra commands for testcraft.""" app.add_command_group("Lifecycle", [TestCommand], ordered=True) - app.add_command_group("Other", [LintCommand]) + app.add_command_group("Other", [InitCommand, LintCommand]) app.add_command_group("State", [StateCommand]) diff --git a/testcraft/commands/__init__.py b/testcraft/commands/__init__.py index 19578dbf6..39c254828 100644 --- a/testcraft/commands/__init__.py +++ b/testcraft/commands/__init__.py @@ -15,10 +15,12 @@ # with this program. If not, see . """Testcraft CLI commands.""" +from .init import InitCommand from .lint import LintCommand from .state import StateCommand __all__ = [ + "InitCommand", "LintCommand", "StateCommand", ] diff --git a/testcraft/commands/init.py b/testcraft/commands/init.py new file mode 100644 index 000000000..8e8f646b2 --- /dev/null +++ b/testcraft/commands/init.py @@ -0,0 +1,28 @@ +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . +"""Command to initialize a project.""" + +from __future__ import annotations + +from craft_application.commands import InitCommand as BaseInitCommand +from typing_extensions import override + + +class InitCommand(BaseInitCommand): + """Init command override for Testcraft.""" + + @override + @property + def vcs_ignore_globs(self) -> list[str]: + return [*super().vcs_ignore_globs, "/*.test"] diff --git a/tests/spread/testcraft/init-vcs/task.yaml b/tests/spread/testcraft/init-vcs/task.yaml new file mode 100644 index 000000000..8885bf691 --- /dev/null +++ b/tests/spread/testcraft/init-vcs/task.yaml @@ -0,0 +1,20 @@ +summary: test testcraft init with VCS features + +environment: + VCS_STYLE/blank: "" + VCS_STYLE/git: "git" + VCS_STYLE/none: "none" + +execute: | + mkdir init-test + cd init-test + + [ -n "$VCS_STYLE" ] && vcs_flag="--vcs $VCS_STYLE" || vcs_flag="" + testcraft init $vcs_flag + + # Succeeds if $VCS_STYLE is set to "git", fails if it is "none". + test -d .git; test $? -ne $([ "$VCS_STYLE" = "none" ]; echo $?) + test -f .gitignore; test $? -ne $([ "$VCS_STYLE" = "none" ]; echo $?) + +restore: | + rm -rf init-test diff --git a/tests/unit/commands/test_init.py b/tests/unit/commands/test_init.py index 2b81a17c9..d4b6f2dcd 100644 --- a/tests/unit/commands/test_init.py +++ b/tests/unit/commands/test_init.py @@ -1,6 +1,6 @@ # This file is part of craft-application. # -# Copyright 2024 Canonical Ltd. +# Copyright 2026 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License version 3, as @@ -60,6 +60,7 @@ def test_init_in_cwd(init_command, name, new_dir, mock_services, emitter): project_dir=None, name=name, profile="test-profile", + vcs="none", ) mock_services.init.validate_project_name.return_value = expected_name @@ -82,6 +83,7 @@ def test_init_run_project_dir(init_command, name, mock_services, emitter): project_dir=project_dir, name=name, profile="test-profile", + vcs="none", ) mock_services.init.validate_project_name.return_value = expected_name @@ -116,6 +118,7 @@ def test_existing_files(init_command, tmp_path, mock_services): project_dir=tmp_path, name="test-project-name", profile="test-profile", + vcs="none", ) with pytest.raises(InitError, match="test-error"): @@ -128,6 +131,7 @@ def test_invalid_name(init_command, mock_services): mock_services.init.validate_project_name.side_effect = InitError("test-error") parsed_args = argparse.Namespace( name="invalid--name", + vcs="none", ) with pytest.raises(InitError, match="test-error"): init_command.run(parsed_args) @@ -146,6 +150,7 @@ def _validate_project_name(_name: str, *, use_default: bool = False): project_dir=project_dir, name=None, profile="simple", + vcs="none", ) init_command.run(parsed_args) @@ -165,6 +170,7 @@ def test_invalid_base_variant(init_command, tmp_path, mock_services): name="test-project-name", profile="simple", base="ubuntu@23.04", + vcs="none", ) with pytest.raises(InitError, match="Base variant 'ubuntu@23.04'") as exc_info: @@ -183,6 +189,7 @@ def test_base_not_available_for_profile(init_command, tmp_path, mock_services): name="test-project-name", profile="simple", base="ubuntu@22.04", + vcs="none", ) with pytest.raises( @@ -207,6 +214,7 @@ def test_valid_base_name( name="test-project-name", profile="simple", base=base, + vcs="none", ) mock_services.init.validate_project_name.return_value = "test-project-name" @@ -228,6 +236,7 @@ def test_invalid_base_name(init_command, tmp_path, mock_services, base): name="test-project-name", profile="simple", base=base, + vcs="none", ) with pytest.raises(InitError, match="invalid base name"): @@ -244,6 +253,7 @@ def test_valid_base_variant(init_command, fake_template_dirs, mock_services, emi name="test-project-name", profile="simple", base="ubuntu@22.04", + vcs="none", ) mock_services.init.validate_project_name.return_value = "test-project-name" @@ -255,3 +265,62 @@ def test_valid_base_variant(init_command, fake_template_dirs, mock_services, emi template_dir=init_command.parent_template_dir / "simple__ubuntu@22.04", ) emitter.assert_message("Successfully initialised project.") + + +def test_initialize_vcs( + init_command, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + emitter, + mocker, +) -> None: + monkeypatch.chdir(tmp_path) + mocker.patch.object(InitCommand, "vcs_ignore_globs", ["*.test"]) + + init_command.initialize_vcs("git", tmp_path) + + assert pathlib.Path(".git").is_dir() + emitter.assert_debug(f"Setting up VCS in {str(tmp_path)!r}.") + assert (tmp_path / ".gitignore").read_text() == "# Added by Testcraft\n*.test" + + +def test_create_vcs_ignore_empty( + init_command, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + mocker, +) -> None: + monkeypatch.chdir(tmp_path) + mocker.patch.object(InitCommand, "vcs_ignore_globs", []) + + init_command._create_git_ignore(tmp_path) + + assert not (tmp_path / ".gitignore").exists() + + +@pytest.mark.parametrize( + ("existing_content", "expected_content"), + [ + (None, "# Added by Testcraft\n*.test\n*.testcomp"), + ( + "existing_content\n", + "existing_content\n\n# Added by Testcraft\n*.test\n*.testcomp", + ), + ], +) +def test_create_vcs_ignore( + init_command, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + existing_content: str | None, + expected_content: str, + mocker, +) -> None: + monkeypatch.chdir(tmp_path) + mocker.patch.object(InitCommand, "vcs_ignore_globs", ["*.test", "*.testcomp"]) + if existing_content is not None: + (tmp_path / ".gitignore").write_text(existing_content) + + init_command._create_git_ignore(tmp_path) + + assert (tmp_path / ".gitignore").read_text() == expected_content diff --git a/tests/unit/git/test_git.py b/tests/unit/git/test_git.py index 4a4869323..6a94bb548 100644 --- a/tests/unit/git/test_git.py +++ b/tests/unit/git/test_git.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 Canonical Ltd. +# Copyright 2026 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 as @@ -699,7 +699,7 @@ def test_clone_repository_appends_correct_parameters_to_clone_command( """Test if GitRepo uses correct arguments in subprocess calls.""" # it is not a repo before clone is triggered, but will be after fake pygit2.clone_repository is called mocker.patch("craft_application.git._git_repo.is_repo", side_effect=[False, True]) - mocked_init = mocker.patch.object(GitRepo, "_init_repo") + mocked_init = mocker.patch.object(GitRepo, "init_repo") fake_repo_url = "fake-repository-url.localhost" from craft_application.git._git_repo import ( # noqa: PLC0415 logger as git_repo_logger, @@ -728,7 +728,7 @@ def test_clone_repository_returns_git_repo_on_succcess_clone(mocker, empty_repos """Test if GitRepo is return on clone success.""" # it is not a repo before clone is triggered, but will be after fake pygit2.clone_repository is called mocker.patch("craft_application.git._git_repo.is_repo", side_effect=[False, True]) - mocked_init = mocker.patch.object(GitRepo, "_init_repo") + mocked_init = mocker.patch.object(GitRepo, "init_repo") fake_repo_url = "fake-repository-url.localhost" fake_branch = "some-fake-branch" diff --git a/tests/unit/services/test_remotebuild.py b/tests/unit/services/test_remotebuild.py index 52ecb66b7..2838eecdd 100644 --- a/tests/unit/services/test_remotebuild.py +++ b/tests/unit/services/test_remotebuild.py @@ -1,6 +1,6 @@ # This file is part of craft-application. # -# Copyright 2024 Canonical Ltd. +# Copyright 2026 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License version 3, as @@ -54,9 +54,9 @@ def mock_push_url_raises_git_error(monkeypatch): @pytest.fixture def mock_init_raises_git_error(monkeypatch): git_repo_init = get_mock_callable( - side_effect=git.GitError("Fake _init_repo error during tests") + side_effect=git.GitError("Fake init_repo error during tests") ) - monkeypatch.setattr(git.GitRepo, "_init_repo", git_repo_init) + monkeypatch.setattr(git.GitRepo, "init_repo", git_repo_init) return git_repo_init @@ -235,7 +235,7 @@ def test_ensure_repository_wraps_git_error_during_init( mock_lp_project, ): remote_build_service._lp_project = mock_lp_project - with pytest.raises(RemoteBuildGitError, match="Fake _init_repo error during tests"): + with pytest.raises(RemoteBuildGitError, match="Fake init_repo error during tests"): remote_build_service._ensure_repository(tmp_path) From e25a47962049f6e249f64e88442f937a52c12e8a Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 4 Aug 2026 15:00:28 -0400 Subject: [PATCH 2/3] fix: don't double-git-init --- craft_application/commands/init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/craft_application/commands/init.py b/craft_application/commands/init.py index a6377d833..81ba17d2f 100644 --- a/craft_application/commands/init.py +++ b/craft_application/commands/init.py @@ -186,7 +186,7 @@ def initialize_vcs( from craft_application.git import GitRepo # noqa: PLC0415 - GitRepo(project_dir).init_repo() + _ = GitRepo(project_dir) self._create_git_ignore(project_dir) From 6b3480f0f4c382401a4e2f723146babb40c7b4e4 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 4 Aug 2026 15:01:21 -0400 Subject: [PATCH 3/3] docs: clarify comment in spread test --- tests/spread/testcraft/init-vcs/task.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/spread/testcraft/init-vcs/task.yaml b/tests/spread/testcraft/init-vcs/task.yaml index 8885bf691..5dc0573bc 100644 --- a/tests/spread/testcraft/init-vcs/task.yaml +++ b/tests/spread/testcraft/init-vcs/task.yaml @@ -12,7 +12,7 @@ execute: | [ -n "$VCS_STYLE" ] && vcs_flag="--vcs $VCS_STYLE" || vcs_flag="" testcraft init $vcs_flag - # Succeeds if $VCS_STYLE is set to "git", fails if it is "none". + # Check that they do exist if using VCS, or that they _don't_ exist if VCS is "none" test -d .git; test $? -ne $([ "$VCS_STYLE" = "none" ]; echo $?) test -f .gitignore; test $? -ne $([ "$VCS_STYLE" = "none" ]; echo $?)