Repository: wayfair-incubator/pygitops (language: python)

Issue:
Implement pygitops source
Relocate source code from wayfair internal pygitops repository
Ensure docstrings are complete and make sense
Since our function docstrings are used in the autogenerated api reference, we should ensure that they are:

* complete
* make sense
* correctly formatted


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index c192e02..8f61b1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -274,3 +274,5 @@ dmypy.json
 .venv/
 
 # End of https://www.gitignore.io/api/emacs,python,intellij+all
+
+lockfiles/
diff --git a/docs/api.md b/docs/api.md
index 7d9df96..0e33ae8 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -2,4 +2,20 @@
 
 ## Functions
 
+::: pygitops.operations.build_github_repo_url
+
 ::: pygitops.operations.get_updated_repo
+
+::: pygitops.operations.get_default_branch
+
+::: pygitops.operations.feature_branch
+
+::: pygitops.operations.stage_commit_push_changes
+
+## Exceptions
+
+::: pygitops.exceptions.PyGitOpsError
+
+::: pygitops.exceptions.PyGitOpsValueError
+
+::: pygitops.exceptions.PyGitOpsStagedItemsError
diff --git a/pygitops/_constants.py b/pygitops/_constants.py
new file mode 100644
index 0000000..c316280
--- /dev/null
+++ b/pygitops/_constants.py
@@ -0,0 +1,5 @@
+GIT_BRANCH_MASTER = "master"
+GIT_BRANCH_MAIN = "main"
+
+GITHUB_PUBLIC_DOMAIN_NAME = "github.com"
+KNOWN_DEFAULT_BRANCHES = [GIT_BRANCH_MASTER, GIT_BRANCH_MAIN]
diff --git a/pygitops/_util.py b/pygitops/_util.py
new file mode 100644
index 0000000..aeb89a1
--- /dev/null
+++ b/pygitops/_util.py
@@ -0,0 +1,103 @@
+import logging
+import os
+from contextlib import contextmanager
+from pathlib import Path
+
+from filelock import FileLock, Timeout
+from git import PushInfo, Repo
+
+from pygitops.exceptions import PyGitOpsError
+
+_logger = logging.getLogger(__name__)
+_lockfile_path = Path("lockfiles")
+
+FILELOCK_ACQUIRE_TIMEOUT_SECONDS = 10
+
+
+@contextmanager
+def lock_repo(repo: Repo):
+    """
+    Lock a given repo for use.
+
+    :param repo: The repo to lock on.
+    """
+
+    repo_name = os.path.basename(os.path.normpath(repo.working_dir))
+    lockfile_name = str(get_lockfile_path(repo_name))
+
+    lock = FileLock(lockfile_name)
+    try:
+        with lock.acquire(timeout=FILELOCK_ACQUIRE_TIMEOUT_SECONDS):
+            _logger.debug(
+                f"Successfully acquired lock: {lockfile_name} for repo: {repo}"
+            )
+            yield
+            _logger.debug(f"About to release lock: {lockfile_name} for repo: {repo}")
+    except Timeout:
+        raise PyGitOpsError(
+            f"The timeout of {FILELOCK_ACQUIRE_TIMEOUT_SECONDS} seconds was exceeded when attempting to acquire the lockfile: {lockfile_name}"
+        )
+
+
+def checkout_pull_branch(repo: Repo, branch: str) -> None:
+    """
+    Pull changes from the specified branch of a repo.
+
+    Will fail if the branch does not exist on the remote
+    """
+
+    origin = repo.remotes.origin
+
+    # `origin.refs` might be out of date, this makes local checkout of repo aware of remote branches
+    origin.fetch()
+
+    if branch not in repo.heads:
+
+        # handle case where provided branch name isnt a known remote branch
+        if branch not in origin.refs:
+            raise PyGitOpsError(
+                f"The provided branch {branch} does not exist on the remote origin"
+            )
+
+        repo.create_head(branch, origin.refs[branch])
+        _logger.debug(
+            f"[Pull Branch] Create head was successful for repo: {repo}, branch: {branch}"
+        )
+
+        repo.heads[branch].set_tracking_branch(origin.refs[branch])
+        _logger.debug(
+            f"[Pull Branch] Set tracking branch successful for repo: {repo}, branch: {branch}"
+        )
+
+    # checkout local `branch` to working tree
+    repo.heads[branch].checkout()
+    _logger.debug(
+        f"[Pull Branch] Checkout local branch successful for repo: {repo}, branch: {branch}"
+    )
+
+    # pull the changes from the remote branch
+    origin.pull(branch)
+    _logger.debug(
+        f"[Pull Branch] Pull of changes successful for repo: {repo}, branch: {branch}"
+    )
+
+
+def get_lockfile_path(repo_name: str) -> Path:
+    """Get a lockfile to lock a git repo."""
+
+    if not _lockfile_path.is_dir():
+        _lockfile_path.mkdir()
+
+    return _lockfile_path / f"{repo_name}_lock_file.lock"
+
+
+def push_error_present(push_info: PushInfo) -> bool:
+    """
+    Given an instance of `git.remote.PushInfo`, determine if error is present.
+
+    GitPython's remote operations use bitflags to indicate status of operations:
+        https://gitpython.readthedocs.io/en/stable/reference.html#git.remote.PushInfo
+
+    Check for presence of the error flag in the returned flags bitmask.
+    """
+    return bool(push_info.flags & push_info.ERROR)
diff --git a/pygitops/exceptions.py b/pygitops/exceptions.py
new file mode 100644
index 0000000..b24a4d5
--- /dev/null
+++ b/pygitops/exceptions.py
@@ -0,0 +1,10 @@
+class PyGitOpsError(Exception):
+    """Parent exception class for all errors coming from `pygitops` package"""
+
+
+class PyGitOpsValueError(PyGitOpsError):
+    """The provided argument is invalid"""
+
+
+class PyGitOpsStagedItemsError(PyGitOpsError):
+    """There were no items to stage for commit"""
diff --git a/pygitops/operations.py b/pygitops/operations.py
index 584696f..b23c4f5 100755
--- a/pygitops/operations.py
+++ b/pygitops/operations.py
@@ -1,12 +1,285 @@
-from git import Repo
+import logging
+import re
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Iterator, List, Optional
 
+from filelock import FileLock
+from git import Actor, GitError, Repo
 
