diff --git a/craft_application/commands/init.py b/craft_application/commands/init.py
index fa97a6bbd..81ba17d2f 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)
+
+ 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..5dc0573bc
--- /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
+
+ # 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
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)