Repository: py-cov-action/python-coverage-comment-action (python)

Issue:
No coverage comment
Hello, I have tried to debug this problem but run out of ideas. Base on exception message I see what line is raising the error its referring to missing  GITHUB_TOKEN but its included 
```
#.github/workflows/pr-review.yml
name: Pull request auto review

on:
    pull_request:
      branches:
        - main

jobs:
  test:
    name: Run tests & display coverage
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      pull-requests: write
      contents: write

    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: 3.9
      - name: Install dependencies
        run: |
          # install dependencies
          python -m pip install --upgrade pip
          pip install pipenv
          pipenv install --dev

      # - name: Lint with flake8
      #   run: |
      #     make lint
      - name: Test with coverage
        run: |
          make test
          cd src
          ls -al
      - name: Coverage comment
        id: coverage_comment
        uses: py-cov-action/python-coverage-comment-action@v3
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Store Pull Request comment to be posted
        uses: actions/upload-artifact@v3
        if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true'
        with:
          name: python-coverage-comment-action
          path: python-coverage-comment-action.txt

```
I was following readme file, and part responsible for github token looks good to me.
```
#/.github/workflows/coverage.yml
name: Post coverage comment

on:
  workflow_run:
    workflows: ["Pull request auto review"]
    types:
      - completed

jobs:
  test:
    name: Display coverage
    runs-on: ubuntu-latest
    if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
    permissions:
      pull-requests: write
      contents: write
      actions: read
    steps:
      - name: Post comment
        uses: py-cov-action/python-coverage-comment-action@v3
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }}
```

Here is a full error with trackback
``` Notice: Starting action
Error: Critical error. This error possibly occurred because the permissions of the workflow are set incorrectly. You can see the correct setting of permissions here: https://github.com/py-cov-action/python-coverage-comment-action#basic-usage
Otherwise please look for open issues or open one in https://github.com/py-cov-action/python-coverage-comment-action/
Traceback (most recent call last):
  File "/usr/local/lib/python3.10/site-packages/coverage_comment/subprocess.py", line 17, in run
    return subprocess.run(
  File "/usr/local/lib/python3.10/subprocess.py", line 526, in run
    raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command '('coverage', 'json', '-o', '-')' returned non-zero exit status 1.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/local/lib/python3.10/site-packages/coverage_comment/main.py", line 39, in main
    exit_code = action(
  File "/usr/local/lib/python3.10/site-packages/coverage_comment/main.py", line 75, in action
    coverage = coverage_module.get_coverage_info(merge=config.MERGE_COVERAGE_FILES)
  File "/usr/local/lib/python3.10/site-packages/coverage_comment/coverage.py", line 75, in get_coverage_info
    json_coverage = subprocess.run("coverage", "json", "-o", "-")
  File "/usr/local/lib/python3.10/site-packages/coverage_comment/subprocess.py", line [25](https://github.com/org/repo/actions/runs/4864464278/jobs/8673513037#step:7:26), in run
    raise SubProcessError("\n".join([exc.stdout, exc.stderr])) from exc
coverage_comment.subprocess.SubProcessError: No data to report.
```

And bit of my test and coverage step what according to logs doing the job well.
Source code is located in /src and tests are located at /src/tests
Coverage config file
```
#/src/.coveragerc with content
[run]
relative_files = True
```

More step output

``` Run make test
  
cd src; pipenv run coverage run -m pytest -v tests/
============================= test session starts ==============================
platform linux -- Python 3.9.16, pytest-7.3.1, pluggy-1.0.0 -- /home/runner/.local/share/virtualenvs/repo-name

============================= 28 passed in 26.59s ==============================
cd src; pipenv run coverage report -m

--------------------------------------------------------------------------
TOTAL                                          [75](https://github.com/repo-name/actions/runs/4864464278/jobs/8673513037#step:6:76)6     29    96%
total 112
drwxr-xr-x 4 runner docker  4096 May  2 18:21 .
drwxr-xr-x 8 runner docker  4096 May  2 18:20 ..
-rw-r--r-- 1 runner docker 53248 May  2 18:21 .coverage
-rw-r--r-- 1 runner docker    27 May  2 18:20 .coveragerc
drwxr-xr-x 3 runner docker  4096 May  2 18:21 .pytest_cache
-rw-r--r-- 1 runner docker     0 May  2 18:20 __init__.py
```
Sorry but I had to cut off filenames and repo name due to being private repo, but as pytest run with no problem, and I see .coverage file is created, I struggle to understand why subprocess try to generate report one more time and its failing, any ideas?

Will appreciate any hints ;)

Pull request description:
Run coverage in subdirectory
Closes #167 (and linked to #16)

I'm not 100% sure it works, because I've mostly make tests pass, but I'd like to try that for real (and wondering about changing the e2e tests to use a subdir)

Patch:
diff --git a/.gitignore b/.gitignore
index 1c6ff85..d1b1e7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 .DS_Store
 .coverage
 dev-env-vars
