Repository: py-cov-action/python-coverage-comment-action
Pull request #139 (2023-02-07 23:20:11)

Revert "Revert HTML report"
We're back for more. This PR re-introduces HTML coverage:
- re-revert the code
- and introduces a new strategy for moving the code to the right branch:
	- generating the html report outside of the repo directory
	- then switch to the coverage branch (in a more reliable way)
	- then delete the existing htmlcov dir
	- and finally move the new htmlcov dir in place, git add and commit

I'm going to try and test this better

github-actions[bot]: ## Coverage report
The coverage rate went from `100%` to `100%` :arrow_right:
The branch rate is `100%`.

`100%` of new lines are covered.

<details>
<summary>Diff Coverage details (click to unfold)</summary>

### coverage_comment/coverage.py
`100%` of new lines are covered (`100%` of the complete file).

### coverage_comment/communication.py
`100%` of new lines are covered (`100%` of the complete file).

### coverage_comment/files.py
`100%` of new lines are covered (`100%` of the complete file).

### coverage_comment/template.py
`100%` of new lines are covered (`100%` of the complete file).

### coverage_comment/main.py
`100%` of new lines are covered (`100%` of the complete file).

### coverage_comment/storage.py
`100%` of new lines are covered (`100%` of the complete file).

</details>
<!-- This comment was produced by python-coverage-comment-action -->

Patch:
diff --git a/README.md b/README.md
index ac7f82c..3ed4673 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,9 @@
 
 ## Presentation
 
-Publish diff coverage report as PR comment, and create a coverage badge to
-display on the readme.
+Publish diff coverage report as PR comment, create a coverage badge to
+display on the readme, and a browsable HTML coverage report (hosted in a dedicated
+branch of the repository)
 
 [See this action in action](https://github.com/py-cov-action/python-coverage-comment-action-v3-example)
 
@@ -39,6 +40,8 @@ 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
+- 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)
 
diff --git a/coverage_comment/communication.py b/coverage_comment/communication.py
index abacd31..33a5e6f 100644
--- a/coverage_comment/communication.py
+++ b/coverage_comment/communication.py
@@ -1,97 +1,34 @@
 import pathlib
 
-from coverage_comment import files
-
-README_CONTENTS = """
-# Coverage data
-
-This branch is just here to hold coverage data. It's part of the
-[python-coverage-comment-action](https://github.com/marketplace/actions/python-coverage-comment)
-GitHub Action.
-
-Below are examples of the badges you can use in your main branch `README` file.
-
-### Direct image
-
-[![Coverage badge]({direct_image_url})]({readme_url})
-
-This is the one to use if your repository is private or if you don't want to customize anything.
-""".strip()
-
-PUBLIC_README_CONTENTS = """
-### [Shields.io](https://shields.io) Json Endpoint
-
-[![Coverage badge]({endpoint_image_url})]({readme_url})
-
-Using this one will allow you to [customize](https://shields.io/endpoint) the look of your badge.
-It won't work with private repositories. It won't be refreshed more than once per five minutes.
-
-### [Shields.io](https://shields.io) Dynamic Badge
-
-[![Coverage badge]({dynamic_image_url})]({readme_url})
-
-This one will always be the same color. It won't work for private repos. I'm not even sure why we included it.
-""".strip()
-
-LOG_CONTENTS = """You can use the following URLs to display your badge:
-
-Badge SVG available at:
-    {direct_image_url}""".strip()
-
-PUBLIC_LOG_CONTENTS = """
-Badge from shields endpoint is easier to customize but doesn't work with private repo:
-    {endpoint_image_url}
-
-Badge from shields dynamic url (less useful but you never know):
-    {dynamic_image_url}""".strip()
-
-LOG_CONCLUSION = """
-See more details and ready-to-copy-paste-markdown at {readme_url}""".strip()
+from coverage_comment import files, template
 
 
 def get_readme_and_log(
     image_urls: files.ImageURLs,
     readme_url: str,
+    html_report_url: str,
+    markdown_report: str,
     is_public: bool,
-) -> tuple[files.FileWithPath, str]:
-    """
-    The text we display in the GitHub Actions Log, and what we write in the
-    branch readme when initializing the coverage data branch is quite similar.
-    This function generate both chunks of text with the hope that they'll stay
-    in sync.
-    """
-    readme_contents_parts = [
-        README_CONTENTS.format(
-            readme_url=readme_url,
-            direct_image_url=image_urls["direct"],
-        )
-    ]
-    if is_public:
-        readme_contents_parts.append(
-            PUBLIC_README_CONTENTS.format(
-                readme_url=readme_url,
-                endpoint_image_url=image_urls["endpoint"],
-                dynamic_image_url=image_urls["dynamic"],
-            )
-        )
-    readme = files.FileWithPath(
+) -> tuple[files.WriteFile, str]:
+    readme_markdown = template.get_readme_markdown(
+        is_public=is_public,
+        readme_url=readme_url,
+        markdown_report=markdown_report,
+        html_report_url=html_report_url,
+        direct_image_url=image_urls["direct"],
+        endpoint_image_url=image_urls["endpoint"],
+        dynamic_image_url=image_urls["dynamic"],
+    )
+    log_message = template.get_log_message(
+        is_public=is_public,
+        readme_url=readme_url,
+        html_report_url=html_report_url,
+        direct_image_url=image_urls["direct"],
+        endpoint_image_url=image_urls["endpoint"],
+        dynamic_image_url=image_urls["dynamic"],
+    )
+    readme = files.WriteFile(
         path=pathlib.Path("README.md"),
-        contents="\n\n".join(readme_contents_parts),
+        contents=readme_markdown,
     )
-
-    log_message_parts = [
-        LOG_CONTENTS.format(direct_image_url=image_urls["direct"]),
-    ]
-
-    if is_public:
-        public_part = PUBLIC_LOG_CONTENTS.format(
-            endpoint_image_url=image_urls["endpoint"],
-            dynamic_image_url=image_urls["dynamic"],
-        )
-        log_message_parts.append(public_part)
-
-    log_message_parts.append(LOG_CONCLUSION.format(readme_url=readme_url))
-
-    log_message = "\n\n".join(log_message_parts)
-
     return readme, log_message
diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py
index e9e259e..25c1384 100644
--- a/coverage_comment/coverage.py
+++ b/coverage_comment/coverage.py
@@ -90,6 +90,14 @@ def get_coverage_info(merge: bool) -> Coverage:
     return extract_info(json.loads(json_coverage))
 
 
