Skip to content

Commit 6b9a3e6

Browse files
authored
Merge pull request #269 from jdmansour/handle-modify-delete-conflicts
Deal with modify/delete conflicts
2 parents 7ce7827 + 67dfbd5 commit 6b9a3e6

File tree

2 files changed

+137
-23
lines changed

2 files changed

+137
-23
lines changed

nbgitpuller/pull.py

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,62 @@ def rename_local_untracked(self):
244244
os.rename(f, new_file_name)
245245
yield 'Renamed {} to {} to avoid conflict with upstream'.format(f, new_file_name)
246246

247+
def merge(self):
248+
"""
249+
Merges branch from origin into current branch, resolving conflicts when possible.
250+
251+
Resolves conflicts in two ways:
252+
253+
- Passes `-Xours` to git, setting merge-strategy to preserve changes made by the
254+
user whererver possible
255+
- Detect (modify/delete) conflicts, where the user has locally modified something
256+
that was deleted upstream. We just keep the local file.
257+
"""
258+
modify_delete_conflict = False
259+
try:
260+
for line in execute_cmd([
261+
'git',
262+
'-c', 'user.email=nbgitpuller@nbgitpuller.link',
263+
'-c', 'user.name=nbgitpuller',
264+
'merge',
265+
'-Xours', 'origin/{}'.format(self.branch_name)
266+
],
267+
cwd=self.repo_dir):
268+
yield line
269+
# Detect conflict caused by one branch
270+
if line.startswith("CONFLICT (modify/delete)"):
271+
modify_delete_conflict = True
272+
except subprocess.CalledProcessError:
273+
if not modify_delete_conflict:
274+
raise
275+
276+
if modify_delete_conflict:
277+
yield "Caught modify/delete conflict, trying to resolve"
278+
# If a file was deleted on one branch, and modified on another,
279+
# we just keep the modified file. This is done by `git add`ing it.
280+
yield from self.commit_all()
281+
282+
def commit_all(self):
283+
"""
284+
Creates a new commit with all current changes
285+
"""
286+
yield from execute_cmd([
287+
'git',
288+
# We explicitly set user info of the commits we are making, to keep that separate from
289+
# whatever author info is set in system / repo config by the user. We pass '-c' to git
290+
# itself (rather than to 'git commit') to temporarily set config variables. This is
291+
# better than passing --author, since git treats author separately from committer.
292+
'-c', 'user.email=nbgitpuller@nbgitpuller.link',
293+
'-c', 'user.name=nbgitpuller',
294+
'commit',
295+
'-am', 'Automatic commit by nbgitpuller',
296+
# We allow empty commits. On NFS (at least), sometimes repo_is_dirty returns a false
297+
# positive, returning True even when there are no local changes (git diff-files seems to return
298+
# bogus output?). While ideally that would not happen, allowing empty commits keeps us
299+
# resilient to that issue.
300+
'--allow-empty'
301+
], cwd=self.repo_dir)
302+
247303
def update(self):
248304
"""
249305
Do the pulling if necessary
@@ -266,34 +322,13 @@ def update(self):
266322
yield from execute_cmd(['git', 'reset', '--mixed'], cwd=self.repo_dir)
267323

268324
# If there are local changes, make a commit so we can do merges when pulling
269-
# We also allow empty commits. On NFS (at least), sometimes repo_is_dirty returns a false
270-
# positive, returning True even when there are no local changes (git diff-files seems to return
271-
# bogus output?). While ideally that would not happen, allowing empty commits keeps us
272-
# resilient to that issue.
273-
# We explicitly set user info of the commits we are making, to keep that separate from
274-
# whatever author info is set in system / repo config by the user. We pass '-c' to git
275-
# itself (rather than to 'git commit') to temporarily set config variables. This is
276-
# better than passing --author, since git treats author separately from committer.
277325
if self.repo_is_dirty():
278326
yield from self.ensure_lock()
279-
yield from execute_cmd([
280-
'git',
281-
'-c', 'user.email=nbgitpuller@nbgitpuller.link',
282-
'-c', 'user.name=nbgitpuller',
283-
'commit',
284-
'-am', 'Automatic commit by nbgitpuller',
285-
'--allow-empty'
286-
], cwd=self.repo_dir)
327+
yield from self.commit_all()
287328

288329
# Merge master into local!
289330
yield from self.ensure_lock()
290-
yield from execute_cmd([
291-
'git',
292-
'-c', 'user.email=nbgitpuller@nbgitpuller.link',
293-
'-c', 'user.name=nbgitpuller',
294-
'merge',
295-
'-Xours', 'origin/{}'.format(self.branch_name)
296-
], cwd=self.repo_dir)
331+
yield from self.merge()
297332

298333

299334
def main():

tests/test_gitpuller.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,85 @@ def test_delete_remotely_modify_locally():
355355
assert puller.read_file('README.md') == 'HELLO'
356356

357357

358+
def test_diverged():
359+
"""
360+
Test deleting a file upstream, and editing it locally. This time we
361+
commit to create diverged brances.
362+
"""
363+
with Remote() as remote, Pusher(remote) as pusher:
364+
pusher.push_file('README.md', 'new')
365+
366+
with Puller(remote) as puller:
367+
# Delete the file remotely
368+
pusher.git('rm', 'README.md')
369+
pusher.git('commit', '-m', 'Deleted file')
370+
pusher.git('push', '-u', 'origin', 'master')
371+
372+
# Edit locally
373+
puller.write_file('README.md', 'conflict')
374+
puller.git('add', 'README.md')
375+
puller.git('commit', '-m', 'Make conflicting change')
376+
377+
# The local change should be kept
378+
puller.pull_all()
379+
assert puller.read_file('README.md') == 'conflict'
380+
381+
382+
def test_diverged_reverse():
383+
"""
384+
Test deleting a file locally, and editing it upstream. We commit the changes
385+
to create diverged branches. Like `test_diverged`, but flipped.
386+
"""
387+
with Remote() as remote, Pusher(remote) as pusher:
388+
pusher.push_file('README.md', 'new')
389+
390+
with Puller(remote) as puller:
391+
# Delete the file locally
392+
puller.git('rm', 'README.md')
393+
puller.git('commit', '-m', 'Deleted file')
394+
395+
# Edit the file remotely
396+
pusher.push_file('README.md', 'conflicting change')
397+
398+
# Pulling should get the latest version of the file
399+
puller.pull_all()
400+
assert(puller.read_file('README.md') == 'conflicting change')
401+
402+
403+
def test_diverged_multiple():
404+
"""
405+
Test deleting a file upstream, and editing it locally. We commit the changes
406+
to create diverged branches.
407+
408+
Use two files, so git merge doesn't mention the conflict in the first line.
409+
410+
puller: Auto-merging AFILE.txt
411+
puller: CONFLICT (modify/delete): BFILE.txt deleted in origin/master and modified in HEAD. Version HEAD of BFILE.txt left in tree.
412+
puller: Automatic merge failed; fix conflicts and then commit the result.
413+
414+
"""
415+
with Remote() as remote, Pusher(remote) as pusher:
416+
pusher.push_file('AFILE.txt', 'new')
417+
pusher.push_file('BFILE.txt', 'new')
418+
419+
with Puller(remote) as puller:
420+
# Remote changes - BFILE.txt deleted
421+
pusher.write_file('AFILE.txt', 'changed remotely')
422+
pusher.git('add', 'AFILE.txt')
423+
pusher.git('rm', 'BFILE.txt')
424+
pusher.git('commit', '-m', 'Remote changes')
425+
pusher.git('push', '-u', 'origin', 'master')
426+
427+
# Local changes - BFILE.txt edited
428+
puller.write_file('AFILE.txt', 'edited')
429+
puller.write_file('BFILE.txt', 'edited')
430+
puller.git('commit', '-am', 'Make conflicting change')
431+
432+
puller.pull_all()
433+
assert puller.read_file('AFILE.txt') == 'edited'
434+
assert puller.read_file('BFILE.txt') == 'edited'
435+
436+
358437
def test_delete_locally_and_remotely():
359438
"""
360439
Test that sync works after deleting a file locally and remotely

0 commit comments

Comments
 (0)