+.coverage*
diff --git a/README.md b/README.md
index a12b468..cedaad4 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ See [an example](https://github.com/py-cov-action/python-coverage-comment-action
 ### Default branch mode
 
 On repository's default branch, it will extract the coverage rate and create
-files that will be stored on a dedicated independant branch in your repository.
+files that will be stored on a dedicated independent branch in your repository.
 
 These files include:
 
@@ -42,7 +42,7 @@ These files include:
   repository is public to customize the look of your badge
 - Another `json` file used internally by the action to report on coverage
   evolution (does a PR make the coverage go up or down?)
-- A short file-by-file coverage report embedded directy into the branch's README. An excerpt from this is also output directly as a [job summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary).
+- A short file-by-file coverage report embedded directly into the branch's README. An excerpt from this is also output directly as a [job summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary).
 - The full HTML coverage report and links to make this report browsable
 
 See [an example](https://github.com/py-cov-action/python-coverage-comment-action-v3-example)
@@ -56,7 +56,7 @@ Please ensure that your `.coverage` file(s) is created with the option
 
 Please ensure that the branch `python-coverage-comment-action-data` is not
 protected (there's no reason that it would be the case, except if you have very
-sprecific wildcard rules). If it is, either adjust your rules, or set the
+specific wildcard rules). If it is, either adjust your rules, or set the
 `COVERAGE_DATA_BRANCH` parameter as described below. GitHub Actions will create
 this branch with initial data at the first run if it doesn't exist, and will
 independently commit to that branch after each commit to your default branch.
@@ -70,6 +70,7 @@ badge to your Readme will be displayed in:
 - The text output of the workflow run
 
 ### Basic usage
+
 ```yaml
 # .github/workflows/ci.yml
 name: CI
@@ -78,7 +79,7 @@ on:
   pull_request:
   push:
     branches:
-      - 'main'
+      - "main"
 
 jobs:
   test:
@@ -96,7 +97,7 @@ jobs:
       - uses: actions/checkout@v3
 
       - name: Install everything, run the tests, produce the .coverage file
-        run: make test  # This is the part where you put your own test command
+        run: make test # This is the part where you put your own test command
 
       - name: Coverage comment
         id: coverage_comment
@@ -166,9 +167,9 @@ on:
   pull_request:
   push:
     branches:
-      - 'master'
+      - "master"
     tags:
-      - '*'
+      - "*"
 
 jobs:
   build:
@@ -193,7 +194,7 @@ jobs:
           python-version: ${{ matrix.python_version }}
 
       - name: Install everything, run the tests, produce a .coverage.xxx file
-        run: make test  # This is the part where you put your own test command
+        run: make test # This is the part where you put your own test command
         env:
           COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
           # Alternatively you can run coverage with the --parallel flag or add
@@ -220,7 +221,7 @@ jobs:
       - uses: actions/download-artifact@v3
         id: download
         with:
-          name: 'coverage'
+          name: "coverage"
 
       - name: Coverage comment
         id: coverage_comment
@@ -235,10 +236,10 @@ jobs:
         with:
           name: python-coverage-comment-action
           path: python-coverage-comment-action.txt
-
 ```
 
 ### All options
+
 ```yaml
 - name: Display coverage
   id: coverage_comment
@@ -249,6 +250,10 @@ jobs:
     # Only necessary in the "workflow_run" workflow.
     GITHUB_PR_RUN_ID: ${{ inputs.GITHUB_PR_RUN_ID }}
 
+    # Use this in case the folder to run coverage commands from is not the
+    # top level of your repository
+    COVERAGE_PATH: my_project/
+
     # If the coverage percentage is above or equal to this value, the badge will be green.
     MINIMUM_GREEN: 100
 
@@ -291,18 +296,18 @@ By default, comments are generated from a
 [Jinja](https://jinja.palletsprojects.com) template that you can read
 [here](https://github.com/py-cov-action/python-coverage-comment-action/blob/v3/coverage_comment/template_files/comment.md.j2).
 
-If you want to change this template, you can set ``COMMENT_TEMPLATE``. This is
+If you want to change this template, you can set `COMMENT_TEMPLATE`. This is
 an advanced usage, so you're likely to run into more road bumps.
 
 You will need to follow some rules for your template to be valid:
 
 - Your template needs to be syntactically correct with Jinja2 rules
 - You may define a new template from scratch, but in this case you are required
-  to include ``{{ marker }}``, which includes an HTML comment (invisible on
+  to include `{{ marker }}`, which includes an HTML comment (invisible on
   GitHub) that the action uses to identify its own comments.
 - If you'd rather want to change parts of the default template, you can do so
-  by starting your comment with ``{% extends "base" %}``, and then override the
-  blocks (``{% block foo %}``) that you wish to change. If you're unsure how it
+  by starting your comment with `{% extends "base" %}`, and then override the
+  blocks (`{% block foo %}`) that you wish to change. If you're unsure how it
   works, see [the Jinja
   documentation](https://jinja.palletsprojects.com/en/3.0.x/templates/#template-inheritance)
 - In either case, you will most likely want to get yourself familiar with the
@@ -311,6 +316,7 @@ You will need to follow some rules for your template to be valid:
   Should those variables change, we'll do our best to bump the action's major version.
 
 ### Examples
+
 In the first example, we change the emoji that illustrates coverage going down from
 `:down_arrow:` to `:sob:`:
 
@@ -327,14 +333,16 @@ coverage (percentage) of the whole project from the PR build:
 ```
 
 # Other topics
+
 ## Pinning
+
 On the examples above, the version was set to `v3` (a branch). You can also pin
 a specific version such as `v3.0.0` (a tag). There are still things left to
 figure out in how to manage releases and version. If you're interested, please
 open an issue to discuss this.
 
-In terms of security/reproductibility, the best solution is probably to pin the
-version to an exact tag, and use dependabot to update it regularily.
+In terms of security/reproducibility, the best solution is probably to pin the
+version to an exact tag, and use dependabot to update it regularly.
 
 ## Note on the state of this action
 
@@ -358,7 +366,6 @@ a brand new action (this action) was created.
 You can find the (unmaintained) language-generic version
 [here](https://github.com/marketplace/actions/coverage-comment).
 
-
 ## Why do we need `relative_files = true` ?
 
 Yes, I agree, this is annoying! The reason is that by default, coverage writes
@@ -375,9 +382,11 @@ anymore.
 ## .coverage file generated on a Windows file system
 
 If your project's coverage was built on Windows, you may get an error like:
+
 ```
 CoverageWarning: Couldn't parse 'yourproject\__init__.py': No source for code: 'yourproject\__init__.py'. (couldnt-parse)
 ```
+
 This is likely due to coverage being confused with the coverage being computed with `\` but read with `/`. You can most probably fix it with the following in your [coverage configuration](https://coverage.readthedocs.io/en/latest/config.html):
 
 ```
diff --git a/action.yml b/action.yml
index a4d4784..ef328b7 100644
--- a/action.yml
+++ b/action.yml
@@ -1,7 +1,7 @@
 name: Python Coverage Comment
 branding:
-  icon: 'umbrella'
-  color: 'purple'
+  icon: "umbrella"
+  color: "purple"
 description: >
   Publish diff coverage report as PR comment, and create a coverage badge
   to display on the readme.
@@ -29,6 +29,12 @@ inputs:
       branch is not protected.
     default: python-coverage-comment-action-data
     required: false
+  COVERAGE_PATH:
+    description: >
+      Path to the directory under the git root where the coverage data is
+      stored. Default is '.'.
+    default: "."
+    required: false
   COMMENT_ARTIFACT_NAME:
     description: >
       Name of the artifact in which the body of the comment to post on the PR is stored.
@@ -82,6 +88,7 @@ runs:
     GITHUB_PR_RUN_ID: ${{ inputs.GITHUB_PR_RUN_ID }}
     COMMENT_TEMPLATE: ${{ inputs.COMMENT_TEMPLATE }}
     COVERAGE_DATA_BRANCH: ${{ inputs.COVERAGE_DATA_BRANCH }}
+    COVERAGE_PATH: ${{ inputs.COVERAGE_PATH }}
     COMMENT_ARTIFACT_NAME: ${{ inputs.COMMENT_ARTIFACT_NAME }}
     COMMENT_FILENAME: ${{ inputs.COMMENT_FILENAME }}
     MINIMUM_GREEN: ${{ inputs.MINIMUM_GREEN }}
diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py
index 38c98a6..fbf4174 100644
--- a/coverage_comment/coverage.py
+++ b/coverage_comment/coverage.py
@@ -67,12 +67,14 @@ def compute_coverage(num_covered: int, num_total: int) -> decimal.Decimal:
     return decimal.Decimal(num_covered) / decimal.Decimal(num_total)
 
 
-def get_coverage_info(merge: bool) -> Coverage:
+def get_coverage_info(merge: bool, coverage_path: pathlib.Path) -> Coverage:
     try:
         if merge:
-            subprocess.run("coverage", "combine")
+            subprocess.run("coverage", "combine", path=coverage_path)
 
-        json_coverage = subprocess.run("coverage", "json", "-o", "-")
+        json_coverage = subprocess.run(
+            "coverage", "json", "-o", "-", path=coverage_path
+        )
     except subprocess.SubProcessError as exc:
         if "No source for code:" in str(exc):
             log.error(
@@ -87,18 +89,33 @@ def get_coverage_info(merge: bool) -> Coverage:
             )
         raise
 
-    return extract_info(json.loads(json_coverage))
+    return extract_info(data=json.loads(json_coverage), coverage_path=coverage_path)
 
 
-def generate_coverage_html_files(path: pathlib.Path) -> None:
-    subprocess.run("coverage", "html", "--skip-empty", "--directory", str(path))
+def generate_coverage_html_files(
+    destination: pathlib.Path, coverage_path: pathlib.Path
+) -> None:
+    subprocess.run(
+        "coverage",
+        "html",
+        "--skip-empty",
+        "--directory",
+        str(destination),
+        path=coverage_path,
+    )
 
 
-def generate_coverage_markdown() -> str:
-    return subprocess.run("coverage", "report", "--format=markdown", "--show-missing")
+def generate_coverage_markdown(coverage_path: pathlib.Path) -> str:
+    return subprocess.run(
+        "coverage",
+        "report",
+        "--format=markdown",
+        "--show-missing",
+        path=coverage_path,
+    )
 
 
-def extract_info(data) -> Coverage:
+def extract_info(data: dict, coverage_path: pathlib.Path) -> Coverage:
     """
     {
         "meta": {
@@ -146,8 +163,9 @@ def extract_info(data) -> Coverage:
             show_contexts=data["meta"]["show_contexts"],
         ),
         files={
-            pathlib.Path(path): FileCoverage(
-                path=pathlib.Path(path),
+            coverage_path
+            / path: FileCoverage(
+                path=coverage_path / path,
                 excluded_lines=file_data["excluded_lines"],
                 executed_lines=file_data["executed_lines"],
                 missing_lines=file_data["missing_lines"],
@@ -191,9 +209,9 @@ def extract_info(data) -> Coverage:
     )
 
 
-def get_diff_coverage_info(base_ref: str) -> DiffCoverage:
-    subprocess.run("git", "fetch", "--depth=1000")
-    subprocess.run("coverage", "xml")
+def get_diff_coverage_info(base_ref: str, coverage_path: pathlib.Path) -> DiffCoverage:
+    subprocess.run("git", "fetch", "--depth=1000", path=pathlib.Path.cwd())
+    subprocess.run("coverage", "xml", path=coverage_path)
     with tempfile.NamedTemporaryFile("r") as f:
         subprocess.run(
             "diff-cover",
@@ -202,6 +220,7 @@ def get_diff_coverage_info(base_ref: str) -> DiffCoverage:
             f"--json-report={f.name}",
             "--diff-range-notation=..",
             "--quiet",
+            path=coverage_path,
         )
         diff_json = json.loads(pathlib.Path(f.name).read_text())
 
diff --git a/coverage_comment/files.py b/coverage_comment/files.py
index ba76cb5..f1f1aea 100644
--- a/coverage_comment/files.py
+++ b/coverage_comment/files.py
@@ -112,10 +112,14 @@ def get_urls(url_getter: Callable) -> ImageURLs:
     }
 
 
-def get_coverage_html_files(gen_dir: pathlib.Path = pathlib.Path("/tmp")) -> ReplaceDir:
-    source = pathlib.Path(tempfile.mkdtemp(dir=gen_dir))
-    coverage.generate_coverage_html_files(path=source)
+def get_coverage_html_files(
+    *, coverage_path: pathlib.Path, gen_dir: pathlib.Path = pathlib.Path("/tmp")
+) -> ReplaceDir:
+    html_dir = pathlib.Path(tempfile.mkdtemp(dir=gen_dir))
+    coverage.generate_coverage_html_files(
+        destination=html_dir, coverage_path=coverage_path
+    )
     dest = pathlib.Path("htmlcov")
     # Coverage may or may not create a .gitignore.
-    (source / ".gitignore").unlink(missing_ok=True)
-    return ReplaceDir(source=source, path=dest)
+    (html_dir / ".gitignore").unlink(missing_ok=True)
+    return ReplaceDir(source=html_dir, path=dest)
diff --git a/coverage_comment/main.py b/coverage_comment/main.py
index beeb107..f96d7b3 100644
--- a/coverage_comment/main.py
+++ b/coverage_comment/main.py
@@ -72,11 +72,12 @@ def action(
         return 1
 
     if event_name in {"pull_request", "push"}:
-        coverage = coverage_module.get_coverage_info(merge=config.MERGE_COVERAGE_FILES)
-
+        coverage = coverage_module.get_coverage_info(
+            merge=config.MERGE_COVERAGE_FILES, coverage_path=config.COVERAGE_PATH
+        )
         if event_name == "pull_request":
             diff_coverage = coverage_module.get_diff_coverage_info(
-                base_ref=config.GITHUB_BASE_REF
+                base_ref=config.GITHUB_BASE_REF, coverage_path=config.COVERAGE_PATH
             )
             if config.ANNOTATE_MISSING_LINES:
                 annotations.create_pr_annotations(
@@ -276,9 +277,13 @@ def save_coverage_data_files(
     is_public = repo_info.is_public()
     if is_public:
         log.info("Generating HTML coverage report")
-        operations.append(files.get_coverage_html_files())
+        operations.append(
+            files.get_coverage_html_files(coverage_path=config.COVERAGE_PATH)
+        )
 
-    markdown_report = coverage_module.generate_coverage_markdown()
+    markdown_report = coverage_module.generate_coverage_markdown(
+        coverage_path=config.COVERAGE_PATH
+    )
 
     github.add_job_summary(
         content=f"## Coverage report\n\n{markdown_report}",
diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py
index 2e7d7d1..94aadce 100644
--- a/coverage_comment/settings.py
+++ b/coverage_comment/settings.py
@@ -2,6 +2,7 @@ import dataclasses
 import decimal
 import inspect
 import pathlib
+from collections.abc import MutableMapping
 from typing import Any
 
 from coverage_comment import log
@@ -15,7 +16,7 @@ class InvalidAnnotationType(Exception):
     pass
 
 
-def path_below(path_str: str) -> pathlib.Path:
+def path_below(path_str: str | pathlib.Path) -> pathlib.Path:
     try:
         return pathlib.Path(path_str).resolve().relative_to(pathlib.Path.cwd())
     except ValueError as exc:
@@ -41,6 +42,7 @@ class Config:
     GITHUB_STEP_SUMMARY: pathlib.Path
     COMMENT_TEMPLATE: str | None = None
     COVERAGE_DATA_BRANCH: str = "python-coverage-comment-action-data"
+    COVERAGE_PATH: pathlib.Path = pathlib.Path(".")
     COMMENT_ARTIFACT_NAME: str = "python-coverage-comment-action"
     COMMENT_FILENAME: pathlib.Path = pathlib.Path("python-coverage-comment-action.txt")
     GITHUB_OUTPUT: pathlib.Path | None = None
@@ -102,6 +104,10 @@ class Config:
     def clean_comment_filename(cls, value: str) -> pathlib.Path:
         return path_below(value)
 
+    @classmethod
+    def clean_coverage_path(cls, value: str) -> pathlib.Path:
+        return path_below(value)
+
     @classmethod
     def clean_github_output(cls, value: str) -> pathlib.Path:
         return pathlib.Path(value)
@@ -113,8 +119,11 @@ class Config:
             return int(self.GITHUB_REF.split("/")[2])
         return None
 
+    # We need to type environ as a MutableMapping because that's what
+    # os.environ is, and just saying `dict[str, str]` is not enough to make
+    # mypy happy
     @classmethod
-    def from_environ(cls, environ: dict[str, str]) -> "Config":
+    def from_environ(cls, environ: MutableMapping[str, str]) -> "Config":
         possible_variables = [e for e in inspect.signature(cls).parameters]
         config: dict[str, Any] = {
             k: v for k, v in environ.items() if k in possible_variables
diff --git a/coverage_comment/subprocess.py b/coverage_comment/subprocess.py
index a2b9c35..1d2ba81 100644
--- a/coverage_comment/subprocess.py
+++ b/coverage_comment/subprocess.py
@@ -1,5 +1,6 @@
 import functools
 import os
+import pathlib
 import subprocess
 from typing import Any
 
@@ -12,10 +13,11 @@ class GitError(SubProcessError):
     pass
 
 
-def run(*args, **kwargs) -> str:
+def run(*args, path: pathlib.Path, **kwargs) -> str:
     try:
         return subprocess.run(
             args,
+            cwd=path,
             text=True,
             check=True,
             capture_output=True,
@@ -40,7 +42,7 @@ class Git:
     >>> git.rev_parse("--short", "HEAD")
     """
 
-    cwd = "."
+    cwd = pathlib.Path(".")
 
     def _git(self, *args: str, env: dict[str, str] | None = None, **kwargs) -> str:
         # When setting the `env` argument to run, instead of inheriting env
@@ -54,7 +56,7 @@ class Git:
             return run(
                 "git",
                 *args,
-                cwd=self.cwd,
+                path=self.cwd,
                 env=os.environ | (env or {}),
                 **kwargs,
             )
diff --git a/dev-env b/dev-env
index c102073..ca8b789 100755
--- a/dev-env
+++ b/dev-env
@@ -106,7 +106,7 @@ function ghenv(){
 function token-from-gh(){
     # gh-cli token has repo scope by default, but if we needed different scopes, it would be:
     # gh auth refresh --scopes=repo
-    export GITHUB_TOKEN=$(gh auth status --show-token 1>/dev/null 2>&1 |grep Token | cut -d ':' -f2 | xargs)
+    export GITHUB_TOKEN=$(gh auth token)
 }
 function create-token(){
     scopes="repo,delete_repo,read:org"
diff --git a/setup.cfg b/setup.cfg
index adf8c25..42de93f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -35,3 +35,4 @@ filterwarnings =
     error
 markers=
     repo_suffix: Allows to use an additional suffix for the e2e test repo.
+    code_path: Allows to place the code in a subdirectory for the e2e test repo.


Test patch:
diff --git a/tests/README.md b/tests/README.md
index add59ec..5e47042 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -31,7 +31,7 @@ in the test suite would probably be much harder.
 
 We currently have 100% coverage, in 3 categories:
 - Low-level functions that have a lot of complex logic are unit-tested
-- Glue functions have slightly less logic, there's about a dozen of possible code paths through those functions. Each path has an integration test. Any additional logic is pushed down the stack where it will be unit-tested. In order for this to work elegently, all the glue function recieve and use objects that will ultimately be responsible for the low-level behaviour that we want to be able to simulate in the test. For example, the glue functions receive an httpx Client as parameter. In the test, we're able to call these functions with test double that will not do real HTTP calls. This means that the integration tests that provide coverage for the glue functions also lets us check that the different bricks are correctly plugged to one another
+- Glue functions have slightly less logic, there's about a dozen of possible code paths through those functions. Each path has an integration test. Any additional logic is pushed down the stack where it will be unit-tested. In order for this to work elegently, all the glue function receive and use objects that will ultimately be responsible for the low-level behaviour that we want to be able to simulate in the test. For example, the glue functions receive an httpx Client as parameter. In the test, we're able to call these functions with test double that will not do real HTTP calls. This means that the integration tests that provide coverage for the glue functions also lets us check that the different bricks are correctly plugged to one another
 - The top-level code that is responsible to call the glue functions with the real implementations for httpx clients etc. relies on mocks. It's not very elegant but it's a single function, and it means we get to have 100% coverage.
 - End-to-end tests don't count toward coverage
 
diff --git a/tests/end_to_end/conftest.py b/tests/end_to_end/conftest.py
index 50ee508..ea8f9aa 100644
--- a/tests/end_to_end/conftest.py
+++ b/tests/end_to_end/conftest.py
@@ -41,7 +41,7 @@ def call():
 @contextlib.contextmanager
 def _cd(tmp_path: pathlib.Path, path: str):
     full_path = tmp_path / path
-    full_path.mkdir(exist_ok=True)
+    full_path.mkdir(exist_ok=True, parents=True)
     old_path = pathlib.Path.cwd()
     if old_path == full_path:
         yield full_path
@@ -191,18 +191,30 @@ def gh_other_username(gh_other):
 
 
 @pytest.fixture
-def git_repo(cd, git, action_ref):
+def git_repo(cd, git, action_ref, code_path):
     with cd("repo") as repo:
         git("init", "-b", "main")
+        # Copy .github
+        shutil.copytree(
+            pathlib.Path(__file__).parent / "repo" / ".github",
+            repo / ".github",
+            dirs_exist_ok=True,
+        )
+        # Copy everything else
         shutil.copytree(
             pathlib.Path(__file__).parent / "repo",
-            repo,
+            repo / code_path,
             dirs_exist_ok=True,
+            ignore=shutil.ignore_patterns(".github"),
         )
         # Rewrite the specific version of the action we run in the workflow files.
         for file in (repo / ".github/workflows").iterdir():
-            file: pathlib.Path
-            file.write_text(file.read_text().replace("__ACTION_REF__", action_ref))
+            content = (
+                file.read_text()
+                .replace("__ACTION_REF__", action_ref)
+                .replace("__ACTION_COVERAGE_PATH__", str(code_path))
+            )
+            file.write_text(content)
 
         git("add", ".")
         git("commit", "-m", "initial commit")
@@ -221,6 +233,12 @@ def repo_name(request):
     return name
 
 
+@pytest.fixture
+def code_path(request):
+    mark = request.node.get_closest_marker("code_path")
+    return pathlib.Path(*mark.args) if mark else pathlib.Path(".")
+
+
 @pytest.fixture
 def repo_full_name(repo_name, gh_me_username):
     return f"{gh_me_username}/{repo_name}"
@@ -378,9 +396,9 @@ def wait_for_run_triggered_by_user_to_start():
 
 
 @pytest.fixture
-def add_coverage_line(git):
+def add_coverage_line(git, code_path):
     def f(line):
-        csv_file = pathlib.Path("tests/cases.csv")
+        csv_file = pathlib.Path(code_path / "tests/cases.csv")
         csv_file.write_text(csv_file.read_text() + line + "\n")
 
         git("add", str(csv_file))
diff --git a/tests/end_to_end/repo/.github/workflows/ci.yml b/tests/end_to_end/repo/.github/workflows/ci.yml
index dbdc8bb..18c7daf 100644
--- a/tests/end_to_end/repo/.github/workflows/ci.yml
+++ b/tests/end_to_end/repo/.github/workflows/ci.yml
@@ -4,7 +4,7 @@ on:
   pull_request:
   push:
     branches:
-      - 'main'
+      - "main"
 
 concurrency:
   group: CI
@@ -17,7 +17,6 @@ jobs:
       pull-requests: write
       contents: write
     steps:
-
       - uses: actions/checkout@v3
 
       - name: Install poetry
@@ -25,12 +24,14 @@ jobs:
 
       - uses: actions/setup-python@v4
         with:
-          python-version: '3.10'
-          cache: 'poetry'
+          python-version: "3.10"
+          cache: "poetry"
 
       - run: poetry install
+        working-directory: __ACTION_COVERAGE_PATH__
 
       - run: poetry run pytest
+        working-directory: __ACTION_COVERAGE_PATH__
 
       - name: Coverage comment
         id: coverage_comment
@@ -39,6 +40,7 @@ jobs:
           GITHUB_TOKEN: ${{ github.token }}
           ANNOTATE_MISSING_LINES: true
           ANNOTATION_TYPE: notice
+          COVERAGE_PATH: __ACTION_COVERAGE_PATH__
 
       - name: Store Pull Request comment to be posted
         uses: actions/upload-artifact@v3
diff --git a/tests/end_to_end/repo/.github/workflows/coverage-comment.yml b/tests/end_to_end/repo/.github/workflows/coverage-comment.yml
index af0387d..9db6642 100644
--- a/tests/end_to_end/repo/.github/workflows/coverage-comment.yml
+++ b/tests/end_to_end/repo/.github/workflows/coverage-comment.yml
@@ -21,3 +21,4 @@ jobs:
         with:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }}
+          COVERAGE_PATH: __ACTION_COVERAGE_PATH__
diff --git a/tests/end_to_end/test_all.py b/tests/end_to_end/test_all.py
index 05682cf..4b44529 100644
--- a/tests/end_to_end/test_all.py
+++ b/tests/end_to_end/test_all.py
@@ -5,6 +5,7 @@ import pytest
 
 
 @pytest.mark.repo_suffix("public")
+@pytest.mark.code_path("subdir")
 def test_public_repo(
     gh_create_repo,
     wait_for_run_to_start,
@@ -28,10 +29,10 @@ def test_public_repo(
     run_id = wait_for_run_to_start(sha1=get_sha1(), branch="main", gh=gh_me)
 
     # AAAaand it's started. Now let's wait for it to end.
-    # Also, raise if it doesn't end succefully. That half of the job.
+    # Also, raise if it doesn't end successfully. That half of the job.
     gh_me("run", "watch", run_id, "--exit-status")
 
-    # Now to the other half: maybe it did nothing succesfully, so let's check
+    # Now to the other half: maybe it did nothing successfully, so let's check
     # that the lob log contains the 3 links to our svg images
     repo_api_url = "/repos/{owner}/{repo}"
 
@@ -201,7 +202,7 @@ def test_private_repo(
     # Also, raise if it doesn't end succefully. That half of the job.
     gh_me("run", "watch", run_id, "--exit-status")
 
-    # Now to the other half: maybe it did nothing succesfully. Let's check
+    # Now to the other half: maybe it did nothing successfully. Let's check
     # Stdout contains the expected link. This time, there's only one link
     # (because shields.io can't access a private repo)
 
diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py
index c6ebb8b..db08ef9 100644
--- a/tests/unit/test_coverage.py
+++ b/tests/unit/test_coverage.py
@@ -28,11 +28,11 @@ def test_get_coverage_info(mocker, coverage_json, coverage_obj):
         "coverage_comment.subprocess.run", return_value=json.dumps(coverage_json)
     )
 
-    result = coverage.get_coverage_info(merge=True)
+    result = coverage.get_coverage_info(merge=True, coverage_path=pathlib.Path("."))
 
     assert run.call_args_list == [
-        mocker.call("coverage", "combine"),
-        mocker.call("coverage", "json", "-o", "-"),
+        mocker.call("coverage", "combine", path=pathlib.Path(".")),
+        mocker.call("coverage", "json", "-o", "-", path=pathlib.Path(".")),
     ]
 
     assert result == coverage_obj
@@ -43,9 +43,12 @@ def test_get_coverage_info__no_merge(mocker, coverage_json):
         "coverage_comment.subprocess.run", return_value=json.dumps(coverage_json)
     )
 
-    coverage.get_coverage_info(merge=False)
+    coverage.get_coverage_info(merge=False, coverage_path=pathlib.Path("."))
 
-    assert mocker.call("coverage", "combine") not in run.call_args_list
+    assert (
+        mocker.call("coverage", "combine", path=pathlib.Path("."))
+        not in run.call_args_list
+    )
 
 
 def test_get_coverage_info__error_base(mocker, get_logs):
@@ -54,7 +57,7 @@ def test_get_coverage_info__error_base(mocker, get_logs):
     )
 
     with pytest.raises(subprocess.SubProcessError):
-        coverage.get_coverage_info(merge=False)
+        coverage.get_coverage_info(merge=False, coverage_path=pathlib.Path("."))
 
     assert not get_logs("ERROR")
 
@@ -66,7 +69,7 @@ def test_get_coverage_info__error_no_source(mocker, get_logs):
     )
 
     with pytest.raises(subprocess.SubProcessError):
-        coverage.get_coverage_info(merge=False)
+        coverage.get_coverage_info(merge=False, coverage_path=pathlib.Path("."))
 
     assert get_logs("ERROR", "Cannot read")
 
@@ -76,20 +79,35 @@ def test_generate_coverage_html_files(mocker):
         "coverage_comment.subprocess.run",
     )
 