+def generate_coverage_html_files(path: pathlib.Path) -> None:
+    subprocess.run("coverage", "html", "--skip-empty", "--directory", str(path))
+
+
+def generate_coverage_markdown() -> str:
+    return subprocess.run("coverage", "report", "--format=markdown", "--show-missing")
+
+
 def extract_info(data) -> Coverage:
     """
     {
diff --git a/coverage_comment/files.py b/coverage_comment/files.py
index ef321f4..ba76cb5 100644
--- a/coverage_comment/files.py
+++ b/coverage_comment/files.py
@@ -6,30 +6,62 @@ import dataclasses
 import decimal
 import json
 import pathlib
+import shutil
+import tempfile
 from collections.abc import Callable
-from typing import TypedDict
+from typing import Protocol, TypedDict
 
 import httpx
 
-from coverage_comment import badge
+from coverage_comment import badge, coverage, log
 
 ENDPOINT_PATH = pathlib.Path("endpoint.json")
 DATA_PATH = pathlib.Path("data.json")
 BADGE_PATH = pathlib.Path("badge.svg")
 
 
+class Operation(Protocol):
+    path: pathlib.Path
+
+    def apply(self):
+        ...
+
+
 @dataclasses.dataclass
-class FileWithPath:
+class WriteFile:
     path: pathlib.Path
     contents: str
 
+    def apply(self):
+        preview_len = 50
+        ellipsis = "..." if len(self.contents) > preview_len else ""
+        log.debug(f"Writing file {self.path} ({self.contents[:preview_len]}{ellipsis})")
+        self.path.write_text(self.contents)
+
+
+@dataclasses.dataclass
+class ReplaceDir:
+    """
+    Deletes the dir at `path`, then copies the dir from source to destination
+    """
+
+    source: pathlib.Path
+    path: pathlib.Path
+
+    def apply(self):
+        if self.path.exists():
+            log.debug(f"Deleting {self.path}")
+            shutil.rmtree(self.path)
+        log.debug(f"Moving {self.source} to {self.path}")
+        shutil.move(self.source, self.path)
+
 
 def compute_files(
     line_rate: decimal.Decimal,
     minimum_green: decimal.Decimal,
     minimum_orange: decimal.Decimal,
     http_session: httpx.Client,
-) -> list[FileWithPath]:
+) -> list[Operation]:
     line_rate *= decimal.Decimal("100")
     color = badge.get_badge_color(
         rate=line_rate,
@@ -37,17 +69,17 @@ def compute_files(
         minimum_orange=minimum_orange,
     )
     return [
-        FileWithPath(
+        WriteFile(
             path=ENDPOINT_PATH,
             contents=badge.compute_badge_endpoint_data(
                 line_rate=line_rate, color=color
             ),
         ),
-        FileWithPath(
+        WriteFile(
             path=DATA_PATH,
             contents=compute_datafile(line_rate=line_rate),
         ),
-        FileWithPath(
+        WriteFile(
             path=BADGE_PATH,
             contents=badge.compute_badge_image(
                 line_rate=line_rate, color=color, http_session=http_session
@@ -78,3 +110,12 @@ def get_urls(url_getter: Callable) -> ImageURLs:
         "endpoint": badge.get_endpoint_url(endpoint_url=url_getter(path=ENDPOINT_PATH)),
         "dynamic": badge.get_dynamic_url(endpoint_url=url_getter(path=ENDPOINT_PATH)),
     }
+
+
+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)
+    dest = pathlib.Path("htmlcov")
+    # Coverage may or may not create a .gitignore.
+    (source / ".gitignore").unlink(missing_ok=True)
+    return ReplaceDir(source=source, path=dest)
diff --git a/coverage_comment/main.py b/coverage_comment/main.py
index 1ae4254..0bb2393 100644
--- a/coverage_comment/main.py
+++ b/coverage_comment/main.py
@@ -87,7 +87,7 @@ def action(
             )
         else:
             # event_name == "push"
-            return save_badge(
+            return save_coverage_data_files(
                 config=config,
                 coverage=coverage,
                 github_session=github_session,
@@ -125,11 +125,11 @@ def generate_comment(
         previous_coverage = files.parse_datafile(contents=previous_coverage_data_file)
 
     try:
-        comment = template.get_markdown_comment(
+        comment = template.get_comment_markdown(
             coverage=coverage,
             diff_coverage=diff_coverage,
             previous_coverage_rate=previous_coverage,
-            base_template=template.read_template_file(),
+            base_template=template.read_template_file("comment.md.j2"),
             custom_template=config.COMMENT_TEMPLATE,
         )
     except template.MissingMarker:
@@ -240,7 +240,7 @@ def post_comment(config: settings.Config, github_session: httpx.Client) -> int:
     return 0
 
 
-def save_badge(
+def save_coverage_data_files(
     config: settings.Config,
     coverage: coverage_module.Coverage,
     github_session: httpx.Client,
@@ -259,33 +259,47 @@ def save_badge(
         log.info("Skipping badge save as we're not on the default branch")
         return 0
 
-    log.info("Saving coverage files & badge into the repository")
-    files_to_save = files.compute_files(
+    log.info("Computing coverage files & badge")
+    operations: list[files.Operation] = files.compute_files(
         line_rate=coverage.info.percent_covered,
         minimum_green=config.MINIMUM_GREEN,
         minimum_orange=config.MINIMUM_ORANGE,
         http_session=http_session,
     )
+
     is_public = repo_info.is_public()
+    if is_public:
+        log.info("Generating HTML coverage report")
+        operations.append(files.get_coverage_html_files())
+
+    markdown_report = coverage_module.generate_coverage_markdown()
+
     url_getter = functools.partial(
-        storage.get_file_url,
+        storage.get_raw_file_url,
         is_public=is_public,
         repository=config.GITHUB_REPOSITORY,
         branch=config.COVERAGE_DATA_BRANCH,
     )
+    readme_url = storage.get_repo_file_url(
+        branch=config.COVERAGE_DATA_BRANCH,
+        repository=config.GITHUB_REPOSITORY,
+    )
+    html_report_url = storage.get_html_report_url(
+        branch=config.COVERAGE_DATA_BRANCH,
+        repository=config.GITHUB_REPOSITORY,
+    )
     readme_file, log_message = communication.get_readme_and_log(
-        readme_url=storage.get_readme_url(
-            branch=config.COVERAGE_DATA_BRANCH,
-            repository=config.GITHUB_REPOSITORY,
-        ),
-        image_urls=files.get_urls(url_getter=url_getter),
         is_public=is_public,
+        readme_url=readme_url,
+        image_urls=files.get_urls(url_getter=url_getter),
+        html_report_url=html_report_url,
+        markdown_report=markdown_report,
     )
-    storage.upload_files(
-        files=files_to_save,
+    operations.append(readme_file)
+    storage.commit_operations(
+        operations=operations,
         git=git,
         branch=config.COVERAGE_DATA_BRANCH,
-        initial_file=readme_file,
     )
 
     log.info(log_message)
diff --git a/coverage_comment/storage.py b/coverage_comment/storage.py
index f671a48..36b102f 100644
--- a/coverage_comment/storage.py
+++ b/coverage_comment/storage.py
@@ -21,26 +21,7 @@ COMMIT_ENVIRONMENT = {
     "GIT_COMMITTER_NAME": GITHUB_ACTIONS_BOT_NAME,
     "GIT_COMMITTER_EMAIL": GITHUB_ACTIONS_BOT_EMAIL,
 }
-INITIAL_GIT_COMMIT_MESSAGE = "Initialize python-coverage-comment-action special branch"
-GIT_COMMIT_MESSAGE = "Update badge"
-
-
-def initialize_branch(
-    git: subprocess.Git,
-    branch: str,
-    initial_file: files.FileWithPath,
-):
-    log.info(f"Creating branch {branch}")
-    git.checkout("--orphan", branch)
-    git.reset("--hard")
-
-    initial_file.path.write_text(initial_file.contents)
-    git.add(str(initial_file.path))
-    git.commit(
-        "--message",
-        INITIAL_GIT_COMMIT_MESSAGE,
-        env=COMMIT_ENVIRONMENT,
-    )
+GIT_COMMIT_MESSAGE = "Update coverage data"
 
 
 @contextlib.contextmanager
@@ -52,54 +33,63 @@ def checked_out_branch(git: subprocess.Git, branch: str):
 
     log.debug(f"Current checkout is {current_checkout}")
 
-    git.fetch()
+    log.debug("Resetting all changes")
+    # Goodbye `.coverage` file.
+    git.reset("--hard")
 
-    branch_exists = True
     try:
-        git.checkout(branch)
-        log.debug(f"Branch {branch} exist.")
+        git.fetch("origin", branch)
     except subprocess.SubProcessError:
+        # Branch seems to no exist, OR fetch failed for a different reason.
+        # Let's make sure:
+        # 1/ Fetch again, but this time all the remote
+        git.fetch("origin")
+        # 2/ And check that our branch really doesn't exist
+        try:
+            git.rev_parse("--verify", f"origin/{branch}")
+        except subprocess.SubProcessError:
+            # Ok good, the branch really doesn't exist.
+            pass
+        else:
+            # Ok, our branch exist, but we failed to fetch it. Let's raise.
+            raise
         log.debug(f"Branch {branch} doesn't exist.")
-        branch_exists = False
+        log.info(f"Creating branch {branch}")
+        git.switch("--orphan", branch)
+    else:
+        log.debug(f"Branch {branch} exist.")
+        git.switch(branch)
 
     try:
-        yield branch_exists
+        yield
     finally:
         log.debug(f"Back to checkout of {current_checkout}")
-        git.checkout(current_checkout)
+        git.switch(current_checkout)
 
 
-def upload_files(
-    files: list[files.FileWithPath],
+def commit_operations(
+    operations: list[files.Operation],
     git: subprocess.Git,
     branch: str,
-    initial_file: files.FileWithPath,
 ):
     """
     Store the given files.
 
     Parameters
     ----------