-# Placeholder function to test autogeneration of function documentation.
-# Will remove during https://github.com/wayfair-incubator/pygitops/issues/23
-def get_updated_repo(repo_name: str) -> Repo:
+from pygitops._constants import GITHUB_PUBLIC_DOMAIN_NAME, KNOWN_DEFAULT_BRANCHES
+from pygitops._util import checkout_pull_branch as _checkout_pull_branch
+from pygitops._util import get_lockfile_path as _get_lockfile_path
+from pygitops._util import lock_repo as _lock_repo
+from pygitops._util import push_error_present as _push_error_present
+from pygitops.exceptions import (
+    PyGitOpsError,
+    PyGitOpsStagedItemsError,
+    PyGitOpsValueError,
+)
+from pygitops.types import GitBranchType, GitDefaultBranch, PathOrStr
+
+_logger = logging.getLogger(__name__)
+
+
+def stage_commit_push_changes(
+    repo: Repo,
+    branch: GitBranchType,
+    actor: Actor,
+    commit_message: str,
+    items_to_stage: Optional[List[Path]] = None,
+) -> None:
     """
-    Get the specified repository, updating if already present.
+    Handles the logic of persisting filesystem changes to a local repository via a commit to a feature branch.
+
+    This includes staging, committing, and pushing the local changes.
+
+    :param repo: Repository object.
+    :param branch: Feature branch from which changes will be committed and pushed.
+    :param actor: The Github actor with which to perform the commit operation.
+    :param commit_message: Text to be used as the commit message.
+    :param items_to_stage: List of files and directories that will be staged for commit, if None will include all changes.
+    :raises PyGitOpsStagedItemsError: There were no items to stage.
+    :raises PyGitOpsError: There was an error staging, committing, or pushing code.
+    """
+
+    branch_name = _get_branch_name(repo, branch)
+
+    index = repo.index
+    workdir_path = Path(repo.working_dir)
+
+    if items_to_stage is None:
+        untracked_file_paths = [Path(f) for f in repo.untracked_files]
+        items_to_stage = untracked_file_paths + [
+            Path(f.a_path) for f in index.diff(None)
+        ]
 
-    :param repo_name: Name of the repository.
+    if not items_to_stage:
+        raise PyGitOpsStagedItemsError(
+            "There are no items to stage, cannot perform commit operation"
+        )
+
+    # stage and commit changes using the provided actor.
+    for item in items_to_stage:
+        full_path = workdir_path / item
+        index.add(str(item)) if full_path.exists() else index.remove(str(item), r=True)
+    index.commit(commit_message, author=actor, committer=actor)
+
+    _logger.debug(f"Successfully made commit to repository: {repo}")
+
+    # push changes to the remote branch
+    origin = repo.remotes.origin
+    push_info = origin.push(branch_name)[0]
+
+    _logger.debug(
+        f"Commit pushed to remote branch: {branch_name}, push info: {push_info}"
+    )
+
+    if _push_error_present(push_info):
+        raise PyGitOpsError(
+            f"Unable to push to branch {branch_name} of new repo: {repo}"
+        )
+
+
+@contextmanager
+def feature_branch(repo: Repo, branch: GitBranchType) -> Iterator[None]:
     """
-    return Repo.init("some-path")
+    Checkout the desired feature branch.
+
+    Return to the default branch when context is exited.
+
+    The main responsibility of this context manager is to limit concurrent
+    operations on the local filesystem via the FileLock.
+
+    Local git commit operations in particular should leverage this behavior.
+
+    *** Assumes default branch is checked out, otherwise raises PyGitOpsError ***
+
+    :param repo: Repository object
+    :param branch: GitBranchType object indicating the branch we would like to checkout
+    :raises PyGitOpsError: There was an error performing the feature branch operation.
+    """
+
+    branch_name = _get_branch_name(repo, branch)
+    default_branch = get_default_branch(repo)
+
+    untracked_files = repo.untracked_files
+    if untracked_files:
+        raise PyGitOpsError(
+            f"We cannot checkout a feature branch when there are unstaged changes in your current branch: {untracked_files}"
+        )
+
+    active_branch = repo.active_branch
+    if active_branch != repo.heads[default_branch]:
+        raise PyGitOpsError(
+            f"We can only checkout feature branches originating from the default branch: {default_branch}. Current branch is: {active_branch.name}"
+        )
+
+    _logger.debug(
+        f"About to invoke `_lock_repo` for repo: {repo}, branch name: {branch_name}"
+    )
+
+    # lock the following operation such that the process of updating default branch,
+    # checking out a feature branch, applying changes, and moving back to default branch never occurs concurrently
+    with _lock_repo(repo):
+
+        _logger.debug(
+            f"Successfully using repository: {repo}, branch_name: {branch_name}"
+        )
+
+        # before creating a feature branch, checkout and update the default branch of the repository
+        # helpful in the case where a failed process clears the FileLock, but leaves the repo on a feature branch
+        origin = repo.remotes.origin
+        # `origin.refs` might be out of date, this makes local checkout of repo aware of remote branches
+        origin.fetch()
+
+        # Handle the case where the remote is a bare repository with no commit history.
+        # When there is no commit history, there is no default branch
+        if default_branch in origin.refs:
+            _checkout_pull_branch(repo, default_branch)
+            _logger.debug(
+                f"Successfully updated {default_branch} branch of repo: {repo}"
+            )
+
+        if branch_name != default_branch:
+            # create and checkout a local feature branch
+            feature_branch = repo.create_head(branch_name)
+            feature_branch.checkout()
+
+            _logger.debug(
+                f"Successfully checked out feature branch: {branch_name} for repository: {repo}"
+            )
+
+        # give control back to call of this context manager
+        try:
+            yield
+        finally:
+            # move back to the repo's default branch when the `feature_branch` context is exited
+            repo.heads[default_branch].checkout()
+            _logger.debug(
+                f"Successfully moved back to {default_branch} branch for repository: {repo} after using feature branch"
+            )
+
+
+def build_github_repo_url(
+    service_account_name: str,
+    service_account_token: str,
+    repo_namespace: str,
+    repo_name: str,
+    domain_name: str = GITHUB_PUBLIC_DOMAIN_NAME,
+) -> str:
+    """
+    Build github repo_url with the intention of providing as the `repo_url` argument to `get_updated_repo`.
+    This factory function is specific to github, and might not support url schemas of different git services.
+
+    :param service_account_name: Name of the Github service account used to clone repository.
+    :param service_account_token: Raw token associated with the provided Github service account name.
+    :param repo_namespace: The namespace of the target repository.
+    :param repo_name: The name of the target repository.
+    :param domain_name: Github domain, allows us to point to instances other than public Github.
+    """
+
+    return f"https://{service_account_name}:{service_account_token}@{domain_name}/{repo_namespace}/{repo_name}.git"
+
+
+def get_updated_repo(
+    repo_url: str, clone_dir: PathOrStr, repo_name: str, **kwargs
+) -> Repo:
+    """
+    Clone the default branch of the target repository, returning a repo object.
+
+    If repo is already present, we will pull in the latest changes to the default branch of the repo.
+
+    :param repo_url: URL of the Github repository to be cloned.
+    :param clone_dir: The root directory where repositories are cloned.
+    :param repo_name: The name of the repository to be cloned.
+    :raises PyGitOpsError: There was an error cloning the repository.
+    """
+
+    # make sure it's actually a Path if our user passed a str
+    clone_dir = Path(clone_dir)
+
+    dest_repo_path = clone_dir / repo_name
+    git_lockfile_path = _get_lockfile_path(repo_name)
+
+    # Lock the following operation such that only one process will attempt to clone the repo at a time.
+    with FileLock(str(git_lockfile_path)):
+        try:
+            # only clone the project if the local checkout doesnt exist
+            if dest_repo_path.is_dir():
+                repo = Repo(dest_repo_path)
+                # pull down latest changes from `branch` if provided in kwargs, deferring to repo default branch
+                branch = kwargs.get("branch") or get_default_branch(repo)
+                _checkout_pull_branch(repo, branch)
+                return repo
+
+            return Repo.clone_from(repo_url, dest_repo_path, **kwargs)
+        except GitError as e:
+            clean_repo_url = re.sub("https://.+?:.+?@", "https://***:***@", repo_url)
+            raise PyGitOpsError(
+                f"Error cloning repo {clean_repo_url} into destination path {dest_repo_path}: {e}"
+            ) from e
+
+
+def get_default_branch(repo: Repo) -> str:
+    """
+    Get the default branch of the provided repository.
+
+    Although Git itself does not have a concept of default branches, a default branch on a GitHub repository maps directly to the HEAD symbolic ref.
+    Pull in remote branches and update local head refs pointing to the remote repository.
+    We expect the HEAD ref to match a particular pattern that we regex against.
+
+    :param repo: git.Repo instance.
+    :return: string representing name of default branch.
+    """
+
+    git_ref_regex = r"refs\/remotes\/origin\/(\w*)"
+    symbolic_ref_head = "refs/remotes/origin/HEAD"
+
+    # local repo should be aware of branch objects prior to running the `set-head` command, where an unknown branch might be present
+    repo.remotes.origin.fetch()
+
+    # update HEAD pointer before querying local state
+    repo.git.remote(["set-head", "-a", "origin"])
+
+    # query local state for the HEAD pointer
+    default_ref = repo.git.symbolic_ref(symbolic_ref_head)
+
+    match = re.match(git_ref_regex, default_ref)
+    if not match:
+        raise PyGitOpsError(
+            f"None of the symbolic refs using {symbolic_ref_head} matched the regex: {git_ref_regex}"
+        )
+
+    expected_position = 1
+    try:
+        return match.group(expected_position)
+    except IndexError:
+        raise PyGitOpsError(
+            f"The match object did not have a group in position {expected_position}"
+        )
+
+
+def _get_branch_name(repo: Repo, branch: GitBranchType) -> str:
+    """
+    Get the string version of the provided branch name.
+
+    Helpful for resolving non string representations of git branches.
+
+    :param repo: git.Repo instance
+    :param branch: branch to resolve, either a plain string or a value allowable by the `GitBranchType`
+    :return: string representing name of default branch.
+    """
+
+    if isinstance(branch, str):
+        if branch in KNOWN_DEFAULT_BRANCHES:
+            raise PyGitOpsValueError(
+                f"The branch value of {branch} cannot be provided directly. Please instead provide `pygitops.GIT_DEFAULT_BRANCH` to indicate a reference to the default branch of the repository."
+            )
+        return branch
+    elif isinstance(branch, GitDefaultBranch):
+        return get_default_branch(repo)
+
+    raise PyGitOpsValueError(
+        f"The provided branch {branch} is not a valid GitBranchType"
+    )
diff --git a/pygitops/types.py b/pygitops/types.py
new file mode 100644
index 0000000..0ba7b85
--- /dev/null
+++ b/pygitops/types.py
@@ -0,0 +1,14 @@
+from pathlib import Path
+from typing import Union
+
+
+class GitDefaultBranch:
+    """
+    A sentinal / placeholder class used to represent the intention to use the default branch on a repository
+    """
+
+
+GitBranchType = Union[GitDefaultBranch, str]
+PathOrStr = Union[Path, str]
+
+GIT_DEFAULT_BRANCH = GitDefaultBranch()
diff --git a/requirements.txt b/requirements.txt
index d826c08..3bce4c0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,1 +1,3 @@
-gitpython==3.1.12
+dataclasses==0.6
+filelock~=3.0
+GitPython~=3.1


