Repository: KyleKing/copier-template-tester (language: python)

Issue:
Build a Test Suite
I want to simulate different use cases

- Scenarios:
	- `ctt` CLI
	- verify pass/fail of `ctt` run from pre-commit
	    - *I did check the exit logic from #3, but didn't actually run `ctt` from `pre-commit`*
- Tests must be cross-platform. Limit external tools to a very minimal use of `git`, but may need a way to run `pre-commit` and `ctt` outside the poetry directory
- Standard expectations for complete cleanup, parallelization, and maintainability


Discussion / hints:

Fix patch:
diff --git a/.calcipy.json b/.calcipy.json
index d9a2775..4a1a86a 100644
--- a/.calcipy.json
+++ b/.calcipy.json
@@ -1,5 +1,5 @@
 {
     "test": {
-       "min_cover": 50
+       "min_cover": 95
     }
 }
diff --git a/.calcipy_packaging.lock b/.calcipy_packaging.lock
index 01e3400..f90b8fe 100644
--- a/.calcipy_packaging.lock
+++ b/.calcipy_packaging.lock
@@ -16,20 +16,20 @@
         "version": "1.2.3"
     },
     "astroid": {
-        "datetime": "2023-04-03T10:21:47.845450+00:00",
+        "datetime": "2023-04-16T20:56:31.107697+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-03T10:21:47.845450+00:00",
-        "latest_version": "2.15.2",
+        "latest_datetime": "2023-04-16T20:56:31.107697+00:00",
+        "latest_version": "2.15.3",
         "name": "astroid",
-        "version": "2.15.2"
+        "version": "2.15.3"
     },
     "attrs": {
-        "datetime": "2022-12-21T09:48:49.401035+00:00",
+        "datetime": "2023-04-16T10:48:16.358050+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2022-12-21T09:48:49.401035+00:00",
-        "latest_version": "22.2.0",
+        "latest_datetime": "2023-04-16T10:48:16.358050+00:00",
+        "latest_version": "23.1.0",
         "name": "attrs",
-        "version": "22.2.0"
+        "version": "23.1.0"
     },
     "autopep8": {
         "datetime": "2023-03-03T09:21:38.371125+00:00",
@@ -56,12 +56,12 @@
         "version": "1.7.5"
     },
     "beartype": {
-        "datetime": "2023-04-08T04:58:45.615705+00:00",
+        "datetime": "2023-04-11T07:55:34.693052+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-08T04:58:45.615705+00:00",
-        "latest_version": "0.13.0",
+        "latest_datetime": "2023-04-11T07:55:34.693052+00:00",
+        "latest_version": "0.13.1",
         "name": "beartype",
-        "version": "0.13.0"
+        "version": "0.13.1"
     },
     "bidict": {
         "datetime": "2023-01-01T01:54:13.937546+00:00",
@@ -152,20 +152,20 @@
         "version": "2.42.1"
     },
     "copier": {
-        "datetime": "2023-04-07T16:59:53.559389+00:00",
+        "datetime": "2023-04-19T17:40:33.020968+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-07T16:59:53.559389+00:00",
-        "latest_version": "7.1.0",
+        "latest_datetime": "2023-04-19T17:40:33.020968+00:00",
+        "latest_version": "7.2.0",
         "name": "copier",
-        "version": "7.1.0"
+        "version": "7.2.0"
     },
     "corallium": {
-        "datetime": "2023-04-08T00:23:03.631516+00:00",
+        "datetime": "2023-04-22T05:20:32.410131+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-08T00:23:03.631516+00:00",
-        "latest_version": "0.1.1",
+        "latest_datetime": "2023-04-22T05:20:32.410131+00:00",
+        "latest_version": "0.2.0",
         "name": "corallium",
-        "version": "0.1.1"
+        "version": "0.2.0"
     },
     "coverage": {
         "datetime": "2023-04-06T14:32:14.021193+00:00",
@@ -240,12 +240,12 @@
         "version": "22.0.0"
     },
     "filelock": {
-        "datetime": "2023-04-06T18:07:27.863644+00:00",
+        "datetime": "2023-04-18T16:56:18.417637+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-06T18:07:27.863644+00:00",
-        "latest_version": "3.11.0",
+        "latest_datetime": "2023-04-18T16:56:18.417637+00:00",
+        "latest_version": "3.12.0",
         "name": "filelock",
-        "version": "3.11.0"
+        "version": "3.12.0"
     },
     "funcy": {
         "datetime": "2023-03-28T06:22:42.576888+00:00",
@@ -288,12 +288,12 @@
         "version": "22.1.0"
     },
     "griffe": {
-        "datetime": "2023-04-03T14:08:26.902156+00:00",
+        "datetime": "2023-04-16T12:27:58.565613+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-03T14:08:26.902156+00:00",
-        "latest_version": "0.26.0",
+        "latest_datetime": "2023-04-16T12:27:58.565613+00:00",
+        "latest_version": "0.27.1",
         "name": "griffe",
-        "version": "0.26.0"
+        "version": "0.27.1"
     },
     "httplib2": {
         "datetime": "2023-03-21T22:29:35.683092+00:00",
@@ -552,12 +552,12 @@
         "version": "1.24.2"
     },
     "packaging": {
-        "datetime": "2023-01-08T18:18:53.118335+00:00",
+        "datetime": "2023-04-12T16:11:09.290380+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-01-08T18:18:53.118335+00:00",
-        "latest_version": "23.0",
+        "latest_datetime": "2023-04-12T16:11:09.290380+00:00",
+        "latest_version": "23.1",
         "name": "packaging",
-        "version": "23.0"
+        "version": "23.1"
     },
     "pandas": {
         "datetime": "2023-04-03T14:10:47.709938+00:00",
@@ -584,20 +584,20 @@
         "version": "5.11.1"
     },
     "peewee": {
-        "datetime": "2023-02-27T14:21:32.453472+00:00",
+        "datetime": "2023-04-21T20:17:51.499630+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-02-27T14:21:32.453472+00:00",
-        "latest_version": "3.16.0",
+        "latest_datetime": "2023-04-21T20:17:51.499630+00:00",
+        "latest_version": "3.16.2",
         "name": "peewee",
-        "version": "3.16.0"
+        "version": "3.16.2"
     },
     "pip": {
-        "datetime": "2023-02-17T18:31:51.845190+00:00",
+        "datetime": "2023-04-15T10:52:52.618235+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-02-17T18:31:51.845190+00:00",
-        "latest_version": "23.0.1",
+        "latest_datetime": "2023-04-15T10:52:52.618235+00:00",
+        "latest_version": "23.1",
         "name": "pip",
-        "version": "23.0.1"
+        "version": "23.1"
     },
     "pip-check": {
         "datetime": "2022-11-06T05:14:07.450137+00:00",
@@ -640,12 +640,12 @@
         "version": "3.0.38"
     },
     "psutil": {
-        "datetime": "2022-11-07T18:44:23.237667+00:00",
+        "datetime": "2023-04-17T18:24:26.646150+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2022-11-07T18:44:23.237667+00:00",
-        "latest_version": "5.9.4",
+        "latest_datetime": "2023-04-17T18:24:26.646150+00:00",
+        "latest_version": "5.9.5",
         "name": "psutil",
-        "version": "5.9.4"
+        "version": "5.9.5"
     },
     "pycodestyle": {
         "datetime": "2022-11-23T18:27:26.474295+00:00",
@@ -664,12 +664,12 @@
         "version": "1.10.7"
     },
     "pygments": {
-        "datetime": "2023-01-01T08:52:51.439424+00:00",
+        "datetime": "2023-04-18T18:49:18.695613+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-01-01T08:52:51.439424+00:00",
-        "latest_version": "2.14.0",
+        "latest_datetime": "2023-04-18T18:49:18.695613+00:00",
+        "latest_version": "2.15.1",
         "name": "pygments",
-        "version": "2.14.0"
+        "version": "2.15.1"
     },
     "pylint": {
         "datetime": "2023-04-03T12:24:24.404684+00:00",
@@ -712,12 +712,12 @@
         "version": "0.19.3"
     },
     "pytest": {
-        "datetime": "2023-04-08T21:46:21.589672+00:00",
+        "datetime": "2023-04-14T18:11:24.793075+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-08T21:46:21.589672+00:00",
-        "latest_version": "7.3.0",
+        "latest_datetime": "2023-04-14T18:11:24.793075+00:00",
+        "latest_version": "7.3.1",
         "name": "pytest",
-        "version": "7.3.0"
+        "version": "7.3.1"
     },
     "pytest-cov": {
         "datetime": "2022-09-28T18:39:21.138279+00:00",
@@ -848,12 +848,12 @@
         "version": "2.28.2"
     },
     "rich": {
-        "datetime": "2023-03-27T10:52:04.183459+00:00",
+        "datetime": "2023-04-12T16:49:40.099023+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-03-27T10:52:04.183459+00:00",
-        "latest_version": "13.3.3",
+        "latest_datetime": "2023-04-12T16:49:40.099023+00:00",
+        "latest_version": "13.3.4",
         "name": "rich",
-        "version": "13.3.3"
+        "version": "13.3.4"
     },
     "ruamel-yaml": {
         "datetime": "2022-02-12T08:54:03.046398+00:00",
@@ -872,20 +872,20 @@
         "version": "0.2.7"
     },
     "ruff": {
-        "datetime": "2023-04-05T03:04:51.025462+00:00",
+        "datetime": "2023-04-19T20:37:10.358279+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-05T03:04:51.025462+00:00",
-        "latest_version": "0.0.261",
+        "latest_datetime": "2023-04-19T20:37:10.358279+00:00",
+        "latest_version": "0.0.262",
         "name": "ruff",
-        "version": "0.0.261"
+        "version": "0.0.262"
     },
     "semgrep": {
-        "datetime": "2023-04-05T23:11:45.516289+00:00",
+        "datetime": "2023-04-21T18:14:19.225210+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-04-05T23:11:45.516289+00:00",
-        "latest_version": "1.17.1",
+        "latest_datetime": "2023-04-21T18:14:19.225210+00:00",
+        "latest_version": "1.19.0",
         "name": "semgrep",
-        "version": "1.17.1"
+        "version": "1.19.0"
     },
     "six": {
         "datetime": "2021-05-05T14:18:17.237677+00:00",
@@ -992,12 +992,12 @@
         "version": "1.26.15"
     },
     "virtualenv": {
-        "datetime": "2023-03-12T18:30:10.374631+00:00",
+        "datetime": "2023-04-19T23:49:16.500287+00:00",
         "domain": "https://pypi.org/pypi/{name}/json",
-        "latest_datetime": "2023-03-12T18:30:10.374631+00:00",
-        "latest_version": "20.21.0",
+        "latest_datetime": "2023-04-19T23:49:16.500287+00:00",
+        "latest_version": "20.22.0",
         "name": "virtualenv",
-        "version": "20.21.0"
+        "version": "20.22.0"
     },
     "watchdog": {
         "datetime": "2023-03-20T09:20:25.047937+00:00",
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index c8d7bab..21c9db8 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -5,7 +5,7 @@
 
 - id: copier-template-tester
   description: 'ctt: copier-template-tester'
-  entry: ctt
+  entry: ctt --check-untracked
   language: python
   minimum_pre_commit_version: 2.9.2
   name: copier-template-tester
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index f2c94f1..ad0615a 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -1,9 +1,19 @@
-## Unreleased
+## 1.1.0 (2023-04-22)
+
+### Feat
+
+- add CLI arguments
 
 ### Fix
 
+- correctly handle files in untracked directories
+- **#3**: return error in pre-commit on new output directories (#5)
 - **#2**: resolve issues in Github Workflows
 
+### Refactor
+
+- switch to the corallium logger
+
 ## 1.0.2 (2022-11-20)
 
 ### Fix
diff --git a/docs/docs/DEVELOPER_GUIDE.md b/docs/docs/DEVELOPER_GUIDE.md
index a00a1fb..5e923e5 100644
--- a/docs/docs/DEVELOPER_GUIDE.md
+++ b/docs/docs/DEVELOPER_GUIDE.md
@@ -47,8 +47,8 @@ poetry config pypi-token.pypi ...
 | File                                 |   Statements |   Missing |   Excluded | Coverage   |
 |--------------------------------------|--------------|-----------|------------|------------|
 | `copier_template_tester/__init__.py` |            2 |         0 |          0 | 100.0%     |
-| `copier_template_tester/main.py`     |           39 |         0 |          5 | 100.0%     |
-| **Totals**                           |           41 |         0 |          5 | 100.0%     |
+| `copier_template_tester/main.py`     |           65 |         0 |         18 | 100.0%     |
+| **Totals**                           |           67 |         0 |         18 | 100.0%     |
 
-Generated on: 2023-04-08
+Generated on: 2023-04-22
 <!-- {cte} -->
diff --git a/poetry.lock b/poetry.lock
index 54f9336..e1fbeff 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -388,14 +388,14 @@ questionary = ">=1.8.1"
 
 [[package]]
 name = "corallium"
-version = "0.1.1"
+version = "0.2.0"
 description = "Shared functionality for calcipy-ecosystem"
 category = "main"
 optional = false
 python-versions = ">=3.8.12,<4.0.0"
 files = [
-    {file = "corallium-0.1.1-py3-none-any.whl", hash = "sha256:2d143fa5a96c36155af700e314af5bac50c2e97fd8a95aa3a50def2eb57d1100"},
-    {file = "corallium-0.1.1.tar.gz", hash = "sha256:a66431269c27a2937b59c028cef35cee59c6ab13ec0805253e1201154fa6b7fc"},
+    {file = "corallium-0.2.0-py3-none-any.whl", hash = "sha256:6b93de9484d3cee55dc0c05e83eb644d299f014181fe49738ae6908fb2d3d4ff"},
+    {file = "corallium-0.2.0.tar.gz", hash = "sha256:62973c138ac2d2a86df44dafbe3d258f19e595aa23783bd4ae1d2f687dd8821a"},
 ]
 
 [package.dependencies]
@@ -2749,4 +2749,4 @@ files = [
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.10.5"
-content-hash = "eb243a66be59d67c7d34821745d779f051f2cba6601834fbcc66294401087993"
+content-hash = "146d0af91407ebcc01284e2ac9dffaac8731e581e1a1a69cbee31cc064bed903"
diff --git a/pyproject.toml b/pyproject.toml
index 86650ab..cd947c6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ build-backend = "poetry.core.masonry.api"
 requires = ["poetry-core"]
 
 [tool.commitizen]
-version = "1.0.2"
+version = "1.1.0"
 version_files = ["copier_template_tester/__init__.py:^__version", "pyproject.toml:^version"]
 
 [tool.poetry]
@@ -24,19 +24,19 @@ maintainers = []
 name = "copier_template_tester"
 readme = "docs/README.md"
 repository = "https://github.com/kyleking/copier-template-tester"
-version = "1.0.2"
+version = "1.1.0"
 
 [tool.poetry.dependencies]
 python = "^3.10.5"
 copier = ">=7.0.1"
-corallium = ">=0.1.1"
+corallium = ">=0.2.0"
 
 [tool.poetry.group.dev.dependencies]
 calcipy = {extras = ["doc", "lint", "nox", "stale", "tags", "test", "types"], version = ">=1.2.3"}
 pytest-shell-utilities = ">=1.7.0"
 
 [tool.poetry.scripts]
-ctt = "copier_template_tester.main:run"
+ctt = "copier_template_tester.main:run_cli"
 
 [tool.poetry.urls]
 "Bug Tracker" = "https://github.com/kyleking/copier-template-tester/issues"


Test patch:
diff --git a/copier_template_tester/__init__.py b/copier_template_tester/__init__.py
index c90697d..66ac97b 100644
--- a/copier_template_tester/__init__.py
+++ b/copier_template_tester/__init__.py
@@ -1,6 +1,6 @@
 """copier_template_tester."""
 
-__version__ = '1.0.2'
+__version__ = '1.1.0'
 __pkg_name__ = 'copier_template_tester'
 
 # ====== Above is the recommended code from calcipy_template and may be updated on new releases ======
diff --git a/copier_template_tester/main.py b/copier_template_tester/main.py
index 9d766b2..27a6e21 100644
--- a/copier_template_tester/main.py
+++ b/copier_template_tester/main.py
@@ -4,21 +4,28 @@
 
 """
 
+import logging
 import shlex
 import shutil
-import subprocess
+import subprocess  # noqa: S404  # nosec
 import sys
+from argparse import ArgumentParser, ArgumentTypeError
 from pathlib import Path
 
 import copier
 from beartype import beartype
+from corallium.log import configure_logger, get_logger
+from corallium.loggers.plain_printer import plain_printer
 from corallium.tomllib import tomllib
 
+configure_logger(log_level=logging.INFO, logger=plain_printer)
+logger = get_logger()
+
 
 @beartype
 def _validate_config(config: dict) -> None:  # type: ignore[type-arg]
     if 'defaults' not in config:
-        print('Warning: You probably want a section: [defaults]')  # noqa: T201
+        logger.text('Warning: You probably want a section: [defaults]')
     if not config.get('output'):
         raise RuntimeError('CTT expected headers like: [output."<something>"]')
 
@@ -59,23 +66,35 @@ def _render(  # type: ignore[no-untyped-def]
 
 
 @beartype
-def _ls_untracked_dir() -> set[Path]:
+def _ls_untracked_dir(base_dir: Path) -> set[Path]:
+    """Use git to list all untracked files."""
     cmd = 'git ls-files --directory --exclude-standard --no-empty-dir --others'
-    process = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE)  # noqa: S603
+    process = subprocess.Popen(  # noqa: DUO116  # nosec  # nosemgrep
+        shlex.split(cmd), stdout=subprocess.PIPE, cwd=base_dir,  # noqa: S603
+    )
     stdout, _stderr = process.communicate()
-    return {Path.cwd() / _d.strip() for _d in stdout.decode().split('\n') if _d}
+    return {base_dir / _d.strip() for _d in stdout.decode().split('\n') if _d}
+
+
+@beartype
+def _is_relative(file_path: Path, directories: set[Path]) -> bool:
+    """Returns True if the file_path is relative to any of the directories."""
+    return any(file_path.is_relative_to(directory) for directory in directories)
 
 
 @beartype
-def _check_for_untracked(output_paths: set[Path]) -> None:
+def _check_for_untracked(output_paths: set[Path], base_dir: Path) -> None:
     """Resolves the edge case in #3 by raising when pre-commit won't error."""
-    if new_dirs := output_paths.intersection(_ls_untracked_dir()):
-        print(f'pre-commit error: untracked files from {new_dirs} must be added')  # noqa: T201
+    if untracked_paths := {
+        untracked for untracked in _ls_untracked_dir(base_dir)
+        if _is_relative(untracked, output_paths)
+    }:
+        logger.text('pre-commit error: untracked files must be added', untracked_paths=untracked_paths)
         sys.exit(1)
 
 
 @beartype
-def run(base_dir: Path | None = None) -> None:
+def run(*, base_dir: Path | None = None, check_untracked: bool = False) -> None:
     """Main class to run ctt."""
     base_dir = base_dir or Path.cwd()
     config = _load_config(base_dir)
@@ -87,10 +106,30 @@ def run(base_dir: Path | None = None) -> None:
         output_path = base_dir / key
         output_path.mkdir(parents=True, exist_ok=True)
         paths.add(output_path)
-        print(f'Creating: {output_path}')  # noqa: T201
+        logger.text(f'Creating: {output_path}')
         _render(input_path, base_dir / output_path, data=defaults | data)
-    _check_for_untracked(paths)
+
+    if check_untracked:  # pragma: no cover
+        _check_for_untracked(paths, base_dir)
 
 
-if __name__ == '__main__':  # pragma: no cover
-    run(Path.cwd().parent / 'calcipy_template')
+@beartype
+def run_cli() -> None:  # pragma: no cover
+    """Accept CLI configuration for running ctt."""
+    @beartype
+    def dir_path(pth: str | None) -> Path:
+        if pth and Path(pth).is_dir():
+            return Path(pth).resolve()
+        msg = f'Expected a path to a directory. Received: `{pth}`'
+        raise ArgumentTypeError(msg)
+
+    cli = ArgumentParser()
+    cli.add_argument(
+        '-b',
+        '--base-dir',
+        help='Specify the path to the directory that contains the configuration file',
+        type=dir_path)
+    cli.add_argument('--check-untracked', help='Only used for pre-commit', action='store_true')
+
+    args = cli.parse_args()
+    run(base_dir=args.base_dir, check_untracked=args.check_untracked)
diff --git a/tests/configuration.py b/tests/configuration.py
index a0e16ec..fa00b0b 100644
--- a/tests/configuration.py
+++ b/tests/configuration.py
@@ -1,8 +1,18 @@
 """Global variables for testing."""
 
+import logging
+from functools import partial
 from pathlib import Path
 
 from corallium.file_helpers import delete_dir, ensure_dir
+from corallium.log import configure_logger
+from corallium.loggers.rich_printer import rich_printer
+from corallium.loggers.styles import STYLES
+from rich.console import Console
+
+# Ensure a consistent line length
+DEFAULT_LOGGER = partial(rich_printer, _console=Console(width=200), _styles=STYLES)
+configure_logger(log_level=logging.DEBUG, logger=DEFAULT_LOGGER)
 
 TEST_DIR = Path(__file__).resolve().parent
 """Path to the `test` directory that contains this file and all other tests."""
diff --git a/tests/data/copier_demo/.gitattributes b/tests/data/copier_demo/.gitattributes
new file mode 100644
index 0000000..77eba7d
--- /dev/null
+++ b/tests/data/copier_demo/.gitattributes
@@ -0,0 +1,2 @@
+# Ensure whitespace consistency when testing ctt on any OS
+text eol=lf
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..e54035d
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,73 @@
+import shutil
+import tempfile
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Generator
+
+from beartype import beartype
+from corallium.log import get_logger
+from pytestshellutils.shell import Subprocess
+from pytestshellutils.utils.processes import ProcessResult
+
+from .configuration import TEST_DATA_DIR, TEST_DIR
+
+logger = get_logger()
+
+CTT_CMD = ['poetry', 'run', 'ctt']
+DEMO_DIR = TEST_DATA_DIR / 'copier_demo'
+
+
+class ExpectedError(Exception):
+    """Test-only exception."""
+
+
+@beartype
+def _format_ret_kwargs(ret: ProcessResult) -> dict[str, str]:
+    """Cleanup the shell output for printing."""
+    return {'stdout': f'`{ret.stdout.strip()}`', 'stderr': f'`{ret.stderr.strip()}`'}
+
+
+@beartype
+def run_ctt(shell: Subprocess, cwd: Path, args: list[str] | None = None) -> ProcessResult:
+    """Run `ctt` in the specified directory."""
+    ret = shell.run(*CTT_CMD, *(args or []), cwd=cwd)
+    logger.text('ran ctt', **_format_ret_kwargs(ret))
+    return ret
+
+
+@beartype
+def run_check(shell: Subprocess, *args, **kwargs) -> ProcessResult:
+    """Check that the shell process completed with exit code 0."""
+    ret = shell.run(*args, **kwargs)
+    logger.text('run_check', _args=args, _kwargs=kwargs, **_format_ret_kwargs(ret))
+    if ret.returncode != 0:
+        raise RuntimeError(f'Failed to run {args} with {kwargs}')
+    return ret
+
+
+@beartype
+def add_commit(shell: Subprocess, cwd: Path) -> None:
+    """Add and commit all files within the specified directory."""
+    assert not cwd.is_relative_to(TEST_DIR.parent)  # Prevent accidents!
+    run_check(shell, 'git', 'add', '.', cwd=cwd)
+    run_check(shell, 'git', 'commit', '-m="add-commit"', cwd=cwd)
+
+
+@contextmanager
+@beartype
+def temporary_git_dir(shell: Subprocess, *, source_dir: Path | None = None) -> Generator[Path, None, None]:
+    """Initialize a temporary directory for testing."""
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        working_dir = Path(tmp_dir) / (source_dir.name if source_dir else 'subdir')
+        if source_dir:
+            shutil.copytree(source_dir, working_dir)
+        working_dir.mkdir(exist_ok=True)
+
+        run_check(shell, 'git', 'init', cwd=working_dir)
+        # Required for Windows CI and works without issue everywhere else
+        run_check(shell, 'git', 'config', '--local', 'user.email', 'tester@py.test', cwd=working_dir)
+        run_check(shell, 'git', 'config', '--local', 'user.name', 'Pytest', cwd=working_dir)
+        if source_dir:
+            add_commit(shell, cwd=working_dir)
+
+        yield working_dir
diff --git a/tests/test_main.py b/tests/test_main.py
index dec53b7..5c8dfa8 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,15 +1,19 @@
+import sys
 from contextlib import nullcontext as does_not_raise
 from pathlib import Path
 
 import copier
 import pytest
 from beartype import beartype
+from corallium.log import get_logger
+from pytestshellutils.shell import Subprocess
 
-from copier_template_tester.main import _validate_config, run
+from copier_template_tester.main import _check_for_untracked, _validate_config, run
 
 from .configuration import TEST_DATA_DIR
+from .helpers import DEMO_DIR, ExpectedError, add_commit, run_ctt, temporary_git_dir
 
-CTT_CMD = ['poetry', 'run', 'ctt']
+logger = get_logger()
 
 
 @pytest.mark.parametrize(
@@ -28,12 +32,14 @@
         ),
     ],
 )