-    files : list[files.FileWithPath]
-        Files to store
+    operations : list[files.Operation]
+        File operations to process
     git : subprocess.Git
         Git actor
     branch : str
         branch on which to store the files
-    initial_file : files.FileWithPath
+    initial_file : files.Operation
         In case the branch didn't exist, initialize it with this initial file.
     """
-    with checked_out_branch(git=git, branch=branch) as branch_exists:
-        if not branch_exists:
-            initialize_branch(
-                git=git,
-                branch=branch,
-                initial_file=initial_file,
-            )
-
-        for file in files:
-            file.path.write_text(file.contents)
-            git.add(str(file.path))
+    with checked_out_branch(git=git, branch=branch):
+        for op in operations:
+            op.apply()
+            git.add(str(op.path))
 
         try:
             git.diff("--staged", "--exit-code")
@@ -134,7 +124,7 @@ def get_datafile_contents(
     return base64.b64decode(response.content).decode()
 
 
-def get_file_url(
+def get_raw_file_url(
     repository: str,
     branch: str,
     path: pathlib.Path,
@@ -157,8 +147,26 @@ def get_file_url(
     # seconds.
 
 
-def get_readme_url(repository: str, branch: str) -> str:
-    return f"https://github.com/{repository}/tree/{branch}"
+def get_repo_file_url(repository: str, branch: str, path: str = "/") -> str:
+    """
+    Computes the GitHub Web UI URL for a given path:
+    If the path is empty or ends with a slash, it will be interpreted as a folder,
+    so the URL will point to the page listing files and displaying the README.
+    Otherwise, the URL will point to the page displaying the file contents within
+    the UI.
+    Leading and trailing slashes in path are removed from the final URL.
+    """
+    # See test_get_repo_file_url for precise specifications
+    path = "/" + path.lstrip("/")
+    part = "tree" if path.endswith("/") else "blob"
+    return f"https://github.com/{repository}/{part}/{branch}{path}".rstrip("/")
+
+
+def get_html_report_url(repository: str, branch: str) -> str:
+    readme_url = get_repo_file_url(
+        repository=repository, branch=branch, path="/htmlcov/index.html"
+    )
+    return f"https://htmlpreview.github.io/?{readme_url}"
 
 
 def fix_ownership_issues(git: subprocess.Git):
diff --git a/coverage_comment/template.py b/coverage_comment/template.py
index 5001902..79004e0 100644
--- a/coverage_comment/template.py
+++ b/coverage_comment/template.py
@@ -39,7 +39,7 @@ class TemplateError(Exception):
     pass
 
 
-def get_markdown_comment(
+def get_comment_markdown(
     coverage: coverage_module.Coverage,
     diff_coverage: coverage_module.DiffCoverage,
     previous_coverage_rate: decimal.Decimal | None,
@@ -66,8 +66,52 @@ def get_markdown_comment(
     return comment
 
 
-def read_template_file() -> str:
-    return resources.read_text("coverage_comment", "default.md.j2")
+def get_readme_markdown(
+    is_public: bool,
+    readme_url: str,
+    markdown_report: str,
+    direct_image_url: str,
+    html_report_url: str | None,
+    dynamic_image_url: str | None,
+    endpoint_image_url: str | None,
+):
+    env = SandboxedEnvironment()
+    template = jinja2.Template(read_template_file("readme.md.j2"))
+    return env.get_template(template).render(
+        is_public=is_public,
+        readme_url=readme_url,
+        markdown_report=markdown_report,
+        direct_image_url=direct_image_url,
+        html_report_url=html_report_url,
+        dynamic_image_url=dynamic_image_url,
+        endpoint_image_url=endpoint_image_url,
+    )
+
+
+def get_log_message(
+    is_public: bool,
+    readme_url: str,
+    direct_image_url: str,
+    html_report_url: str | None,
+    dynamic_image_url: str | None,
+    endpoint_image_url: str | None,
+):
+    env = SandboxedEnvironment()
+    template = jinja2.Template(read_template_file("log.txt.j2"))
+    return env.get_template(template).render(
+        is_public=is_public,
+        html_report_url=html_report_url,
+        direct_image_url=direct_image_url,
+        endpoint_image_url=endpoint_image_url,
+        dynamic_image_url=dynamic_image_url,
+        readme_url=readme_url,
+    )
+
+
+def read_template_file(template: str) -> str:
+    return (
+        resources.files("coverage_comment") / "template_files" / template
+    ).read_text()
 
 
 def pct(val: decimal.Decimal | float) -> str:
diff --git a/coverage_comment/default.md.j2 b/coverage_comment/template_files/comment.md.j2
similarity index 100%
rename from coverage_comment/default.md.j2
rename to coverage_comment/template_files/comment.md.j2
diff --git a/coverage_comment/template_files/log.txt.j2 b/coverage_comment/template_files/log.txt.j2
new file mode 100644
index 0000000..0b69f09
--- /dev/null
+++ b/coverage_comment/template_files/log.txt.j2
@@ -0,0 +1,20 @@
+{% if is_public -%}
+You can browse the full coverage report at:
+    {{ html_report_url }}
+
+{% endif -%}
+You can use the following URLs to display your badge:
+
+- Badge SVG available at:
+    {{ direct_image_url }}
+
+{% if is_public -%}
+- Badge from shields endpoint is easier to customize but doesn't work with private repo:
+    {{ endpoint_image_url }}
+
+- Badge from shields dynamic url (less useful but you never know):
+    {{ dynamic_image_url }}
+
+{% endif -%}
+See more details and ready-to-copy-paste-markdown at:
+    {{ readme_url }}
diff --git a/coverage_comment/template_files/readme.md.j2 b/coverage_comment/template_files/readme.md.j2
new file mode 100644
index 0000000..8f85075
--- /dev/null
+++ b/coverage_comment/template_files/readme.md.j2
@@ -0,0 +1,39 @@
+# Repository Coverage
+
+{% if is_public -%}
+[Full report]({{ html_report_url }})
+{%- endif %}
+
+{{ markdown_report }}
+
+## Setup coverage badge
+
+Below are examples of the badges you can use in your main branch `README` file.
+
+### Direct image
+
+[![Coverage badge]({{ direct_image_url }})]({{ html_report_url if is_public else readme_url }})
+
+This is the one to use if your repository is private or if you don't want to customize anything.
+
+{% if is_public -%}
+### [Shields.io](https://shields.io) Json Endpoint
+
+[![Coverage badge]({{ endpoint_image_url }})]({{html_report_url}})
+
+Using this one will allow you to [customize](https://shields.io/endpoint) the look of your badge.
+It won't work with private repositories. It won't be refreshed more than once per five minutes.
+
+### [Shields.io](https://shields.io) Dynamic Badge
+
+[![Coverage badge]({{ dynamic_image_url }})]({{ html_report_url }})
+
+This one will always be the same color. It won't work for private repos. I'm not even sure why we included it.
+{%- endif %}
+
+## What is that?
+
+This branch is part of the
+[python-coverage-comment-action](https://github.com/marketplace/actions/python-coverage-comment)
+GitHub Action. All the files in this branch are automatically generated and may be
+overwritten at any moment.
diff --git a/dev-env b/dev-env
index 28ad73b..c102073 100755
--- a/dev-env
+++ b/dev-env
@@ -51,7 +51,7 @@ function create-repo(){
 }
 
 function delete-repo(){
-    gh repo delete "${GITHUB_REPOSITORY}" --confirm
+    gh repo delete "${GITHUB_REPOSITORY}"
     if [ "${TMPDIR}" != "" ]; then
         rm -rf ${TMPDIR}/* ${TMPDIR}/.*
     fi
diff --git a/setup.cfg b/setup.cfg
index 5446b2d..adf8c25 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -17,6 +17,10 @@ ignore_missing_imports = True
 [coverage:run]
 relative_files = true
 
+[coverage:report]
+exclude_lines =
+    \.\.\.
+
 [tool:pytest]
 addopts =
     --cov-report term-missing --cov-branch --cov-report html --cov-report term


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 6a3fa9a..e616776 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -291,7 +291,7 @@ def diff_coverage_obj():
 
 
 @pytest.fixture
-def session():
+def session(is_failed):
     """
     You get a session object. Register responses on it:
         session.register(method="GET", path="/a/b")(status_code=200)
