diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 9f8093c2..9fd1c852 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,9 @@ 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. 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 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..e1c2ff5b 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 @@ -49,8 +50,13 @@ def execute(self) -> None: 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.new_branch(pr_branch) + elif self.__args.branch: + git_repo.checkout_or_create_branch(self.__args.branch) 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..aee778ac 100644 --- a/gitopscli/git_api/git_repo.py +++ b/gitopscli/git_api/git_repo.py @@ -77,6 +77,26 @@ 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: + if self.__remote_branch_exists(branch): + self.checkout(branch) + else: + self.new_branch(branch) + def commit( self, git_user: str, diff --git a/tests/commands/test_deploy.py b/tests/commands/test_deploy.py index e68d546c..2682e9b6 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 @@ -277,6 +278,95 @@ 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.clone(), + 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.clone(), + 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..29a5983a 100644 --- a/tests/git_api/test_git_repo.py +++ b/tests/git_api/test_git_repo.py @@ -208,6 +208,71 @@ 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.clone() + logging_mock.reset_mock() + + 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_called_once_with("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.clone() + logging_mock.reset_mock() + + 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("Checking out branch: %s", "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.clone() + logging_mock.reset_mock() + + 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("Checking out branch: %s", "master") + + @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 +463,41 @@ 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.clone() + 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)