fsmonitor: silence pylint warnings
[git-cola.git] / cola / widgets / remote.py
blob7818248615e4a914bb8122e69015c06ac206500e
1 """Widgets for Fetch, Push, and Pull"""
2 import fnmatch
4 from qtpy import QtGui
5 from qtpy import QtWidgets
6 from qtpy.QtCore import Qt
8 from ..i18n import N_
9 from ..interaction import Interaction
10 from ..qtutils import connect_button
11 from ..qtutils import get
12 from .. import gitcmds
13 from .. import icons
14 from .. import qtutils
15 from .. import utils
16 from . import defs
17 from . import standard
18 from . import remotemessage
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_('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')
184 tooltip = N_(
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')
193 tooltip = N_(
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
199 text = N_('Force')
200 tooltip = N_(
201 'Allow non-fast-forward updates. Using "force" can '
202 'cause the remote repository to lose commits; '
203 'use it with care'
205 self.force_checkbox = qtutils.checkbox(
206 checked=False, text=text, tooltip=tooltip
209 self.tags_checkbox = qtutils.checkbox(text=N_('Include tags '))
211 tooltip = N_(
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(
237 defs.small_margin,
238 defs.spacing,
239 self.remote_branch_label,
240 self.remote_branch,
243 self.options_layout = qtutils.hbox(
244 defs.no_margin,
245 defs.button_spacing,
246 self.force_checkbox,
247 self.ff_only_checkbox,
248 self.no_ff_checkbox,
249 self.tags_checkbox,
250 self.prune_checkbox,
251 self.rebase_checkbox,
252 self.upstream_checkbox,
253 self.prompt_checkbox,
254 self.remote_messages_checkbox,
255 qtutils.STRETCH,
256 self.close_button,
257 self.action_button,
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(
269 defs.no_margin,
270 defs.spacing,
271 self.remote_branch_layout,
272 self.remote_branches,
275 if action == PUSH:
276 widgets = (
277 self.remote_input_layout,
278 self.local_branch_input_layout,
279 self.remote_branch_input_layout,
281 else: # fetch and pull
282 widgets = (
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)
301 else:
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)
334 qtutils.add_action(
335 self, N_('Close'), self.close, QtGui.QKeySequence.Close, 'Esc'
337 if action != FETCH:
338 self.prune_checkbox.hide()
340 if action != PUSH:
341 # Push-only options
342 self.upstream_checkbox.hide()
343 self.prompt_checkbox.hide()
344 self.remote_messages_checkbox.hide()
346 if action == PULL:
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()
353 else:
354 # Pull-only options
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"
368 action = self.action
369 if action in (FETCH, PULL):
370 self.local_branch.setText('')
371 self.remote_branch.setText('')
372 return
374 # Select the current branch by default for push
375 if action == PUSH:
376 branch = self.model.currentbranch
377 try:
378 idx = self.model.local_branches.index(branch)
379 except ValueError:
380 return
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)
392 if 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)
398 if 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)
415 if item:
416 item.setSelected(True)
417 self.remotes.setCurrentItem(item)
418 self.set_remote_name(item.text())
419 result = True
420 else:
421 result = False
422 return result
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)
430 else:
431 result = False
432 return result
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.
443 found = False
444 for remote in remotes:
445 if remote in self.model.remotes:
446 found = True
447 break
448 if found:
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)
452 return found
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)
457 if item:
458 item.setSelected(True)
459 self.local_branches.setCurrentItem(item)
460 self.local_branch.setText(item.text())
461 result = True
462 else:
463 result = False
464 return result
466 def display_remotes(self, widget):
467 """Display the available remotes in a listwidget"""
468 displayed = []
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)
480 if not selection:
481 self.selected_remotes = []
482 return
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)
490 branches = []
491 patterns = []
492 for remote_name in selected_remotes:
493 patterns.append(remote_name + '/*')
495 for branch in all_branches:
496 for pat in patterns:
497 if fnmatch.fnmatch(branch, pat):
498 branches.append(branch)
499 break
500 if branches:
501 self.set_remote_branches(branches)
502 else:
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)
516 if not selection:
517 return
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)
526 if not selection:
527 return
528 branch = utils.strip_one(selection)
529 if branch == 'HEAD':
530 return
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)
547 return (
548 remote_name,
550 'ff_only': ff_only,
551 'force': force,
552 'local_branch': local_branch,
553 'no_ff': no_ff,
554 'rebase': rebase,
555 'remote_branch': remote_branch,
556 'set_upstream': set_upstream,
557 'tags': tags,
558 'prune': prune,
562 # Actions
564 def push_to_all(self, _remote, *args, **kwargs):
565 """Push to all selected remotes"""
566 selected_remotes = self.selected_remotes
567 all_results = None
568 for remote in selected_remotes:
569 result = self.model.push(remote, *args, **kwargs)
570 all_results = combine(result, all_results)
571 return all_results
573 def action_callback(self):
574 """Perform the actual fetch/push/pull operation"""
575 action = self.action
576 remote_messages = False
577 if action == FETCH:
578 model_action = self.model.fetch
579 elif action == PUSH:
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()
586 if not remote_name:
587 errmsg = N_('No repository selected.')
588 Interaction.log(errmsg)
589 return
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:
603 title = N_('Push')
604 args = {
605 'branch': branch,
606 'remote': remote,
608 msg = (
610 'Branch "%(branch)s" does not exist in "%(remote)s".\n'
611 'A new remote branch will be published.'
613 % args
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()
620 return
622 if get(self.force_checkbox):
623 if action == FETCH:
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')
628 elif action == PUSH:
629 title = N_('Force Push?')
630 msg = N_(
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
637 return
638 if not Interaction.confirm(
639 title, msg, info_txt, ok_text, default=False, icon=icons.discard()
641 return
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)
648 if remote_messages:
649 result = remotemessage.with_context(self.context)
650 else:
651 result = None
652 self.runtask.start(
653 task,
654 progress=self.progress,
655 finish=self.action_completed,
656 result=result,
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)):
663 return
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
671 if details:
672 log_message += '\n\n' + details
673 Interaction.log(log_message)
675 if status == 0:
676 self.accept()
677 return
679 if self.action == PUSH:
680 message += '\n\n'
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)
698 return state
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)
707 return result
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)
722 return state
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)
737 return result
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)
763 return result
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)
773 return state