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
17 from . import standard
18 from . import remotemessage
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
.progress
= standard
.progress(title
, N_('Updating'), self
)
137 self
.local_label
= QtWidgets
.QLabel()
138 self
.local_label
.setText(N_('Local Branch'))
140 self
.local_branch
= QtWidgets
.QLineEdit()
141 qtutils
.add_completer(self
.local_branch
, model
.local_branches
)
143 self
.local_branches
= QtWidgets
.QListWidget()
144 self
.local_branches
.addItems(model
.local_branches
)
146 self
.remote_label
= QtWidgets
.QLabel()
147 self
.remote_label
.setText(N_('Remote'))
149 self
.remote_name
= QtWidgets
.QLineEdit()
150 qtutils
.add_completer(self
.remote_name
, model
.remotes
)
151 # pylint: disable=no-member
152 self
.remote_name
.editingFinished
.connect(self
.remote_name_edited
)
153 self
.remote_name
.textEdited
.connect(lambda x
: self
.remote_name_edited())
155 self
.remotes
= QtWidgets
.QListWidget()
157 mode
= QtWidgets
.QAbstractItemView
.ExtendedSelection
158 self
.remotes
.setSelectionMode(mode
)
159 self
.remotes
.addItems(model
.remotes
)
161 self
.remote_branch_label
= QtWidgets
.QLabel()
162 self
.remote_branch_label
.setText(N_('Remote Branch'))
164 self
.remote_branch
= QtWidgets
.QLineEdit()
165 remote_branches
= strip_remotes(model
.remote_branches
)
166 qtutils
.add_completer(self
.remote_branch
, remote_branches
)
168 self
.remote_branches
= QtWidgets
.QListWidget()
169 self
.remote_branches
.addItems(model
.remote_branches
)
171 text
= N_('Prompt on creation')
172 tooltip
= N_('Prompt when pushing creates new remote branches')
173 self
.prompt_checkbox
= qtutils
.checkbox(
174 checked
=True, text
=text
, tooltip
=tooltip
177 text
= N_('Show remote messages')
178 tooltip
= N_('Display remote messages in a separate dialog')
179 self
.remote_messages_checkbox
= qtutils
.checkbox(
180 checked
=False, text
=text
, tooltip
=tooltip
183 text
= N_('Fast-forward only')
185 'Refuse to merge unless the current HEAD is already up-'
186 'to-date or the merge can be resolved as a fast-forward'
188 self
.ff_only_checkbox
= qtutils
.checkbox(
189 checked
=True, text
=text
, tooltip
=tooltip
192 text
= N_('No fast-forward')
194 'Create a merge commit even when the merge resolves as a fast-forward'
196 self
.no_ff_checkbox
= qtutils
.checkbox(
197 checked
=False, text
=text
, tooltip
=tooltip
201 'Allow non-fast-forward updates. Using "force" can '
202 'cause the remote repository to lose commits; '
205 self
.force_checkbox
= qtutils
.checkbox(
206 checked
=False, text
=text
, tooltip
=tooltip
209 self
.tags_checkbox
= qtutils
.checkbox(text
=N_('Include tags '))
212 'Remove remote-tracking branches that no longer exist on the remote'
214 self
.prune_checkbox
= qtutils
.checkbox(text
=N_('Prune '), tooltip
=tooltip
)
216 tooltip
= N_('Rebase the current branch instead of merging')
217 self
.rebase_checkbox
= qtutils
.checkbox(text
=N_('Rebase'), tooltip
=tooltip
)
219 text
= N_('Set upstream')
220 tooltip
= N_('Configure the remote branch as the the new upstream')
221 self
.upstream_checkbox
= qtutils
.checkbox(text
=text
, tooltip
=tooltip
)
223 self
.action_button
= qtutils
.ok_button(title
, icon
=icon
)
224 self
.close_button
= qtutils
.close_button()
226 self
.buttons
= utils
.Group(self
.action_button
, self
.close_button
)
228 self
.local_branch_layout
= qtutils
.hbox(
229 defs
.small_margin
, defs
.spacing
, self
.local_label
, self
.local_branch
232 self
.remote_layout
= qtutils
.hbox(
233 defs
.small_margin
, defs
.spacing
, self
.remote_label
, self
.remote_name
236 self
.remote_branch_layout
= qtutils
.hbox(
239 self
.remote_branch_label
,
243 self
.options_layout
= qtutils
.hbox(
247 self
.ff_only_checkbox
,
251 self
.rebase_checkbox
,
252 self
.upstream_checkbox
,
253 self
.prompt_checkbox
,
254 self
.remote_messages_checkbox
,
260 self
.remote_input_layout
= qtutils
.vbox(
261 defs
.no_margin
, defs
.spacing
, self
.remote_layout
, self
.remotes
264 self
.local_branch_input_layout
= qtutils
.vbox(
265 defs
.no_margin
, defs
.spacing
, self
.local_branch_layout
, self
.local_branches
268 self
.remote_branch_input_layout
= qtutils
.vbox(
271 self
.remote_branch_layout
,
272 self
.remote_branches
,
277 self
.remote_input_layout
,
278 self
.local_branch_input_layout
,
279 self
.remote_branch_input_layout
,
281 else: # fetch and pull
283 self
.remote_input_layout
,
284 self
.remote_branch_input_layout
,
285 self
.local_branch_input_layout
,
287 self
.top_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, *widgets
)
289 self
.main_layout
= qtutils
.vbox(
290 defs
.margin
, defs
.spacing
, self
.top_layout
, self
.options_layout
292 self
.setLayout(self
.main_layout
)
294 default_remote
= get_default_remote(context
)
296 remotes
= model
.remotes
297 if default_remote
in remotes
:
298 idx
= remotes
.index(default_remote
)
299 if self
.select_remote(idx
):
300 self
.set_remote_name(default_remote
)
302 if self
.select_first_remote():
303 self
.set_remote_name(remotes
[0])
305 # Trim the remote list to just the default remote
306 self
.update_remotes()
307 self
.set_field_defaults()
309 # Setup signals and slots
310 # pylint: disable=no-member
311 self
.remotes
.itemSelectionChanged
.connect(self
.update_remotes
)
313 local
= self
.local_branches
314 local
.itemSelectionChanged
.connect(self
.update_local_branches
)
316 remote
= self
.remote_branches
317 remote
.itemSelectionChanged
.connect(self
.update_remote_branches
)
319 self
.no_ff_checkbox
.toggled
.connect(
320 lambda x
: uncheck(x
, self
.ff_only_checkbox
, self
.rebase_checkbox
)
323 self
.ff_only_checkbox
.toggled
.connect(
324 lambda x
: uncheck(x
, self
.no_ff_checkbox
, self
.rebase_checkbox
)
327 self
.rebase_checkbox
.toggled
.connect(
328 lambda x
: uncheck(x
, self
.no_ff_checkbox
, self
.ff_only_checkbox
)
331 connect_button(self
.action_button
, self
.action_callback
)
332 connect_button(self
.close_button
, self
.close
)
335 self
, N_('Close'), self
.close
, QtGui
.QKeySequence
.Close
, 'Esc'
338 self
.prune_checkbox
.hide()
342 self
.upstream_checkbox
.hide()
343 self
.prompt_checkbox
.hide()
344 self
.remote_messages_checkbox
.hide()
347 # Fetch and Push-only options
348 self
.force_checkbox
.hide()
349 self
.tags_checkbox
.hide()
350 self
.local_label
.hide()
351 self
.local_branch
.hide()
352 self
.local_branches
.hide()
355 self
.rebase_checkbox
.hide()
356 self
.no_ff_checkbox
.hide()
357 self
.ff_only_checkbox
.hide()
359 self
.init_size(parent
=parent
)
361 def set_rebase(self
, value
):
362 """Check the rebase checkbox"""
363 self
.rebase_checkbox
.setChecked(value
)
365 def set_field_defaults(self
):
366 """Set sensible initial defaults"""
367 # Default to "git fetch origin main"
369 if action
in (FETCH
, PULL
):
370 self
.local_branch
.setText('')
371 self
.remote_branch
.setText('')
374 # Select the current branch by default for push
376 branch
= self
.model
.currentbranch
378 idx
= self
.model
.local_branches
.index(branch
)
381 if self
.select_local_branch(idx
):
382 self
.set_local_branch(branch
)
383 self
.set_remote_branch('')
385 def set_remote_name(self
, remote_name
):
386 """Set the remote name"""
387 self
.remote_name
.setText(remote_name
)
389 def set_local_branch(self
, branch
):
390 """Set the local branch name"""
391 self
.local_branch
.setText(branch
)
393 self
.local_branch
.selectAll()
395 def set_remote_branch(self
, branch
):
396 """Set the remote branch name"""
397 self
.remote_branch
.setText(branch
)
399 self
.remote_branch
.selectAll()
401 def set_remote_branches(self
, branches
):
402 """Set the list of remote branches"""
403 self
.remote_branches
.clear()
404 self
.remote_branches
.addItems(branches
)
405 self
.filtered_remote_branches
= branches
406 qtutils
.add_completer(self
.remote_branch
, strip_remotes(branches
))
408 def select_first_remote(self
):
409 """Select the first remote in the list view"""
410 return self
.select_remote(0)
412 def select_remote(self
, idx
):
413 """Select a remote by index"""
414 item
= self
.remotes
.item(idx
)
416 item
.setSelected(True)
417 self
.remotes
.setCurrentItem(item
)
418 self
.set_remote_name(item
.text())
424 def select_remote_by_name(self
, remote
):
425 """Select a remote by name"""
426 remotes
= self
.model
.remotes
427 if remote
in remotes
:
428 idx
= remotes
.index(remote
)
429 result
= self
.select_remote(idx
)
434 def set_selected_remotes(self
, remotes
):
435 """Set the list of selected remotes
437 Return True if all remotes were found and selected.
440 # Invalid remote names are ignored.
441 # This handles a remote going away between sessions.
442 # The selection is unchanged when none of the specified remotes exist.
444 for remote
in remotes
:
445 if remote
in self
.model
.remotes
:
449 # Only clear the selection if the specified remotes exist
450 self
.remotes
.clearSelection()
451 found
= all(self
.select_remote_by_name(x
) for x
in remotes
)
454 def select_local_branch(self
, idx
):
455 """Selects a local branch by index in the list view"""
456 item
= self
.local_branches
.item(idx
)
458 item
.setSelected(True)
459 self
.local_branches
.setCurrentItem(item
)
460 self
.local_branch
.setText(item
.text())
466 def display_remotes(self
, widget
):
467 """Display the available remotes in a listwidget"""
469 for remote_name
in self
.model
.remotes
:
470 url
= self
.model
.remote_url(remote_name
, self
.action
)
471 display
= '{}\t({})'.format(remote_name
, N_('URL: %s') % url
)
472 displayed
.append(display
)
473 qtutils
.set_items(widget
, displayed
)
475 def update_remotes(self
):
476 """Update the remote name when a remote from the list is selected"""
477 widget
= self
.remotes
478 remotes
= self
.model
.remotes
479 selection
= qtutils
.selected_item(widget
, remotes
)
481 self
.selected_remotes
= []
483 self
.set_remote_name(selection
)
484 self
.selected_remotes
= qtutils
.selected_items(self
.remotes
, self
.model
.remotes
)
485 self
.set_remote_to(selection
, self
.selected_remotes
)
487 def set_remote_to(self
, _remote
, selected_remotes
):
488 context
= self
.context
489 all_branches
= gitcmds
.branch_list(context
, remote
=True)
492 for remote_name
in selected_remotes
:
493 patterns
.append(remote_name
+ '/*')
495 for branch
in all_branches
:
497 if fnmatch
.fnmatch(branch
, pat
):
498 branches
.append(branch
)
501 self
.set_remote_branches(branches
)
503 self
.set_remote_branches(all_branches
)
504 self
.set_remote_branch('')
506 def remote_name_edited(self
):
507 """Update the current remote when the remote name is typed manually"""
508 remote
= self
.remote_name
.text()
509 self
.set_remote_to(remote
, [remote
])
511 def update_local_branches(self
):
512 """Update the local/remote branch names when a branch is selected"""
513 branches
= self
.model
.local_branches
514 widget
= self
.local_branches
515 selection
= qtutils
.selected_item(widget
, branches
)
518 self
.set_local_branch(selection
)
519 self
.set_remote_branch(selection
)
521 def update_remote_branches(self
):
522 """Update the remote branch name when a branch is selected"""
523 widget
= self
.remote_branches
524 branches
= self
.filtered_remote_branches
525 selection
= qtutils
.selected_item(widget
, branches
)
528 branch
= utils
.strip_one(selection
)
531 self
.set_remote_branch(branch
)
533 def common_args(self
):
534 """Returns git arguments common to fetch/push/pull"""
535 remote_name
= self
.remote_name
.text()
536 local_branch
= self
.local_branch
.text()
537 remote_branch
= self
.remote_branch
.text()
539 ff_only
= get(self
.ff_only_checkbox
)
540 force
= get(self
.force_checkbox
)
541 no_ff
= get(self
.no_ff_checkbox
)
542 rebase
= get(self
.rebase_checkbox
)
543 set_upstream
= get(self
.upstream_checkbox
)
544 tags
= get(self
.tags_checkbox
)
545 prune
= get(self
.prune_checkbox
)
552 'local_branch': local_branch
,
555 'remote_branch': remote_branch
,
556 'set_upstream': set_upstream
,
564 def push_to_all(self
, _remote
, *args
, **kwargs
):
565 """Push to all selected remotes"""
566 selected_remotes
= self
.selected_remotes
568 for remote
in selected_remotes
:
569 result
= self
.model
.push(remote
, *args
, **kwargs
)
570 all_results
= combine(result
, all_results
)
573 def action_callback(self
):
574 """Perform the actual fetch/push/pull operation"""
576 remote_messages
= False
578 model_action
= self
.model
.fetch
580 model_action
= self
.push_to_all
581 remote_messages
= get(self
.remote_messages_checkbox
)
582 else: # if action == PULL:
583 model_action
= self
.model
.pull
585 remote_name
= self
.remote_name
.text()
587 errmsg
= N_('No repository selected.')
588 Interaction
.log(errmsg
)
590 remote
, kwargs
= self
.common_args()
591 self
.selected_remotes
= qtutils
.selected_items(self
.remotes
, self
.model
.remotes
)
593 # Check if we're about to create a new branch and warn.
594 remote_branch
= self
.remote_branch
.text()
595 local_branch
= self
.local_branch
.text()
597 if action
== PUSH
and not remote_branch
:
598 branch
= local_branch
599 candidate
= f
'{remote}/{branch}'
600 prompt
= get(self
.prompt_checkbox
)
602 if prompt
and candidate
not in self
.model
.remote_branches
:
610 'Branch "%(branch)s" does not exist in "%(remote)s".\n'
611 'A new remote branch will be published.'
615 info_txt
= N_('Create a new remote branch?')
616 ok_text
= N_('Create Remote Branch')
617 if not Interaction
.confirm(
618 title
, msg
, info_txt
, ok_text
, icon
=icons
.cola()
622 if get(self
.force_checkbox
):
624 title
= N_('Force Fetch?')
625 msg
= N_('Non-fast-forward fetch overwrites local history!')
626 info_txt
= N_('Force fetching from %s?') % remote
627 ok_text
= N_('Force Fetch')
629 title
= N_('Force Push?')
631 'Non-fast-forward push overwrites published '
632 'history!\n(Did you pull first?)'
634 info_txt
= N_('Force push to %s?') % remote
635 ok_text
= N_('Force Push')
636 else: # pull: shouldn't happen since the controls are hidden
638 if not Interaction
.confirm(
639 title
, msg
, info_txt
, ok_text
, default
=False, icon
=icons
.discard()
643 # Disable the GUI by default
644 self
.buttons
.setEnabled(False)
646 # Use a thread to update in the background
647 task
= ActionTask(model_action
, remote
, kwargs
)
649 result
= remotemessage
.with_context(self
.context
)
654 progress
=self
.progress
,
655 finish
=self
.action_completed
,
659 def action_completed(self
, task
):
660 """Grab the results of the action and finish up"""
661 self
.buttons
.setEnabled(True)
662 if not task
.result
or not isinstance(task
.result
, (list, tuple)):
665 status
, out
, err
= task
.result
666 command
= 'git %s' % self
.action
.lower()
667 message
= Interaction
.format_command_status(command
, status
)
668 details
= Interaction
.format_out_err(out
, err
)
670 log_message
= message
672 log_message
+= '\n\n' + details
673 Interaction
.log(log_message
)
679 if self
.action
== PUSH
:
681 message
+= N_('Have you rebased/pulled lately?')
683 Interaction
.critical(self
.windowTitle(), message
=message
, details
=details
)
686 # Use distinct classes so that each saves its own set of preferences
687 class Fetch(RemoteActionDialog
):
688 """Fetch from remote repositories"""
690 def __init__(self
, context
, parent
=None):
691 super().__init
__(context
, FETCH
, N_('Fetch'), parent
=parent
, icon
=icons
.repo())
693 def export_state(self
):
694 """Export persistent settings"""
695 state
= RemoteActionDialog
.export_state(self
)
696 state
['tags'] = get(self
.tags_checkbox
)
697 state
['prune'] = get(self
.prune_checkbox
)
700 def apply_state(self
, state
):
701 """Apply persistent settings"""
702 result
= RemoteActionDialog
.apply_state(self
, state
)
703 tags
= bool(state
.get('tags', False))
704 self
.tags_checkbox
.setChecked(tags
)
705 prune
= bool(state
.get('prune', False))
706 self
.prune_checkbox
.setChecked(prune
)
710 class Push(RemoteActionDialog
):
711 """Push to remote repositories"""
713 def __init__(self
, context
, parent
=None):
714 super().__init
__(context
, PUSH
, N_('Push'), parent
=parent
, icon
=icons
.push())
716 def export_state(self
):
717 """Export persistent settings"""
718 state
= RemoteActionDialog
.export_state(self
)
719 state
['prompt'] = get(self
.prompt_checkbox
)
720 state
['remote_messages'] = get(self
.remote_messages_checkbox
)
721 state
['tags'] = get(self
.tags_checkbox
)
724 def apply_state(self
, state
):
725 """Apply persistent settings"""
726 result
= RemoteActionDialog
.apply_state(self
, state
)
727 # Restore the "prompt on creation" checkbox
728 prompt
= bool(state
.get('prompt', True))
729 self
.prompt_checkbox
.setChecked(prompt
)
730 # Restore the "show remote messages" checkbox
731 remote_messages
= bool(state
.get('remote_messages', False))
732 self
.remote_messages_checkbox
.setChecked(remote_messages
)
733 # Restore the "tags" checkbox
734 tags
= bool(state
.get('tags', False))
735 self
.tags_checkbox
.setChecked(tags
)
740 class Pull(RemoteActionDialog
):
741 """Pull from remote repositories"""
743 def __init__(self
, context
, parent
=None):
744 super().__init
__(context
, PULL
, N_('Pull'), parent
=parent
, icon
=icons
.pull())
746 def apply_state(self
, state
):
747 """Apply persistent settings"""
748 result
= RemoteActionDialog
.apply_state(self
, state
)
749 # Rebase has the highest priority
750 rebase
= bool(state
.get('rebase', False))
751 ff_only
= not rebase
and bool(state
.get('ff_only', False))
752 no_ff
= not rebase
and not ff_only
and bool(state
.get('no_ff', False))
754 self
.rebase_checkbox
.setChecked(rebase
)
755 self
.no_ff_checkbox
.setChecked(no_ff
)
757 # Allow users coming from older versions that have rebase=False to
758 # pickup the new ff_only=True default by only setting ff_only False
759 # when it either exists in the config or when rebase=True.
760 if 'ff_only' in state
or rebase
:
761 self
.ff_only_checkbox
.setChecked(ff_only
)
765 def export_state(self
):
766 """Export persistent settings"""
767 state
= RemoteActionDialog
.export_state(self
)
769 state
['ff_only'] = get(self
.ff_only_checkbox
)
770 state
['no_ff'] = get(self
.no_ff_checkbox
)
771 state
['rebase'] = get(self
.rebase_checkbox
)