-    coverage.generate_coverage_html_files(path=pathlib.Path("/tmp/foo"))
+    coverage.generate_coverage_html_files(
+        destination=pathlib.Path("/tmp/foo"), coverage_path=pathlib.Path(".")
+    )
 
     assert run.call_args_list == [
-        mocker.call("coverage", "html", "--skip-empty", "--directory", "/tmp/foo"),
+        mocker.call(
+            "coverage",
+            "html",
+            "--skip-empty",
+            "--directory",
+            "/tmp/foo",
+            path=pathlib.Path("."),
+        ),
     ]
 
 
 def test_generate_coverage_markdown(mocker):
     run = mocker.patch("coverage_comment.subprocess.run", return_value="foo")
 
-    result = coverage.generate_coverage_markdown()
+    result = coverage.generate_coverage_markdown(coverage_path=pathlib.Path("."))
 
     assert run.call_args_list == [
-        mocker.call("coverage", "report", "--format=markdown", "--show-missing"),
+        mocker.call(
+            "coverage",
+            "report",
+            "--format=markdown",
+            "--show-missing",
+            path=pathlib.Path("."),
+        ),
     ]
 
     assert result == "foo"
diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py
index cd0da81..5540eab 100644
--- a/tests/unit/test_files.py
+++ b/tests/unit/test_files.py
@@ -73,9 +73,9 @@ def test_get_urls():
 
 
 def test_get_coverage_html_files(mocker, in_tmp_path):
