Skip to content

Commit 22c9b15

Browse files
feat: Pull and rebase before push
Resolves #229 If another user pushes changes to the remote repo after gitopscli has cloned it, the subsequent push will fail. Alliviate this problem by pulling and rebasing local changes on top of any remote changes just before pushing.: For the case of creating a new branch (for a PR), the pull is skipped because it would fail due to the missing remote branch.
1 parent 458dca6 commit 22c9b15

File tree

10 files changed

+129
-1
lines changed

10 files changed

+129
-1
lines changed

gitopscli/commands/create_preview.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def __commit_and_push(self, git_repo: GitRepo, message: str) -> None:
101101
self.__args.git_author_email,
102102
message,
103103
)
104+
git_repo.pull_rebase()
104105
git_repo.push()
105106

106107
def __get_gitops_config(self) -> GitOpsConfig:

gitopscli/commands/delete_preview.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def __commit_and_push(self, git_repo: GitRepo, message: str) -> None:
7474
self.__args.git_author_email,
7575
message,
7676
)
77+
git_repo.pull_rebase()
7778
git_repo.push()
7879

7980
@staticmethod

gitopscli/commands/deploy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def execute(self) -> None:
5555
logging.info("All values already up-to-date. I'm done here.")
5656
return
5757

58+
git_repo.pull_rebase()
5859
git_repo.push()
5960

6061
if self.__args.create_pr:

gitopscli/commands/sync_apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,5 @@ def __commit_and_push(
103103
git_author_email,
104104
f"{author} updated " + app_file_name,
105105
)
106+
root_config_git_repo.pull_rebase()
106107
root_config_git_repo.push()

gitopscli/git_api/git_repo.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ def __validate_git_author(self, name: str | None, email: str | None) -> None:
105105
if (name and not email) or (not name and email):
106106
raise GitOpsException("Please provide the name and email address of the Git author or provide neither!")
107107

108+
def pull_rebase(self) -> None:
109+
repo = self.__get_repo()
110+
branch = repo.git.branch("--show-current")
111+
if not self.__remote_branch_exists(branch):
112+
return
113+
logging.info("Pull and rebase: %s", branch)
114+
repo.git.pull("--rebase")
115+
108116
def push(self, branch: str | None = None) -> None:
109117
repo = self.__get_repo()
110118
if not branch:
@@ -122,6 +130,10 @@ def get_author_from_last_commit(self) -> str:
122130
last_commit = repo.head.commit
123131
return str(repo.git.show("-s", "--format=%an <%ae>", last_commit.hexsha))
124132

133+
def __remote_branch_exists(self, branch: str) -> bool:
134+
repo = self.__get_repo()
135+
return bool(repo.git.ls_remote("--heads", "origin", f"refs/heads/{branch}").strip() != "")
136+
125137
def __delete_tmp_dir(self) -> None:
126138
if self.__tmp_dir:
127139
delete_tmp_dir(self.__tmp_dir)

tests/commands/test_create_preview.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def git_repo_api_factory_create_mock(_: GitApiConfig, organisation: str, reposit
107107
self.template_git_repo_mock.get_full_file_path.side_effect = lambda x: f"/tmp/template-repo/{x}"
108108
self.template_git_repo_mock.clone.return_value = None
109109
self.template_git_repo_mock.commit.return_value = None
110+
self.template_git_repo_mock.pull_rebase.return_value = None
110111
self.template_git_repo_mock.push.return_value = None
111112

112113
self.target_git_repo_mock = self.create_mock(GitRepo)
@@ -208,6 +209,7 @@ def test_create_new_preview(self):
208209
"GIT_AUTHOR_EMAIL",
209210
"Create new preview environment for 'my-app' and git hash '3361723dbd91fcfae7b5b8b8b7d462fbc14187a9'.",
210211
),
212+
call.GitRepo.pull_rebase(),
211213
call.GitRepo.push(),
212214
]
213215

@@ -312,6 +314,7 @@ def test_create_new_preview_from_same_template_target_repo(self):
312314
"GIT_AUTHOR_EMAIL",
313315
"Create new preview environment for 'my-app' and git hash '3361723dbd91fcfae7b5b8b8b7d462fbc14187a9'.",
314316
),
317+
call.GitRepo.pull_rebase(),
315318
call.GitRepo.push(),
316319
]
317320

@@ -381,6 +384,7 @@ def test_update_existing_preview(self):
381384
"GIT_AUTHOR_EMAIL",
382385
"Update preview environment for 'my-app' and git hash '3361723dbd91fcfae7b5b8b8b7d462fbc14187a9'.",
383386
),
387+
call.GitRepo.pull_rebase(),
384388
call.GitRepo.push(),
385389
]
386390

tests/commands/test_delete_preview.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def setUp(self):
6363
self.git_repo_mock.get_full_file_path.side_effect = lambda x: f"/tmp/created-tmp-dir/{x}"
6464
self.git_repo_mock.clone.return_value = None
6565
self.git_repo_mock.commit.return_value = None
66+
self.git_repo_mock.pull_rebase.return_value = None
6667
self.git_repo_mock.push.return_value = None
6768