@@ -354,7 +354,8 @@ def session():
 
     session = Session()
     yield session
-    assert not session.responses
+    if not is_failed:
+        assert not session.responses
 
 
 @pytest.fixture
@@ -400,7 +401,7 @@ def zip_bytes():
 
 
 @pytest.fixture
-def git():
+def git(is_failed):
     """
     You get a git object. Register calls on it:
         git.register("git checkout master")(exit_code=1)
@@ -443,7 +444,8 @@ def git():
 
     git = Git()
     yield git
-    assert not git.expected_calls
+    if not is_failed:
+        assert not git.expected_calls
 
 
 @pytest.fixture
@@ -452,3 +454,22 @@ def output_file(tmp_path):
     file.touch()
 
     return file
+
+
+_is_failed = []
+
+
+def pytest_runtest_logreport(report):
+    if report.outcome == "failed":
+        _is_failed.append(True)
+
+
+@pytest.fixture
+def is_failed():
+    _is_failed.clear()
+
+    def f():
+        return bool(_is_failed)
+
+    yield f
+    _is_failed.clear()
diff --git a/tests/end_to_end/conftest.py b/tests/end_to_end/conftest.py
index bc3a6d1..238b478 100644
--- a/tests/end_to_end/conftest.py
+++ b/tests/end_to_end/conftest.py
@@ -231,7 +231,7 @@ def gh_delete_repo(repo_name):
     def f(gh):
         try:
             print(f"Deleting repository {repo_name}")
-            gh("repo", "delete", repo_name, "--confirm")
+            gh("repo", "delete", repo_name, "--yes")
         except subprocess.CalledProcessError:
             pass
 
@@ -286,9 +286,9 @@ def gh_create_fork(is_failed, gh_delete_repo, gh_other, gh_me_username, repo_nam
 
 
 @pytest.fixture
-def head_sha1(git):
-    def _():
-        return git("rev-parse", "HEAD").strip()
+def get_sha1(git):
+    def _(spec="HEAD"):
+        return git("rev-parse", spec).strip()
 
     return _
 
@@ -359,28 +359,9 @@ def wait_for_run_triggered_by_user_to_start():
 def add_coverage_line(git):
     def f(line):
         csv_file = pathlib.Path("tests/cases.csv")
-        csv_file.write_text(csv_file.read_text() + line)
+        csv_file.write_text(csv_file.read_text() + line + "\n")
 
         git("add", str(csv_file))
         git("commit", "-m", "improve coverage")
 
     return f
-
-
-_is_failed = []
-
-
-def pytest_runtest_logreport(report):
-    if report.outcome == "failed":
-        _is_failed.append(True)
-
-
-@pytest.fixture
-def is_failed():
-    _is_failed.clear()
-
-    def f():
-        return bool(_is_failed)
-
-    yield f
-    _is_failed.clear()
diff --git a/tests/end_to_end/test_all.py b/tests/end_to_end/test_all.py
index e92de73..9f87c49 100644
--- a/tests/end_to_end/test_all.py
+++ b/tests/end_to_end/test_all.py
@@ -9,7 +9,7 @@ def test_public_repo(
     gh_create_repo,
     wait_for_run_to_start,
     wait_for_run_triggered_by_user_to_start,
-    head_sha1,
+    get_sha1,
     gh_me,
     gh_other,
     repo_full_name,
@@ -25,7 +25,7 @@ def test_public_repo(
     gh_create_repo("--public")
 
     # GitHub Actions should start soon
-    run_id = wait_for_run_to_start(sha1=head_sha1(), branch="main", gh=gh_me)
+    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.
@@ -50,27 +50,30 @@ def test_public_repo(
     # The easiest way to check the links is to assume there will be no other
     # line with a link prefixed by 4 spaces. If at some point there is, we'll
     # change the test.
-    links = [line.strip().split()[-1] for line in log_lines if "    https://" in line]
-    assert len(links) == 3
+    links = {line.strip().split()[-1] for line in log_lines if "    https://" in line}
+    # - html report
+    # - badge 1, 2 and 3
+    # - coverage branch readme url
+    assert len(links) == 5
 
     client = httpx.Client()
 
-    # Check that all 3 links are valid and lead to a 200
+    # Check that all 5 links are valid and lead to a 200
     # It's made this way to avoid hardcoding links in the test, because I assume
     # they'll be evolving.
+    number_of_svgs = 0
     for link in links:
         response = client.get(link)
         response.raise_for_status()
-        assert response.text.startswith("<svg")
+        number_of_svgs += int(response.text.startswith("<svg"))
+
+    assert number_of_svgs == 3
 
     # Check that logs point to the branch that has the readme file.
     data_branch_url = (
         f"https://github.com/{repo_full_name}/tree/python-coverage-comment-action-data"
     )
-    assert data_branch_url in logs
-
-    # Also, at this point, the branch should really exist.
-    client.get(data_branch_url).raise_for_status()
+    assert data_branch_url in links
 
     # Time to check the Readme contents
     raw_url_prefix = (
@@ -82,7 +85,7 @@ def test_public_repo(
     response.raise_for_status()
     # And all previously found links should be present
     readme = response.text
-    for link in links:
+    for link in links - {data_branch_url}:
         assert link in readme
 
     # And while we're at it, there are 2 other files we want to check in this
@@ -101,7 +104,7 @@ def test_public_repo(
         git("checkout", "-b", "new_branch")
         add_coverage_line("a,b,c,,a-b-c")
         git("push", "origin", "new_branch", env={"GITHUB_TOKEN": token_me})
-        sha1 = head_sha1()
+        sha1 = get_sha1()
 
     gh_me("pr", "create", "--fill")
 
@@ -121,13 +124,22 @@ def test_public_repo(
     )
     assert ":arrow_up:" in comment
 
+    # Let's merge the PR and see if everything works fine
+    gh_me("pr", "merge", "1", "--merge")
+    git("fetch", env={"GITHUB_TOKEN": token_me})
+
+    run_id = wait_for_run_to_start(
+        sha1=get_sha1("origin/main"), branch="main", gh=gh_me
+    )
+    gh_me("run", "watch", run_id, "--exit-status")
+
     # And now let's create a PR from a fork of a different user
-    gh_create_fork()
     with cd("fork"):
+        gh_create_fork()
         git("checkout", "-b", "external_branch")
-        add_coverage_line("a,b,c,,a-b-c")
+        add_coverage_line("a,,c,d,a-c-d")
         git("push", "origin", "external_branch", env={"GITHUB_TOKEN": token_other})
-        ext_sha1 = head_sha1()
+        ext_sha1 = get_sha1()
 
     full_branch_name = f"{gh_other_username}:external_branch"
     gh_other("pr", "create", "--fill", "--head", full_branch_name)
@@ -171,7 +183,7 @@ def test_public_repo(
 def test_private_repo(
     gh_create_repo,
     wait_for_run_to_start,
-    head_sha1,
+    get_sha1,
     gh_me,
     repo_full_name,
     cd,
@@ -183,7 +195,7 @@ def test_private_repo(
     gh_create_repo("--private")
 
     # Actions will start soon
-    run_id = wait_for_run_to_start(sha1=head_sha1(), branch="main", gh=gh_me)
+    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.
@@ -239,7 +251,7 @@ def test_private_repo(
         git("checkout", "-b", "new_branch")
         add_coverage_line("a,b,c,,a-b-c")
         git("push", "origin", "new_branch", env={"GITHUB_TOKEN": token_me})
-        sha1 = head_sha1()
+        sha1 = get_sha1()
 
     gh_me("pr", "create", "--fill")
 
@@ -256,3 +268,13 @@ def test_private_repo(
         fail_value="\n",
     )
     assert ":arrow_up:" in comment
+
+    # Let's merge the PR and see if everything works fine
+    gh_me("pr", "merge", "1", "--merge")
+
+    git("fetch", env={"GITHUB_TOKEN": token_me})
+
+    run_id = wait_for_run_to_start(
+        sha1=get_sha1("origin/main"), branch="main", gh=gh_me
+    )
+    gh_me("run", "watch", run_id, "--exit-status")
diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py
index c43fddc..a423186 100644
--- a/tests/integration/test_main.py
+++ b/tests/integration/test_main.py
@@ -318,15 +318,18 @@ def test_action__push__default_branch(
     )(text="<this is a svg badge>")
 
     git.register("git branch --show-current")(stdout="foo")
-    git.register("git fetch")()
-    git.register("git checkout python-coverage-comment-action-data")()
+    git.register("git reset --hard")()
+    git.register("git fetch origin python-coverage-comment-action-data")()
+    git.register("git switch python-coverage-comment-action-data")()
     git.register("git add endpoint.json")()
     git.register("git add data.json")()
     git.register("git add badge.svg")()
+    git.register("git add htmlcov")()
+    git.register("git add README.md")()
     git.register("git diff --staged --exit-code")(exit_code=1)
-    git.register("git commit --message Update badge")()
+    git.register("git commit --message Update coverage data")()
     git.register("git push origin python-coverage-comment-action-data")()
-    git.register("git checkout foo")()
+    git.register("git switch foo")()
 
     result = main.action(
         config=push_config(),
@@ -337,21 +340,71 @@ def test_action__push__default_branch(
     assert result == 0
 
     assert not get_logs("INFO", "Skipping badge")
-    assert get_logs("INFO", "Saving coverage files & badge into the repository")
+    assert get_logs("INFO", "Saving coverage files")
 
     log = get_logs("INFO", "Badge SVG available at")[0]
-    expected = """You can use the following URLs to display your badge:
+    expected = """You can browse the full coverage report at:
+    https://htmlpreview.github.io/?https://github.com/py-cov-action/foobar/blob/python-coverage-comment-action-data/htmlcov/index.html
+
+You can use the following URLs to display your badge:
 
-Badge SVG available at:
+- Badge SVG available at:
     https://raw.githubusercontent.com/py-cov-action/foobar/python-coverage-comment-action-data/badge.svg
 
-Badge from shields endpoint is easier to customize but doesn't work with private repo:
+- Badge from shields endpoint is easier to customize but doesn't work with private repo:
     https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/py-cov-action/foobar/python-coverage-comment-action-data/endpoint.json
 
-Badge from shields dynamic url (less useful but you never know):
+- Badge from shields dynamic url (less useful but you never know):
     https://img.shields.io/badge/dynamic/json?color=brightgreen&label=coverage&query=%24.message&url=https%3A%2F%2Fraw.githubusercontent.com%2Fpy-cov-action%2Ffoobar%2Fpython-coverage-comment-action-data%2Fendpoint.json
 
-See more details and ready-to-copy-paste-markdown at https://github.com/py-cov-action/foobar/tree/python-coverage-comment-action-data"""
+See more details and ready-to-copy-paste-markdown at:
+    https://github.com/py-cov-action/foobar/tree/python-coverage-comment-action-data"""
+    assert log == expected
+
+
+def test_action__push__default_branch__private(
+    push_config, session, in_integration_env, get_logs, git
+):
+    session.register("GET", "/repos/py-cov-action/foobar")(
+        json={"default_branch": "main", "visibility": "private"}
+    )
+    session.register(
+        "GET",
+        "https://img.shields.io/static/v1?label=Coverage&message=85%25&color=orange",
+    )(text="<this is a svg badge>")
+
+    git.register("git branch --show-current")(stdout="foo")
+    git.register("git reset --hard")()
+    git.register("git fetch origin python-coverage-comment-action-data")()
+    git.register("git switch python-coverage-comment-action-data")()
+    git.register("git add endpoint.json")()
+    git.register("git add data.json")()
+    git.register("git add badge.svg")()
+    git.register("git add README.md")()
+    git.register("git diff --staged --exit-code")(exit_code=1)
+    git.register("git commit --message Update coverage data")()
+    git.register("git push origin python-coverage-comment-action-data")()
+    git.register("git switch foo")()
+
+    result = main.action(
+        config=push_config(),
+        github_session=session,
+        http_session=session,
+        git=git,
+    )
+    assert result == 0
+
+    assert not get_logs("INFO", "Skipping badge")
+    assert get_logs("INFO", "Saving coverage files")
+
+    log = get_logs("INFO", "Badge SVG available at")[0]
+    expected = """You can use the following URLs to display your badge:
+
+- Badge SVG available at:
+    https://github.com/py-cov-action/foobar/raw/python-coverage-comment-action-data/badge.svg
+
+See more details and ready-to-copy-paste-markdown at:
+    https://github.com/py-cov-action/foobar/tree/python-coverage-comment-action-data"""
     assert log == expected
 
 