-    def gen_side_effect(path):
-        (path / ".gitignore").touch()
-        (path / "index.html").touch()
+    def gen_side_effect(destination, coverage_path):
+        (destination / ".gitignore").touch()
+        (destination / "index.html").touch()
 
     gen = mocker.patch(
         "coverage_comment.coverage.generate_coverage_html_files",
@@ -83,7 +83,7 @@ def test_get_coverage_html_files(mocker, in_tmp_path):
     )
     gen_dir = in_tmp_path / "gen"
     gen_dir.mkdir()
-    rep = files.get_coverage_html_files(gen_dir=gen_dir)
+    rep = files.get_coverage_html_files(gen_dir=gen_dir, coverage_path=".")
     (source_htmlcov,) = gen_dir.iterdir()
 
     assert rep == files.ReplaceDir(path=pathlib.Path("htmlcov"), source=source_htmlcov)
diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
index 6c07a6a..edab171 100644
--- a/tests/unit/test_settings.py
+++ b/tests/unit/test_settings.py
@@ -37,6 +37,7 @@ def test_config__from_environ__ok():
             "COMMENT_FILENAME": "qux",
             "COMMENT_TEMPLATE": "footemplate",
             "COVERAGE_DATA_BRANCH": "branchname",
+            "COVERAGE_PATH": "source_folder/",
             "MINIMUM_GREEN": "90",
             "MINIMUM_ORANGE": "50.8",
             "MERGE_COVERAGE_FILES": "true",
