doc: add Thomas to the credits
[git-cola.git] / cola / widgets / remote.py
blob560790a860c0e427e2e4bcc6ece1456ca95cd978
1 """Widgets for Fetch, Push, and Pull"""
2 from __future__ import division, absolute_import, 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 ..models import main
12 from ..qtutils import connect_button
13 from ..qtutils import get
14 from .. import gitcmds
15 from .. import icons
16 from .. import qtutils
17 from .. import utils
18 from .standard import ProgressDialog
19 from . import defs
20 from . import standard
23 FETCH = 'FETCH'
24 PUSH = 'PUSH'
25 PULL = 'PULL'
28 def fetch(context):
29 """Fetch from remote repositories"""
30 return run(context, Fetch)
33 def push(context):
34 """Push to remote repositories"""
35 return run(context, Push)
38 def pull(context):
39 """Pull from remote repositories"""
40 return run(context, Pull)
43 def run(context, RemoteDialog):
44 """Launches fetch/push/pull dialogs."""
45 # Copy global stuff over to speedup startup
46 model = main.MainModel(context)
47 global_model = context.model
48 model.currentbranch = global_model.currentbranch
49 model.local_branches = global_model.local_branches
50 model.remote_branches = global_model.remote_branches
51 model.tags = global_model.tags
52 model.remotes = global_model.remotes
53 parent = qtutils.active_window()
54 view = RemoteDialog(context, model, parent=parent)
55 view.show()
56 return view
59 def combine(result, prev):
60 """Combine multiple (status, out, err) tuples into a combined tuple
62 The current state is passed in via `prev`.
63 The status code is a max() over all the subprocess status codes.
64 Individual (out, err) strings are sequentially concatenated together.
66 """
67 if isinstance(prev, (tuple, list)):
68 if len(prev) != 3:
69 raise AssertionError('combine() with length %d' % len(prev))
70 combined = (max(prev[0], result[0]),
71 combine(prev[1], result[1]),
72 combine(prev[2], result[2]))
73 elif prev and result:
74 combined = prev + '\n\n' + result
75 elif prev:
76 combined = prev
77 else:
78 combined = result
80 return combined
83 def uncheck(value, *checkboxes):
84 """Uncheck the specified checkboxes if value is True"""
85 if value:
86 for checkbox in checkboxes:
87 checkbox.setChecked(False)
90 def strip_remotes(remote_branches):
91 """Strip the <remote>/ prefixes from branches
93 e.g. "origin/master" becomes "master".
95 """
96 branches = [utils.strip_one(branch) for branch in remote_branches]
97 return [branch for branch in branches if branch != 'HEAD']
100 class ActionTask(qtutils.Task):
101 """Run actions asynchronously"""
103 def __init__(self, parent, model_action, remote, kwargs):
104 qtutils.Task.__init__(self, parent)
105 self.model_action = model_action
106 self.remote = remote
107 self.kwargs = kwargs
109 def task(self):
110 """Runs the model action and captures the result"""
111 return self.model_action(self.remote, **self.kwargs)
114 class RemoteActionDialog(standard.Dialog):
115 """Interface for performing remote operations"""
117 def __init__(self, context, model, action, title, parent=None, icon=None):
118 """Customize the dialog based on the remote action"""
119 standard.Dialog.__init__(self, parent=parent)
121 self.context = context
122 self.model = model
123 self.action = action
124 self.filtered_remote_branches = []
125 self.selected_remotes = []
127 self.setWindowTitle(title)
128 if parent is not None:
129 self.setWindowModality(Qt.WindowModal)
131 self.runtask = qtutils.RunTask(parent=self)
132 self.progress = ProgressDialog(title, N_('Updating'), self)
134 self.local_label = QtWidgets.QLabel()
135 self.local_label.setText(N_('Local Branch'))
137 self.local_branch = QtWidgets.QLineEdit()
138 qtutils.add_completer(self.local_branch, self.model.local_branches)
140 self.local_branches = QtWidgets.QListWidget()
141 self.local_branches.addItems(self.model.local_branches)
143 self.remote_label = QtWidgets.QLabel()
144 self.remote_label.setText(N_('Remote'))
146 self.remote_name = QtWidgets.QLineEdit()
147 qtutils.add_completer(self.remote_name, self.model.remotes)
148 self.remote_name.editingFinished.connect(self.remote_name_edited)
149 self.remote_name.textEdited.connect(lambda x: self.remote_name_edited())
151 self.remotes = QtWidgets.QListWidget()
152 if action == PUSH:
153 mode = QtWidgets.QAbstractItemView.ExtendedSelection
154 self.remotes.setSelectionMode(mode)
155 self.remotes.addItems(self.model.remotes)
157 self.remote_branch_label = QtWidgets.QLabel()
158 self.remote_branch_label.setText(N_('Remote Branch'))
160 self.remote_branch = QtWidgets.QLineEdit()
161 remote_branches = strip_remotes(self.model.remote_branches)
162 qtutils.add_completer(self.remote_branch, remote_branches)
164 self.remote_branches = QtWidgets.QListWidget()
165 self.remote_branches.addItems(self.model.remote_branches)
167 text = N_('Prompt on creation')
168 tooltip = N_('Prompt when pushing creates new remote branches')
169 self.prompt_checkbox = qtutils.checkbox(checked=True, text=text,
170 tooltip=tooltip)
172 text = N_('Fast-forward only')
173 tooltip = N_('Refuse to merge unless the current HEAD is already up-'
174 'to-date or the merge can be resolved as a fast-forward')
175 self.ff_only_checkbox = qtutils.checkbox(checked=True,
176 text=text, tooltip=tooltip)
178 text = N_('No fast-forward')
179 tooltip = N_('Create a merge commit even when the merge resolves as a '
180 'fast-forward')
181 self.no_ff_checkbox = qtutils.checkbox(checked=False,
182 text=text, tooltip=tooltip)
183 text = N_('Force')
184 tooltip = N_('Allow non-fast-forward updates. Using "force" can '
185 'cause the remote repository to lose commits; '
186 'use it with care')
187 self.force_checkbox = qtutils.checkbox(checked=False, text=text,
188 tooltip=tooltip)
190 self.tags_checkbox = qtutils.checkbox(text=N_('Include tags '))
192 tooltip = N_('Remove remote-tracking branches that no longer '
193 'exist on the remote')
194 self.prune_checkbox = qtutils.checkbox(text=N_('Prune '),
195 tooltip=tooltip)
197 tooltip = N_('Rebase the current branch instead of merging')
198 self.rebase_checkbox = qtutils.checkbox(text=N_('Rebase'),
199 tooltip=tooltip)
201 text = N_('Set upstream')
202 tooltip = N_('Configure the remote branch as the the new upstream')
203 self.upstream_checkbox = qtutils.checkbox(text=text, tooltip=tooltip)
205 self.action_button = qtutils.ok_button(title, icon=icon)
206 self.close_button = qtutils.close_button()
208 self.buttons = utils.Group(self.action_button, self.close_button)
210 self.local_branch_layout = qtutils.hbox(
211 defs.small_margin,
212 defs.spacing,
213 self.local_label,
214 self.local_branch)
216 self.remote_layout = qtutils.hbox(
217 defs.small_margin,
218 defs.spacing,
219 self.remote_label,
220 self.remote_name)
222 self.remote_branch_layout = qtutils.hbox(
223 defs.small_margin,
224 defs.spacing,
225 self.remote_branch_label,
226 self.remote_branch)
228 self.options_layout = qtutils.hbox(
229 defs.no_margin, defs.button_spacing,
230 self.close_button,
231 qtutils.STRETCH,
232 self.force_checkbox,
233 self.ff_only_checkbox,
234 self.no_ff_checkbox,
235 self.tags_checkbox,
236 self.prune_checkbox,
237 self.rebase_checkbox,
238 self.upstream_checkbox,
239 self.prompt_checkbox,
240 self.action_button)
242 self.remote_input_layout = qtutils.vbox(
243 defs.no_margin, defs.spacing,
244 self.remote_layout, self.remotes)
246 self.local_branch_input_layout = qtutils.vbox(
247 defs.no_margin, defs.spacing,
248 self.local_branch_layout, self.local_branches)
250 self.remote_branch_input_layout = qtutils.vbox(
251 defs.no_margin, defs.spacing,
252 self.remote_branch_layout, self.remote_branches)
254 if action == PUSH:
255 widgets = (
256 self.remote_input_layout,
257 self.local_branch_input_layout,
258 self.remote_branch_input_layout)
259 else: # fetch and pull
260 widgets = (
261 self.remote_input_layout,
262 self.remote_branch_input_layout,
263 self.local_branch_input_layout)
264 self.top_layout = qtutils.hbox(defs.no_margin, defs.spacing, *widgets)
266 self.main_layout = qtutils.vbox(
267 defs.no_margin, defs.spacing,
268 self.top_layout, self.options_layout)
269 self.setLayout(self.main_layout)
271 default_remote = gitcmds.upstream_remote(context) or 'origin'
273 remotes = self.model.remotes
274 if default_remote in remotes:
275 idx = remotes.index(default_remote)
276 if self.select_remote(idx):
277 self.set_remote_name(default_remote)
278 else:
279 if self.select_first_remote():
280 self.set_remote_name(remotes[0])
282 # Trim the remote list to just the default remote
283 self.update_remotes()
284 self.set_field_defaults()
286 # Setup signals and slots
287 self.remotes.itemSelectionChanged.connect(self.update_remotes)
289 local = self.local_branches
290 local.itemSelectionChanged.connect(self.update_local_branches)
292 remote = self.remote_branches
293 remote.itemSelectionChanged.connect(self.update_remote_branches)
295 self.no_ff_checkbox.toggled.connect(
296 lambda x: uncheck(x, self.ff_only_checkbox, self.rebase_checkbox))
298 self.ff_only_checkbox.toggled.connect(
299 lambda x: uncheck(x, self.no_ff_checkbox, self.rebase_checkbox))
301 self.rebase_checkbox.toggled.connect(
302 lambda x: uncheck(x, self.no_ff_checkbox, self.ff_only_checkbox))
304 connect_button(self.action_button, self.action_callback)
305 connect_button(self.close_button, self.close)
307 qtutils.add_action(self, N_('Close'), self.close,
308 QtGui.QKeySequence.Close, 'Esc')
309 if action != FETCH:
310 self.prune_checkbox.hide()
312 if action != PUSH:
313 # Push-only options
314 self.upstream_checkbox.hide()
315 self.prompt_checkbox.hide()
317 if action == PULL:
318 # Fetch and Push-only options
319 self.force_checkbox.hide()
320 self.tags_checkbox.hide()
321 self.local_label.hide()
322 self.local_branch.hide()
323 self.local_branches.hide()
324 else:
325 # Pull-only options
326 self.rebase_checkbox.hide()
327 self.no_ff_checkbox.hide()
328 self.ff_only_checkbox.hide()
330 self.init_size(parent=parent)
332 def set_rebase(self, value):
333 """Check the rebase checkbox"""
334 self.rebase_checkbox.setChecked(value)
336 def set_field_defaults(self):
337 """Set sensible initial defaults"""
338 # Default to "git fetch origin master"
339 action = self.action
340 if action == FETCH or action == PULL:
341 self.local_branch.setText('')
342 self.remote_branch.setText('')
343 return
345 # Select the current branch by default for push
346 if action == PUSH:
347 branch = self.model.currentbranch
348 try:
349 idx = self.model.local_branches.index(branch)
350 except ValueError:
351 return
352 if self.select_local_branch(idx):
353 self.set_local_branch(branch)
354 self.set_remote_branch('')
356 def set_remote_name(self, remote_name):
357 """Set the remote name"""
358 self.remote_name.setText(remote_name)
360 def set_local_branch(self, branch):
361 """Set the local branch name"""
362 self.local_branch.setText(branch)
363 if branch:
364 self.local_branch.selectAll()
366 def set_remote_branch(self, branch):
367 """Set the remote branch name"""
368 self.remote_branch.setText(branch)
369 if branch:
370 self.remote_branch.selectAll()
372 def set_remote_branches(self, branches):
373 """Set the list of remote branches"""
374 self.remote_branches.clear()
375 self.remote_branches.addItems(branches)
376 self.filtered_remote_branches = branches
377 qtutils.add_completer(self.remote_branch, strip_remotes(branches))
379 def select_first_remote(self):
380 """Select the first remote in the list view"""
381 return self.select_remote(0)
383 def select_remote(self, idx):
384 """Select a remote by index"""
385 item = self.remotes.item(idx)
386 if item:
387 item.setSelected(True)
388 self.remotes.setCurrentItem(item)
389 self.set_remote_name(item.text())
390 result = True
391 else:
392 result = False
393 return result
395 def select_local_branch(self, idx):
396 """Selects a local branch by index in the list view"""
397 item = self.local_branches.item(idx)
398 if item:
399 item.setSelected(True)
400 self.local_branches.setCurrentItem(item)
401 self.local_branch.setText(item.text())
402 result = True
403 else:
404 result = False
405 return result
407 def display_remotes(self, widget):
408 """Display the available remotes in a listwidget"""
409 displayed = []
410 for remote_name in self.model.remotes:
411 url = self.model.remote_url(remote_name, self.action)
412 display = ('%s\t(%s)'
413 % (remote_name, N_('URL: %s') % url))
414 displayed.append(display)
415 qtutils.set_items(widget, displayed)
417 def update_remotes(self):
418 """Update the remote name when a remote from the list is selected"""
419 widget = self.remotes
420 remotes = self.model.remotes
421 selection = qtutils.selected_item(widget, remotes)
422 if not selection:
423 self.selected_remotes = []
424 return
425 self.set_remote_name(selection)
426 self.selected_remotes = qtutils.selected_items(self.remotes,
427 self.model.remotes)
428 self.set_remote_to(selection, self.selected_remotes)
430 def set_remote_to(self, _remote, selected_remotes):
431 context = self.context
432 all_branches = gitcmds.branch_list(context, remote=True)
433 branches = []
434 patterns = []
435 for remote_name in selected_remotes:
436 patterns.append(remote_name + '/*')
438 for branch in all_branches:
439 for pat in patterns:
440 if fnmatch.fnmatch(branch, pat):
441 branches.append(branch)
442 break
443 if branches:
444 self.set_remote_branches(branches)
445 else:
446 self.set_remote_branches(all_branches)
447 self.set_remote_branch('')
449 def remote_name_edited(self):
450 """Update the current remote when the remote name is typed manually"""
451 remote = self.remote_name.text()
452 self.set_remote_to(remote, [remote])
454 def update_local_branches(self):
455 """Update the local/remote branch names when a branch is selected"""
456 branches = self.model.local_branches
457 widget = self.local_branches
458 selection = qtutils.selected_item(widget, branches)
459 if not selection:
460 return
461 self.set_local_branch(selection)
462 self.set_remote_branch(selection)
464 def update_remote_branches(self):
465 """Update the remote branch name when a branch is selected"""
466 widget = self.remote_branches
467 branches = self.filtered_remote_branches
468 selection = qtutils.selected_item(widget, branches)
469 if not selection:
470 return
471 branch = utils.strip_one(selection)
472 if branch == 'HEAD':
473 return
474 self.set_remote_branch(branch)
476 def common_args(self):
477 """Returns git arguments common to fetch/push/pull"""
478 remote_name = self.remote_name.text()
479 local_branch = self.local_branch.text()
480 remote_branch = self.remote_branch.text()
482 ff_only = get(self.ff_only_checkbox)
483 force = get(self.force_checkbox)
484 no_ff = get(self.no_ff_checkbox)
485 rebase = get(self.rebase_checkbox)
486 set_upstream = get(self.upstream_checkbox)
487 tags = get(self.tags_checkbox)
488 prune = get(self.prune_checkbox)
490 return (remote_name,
492 'ff_only': ff_only,
493 'force': force,
494 'local_branch': local_branch,
495 'no_ff': no_ff,
496 'rebase': rebase,
497 'remote_branch': remote_branch,
498 'set_upstream': set_upstream,
499 'tags': tags,
500 'prune': prune,
503 # Actions
505 def push_to_all(self, _remote, *args, **kwargs):
506 """Push to all selected remotes"""
507 selected_remotes = self.selected_remotes
508 all_results = None
509 for remote in selected_remotes:
510 result = self.model.push(remote, *args, **kwargs)
511 all_results = combine(result, all_results)
512 return all_results
514 def action_callback(self):
515 """Perform the actual fetch/push/pull operation"""
516 action = self.action
517 if action == FETCH:
518 model_action = self.model.fetch
519 elif action == PUSH:
520 model_action = self.push_to_all
521 else: # if action == PULL:
522 model_action = self.model.pull
524 remote_name = self.remote_name.text()
525 if not remote_name:
526 errmsg = N_('No repository selected.')
527 Interaction.log(errmsg)
528 return
529 remote, kwargs = self.common_args()
530 self.selected_remotes = qtutils.selected_items(self.remotes,
531 self.model.remotes)
533 # Check if we're about to create a new branch and warn.
534 remote_branch = self.remote_branch.text()
535 local_branch = self.local_branch.text()
537 if action == PUSH and not remote_branch:
538 branch = local_branch
539 candidate = '%s/%s' % (remote, branch)
540 prompt = get(self.prompt_checkbox)
542 if prompt and candidate not in self.model.remote_branches:
543 title = N_('Push')
544 args = dict(branch=branch, remote=remote)
545 msg = N_('Branch "%(branch)s" does not exist in "%(remote)s".\n'
546 'A new remote branch will be published.') % args
547 info_txt = N_('Create a new remote branch?')
548 ok_text = N_('Create Remote Branch')
549 if not Interaction.confirm(title, msg, info_txt, ok_text,
550 icon=icons.cola()):
551 return
553 if get(self.force_checkbox):
554 if action == FETCH:
555 title = N_('Force Fetch?')
556 msg = N_('Non-fast-forward fetch overwrites local history!')
557 info_txt = N_('Force fetching from %s?') % remote
558 ok_text = N_('Force Fetch')
559 elif action == PUSH:
560 title = N_('Force Push?')
561 msg = N_('Non-fast-forward push overwrites published '
562 'history!\n(Did you pull first?)')
563 info_txt = N_('Force push to %s?') % remote
564 ok_text = N_('Force Push')
565 else: # pull: shouldn't happen since the controls are hidden
566 return
567 if not Interaction.confirm(title, msg, info_txt, ok_text,
568 default=False, icon=icons.discard()):
569 return
571 # Disable the GUI by default
572 self.buttons.setEnabled(False)
574 # Use a thread to update in the background
575 task = ActionTask(self, model_action, remote, kwargs)
576 self.runtask.start(task,
577 progress=self.progress,
578 finish=self.action_completed)
580 def action_completed(self, task):
581 """Grab the results of the action and finish up"""
582 status, out, err = task.result
583 self.buttons.setEnabled(True)
585 command = 'git %s' % self.action.lower()
586 message = Interaction.format_command_status(command, status)
587 details = Interaction.format_out_err(out, err)
589 log_message = message
590 if details:
591 log_message += '\n\n' + details
592 Interaction.log(log_message)
594 if status == 0:
595 self.accept()
596 return
598 if self.action == PUSH:
599 message += '\n\n'
600 message += N_('Have you rebased/pulled lately?')
602 Interaction.critical(self.windowTitle(),
603 message=message, details=details)
606 # Use distinct classes so that each saves its own set of preferences
607 class Fetch(RemoteActionDialog):
608 """Fetch from remote repositories"""
610 def __init__(self, context, model, parent=None):
611 super(Fetch, self).__init__(
612 context, model, FETCH, N_('Fetch'),
613 parent=parent, icon=icons.repo())
615 def export_state(self):
616 """Export persistent settings"""
617 state = RemoteActionDialog.export_state(self)
618 state['tags'] = get(self.tags_checkbox)
619 state['prune'] = get(self.prune_checkbox)
620 return state
622 def apply_state(self, state):
623 """Apply persistent settings"""
624 result = RemoteActionDialog.apply_state(self, state)
625 tags = bool(state.get('tags', False))
626 self.tags_checkbox.setChecked(tags)
627 prune = bool(state.get('prune', False))
628 self.prune_checkbox.setChecked(prune)
629 return result
632 class Push(RemoteActionDialog):
633 """Push to remote repositories"""
635 def __init__(self, context, model, parent=None):
636 super(Push, self).__init__(
637 context, model, PUSH, N_('Push'), parent=parent, icon=icons.push())
639 def export_state(self):
640 """Export persistent settings"""
641 state = RemoteActionDialog.export_state(self)
642 state['tags'] = get(self.tags_checkbox)
643 state['prompt'] = get(self.prompt_checkbox)
644 return state
646 def apply_state(self, state):
647 """Apply persistent settings"""
648 result = RemoteActionDialog.apply_state(self, state)
650 tags = bool(state.get('tags', False))
651 self.tags_checkbox.setChecked(tags)
653 prompt = bool(state.get('prompt', True))
654 self.prompt_checkbox.setChecked(prompt)
655 return result
658 class Pull(RemoteActionDialog):
659 """Pull from remote repositories"""
661 def __init__(self, context, model, parent=None):
662 super(Pull, self).__init__(
663 context, model, PULL, N_('Pull'), parent=parent, icon=icons.pull())
665 def apply_state(self, state):
666 """Apply persistent settings"""
667 result = RemoteActionDialog.apply_state(self, state)
668 # Rebase has the highest priority
669 rebase = bool(state.get('rebase', False))
670 ff_only = not rebase and bool(state.get('ff_only', False))
671 no_ff = not rebase and not ff_only and bool(state.get('no_ff', False))
673 self.rebase_checkbox.setChecked(rebase)
674 self.no_ff_checkbox.setChecked(no_ff)
676 # Allow users coming from older versions that have rebase=False to
677 # pickup the new ff_only=True default by only setting ff_only False
678 # when it either exists in the config or when rebase=True.
679 if 'ff_only' in state or rebase:
680 self.ff_only_checkbox.setChecked(ff_only)
682 return result
684 def export_state(self):
685 """Export persistent settings"""
686 state = RemoteActionDialog.export_state(self)
688 state['ff_only'] = get(self.ff_only_checkbox)
689 state['no_ff'] = get(self.no_ff_checkbox)
690 state['rebase'] = get(self.rebase_checkbox)
692 return state