diff --git a/tests/unit/test_communication.py b/tests/unit/test_communication.py
index ec8243b..7fdb2b0 100644
--- a/tests/unit/test_communication.py
+++ b/tests/unit/test_communication.py
@@ -1,36 +1,69 @@
-import pytest
-
 from coverage_comment import communication
 
 
-@pytest.mark.parametrize("is_public", [True, False])
-def test_get_readme_and_log(is_public):
+def test_get_readme_and_log__public():
+    readme_file, log = communication.get_readme_and_log(
+        is_public=True,
+        image_urls={
+            "direct": "https://a",
+            "endpoint": "https://b",
+            "dynamic": "https://c",
+        },
+        readme_url="https://readme",
+        html_report_url="https://html_report",
+        markdown_report="**Hello report!**",
+    )
+
+    readme = readme_file.contents
+
+    assert str(readme_file.path) == "README.md"
+
+    assert "# Repository Coverage" in readme
+
+    assert "[![Coverage badge](https://a)](https://html_report)" in readme
+
+    assert "[![Coverage badge](https://b)](https://html_report)" in readme
+
+    assert "[![Coverage badge](https://c)](https://html_report)" in readme
+
+    assert "https://a" in log
+
+    assert "https://readme" in log
+
+    assert "https://b" in log
+
+    assert "https://c" in log
+
+
+def test_get_readme_and_log__private():
     readme_file, log = communication.get_readme_and_log(
+        is_public=False,
         image_urls={
             "direct": "https://a",
             "endpoint": "https://b",
             "dynamic": "https://c",
         },
         readme_url="https://readme",
-        is_public=is_public,
+        html_report_url="https://html_report",
+        markdown_report="**Hello report!**",
     )
 
     readme = readme_file.contents
 
     assert str(readme_file.path) == "README.md"
 