@@ -58,6 +59,7 @@ def test_config__from_environ__ok():
         COMMENT_FILENAME=pathlib.Path("qux"),
         COMMENT_TEMPLATE="footemplate",
         COVERAGE_DATA_BRANCH="branchname",
+        COVERAGE_PATH=pathlib.Path("source_folder/"),
         MINIMUM_GREEN=decimal.Decimal("90"),
         MINIMUM_ORANGE=decimal.Decimal("50.8"),
         MERGE_COVERAGE_FILES=True,
diff --git a/tests/unit/test_subprocess.py b/tests/unit/test_subprocess.py
index 400f0e0..83893ed 100644
--- a/tests/unit/test_subprocess.py
+++ b/tests/unit/test_subprocess.py
@@ -1,19 +1,25 @@
+import pathlib
+
 import pytest
 
 from coverage_comment import subprocess
 
 
 def test_run__ok():
-    subprocess.run("echo", "yay") == "yay"
+    assert subprocess.run("echo", "yay", path=pathlib.Path(".")).strip() == "yay"
+
+
+def test_run__path():
+    assert subprocess.run("pwd", path=pathlib.Path("/")).strip() == "/"
 
 
 def test_run__kwargs():
-    subprocess.run("pwd", cwd="/") == "/"
+    assert "A=B" in subprocess.run("env", env={"A": "B"}, path=pathlib.Path("."))
 
 
 def test_run__error():
     with pytest.raises(subprocess.SubProcessError):
