core: make getcwd() fail-safe
[git-cola.git] / cola / widgets / remote.py
blob044467160a619bc874a3511985acacb18763d5a8
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.margin, defs.spacing,
268 self.top_layout, self.options_layout)
269 self.setLayout(self.main_layout)
271 # Select the upstream remote if configured, or "origin"
272 default_remote = gitcmds.upstream_remote(context) or 'origin'
273 if self.select_remote_by_name(default_remote):
274 self.set_remote_name(default_remote)
275 elif self.select_first_remote():
276 self.set_remote_name(model.remotes[0])
278 # Trim the remote list to just the default remote
279 self.update_remotes()
280 self.set_field_defaults()
282 # Setup signals and slots
283 self.remotes.itemSelectionChanged.connect(self.update_remotes)
285 local = self.local_branches
286 local.itemSelectionChanged.connect(self.update_local_branches)
288 remote = self.remote_branches
289 remote.itemSelectionChanged.connect(self.update_remote_branches)
291 self.no_ff_checkbox.toggled.connect(
292 lambda x: uncheck(x, self.ff_only_checkbox, self.rebase_checkbox))
294 self.ff_only_checkbox.toggled.connect(
295 lambda x: uncheck(x, self.no_ff_checkbox, self.rebase_checkbox))
297 self.rebase_checkbox.toggled.connect(
298 lambda x: uncheck(x, self.no_ff_checkbox, self.ff_only_checkbox))
300 connect_button(self.action_button, self.action_callback)
301 connect_button(self.close_button, self.close)
303 qtutils.add_action(self, N_('Close'), self.close,
304 QtGui.QKeySequence.Close, 'Esc')
305 if action != FETCH:
306 self.prune_checkbox.hide()
308 if action != PUSH:
309 # Push-only options
310 self.upstream_checkbox.hide()
311 self.prompt_checkbox.hide()
313 if action == PULL:
314 # Fetch and Push-only options
315 self.force_checkbox.hide()
316 self.tags_checkbox.hide()
317 self.local_label.hide()
318 self.local_branch.hide()
319 self.local_branches.hide()
320 else:
321 # Pull-only options
322 self.rebase_checkbox.hide()
323 self.no_ff_checkbox.hide()
324 self.ff_only_checkbox.hide()
326 self.init_size(parent=parent)
328 def set_rebase(self, value):
329 """Check the rebase checkbox"""
330 self.rebase_checkbox.setChecked(value)
332 def set_field_defaults(self):
333 """Set sensible initial defaults"""
334 # Default to "git fetch origin master"
335 action = self.action
336 if action in (FETCH, PULL):
337 self.local_branch.setText('')
338 self.remote_branch.setText('')
339 return
341 # Select the current branch by default for push
342 if action == PUSH:
343 branch = self.model.currentbranch
344 try:
345 idx = self.model.local_branches.index(branch)
346 except ValueError:
347 return
348 if self.select_local_branch(idx):
349 self.set_local_branch(branch)
350 self.set_remote_branch('')
352 def set_remote_name(self, remote_name):
353 """Set the remote name"""
354 self.remote_name.setText(remote_name)
356 def set_local_branch(self, branch):
357 """Set the local branch name"""
358 self.local_branch.setText(branch)
359 if branch:
360 self.local_branch.selectAll()
362 def set_remote_branch(self, branch):
363 """Set the remote branch name"""
364 self.remote_branch.setText(branch)
365 if branch:
366 self.remote_branch.selectAll()
368 def set_remote_branches(self, branches):
369 """Set the list of remote branches"""
370 self.remote_branches.clear()
371 self.remote_branches.addItems(branches)
372 self.filtered_remote_branches = branches
373 qtutils.add_completer(self.remote_branch, strip_remotes(branches))
375 def select_first_remote(self):
376 """Select the first remote in the list view"""
377 return self.select_remote(0)
379 def select_remote(self, idx):
380 """Select a remote by index"""
381 item = self.remotes.item(idx)
382 if item:
383 item.setSelected(True)
384 self.remotes.setCurrentItem(item)
385 self.set_remote_name(item.text())
386 result = True
387 else:
388 result = False
389 return result
391 def select_remote_by_name(self, remote):
392 """Select a remote by name"""
393 remotes = self.model.remotes
394 if remote in remotes:
395 idx = remotes.index(remote)
396 result = self.select_remote(idx)
397 else:
398 result = False
399 return result
401 def set_selected_remotes(self, remotes):
402 """Set the list of selected remotes
404 Return True if all remotes were found and selected.
407 # Invalid remote names are ignored.
408 # This handles a remote going away between sessions.
409 # The selection is unchanged when none of the specified remotes exist.
410 found = False
411 for remote in remotes:
412 if remote in self.model.remotes:
413 found = True
414 break
415 if found:
416 # Only clear the selection if the specified remotes exist
417 self.remotes.clearSelection()
418 found = all([self.select_remote_by_name(x) for x in remotes])
419 return found
421 def select_local_branch(self, idx):
422 """Selects a local branch by index in the list view"""
423 item = self.local_branches.item(idx)
424 if item:
425 item.setSelected(True)
426 self.local_branches.setCurrentItem(item)
427 self.local_branch.setText(item.text())
428 result = True
429 else:
430 result = False
431 return result
433 def display_remotes(self, widget):
434 """Display the available remotes in a listwidget"""
435 displayed = []
436 for remote_name in self.model.remotes:
437 url = self.model.remote_url(remote_name, self.action)
438 display = ('%s\t(%s)'
439 % (remote_name, N_('URL: %s') % url))
440 displayed.append(display)
441 qtutils.set_items(widget, displayed)
443 def update_remotes(self):
444 """Update the remote name when a remote from the list is selected"""
445 widget = self.remotes
446 remotes = self.model.remotes
447 selection = qtutils.selected_item(widget, remotes)
448 if not selection:
449 self.selected_remotes = []
450 return
451 self.set_remote_name(selection)
452 self.selected_remotes = qtutils.selected_items(self.remotes,
453 self.model.remotes)
454 self.set_remote_to(selection, self.selected_remotes)
456 def set_remote_to(self, _remote, selected_remotes):
457 context = self.context
458 all_branches = gitcmds.branch_list(context, remote=True)
459 branches = []
460 patterns = []
461 for remote_name in selected_remotes:
462 patterns.append(remote_name + '/*')
464 for branch in all_branches:
465 for pat in patterns:
466 if fnmatch.fnmatch(branch, pat):
467 branches.append(branch)
468 break
469 if branches:
470 self.set_remote_branches(branches)
471 else:
472 self.set_remote_branches(all_branches)
473 self.set_remote_branch('')
475 def remote_name_edited(self):
476 """Update the current remote when the remote name is typed manually"""
477 remote = self.remote_name.text()
478 self.set_remote_to(remote, [remote])
480 def update_local_branches(self):
481 """Update the local/remote branch names when a branch is selected"""
482 branches = self.model.local_branches
483 widget = self.local_branches
484 selection = qtutils.selected_item(widget, branches)
485 if not selection:
486 return
487 self.set_local_branch(selection)
488 self.set_remote_branch(selection)
490 def update_remote_branches(self):
491 """Update the remote branch name when a branch is selected"""
492 widget = self.remote_branches
493 branches = self.filtered_remote_branches
494 selection = qtutils.selected_item(widget, branches)
495 if not selection:
496 return
497 branch = utils.strip_one(selection)
498 if branch == 'HEAD':
499 return
500 self.set_remote_branch(branch)
502 def common_args(self):
503 """Returns git arguments common to fetch/push/pull"""
504 remote_name = self.remote_name.text()
505 local_branch = self.local_branch.text()
506 remote_branch = self.remote_branch.text()
508 ff_only = get(self.ff_only_checkbox)
509 force = get(self.force_checkbox)
510 no_ff = get(self.no_ff_checkbox)
511 rebase = get(self.rebase_checkbox)
512 set_upstream = get(self.upstream_checkbox)
513 tags = get(self.tags_checkbox)
514 prune = get(self.prune_checkbox)
516 return (remote_name,
518 'ff_only': ff_only,
519 'force': force,
520 'local_branch': local_branch,
521 'no_ff': no_ff,
522 'rebase': rebase,
523 'remote_branch': remote_branch,
524 'set_upstream': set_upstream,
525 'tags': tags,
526 'prune': prune,
529 # Actions
531 def push_to_all(self, _remote, *args, **kwargs):
532 """Push to all selected remotes"""
533 selected_remotes = self.selected_remotes
534 all_results = None
535 for remote in selected_remotes:
536 result = self.model.push(remote, *args, **kwargs)
537 all_results = combine(result, all_results)
538 return all_results
540 def action_callback(self):
541 """Perform the actual fetch/push/pull operation"""
542 action = self.action
543 if action == FETCH:
544 model_action = self.model.fetch
545 elif action == PUSH:
546 model_action = self.push_to_all
547 else: # if action == PULL:
548 model_action = self.model.pull
550 remote_name = self.remote_name.text()
551 if not remote_name:
552 errmsg = N_('No repository selected.')
553 Interaction.log(errmsg)
554 return
555 remote, kwargs = self.common_args()
556 self.selected_remotes = qtutils.selected_items(self.remotes,
557 self.model.remotes)
559 # Check if we're about to create a new branch and warn.
560 remote_branch = self.remote_branch.text()
561 local_branch = self.local_branch.text()
563 if action == PUSH and not remote_branch:
564 branch = local_branch
565 candidate = '%s/%s' % (remote, branch)
566 prompt = get(self.prompt_checkbox)
568 if prompt and candidate not in self.model.remote_branches:
569 title = N_('Push')
570 args = dict(branch=branch, remote=remote)
571 msg = N_('Branch "%(branch)s" does not exist in "%(remote)s".\n'
572 'A new remote branch will be published.') % args
573 info_txt = N_('Create a new remote branch?')
574 ok_text = N_('Create Remote Branch')
575 if not Interaction.confirm(title, msg, info_txt, ok_text,
576 icon=icons.cola()):
577 return
579 if get(self.force_checkbox):
580 if action == FETCH:
581 title = N_('Force Fetch?')
582 msg = N_('Non-fast-forward fetch overwrites local history!')
583 info_txt = N_('Force fetching from %s?') % remote
584 ok_text = N_('Force Fetch')
585 elif action == PUSH:
586 title = N_('Force Push?')
587 msg = N_('Non-fast-forward push overwrites published '
588 'history!\n(Did you pull first?)')
589 info_txt = N_('Force push to %s?') % remote
590 ok_text = N_('Force Push')
591 else: # pull: shouldn't happen since the controls are hidden
592 return
593 if not Interaction.confirm(title, msg, info_txt, ok_text,
594 default=False, icon=icons.discard()):
595 return
597 # Disable the GUI by default
598 self.buttons.setEnabled(False)
600 # Use a thread to update in the background
601 task = ActionTask(self, model_action, remote, kwargs)
602 self.runtask.start(task,
603 progress=self.progress,
604 finish=self.action_completed)
606 def action_completed(self, task):
607 """Grab the results of the action and finish up"""
608 status, out, err = task.result
609 self.buttons.setEnabled(True)
611 command = 'git %s' % self.action.lower()
612 message = Interaction.format_command_status(command, status)
613 details = Interaction.format_out_err(out, err)
615 log_message = message
616 if details:
617 log_message += '\n\n' + details
618 Interaction.log(log_message)
620 if status == 0:
621 self.accept()
622 return
624 if self.action == PUSH:
625 message += '\n\n'
626 message += N_('Have you rebased/pulled lately?')
628 Interaction.critical(self.windowTitle(),
629 message=message, details=details)
632 # Use distinct classes so that each saves its own set of preferences
633 class Fetch(RemoteActionDialog):
634 """Fetch from remote repositories"""
636 def __init__(self, context, model, parent=None):
637 super(Fetch, self).__init__(
638 context, model, FETCH, N_('Fetch'),
639 parent=parent, icon=icons.repo())
641 def export_state(self):
642 """Export persistent settings"""
643 state = RemoteActionDialog.export_state(self)
644 state['tags'] = get(self.tags_checkbox)
645 state['prune'] = get(self.prune_checkbox)
646 return state
648 def apply_state(self, state):
649 """Apply persistent settings"""
650 result = RemoteActionDialog.apply_state(self, state)
651 tags = bool(state.get('tags', False))
652 self.tags_checkbox.setChecked(tags)
653 prune = bool(state.get('prune', False))
654 self.prune_checkbox.setChecked(prune)
655 return result
658 class Push(RemoteActionDialog):
659 """Push to remote repositories"""
661 def __init__(self, context, model, parent=None):
662 super(Push, self).__init__(
663 context, model, PUSH, N_('Push'), parent=parent, icon=icons.push())
665 def export_state(self):
666 """Export persistent settings"""
667 state = RemoteActionDialog.export_state(self)
668 state['prompt'] = get(self.prompt_checkbox)
669 state['remote'] = get(self.remote_name)
670 state['selection'] = self.selected_remotes
671 state['tags'] = get(self.tags_checkbox)
672 return state
674 def apply_state(self, state):
675 """Apply persistent settings"""
676 result = RemoteActionDialog.apply_state(self, state)
678 # Restore the "prompt on creation" checkbox
679 prompt = bool(state.get('prompt', True))
680 self.prompt_checkbox.setChecked(prompt)
682 # Restore the "remote" text
683 remote = state.get('remote', None)
684 if remote is not None:
685 self.set_remote_name(remote)
687 # Restore selected remotes
688 selection = state.get('selection', [])
689 self.set_selected_remotes(selection)
691 # Restore the "tags" checkbox
692 tags = bool(state.get('tags', False))
693 self.tags_checkbox.setChecked(tags)
695 return result
698 class Pull(RemoteActionDialog):
699 """Pull from remote repositories"""
701 def __init__(self, context, model, parent=None):
702 super(Pull, self).__init__(
703 context, model, PULL, N_('Pull'), parent=parent, icon=icons.pull())
705 def apply_state(self, state):
706 """Apply persistent settings"""
707 result = RemoteActionDialog.apply_state(self, state)
708 # Rebase has the highest priority
709 rebase = bool(state.get('rebase', False))
710 ff_only = not rebase and bool(state.get('ff_only', False))
711 no_ff = not rebase and not ff_only and bool(state.get('no_ff', False))
713 self.rebase_checkbox.setChecked(rebase)
714 self.no_ff_checkbox.setChecked(no_ff)
716 # Allow users coming from older versions that have rebase=False to
717 # pickup the new ff_only=True default by only setting ff_only False
718 # when it either exists in the config or when rebase=True.
719 if 'ff_only' in state or rebase:
720 self.ff_only_checkbox.setChecked(ff_only)
722 return result
724 def export_state(self):
725 """Export persistent settings"""
726 state = RemoteActionDialog.export_state(self)
728 state['ff_only'] = get(self.ff_only_checkbox)
729 state['no_ff'] = get(self.no_ff_checkbox)
730 state['rebase'] = get(self.rebase_checkbox)
732 return state