Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docs/commands/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand All @@ -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
```

Expand All @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions gitopscli/cliparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions gitopscli/commands/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions gitopscli/git_api/git_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
90 changes: 90 additions & 0 deletions tests/commands/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
100 changes: 100 additions & 0 deletions tests/git_api/test_git_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 13 additions & 6 deletions tests/test_cliparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1142,6 +1146,8 @@ def test_deploy_all_args(self):
"FILE",
"--values",
"{a.b: 42}", # yaml
"--branch",
"BRANCH",
"--create-pr",
"--auto-merge",
"--single-commit",
Expand All @@ -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)
Expand Down