Skip to content
Draft
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
66 changes: 64 additions & 2 deletions craft_application/commands/init.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"],

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This copies how uv handles it, which is certainly future-proof, but I'm considering doing --no-vcs as an opt-out flag and removing --vcs as a flag since I doubt we'll support anything other than git any time soon.

default="git",
help="Initialize a version control system.",
)
parser.add_argument(
"--base",
type=str,
Expand Down Expand Up @@ -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]:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The intention here was to create an easy way to customize what entries appear in a generated gitignore. For example, if Snapcraft overrides this to return ["*.snap", "*.comp"], it would add this to a gitignore file:

# Added by Snapcraft
*.snap
*.comp

"""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.
Expand Down Expand Up @@ -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)

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
Comment thread
bepri marked this conversation as resolved.
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)
Expand Down
4 changes: 2 additions & 2 deletions craft_application/git/_git_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions testcraft/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])


Expand Down
2 changes: 2 additions & 0 deletions testcraft/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""Testcraft CLI commands."""

from .init import InitCommand
from .lint import LintCommand
from .state import StateCommand

__all__ = [
"InitCommand",
"LintCommand",
"StateCommand",
]
28 changes: 28 additions & 0 deletions testcraft/commands/init.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
"""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"]
20 changes: 20 additions & 0 deletions tests/spread/testcraft/init-vcs/task.yaml
Original file line number Diff line number Diff line change
@@ -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

# 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 $?)

restore: |
rm -rf init-test
71 changes: 70 additions & 1 deletion tests/unit/commands/test_init.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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"):
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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"

Expand All @@ -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"):
Expand All @@ -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"

Expand All @@ -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
6 changes: 3 additions & 3 deletions tests/unit/git/test_git.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"

Expand Down
8 changes: 4 additions & 4 deletions tests/unit/services/test_remotebuild.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)


Expand Down
Loading