-    assert "# Coverage data" in readme
+    assert "# Repository Coverage" in readme
 
     assert "[![Coverage badge](https://a)](https://readme)" in readme
 
-    assert ("[![Coverage badge](https://b)](https://readme)" in readme) is (is_public)
+    assert "https://b" not in readme
 
-    assert ("[![Coverage badge](https://c)](https://readme)" in readme) is (is_public)
+    assert "https://c" not in readme
 
     assert "https://a" in log
 
     assert "https://readme" in log
 
-    assert ("https://b" in log) is (is_public)
+    assert "https://b" not in log
 
-    assert ("https://c" in log) is (is_public)
+    assert "https://c" not in log
diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py
index 5c6e1f7..c6ebb8b 100644
--- a/tests/unit/test_coverage.py
+++ b/tests/unit/test_coverage.py
@@ -1,5 +1,6 @@
 import decimal
 import json
+import pathlib
 
 import pytest
 
@@ -68,3 +69,27 @@ def test_get_coverage_info__error_no_source(mocker, get_logs):
         coverage.get_coverage_info(merge=False)
 
     assert get_logs("ERROR", "Cannot read")
+
+
+def test_generate_coverage_html_files(mocker):
+    run = mocker.patch(
+        "coverage_comment.subprocess.run",
+    )
+
+    coverage.generate_coverage_html_files(path=pathlib.Path("/tmp/foo"))
+
+    assert run.call_args_list == [
+        mocker.call("coverage", "html", "--skip-empty", "--directory", "/tmp/foo"),
+    ]
+
+
+def test_generate_coverage_markdown(mocker):
+    run = mocker.patch("coverage_comment.subprocess.run", return_value="foo")
+
+    result = coverage.generate_coverage_markdown()
+
+    assert run.call_args_list == [
+        mocker.call("coverage", "report", "--format=markdown", "--show-missing"),
+    ]
+
+    assert result == "foo"
diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py
index 152314d..cd0da81 100644
--- a/tests/unit/test_files.py
+++ b/tests/unit/test_files.py
@@ -4,6 +4,26 @@ import pathlib
 from coverage_comment import files
 
 
+def test_write_file(tmp_path):
+    files.WriteFile(path=tmp_path / "a", contents="foo").apply()
+
+    assert (tmp_path / "a").read_text() == "foo"
+
+
+def test_replace_dir(tmp_path):
+    (tmp_path / "foo").mkdir()
+    (tmp_path / "foo/foofile").touch()
+    (tmp_path / "bar").mkdir()
+    (tmp_path / "bar/barfile").touch()
+
+    files.ReplaceDir(path=(tmp_path / "bar"), source=(tmp_path / "foo")).apply()
+
+    assert not (tmp_path / "foo").exists()
+    assert (tmp_path / "bar").exists()
+    assert (tmp_path / "bar/foofile").exists()
+    assert not (tmp_path / "bar/barfile").exists()
+
+
 def test_compute_files(session):
     session.register(
         "GET", "https://img.shields.io/static/v1?label=Coverage&message=12%25&color=red"
@@ -16,14 +36,12 @@ def test_compute_files(session):
         http_session=session,
     )
     expected = [
-        files.FileWithPath(
+        files.WriteFile(
             path=pathlib.Path("endpoint.json"),
             contents='{"schemaVersion": 1, "label": "Coverage", "message": "12%", "color": "red"}',
         ),
-        files.FileWithPath(
-            path=pathlib.Path("data.json"), contents='{"coverage": 12.34}'
-        ),
-        files.FileWithPath(path=pathlib.Path("badge.svg"), contents="foo"),
+        files.WriteFile(path=pathlib.Path("data.json"), contents='{"coverage": 12.34}'),
+        files.WriteFile(path=pathlib.Path("badge.svg"), contents="foo"),
     ]
     assert result == expected
 
@@ -52,3 +70,23 @@ def test_get_urls():
         "dynamic": "https://img.shields.io/badge/dynamic/json?color=brightgreen&label=coverage&query=%24.message&url=https%3A%2F%2Fendpoint.json",
         "endpoint": "https://img.shields.io/endpoint?url=https://endpoint.json",
     }
