diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 9f8093c2..47eb7068 100644 --- a/docs/commands/deploy.md +++ b/docs/commands/deploy.md @@ -18,7 +18,7 @@ backend: value: foo # <- and this one in a list, selected via sibling value 'TEST' ``` -With the following command GitOps CLI will update all values on the default branch. +With the following command GitOps CLI will update all values on the default branch. Use `--branch` to commit on an existing branch, or to create that branch if it does not exist yet. ```bash gitopscli deploy \ @@ -99,6 +99,8 @@ This will end up in one single commit with your specified commit-message. In some cases you might want to create a pull request for your updates. You can achieve this by adding `--create-pr` to the command. The pull request can be left open or merged directly with `--auto-merge`. +By default GitOps CLI creates a random branch for the pull request (e.g. `gitopscli-deploy-b973b5bb`). Use `--branch` to specify that branch name instead: an existing remote branch is checked out, otherwise a new branch is created. `--branch` also works without `--create-pr`. + ```bash gitopscli deploy \ --git-provider-url https://bitbucket.baloise.dev \ @@ -111,6 +113,7 @@ gitopscli deploy \ --file "example/values.yaml" \ --values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env[?name==''TEST''].value': bar}" \ --create-pr \ + --branch "deploy/myapp" \ --auto-merge ``` @@ -123,9 +126,9 @@ gitopscli deploy \ ``` usage: gitopscli deploy [-h] --file FILE --values VALUES [--single-commit [SINGLE_COMMIT]] - [--commit-message COMMIT_MESSAGE] --username USERNAME - --password PASSWORD [--git-user GIT_USER] - [--git-email GIT_EMAIL] + [--commit-message COMMIT_MESSAGE] [--branch BRANCH] + --username USERNAME --password PASSWORD + [--git-user GIT_USER] [--git-email GIT_EMAIL] [--git-author-name GIT_AUTHOR_NAME] [--git-author-email GIT_AUTHOR_EMAIL] --organisation ORGANISATION --repository-name @@ -145,6 +148,8 @@ options: Create only single commit for all updates --commit-message COMMIT_MESSAGE Specify exact commit message of deployment commit + --branch BRANCH Specify the branch where the changes should be + committed to. Creates a new branch if it doesn't exist yet. --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env variable) --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD @@ -165,7 +170,7 @@ options: --git-provider-url GIT_PROVIDER_URL Git provider base API URL (e.g. https://bitbucket.example.tld) --create-pr [CREATE_PR] - Creates a Pull Request + Creates a Pull Request from a random new branch. Use --branch to use a specific branch name instead. --auto-merge [AUTO_MERGE] Automatically merge the created PR (only valid with --create-pr) --merge-method MERGE_METHOD diff --git a/gitopscli/cliparser.py b/gitopscli/cliparser.py index 5b28e051..42091be1 100644 --- a/gitopscli/cliparser.py +++ b/gitopscli/cliparser.py @@ -103,6 +103,15 @@ def __create_deploy_parser() -> ArgumentParser: type=str, default=None, ) + parser.add_argument( + "--branch", + help=( + "Specify the branch where the changes should be committed to. " + "If omitted with --create-pr, a random branch is created." + ), + type=str, + default=None, + ) __add_git_credentials_args(parser) __add_git_commit_user_args(parser) __add_git_org_and_repo_args(parser) diff --git a/gitopscli/commands/deploy.py b/gitopscli/commands/deploy.py index 6884abeb..bb634765 100644 --- a/gitopscli/commands/deploy.py +++ b/gitopscli/commands/deploy.py @@ -38,6 +38,7 @@ class Args(GitApiConfig): pr_labels: list[str] | None merge_parameters: Any | None merge_method: Literal["squash", "rebase", "merge"] = "merge" + branch: str | None = None def __init__(self, args: DeployCommand.Args) -> None: self.__args = args @@ -46,11 +47,17 @@ def __init__(self, args: DeployCommand.Args) -> None: def execute(self) -> None: git_repo_api = self.__create_git_repo_api() with GitRepo(git_repo_api) as git_repo: - git_repo.clone() - if self.__args.create_pr: - pr_branch = f"gitopscli-deploy-{str(uuid.uuid4())[:8]}" - git_repo.new_branch(pr_branch) + pr_branch = self.__args.branch or f"gitopscli-deploy-{str(uuid.uuid4())[:8]}" + if self.__args.branch: + git_repo.checkout_or_create_branch(pr_branch) + else: + git_repo.clone() + git_repo.new_branch(pr_branch) + elif self.__args.branch: + git_repo.checkout_or_create_branch(self.__args.branch) + else: + git_repo.clone() updated_values = self.__update_values(git_repo) if not updated_values: diff --git a/gitopscli/git_api/git_repo.py b/gitopscli/git_api/git_repo.py index 262659d6..bf2b050a 100644 --- a/gitopscli/git_api/git_repo.py +++ b/gitopscli/git_api/git_repo.py @@ -4,7 +4,7 @@ from types import TracebackType from typing import Literal -from git import GitCommandError, GitError, Repo +from git import Git, GitCommandError, GitError, Repo from typing_extensions import Self # noqa: UP035 from gitopscli.gitops_exception import GitOpsException @@ -77,6 +77,28 @@ def new_branch(self, branch: str) -> None: except GitError as ex: raise GitOpsException(f"Error creating new branch '{branch}'.") from ex + def checkout(self, branch: str) -> None: + logging.info("Checking out branch: %s", branch) + repo = self.__get_repo() + try: + current_branch = repo.git.branch("--show-current") + if current_branch == branch: + return + repo.git.fetch("origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", "--depth=1") + repo.git.checkout("-B", branch, f"origin/{branch}") + repo.git.config(f"branch.{branch}.remote", "origin") + repo.git.config(f"branch.{branch}.merge", f"refs/heads/{branch}") + except GitError as ex: + raise GitOpsException(f"Error checking out branch '{branch}'.") from ex + + def checkout_or_create_branch(self, branch: str) -> None: + url = self.__api.get_clone_url() + if self.__remote_branch_exists(branch, remote=url): + self.clone(branch) + else: + self.clone() + self.new_branch(branch) + def commit( self, git_user: str, @@ -131,9 +153,27 @@ def get_author_from_last_commit(self) -> str: last_commit = repo.head.commit return str(repo.git.show("-s", "--format=%an <%ae>", last_commit.hexsha)) - def __remote_branch_exists(self, branch: str) -> bool: - repo = self.__get_repo() - result = repo.git.ls_remote("--heads", "origin", f"refs/heads/{branch}") + def __remote_branch_exists(self, branch: str, remote: str = "origin") -> bool: + if remote == "origin": + repo = self.__get_repo() + result = repo.git.ls_remote("--heads", remote, f"refs/heads/{branch}") + else: + username = self.__api.get_username() + password = self.__api.get_password() + try: + g = Git() + if username is not None and password is not None: + if not self.__tmp_dir: + self.__tmp_dir = create_tmp_dir() + credentials_file = self.__create_credentials_file(username, password) + result = g.execute([ + "git", "-c", f"credential.helper={credentials_file}", + "ls-remote", "--heads", remote, f"refs/heads/{branch}", + ]) + else: + result = g.ls_remote("--heads", remote, f"refs/heads/{branch}") + except GitError as ex: + raise GitOpsException(f"Error checking remote branch '{branch}' at '{remote}'.") from ex if isinstance(result, str): return result.strip() != "" return bool(result) diff --git a/tests/commands/test_deploy.py b/tests/commands/test_deploy.py index e68d546c..947f68b1 100644 --- a/tests/commands/test_deploy.py +++ b/tests/commands/test_deploy.py @@ -46,6 +46,7 @@ def setUp(self): self.git_repo_mock.__exit__.return_value = False self.git_repo_mock.clone.return_value = None self.git_repo_mock.new_branch.return_value = None + self.git_repo_mock.checkout_or_create_branch.return_value = None self.example_commit_hash = "5f3a443e7ecb3723c1a71b9744e2993c0b6dfc00" self.git_repo_mock.commit.return_value = self.example_commit_hash self.git_repo_mock.pull_rebase.return_value = None @@ -137,8 +138,8 @@ def test_create_pr_single_value_change_happy_flow_with_output(self, mock_print): assert self.mock_manager.method_calls == [ call.GitRepoApiFactory.create(args, "ORGA", "REPO"), call.GitRepo(self.git_repo_api_mock), - call.GitRepo.clone(), call.uuid.uuid4(), + call.GitRepo.clone(), call.GitRepo.new_branch("gitopscli-deploy-b973b5bb"), call.GitRepo.get_full_file_path("test/file.yml"), call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"), @@ -192,8 +193,8 @@ def test_create_pr_multiple_value_changes_happy_flow_with_output(self, mock_prin assert self.mock_manager.method_calls == [ call.GitRepoApiFactory.create(args, "ORGA", "REPO"), call.GitRepo(self.git_repo_api_mock), - call.GitRepo.clone(), call.uuid.uuid4(), + call.GitRepo.clone(), call.GitRepo.new_branch("gitopscli-deploy-b973b5bb"), call.GitRepo.get_full_file_path("test/file.yml"), call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"), @@ -253,8 +254,8 @@ def test_create_pr_and_merge_happy_flow(self, mock_print): assert self.mock_manager.method_calls == [ call.GitRepoApiFactory.create(args, "ORGA", "REPO"), call.GitRepo(self.git_repo_api_mock), - call.GitRepo.clone(), call.uuid.uuid4(), + call.GitRepo.clone(), call.GitRepo.new_branch("gitopscli-deploy-b973b5bb"), call.GitRepo.get_full_file_path("test/file.yml"), call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"), @@ -277,6 +278,93 @@ def test_create_pr_and_merge_happy_flow(self, mock_print): no_output = "" self.assertMultiLineEqual(mock_print.getvalue(), no_output) + @mock.patch("sys.stdout", new_callable=StringIO) + def test_create_pr_with_custom_branch(self, mock_print): + args = DeployCommand.Args( + file="test/file.yml", + values={"a.b.c": "foo"}, + username="USERNAME", + password="PASSWORD", + git_user="GIT_USER", + git_email="GIT_EMAIL", + git_author_name=None, + git_author_email=None, + create_pr=True, + auto_merge=False, + single_commit=False, + organisation="ORGA", + repository_name="REPO", + git_provider=GitProvider.GITHUB, + git_provider_url=None, + commit_message=None, + json=False, + pr_labels=None, + merge_parameters=None, + branch="my-custom-branch", + ) + DeployCommand(args).execute() + + assert self.mock_manager.method_calls == [ + call.GitRepoApiFactory.create(args, "ORGA", "REPO"), + call.GitRepo(self.git_repo_api_mock), + call.GitRepo.checkout_or_create_branch("my-custom-branch"), + call.GitRepo.get_full_file_path("test/file.yml"), + call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"), + call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"), + call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "changed 'a.b.c' to 'foo' in test/file.yml"), + call.GitRepo.pull_rebase(), + call.GitRepo.push(), + call.GitRepoApi.create_pull_request_to_default_branch( + "my-custom-branch", + "Updated value in test/file.yml", + "Updated 1 value in `test/file.yml`:\n```yaml\na.b.c: foo\n```\n", + ), + ] + + no_output = "" + self.assertMultiLineEqual(mock_print.getvalue(), no_output) + + @mock.patch("sys.stdout", new_callable=StringIO) + def test_custom_branch_without_create_pr(self, mock_print): + args = DeployCommand.Args( + file="test/file.yml", + values={"a.b.c": "foo"}, + username="USERNAME", + password="PASSWORD", + git_user="GIT_USER", + git_email="GIT_EMAIL", + git_author_name=None, + git_author_email=None, + create_pr=False, + auto_merge=False, + single_commit=False, + organisation="ORGA", + repository_name="REPO", + git_provider=GitProvider.GITHUB, + git_provider_url=None, + commit_message=None, + json=False, + pr_labels=None, + merge_parameters=None, + branch="my-custom-branch", + ) + DeployCommand(args).execute() + + assert self.mock_manager.method_calls == [ + call.GitRepoApiFactory.create(args, "ORGA", "REPO"), + call.GitRepo(self.git_repo_api_mock), + call.GitRepo.checkout_or_create_branch("my-custom-branch"), + call.GitRepo.get_full_file_path("test/file.yml"), + call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"), + call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"), + call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "changed 'a.b.c' to 'foo' in test/file.yml"), + call.GitRepo.pull_rebase(), + call.GitRepo.push(), + ] + + no_output = "" + self.assertMultiLineEqual(mock_print.getvalue(), no_output) + @mock.patch("sys.stdout", new_callable=StringIO) def test_single_commit_happy_flow(self, mock_print): args = DeployCommand.Args( diff --git a/tests/git_api/test_git_repo.py b/tests/git_api/test_git_repo.py index 9286e1de..75002138 100644 --- a/tests/git_api/test_git_repo.py +++ b/tests/git_api/test_git_repo.py @@ -208,6 +208,128 @@ def test_new_branch_name_collision(self, logging_mock): self.assertEqual("Error creating new branch 'master'.", str(ex.value)) logging_mock.info.assert_called_once_with("Creating new branch: %s", "master") + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_or_create_branch_creates_when_missing(self, logging_mock): + with GitRepo(self.__mock_repo_api) as testee: + testee.checkout_or_create_branch("foo") + + repo = Repo(testee.get_full_file_path(".")) + self.assertEqual("foo", repo.git.branch("--show-current")) + readme = self.__read_file(testee.get_full_file_path("README.md")) + self.assertEqual("master branch readme", readme) + logging_mock.info.assert_any_call("Creating new branch: %s", "foo") + + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_or_create_branch_checks_out_existing(self, logging_mock): + with GitRepo(self.__mock_repo_api) as testee: + testee.checkout_or_create_branch("xyz") + + repo = Repo(testee.get_full_file_path(".")) + self.assertEqual("xyz", repo.git.branch("--show-current")) + readme = self.__read_file(testee.get_full_file_path("README.md")) + self.assertEqual("xyz branch readme", readme) + logging_mock.info.assert_called_once_with( + "Cloning repository: %s (branch: %s)", + self.__mock_repo_api.get_clone_url(), + "xyz", + ) + + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_or_create_branch_current_branch(self, logging_mock): + with GitRepo(self.__mock_repo_api) as testee: + testee.checkout_or_create_branch("master") + + repo = Repo(testee.get_full_file_path(".")) + self.assertEqual("master", repo.git.branch("--show-current")) + logging_mock.info.assert_called_once_with( + "Cloning repository: %s (branch: %s)", + self.__mock_repo_api.get_clone_url(), + "master", + ) + + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_or_create_branch_clones_existing_branch_directly(self, logging_mock): + with GitRepo(self.__mock_repo_api) as testee: + testee.checkout_or_create_branch("xyz") + + repo = Repo(testee.get_full_file_path(".")) + self.assertEqual("xyz", repo.git.branch("--show-current")) + readme = self.__read_file(testee.get_full_file_path("README.md")) + self.assertEqual("xyz branch readme", readme) + logging_mock.info.assert_called_once_with( + "Cloning repository: %s (branch: %s)", + self.__mock_repo_api.get_clone_url(), + "xyz", + ) + + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_or_create_branch_clones_default_and_creates_when_missing(self, logging_mock): + with GitRepo(self.__mock_repo_api) as testee: + testee.checkout_or_create_branch("brand-new-branch") + + repo = Repo(testee.get_full_file_path(".")) + self.assertEqual("brand-new-branch", repo.git.branch("--show-current")) + readme = self.__read_file(testee.get_full_file_path("README.md")) + self.assertEqual("master branch readme", readme) + logging_mock.info.assert_any_call("Cloning repository: %s", self.__mock_repo_api.get_clone_url()) + logging_mock.info.assert_any_call("Creating new branch: %s", "brand-new-branch") + + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_or_create_branch_with_credentials_existing_branch(self, logging_mock): + self.__mock_repo_api.get_username.return_value = "User" + self.__mock_repo_api.get_password.return_value = "Pass" + with GitRepo(self.__mock_repo_api) as testee: + testee.checkout_or_create_branch("xyz") + + repo = Repo(testee.get_full_file_path(".")) + self.assertEqual("xyz", repo.git.branch("--show-current")) + readme = self.__read_file(testee.get_full_file_path("README.md")) + self.assertEqual("xyz branch readme", readme) + + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_or_create_branch_with_credentials_new_branch(self, logging_mock): + self.__mock_repo_api.get_username.return_value = "User" + self.__mock_repo_api.get_password.return_value = "Pass" + with GitRepo(self.__mock_repo_api) as testee: + testee.checkout_or_create_branch("brand-new-branch") + + repo = Repo(testee.get_full_file_path(".")) + self.assertEqual("brand-new-branch", repo.git.branch("--show-current")) + readme = self.__read_file(testee.get_full_file_path("README.md")) + self.assertEqual("master branch readme", readme) + + def test_checkout_or_create_branch_raises_on_remote_lookup_failure(self): + self.__mock_repo_api.get_clone_url.return_value = "invalid_url" + with GitRepo(self.__mock_repo_api) as testee: + with pytest.raises(GitOpsException) as ex: + testee.checkout_or_create_branch("some-branch") + self.assertIn("invalid_url", str(ex.value)) + + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_existing_branch(self, logging_mock): + with GitRepo(self.__mock_repo_api) as testee: + testee.clone() + logging_mock.reset_mock() + + testee.checkout("xyz") + + repo = Repo(testee.get_full_file_path(".")) + self.assertEqual("xyz", repo.git.branch("--show-current")) + readme = self.__read_file(testee.get_full_file_path("README.md")) + self.assertEqual("xyz branch readme", readme) + logging_mock.info.assert_called_once_with("Checking out branch: %s", "xyz") + + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_unknown_branch(self, logging_mock): + with GitRepo(self.__mock_repo_api) as testee: + testee.clone() + logging_mock.reset_mock() + + with pytest.raises(GitOpsException) as ex: + testee.checkout("unknown") + self.assertEqual("Error checking out branch 'unknown'.", str(ex.value)) + logging_mock.info.assert_called_once_with("Checking out branch: %s", "unknown") + @patch("gitopscli.git_api.git_repo.logging") def test_commit(self, logging_mock): with GitRepo(self.__mock_repo_api) as testee: @@ -398,6 +520,40 @@ def test_pull_rebase_remote_branch_single_commit(self, logging_mock): self.assertEqual("origin branch commit\n", commits[1].message) self.assertEqual("initial xyz branch commit\n", commits[2].message) + @patch("gitopscli.git_api.git_repo.logging") + def test_checkout_or_create_existing_branch_then_pull_rebase_and_push(self, logging_mock): + origin_repo = self.__origin + with GitRepo(self.__mock_repo_api) as testee: + testee.checkout_or_create_branch("xyz") + + with Path(testee.get_full_file_path("local.md")).open("w") as outfile: + outfile.write("local file") + local_repo = Repo(testee.get_full_file_path(".")) + local_repo.git.add("--all") + local_repo.config_writer().set_value("user", "email", "unit@tester.com").release() + local_repo.git.commit("-m", "local branch commit") + + origin_repo.git.checkout("xyz") + with Path(f"{origin_repo.working_dir}/origin.md").open("w") as readme: + readme.write("origin file") + origin_repo.git.add("--all") + origin_repo.config_writer().set_value("user", "email", "unit@tester.com").release() + origin_repo.git.commit("-m", "origin branch commit") + + logging_mock.reset_mock() + + testee.pull_rebase() + + logging_mock.info.assert_called_once_with("Pull and rebase: %s", "xyz") + + testee.push() + + commits = list(self.__origin.iter_commits("xyz")) + self.assertEqual(4, len(commits)) + self.assertEqual("local branch commit\n", commits[0].message) + self.assertEqual("origin branch commit\n", commits[1].message) + self.assertEqual("initial xyz branch commit\n", commits[2].message) + @patch("gitopscli.git_api.git_repo.logging") def test_pull_rebase_without_new_commits(self, logging_mock): with GitRepo(self.__mock_repo_api) as testee: diff --git a/tests/test_cliparser.py b/tests/test_cliparser.py index f71e2577..202ca116 100644 --- a/tests/test_cliparser.py +++ b/tests/test_cliparser.py @@ -310,9 +310,9 @@ EXPECTED_DEPLOY_NO_ARGS_ERROR = """\ usage: gitopscli deploy [-h] --file FILE --values VALUES [--single-commit [SINGLE_COMMIT]] - [--commit-message COMMIT_MESSAGE] --username USERNAME - --password PASSWORD [--git-user GIT_USER] - [--git-email GIT_EMAIL] + [--commit-message COMMIT_MESSAGE] [--branch BRANCH] + --username USERNAME --password PASSWORD + [--git-user GIT_USER] [--git-email GIT_EMAIL] [--git-author-name GIT_AUTHOR_NAME] [--git-author-email GIT_AUTHOR_EMAIL] --organisation ORGANISATION --repository-name @@ -328,9 +328,9 @@ EXPECTED_DEPLOY_HELP = """\ usage: gitopscli deploy [-h] --file FILE --values VALUES [--single-commit [SINGLE_COMMIT]] - [--commit-message COMMIT_MESSAGE] --username USERNAME - --password PASSWORD [--git-user GIT_USER] - [--git-email GIT_EMAIL] + [--commit-message COMMIT_MESSAGE] [--branch BRANCH] + --username USERNAME --password PASSWORD + [--git-user GIT_USER] [--git-email GIT_EMAIL] [--git-author-name GIT_AUTHOR_NAME] [--git-author-email GIT_AUTHOR_EMAIL] --organisation ORGANISATION --repository-name @@ -350,6 +350,9 @@ Create only single commit for all updates --commit-message COMMIT_MESSAGE Specify exact commit message of deployment commit + --branch BRANCH Specify the branch where the changes should be + committed to. If omitted with --create-pr, a random + branch is created. --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env variable) --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD @@ -1109,6 +1112,7 @@ def test_deploy_required_args(self): self.assertEqual(args.values, {"a.b": 42}) self.assertIsNone(args.git_provider_url) + self.assertIsNone(args.branch) self.assertFalse(args.create_pr) self.assertFalse(args.auto_merge) self.assertFalse(args.single_commit) @@ -1142,6 +1146,8 @@ def test_deploy_all_args(self): "FILE", "--values", "{a.b: 42}", # yaml + "--branch", + "BRANCH", "--create-pr", "--auto-merge", "--single-commit", @@ -1164,6 +1170,7 @@ def test_deploy_all_args(self): self.assertEqual(args.git_provider, GitProvider.BITBUCKET) self.assertEqual(args.git_provider_url, "GIT_PROVIDER_URL") + self.assertEqual(args.branch, "BRANCH") self.assertTrue(args.create_pr) self.assertTrue(args.auto_merge) self.assertTrue(args.single_commit)