6869
self.seal_mocks()
@@ -100,6 +101,7 @@ def test_delete_existing_happy_flow(self):
100101
"GIT_AUTHOR_EMAIL",
101102
"Delete preview environment for 'APP' and preview id 'PREVIEW_ID'.",
102103
),
104+
call.GitRepo.pull_rebase(),
103105
call.GitRepo.push(),
104106
]
105107

tests/commands/test_deploy.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def setUp(self):
4848
self.git_repo_mock.new_branch.return_value = None
4949
self.example_commit_hash = "5f3a443e7ecb3723c1a71b9744e2993c0b6dfc00"
5050
self.git_repo_mock.commit.return_value = self.example_commit_hash
51+
self.git_repo_mock.pull_rebase.return_value = None
5152
self.git_repo_mock.push.return_value = None
5253
self.git_repo_mock.get_full_file_path.side_effect = lambda x: f"/tmp/created-tmp-dir/{x}"
5354

@@ -101,6 +102,7 @@ def test_happy_flow(self, mock_print):
101102
"GIT_AUTHOR_EMAIL",
102103
"changed 'a.b.d' to 'bar' in test/file.yml",
103104
),
105+
call.GitRepo.pull_rebase(),
104106
call.GitRepo.push(),
105107
]
106108

@@ -142,6 +144,7 @@ def test_create_pr_single_value_change_happy_flow_with_output(self, mock_print):
142144
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"),
143145
call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"),
144146
call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "changed 'a.b.c' to 'foo' in test/file.yml"),
147+
call.GitRepo.pull_rebase(),
145148
call.GitRepo.push(),
146149
call.GitRepoApi.create_pull_request_to_default_branch(
147150
"gitopscli-deploy-b973b5bb",
@@ -199,6 +202,7 @@ def test_create_pr_multiple_value_changes_happy_flow_with_output(self, mock_prin
199202
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.d", "bar"),
200203
call.logging.info("Updated yaml property %s to %s", "a.b.d", "bar"),
201204
call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "changed 'a.b.d' to 'bar' in test/file.yml"),
205+
call.GitRepo.pull_rebase(),
202206
call.GitRepo.push(),
203207
call.GitRepoApi.create_pull_request_to_default_branch(
204208
"gitopscli-deploy-b973b5bb",
@@ -259,6 +263,7 @@ def test_create_pr_and_merge_happy_flow(self, mock_print):
259263
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.d", "bar"),
260264
call.logging.info("Updated yaml property %s to %s", "a.b.d", "bar"),
261265
call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "changed 'a.b.d' to 'bar' in test/file.yml"),
266+
call.GitRepo.pull_rebase(),
262267
call.GitRepo.push(),
263268
call.GitRepoApi.create_pull_request_to_default_branch(
264269
"gitopscli-deploy-b973b5bb",
@@ -313,6 +318,7 @@ def test_single_commit_happy_flow(self, mock_print):
313318
None,
314319
"updated 2 values in test/file.yml\n\na.b.c: foo\na.b.d: bar",
315320
),
321+
call.GitRepo.pull_rebase(),
316322
call.GitRepo.push(),
317323
]
318324

@@ -352,6 +358,7 @@ def test_single_commit_single_value_change_happy_flow(self, mock_print):
352358
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.c", "foo"),
353359
call.logging.info("Updated yaml property %s to %s", "a.b.c", "foo"),
354360
call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "changed 'a.b.c' to 'foo' in test/file.yml"),
361+
call.GitRepo.pull_rebase(),
355362
call.GitRepo.push(),
356363
]
357364

@@ -393,6 +400,7 @@ def test_commit_message_multiple_value_changes_happy_flow(self, mock_print):
393400
call.update_yaml_file("/tmp/created-tmp-dir/test/file.yml", "a.b.d", "bar"),
394401
call.logging.info("Updated yaml property %s to %s", "a.b.d", "bar"),
395402
call.GitRepo.commit("GIT_USER", "GIT_EMAIL", None, None, "testcommit"),
403+
call.GitRepo.pull_rebase(),
396404
call.GitRepo.push(),
397405
]
398406

tests/commands/test_sync_apps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def setUp(self):
7373
self.root_config_git_repo_mock.get_clone_url.return_value = "https://repository.url/root/root-config.git"
7474
self.root_config_git_repo_mock.clone.return_value = None
7575
self.root_config_git_repo_mock.commit.return_value = None
76+
self.root_config_git_repo_mock.pull_rebase.return_value = None
7677
self.root_config_git_repo_mock.push.return_value = None
7778

7879
self.git_repo_api_factory_mock = self.monkey_patch(GitRepoApiFactory)
@@ -166,6 +167,7 @@ def test_sync_apps_happy_flow(self):
166167
"GIT_AUTHOR_EMAIL",
167168
"author updated /tmp/root-config-repo/apps/team-non-prod.yaml",
168169
),
170+
call.GitRepo_root.pull_rebase(),
169171
call.GitRepo_root.push(),
170172
call.GitRepo_root.__exit__(None, None, None),
171173
call.GitRepo_team.__exit__(None, None, None),