+
+
+def test_get_coverage_html_files(mocker, in_tmp_path):
+    def gen_side_effect(path):
+        (path / ".gitignore").touch()
+        (path / "index.html").touch()
+
+    gen = mocker.patch(
+        "coverage_comment.coverage.generate_coverage_html_files",
+        side_effect=gen_side_effect,
+    )
+    gen_dir = in_tmp_path / "gen"
+    gen_dir.mkdir()
+    rep = files.get_coverage_html_files(gen_dir=gen_dir)
+    (source_htmlcov,) = gen_dir.iterdir()
+
+    assert rep == files.ReplaceDir(path=pathlib.Path("htmlcov"), source=source_htmlcov)
+
+    assert gen.called is True
+    assert not (source_htmlcov / "gitignore").exists()
diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py
index 4d02191..c7f8c9f 100644
--- a/tests/unit/test_storage.py
+++ b/tests/unit/test_storage.py
@@ -3,134 +3,127 @@ import pathlib
 
 import pytest
 
-from coverage_comment import files, storage
+from coverage_comment import files, storage, subprocess
 
 
-def test_initialize_branch(git, tmp_path):
-    readme_path = tmp_path / "readme.txt"
-    git.register("git checkout --orphan foo")()
+def test_checked_out_branch(git):
+    git.register("git branch --show-current")(stdout="bar")
     git.register("git reset --hard")()
-    git.register(f"git add {readme_path}")()
-    git.register(
-        "git commit --message Initialize python-coverage-comment-action special branch",
-        env={
-            "GIT_AUTHOR_NAME": "github-actions",
-            "GIT_AUTHOR_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
-            "GIT_COMMITTER_NAME": "github-actions",
-            "GIT_COMMITTER_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
-        },
-    )()
+    git.register("git fetch origin foo")()
+    git.register("git switch foo")()
 
-    storage.initialize_branch(
-        git=git,
-        branch="foo",
-        initial_file=files.FileWithPath(path=readme_path, contents="bar"),
-    )
+    with storage.checked_out_branch(git=git, branch="foo"):
+        git.register("git switch bar")()
+
+
+def test_checked_out_branch__detached_head(git):
+    git.register("git branch --show-current")(exit_code=1)
+    git.register("git rev-parse --short HEAD")(stdout="123abc")
+    git.register("git reset --hard")()
+    git.register("git fetch origin foo")()
+    git.register("git switch foo")()
 
-    assert readme_path.read_text() == "bar"
+    with storage.checked_out_branch(git=git, branch="foo"):
+        git.register("git switch 123abc")()
 
 
-def test_checked_out_branch(git):
+def test_checked_out_branch__branch_does_not_exist(git):
     git.register("git branch --show-current")(stdout="bar")
-    git.register("git fetch")()
-    git.register("git checkout foo")()
+    git.register("git reset --hard")()
+    git.register("git fetch origin foo")(exit_code=1)
+    git.register("git fetch origin")()
+    git.register("git rev-parse --verify origin/foo")(exit_code=1)
+    git.register("git switch --orphan foo")()
 
-    with storage.checked_out_branch(git=git, branch="foo") as exists:
-        assert exists is True
-        git.register("git checkout bar")()
+    with storage.checked_out_branch(git=git, branch="foo"):
+        git.register("git switch bar")()
 
 
-def test_checked_out_branch__detached_head(git):
-    git.register("git branch --show-current")(exit_code=1)
-    git.register("git rev-parse --short HEAD")(stdout="123abc")
-    git.register("git fetch")()
-    git.register("git checkout foo")()
+def test_checked_out_branch__fetch_fails(git):
+    git.register("git branch --show-current")(stdout="bar")
+    git.register("git reset --hard")()
+    git.register("git fetch origin foo")(exit_code=1)
+    git.register("git fetch origin")()
+    git.register("git rev-parse --verify origin/foo")()
 
-    with storage.checked_out_branch(git=git, branch="foo") as exists:
-        assert exists is True
-        git.register("git checkout 123abc")()
+    with pytest.raises(subprocess.GitError):
+        with storage.checked_out_branch(git=git, branch="foo"):
+            pass
 
 
-def test_checked_out_branch__branch_doesnt_exist(git):
+def test_commit_operations__no_diff(git, in_tmp_path):
+    operations = [
+        files.WriteFile(path=pathlib.Path("a.txt"), contents="a"),
+        files.WriteFile(path=pathlib.Path("b.txt"), contents="b"),
+    ]
+
+    # checked_out_branch
     git.register("git branch --show-current")(stdout="bar")
-    git.register("git fetch")()
-    git.register("git checkout foo")(exit_code=1)
+    git.register("git reset --hard")()
+    git.register("git fetch origin foo")()
+    git.register("git switch foo")()
+
+    # upload_files
+    git.register(f"git add {operations[0].path}")()
+    git.register(f"git add {operations[1].path}")()
+    git.register("git diff --staged --exit-code")()  # no diff
 
-    with storage.checked_out_branch(git=git, branch="foo") as exists:
-        assert exists is False
-        git.register("git checkout bar")()
+    # __exit__ of checked_out_branch
+    git.register("git switch bar")()
 
+    storage.commit_operations(
+        operations=operations,
+        git=git,
+        branch="foo",
+    )
 