-        subprocess.run("false")
+        subprocess.run("false", path=pathlib.Path("."))
 
 
 @pytest.fixture
@@ -24,7 +30,7 @@ def environ(mocker):
 def test_git(mocker, environ):
     run = mocker.patch("coverage_comment.subprocess.run")
     git = subprocess.Git()
-    git.cwd = "/tmp"
+    git.cwd = pathlib.Path("/tmp")
     environ["A"] = "B"
 
     git.clone("https://some_address.git", "--depth", "1", text=True)
@@ -38,11 +44,17 @@ def test_git(mocker, environ):
                 "https://some_address.git",
                 "--depth",
                 "1",
-                cwd="/tmp",
+                path=pathlib.Path("/tmp"),
                 text=True,
                 env=mocker.ANY,
             ),
-            mocker.call("git", "add", "some_file", cwd="/tmp", env=mocker.ANY),
+            mocker.call(
+                "git",
+                "add",
+                "some_file",
+                path=pathlib.Path("/tmp"),
+                env=mocker.ANY,
+            ),
         ]
     )
 
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
index 9dfed3a..78a298b 100644
--- a/tests/unit/test_template.py
+++ b/tests/unit/test_template.py
@@ -1,5 +1,6 @@
 import datetime
 import decimal
+import pathlib
 
 import pytest
 
