diff: move selected_lines() and selected_text() to DiffTextEdit
[git-cola.git] / cola / widgets / remote.py
bloba5119b149269320802fbca51f8634ed86287475b
1 """Widgets for Fetch, Push, and Pull"""
2 from __future__ import absolute_import, division, print_function, unicode_literals
3 import fnmatch
5 from qtpy import QtGui
6 from qtpy import QtWidgets
7 from qtpy.QtCore import Qt
9 from ..i18n import N_
10 from ..interaction import Interaction
11 from ..qtutils import connect_button
12 from ..qtutils import get
13 from .. import gitcmds
14 from .. import icons
15 from .. import qtutils
16 from .. import utils
17 from . import defs
18 from . import standard
21 FETCH = 'FETCH'
22 PUSH = 'PUSH'
23 PULL = 'PULL'
26 def fetch(context):
27 """Fetch from remote repositories"""
28 return run(context, Fetch)
31 def push(context):
32 """Push to remote repositories"""
33 return run(context, Push)
36 def pull(context):
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)
46 view.show()
47 return view
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.
57 """
58 if isinstance(prev, (tuple, list)):
59 if len(prev) != 3:
60 raise AssertionError('combine() with length %d' % len(prev))
61 combined = (
62 max(prev[0], result[0]),
63 combine(prev[1], result[1]),
64 combine(prev[2], result[2]),
66 elif prev and result:
67 combined = prev + '\n\n' + result
68 elif prev:
69 combined = prev
70 else:
71 combined = result
73 return combined
76 def uncheck(value, *checkboxes):
77 """Uncheck the specified checkboxes if value is True"""
78 if value:
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".
88 """
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)
99 """
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
110 self.remote = remote
111 self.kwargs = kwargs
113 def task(self):
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
130 self.action = action
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()
156 if action == PUSH:
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_('Fast-forward only')
178 tooltip = N_(
179 'Refuse to merge unless the current HEAD is already up-'
180 'to-date or the merge can be resolved as a fast-forward'
182 self.ff_only_checkbox = qtutils.checkbox(
183 checked=True, text=text, tooltip=tooltip
186 text = N_('No fast-forward')
187 tooltip = N_(
188 'Create a merge commit even when the merge resolves as a fast-forward'
190 self.no_ff_checkbox = qtutils.checkbox(
191 checked=False, text=text, tooltip=tooltip
193 text = N_('Force')
194 tooltip = N_(
195 'Allow non-fast-forward updates. Using "force" can '
196 'cause the remote repository to lose commits; '
197 'use it with care'
199 self.force_checkbox = qtutils.checkbox(
200 checked=False, text=text, tooltip=tooltip
203 self.tags_checkbox = qtutils.checkbox(text=N_('Include tags '))
205 tooltip = N_(
206 'Remove remote-tracking branches that no longer exist on the remote'
208 self.prune_checkbox = qtutils.checkbox(text=N_('Prune '), tooltip=tooltip)
210 tooltip = N_('Rebase the current branch instead of merging')
211 self.rebase_checkbox = qtutils.checkbox(text=N_('Rebase'), tooltip=tooltip)
213 text = N_('Set upstream')
214 tooltip = N_('Configure the remote branch as the the new upstream')
215 self.upstream_checkbox = qtutils.checkbox(text=text, tooltip=tooltip)
217 self.action_button = qtutils.ok_button(title, icon=icon)
218 self.close_button = qtutils.close_button()
220 self.buttons = utils.Group(self.action_button, self.close_button)
222 self.local_branch_layout = qtutils.hbox(
223 defs.small_margin, defs.spacing, self.local_label, self.local_branch
226 self.remote_layout = qtutils.hbox(
227 defs.small_margin, defs.spacing, self.remote_label, self.remote_name
230 self.remote_branch_layout = qtutils.hbox(
231 defs.small_margin,
232 defs.spacing,
233 self.remote_branch_label,
234 self.remote_branch,
237 self.options_layout = qtutils.hbox(
238 defs.no_margin,
239 defs.button_spacing,
240 self.force_checkbox,
241 self.ff_only_checkbox,
242 self.no_ff_checkbox,
243 self.tags_checkbox,
244 self.prune_checkbox,
245 self.rebase_checkbox,
246 self.upstream_checkbox,
247 self.prompt_checkbox,
248 qtutils.STRETCH,
249 self.close_button,
250 self.action_button,
253 self.remote_input_layout = qtutils.vbox(
254 defs.no_margin, defs.spacing, self.remote_layout, self.remotes
257 self.local_branch_input_layout = qtutils.vbox(
258 defs.no_margin, defs.spacing, self.local_branch_layout, self.local_branches
261 self.remote_branch_input_layout = qtutils.vbox(
262 defs.no_margin,
263 defs.spacing,
264 self.remote_branch_layout,
265 self.remote_branches,
268 if action == PUSH:
269 widgets = (
270 self.remote_input_layout,
271 self.local_branch_input_layout,
272 self.remote_branch_input_layout,
274 else: # fetch and pull
275 widgets = (
276 self.remote_input_layout,
277 self.remote_branch_input_layout,
278 self.local_branch_input_layout,
280 self.top_layout = qtutils.hbox(defs.no_margin, defs.spacing, *widgets)
282 self.main_layout = qtutils.vbox(
283 defs.margin, defs.spacing, self.top_layout, self.options_layout
285 self.setLayout(self.main_layout)
287 default_remote = get_default_remote(context)
289 remotes = model.remotes
290 if default_remote in remotes:
291 idx = remotes.index(default_remote)
292 if self.select_remote(idx):
293 self.set_remote_name(default_remote)
294 else:
295 if self.select_first_remote():
296 self.set_remote_name(remotes[0])
298 # Trim the remote list to just the default remote
299 self.update_remotes()
300 self.set_field_defaults()
302 # Setup signals and slots
303 # pylint: disable=no-member
304 self.remotes.itemSelectionChanged.connect(self.update_remotes)
306 local = self.local_branches
307 local.itemSelectionChanged.connect(self.update_local_branches)
309 remote = self.remote_branches
310 remote.itemSelectionChanged.connect(self.update_remote_branches)
312 self.no_ff_checkbox.toggled.connect(
313 lambda x: uncheck(x, self.ff_only_checkbox, self.rebase_checkbox)
316 self.ff_only_checkbox.toggled.connect(
317 lambda x: uncheck(x, self.no_ff_checkbox, self.rebase_checkbox)
320 self.rebase_checkbox.toggled.connect(
321 lambda x: uncheck(x, self.no_ff_checkbox, self.ff_only_checkbox)
324 connect_button(self.action_button, self.action_callback)
325 connect_button(self.close_button, self.close)
327 qtutils.add_action(
328 self, N_('Close'), self.close, QtGui.QKeySequence.Close, 'Esc'
330 if action != FETCH:
331 self.prune_checkbox.hide()
333 if action != PUSH:
334 # Push-only options
335 self.upstream_checkbox.hide()
336 self.prompt_checkbox.hide()
338 if action == PULL:
339 # Fetch and Push-only options
340 self.force_checkbox.hide()
341 self.tags_checkbox.hide()
342 self.local_label.hide()
343 self.local_branch.hide()
344 self.local_branches.hide()
345 else:
346 # Pull-only options
347 self.rebase_checkbox.hide()
348 self.no_ff_checkbox.hide()
349 self.ff_only_checkbox.hide()
351 self.init_size(parent=parent)
353 def set_rebase(self, value):
354 """Check the rebase checkbox"""
355 self.rebase_checkbox.setChecked(value)
357 def set_field_defaults(self):
358 """Set sensible initial defaults"""
359 # Default to "git fetch origin main"
360 action = self.action
361 if action in (FETCH, PULL):
362 self.local_branch.setText('')
363 self.remote_branch.setText('')
364 return
366 # Select the current branch by default for push
367 if action == PUSH:
368 branch = self.model.currentbranch
369 try:
370 idx = self.model.local_branches.index(branch)
371 except ValueError:
372 return
373 if self.select_local_branch(idx):
374 self.set_local_branch(branch)
375 self.set_remote_branch('')
377 def set_remote_name(self, remote_name):
378 """Set the remote name"""
379 self.remote_name.setText(remote_name)
381 def set_local_branch(self, branch):
382 """Set the local branch name"""
383 self.local_branch.setText(branch)
384 if branch:
385 self.local_branch.selectAll()
387 def set_remote_branch(self, branch):
388 """Set the remote branch name"""
389 self.remote_branch.setText(branch)
390 if branch:
391 self.remote_branch.selectAll()
393 def set_remote_branches(self, branches):
394 """Set the list of remote branches"""
395 self.remote_branches.clear()
396 self.remote_branches.addItems(branches)
397 self.filtered_remote_branches = branches
398 qtutils.add_completer(self.remote_branch, strip_remotes(branches))
400 def select_first_remote(self):
401 """Select the first remote in the list view"""
402 return self.select_remote(0)
404 def select_remote(self, idx):
405 """Select a remote by index"""
406 item = self.remotes.item(idx)
407 if item:
408 item.setSelected(True)
409 self.remotes.setCurrentItem(item)
410 self.set_remote_name(item.text())
411 result = True
412 else:
413 result = False
414 return result
416 def select_remote_by_name(self, remote):
417 """Select a remote by name"""
418 remotes = self.model.remotes
419 if remote in remotes:
420 idx = remotes.index(remote)
421 result = self.select_remote(idx)
422 else:
423 result = False
424 return result
426 def set_selected_remotes(self, remotes):
427 """Set the list of selected remotes
429 Return True if all remotes were found and selected.
432 # Invalid remote names are ignored.
433 # This handles a remote going away between sessions.
434 # The selection is unchanged when none of the specified remotes exist.
435 found = False
436 for remote in remotes:
437 if remote in self.model.remotes:
438 found = True
439 break
440 if found:
441 # Only clear the selection if the specified remotes exist
442 self.remotes.clearSelection()
443 found = all(self.select_remote_by_name(x) for x in remotes)
444 return found
446 def select_local_branch(self, idx):
447 """Selects a local branch by index in the list view"""
448 item = self.local_branches.item(idx)
449 if item:
450 item.setSelected(True)
451 self.local_branches.setCurrentItem(item)
452 self.local_branch.setText(item.text())
453 result = True
454 else:
455 result = False
456 return result
458 def display_remotes(self, widget):
459 """Display the available remotes in a listwidget"""
460 displayed = []
461 for remote_name in self.model.remotes:
462 url = self.model.remote_url(remote_name, self.action)
463 display = '%s\t(%s)' % (remote_name, N_('URL: %s') % url)
464 displayed.append(display)
465 qtutils.set_items(widget, displayed)
467 def update_remotes(self):
468 """Update the remote name when a remote from the list is selected"""
469 widget = self.remotes
470 remotes = self.model.remotes
471 selection = qtutils.selected_item(widget, remotes)
472 if not selection:
473 self.selected_remotes = []
474 return
475 self.set_remote_name(selection)
476 self.selected_remotes = qtutils.selected_items(self.remotes, self.model.remotes)
477 self.set_remote_to(selection, self.selected_remotes)
479 def set_remote_to(self, _remote, selected_remotes):
480 context = self.context
481 all_branches = gitcmds.branch_list(context, remote=True)
482 branches = []
483 patterns = []
484 for remote_name in selected_remotes:
485 patterns.append(remote_name + '/*')
487 for branch in all_branches:
488 for pat in patterns:
489 if fnmatch.fnmatch(branch, pat):
490 branches.append(branch)
491 break
492 if branches:
493 self.set_remote_branches(branches)
494 else:
495 self.set_remote_branches(all_branches)
496 self.set_remote_branch('')
498 def remote_name_edited(self):
499 """Update the current remote when the remote name is typed manually"""
500 remote = self.remote_name.text()
501 self.set_remote_to(remote, [remote])
503 def update_local_branches(self):
504 """Update the local/remote branch names when a branch is selected"""
505 branches = self.model.local_branches
506 widget = self.local_branches
507 selection = qtutils.selected_item(widget, branches)
508 if not selection:
509 return
510 self.set_local_branch(selection)
511 self.set_remote_branch(selection)
513 def update_remote_branches(self):
514 """Update the remote branch name when a branch is selected"""
515 widget = self.remote_branches
516 branches = self.filtered_remote_branches
517 selection = qtutils.selected_item(widget, branches)
518 if not selection:
519 return
520 branch = utils.strip_one(selection)
521 if branch == 'HEAD':
522 return
523 self.set_remote_branch(branch)
525 def common_args(self):
526 """Returns git arguments common to fetch/push/pull"""
527 remote_name = self.remote_name.text()
528 local_branch = self.local_branch.text()
529 remote_branch = self.remote_branch.text()
531 ff_only = get(self.ff_only_checkbox)
532 force = get(self.force_checkbox)
533 no_ff = get(self.no_ff_checkbox)
534 rebase = get(self.rebase_checkbox)
535 set_upstream = get(self.upstream_checkbox)
536 tags = get(self.tags_checkbox)
537 prune = get(self.prune_checkbox)
539 return (
540 remote_name,
542 'ff_only': ff_only,
543 'force': force,
544 'local_branch': local_branch,
545 'no_ff': no_ff,
546 'rebase': rebase,
547 'remote_branch': remote_branch,
548 'set_upstream': set_upstream,
549 'tags': tags,
550 'prune': prune,
554 # Actions
556 def push_to_all(self, _remote, *args, **kwargs):
557 """Push to all selected remotes"""
558 selected_remotes = self.selected_remotes
559 all_results = None
560 for remote in selected_remotes:
561 result = self.model.push(remote, *args, **kwargs)
562 all_results = combine(result, all_results)
563 return all_results
565 def action_callback(self):
566 """Perform the actual fetch/push/pull operation"""
567 action = self.action
568 if action == FETCH:
569 model_action = self.model.fetch
570 elif action == PUSH:
571 model_action = self.push_to_all
572 else: # if action == PULL:
573 model_action = self.model.pull
575 remote_name = self.remote_name.text()
576 if not remote_name:
577 errmsg = N_('No repository selected.')
578 Interaction.log(errmsg)
579 return
580 remote, kwargs = self.common_args()
581 self.selected_remotes = qtutils.selected_items(self.remotes, self.model.remotes)
583 # Check if we're about to create a new branch and warn.
584 remote_branch = self.remote_branch.text()
585 local_branch = self.local_branch.text()
587 if action == PUSH and not remote_branch:
588 branch = local_branch
589 candidate = '%s/%s' % (remote, branch)
590 prompt = get(self.prompt_checkbox)
592 if prompt and candidate not in self.model.remote_branches:
593 title = N_('Push')
594 args = dict(branch=branch, remote=remote)
595 msg = (
597 'Branch "%(branch)s" does not exist in "%(remote)s".\n'
598 'A new remote branch will be published.'
600 % args
602 info_txt = N_('Create a new remote branch?')
603 ok_text = N_('Create Remote Branch')
604 if not Interaction.confirm(
605 title, msg, info_txt, ok_text, icon=icons.cola()
607 return
609 if get(self.force_checkbox):
610 if action == FETCH:
611 title = N_('Force Fetch?')
612 msg = N_('Non-fast-forward fetch overwrites local history!')
613 info_txt = N_('Force fetching from %s?') % remote
614 ok_text = N_('Force Fetch')
615 elif action == PUSH:
616 title = N_('Force Push?')
617 msg = N_(
618 'Non-fast-forward push overwrites published '
619 'history!\n(Did you pull first?)'
621 info_txt = N_('Force push to %s?') % remote
622 ok_text = N_('Force Push')
623 else: # pull: shouldn't happen since the controls are hidden
624 return
625 if not Interaction.confirm(
626 title, msg, info_txt, ok_text, default=False, icon=icons.discard()
628 return
630 # Disable the GUI by default
631 self.buttons.setEnabled(False)
633 # Use a thread to update in the background
634 task = ActionTask(model_action, remote, kwargs)
635 self.runtask.start(task, progress=self.progress, finish=self.action_completed)
637 def action_completed(self, task):
638 """Grab the results of the action and finish up"""
639 status, out, err = task.result
640 self.buttons.setEnabled(True)
642 command = 'git %s' % self.action.lower()
643 message = Interaction.format_command_status(command, status)
644 details = Interaction.format_out_err(out, err)
646 log_message = message
647 if details:
648 log_message += '\n\n' + details
649 Interaction.log(log_message)
651 if status == 0:
652 self.accept()
653 return
655 if self.action == PUSH:
656 message += '\n\n'
657 message += N_('Have you rebased/pulled lately?')
659 Interaction.critical(self.windowTitle(), message=message, details=details)
662 # Use distinct classes so that each saves its own set of preferences
663 class Fetch(RemoteActionDialog):
664 """Fetch from remote repositories"""
666 def __init__(self, context, parent=None):
667 super(Fetch, self).__init__(
668 context, FETCH, N_('Fetch'), parent=parent, icon=icons.repo()
671 def export_state(self):
672 """Export persistent settings"""
673 state = RemoteActionDialog.export_state(self)
674 state['tags'] = get(self.tags_checkbox)
675 state['prune'] = get(self.prune_checkbox)
676 return state
678 def apply_state(self, state):
679 """Apply persistent settings"""
680 result = RemoteActionDialog.apply_state(self, state)
681 tags = bool(state.get('tags', False))
682 self.tags_checkbox.setChecked(tags)
683 prune = bool(state.get('prune', False))
684 self.prune_checkbox.setChecked(prune)
685 return result
688 class Push(RemoteActionDialog):
689 """Push to remote repositories"""
691 def __init__(self, context, parent=None):
692 super(Push, self).__init__(
693 context, PUSH, N_('Push'), parent=parent, icon=icons.push()
696 def export_state(self):
697 """Export persistent settings"""
698 state = RemoteActionDialog.export_state(self)
699 state['prompt'] = get(self.prompt_checkbox)
700 state['tags'] = get(self.tags_checkbox)
701 return state
703 def apply_state(self, state):
704 """Apply persistent settings"""
705 result = RemoteActionDialog.apply_state(self, state)
707 # Restore the "prompt on creation" checkbox
708 prompt = bool(state.get('prompt', True))
709 self.prompt_checkbox.setChecked(prompt)
711 # Restore the "tags" checkbox
712 tags = bool(state.get('tags', False))
713 self.tags_checkbox.setChecked(tags)
715 return result
718 class Pull(RemoteActionDialog):
719 """Pull from remote repositories"""
721 def __init__(self, context, parent=None):
722 super(Pull, self).__init__(
723 context, PULL, N_('Pull'), parent=parent, icon=icons.pull()
726 def apply_state(self, state):
727 """Apply persistent settings"""
728 result = RemoteActionDialog.apply_state(self, state)
729 # Rebase has the highest priority
730 rebase = bool(state.get('rebase', False))
731 ff_only = not rebase and bool(state.get('ff_only', False))
732 no_ff = not rebase and not ff_only and bool(state.get('no_ff', False))
734 self.rebase_checkbox.setChecked(rebase)
735 self.no_ff_checkbox.setChecked(no_ff)
737 # Allow users coming from older versions that have rebase=False to
738 # pickup the new ff_only=True default by only setting ff_only False
739 # when it either exists in the config or when rebase=True.
740 if 'ff_only' in state or rebase:
741 self.ff_only_checkbox.setChecked(ff_only)
743 return result
745 def export_state(self):
746 """Export persistent settings"""
747 state = RemoteActionDialog.export_state(self)
749 state['ff_only'] = get(self.ff_only_checkbox)
750 state['no_ff'] = get(self.no_ff_checkbox)
751 state['rebase'] = get(self.rebase_checkbox)
753 return state