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
)
149 # pylint: disable=no-member
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 self
.action_button
= qtutils
.ok_button(title
, icon
=icon
)
222 self
.close_button
= qtutils
.close_button()
223 self
.buttons_group
= utils
.Group(self
.close_button
, self
.action_button
)
224 self
.inputs_group
= utils
.Group(
226 self
.ff_only_checkbox
,
231 self
.rebase_checkbox
,
235 self
.remote_branches
,
236 self
.upstream_checkbox
,
237 self
.prompt_checkbox
,
238 self
.remote_messages_checkbox
,
240 self
.progress
= standard
.progress_bar(
242 hide
=(self
.action_button
,),
243 disable
=(self
.buttons_group
, self
.inputs_group
),
246 self
.local_branch_layout
= qtutils
.hbox(
247 defs
.small_margin
, defs
.spacing
, self
.local_label
, self
.local_branch
250 self
.remote_layout
= qtutils
.hbox(
251 defs
.small_margin
, defs
.spacing
, self
.remote_label
, self
.remote_name
254 self
.remote_branch_layout
= qtutils
.hbox(
257 self
.remote_branch_label
,
261 self
.options_layout
= qtutils
.hbox(
265 self
.ff_only_checkbox
,
269 self
.rebase_checkbox
,
270 self
.upstream_checkbox
,
271 self
.prompt_checkbox
,
272 self
.remote_messages_checkbox
,
279 self
.remote_input_layout
= qtutils
.vbox(
280 defs
.no_margin
, defs
.spacing
, self
.remote_layout
, self
.remotes
283 self
.local_branch_input_layout
= qtutils
.vbox(
284 defs
.no_margin
, defs
.spacing
, self
.local_branch_layout
, self
.local_branches
287 self
.remote_branch_input_layout
= qtutils
.vbox(
290 self
.remote_branch_layout
,
291 self
.remote_branches
,
296 self
.remote_input_layout
,
297 self
.local_branch_input_layout
,
298 self
.remote_branch_input_layout
,
300 else: # fetch and pull
302 self
.remote_input_layout
,
303 self
.remote_branch_input_layout
,
304 self
.local_branch_input_layout
,
306 self
.top_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, *widgets
)
308 self
.main_layout
= qtutils
.vbox(
309 defs
.margin
, defs
.spacing
, self
.top_layout
, self
.options_layout
311 self
.setLayout(self
.main_layout
)
313 default_remote
= get_default_remote(context
)
315 remotes
= model
.remotes
316 if default_remote
in remotes
:
317 idx
= remotes
.index(default_remote
)
318 if self
.select_remote(idx
):
319 self
.set_remote_name(default_remote
)
321 if self
.select_first_remote():
322 self
.set_remote_name(remotes
[0])
324 # Trim the remote list to just the default remote
325 self
.update_remotes()
326 self
.set_field_defaults()
328 # Setup signals and slots
329 # pylint: disable=no-member
330 self
.remotes
.itemSelectionChanged
.connect(self
.update_remotes
)
332 local
= self
.local_branches
333 local
.itemSelectionChanged
.connect(self
.update_local_branches
)
335 remote
= self
.remote_branches
336 remote
.itemSelectionChanged
.connect(self
.update_remote_branches
)
338 self
.no_ff_checkbox
.toggled
.connect(
339 lambda x
: uncheck(x
, self
.ff_only_checkbox
, self
.rebase_checkbox
)
342 self
.ff_only_checkbox
.toggled
.connect(
343 lambda x
: uncheck(x
, self
.no_ff_checkbox
, self
.rebase_checkbox
)
346 self
.rebase_checkbox
.toggled
.connect(
347 lambda x
: uncheck(x
, self
.no_ff_checkbox
, self
.ff_only_checkbox
)
350 connect_button(self
.action_button
, self
.action_callback
)
351 connect_button(self
.close_button
, self
.close
)
354 self
, N_('Close'), self
.close
, QtGui
.QKeySequence
.Close
, 'Esc'
357 self
.prune_checkbox
.hide()
361 self
.upstream_checkbox
.hide()
362 self
.prompt_checkbox
.hide()
365 # Fetch and Push-only options
366 self
.force_checkbox
.hide()
367 self
.tags_checkbox
.hide()
368 self
.local_label
.hide()
369 self
.local_branch
.hide()
370 self
.local_branches
.hide()
373 self
.rebase_checkbox
.hide()
374 self
.no_ff_checkbox
.hide()
375 self
.ff_only_checkbox
.hide()
377 self
.init_size(parent
=parent
)
379 def set_rebase(self
, value
):
380 """Check the rebase checkbox"""
381 self
.rebase_checkbox
.setChecked(value
)
383 def set_field_defaults(self
):
384 """Set sensible initial defaults"""
385 # Default to "git fetch origin main"
387 if action
in (FETCH
, PULL
):
388 self
.local_branch
.setText('')
389 self
.remote_branch
.setText('')
392 # Select the current branch by default for push
394 branch
= self
.model
.currentbranch
396 idx
= self
.model
.local_branches
.index(branch
)
399 if self
.select_local_branch(idx
):
400 self
.set_local_branch(branch
)
401 self
.set_remote_branch('')
403 def set_remote_name(self
, remote_name
):
404 """Set the remote name"""
405 self
.remote_name
.setText(remote_name
)
407 def set_local_branch(self
, branch
):
408 """Set the local branch name"""
409 self
.local_branch
.setText(branch
)
411 self
.local_branch
.selectAll()
413 def set_remote_branch(self
, branch
):
414 """Set the remote branch name"""
415 self
.remote_branch
.setText(branch
)
417 self
.remote_branch
.selectAll()
419 def set_remote_branches(self
, branches
):
420 """Set the list of remote branches"""
421 self
.remote_branches
.clear()
422 self
.remote_branches
.addItems(branches
)
423 self
.filtered_remote_branches
= branches
424 qtutils
.add_completer(self
.remote_branch
, strip_remotes(branches
))
426 def select_first_remote(self
):
427 """Select the first remote in the list view"""
428 return self
.select_remote(0)
430 def select_remote(self
, idx
):
431 """Select a remote by index"""
432 item
= self
.remotes
.item(idx
)
434 item
.setSelected(True)
435 self
.remotes
.setCurrentItem(item
)
436 self
.set_remote_name(item
.text())
442 def select_remote_by_name(self
, remote
):
443 """Select a remote by name"""
444 remotes
= self
.model
.remotes
445 if remote
in remotes
:
446 idx
= remotes
.index(remote
)
447 result
= self
.select_remote(idx
)
452 def set_selected_remotes(self
, remotes
):
453 """Set the list of selected remotes
455 Return True if all remotes were found and selected.
458 # Invalid remote names are ignored.
459 # This handles a remote going away between sessions.
460 # The selection is unchanged when none of the specified remotes exist.
462 for remote
in remotes
:
463 if remote
in self
.model
.remotes
:
467 # Only clear the selection if the specified remotes exist
468 self
.remotes
.clearSelection()
469 found
= all(self
.select_remote_by_name(x
) for x
in remotes
)
472 def select_local_branch(self
, idx
):
473 """Selects a local branch by index in the list view"""
474 item
= self
.local_branches
.item(idx
)
476 item
.setSelected(True)
477 self
.local_branches
.setCurrentItem(item
)
478 self
.local_branch
.setText(item
.text())
484 def display_remotes(self
, widget
):
485 """Display the available remotes in a listwidget"""
487 for remote_name
in self
.model
.remotes
:
488 url
= self
.model
.remote_url(remote_name
, self
.action
)
489 display
= '{}\t({})'.format(remote_name
, N_('URL: %s') % url
)
490 displayed
.append(display
)
491 qtutils
.set_items(widget
, displayed
)
493 def update_remotes(self
):
494 """Update the remote name when a remote from the list is selected"""
495 widget
= self
.remotes
496 remotes
= self
.model
.remotes
497 selection
= qtutils
.selected_item(widget
, remotes
)
499 self
.selected_remotes
= []
501 self
.set_remote_name(selection
)
502 self
.selected_remotes
= qtutils
.selected_items(self
.remotes
, self
.model
.remotes
)
503 self
.set_remote_to(selection
, self
.selected_remotes
)
505 def set_remote_to(self
, _remote
, selected_remotes
):
506 context
= self
.context
507 all_branches
= gitcmds
.branch_list(context
, remote
=True)
510 for remote_name
in selected_remotes
:
511 patterns
.append(remote_name
+ '/*')
513 for branch
in all_branches
:
515 if fnmatch
.fnmatch(branch
, pat
):
516 branches
.append(branch
)
519 self
.set_remote_branches(branches
)
521 self
.set_remote_branches(all_branches
)
522 self
.set_remote_branch('')
524 def remote_name_edited(self
):
525 """Update the current remote when the remote name is typed manually"""
526 remote
= self
.remote_name
.text()
527 self
.set_remote_to(remote
, [remote
])
529 def update_local_branches(self
):
530 """Update the local/remote branch names when a branch is selected"""
531 branches
= self
.model
.local_branches
532 widget
= self
.local_branches
533 selection
= qtutils
.selected_item(widget
, branches
)
536 self
.set_local_branch(selection
)
537 self
.set_remote_branch(selection
)
539 def update_remote_branches(self
):
540 """Update the remote branch name when a branch is selected"""
541 widget
= self
.remote_branches
542 branches
= self
.filtered_remote_branches
543 selection
= qtutils
.selected_item(widget
, branches
)
546 branch
= utils
.strip_one(selection
)
549 self
.set_remote_branch(branch
)
551 def common_args(self
):
552 """Returns git arguments common to fetch/push/pull"""
553 remote_name
= self
.remote_name
.text()
554 local_branch
= self
.local_branch
.text()
555 remote_branch
= self
.remote_branch
.text()
557 ff_only
= get(self
.ff_only_checkbox
)
558 force
= get(self
.force_checkbox
)
559 no_ff
= get(self
.no_ff_checkbox
)
560 rebase
= get(self
.rebase_checkbox
)
561 set_upstream
= get(self
.upstream_checkbox
)
562 tags
= get(self
.tags_checkbox
)
563 prune
= get(self
.prune_checkbox
)
570 'local_branch': local_branch
,
573 'remote_branch': remote_branch
,
574 'set_upstream': set_upstream
,
582 def push_to_all(self
, _remote
, *args
, **kwargs
):
583 """Push to all selected remotes"""
584 selected_remotes
= self
.selected_remotes
586 for remote
in selected_remotes
:
587 result
= self
.model
.push(remote
, *args
, **kwargs
)
588 all_results
= combine(result
, all_results
)
591 def action_callback(self
):
592 """Perform the actual fetch/push/pull operation"""
594 remote_messages
= get(self
.remote_messages_checkbox
)
596 model_action
= self
.model
.fetch
598 model_action
= self
.push_to_all
599 else: # if action == PULL:
600 model_action
= self
.model
.pull
602 remote_name
= self
.remote_name
.text()
604 errmsg
= N_('No repository selected.')
605 Interaction
.log(errmsg
)
607 remote
, kwargs
= self
.common_args()
608 self
.selected_remotes
= qtutils
.selected_items(self
.remotes
, self
.model
.remotes
)
610 # Check if we're about to create a new branch and warn.
611 remote_branch
= self
.remote_branch
.text()
612 local_branch
= self
.local_branch
.text()
614 if action
== PUSH
and not remote_branch
:
615 branch
= local_branch
616 candidate
= f
'{remote}/{branch}'
617 prompt
= get(self
.prompt_checkbox
)
619 if prompt
and candidate
not in self
.model
.remote_branches
:
627 'Branch "%(branch)s" does not exist in "%(remote)s".\n'
628 'A new remote branch will be published.'
632 info_txt
= N_('Create a new remote branch?')
633 ok_text
= N_('Create Remote Branch')
634 if not Interaction
.confirm(
635 title
, msg
, info_txt
, ok_text
, icon
=icons
.cola()
639 if get(self
.force_checkbox
):
641 title
= N_('Force Fetch?')
642 msg
= N_('Non-fast-forward fetch overwrites local history!')
643 info_txt
= N_('Force fetching from %s?') % remote
644 ok_text
= N_('Force Fetch')
646 title
= N_('Force Push?')
648 'Non-fast-forward push overwrites published '
649 'history!\n(Did you pull first?)'
651 info_txt
= N_('Force push to %s?') % remote
652 ok_text
= N_('Force Push')
653 else: # pull: shouldn't happen since the controls are hidden
655 if not Interaction
.confirm(
656 title
, msg
, info_txt
, ok_text
, default
=False, icon
=icons
.discard()
660 self
.progress
.setMaximumWidth(self
.action_button
.width())
661 self
.progress
.setMinimumHeight(self
.action_button
.height() - 2)
663 # Use a thread to update in the background
664 task
= ActionTask(model_action
, remote
, kwargs
)
666 result
= log
.show_remote_messages(self
, self
.context
)
671 progress
=self
.progress
,
672 finish
=self
.action_completed
,
676 def action_completed(self
, task
):
677 """Grab the results of the action and finish up"""
678 if not task
.result
or not isinstance(task
.result
, (list, tuple)):
681 status
, out
, err
= task
.result
682 command
= 'git %s' % self
.action
.lower()
683 message
= Interaction
.format_command_status(command
, status
)
684 details
= Interaction
.format_out_err(out
, err
)
686 log_message
= message
688 log_message
+= '\n\n' + details
689 Interaction
.log(log_message
)
695 if self
.action
== PUSH
:
697 message
+= N_('Have you rebased/pulled lately?')
699 Interaction
.critical(self
.windowTitle(), message
=message
, details
=details
)
701 def export_state(self
):
702 """Export persistent settings"""
703 state
= standard
.Dialog
.export_state(self
)
704 state
['remote_messages'] = get(self
.remote_messages_checkbox
)
707 def apply_state(self
, state
):
708 """Apply persistent settings"""
709 result
= standard
.Dialog
.apply_state(self
, state
)
710 # Restore the "show remote messages" checkbox
711 remote_messages
= bool(state
.get('remote_messages', False))
712 self
.remote_messages_checkbox
.setChecked(remote_messages
)
716 # Use distinct classes so that each saves its own set of preferences
717 class Fetch(RemoteActionDialog
):
718 """Fetch from remote repositories"""
720 def __init__(self
, context
, parent
=None):
721 super().__init
__(context
, FETCH
, N_('Fetch'), parent
=parent
, icon
=icons
.repo())
723 def export_state(self
):
724 """Export persistent settings"""
725 state
= RemoteActionDialog
.export_state(self
)
726 state
['tags'] = get(self
.tags_checkbox
)
727 state
['prune'] = get(self
.prune_checkbox
)
730 def apply_state(self
, state
):
731 """Apply persistent settings"""
732 result
= RemoteActionDialog
.apply_state(self
, state
)
733 tags
= bool(state
.get('tags', False))
734 self
.tags_checkbox
.setChecked(tags
)
735 prune
= bool(state
.get('prune', False))
736 self
.prune_checkbox
.setChecked(prune
)
740 class Push(RemoteActionDialog
):
741 """Push to remote repositories"""
743 def __init__(self
, context
, parent
=None):
744 super().__init
__(context
, PUSH
, N_('Push'), parent
=parent
, icon
=icons
.push())
746 def export_state(self
):
747 """Export persistent settings"""
748 state
= RemoteActionDialog
.export_state(self
)
749 state
['prompt'] = get(self
.prompt_checkbox
)
750 state
['tags'] = get(self
.tags_checkbox
)
753 def apply_state(self
, state
):
754 """Apply persistent settings"""
755 result
= RemoteActionDialog
.apply_state(self
, state
)
756 # Restore the "prompt on creation" checkbox
757 prompt
= bool(state
.get('prompt', True))
758 self
.prompt_checkbox
.setChecked(prompt
)
759 # Restore the "tags" checkbox
760 tags
= bool(state
.get('tags', False))
761 self
.tags_checkbox
.setChecked(tags
)
765 class Pull(RemoteActionDialog
):
766 """Pull from remote repositories"""
768 def __init__(self
, context
, parent
=None):
769 super().__init
__(context
, PULL
, N_('Pull'), parent
=parent
, icon
=icons
.pull())
771 def apply_state(self
, state
):
772 """Apply persistent settings"""
773 result
= RemoteActionDialog
.apply_state(self
, state
)
774 # Rebase has the highest priority
775 rebase
= bool(state
.get('rebase', False))
776 self
.rebase_checkbox
.setChecked(rebase
)
778 ff_only
= not rebase
and bool(state
.get('ff_only', False))
779 no_ff
= not rebase
and not ff_only
and bool(state
.get('no_ff', False))
780 self
.no_ff_checkbox
.setChecked(no_ff
)
781 # Allow users coming from older versions that have rebase=False to
782 # pickup the new ff_only=True default by only setting ff_only False
783 # when it either exists in the config or when rebase=True.
784 if 'ff_only' in state
or rebase
:
785 self
.ff_only_checkbox
.setChecked(ff_only
)
788 def export_state(self
):
789 """Export persistent settings"""
790 state
= RemoteActionDialog
.export_state(self
)
791 state
['ff_only'] = get(self
.ff_only_checkbox
)
792 state
['no_ff'] = get(self
.no_ff_checkbox
)
793 state
['rebase'] = get(self
.rebase_checkbox
)