-@pytest.mark.parametrize(
-    "branch_exists, has_diff",
-    [
-        (False, True),
-        (True, False),
-    ],
-)
-def test_upload_files(git, in_tmp_path, branch_exists, has_diff):
-    readme_path = pathlib.Path("readme.txt")
-    initial_file = files.FileWithPath(path=readme_path, contents="foo")
-    files_to_save = [
-        files.FileWithPath(path=pathlib.Path("a.txt"), contents="a"),
-        files.FileWithPath(path=pathlib.Path("b.txt"), contents="b"),
+    # But content has been written to disk
+    assert operations[0].path.read_text() == operations[0].contents
+    assert operations[1].path.read_text() == operations[1].contents
+
+
+def test_commit_operations(git, in_tmp_path):
+    operations = [
+        files.WriteFile(path=pathlib.Path("a.txt"), contents="a"),
+        files.WriteFile(path=pathlib.Path("b.txt"), contents="b"),
     ]
 
     # checked_out_branch
     git.register("git branch --show-current")(stdout="bar")
-    git.register("git fetch")()
-    git.register("git checkout foo")(exit_code=0 if branch_exists else 1)
-
-    # I usually hate `if` statements in tests, but we're doing
-    # 2 different very repetitive (almost identical) tests here,
-    # so it really just is a way to make it easier to maintain.
-    # Let's not abuse it.
-    if not branch_exists:
-        # initialize_branch
-        git.register("git checkout --orphan foo")()
-        git.register("git reset --hard")()
-        git.register(f"git add {readme_path}")()
-        git.register(
-            "git commit --message Initialize python-coverage-comment-action special branch",
-            env={
-                "GIT_AUTHOR_NAME": "github-actions",
-                "GIT_AUTHOR_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
-                "GIT_COMMITTER_NAME": "github-actions",
-                "GIT_COMMITTER_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
-            },
-        )()
+    git.register("git reset --hard")()
+    git.register("git fetch origin foo")()
+    git.register("git switch foo")()
 
     # upload_files
-    git.register(f"git add {files_to_save[0].path}")()
-    git.register(f"git add {files_to_save[1].path}")()
-    git.register("git diff --staged --exit-code")(exit_code=1 if has_diff else 0)
-
-    if has_diff:
-        # (yes, it's missing the quotes, but this is just an artifact from our test
-        # double)
-        git.register(
-            "git commit --message Update badge",
-            env={
-                "GIT_AUTHOR_NAME": "github-actions",
-                "GIT_AUTHOR_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
-                "GIT_COMMITTER_NAME": "github-actions",
-                "GIT_COMMITTER_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
-            },
-        )()
-        git.register("git push origin foo")()
+    git.register(f"git add {operations[0].path}")()
+    git.register(f"git add {operations[1].path}")()
+
+    git.register("git diff --staged --exit-code")(exit_code=1)  # diff!
+
+    # (yes, it's missing the quotes, but this is just an artifact from our test
+    # double)
+    git.register(
+        "git commit --message Update coverage data",
+        env={
+            "GIT_AUTHOR_NAME": "github-actions",
+            "GIT_AUTHOR_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
+            "GIT_COMMITTER_NAME": "github-actions",
+            "GIT_COMMITTER_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com",
+        },
+    )()
+    git.register("git push origin foo")()
 
     # __exit__ of checked_out_branch
-    git.register("git checkout bar")()
+    git.register("git switch bar")()
 
-    storage.upload_files(
-        files=files_to_save,
+    storage.commit_operations(
+        operations=operations,
         git=git,
         branch="foo",
-        initial_file=initial_file,
     )
 
-    assert files_to_save[0].path.read_text() == files_to_save[0].contents
-    assert files_to_save[1].path.read_text() == files_to_save[1].contents
+    assert operations[0].path.read_text() == operations[0].contents
+    assert operations[1].path.read_text() == operations[1].contents
 
 
 def test_get_datafile_contents__not_found(gh, session):
@@ -167,8 +160,8 @@ def test_get_datafile_contents(gh, session):
         (True, "https://raw.githubusercontent.com/foo/bar/baz/qux"),
     ],
 )
-def test_get_file_url(is_public, expected):
-    result = storage.get_file_url(
+def test_get_raw_file_url(is_public, expected):
+    result = storage.get_raw_file_url(
         repository="foo/bar",
         branch="baz",
         path=pathlib.Path("qux"),
@@ -177,7 +170,30 @@ def test_get_file_url(is_public, expected):
     assert result == expected
 
 
-def test_get_readme_url():
-    result = storage.get_readme_url(repository="foo/bar", branch="baz")
+@pytest.mark.parametrize(
+    "path, expected",
+    [
+        ("", "https://github.com/foo/bar/tree/baz"),
+        ("/", "https://github.com/foo/bar/tree/baz"),
+        ("qux", "https://github.com/foo/bar/blob/baz/qux"),  # blob
+        ("qux/", "https://github.com/foo/bar/tree/baz/qux"),
+        ("/qux", "https://github.com/foo/bar/blob/baz/qux"),  # blob
+        ("/qux/", "https://github.com/foo/bar/tree/baz/qux"),
+    ],
+)
+def test_get_repo_file_url(path, expected):
+    result = storage.get_repo_file_url(repository="foo/bar", branch="baz", path=path)
+
+    assert result == expected
+
+
+def test_get_repo_file_url__no_path():
+    result = storage.get_repo_file_url(repository="foo/bar", branch="baz")
 
     assert result == "https://github.com/foo/bar/tree/baz"
+
+
+def test_get_html_report_url():
+    result = storage.get_html_report_url(repository="foo/bar", branch="baz")
+    expected = "https://htmlpreview.github.io/?https://github.com/foo/bar/blob/baz/htmlcov/index.html"
+    assert result == expected
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
index fde6466..9dfed3a 100644
--- a/tests/unit/test_template.py
+++ b/tests/unit/test_template.py
@@ -6,9 +6,9 @@ import pytest
 from coverage_comment import coverage, template
 
 
-def test_get_markdown_comment(coverage_obj, diff_coverage_obj):
+def test_get_comment_markdown(coverage_obj, diff_coverage_obj):
     result = (
-        template.get_markdown_comment(
+        template.get_comment_markdown(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
@@ -39,11 +39,11 @@ def test_get_markdown_comment(coverage_obj, diff_coverage_obj):
 
 
 def test_template(coverage_obj, diff_coverage_obj):
-    result = template.get_markdown_comment(
+    result = template.get_comment_markdown(
         coverage=coverage_obj,
         diff_coverage=diff_coverage_obj,
         previous_coverage_rate=decimal.Decimal("0.92"),
-        base_template=template.read_template_file(),
+        base_template=template.read_template_file("comment.md.j2"),
         custom_template="""{% extends "base" %}
         {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %}
         """,
@@ -142,11 +142,11 @@ def test_template_full():
         },
     )
 
-    result = template.get_markdown_comment(
+    result = template.get_comment_markdown(
         coverage=cov,
         diff_coverage=diff_cov,
         previous_coverage_rate=decimal.Decimal("1.0"),
-        base_template=template.read_template_file(),
+        base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
 The coverage rate went from `100%` to `100%` :arrow_right:
@@ -178,11 +178,11 @@ def test_template__no_new_lines_with_coverage(coverage_obj):
         files={},
     )
 
-    result = template.get_markdown_comment(
+    result = template.get_comment_markdown(
         coverage=coverage_obj,
         diff_coverage=diff_cov,
         previous_coverage_rate=decimal.Decimal("1.0"),
-        base_template=template.read_template_file(),
+        base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
 The coverage rate went from `100%` to `75%` :arrow_down:
@@ -196,11 +196,11 @@ _None of the new lines are part of the tested code. Therefore, there is no cover
 
 
 def test_template__no_branch_no_previous(coverage_obj_no_branch, diff_coverage_obj):
-    result = template.get_markdown_comment(
+    result = template.get_comment_markdown(
         coverage=coverage_obj_no_branch,
         diff_coverage=diff_coverage_obj,
         previous_coverage_rate=None,
-        base_template=template.read_template_file(),
+        base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
 > **Note**
@@ -223,29 +223,29 @@ Missing lines: `7`, `9`
 
 
 def test_read_template_file():
-    assert template.read_template_file().startswith(
+    assert template.read_template_file("comment.md.j2").startswith(
         "{% block title %}## Coverage report{% endblock title %}"
     )
 
 
 def test_template__no_marker(coverage_obj, diff_coverage_obj):
     with pytest.raises(template.MissingMarker):
-        template.get_markdown_comment(
+        template.get_comment_markdown(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
-            base_template=template.read_template_file(),
+            base_template=template.read_template_file("comment.md.j2"),
             custom_template="""foo bar""",
         )
 
 
 def test_template__broken_template(coverage_obj, diff_coverage_obj):
     with pytest.raises(template.TemplateError):
-        template.get_markdown_comment(
+        template.get_comment_markdown(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
-            base_template=template.read_template_file(),
+            base_template=template.read_template_file("comment.md.j2"),
             custom_template="""{% extends "foo" %}""",
         )