build(release): make Git.checkout a context manager (#4239)

Release commands that switch to older release branches left the working
tree on the target branch when exiting. This caused the post-job cleanup
for the local `.github/actions/setup-bazel` composite action to fail
when the checked-out release branch predated that action.

Convert `Git.checkout()` into a context manager that restores the prior
branch on exit, and update all callers and agent Python conventions.
diff --git a/.agents/rules/python.md b/.agents/rules/python.md
index fd7aef7..f2545ab 100644
--- a/.agents/rules/python.md
+++ b/.agents/rules/python.md
@@ -58,4 +58,6 @@
 * Module-level functions delegating to class methods should have a docstring
   referring to the class method (e.g. `"""Refer to \`Class.method\`."""`).
 
-
+## Context Managers
+* Implement temporary state changes (e.g., `Git.checkout()`, `os.chdir()`) as
+  context managers that restore prior state on exit.
diff --git a/dev/release/backport_prepare.py b/dev/release/backport_prepare.py
index 21d00b1..3f03bdb 100644
--- a/dev/release/backport_prepare.py
+++ b/dev/release/backport_prepare.py
@@ -158,16 +158,13 @@
         if self.git.status():
             raise RuntimeError("Workspace is dirty. Aborting.")
 
-        current_branch = self.git.get_current_branch()
         verify_results = {}  # branch -> (success, reason)
         version_map = {}  # branch -> next_version
 
-        try:
-            for branch in target_branches:
-                minor_ver = branch.split("/")[1]
-                print(f"Verifying application on {branch}...")
-                self.git.checkout(branch, track_remote=args.remote)
-
+        for branch in target_branches:
+            minor_ver = branch.split("/")[1]
+            print(f"Verifying application on {branch}...")
+            with self.git.checkout(branch, track_remote=args.remote):
                 # Determine next version
                 next_version = determine_next_version(branch)
                 version_map[branch] = next_version
@@ -200,10 +197,6 @@
                     except Exception:
                         pass
                     self.git.reset_hard(reset_to=f"{args.remote}/{branch}")
-        finally:
-            # Restore original branch
-            if current_branch:
-                self.git.checkout(current_branch)
 
         # Generate issue content
         body_lines = [
diff --git a/dev/release/create_rc.py b/dev/release/create_rc.py
index fabf0c5..3ca50e5 100644
--- a/dev/release/create_rc.py
+++ b/dev/release/create_rc.py
@@ -125,18 +125,17 @@
 
         target_ref = f"{args.remote}/{branch_name}"
         commit_sha = self.git.get_commit_sha(target_ref)
-        self.git.checkout(target_ref)
-
-        print("Checking for leftover VERSION_NEXT_ markers...")
-        markers = find_version_markers()
-        if markers:
-            for marker in markers:
-                print(marker)
-            print(
-                "Error: Found VERSION_NEXT markers indicating version needs to"
-                " be specified."
-            )
-            return 1
+        with self.git.checkout(target_ref):
+            print("Checking for leftover VERSION_NEXT_ markers...")
+            markers = find_version_markers()
+            if markers:
+                for marker in markers:
+                    print(marker)
+                print(
+                    "Error: Found VERSION_NEXT markers indicating version needs to"
+                    " be specified."
+                )
+                return 1
 
         # Precheck: next RC number must exist and be unchecked in the checklist
         rc_tags = state.get("rc_tags", {})
diff --git a/dev/release/git.py b/dev/release/git.py
index bab76a6..d4943dc 100644
--- a/dev/release/git.py
+++ b/dev/release/git.py
@@ -1,6 +1,8 @@
 """Git helper functions for the release tool."""
 
+import contextlib
 import subprocess
+from collections.abc import Iterator
 
 from dev.release.shell import run_cmd
 
@@ -50,20 +52,25 @@
         output = self._run_git("tag")
         return output.splitlines() if output else []
 
+    @contextlib.contextmanager
     def checkout(
         self,
         ref: str,
         create_branch: bool = False,
         track_remote: str | None = None,
-    ) -> None:
-        """Checks out a git reference (tag, branch, or commit).
+    ) -> Iterator[None]:
+        """Checks out a git reference and restores the previous branch on exit.
 
         Args:
             ref: The git reference (tag, branch, or commit) to checkout.
             create_branch: If True, creates the branch before checking it out.
             track_remote: If specified, checks out the branch tracking this
               remote's corresponding branch.
+
+        Yields:
+            None.
         """
+        previous_branch = self.get_current_branch()
         cmd = ["checkout"]
         if create_branch:
             cmd.append("-b")
@@ -82,6 +89,12 @@
         if should_reset_hard:
             self.reset_hard(reset_to=f"{track_remote}/{ref}")
 
+        try:
+            yield
+        finally:
+            if previous_branch:
+                self._run_git("checkout", previous_branch, capture_output=False)
+
     def add(self, *files: str) -> None:
         """Stages files for commit.
 
diff --git a/dev/release/prepare.py b/dev/release/prepare.py
index d2df2b0..2d11cff 100644
--- a/dev/release/prepare.py
+++ b/dev/release/prepare.py
@@ -1,6 +1,7 @@
 """Subcommand to prepare the release (updates changelog, placeholders)."""
 
 import argparse
+import contextlib
 import datetime
 
 from dev.release import changelog_news
@@ -100,101 +101,105 @@
                     f"[DRY RUN] Branch {branch_name} already exists. Would"
                     " checkout existing branch."
                 )
+                checkout_cm = contextlib.nullcontext()
             else:
                 print(f"Branch {branch_name} already exists. Checking it out...")
-                self.git.checkout(branch_name)
+                checkout_cm = self.git.checkout(branch_name)
         else:
             if args.dry_run:
                 print(f"[DRY RUN] Would create and checkout branch {branch_name}")
+                checkout_cm = contextlib.nullcontext()
             else:
-                self.git.checkout(branch_name, create_branch=True)
+                checkout_cm = self.git.checkout(branch_name, create_branch=True)
 
-        # --- Update files ---
-        if args.dry_run:
-            print(
-                f"[DRY RUN] Would update CHANGELOG.md and version placeholders"
-                f" for {version}"
-            )
-        else:
-            print("Updating changelog and placeholders...")
-            release_date = datetime.date.today().strftime("%Y-%m-%d")
-            changelog_news.update_changelog(version, release_date)
-            replace_version_next(version)
-
-        # --- Commit and Push ---
-        if args.dry_run:
-            print(f"[DRY RUN] Would push branch {branch_name} to origin")
-        else:
-            modified_files = self.git.status()
-            if modified_files:
-                # Stage all modified and deleted tracked files
-                self.git.add_modified_and_deleted()
-                self.git.commit(f"Prepare release {version}")
+        with checkout_cm:
+            # --- Update files ---
+            if args.dry_run:
+                print(
+                    f"[DRY RUN] Would update CHANGELOG.md and version placeholders"
+                    f" for {version}"
+                )
             else:
-                print("No files modified by the release tool. Nothing to commit.")
+                print("Updating changelog and placeholders...")
+                release_date = datetime.date.today().strftime("%Y-%m-%d")
+                changelog_news.update_changelog(version, release_date)
+                replace_version_next(version)
 
-            print(f"Pushing branch {branch_name} to origin...")
-            # Force push to overwrite the remote branch if it already exists (e.g. from a previous run)
-            self.git.push("origin", branch_name, set_upstream=True, force=True)
+            # --- Commit and Push ---
+            if args.dry_run:
+                print(f"[DRY RUN] Would push branch {branch_name} to origin")
+            else:
+                modified_files = self.git.status()
+                if modified_files:
+                    # Stage all modified and deleted tracked files
+                    self.git.add_modified_and_deleted()
+                    self.git.commit(f"Prepare release {version}")
+                else:
+                    print("No files modified by the release tool. Nothing to commit.")
 
-        # --- Create PR ---
-        # Determine if we need to create a PR or reuse an existing one
-        open_pr = self.gh.get_open_pr(branch_name)
-        associated_pr = None
+                print(f"Pushing branch {branch_name} to origin...")
+                # Force push to overwrite the remote branch if it already
+                # exists (e.g. from a previous run)
+                self.git.push("origin", branch_name, set_upstream=True, force=True)
 
-        if not open_pr and issue_num:
-            body = self.gh.get_issue_body(issue_num)
-            state = parse_checklist_state(body)
-            associated_pr = state["prepare_release"].pr
+            # --- Create PR ---
+            # Determine if we need to create a PR or reuse an existing one
+            open_pr = self.gh.get_open_pr(branch_name)
+            associated_pr = None
 
-        if open_pr:
-            pr_num = open_pr["number"]
-            pr_url = open_pr["url"]
-            print(f"Open Pull Request already exists: {pr_url} (PR #{pr_num})")
-        elif associated_pr:
-            pr_num = associated_pr.lstrip("#")
-            pr_url = f"https://github.com/bazel-contrib/rules_python/pull/{pr_num}"
-            print(
-                f"PR #{pr_num} is already associated in tracking issue"
-                f" #{issue_num}. Using it."
-            )
-        else:
+            if not open_pr and issue_num:
+                body = self.gh.get_issue_body(issue_num)
+                state = parse_checklist_state(body)
+                associated_pr = state["prepare_release"].pr
+
+            if open_pr:
+                pr_num = open_pr["number"]
+                pr_url = open_pr["url"]
+                print(f"Open Pull Request already exists: {pr_url} (PR #{pr_num})")
+            elif associated_pr:
+                pr_num = associated_pr.lstrip("#")
+                pr_url = f"https://github.com/bazel-contrib/rules_python/pull/{pr_num}"
+                print(
+                    f"PR #{pr_num} is already associated in tracking issue"
+                    f" #{issue_num}. Using it."
+                )
+            else:
+                if args.dry_run:
+                    target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>"
+                    print(
+                        f"[DRY RUN] Would create Pull Request for branch"
+                        f" {branch_name} targeting issue {target_issue}"
+                    )
+                    pr_num = "<NEW_PR>"
+                else:
+                    pr_url = self.gh.create_pr(
+                        title=f"Prepare release v{version}",
+                        body=f"Work towards #{issue_num}",
+                        base="main",
+                        labels=[RELEASE_PREPARED_LABEL],
+                    )
+                    pr_num = pr_url.split("/")[-1]
+                    print(f"Created Pull Request: {pr_url} (PR #{pr_num})")
+
+            # --- Update checklist ---
             if args.dry_run:
                 target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>"
                 print(
-                    f"[DRY RUN] Would create Pull Request for branch"
-                    f" {branch_name} targeting issue {target_issue}"
+                    f"[DRY RUN] Would update tracking issue {target_issue} checklist"
+                    " 'Prepare Release' task status to PENDING"
                 )
-                pr_num = "<NEW_PR>"
             else:
-                pr_url = self.gh.create_pr(
-                    title=f"Prepare release v{version}",
-                    body=f"Work towards #{issue_num}",
-                    base="main",
-                    labels=[RELEASE_PREPARED_LABEL],
+                print(
+                    f"Updating tracking issue #{issue_num} checklist 'Prepare"
+                    " Release' task status to PENDING..."
                 )
-                pr_num = pr_url.split("/")[-1]
-                print(f"Created Pull Request: {pr_url} (PR #{pr_num})")
-
-        # --- Update checklist ---
-        if args.dry_run:
-            target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>"
-            print(
-                f"[DRY RUN] Would update tracking issue {target_issue} checklist"
-                " 'Prepare Release' task status to PENDING"
-            )
-        else:
-            print(
-                f"Updating tracking issue #{issue_num} checklist 'Prepare"
-                " Release' task status to PENDING..."
-            )
-            body = self.gh.get_issue_body(issue_num)
-            metadata = {"status": "pending", "pr": f"#{pr_num}"}
-            updated_body = update_task_in_body(
-                body, "Prepare Release", checked=False, metadata=metadata
-            )
-            self.gh.update_issue_body(issue_num, updated_body)
-            print("Preparation pipeline completed successfully!")
+                body = self.gh.get_issue_body(issue_num)
+                metadata = {"status": "pending", "pr": f"#{pr_num}"}
+                updated_body = update_task_in_body(
+                    body, "Prepare Release", checked=False, metadata=metadata
+                )
+                self.gh.update_issue_body(issue_num, updated_body)
+                print("Preparation pipeline completed successfully!")
 
         return 0
 
diff --git a/dev/release/process_backports.py b/dev/release/process_backports.py
index 2b2a0aa..243f1ab 100644
--- a/dev/release/process_backports.py
+++ b/dev/release/process_backports.py
@@ -380,31 +380,31 @@
         sorted_shas = self.git.sort_commits_chronologically(shas)
 
         self.git.fetch(args.remote)
-        self.git.checkout(branch_name, track_remote=args.remote)
-        start_sha = self.git.get_commit_sha("HEAD")
+        with self.git.checkout(branch_name, track_remote=args.remote):
+            start_sha = self.git.get_commit_sha("HEAD")
 
-        try:
-            result = self._cherry_pick_and_update_prs(
-                sorted_shas,
-                sha_to_item,
-                body,
-                args.issue,
-                args.remote,
-                args.dry_run,
-                version,
-                branch_name,
-                next_rc_suffix,
-            )
-            failed_prs.extend(result.failed_prs)
-            body = result.body
-        finally:
-            if args.dry_run:
-                logger.info(
-                    "[DRY RUN] Resetting branch %s to %s",
+            try:
+                result = self._cherry_pick_and_update_prs(
+                    sorted_shas,
+                    sha_to_item,
+                    body,
+                    args.issue,
+                    args.remote,
+                    args.dry_run,
+                    version,
                     branch_name,
-                    start_sha,
+                    next_rc_suffix,
                 )
-                self.git.reset_hard(reset_to=start_sha)
+                failed_prs.extend(result.failed_prs)
+                body = result.body
+            finally:
+                if args.dry_run:
+                    logger.info(
+                        "[DRY RUN] Resetting branch %s to %s",
+                        branch_name,
+                        start_sha,
+                    )
+                    self.git.reset_hard(reset_to=start_sha)
 
         if failed_prs:
             logger.error("One or more cherry-picks/resolutions failed:")
diff --git a/dev/release/sync_changelog.py b/dev/release/sync_changelog.py
index d79722f..0376017 100644
--- a/dev/release/sync_changelog.py
+++ b/dev/release/sync_changelog.py
@@ -172,105 +172,108 @@
         sync_branch = f"sync-changelog-{version}-{prs_hash}"
 
         self.git.fetch(args.remote, refspec=main_branch)
-        self.git.checkout(main_branch, track_remote=args.remote)
-
-        try:
+        with self.git.checkout(main_branch, track_remote=args.remote):
             if self.git.branch_exists(sync_branch):
-                self.git.checkout(sync_branch)
-                self.git.reset_hard(reset_to=main_branch)
+                checkout_cm = self.git.checkout(sync_branch)
+                should_reset = True
             else:
-                self.git.checkout(sync_branch, create_branch=True)
+                checkout_cm = self.git.checkout(sync_branch, create_branch=True)
+                should_reset = False
 
-            # Run ProcessNews to process news files and version markers
-            process_news_args = argparse.Namespace(
-                version=version,
-                targets=[str(pr) for pr in sorted_prs],
-                release_date=args.release_date,
-            )
-            process_news_runner = ProcessNews(process_news_args, gh=self.gh)
-            ret = process_news_runner.run()
-            if ret != 0:
-                err = f"ProcessNews failed for targets: {sorted_prs}"
-                logger.error(err)
-                self._post_failure_comment(issue_num)
-                return 1
+            with checkout_cm:
+                if should_reset:
+                    self.git.reset_hard(reset_to=main_branch)
 
-            if not self.git.status():
-                logger.info("No changes to sync after running process-news.")
-                return 0
-
-            self.git.add_modified_and_deleted()
-            self.git.commit(f"chore(release): sync changelog for v{version} backports")
-            self.git.push(args.remote, sync_branch, set_upstream=True, force=True)
-
-            pr_title = f"chore(release): sync changelog for v{version} backports"
-            pr_body_lines = [
-                "Updates CHANGELOG.md and removes news files for backports:",
-            ]
-            for pr_num in sorted_prs:
-                pr_body_lines.append(f"- #{pr_num}")
-
-            pr_body_lines.append("")
-            pr_body_lines.append(f"Work towards #{issue_num}")
-            pr_body_lines.append(f"Release-Tracking-Issue: #{issue_num}")
-            pr_body = "\n".join(pr_body_lines)
-
-            logger.info("Creating PR to %s...", main_branch)
-            pr_url = self.gh.create_pr(
-                title=pr_title,
-                body=pr_body,
-                base=main_branch,
-                labels=[SYNC_CHANGELOG_LABEL],
-            )
-            logger.info("Created PR: %s", pr_url)
-
-            pr_num = int(pr_url.split("/")[-1])
-            try:
-                logger.info("Enabling auto-merge for PR #%s...", pr_num)
-                self.gh.enable_auto_merge(pr_num)
-            except Exception as e:
-                logger.warning(
-                    "Failed to enable auto-merge on PR #%s: %s",
-                    pr_num,
-                    format_exception(e),
+                # Run ProcessNews to process news files and version markers
+                process_news_args = argparse.Namespace(
+                    version=version,
+                    targets=[str(pr) for pr in sorted_prs],
+                    release_date=args.release_date,
                 )
+                process_news_runner = ProcessNews(process_news_args, gh=self.gh)
+                ret = process_news_runner.run()
+                if ret != 0:
+                    err = f"ProcessNews failed for targets: {sorted_prs}"
+                    logger.error(err)
+                    self._post_failure_comment(issue_num)
+                    return 1
 
-            try:
-                logger.info(
-                    "Updating tracking issue #%s checklist with"
-                    " Sync Changelog tasks...",
-                    issue_num,
+                if not self.git.status():
+                    logger.info("No changes to sync after running process-news.")
+                    return 0
+
+                self.git.add_modified_and_deleted()
+                self.git.commit(
+                    f"chore(release): sync changelog for v{version} backports"
                 )
-                issue_body = self.gh.get_issue_body(issue_num)
-                for pr in sorted_prs:
-                    task_name = f"Sync Changelog #{pr}"
-                    metadata = {"status": "pending", "pr": f"#{pr_num}"}
-                    issue_body = update_task_in_body(
-                        issue_body,
-                        task_name,
-                        checked=False,
-                        metadata=metadata,
+                self.git.push(args.remote, sync_branch, set_upstream=True, force=True)
+
+                pr_title = f"chore(release): sync changelog for v{version} backports"
+                pr_body_lines = [
+                    "Updates CHANGELOG.md and removes news files for backports:",
+                ]
+                for pr_num in sorted_prs:
+                    pr_body_lines.append(f"- #{pr_num}")
+
+                pr_body_lines.append("")
+                pr_body_lines.append(f"Work towards #{issue_num}")
+                pr_body_lines.append(f"Release-Tracking-Issue: #{issue_num}")
+                pr_body = "\n".join(pr_body_lines)
+
+                logger.info("Creating PR to %s...", main_branch)
+                pr_url = self.gh.create_pr(
+                    title=pr_title,
+                    body=pr_body,
+                    base=main_branch,
+                    labels=[SYNC_CHANGELOG_LABEL],
+                )
+                logger.info("Created PR: %s", pr_url)
+
+                pr_num = int(pr_url.split("/")[-1])
+                try:
+                    logger.info("Enabling auto-merge for PR #%s...", pr_num)
+                    self.gh.enable_auto_merge(pr_num)
+                except Exception as e:
+                    logger.warning(
+                        "Failed to enable auto-merge on PR #%s: %s",
+                        pr_num,
+                        format_exception(e),
                     )
-                self.gh.update_issue_body(issue_num, issue_body)
-            except Exception as e:
-                logger.warning(
-                    "Failed to update tracking issue checklist: %s",
-                    format_exception(e),
-                )
 
-            try:
-                success_body = SYNC_CHANGELOG_SUCCESS_COMMENT_TEMPLATE.format(
-                    pr_url=pr_url,
-                )
-                self.gh.post_issue_comment(issue_num, success_body)
-            except Exception as e:
-                logger.warning(
-                    "Failed to post success comment to issue #%s: %s",
-                    issue_num,
-                    format_exception(e),
-                )
-        finally:
-            self.git.checkout(main_branch)
+                try:
+                    logger.info(
+                        "Updating tracking issue #%s checklist with"
+                        " Sync Changelog tasks...",
+                        issue_num,
+                    )
+                    issue_body = self.gh.get_issue_body(issue_num)
+                    for pr in sorted_prs:
+                        task_name = f"Sync Changelog #{pr}"
+                        metadata = {"status": "pending", "pr": f"#{pr_num}"}
+                        issue_body = update_task_in_body(
+                            issue_body,
+                            task_name,
+                            checked=False,
+                            metadata=metadata,
+                        )
+                    self.gh.update_issue_body(issue_num, issue_body)
+                except Exception as e:
+                    logger.warning(
+                        "Failed to update tracking issue checklist: %s",
+                        format_exception(e),
+                    )
+
+                try:
+                    success_body = SYNC_CHANGELOG_SUCCESS_COMMENT_TEMPLATE.format(
+                        pr_url=pr_url,
+                    )
+                    self.gh.post_issue_comment(issue_num, success_body)
+                except Exception as e:
+                    logger.warning(
+                        "Failed to post success comment to issue #%s: %s",
+                        issue_num,
+                        format_exception(e),
+                    )
 
         return 0
 
diff --git a/tests/tools/private/release/backport_prepare_test.py b/tests/tools/private/release/backport_prepare_test.py
index 99fac9d..cfd0e01 100644
--- a/tests/tools/private/release/backport_prepare_test.py
+++ b/tests/tools/private/release/backport_prepare_test.py
@@ -60,9 +60,14 @@
     mock_git.checkout.assert_has_calls(
         [
             call("release/1.7", track_remote="my-remote"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
             call("release/1.8", track_remote="my-remote"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
             call("release/1.9", track_remote="my-remote"),
-            call("work-branch"),  # Restored branch
+            call().__enter__(),
+            call().__exit__(None, None, None),
         ]
     )
 
diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py
index 70905be..b2f36d1 100644
--- a/tests/tools/private/release/create_rc_test.py
+++ b/tests/tools/private/release/create_rc_test.py
@@ -48,7 +48,13 @@
     mock_git.fetch.assert_has_calls(
         [call("my-remote"), call("my-remote", tags=True, force=True)]
     )
-    mock_git.checkout.assert_called_once_with("my-remote/release/2.0")
+    mock_git.checkout.assert_has_calls(
+        [
+            call("my-remote/release/2.0"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+        ]
+    )
     mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0")
     mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0")
     mock_git.get_commit_sha.assert_called_once_with("my-remote/release/2.0")
@@ -452,7 +458,13 @@
 
     # Assert
     assert result == 1
-    mock_git.checkout.assert_called_once_with("my-remote/release/2.0")
+    mock_git.checkout.assert_has_calls(
+        [
+            call("my-remote/release/2.0"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+        ]
+    )
     mock_git.tag.assert_not_called()
     mock_git.push.assert_not_called()
     assert mock_gh.get_issue_body(123) == initial_body
diff --git a/tests/tools/private/release/git_test.py b/tests/tools/private/release/git_test.py
index b9ed755..7669f91 100644
--- a/tests/tools/private/release/git_test.py
+++ b/tests/tools/private/release/git_test.py
@@ -1,4 +1,5 @@
 import subprocess
+from unittest.mock import call
 
 import pytest
 
@@ -14,39 +15,73 @@
     return git
 
 
-def test_checkout_simple(git_obj):
-    git_obj.checkout("my-branch")
-    git_obj.mock_run_git.assert_called_once_with(
-        "checkout", "my-branch", capture_output=False
+def test_checkout_simple(mocker, git_obj):
+    mocker.patch.object(git_obj, "get_current_branch", return_value="main")
+    with git_obj.checkout("my-branch"):
+        git_obj.mock_run_git.assert_called_once_with(
+            "checkout", "my-branch", capture_output=False
+        )
+    git_obj.mock_run_git.assert_has_calls(
+        [
+            call("checkout", "my-branch", capture_output=False),
+            call("checkout", "main", capture_output=False),
+        ]
+    )
+
+
+def test_checkout_restores_branch_on_exception(mocker, git_obj):
+    mocker.patch.object(git_obj, "get_current_branch", return_value="main")
+    with pytest.raises(RuntimeError, match="boom"):
+        with git_obj.checkout("my-branch"):
+            raise RuntimeError("boom")
+    git_obj.mock_run_git.assert_has_calls(
+        [
+            call("checkout", "my-branch", capture_output=False),
+            call("checkout", "main", capture_output=False),
+        ]
     )
 
 
 def test_checkout_track_remote_new_branch(mocker, git_obj):
+    mocker.patch.object(git_obj, "get_current_branch", return_value="main")
     mock_branch_exists = mocker.patch(
         "dev.release.git.Git.branch_exists", return_value=False
     )
 
-    git_obj.checkout("my-branch", track_remote="origin")
+    with git_obj.checkout("my-branch", track_remote="origin"):
+        mock_branch_exists.assert_called_once_with("my-branch")
+        git_obj.mock_run_git.assert_called_once_with(
+            "checkout", "--track", "origin/my-branch", capture_output=False
+        )
 
-    mock_branch_exists.assert_called_once_with("my-branch")
-    git_obj.mock_run_git.assert_called_once_with(
-        "checkout", "--track", "origin/my-branch", capture_output=False
+    git_obj.mock_run_git.assert_has_calls(
+        [
+            call("checkout", "--track", "origin/my-branch", capture_output=False),
+            call("checkout", "main", capture_output=False),
+        ]
     )
 
 
 def test_checkout_track_remote_existing_branch(mocker, git_obj):
+    mocker.patch.object(git_obj, "get_current_branch", return_value="main")
     mock_branch_exists = mocker.patch(
         "dev.release.git.Git.branch_exists", return_value=True
     )
     mock_reset_hard = mocker.patch("dev.release.git.Git.reset_hard")
 
-    git_obj.checkout("my-branch", track_remote="origin")
+    with git_obj.checkout("my-branch", track_remote="origin"):
+        mock_branch_exists.assert_called_once_with("my-branch")
+        git_obj.mock_run_git.assert_called_once_with(
+            "checkout", "my-branch", capture_output=False
+        )
+        mock_reset_hard.assert_called_once_with(reset_to="origin/my-branch")
 
-    mock_branch_exists.assert_called_once_with("my-branch")
-    git_obj.mock_run_git.assert_called_once_with(
-        "checkout", "my-branch", capture_output=False
+    git_obj.mock_run_git.assert_has_calls(
+        [
+            call("checkout", "my-branch", capture_output=False),
+            call("checkout", "main", capture_output=False),
+        ]
     )
-    mock_reset_hard.assert_called_once_with(reset_to="origin/my-branch")
 
 
 def test_fetch_default(git_obj):
diff --git a/tests/tools/private/release/prepare_test.py b/tests/tools/private/release/prepare_test.py
index 62d2b62..655dba5 100644
--- a/tests/tools/private/release/prepare_test.py
+++ b/tests/tools/private/release/prepare_test.py
@@ -1,4 +1,5 @@
 import argparse
+from unittest.mock import call
 
 from dev.release.gh import RELEASE_PREPARED_LABEL
 from dev.release.prepare import Prepare
@@ -120,7 +121,13 @@
 
     # Assert
     assert result == 0
-    mock_git.checkout.assert_called_once_with("prepare-2.0.0")
+    mock_git.checkout.assert_has_calls(
+        [
+            call("prepare-2.0.0"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+        ]
+    )
     mock_git.commit.assert_not_called()
     mock_git.push.assert_called_once_with(
         "origin", "prepare-2.0.0", set_upstream=True, force=True
@@ -150,7 +157,13 @@
 
     # Assert
     assert result == 0
-    mock_git.checkout.assert_called_once_with("prepare-2.0.0")
+    mock_git.checkout.assert_has_calls(
+        [
+            call("prepare-2.0.0"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+        ]
+    )
     mock_git.commit.assert_not_called()
     mock_git.push.assert_called_once_with(
         "origin", "prepare-2.0.0", set_upstream=True, force=True
@@ -185,7 +198,13 @@
 
     # Assert
     assert result == 0
-    mock_git.checkout.assert_called_once_with("prepare-2.0.0")
+    mock_git.checkout.assert_has_calls(
+        [
+            call("prepare-2.0.0"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+        ]
+    )
     mock_git.commit.assert_not_called()
     mock_git.push.assert_called_once_with(
         "origin", "prepare-2.0.0", set_upstream=True, force=True
diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py
index 8733573..475ada4 100644
--- a/tests/tools/private/release/process_backports_test.py
+++ b/tests/tools/private/release/process_backports_test.py
@@ -65,7 +65,13 @@
             call("origin"),
         ]
     )
-    mock_git.checkout.assert_called_once_with("release/2.0", track_remote="origin")
+    mock_git.checkout.assert_has_calls(
+        [
+            call("release/2.0", track_remote="origin"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+        ]
+    )
     mock_git.cherry_pick.assert_called_once_with("abcdef12")
     mock_changelog.update_changelog.assert_called_once_with("2.0.0", "2026-07-01")
     mock_git.add_modified_and_deleted.assert_called_once()
@@ -206,7 +212,13 @@
     result = ProcessBackports(args, mock_git, mock_gh).run()
 
     assert result == 1
-    mock_git.checkout.assert_called_once_with("release/2.0", track_remote="origin")
+    mock_git.checkout.assert_has_calls(
+        [
+            call("release/2.0", track_remote="origin"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+        ]
+    )
     mock_git.cherry_pick.assert_called_once_with("abcdef12")
     mock_git.cherry_pick_abort.assert_called_once()
 
diff --git a/tests/tools/private/release/sync_changelog_test.py b/tests/tools/private/release/sync_changelog_test.py
index 2cdf202..d5b1b0e 100644
--- a/tests/tools/private/release/sync_changelog_test.py
+++ b/tests/tools/private/release/sync_changelog_test.py
@@ -68,8 +68,11 @@
     mock_git.checkout.assert_has_calls(
         [
             call("main", track_remote="origin"),
+            call().__enter__(),
             call("sync-changelog-2.0.0-6affdae", create_branch=True),
-            call("main"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+            call().__exit__(None, None, None),
         ]
     )
     mock_process_news_class.assert_called_once()
@@ -160,8 +163,11 @@
     mock_git.checkout.assert_has_calls(
         [
             call("main", track_remote="origin"),
+            call().__enter__(),
             call("sync-changelog-2.0.0-6affdae"),
-            call("main"),
+            call().__enter__(),
+            call().__exit__(None, None, None),
+            call().__exit__(None, None, None),
         ]
     )
     mock_git.reset_hard.assert_called_once_with(reset_to="main")