Test patch:
diff --git a/tests/test_example.py b/tests/test_example.py
deleted file mode 100644
index 813df60..0000000
--- a/tests/test_example.py
+++ /dev/null
@@ -1,2 +0,0 @@
-def test_example():
-    assert True
diff --git a/tests/test_operations.py b/tests/test_operations.py
new file mode 100644
index 0000000..c907c6c
--- /dev/null
+++ b/tests/test_operations.py
@@ -0,0 +1,772 @@
+import re
+from contextlib import contextmanager
+from dataclasses import dataclass
+from pathlib import Path, PosixPath
+
+import pytest
+from git import Actor, GitCommandError, GitError, Repo
+
+from pygitops._constants import (
+    GIT_BRANCH_MAIN,
+    GIT_BRANCH_MASTER,
+    GITHUB_PUBLIC_DOMAIN_NAME,
+)
+from pygitops._util import checkout_pull_branch
+from pygitops.exceptions import (
+    PyGitOpsError,
+    PyGitOpsStagedItemsError,
+    PyGitOpsValueError,
+)
+from pygitops.operations import (
+    _get_branch_name,
+    build_github_repo_url,
+    feature_branch,
+    get_default_branch,
+    get_updated_repo,
+    stage_commit_push_changes,
+)
+from pygitops.types import GIT_DEFAULT_BRANCH
+
+SOME_ACTOR = Actor("some-user", "some-user@company.com")
+SOME_COMMIT_MESSAGE = "some-commit-message"
+SOME_CONTENT_FILENAME = "foo.txt"
+SOME_INITIAL_CONTENT = "foobar"
+SOME_NEW_CONTENT = "newbar"
+SOME_FEATURE_BRANCH = "test_branch"
+SOME_DEFAULT_BRANCH_NAME = "some_default_branch_name"
+SOME_SERVICE_ACCOUNT_NAME = "some-service-account-name"
+SOME_SERVICE_ACCOUNT_TOKEN = "test_cred_123"
+
+SOME_GITHUB_DOMAIN_NAME = "some-github-domain-name"
+SOME_REPO_NAMESPACE = "test_namespace"
+SOME_REPO_NAME = "some-repo-name"
+SOME_REPO_URL = f"https://{SOME_SERVICE_ACCOUNT_NAME}:{SOME_SERVICE_ACCOUNT_TOKEN}@{SOME_GITHUB_DOMAIN_NAME}/{SOME_REPO_NAMESPACE}/{SOME_REPO_NAME}.git"
+SOME_CLONE_PATH = Path("foo")
+SOME_LOCAL_REPO_NAME = "local"
+SOME_HEAD_REF = f"refs/remotes/origin/{GIT_BRANCH_MASTER}"
+
+
+@dataclass
+class MultipleTestingRepos:
+    """
+    Model encapsulating the multi-repo setup used when testing git operations.
+
+    :attr remote_repo: The bare remote repo.
+    :attr local_repo: A local repo where state will be asserted.
+    :attr cloned_repo: A cloned repo needed to push to bare remote to test _checkout_pull_branch.
+    """
+
+    remote_repo: Repo
+    local_repo: Repo
+    cloned_repo: Repo
+
+
+def test_stage_commit_push_changes__push_failure__raises_pygitops_error(
+    mocker, tmp_path
+):
+    mocker.patch("pygitops.operations._push_error_present", return_value=True)
+    repos = _initialize_multiple_empty_repos(tmp_path)
+    cloned_repo = repos.cloned_repo
+
+    with feature_branch(cloned_repo, SOME_FEATURE_BRANCH):
+        # Write to remote from the clone, then pull from local
+        test_file_path = Path(cloned_repo.working_dir) / SOME_CONTENT_FILENAME
+        test_file_path.write_text(SOME_INITIAL_CONTENT)
+
+        with pytest.raises(PyGitOpsError, match=SOME_FEATURE_BRANCH):
+            stage_commit_push_changes(
+                cloned_repo, SOME_FEATURE_BRANCH, SOME_ACTOR, SOME_COMMIT_MESSAGE
+            )
+
+
+def test_stage_commit_push_changes__add_new_file__change_persisted(mocker, tmp_path):
+    """
+    Configure 'local' and 'remote' repositories with initial content.
+
+    After generating new content in a local feature branch,
+    verifies that `stage_commit_push_changes` function pushes new content to remote.
+    """
+
+    repos = _initialize_multiple_empty_repos(tmp_path)
+    remote_repo = repos.remote_repo
+    local_repo = repos.local_repo
+
+    with feature_branch(local_repo, SOME_FEATURE_BRANCH):
+
+        test_file_path = Path(local_repo.working_dir) / SOME_CONTENT_FILENAME
+        test_file_path.write_text(SOME_INITIAL_CONTENT)
+
+        # act, asserting expected state before and after operation
+        with _assert_branch_state_stage_commit_push_changes__file_added(
+            remote_repo, local_repo
+        ):
+            stage_commit_push_changes(
+                local_repo, SOME_FEATURE_BRANCH, SOME_ACTOR, SOME_COMMIT_MESSAGE
+            )
+
+            # checkout `SOME_FEATURE_BRANCH` so that `_assert_branch_state_stage_commit_push_changes__file_added` compares correct commits
+            remote_repo.heads[SOME_FEATURE_BRANCH].checkout()
+
+
+def test_stage_commit_push_changes__remove_old_file__change_persisted(mocker, tmp_path):
+    """
+    Configure 'local' and 'remote' repositories with initial content.
+
+    After generating new content in a local feature branch,
+    verifies that `stage_commit_push_changes` function pushes new content to remote.
+    """
+
+    repos = _initialize_multiple_empty_repos(tmp_path)
+    remote_repo = repos.remote_repo
+    local_repo = repos.local_repo
+    cloned_repo = repos.cloned_repo
+
+    with feature_branch(cloned_repo, SOME_FEATURE_BRANCH):
+        test_file_path = Path(cloned_repo.working_dir) / SOME_CONTENT_FILENAME
+        test_file_path.write_text(SOME_INITIAL_CONTENT)
+
+        stage_commit_push_changes(
+            cloned_repo, SOME_FEATURE_BRANCH, SOME_ACTOR, SOME_COMMIT_MESSAGE
+        )
+
+        # Pull file on remote (pushed from clone) into local repo
+        checkout_pull_branch(local_repo, SOME_FEATURE_BRANCH)
+
+        # Assert expected state before and after operation
+        with _assert_branch_state_stage_commit_push_changes__file_removed(
+            remote_repo, local_repo
+        ):
+            removal_path = Path(local_repo.working_dir) / SOME_CONTENT_FILENAME
+            removal_path.unlink()
+
+            # Commit the removal
+            stage_commit_push_changes(
+                local_repo, SOME_FEATURE_BRANCH, SOME_ACTOR, SOME_COMMIT_MESSAGE
+            )
+
+            # checkout `SOME_FEATURE_BRANCH` so that `_assert_branch_state_stage_commit_push_changes__file_added` compares correct commits
+            remote_repo.heads[SOME_FEATURE_BRANCH].checkout()
+
+
+def test_stage_commit_push_changes__with_staged_files__adds_only_requested_file(
+    tmp_path,
+):
+    """
+    Configure 'local' and 'remote' repositories with initial content.
+
+    After generating new content in a local feature branch,
+    verifies that `stage_commit_push_changes` function pushes only the files specified to remote.
+    """
+
+    repos = _initialize_multiple_empty_repos(tmp_path)
+    remote_repo = repos.remote_repo
+    local_repo = repos.local_repo
+    other_file = "my_test_file.txt"
+
+    with feature_branch(local_repo, SOME_FEATURE_BRANCH):
+
+        test_file_path = Path(local_repo.working_dir) / SOME_CONTENT_FILENAME
+        test_file_path.write_text(SOME_INITIAL_CONTENT)
+        other_file_path = Path(local_repo.working_dir) / other_file
+        other_file_path.write_text("other test content")
+
+        # act, asserting expected state before and after operation
+        with _assert_branch_state_stage_commit_push_changes__file_added(
+            remote_repo, local_repo
+        ):
+            stage_commit_push_changes(
+                local_repo,
+                SOME_FEATURE_BRANCH,
+                SOME_ACTOR,
+                SOME_COMMIT_MESSAGE,
+                [test_file_path],
+            )
+
+            # checkout `SOME_FEATURE_BRANCH` so that `_assert_branch_state_stage_commit_push_changes__file_added` compares correct commits
+            remote_repo.heads[SOME_FEATURE_BRANCH].checkout()
+
+        # Assert the "other file" is still untracked
+        assert [other_file] == local_repo.untracked_files
+
+
+def test_stage_commit_push_changes__no_files_to_stage__raises_pygitops_error(
+    mocker, tmp_path
+):
+
+    repos = _initialize_multiple_empty_repos(tmp_path)
+    local_repo = repos.local_repo
+
+    with pytest.raises(PyGitOpsStagedItemsError):
+        with feature_branch(local_repo, SOME_FEATURE_BRANCH):
+            stage_commit_push_changes(
+                local_repo, SOME_FEATURE_BRANCH, SOME_ACTOR, SOME_COMMIT_MESSAGE
+            )
+
+
+def test_feature_branch__untracked_files_present__raises_pygitops_error(mocker):
+    untracked_file = "foo.py"
+    repo = mocker.Mock(
+        untracked_files=[untracked_file],
+        git=mocker.Mock(symbolic_ref=mocker.Mock(return_value=SOME_HEAD_REF)),
+    )
+
+    with pytest.raises(PyGitOpsError, match=untracked_file):
+        with feature_branch(repo, SOME_FEATURE_BRANCH):
+            pass
+
+
+def test_feature_branch__active_branch_not_master__raises_pygitops_error(mocker):
+
+    active_branch_name = "some_active_feature_branch"
+    active_branch = mocker.Mock()
+    repo = mocker.Mock(
+        untracked_files=[],
+        active_branch=mocker.Mock(),
+        heads={active_branch_name: active_branch, GIT_BRANCH_MASTER: None},
+        git=mocker.Mock(symbolic_ref=mocker.Mock(return_value=SOME_HEAD_REF)),
+    )
+
+    with pytest.raises(PyGitOpsError):
+        with feature_branch(repo, SOME_FEATURE_BRANCH):
+            pass
+
+
+def test_feature_branch__master_not_present_in_origin_refs__local_master_not_updated(
+    mocker,
+):
+
+    origin_mock = mocker.Mock(refs=[])
+    remotes_mock = mocker.Mock(origin=origin_mock)
+    local_master_branch = mocker.Mock()
+
+    repo = mocker.Mock(
+        untracked_files=[],
+        active_branch=local_master_branch,
+        remotes=remotes_mock,
+        working_dir=SOME_REPO_NAME,
+        heads={GIT_BRANCH_MASTER: local_master_branch},
+        git=mocker.Mock(symbolic_ref=mocker.Mock(return_value=SOME_HEAD_REF)),
+    )
+
+    _checkout_pull_branch_mock = mocker.patch(
+        "pygitops.operations._checkout_pull_branch"
+    )
+
+    with feature_branch(repo, SOME_FEATURE_BRANCH):
+        pass
+
+    _checkout_pull_branch_mock.assert_not_called()
+
+
+def test_feature_branch__master_present_in_origin_refs__local_master_updated(mocker):
+
+    origin_mock = mocker.Mock(refs=[GIT_BRANCH_MASTER])
+    remotes_mock = mocker.Mock(origin=origin_mock)
+    local_master_branch = mocker.Mock()
+
+    repo = mocker.Mock(
+        untracked_files=[],
+        active_branch=local_master_branch,
+        remotes=remotes_mock,
+        working_dir=SOME_REPO_NAME,
+        heads={GIT_BRANCH_MASTER: local_master_branch},
+        git=mocker.Mock(symbolic_ref=mocker.Mock(return_value=SOME_HEAD_REF)),
+    )
+    _checkout_pull_branch_mock = mocker.patch(
+        "pygitops.operations._checkout_pull_branch"
+    )
+
+    with feature_branch(repo, SOME_FEATURE_BRANCH):
+        pass
+
+    _checkout_pull_branch_mock.assert_called_once_with(repo, GIT_BRANCH_MASTER)
+
+
+@pytest.mark.parametrize(
+    ["feature_branch_name", "checkout_expected"],
+    ((SOME_FEATURE_BRANCH, True), (GIT_DEFAULT_BRANCH, False)),
+)
+def test_feature_branch__conditional_branch_checkout(
+    mocker, feature_branch_name, checkout_expected
+):
+
+    origin_mock = mocker.Mock(refs=[GIT_BRANCH_MASTER])
+    remotes_mock = mocker.Mock(origin=origin_mock)
+    local_master_branch = mocker.Mock()
+    feature_branch_mock = mocker.Mock()
+
+    mocker.patch("pygitops.operations._checkout_pull_branch")
+    repo = mocker.Mock(
+        untracked_files=[],
+        active_branch=local_master_branch,
+        remotes=remotes_mock,
+        working_dir=SOME_REPO_NAME,
+        heads={GIT_BRANCH_MASTER: local_master_branch},
+        create_head=mocker.Mock(return_value=feature_branch_mock),
+        git=mocker.Mock(symbolic_ref=mocker.Mock(return_value=SOME_HEAD_REF)),
+    )
+
+    with feature_branch(repo, feature_branch_name):
+        pass
+
+    assert feature_branch_mock.checkout.called == checkout_expected
+
+
+def test_feature_branch__exception_within_context__cleanup_occurs(mocker, tmp_path):
+
+    some_branch_name = "some-feature-branch"
+    origin_mock = mocker.Mock(refs=[GIT_BRANCH_MASTER])
+    remotes_mock = mocker.Mock(origin=origin_mock)
+    local_master_branch = mocker.Mock()
+
+    mocker.patch("pygitops.operations._checkout_pull_branch")
+
+    repo_mock = mocker.Mock(
+        untracked_files=[],
+        active_branch=local_master_branch,
+        remotes=remotes_mock,
+        working_dir=SOME_REPO_NAME,
+        heads={GIT_BRANCH_MASTER: local_master_branch},
+        create_head=mocker.Mock(),
+        git=mocker.Mock(symbolic_ref=mocker.Mock(return_value=SOME_HEAD_REF)),
+    )
+
+    with pytest.raises(Exception):
+        with feature_branch(repo_mock, some_branch_name):
+            raise Exception("some exception")
+
+    local_master_branch.checkout.assert_called_once()
+
+
+def test_build_github_repo_url__expected_repo_url_returned():
+
+    assert (
+        build_github_repo_url(
+            SOME_SERVICE_ACCOUNT_NAME,
+            SOME_SERVICE_ACCOUNT_TOKEN,
+            SOME_REPO_NAMESPACE,
+            SOME_REPO_NAME,
+            SOME_GITHUB_DOMAIN_NAME,
+        )
+        == f"https://{SOME_SERVICE_ACCOUNT_NAME}:{SOME_SERVICE_ACCOUNT_TOKEN}@{SOME_GITHUB_DOMAIN_NAME}/{SOME_REPO_NAMESPACE}/{SOME_REPO_NAME}.git"
+    )
+
+
+def test_build_github_repo_url__github_domain_name_not_provided__public_github_url_built():
+
+    assert (
+        build_github_repo_url(
+            SOME_SERVICE_ACCOUNT_NAME,
+            SOME_SERVICE_ACCOUNT_TOKEN,
+            SOME_REPO_NAMESPACE,
+            SOME_REPO_NAME,
+        )
+        == f"https://{SOME_SERVICE_ACCOUNT_NAME}:{SOME_SERVICE_ACCOUNT_TOKEN}@{GITHUB_PUBLIC_DOMAIN_NAME}/{SOME_REPO_NAMESPACE}/{SOME_REPO_NAME}.git"
+    )
+
+
+def test_get_updated_repo__git_error_raised_by_repo__raises_pygitops_error(mocker):
+    """GitError is the base exception class for the GitPython API so any exceptions raised by it will be GitErrors"""
+
+    mocker.patch("pygitops.operations.Repo.clone_from", side_effect=GitError)
+
+    with pytest.raises(PyGitOpsError):
+        get_updated_repo(SOME_REPO_URL, SOME_CLONE_PATH, SOME_REPO_NAME)
+
+
+def test_get_updated_repo__repo_dne__fresh_clone_performed(mocker, tmp_path):
+
+    clone_from_mock = mocker.patch("pygitops.operations.Repo.clone_from")
+    expected_clone_path = tmp_path / SOME_REPO_NAME
+
+    get_updated_repo(SOME_REPO_URL, tmp_path, SOME_REPO_NAME)
+
+    clone_from_mock.assert_called_once_with(SOME_REPO_URL, expected_clone_path)
+
+
+def test_get_updated_repo__repo_dne__kwargs_passed_to_clone_from(mocker, tmp_path):
+
+    clone_from_mock = mocker.patch("pygitops.operations.Repo.clone_from")
+    expected_clone_path = tmp_path / SOME_REPO_NAME
+
+    some_kwargs = {"some_arg": "some-value", "another_arg": "another-value"}
+
+    get_updated_repo(SOME_REPO_URL, tmp_path, SOME_REPO_NAME, **some_kwargs)
+
+    clone_from_mock.assert_called_once_with(
+        SOME_REPO_URL, expected_clone_path, **some_kwargs
+    )
+
+
+def test_get_updated_repo__repo_exists_locally__repo_update_performed_against_default_branch(
+    mocker, tmp_path
+):
+
+    (tmp_path / SOME_REPO_NAME).mkdir()
+
+    master_branch_mock = mocker.Mock()
+    repo_mock = mocker.Mock(
+        heads={"master": master_branch_mock},
+        git=mocker.Mock(symbolic_ref=mocker.Mock(return_value=SOME_HEAD_REF)),
+    )
+    mocker.patch("pygitops.operations.Repo", return_value=repo_mock)
+
+    get_updated_repo(SOME_REPO_URL, tmp_path, SOME_REPO_NAME)
+
+    master_branch_mock.checkout.assert_called_once()
+    repo_mock.remotes.origin.pull.assert_called_once()
+
+
+def test_get_updated_repo__repo_exists_locally__repo_update_performed_against_provided_branch(
+    mocker, tmp_path
+):
+
+    (tmp_path / SOME_REPO_NAME).mkdir()
+    repo_mock = mocker.Mock()
+
+    get_default_branch_mock = mocker.patch("pygitops.operations.get_default_branch")
+    _checkout_pull_branch_mock = mocker.patch(
+        "pygitops.operations._checkout_pull_branch"
+    )
+    mocker.patch("pygitops.operations.Repo", return_value=repo_mock)
+
+    get_updated_repo(
+        SOME_REPO_URL, tmp_path, SOME_REPO_NAME, branch=SOME_FEATURE_BRANCH
+    )
+
+    get_default_branch_mock.assert_not_called()
+    _checkout_pull_branch_mock.assert_called_once_with(repo_mock, SOME_FEATURE_BRANCH)
+
+
+def test_get_updated_repo__file_operations__repo_not_present(tmp_path):
+    """
+    A fresh clone of a repository should pull down remote content
+
+    Configures 'remote' repository with initial content
+    Cloning the remote repo should create the expected content in the new local repository
+    """
+
+    # initialize remote repo
+    remote_path = tmp_path / "remote"
+    remote_repo = _initialize_repo_with_content(remote_path)
+
+    # write and commit some content in the remote repo
+    _commit_content(remote_repo, SOME_INITIAL_CONTENT)
+
+    # clone remote repo to local
+    local_repo = get_updated_repo(str(remote_path), tmp_path, SOME_LOCAL_REPO_NAME)
+
+    filepath = f"{local_repo.working_tree_dir}/{SOME_CONTENT_FILENAME}"
+
+    # content from remote should be present in local repo
+    with open(filepath, "r") as test_file:
+        content = test_file.read()
+        assert SOME_INITIAL_CONTENT in content
+
+
+def test_get_updated_repo__local_repo_on_disk__remote_default_branch_changes__remote_default_pulled(
+    tmp_path,
+):
+
+    remote_path = tmp_path / "remote"
+    remote_repo = _initialize_repo_with_content(remote_path)
+
+    local_repo = get_updated_repo(str(remote_path), tmp_path, SOME_LOCAL_REPO_NAME)
+
+    assert local_repo.active_branch.name != SOME_DEFAULT_BRANCH_NAME
+
+    # create and update head ref on remote repo
+    default_head = remote_repo.create_head(SOME_DEFAULT_BRANCH_NAME)
+    remote_repo.head.set_reference(default_head)
+
+    local_repo = get_updated_repo(str(remote_path), tmp_path, SOME_LOCAL_REPO_NAME)
+    assert local_repo.active_branch.name == SOME_DEFAULT_BRANCH_NAME
+
+
+def test_get_updated_repo__file_operations__repo_present_locally(tmp_path):
+    """
+    A clone of a repository that already exists locally should checkout master branch and pull down latest content
+
+    Configures "remote" repository with initial content, and clones repo to local machine
+
+    We will move the local repo to another branch, and make an additional commit on the master branch of the remote repo
+    The side effect of running the clone operation is that the master branch is checked out and updated locally
+    """
+
+    # initialize remote repo
+    remote_path = tmp_path / "remote"
+    remote_repo = _initialize_repo_with_content(remote_path)
+
+    # write and commit some content in the remote repo
+    _commit_content(remote_repo, SOME_INITIAL_CONTENT)
+
+    # setup the preconditions outlined in our test case description
+    local_repo = get_updated_repo(str(remote_path), tmp_path, SOME_LOCAL_REPO_NAME)
+    local_repo.create_head(SOME_FEATURE_BRANCH)
+    local_repo.heads[SOME_FEATURE_BRANCH].checkout()
+    _commit_content(remote_repo, SOME_NEW_CONTENT)
+
+    # act, asserting expected state before and after operation
+    with _assert_get_updated_repo_state(local_repo):
+        get_updated_repo(str(remote_path), tmp_path, SOME_LOCAL_REPO_NAME)
+
+
+def test_get_updated_repo__error__login_not_in_error(mocker):
+
+    mocker.patch(
+        "pygitops.operations.Repo.clone_from",
+        side_effect=GitCommandError("some-command", "some-status"),
+    )
+
+    with pytest.raises(PyGitOpsError) as exc_info:
+        get_updated_repo(SOME_REPO_URL, SOME_CLONE_PATH, SOME_REPO_NAME)
+
+    assert SOME_SERVICE_ACCOUNT_TOKEN not in str(exc_info.value)
+
+
+def test_get_updated_repo__error__login_scrubbed(mocker):
+    mocker.patch(
+        "pygitops.operations.Repo.clone_from",
+        side_effect=GitCommandError("some-command", "some-status"),
+    )
+
+    with pytest.raises(PyGitOpsError) as exc_info:
+        get_updated_repo(SOME_REPO_URL, SOME_CLONE_PATH, SOME_REPO_NAME)
+
+    assert "https://***:***@" in str(exc_info.value)
+
+
+def test_get_updated_repo__clone_dir_as_str(mocker):
+
+    clone_from_mock = mocker.patch(
+        "pygitops.operations.Repo.clone_from",
+    )
+
+    get_updated_repo(SOME_REPO_URL, "clone_dir", SOME_REPO_NAME)
+
+    assert clone_from_mock.called
+    assert clone_from_mock.call_args[0][1] == PosixPath("clone_dir/some-repo-name")
+
+
+def test_get_default_branch__match_not_present__raises_pygitops_error(mocker):
+
+    repo_mock = mocker.Mock(
+        git=mocker.Mock(
+            symbolic_ref=mocker.Mock(return_value="some-unmatched-symbolic-ref")
+        )
+    )
+
+    with pytest.raises(PyGitOpsError):
+        get_default_branch(repo_mock)
+
+
+def test_get_default_branch__match_index_error__raises_pygitops_error(mocker):
+
+    repo_mock = mocker.Mock(
+        git=mocker.Mock(symbolic_ref=mocker.Mock(return_value="refs/remotes/origin/"))
+    )
+
+    mocker.patch(
+        "pygitops.operations.re",
+        new=mocker.Mock(
+            match=mocker.Mock(
+                return_value=mocker.Mock(group=mocker.Mock(side_effect=IndexError))
+            )
+        ),
+    )
+
+    with pytest.raises(PyGitOpsError):
+        get_default_branch(repo_mock)
+
+
+def test_get_default_branch__default_branch_returned(mocker, tmp_path):
+
+    repos = _initialize_multiple_empty_repos(tmp_path)
+    remote_repo = repos.remote_repo
+    local_repo = repos.local_repo
+
+    # the local repo's default branch should reflect the HEAD ref on the remote branch
+    assert get_default_branch(local_repo) == remote_repo.heads[0].name
+
+
+def test_get_default_branch__remote_head_changes__new_default_branch_returned(
+    mocker, tmp_path
+):
+
+    repos = _initialize_multiple_empty_repos(tmp_path)
+    remote_repo = repos.remote_repo
+    local_repo = repos.local_repo
+
+    # initially, the local repo's default branch should reflect the HEAD ref on the remote branch
+    assert get_default_branch(local_repo) == remote_repo.heads[0].name
+
+    # point remote repo's HEAD ref at new branch
+    new_branch = remote_repo.create_head(GIT_BRANCH_MAIN)
+    remote_repo.head.reference = new_branch
+
+    assert get_default_branch(local_repo) == GIT_BRANCH_MAIN
+
+
+@pytest.mark.parametrize("branch_name", [GIT_BRANCH_MAIN, GIT_BRANCH_MASTER])
+def test_get_branch_name__default_branch_str_provided__raises_pygitops_value_error(
+    mocker, branch_name
+):
+
+    with pytest.raises(PyGitOpsValueError):
+        _get_branch_name(mocker.Mock(), branch_name)
+
+
+def test_get_branch_name__str_branch_provided__str_branch_returned(mocker):
+
+    assert _get_branch_name(mocker.Mock(), SOME_FEATURE_BRANCH) == SOME_FEATURE_BRANCH
+
+
+def test_get_branch_name__git_default_branch_provided__get_default_branch_invoked_for_resolution(
+    mocker,
+):
+
+    repo_mock = mocker.Mock()
+    get_default_branch_mock = mocker.patch(
+        "pygitops.operations.get_default_branch", return_value=GIT_BRANCH_MAIN
+    )
+
+    assert _get_branch_name(repo_mock, GIT_DEFAULT_BRANCH) == GIT_BRANCH_MAIN
+
+    get_default_branch_mock.assert_called_once_with(repo_mock)
+
+
+@pytest.mark.parametrize("invalid_value", [0, 1, [], ["foobar"], object(), True, None])
+def test_get_branch_name__invalid_type_provided__raises_pygitops_value_error(
+    mocker, invalid_value
+):
+    with pytest.raises(
+        PyGitOpsValueError,
+        match=re.escape(
+            f"The provided branch {invalid_value} is not a valid GitBranchType"
+        ),
+    ):
+        _get_branch_name(mocker.Mock(), invalid_value)
+
+
+def _initialize_repo_with_content(repo_path):
+    """
+    Helper function used to initialize repo objects, write content, and make initial commit
+    """
+
+    repo_path.mkdir()
+    repo = Repo.init(repo_path)
+
+    _commit_content(repo, SOME_INITIAL_CONTENT, commit_message="Initial commit")
+
+    return repo
+
+
+def _commit_content(repo, content, commit_message="another commit"):
+    """
+    Helper function used to commit content to repository
+    """
+    with open(f"{repo.working_tree_dir}/{SOME_CONTENT_FILENAME}", "a+") as test_file:
+        test_file.write(content)
+    index = repo.index
+    index.add([SOME_CONTENT_FILENAME])
+    index.commit(commit_message)
+
+
+@contextmanager
+def _assert_get_updated_repo_state(repo):
+    """
+    Runs assertions before and after clone operation, in case where repo already exists locally
+    """
+
+    filepath = f"{repo.working_tree_dir}/{SOME_CONTENT_FILENAME}"
+
+    # prior to running the clone operation, `SOME_FEATURE_BRANCH` is the active feature branch
+    assert repo.active_branch == repo.heads[SOME_FEATURE_BRANCH]
+    with open(filepath, "r") as test_file:
+        content = test_file.read()
+        assert SOME_INITIAL_CONTENT in content
+        assert SOME_NEW_CONTENT not in content
+
+    yield
+
+    # after running the clone operation, `GIT_BRANCH_MASTER` is the active feature branch, and the new content is present
+    assert repo.active_branch == repo.heads[GIT_BRANCH_MASTER]
+    with open(filepath, "r") as test_file:
+        content = test_file.read()
+        assert SOME_INITIAL_CONTENT in content
+        assert SOME_NEW_CONTENT in content
+
+
+@contextmanager
+def _assert_branch_state_stage_commit_push_changes__file_added(remote_repo, local_repo):
+    """Run assertions before and after staging, committing, and pushing for a given repo pair."""
+
+    local_content_path = Path(local_repo.working_dir) / SOME_CONTENT_FILENAME
+
+    # The file should exist only on local before pushing
+    assert SOME_INITIAL_CONTENT in local_content_path.read_text()
+
+    yield
+
+    # The remote repo should now have at least one commit,
+    # and the commit history should be present on both local and remote after pushing
+
+    assert len(list(remote_repo.iter_commits())) != 0
+    assert list(remote_repo.iter_commits()) == list(local_repo.iter_commits())
+
+
+@contextmanager
+def _assert_branch_state_stage_commit_push_changes__file_removed(
+    remote_repo, local_repo
+):
+    """Run assertions before and after staging, committing, and pushing for a given repo pair."""
+    local_content_path = Path(local_repo.working_dir) / SOME_CONTENT_FILENAME
+
+    # The file should exist only on local before pushing
+    assert SOME_INITIAL_CONTENT in local_content_path.read_text()
+
+    yield
+
+    # The file should now be removed on local
+    assert not local_content_path.exists()
+    # The commit history should be the same on both local and remote after pushing
+    # (including the commit to remove the file)
+    assert list(remote_repo.iter_commits()) == list(local_repo.iter_commits())
+
+
+# Helper functions for testing git operations
+def _initialize_multiple_empty_repos(base_path) -> MultipleTestingRepos:
+    """
+    Helper function used to initialize and configure local, remote, and clone repos for integration testing.
+    """
+
+    # Setup Test Case
+    local_path = base_path / "local"
+    local_path.mkdir()
+
+    remote_path = base_path / "remote.git"
+    remote_path.mkdir()
+
+    # We have to set up a clone because we can't commit directly to a bare repo
+    clone_path = base_path / "clone"
+    clone_path.mkdir()
+
+    # The remote is set up as a bare repository
+    remote_repo = Repo.init(remote_path)
+
+    # give the remote repo some initial commit history
+    new_file_path = remote_path / "foo.txt"
+    new_file_path.write_text("some changes")
+    remote_repo.index.add([str(new_file_path)])
+    remote_repo.index.commit(
+        SOME_COMMIT_MESSAGE, author=SOME_ACTOR, committer=SOME_ACTOR
+    )
+
+    # Simplify things by assuring local copies are cloned from the remote.
+    # Because they are clones, they will have 'origin' correctly configured
+    local_repo = Repo.clone_from(remote_repo.working_dir, local_path)
+    cloned_repo = Repo.clone_from(remote_repo.working_dir, clone_path)
+
+    return MultipleTestingRepos(
+        remote_repo=remote_repo, local_repo=local_repo, cloned_repo=cloned_repo
+    )
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 0000000..49cb0f7
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,140 @@
+import unittest.mock as mock
+from pathlib import Path
+
+import pytest
+from filelock import Timeout
+from git import PushInfo
+
+from pygitops._util import (
+    checkout_pull_branch,
+    get_lockfile_path,
+    lock_repo,
+    push_error_present,
+)
+from pygitops.exceptions import PyGitOpsError
+
+SOME_REPO_NAME = "some-repo-name"
+
+
+@pytest.mark.parametrize(
+    ("expected_response", "flags"),
+    [(True, PushInfo.ERROR), (True, PushInfo.ERROR + 1), (False, PushInfo.ERROR - 1)],
+)
+def test_push_error_present(expected_response, flags):
+    """
+    Validate push_error_present reports an issue correctly.
+
+    Given some integer representing the push flags, validate the reporting of each
+    push error scenario.
+    """
+
+    push_info = PushInfo(flags, "", "", "")
+    assert push_error_present(push_info) == expected_response
+
+
+def test_lock_repo__filelock_acquire_timeout__raises_pygitops_error(mocker):
+    class FakeFileLock:
+        def acquire(self, timeout: int):
+            raise Timeout("some-file")
+
+    repo_mock = mocker.Mock(working_dir=f"some-repo-namespace/{SOME_REPO_NAME}")
+    mocker.patch("pygitops._util.FileLock", return_value=FakeFileLock())
+
+    with pytest.raises(PyGitOpsError):
+        with lock_repo(repo_mock):
+            pass
+
+
+@pytest.mark.parametrize("path_exists", (True, False))
+def test_get_lockfile_path__expected_path_returned(mocker, tmp_path, path_exists):
+
+    mocker.patch("pygitops._util._lockfile_path", new=tmp_path)
+
+    if path_exists:
+        (tmp_path / SOME_REPO_NAME).mkdir()
+
+    assert get_lockfile_path(SOME_REPO_NAME) == Path(
+        f"{tmp_path}/{SOME_REPO_NAME}_lock_file.lock"
+    )
+
+
+def test_get_lockfile_path__lockfile_dir_dne__lockfile_dir_created(mocker, tmp_path):
+
+    some_lockfile_dir = "some-lockfile-dir"
+    mocker.patch("pygitops._util._lockfile_path", new=tmp_path / some_lockfile_dir)
+
+    assert get_lockfile_path(SOME_REPO_NAME) == Path(
+        f"{tmp_path}/{some_lockfile_dir}/{SOME_REPO_NAME}_lock_file.lock"
+    )
+
+
+def test_pull_branch__head_present__head_not_created(mocker):
+    """
+    In the case where checkout_pull_branch() is invoked, and a head for the specified branch is present,
+    the operation should not create head nor set the tracking branch
+    """
+
+    branch = "test_branch"
+
+    test_branch_head_mock = mocker.Mock()
+
+    origin_mock = mocker.Mock(refs={branch: mock.ANY})
+    create_head_mock = mocker.Mock()
+
+    repo = mocker.Mock(
+        heads={branch: test_branch_head_mock},
+        remotes=mocker.Mock(origin=origin_mock),
+        create_head=create_head_mock,
+    )
+
+    checkout_pull_branch(repo, branch)
+
+    create_head_mock.assert_not_called()
+    test_branch_head_mock.assert_not_called()
+
+
+def test_pull_branch__head_not_present__head_created(mocker):
+    """
+    In the case where checkout_pull_branch() is invoked, and a head is not present,
+    the operation should create the head and set the tracking branch
+    """
+
+    heads = {}
+    branch = "test_branch"
+    ref = mock.ANY
+
+    test_branch_head_mock = mocker.Mock()
+
+    def _create_head(*args):
+        """
+        Invoked as a side effect of the `create_head_mock`.
+        Updates state of `heads` such that tracking branch registration succeeds
+        """
+        heads[branch] = test_branch_head_mock
+
+    origin_mock = mocker.Mock(refs={branch: ref})
+    create_head_mock = mocker.Mock(side_effect=_create_head)
+
+    repo = mocker.Mock(
+        heads=heads,
+        remotes=mocker.Mock(origin=origin_mock),
+        create_head=create_head_mock,
+    )
+
+    checkout_pull_branch(repo, branch)
+
+    create_head_mock.assert_called_once_with(branch, ref)
+    test_branch_head_mock.set_tracking_branch.assert_called_once()
+
+
+def test_pull_branch__branch_not_in_refs__raises_pygitops_error(mocker):
+    """
+    In the case where the provided branch is not in the list of remote refs, A GitOperationError should be raised
+    """
+
+    origin_mock = mocker.Mock(refs={})
+
+    repo = mocker.Mock(heads={}, remotes=mocker.Mock(origin=origin_mock))
+
+    with pytest.raises(PyGitOpsError):
+        checkout_pull_branch(repo, "test_branch")