tests/git_api/test_git_repo.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def __create_origin(self):
4949
with Path(f"{repo_dir}/README.md").open("w") as readme:
5050
readme.write("xyz branch readme")
5151
repo.git.add("--all")
52-
repo.git.commit("-m", "xyz brach commit", "--author", f"{git_user} <{git_email}>")
52+
repo.git.commit("-m", "initial xyz branch commit", "--author", f"{git_user} <{git_email}>")
5353

5454
repo.git.checkout("master") # master = default branch
5555
repo.git.config("receive.denyCurrentBranch", "ignore")
@@ -313,6 +313,102 @@ def test_commit_nothing_to_commit(self, logging_mock):
313313
self.assertEqual("initial commit\n", commits[0].message)
314314
logging_mock.assert_not_called()
315315

316+
@patch("gitopscli.git_api.git_repo.logging")
317+
def test_pull_rebase_master_single_commit(self, logging_mock):
318+
origin_repo = self.__origin
319+
with GitRepo(self.__mock_repo_api) as testee:
320+
testee.clone()
321+
322+
# local commit
323+
with Path(testee.get_full_file_path("local.md")).open("w") as outfile:
324+
outfile.write("local file")
325+
local_repo = Repo(testee.get_full_file_path("."))
326+
local_repo.git.add("--all")
327+
local_repo.git.commit("-m", "local commit", "--author", "local <[email protected]>")
328+
329+
# origin commit
330+
with Path(f"{origin_repo.working_dir}/origin.md").open("w") as readme:
331+
readme.write("origin file")
332+
origin_repo.git.add("--all")
333+
origin_repo.git.commit("-m", "origin commit", "--author", "origin <[email protected]>")
334+
335+
# pull and rebase from remote
336+
logging_mock.reset_mock()
337+
338+
testee.pull_rebase()
339+
340+
logging_mock.info.assert_called_once_with("Pull and rebase: %s", "master")
341+
342+
# then push should work
343+
testee.push()
344+
345+
commits = list(self.__origin.iter_commits("master"))
346+
self.assertEqual(3, len(commits))
347+
self.assertEqual("initial commit\n", commits[2].message)
348+
self.assertEqual("origin commit\n", commits[1].message)
349+
self.assertEqual("local commit\n", commits[0].message)
350+
351+
@patch("gitopscli.git_api.git_repo.logging")
352+
def test_pull_rebase_remote_branch_single_commit(self, logging_mock):
353+
origin_repo = self.__origin
354+
origin_repo.git.checkout("xyz")
355+
with GitRepo(self.__mock_repo_api) as testee:
356+
testee.clone(branch="xyz")
357+
358+
# local commit
359+
with Path(testee.get_full_file_path("local.md")).open("w") as outfile:
360+
outfile.write("local file")
361+
local_repo = Repo(testee.get_full_file_path("."))
362+
local_repo.git.add("--all")
363+
local_repo.git.commit("-m", "local branch commit", "--author", "local <[email protected]>")
364+
365+
# origin commit
366+
with Path(f"{origin_repo.working_dir}/origin.md").open("w") as readme:
367+
readme.write("origin file")
368+
origin_repo.git.add("--all")
369+
origin_repo.git.commit("-m", "origin branch commit", "--author", "origin <[email protected]>")
370+
371+
# pull and rebase from remote
372+
logging_mock.reset_mock()
373+
374+
testee.pull_rebase()
375+
376+
logging_mock.info.assert_called_once_with("Pull and rebase: %s", "xyz")
377+
378+
# then push should work
379+
testee.push()
380+
381+
commits = list(self.__origin.iter_commits("xyz"))
382+
self.assertEqual(4, len(commits))
383+
self.assertEqual("local branch commit\n", commits[0].message)
384+
self.assertEqual("origin branch commit\n", commits[1].message)
385+
self.assertEqual("initial xyz branch commit\n", commits[2].message)
386+
387+
@patch("gitopscli.git_api.git_repo.logging")
388+
def test_pull_rebase_without_new_commits(self, logging_mock):
389+
with GitRepo(self.__mock_repo_api) as testee:
390+
testee.clone()
391+
392+
# pull and rebase from remote
393+
logging_mock.reset_mock()
394+
395+
testee.pull_rebase()
396+
397+
logging_mock.info.assert_called_once_with("Pull and rebase: %s", "master")
398+
399+
@patch("gitopscli.git_api.git_repo.logging")
400+
def test_pull_rebase_if_no_remote_branch_is_noop(self, logging_mock):
401+
with GitRepo(self.__mock_repo_api) as testee:
402+
testee.clone()
403+
testee.new_branch("new-branch-only-local")
404+
405+
# pull and rebase from remote
406+
logging_mock.reset_mock()
407+
408+
testee.pull_rebase()
409+
410+
logging_mock.assert_not_called()
411+
316412
@patch("gitopscli.git_api.git_repo.logging")
317413
def test_push(self, logging_mock):
318414
with GitRepo(self.__mock_repo_api) as testee:

0 commit comments

Comments
 (0)