1 """Widgets for Fetch, Push, and Pull"""
5 from qtpy
import QtWidgets
6 from qtpy
.QtCore
import Qt
9 from ..interaction
import Interaction
10 from ..qtutils
import connect_button
11 from ..qtutils
import get
12 from .. import gitcmds
14 from .. import qtutils
18 from . import standard
27 """Fetch from remote repositories"""
28 return run(context
, Fetch
)
32 """Push to remote repositories"""
33 return run(context
, Push
)
37 """Pull from remote repositories"""
38 return run(context
, Pull
)
41 def run(context
, RemoteDialog
):
42 """Launches fetch/push/pull dialogs."""
43 # Copy global stuff over to speedup startup
44 parent
= qtutils
.active_window()
45 view
= RemoteDialog(context
, parent
=parent
)
50 def combine(result
, prev
):
51 """Combine multiple (status, out, err) tuples into a combined tuple
53 The current state is passed in via `prev`.
54 The status code is a max() over all the subprocess status codes.
55 Individual (out, err) strings are sequentially concatenated together.
58 if isinstance(prev
, (tuple, list)):
60 raise AssertionError('combine() with length %d' % len(prev
))
62 max(prev
[0], result
[0]),
63 combine(prev
[1], result
[1]),
64 combine(prev
[2], result
[2]),
67 combined
= prev
+ '\n\n' + result
76 def uncheck(value
, *checkboxes
):
77 """Uncheck the specified checkboxes if value is True"""
79 for checkbox
in checkboxes
:
80 checkbox
.setChecked(False)
83 def strip_remotes(remote_branches
):
84 """Strip the <remote>/ prefixes from branches
86 e.g. "origin/main" becomes "main".
89 branches
= [utils
.strip_one(branch
) for branch
in remote_branches
]
90 return [branch
for branch
in branches
if branch
!= 'HEAD']
93 def get_default_remote(context
):
94 """Get the name of the default remote to use for pushing.
96 This will be the remote the branch is set to track, if it is set. If it
97 is not, remote.pushDefault will be used (or origin if not set)
100 upstream_remote
= gitcmds
.upstream_remote(context
)
101 return upstream_remote
or context
.cfg
.get('remote.pushDefault', default
='origin')
104 class ActionTask(qtutils
.Task
):
105 """Run actions asynchronously"""
107 def __init__(self
, model_action
, remote
, kwargs
):
108 qtutils
.Task
.__init
__(self
)
109 self
.model_action
= model_action
114 """Runs the model action and captures the result"""
115 return self
.model_action(self
.remote
, **self
.kwargs
)
118 class RemoteActionDialog(standard
.Dialog
):
119 """Interface for performing remote operations"""
121 def __init__(self
, context
, action
, title
, parent
=None, icon
=None):
122 """Customize the dialog based on the remote action"""
123 standard
.Dialog
.__init
__(self
, parent
=parent
)
124 self
.setWindowTitle(title
)
125 if parent
is not None:
126 self
.setWindowModality(Qt
.WindowModal
)
128 self
.context
= context
129 self
.model
= model
= context
.model
131 self
.filtered_remote_branches
= []
132 self
.selected_remotes
= []
134 self
.runtask
= qtutils
.RunTask(parent
=self
)
135 self
.local_label
= QtWidgets
.QLabel()
136 self
.local_label
.setText(N_('Local Branch'))
138 self
.local_branch
= QtWidgets
.QLineEdit()
139 qtutils
.add_completer(self
.local_branch
, model
.local_branches
)
141 self
.local_branches
= QtWidgets
.QListWidget()
142 self
.local_branches
.addItems(model
.local_branches
)
144 self
.remote_label
= QtWidgets
.QLabel()
145 self
.remote_label
.setText(N_('Remote'))
147 self
.remote_name
= QtWidgets
.QLineEdit()
148 qtutils
.add_completer(self
.remote_name
, model
.remotes
)
150 self
.remote_name
.editingFinished
.connect(self
.remote_name_edited
)
151 self
.remote_name
.textEdited
.connect(lambda x
: self
.remote_name_edited())
153 self
.remotes
= QtWidgets
.QListWidget()
155 mode
= QtWidgets
.QAbstractItemView
.ExtendedSelection
156 self
.remotes
.setSelectionMode(mode
)
157 self
.remotes
.addItems(model
.remotes
)
159 self
.remote_branch_label
= QtWidgets
.QLabel()
160 self
.remote_branch_label
.setText(N_('Remote Branch'))
162 self
.remote_branch
= QtWidgets
.QLineEdit()
163 remote_branches
= strip_remotes(model
.remote_branches
)
164 qtutils
.add_completer(self
.remote_branch
, remote_branches
)
166 self
.remote_branches
= QtWidgets
.QListWidget()
167 self
.remote_branches
.addItems(model
.remote_branches
)
169 text
= N_('Prompt on creation')
170 tooltip
= N_('Prompt when pushing creates new remote branches')
171 self
.prompt_checkbox
= qtutils
.checkbox(
172 checked
=True, text
=text
, tooltip
=tooltip
175 text
= N_('Show remote messages')
176 tooltip
= N_('Display remote messages in a separate dialog')
177 self
.remote_messages_checkbox
= qtutils
.checkbox(
178 checked
=False, text
=text
, tooltip
=tooltip
181 text
= N_('Fast-forward only')
183 'Refuse to merge unless the current HEAD is already up-'
184 'to-date or the merge can be resolved as a fast-forward'
186 self
.ff_only_checkbox
= qtutils
.checkbox(
187 checked
=True, text
=text
, tooltip
=tooltip
190 text
= N_('No fast-forward')
192 'Create a merge commit even when the merge resolves as a fast-forward'
194 self
.no_ff_checkbox
= qtutils
.checkbox(
195 checked
=False, text
=text
, tooltip
=tooltip
199 'Allow non-fast-forward updates. Using "force" can '
200 'cause the remote repository to lose commits; '
203 self
.force_checkbox
= qtutils
.checkbox(
204 checked
=False, text
=text
, tooltip
=tooltip
207 self
.tags_checkbox
= qtutils
.checkbox(text
=N_('Include tags '))
210 'Remove remote-tracking branches that no longer exist on the remote'
212 self
.prune_checkbox
= qtutils
.checkbox(text
=N_('Prune '), tooltip
=tooltip
)
214 tooltip
= N_('Rebase the current branch instead of merging')
215 self
.rebase_checkbox
= qtutils
.checkbox(text
=N_('Rebase'), tooltip
=tooltip
)
217 text
= N_('Set upstream')
218 tooltip
= N_('Configure the remote branch as the the new upstream')
219 self
.upstream_checkbox
= qtutils
.checkbox(text
=text
, tooltip
=tooltip
)
221 text
= N_('Close on completion')
222 tooltip
= N_('Close dialog when completed')
223 self
.close_on_completion_checkbox
= qtutils
.checkbox(
224 checked
=True, text
=text
, tooltip
=tooltip
227 self
.action_button
= qtutils
.ok_button(title
, icon
=icon
)
228 self
.close_button
= qtutils
.close_button()
229 self
.buttons_group
= utils
.Group(self
.close_button
, self
.action_button
)
230 self
.inputs_group
= utils
.Group(
231 self
.close_on_completion_checkbox
,
233 self
.ff_only_checkbox
,
238 self
.rebase_checkbox
,
242 self
.remote_branches
,
243 self
.upstream_checkbox
,
244 self
.prompt_checkbox
,
245 self
.remote_messages_checkbox
,
247 self
.progress
= standard
.progress_bar(
249 disable
=(self
.buttons_group
, self
.inputs_group
),
252 self
.local_branch_layout
= qtutils
.hbox(
253 defs
.small_margin
, defs
.spacing
, self
.local_label
, self
.local_branch
256 self
.remote_layout
= qtutils
.hbox(
257 defs
.small_margin
, defs
.spacing
, self
.remote_label
, self
.remote_name
260 self
.remote_branch_layout
= qtutils
.hbox(
263 self
.remote_branch_label
,
267 self
.options_layout
= qtutils
.hbox(
271 self
.ff_only_checkbox
,
275 self
.rebase_checkbox
,
276 self
.upstream_checkbox
,
277 self
.prompt_checkbox
,
278 self
.close_on_completion_checkbox
,
279 self
.remote_messages_checkbox
,
286 self
.remote_input_layout
= qtutils
.vbox(
287 defs
.no_margin
, defs
.spacing
, self
.remote_layout
, self
.remotes
290 self
.local_branch_input_layout
= qtutils
.vbox(
291 defs
.no_margin
, defs
.spacing
, self
.local_branch_layout
, self
.local_branches
294 self
.remote_branch_input_layout
= qtutils
.vbox(
297 self
.remote_branch_layout
,
298 self
.remote_branches
,
303 self
.remote_input_layout
,
304 self
.local_branch_input_layout
,
305 self
.remote_branch_input_layout
,
307 else: # fetch and pull
309 self
.remote_input_layout
,
310 self
.remote_branch_input_layout
,
311 self
.local_branch_input_layout
,
313 self
.top_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, *widgets
)
315 self
.main_layout
= qtutils
.vbox(
316 defs
.margin
, defs
.spacing
, self
.top_layout
, self
.options_layout
318 self
.setLayout(self
.main_layout
)
320 default_remote
= get_default_remote(context
)
322 remotes
= model
.remotes
323 if default_remote
in remotes
:
324 idx
= remotes
.index(default_remote
)
325 if self
.select_remote(idx
):
326 self
.set_remote_name(default_remote
)
328 if self
.select_first_remote():
329 self
.set_remote_name(remotes
[0])
331 # Trim the remote list to just the default remote
332 self
.update_remotes()
333 self
.set_field_defaults()
335 # Setup signals and slots
336 self
.remotes
.itemSelectionChanged
.connect(self
.update_remotes
)
338 local
= self
.local_branches
339 local
.itemSelectionChanged
.connect(self
.update_local_branches
)
341 remote
= self
.remote_branches
342 remote
.itemSelectionChanged
.connect(self
.update_remote_branches
)
344 self
.no_ff_checkbox
.toggled
.connect(
345 lambda x
: uncheck(x
, self
.ff_only_checkbox
, self
.rebase_checkbox
)
348 self
.ff_only_checkbox
.toggled
.connect(
349 lambda x
: uncheck(x
, self
.no_ff_checkbox
, self
.rebase_checkbox
)
352 self
.rebase_checkbox
.toggled
.connect(
353 lambda x
: uncheck(x
, self
.no_ff_checkbox
, self
.ff_only_checkbox
)
356 connect_button(self
.action_button
, self
.action_callback
)
357 connect_button(self
.close_button
, self
.close
)
360 self
, N_('Close'), self
.close
, QtGui
.QKeySequence
.Close
, 'Esc'
363 self
.prune_checkbox
.hide()
367 self
.upstream_checkbox
.hide()
368 self
.prompt_checkbox
.hide()
371 # Fetch and Push-only options
372 self
.force_checkbox
.hide()
373 self
.tags_checkbox
.hide()
374 self
.local_label
.hide()
375 self
.local_branch
.hide()
376 self
.local_branches
.hide()
379 self
.rebase_checkbox
.hide()
380 self
.no_ff_checkbox
.hide()
381 self
.ff_only_checkbox
.hide()
383 self
.init_size(parent
=parent
)
385 def set_rebase(self
, value
):
386 """Check the rebase checkbox"""
387 self
.rebase_checkbox
.setChecked(value
)
389 def set_field_defaults(self
):
390 """Set sensible initial defaults"""
391 # Default to "git fetch origin main"
393 if action
in (FETCH
, PULL
):
394 self
.local_branch
.setText('')
395 self
.remote_branch
.setText('')
398 # Select the current branch by default for push
400 branch
= self
.model
.currentbranch
402 idx
= self
.model
.local_branches
.index(branch
)
405 if self
.select_local_branch(idx
):
406 self
.set_local_branch(branch
)
407 self
.set_remote_branch('')
409 def set_remote_name(self
, remote_name
):
410 """Set the remote name"""
411 self
.remote_name
.setText(remote_name
)
413 def set_local_branch(self
, branch
):
414 """Set the local branch name"""
415 self
.local_branch
.setText(branch
)
417 self
.local_branch
.selectAll()
419 def set_remote_branch(self
, branch
):
420 """Set the remote branch name"""
421 self
.remote_branch
.setText(branch
)
423 self
.remote_branch
.selectAll()
425 def set_remote_branches(self
, branches
):
426 """Set the list of remote branches"""
427 self
.remote_branches
.clear()
428 self
.remote_branches
.addItems(branches
)
429 self
.filtered_remote_branches
= branches
430 qtutils
.add_completer(self
.remote_branch
, strip_remotes(branches
))
432 def select_first_remote(self
):
433 """Select the first remote in the list view"""
434 return self
.select_remote(0)
436 def select_remote(self
, idx
):
437 """Select a remote by index"""
438 item
= self
.remotes
.item(idx
)
440 item
.setSelected(True)
441 self
.remotes
.setCurrentItem(item
)
442 self
.set_remote_name(item
.text())
448 def select_remote_by_name(self
, remote
):
449 """Select a remote by name"""
450 remotes
= self
.model
.remotes
451 if remote
in remotes
:
452 idx
= remotes
.index(remote
)
453 result
= self
.select_remote(idx
)
458 def set_selected_remotes(self
, remotes
):
459 """Set the list of selected remotes
461 Return True if all remotes were found and selected.
464 # Invalid remote names are ignored.
465 # This handles a remote going away between sessions.
466 # The selection is unchanged when none of the specified remotes exist.
468 for remote
in remotes
:
469 if remote
in self
.model
.remotes
:
473 # Only clear the selection if the specified remotes exist
474 self
.remotes
.clearSelection()
475 found
= all(self
.select_remote_by_name(x
) for x
in remotes
)
478 def select_local_branch(self
, idx
):
479 """Selects a local branch by index in the list view"""
480 item
= self
.local_branches
.item(idx
)
482 item
.setSelected(True)
483 self
.local_branches
.setCurrentItem(item
)
484 self
.local_branch
.setText(item
.text())
490 def display_remotes(self
, widget
):
491 """Display the available remotes in a listwidget"""
493 for remote_name
in self
.model
.remotes
:
494 url
= self
.model
.remote_url(remote_name
, self
.action
)
495 display
= '{}\t({})'.format(remote_name
, N_('URL: %s') % url
)
496 displayed
.append(display
)
497 qtutils
.set_items(widget
, displayed
)
499 def update_remotes(self
):
500 """Update the remote name when a remote from the list is selected"""
501 widget
= self
.remotes
502 remotes
= self
.model
.remotes
503 selection
= qtutils
.selected_item(widget
, remotes
)
505 self
.selected_remotes
= []
507 self
.set_remote_name(selection
)
508 self
.selected_remotes
= qtutils
.selected_items(self
.remotes
, self
.model
.remotes
)
509 self
.set_remote_to(selection
, self
.selected_remotes
)
511 def set_remote_to(self
, _remote
, selected_remotes
):
512 context
= self
.context
513 all_branches
= gitcmds
.branch_list(context
, remote
=True)
516 for remote_name
in selected_remotes
:
517 patterns
.append(remote_name
+ '/*')
519 for branch
in all_branches
:
521 if fnmatch
.fnmatch(branch
, pat
):
522 branches
.append(branch
)
525 self
.set_remote_branches(branches
)
527 self
.set_remote_branches(all_branches
)
528 self
.set_remote_branch('')
530 def remote_name_edited(self
):
531 """Update the current remote when the remote name is typed manually"""
532 remote
= self
.remote_name
.text()
533 self
.set_remote_to(remote
, [remote
])
535 def update_local_branches(self
):
536 """Update the local/remote branch names when a branch is selected"""
537 branches
= self
.model
.local_branches
538 widget
= self
.local_branches
539 selection
= qtutils
.selected_item(widget
, branches
)
542 self
.set_local_branch(selection
)
543 self
.set_remote_branch(selection
)
545 def update_remote_branches(self
):
546 """Update the remote branch name when a branch is selected"""
547 widget
= self
.remote_branches
548 branches
= self
.filtered_remote_branches
549 selection
= qtutils
.selected_item(widget
, branches
)
552 branch
= utils
.strip_one(selection
)
555 self
.set_remote_branch(branch
)
557 def common_args(self
):
558 """Returns git arguments common to fetch/push/pull"""
559 remote_name
= self
.remote_name
.text()
560 local_branch
= self
.local_branch
.text()
561 remote_branch
= self
.remote_branch
.text()
563 ff_only
= get(self
.ff_only_checkbox
)
564 force
= get(self
.force_checkbox
)
565 no_ff
= get(self
.no_ff_checkbox
)
566 rebase
= get(self
.rebase_checkbox
)
567 set_upstream
= get(self
.upstream_checkbox
)
568 tags
= get(self
.tags_checkbox
)
569 prune
= get(self
.prune_checkbox
)
576 'local_branch': local_branch
,
579 'remote_branch': remote_branch
,
580 'set_upstream': set_upstream
,
588 def push_to_all(self
, _remote
, *args
, **kwargs
):
589 """Push to all selected remotes"""
590 selected_remotes
= self
.selected_remotes
592 for remote
in selected_remotes
:
593 result
= self
.model
.push(remote
, *args
, **kwargs
)
594 all_results
= combine(result
, all_results
)
597 def action_callback(self
):
598 """Perform the actual fetch/push/pull operation"""
600 remote_messages
= get(self
.remote_messages_checkbox
)
602 model_action
= self
.model
.fetch
604 model_action
= self
.push_to_all
605 else: # if action == PULL:
606 model_action
= self
.model
.pull
608 remote_name
= self
.remote_name
.text()
610 errmsg
= N_('No repository selected.')
611 Interaction
.log(errmsg
)
613 remote
, kwargs
= self
.common_args()
614 self
.selected_remotes
= qtutils
.selected_items(self
.remotes
, self
.model
.remotes
)
616 # Check if we're about to create a new branch and warn.
617 remote_branch
= self
.remote_branch
.text()
618 local_branch
= self
.local_branch
.text()
620 if action
== PUSH
and not remote_branch
:
621 branch
= local_branch
622 candidate
= f
'{remote}/{branch}'
623 prompt
= get(self
.prompt_checkbox
)
625 if prompt
and candidate
not in self
.model
.remote_branches
:
633 'Branch "%(branch)s" does not exist in "%(remote)s".\n'
634 'A new remote branch will be published.'
638 info_txt
= N_('Create a new remote branch?')
639 ok_text
= N_('Create Remote Branch')
640 if not Interaction
.confirm(
641 title
, msg
, info_txt
, ok_text
, icon
=icons
.cola()
645 if get(self
.force_checkbox
):
647 title
= N_('Force Fetch?')
648 msg
= N_('Non-fast-forward fetch overwrites local history!')
649 info_txt
= N_('Force fetching from %s?') % remote
650 ok_text
= N_('Force Fetch')
652 title
= N_('Force Push?')
654 'Non-fast-forward push overwrites published '
655 'history!\n(Did you pull first?)'
657 info_txt
= N_('Force push to %s?') % remote
658 ok_text
= N_('Force Push')
659 else: # pull: shouldn't happen since the controls are hidden
661 if not Interaction
.confirm(
662 title
, msg
, info_txt
, ok_text
, default
=False, icon
=icons
.discard()
666 self
.progress
.setMaximumHeight(
667 self
.action_button
.height() - defs
.small_margin
* 2
670 # Use a thread to update in the background
671 task
= ActionTask(model_action
, remote
, kwargs
)
673 result
= log
.show_remote_messages(self
, self
.context
)
678 progress
=self
.progress
,
679 finish
=self
.action_completed
,
683 def action_completed(self
, task
):
684 """Grab the results of the action and finish up"""
685 if not task
.result
or not isinstance(task
.result
, (list, tuple)):
688 status
, out
, err
= task
.result
689 command
= 'git %s' % self
.action
.lower()
690 message
= Interaction
.format_command_status(command
, status
)
691 details
= Interaction
.format_out_err(out
, err
)
693 log_message
= message
695 log_message
+= '\n\n' + details
696 Interaction
.log(log_message
)
699 close_on_completion
= get(self
.close_on_completion_checkbox
)
700 if close_on_completion
:
704 if self
.action
== PUSH
:
706 message
+= N_('Have you rebased/pulled lately?')
708 Interaction
.critical(self
.windowTitle(), message
=message
, details
=details
)
710 def export_state(self
):
711 """Export persistent settings"""
712 state
= standard
.Dialog
.export_state(self
)
713 state
['close_on_completion'] = get(self
.close_on_completion_checkbox
)
714 state
['remote_messages'] = get(self
.remote_messages_checkbox
)
717 def apply_state(self
, state
):
718 """Apply persistent settings"""
719 result
= standard
.Dialog
.apply_state(self
, state
)
720 # Restore the "close on completion" checkbox
721 close_on_completion
= bool(state
.get('close_on_completion', True))
722 self
.close_on_completion_checkbox
.setChecked(close_on_completion
)
723 # Restore the "show remote messages" checkbox
724 remote_messages
= bool(state
.get('remote_messages', False))
725 self
.remote_messages_checkbox
.setChecked(remote_messages
)
729 # Use distinct classes so that each saves its own set of preferences
730 class Fetch(RemoteActionDialog
):
731 """Fetch from remote repositories"""
733 def __init__(self
, context
, parent
=None):
734 super().__init
__(context
, FETCH
, N_('Fetch'), parent
=parent
, icon
=icons
.repo())
736 def export_state(self
):
737 """Export persistent settings"""
738 state
= RemoteActionDialog
.export_state(self
)
739 state
['tags'] = get(self
.tags_checkbox
)
740 state
['prune'] = get(self
.prune_checkbox
)
743 def apply_state(self
, state
):
744 """Apply persistent settings"""
745 result
= RemoteActionDialog
.apply_state(self
, state
)
746 tags
= bool(state
.get('tags', False))
747 self
.tags_checkbox
.setChecked(tags
)
748 prune
= bool(state
.get('prune', False))
749 self
.prune_checkbox
.setChecked(prune
)
753 class Push(RemoteActionDialog
):
754 """Push to remote repositories"""
756 def __init__(self
, context
, parent
=None):
757 super().__init
__(context
, PUSH
, N_('Push'), parent
=parent
, icon
=icons
.push())
759 def export_state(self
):
760 """Export persistent settings"""
761 state
= RemoteActionDialog
.export_state(self
)
762 state
['prompt'] = get(self
.prompt_checkbox
)
763 state
['tags'] = get(self
.tags_checkbox
)
766 def apply_state(self
, state
):
767 """Apply persistent settings"""
768 result
= RemoteActionDialog
.apply_state(self
, state
)
769 # Restore the "prompt on creation" checkbox
770 prompt
= bool(state
.get('prompt', True))
771 self
.prompt_checkbox
.setChecked(prompt
)
772 # Restore the "tags" checkbox
773 tags
= bool(state
.get('tags', False))
774 self
.tags_checkbox
.setChecked(tags
)
778 class Pull(RemoteActionDialog
):
779 """Pull from remote repositories"""
781 def __init__(self
, context
, parent
=None):
782 super().__init
__(context
, PULL
, N_('Pull'), parent
=parent
, icon
=icons
.pull())
784 def apply_state(self
, state
):
785 """Apply persistent settings"""
786 result
= RemoteActionDialog
.apply_state(self
, state
)
787 # Rebase has the highest priority
788 rebase
= bool(state
.get('rebase', False))
789 self
.rebase_checkbox
.setChecked(rebase
)
791 ff_only
= not rebase
and bool(state
.get('ff_only', False))
792 no_ff
= not rebase
and not ff_only
and bool(state
.get('no_ff', False))
793 self
.no_ff_checkbox
.setChecked(no_ff
)
794 # Allow users coming from older versions that have rebase=False to
795 # pickup the new ff_only=True default by only setting ff_only False
796 # when it either exists in the config or when rebase=True.
797 if 'ff_only' in state
or rebase
:
798 self
.ff_only_checkbox
.setChecked(ff_only
)
801 def export_state(self
):
802 """Export persistent settings"""
803 state
= RemoteActionDialog
.export_state(self
)
804 state
['ff_only'] = get(self
.ff_only_checkbox
)
805 state
['no_ff'] = get(self
.no_ff_checkbox
)
806 state
['rebase'] = get(self
.rebase_checkbox
)