-def test_validate_config(config, expectation):
+@beartype
+def test_validate_config(config: dict, expectation) -> None:
     with expectation:
         _validate_config(config)
 
 
-def test_main_with_copier_mock(monkeypatch):
+@beartype
+def test_main_with_copier_mock(monkeypatch) -> None:
     """Only necessary for coverage metrics."""
     @beartype
     def _run_auto(src_path: str, dst_path: Path, **kwargs) -> None:  # noqa: ARG001
@@ -41,22 +47,82 @@ def _run_auto(src_path: str, dst_path: Path, **kwargs) -> None:  # noqa: ARG001
 
     monkeypatch.setattr(copier, 'run_auto', _run_auto)
 
-    run(base_dir=TEST_DATA_DIR / 'copier_demo')
+    run(base_dir=DEMO_DIR)
 
 
-def test_main(shell):
-    ret = shell.run(*CTT_CMD, cwd=TEST_DATA_DIR / 'copier_demo')
+@beartype
+def test_main(shell: Subprocess) -> None:
+    ret = run_ctt(shell, cwd=DEMO_DIR)
 
     assert ret.returncode == 0
-    ret.stdout.matcher.fnmatch_lines_random(['*Creating:*copier_demo*no_all*'])
-    ret.stderr.matcher.fnmatch_lines_random([
+    ret.stdout.matcher.fnmatch_lines(['*Creating:*copier_demo*no_all*', ''])
+    # Check output from copier
+    ret.stderr.matcher.fnmatch_lines_random([  # Order can vary on Windows
         '*Copying from template*',
-        '*.copier-answers.yml*',
+        '* .copier-answers.yml*',
+        '*identical* README.md*',
+        '*identical* script.py*',
     ])
 
 
-def test_main_missing_config(shell):
-    ret = shell.run(*CTT_CMD, cwd=TEST_DATA_DIR)
+@pytest.mark.parametrize(
+    ('expect_untracked', 'paths'),
+    [
+        (True, ['out_2/.a']),
+        (True, ['out/a/b/c.txt']),
+        (False, ['a/not-in-out.txt']),
+    ],
+    ids=[
+        'check a dotfile',
+        'check a nested file',
+        'check file outside of output path',
+    ],
+)
+@beartype
+def test_check_for_untracked(*, expect_untracked: bool, paths: list[str], shell: Subprocess, monkeypatch) -> None:
+    @beartype
+    def raise_int(arg: int) -> None:
+        msg = f'arg={arg}'
+        raise ExpectedError(msg)
+
+    monkeypatch.setattr(sys, 'exit', raise_int)
+    with temporary_git_dir(shell) as copier_dir:
+        (copier_dir / 'init.txt').write_text('')
+        add_commit(shell, cwd=copier_dir)
+        for pth in paths:
+            new_file = copier_dir / pth
+            new_file.parent.mkdir(exist_ok=True, parents=True)
+            new_file.write_text(pth)
+
+        output_paths = {copier_dir / 'out', copier_dir / 'out_2'}
+        with pytest.raises(ExpectedError, match=r'^arg=1$') if expect_untracked else does_not_raise():
+            _check_for_untracked(output_paths, copier_dir)
+
+
+@beartype
+def test_ctt_with_untracked_files(shell: Subprocess) -> None:
+    untracked_file = Path('template_dir/untracked_file.txt')
+    with temporary_git_dir(shell, source_dir=DEMO_DIR) as copier_dir:
+        first_pass = run_ctt(shell, cwd=TEST_DATA_DIR, args=[f'--base-dir={copier_dir}'])
+        assert first_pass.returncode == 0
+
+        (copier_dir / untracked_file).write_text('Placeholder\n')
+        add_commit(shell, cwd=copier_dir)
+        ret = run_ctt(shell, cwd=TEST_DATA_DIR, args=[f'--base-dir={copier_dir}', '--check-untracked'])
+
+    assert ret.returncode == 1
+    ret.stdout.matcher.fnmatch_lines(['*Creating:*copier_demo*no_all*', ''])
+    # Check output from copier
+    ret.stderr.matcher.fnmatch_lines_random([  # Order can vary on Windows
+        '*Copying from template*',
+        '*conflict* .copier-answers.yml*',
+        f'*create* {untracked_file.name}*',
+    ])
+
+
+@beartype
+def test_main_missing_config(shell: Subprocess) -> None:
+    ret = run_ctt(shell, cwd=TEST_DATA_DIR)
 
     assert ret.returncode == 1
-    ret.stderr.matcher.fnmatch_lines_random(['*No configuration file found. Expected: *ctt.toml*'])
+    ret.stderr.matcher.fnmatch_lines(['*No configuration file found. Expected: *ctt.toml*'])