@@ -86,8 +87,8 @@ def test_template_full():
             missing_branches=0,
         ),
         files={
-            "codebase/code.py": coverage.FileCoverage(
-                path="codebase/code.py",
+            pathlib.Path("codebase/code.py"): coverage.FileCoverage(
+                path=pathlib.Path("codebase/code.py"),
                 executed_lines=[1, 2, 5, 6, 9],
                 missing_lines=[],
                 excluded_lines=[],
@@ -103,8 +104,8 @@ def test_template_full():
                     missing_branches=0,
                 ),
             ),
-            "codebase/other.py": coverage.FileCoverage(
-                path="codebase/other.py",
+            pathlib.Path("codebase/other.py"): coverage.FileCoverage(
+                path=pathlib.Path("codebase/other.py"),
                 executed_lines=[1, 2, 3],
                 missing_lines=[],
                 excluded_lines=[],
@@ -129,13 +130,13 @@ def test_template_full():
         total_percent_covered=decimal.Decimal("1"),
         num_changed_lines=39,
         files={
-            "codebase/code.py": coverage.FileDiffCoverage(
-                path="codebase/code.py",
+            pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage(
+                path=pathlib.Path("codebase/code.py"),
                 percent_covered=decimal.Decimal("0.5"),
                 violation_lines=[5],
             ),
-            "codebase/other.py": coverage.FileDiffCoverage(
-                path="codebase/other.py",
+            pathlib.Path("codebase/other.py"): coverage.FileDiffCoverage(
+                path=pathlib.Path("codebase/other.py"),
                 percent_covered=decimal.Decimal("1"),
                 violation_lines=[],
             ),