git-xbase: pylint updates
[git-cola.git] / share / git-cola / bin / git-xbase
blob3692216043dcb338d77ecd4d8b14f02e1e784d73
1 #!/usr/bin/env python
2 # flake8: noqa
3 from __future__ import absolute_import, division, unicode_literals
4 import os
5 import sys
6 import re
7 from argparse import ArgumentParser
8 from functools import partial
11 def setup_environment():
12 path = os.path
13 dirname = path.dirname
14 # <prefix>/ share/git-cola/ bin/git-xbase
15 prefix = dirname(dirname(dirname(dirname(path.abspath(__file__)))))
16 # str() avoids unicode on python2 by staying in bytes
17 source_tree = path.join(prefix, str('cola'), str('__init__.py'))
18 unixpkgs = path.join(prefix, str('share'), str('git-cola'), str('lib'))
19 winpkgs = path.join(prefix, str('pkgs'))
21 if path.exists(source_tree):
22 modules = prefix
23 elif path.exists(unixpkgs):
24 modules = unixpkgs
25 elif path.exists(winpkgs):
26 modules = winpkgs
27 else:
28 modules = None
29 if modules:
30 sys.path.insert(1, modules)
33 setup_environment()
35 from cola import app # prints a message if Qt cannot be found
36 from qtpy import QtCore
37 from qtpy import QtGui
38 from qtpy import QtWidgets
39 from qtpy.QtCore import Qt
40 from qtpy.QtCore import Signal
42 # pylint: disable=ungrouped-imports
43 from cola import core
44 from cola import difftool
45 from cola import hotkeys
46 from cola import icons
47 from cola import observable
48 from cola import qtutils
49 from cola import utils
50 from cola.i18n import N_
51 from cola.models import dag
52 from cola.models import prefs
53 from cola.widgets import defs
54 from cola.widgets import diff
55 from cola.widgets import standard
56 from cola.widgets import text
58 PICK = 'pick'
59 REWORD = 'reword'
60 EDIT = 'edit'
61 FIXUP = 'fixup'
62 SQUASH = 'squash'
63 EXEC = 'exec'
64 COMMANDS = (PICK, REWORD, EDIT, FIXUP, SQUASH,)
65 COMMAND_IDX = dict([(cmd_, idx_) for idx_, cmd_ in enumerate(COMMANDS)])
66 ABBREV = {
67 'p': PICK,
68 'r': REWORD,
69 'e': EDIT,
70 'f': FIXUP,
71 's': SQUASH,
72 'x': EXEC,
76 def main():
77 """Start a git-xbase session"""
78 args = parse_args()
79 context = app.application_init(args)
80 view = new_window(context, args.filename)
81 app.application_run(context, view, start=view.start, stop=stop)
82 return view.status
85 def stop(_context, _view):
86 """All done, cleanup"""
87 QtCore.QThreadPool.globalInstance().waitForDone()
90 def parse_args():
91 parser = ArgumentParser()
92 parser.add_argument('filename', metavar='<filename>',
93 help='git-rebase-todo file to edit')
94 app.add_common_arguments(parser)
95 return parser.parse_args()
98 def new_window(context, filename):
99 window = XBaseWindow(context)
100 editor = Editor(context, filename, parent=window)
101 window.set_editor(editor)
102 return window
105 def unabbrev(cmd):
106 """Expand shorthand commands into their full name"""
107 return ABBREV.get(cmd, cmd)
110 class XBaseWindow(standard.MainWindow):
112 def __init__(self, context, settings=None, parent=None):
113 super(XBaseWindow, self).__init__(parent)
114 self.context = context
115 self.status = 1
116 self.editor = None
117 default_title = '%s - git xbase' % core.getcwd()
118 title = core.getenv('GIT_XBASE_TITLE', default_title)
119 self.setWindowTitle(title)
121 self.show_help_action = qtutils.add_action(
122 self, N_('Show Help'), partial(show_help, context),
123 hotkeys.QUESTION)
125 self.menubar = QtWidgets.QMenuBar(self)
126 self.help_menu = self.menubar.addMenu(N_('Help'))
127 self.help_menu.addAction(self.show_help_action)
128 self.setMenuBar(self.menubar)
130 qtutils.add_close_action(self)
131 self.init_state(settings, self.init_window_size)
133 def init_window_size(self):
134 """Set the window size on the first initial view"""
135 context = self.context
136 if utils.is_darwin():
137 desktop = context.app.desktop()
138 self.resize(desktop.width(), desktop.height())
139 else:
140 self.showMaximized()
142 def set_editor(self, editor):
143 self.editor = editor
144 self.setCentralWidget(editor)
145 editor.exit.connect(self.exit)
146 editor.setFocus()
148 def start(self, _context, _view):
149 self.editor.start()
151 def exit(self, status):
152 self.status = status
153 self.close()
156 class Editor(QtWidgets.QWidget):
157 exit = Signal(int)
159 def __init__(self, context, filename, parent=None):
160 super(Editor, self).__init__(parent)
162 self.widget_version = 1
163 self.status = 1
164 self.context = context
165 self.filename = filename
166 self.comment_char = comment_char = prefs.comment_char(context)
167 self.cancel_action = core.getenv('GIT_XBASE_CANCEL_ACTION', 'abort')
169 self.notifier = notifier = observable.Observable()
170 self.diff = diff.DiffWidget(context, notifier, self)
171 self.tree = RebaseTreeWidget(context, notifier, comment_char, self)
172 self.setFocusProxy(self.tree)
174 self.rebase_button = qtutils.create_button(
175 text=core.getenv('GIT_XBASE_ACTION', N_('Rebase')),
176 tooltip=N_('Accept changes and rebase\n'
177 'Shortcut: Ctrl+Enter'),
178 icon=icons.ok(),
179 default=True)
181 self.extdiff_button = qtutils.create_button(
182 text=N_('Launch Diff Tool'),
183 tooltip=N_('Launch external diff tool\n'
184 'Shortcut: Ctrl+D'))
185 self.extdiff_button.setEnabled(False)
187 self.help_button = qtutils.create_button(
188 text=N_('Help'), tooltip=N_('Show help\nShortcut: ?'),
189 icon=icons.question())
191 self.cancel_button = qtutils.create_button(
192 text=N_('Cancel'), tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
193 icon=icons.close())
195 splitter = qtutils.splitter(Qt.Vertical, self.tree, self.diff)
197 controls_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
198 self.cancel_button,
199 qtutils.STRETCH,
200 self.help_button,
201 self.extdiff_button,
202 self.rebase_button)
203 layout = qtutils.vbox(defs.no_margin, defs.spacing,
204 splitter, controls_layout)
205 self.setLayout(layout)
207 self.action_rebase = qtutils.add_action(
208 self, N_('Rebase'), self.rebase,
209 hotkeys.CTRL_RETURN, hotkeys.CTRL_ENTER)
211 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
212 self.tree.external_diff.connect(self.external_diff)
214 qtutils.connect_button(self.rebase_button, self.rebase)
215 qtutils.connect_button(self.extdiff_button, self.external_diff)
216 qtutils.connect_button(self.help_button, partial(show_help, context))
217 qtutils.connect_button(self.cancel_button, self.cancel)
219 def start(self):
220 insns = core.read(self.filename)
221 self.parse_sequencer_instructions(insns)
223 # notifier callbacks
224 def commits_selected(self, commits):
225 self.extdiff_button.setEnabled(bool(commits))
227 # helpers
228 def parse_sequencer_instructions(self, insns):
229 idx = 1
230 re_comment_char = re.escape(self.comment_char)
231 exec_rgx = re.compile(r'^\s*(%s)?\s*(x|exec)\s+(.+)$'
232 % re_comment_char)
233 # The upper bound of 40 below must match OID_LENGTH.
234 pick_rgx = re.compile((r'^\s*(%s)?\s*'
235 r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
236 r'\s+([0-9a-f]{7,40})'
237 r'\s+(.+)$') % re_comment_char)
238 for line in insns.splitlines():
239 match = pick_rgx.match(line)
240 if match:
241 enabled = match.group(1) is None
242 command = unabbrev(match.group(2))
243 oid = match.group(3)
244 summary = match.group(4)
245 self.tree.add_item(idx, enabled, command,
246 oid=oid, summary=summary)
247 idx += 1
248 continue
249 match = exec_rgx.match(line)
250 if match:
251 enabled = match.group(1) is None
252 command = unabbrev(match.group(2))
253 cmdexec = match.group(3)
254 self.tree.add_item(idx, enabled, command, cmdexec=cmdexec)
255 idx += 1
256 continue
258 self.tree.decorate(self.tree.items())
259 self.tree.refit()
260 self.tree.select_first()
262 # actions
263 def cancel(self):
264 if self.cancel_action == 'save':
265 status = self.save('')
266 else:
267 status = 1
269 self.status = status
270 self.exit.emit(status)
272 def rebase(self):
273 lines = [item.value() for item in self.tree.items()]
274 sequencer_instructions = '\n'.join(lines) + '\n'
275 status = self.save(sequencer_instructions)
276 self.status = status
277 self.exit.emit(status)
279 def save(self, string):
280 """Save the instruction sheet"""
281 try:
282 core.write(self.filename, string)
283 status = 0
284 except (OSError, IOError, ValueError) as e:
285 msg, details = utils.format_exception(e)
286 sys.stderr.write(msg + '\n\n' + details)
287 status = 128
288 return status
290 def external_diff(self):
291 items = self.tree.selected_items()
292 if not items:
293 return
294 item = items[0]
295 difftool.diff_expression(self.context, self, item.oid + '^!',
296 hide_expr=True)
299 class RebaseTreeWidget(standard.DraggableTreeWidget):
300 external_diff = Signal()
301 move_rows = Signal(object, object)
303 def __init__(self, context, notifier, comment_char, parent=None):
304 super(RebaseTreeWidget, self).__init__(parent=parent)
305 self.context = context
306 self.notifier = notifier
307 self.comment_char = comment_char
308 # header
309 self.setHeaderLabels([
310 N_('#'),
311 N_('Enabled'),
312 N_('Command'),
313 N_('SHA-1'),
314 N_('Summary'),
316 self.header().setStretchLastSection(True)
317 self.setColumnCount(5)
319 # actions
320 self.copy_oid_action = qtutils.add_action(
321 self, N_('Copy SHA-1'), self.copy_oid, QtGui.QKeySequence.Copy)
323 self.external_diff_action = qtutils.add_action(
324 self, N_('Launch Diff Tool'), self.external_diff.emit,
325 hotkeys.DIFF)
327 self.toggle_enabled_action = qtutils.add_action(
328 self, N_('Toggle Enabled'), self.toggle_enabled,
329 hotkeys.PRIMARY_ACTION)
331 self.action_pick = qtutils.add_action(
332 self, N_('Pick'), lambda: self.set_selected_to(PICK),
333 *hotkeys.REBASE_PICK)
335 self.action_reword = qtutils.add_action(
336 self, N_('Reword'), lambda: self.set_selected_to(REWORD),
337 *hotkeys.REBASE_REWORD)
339 self.action_edit = qtutils.add_action(
340 self, N_('Edit'), lambda: self.set_selected_to(EDIT),
341 *hotkeys.REBASE_EDIT)
343 self.action_fixup = qtutils.add_action(
344 self, N_('Fixup'), lambda: self.set_selected_to(FIXUP),
345 *hotkeys.REBASE_FIXUP)
347 self.action_squash = qtutils.add_action(
348 self, N_('Squash'), lambda: self.set_selected_to(SQUASH),
349 *hotkeys.REBASE_SQUASH)
351 self.action_shift_down = qtutils.add_action(
352 self, N_('Shift Down'), self.shift_down,
353 hotkeys.MOVE_DOWN_TERTIARY)
355 self.action_shift_up = qtutils.add_action(
356 self, N_('Shift Up'), self.shift_up, hotkeys.MOVE_UP_TERTIARY)
358 self.itemChanged.connect(self.item_changed)
359 self.itemSelectionChanged.connect(self.selection_changed)
360 self.move_rows.connect(self.move)
361 self.items_moved.connect(self.decorate)
363 def add_item(self, idx, enabled, command,
364 oid='', summary='', cmdexec=''):
365 comment_char = self.comment_char
366 item = RebaseTreeWidgetItem(idx, enabled, command,
367 oid=oid, summary=summary,
368 cmdexec=cmdexec, comment_char=comment_char)
369 self.invisibleRootItem().addChild(item)
371 def decorate(self, items):
372 for item in items:
373 item.decorate(self)
375 def refit(self):
376 self.resizeColumnToContents(0)
377 self.resizeColumnToContents(1)
378 self.resizeColumnToContents(2)
379 self.resizeColumnToContents(3)
380 self.resizeColumnToContents(4)
382 # actions
383 def item_changed(self, item, column):
384 if column == item.ENABLED_COLUMN:
385 self.validate()
387 def validate(self):
388 invalid_first_choice = set([FIXUP, SQUASH])
389 for item in self.items():
390 if item.is_enabled() and item.is_commit():
391 if item.command in invalid_first_choice:
392 item.reset_command(PICK)
393 break
395 def set_selected_to(self, command):
396 for i in self.selected_items():
397 i.reset_command(command)
398 self.validate()
400 def set_command(self, item, command):
401 item.reset_command(command)
402 self.validate()
404 def copy_oid(self):
405 item = self.selected_item()
406 if item is None:
407 return
408 clipboard = item.oid or item.cmdexec
409 qtutils.set_clipboard(clipboard)
411 def selection_changed(self):
412 item = self.selected_item()
413 if item is None or not item.is_commit():
414 return
415 context = self.context
416 oid = item.oid
417 params = dag.DAG(oid, 2)
418 repo = dag.RepoReader(context, params)
419 commits = []
420 for c in repo.get():
421 commits.append(c)
422 if commits:
423 commits = commits[-1:]
424 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
426 def toggle_enabled(self):
427 item = self.selected_item()
428 if item is None:
429 return
430 item.toggle_enabled()
432 def select_first(self):
433 items = self.items()
434 if not items:
435 return
436 idx = self.model().index(0, 0)
437 if idx.isValid():
438 self.setCurrentIndex(idx)
440 def shift_down(self):
441 item = self.selected_item()
442 if item is None:
443 return
444 items = self.items()
445 idx = items.index(item)
446 if idx < len(items) - 1:
447 self.move_rows.emit([idx], idx + 1)
449 def shift_up(self):
450 item = self.selected_item()
451 if item is None:
452 return
453 items = self.items()
454 idx = items.index(item)
455 if idx > 0:
456 self.move_rows.emit([idx], idx - 1)
458 def move(self, src_idxs, dst_idx):
459 new_items = []
460 items = self.items()
461 for idx in reversed(sorted(src_idxs)):
462 item = items[idx].copy()
463 self.invisibleRootItem().takeChild(idx)
464 new_items.insert(0, item)
466 if new_items:
467 self.invisibleRootItem().insertChildren(dst_idx, new_items)
468 self.setCurrentItem(new_items[0])
469 # If we've moved to the top then we need to re-decorate all items.
470 # Otherwise, we can decorate just the new items.
471 if dst_idx == 0:
472 self.decorate(self.items())
473 else:
474 self.decorate(new_items)
475 self.validate()
477 # Qt events
479 def dropEvent(self, event):
480 super(RebaseTreeWidget, self).dropEvent(event)
481 self.validate()
483 def contextMenuEvent(self, event):
484 menu = qtutils.create_menu(N_('Actions'), self)
485 menu.addAction(self.action_pick)
486 menu.addAction(self.action_reword)
487 menu.addAction(self.action_edit)
488 menu.addAction(self.action_fixup)
489 menu.addAction(self.action_squash)
490 menu.addSeparator()
491 menu.addAction(self.toggle_enabled_action)
492 menu.addSeparator()
493 menu.addAction(self.copy_oid_action)
494 menu.addAction(self.external_diff_action)
495 menu.exec_(self.mapToGlobal(event.pos()))
498 class ComboBox(QtWidgets.QComboBox):
499 validate = Signal()
502 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
504 ENABLED_COLUMN = 1
505 COMMAND_COLUMN = 2
506 OID_LENGTH = 7
508 def __init__(self, idx, enabled, command,
509 oid='', summary='', cmdexec='', comment_char='#',
510 parent=None):
511 QtWidgets.QTreeWidgetItem.__init__(self, parent)
512 self.combo = None
513 self.command = command
514 self.idx = idx
515 self.oid = oid
516 self.summary = summary
517 self.cmdexec = cmdexec
518 self.comment_char = comment_char
520 # if core.abbrev is set to a higher value then we will notice by
521 # simply tracking the longest oid we've seen
522 oid_len = self.__class__.OID_LENGTH
523 self.__class__.OID_LENGTH = max(len(oid), oid_len)
525 self.setText(0, '%02d' % idx)
526 self.set_enabled(enabled)
527 # checkbox on 1
528 # combo box on 2
529 if self.is_exec():
530 self.setText(3, '')
531 self.setText(4, cmdexec)
532 else:
533 self.setText(3, oid)
534 self.setText(4, summary)
536 flags = self.flags() | Qt.ItemIsUserCheckable
537 flags = flags | Qt.ItemIsDragEnabled
538 flags = flags & ~Qt.ItemIsDropEnabled
539 self.setFlags(flags)
541 def __eq__(self, other):
542 return self is other
544 def __hash__(self):
545 return self.oid
547 def copy(self):
548 return self.__class__(self.idx, self.is_enabled(), self.command,
549 oid=self.oid, summary=self.summary,
550 cmdexec=self.cmdexec)
552 def decorate(self, parent):
553 if self.is_exec():
554 items = [EXEC]
555 idx = 0
556 else:
557 items = COMMANDS
558 idx = COMMAND_IDX[self.command]
559 combo = self.combo = ComboBox()
560 combo.setEditable(False)
561 combo.addItems(items)
562 combo.setCurrentIndex(idx)
563 combo.setEnabled(self.is_commit())
565 signal = combo.currentIndexChanged
566 signal.connect(lambda x: self.set_command_and_validate(combo))
567 combo.validate.connect(parent.validate)
569 parent.setItemWidget(self, self.COMMAND_COLUMN, combo)
571 def is_exec(self):
572 return self.command == EXEC
574 def is_commit(self):
575 return bool(self.command != EXEC and self.oid and self.summary)
577 def value(self):
578 """Return the serialized representation of an item"""
579 if self.is_enabled():
580 comment = ''
581 else:
582 comment = self.comment_char + ' '
583 if self.is_exec():
584 return '%s%s %s' % (comment, self.command, self.cmdexec)
585 return ('%s%s %s %s' %
586 (comment, self.command, self.oid, self.summary))
588 def is_enabled(self):
589 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
591 def set_enabled(self, enabled):
592 self.setCheckState(self.ENABLED_COLUMN,
593 enabled and Qt.Checked or Qt.Unchecked)
595 def toggle_enabled(self):
596 self.set_enabled(not self.is_enabled())
598 def set_command(self, command):
599 """Set the item to a different command, no-op for exec items"""
600 if self.is_exec():
601 return
602 self.command = command
604 def refresh(self):
605 """Update the view to match the updated state"""
606 if self.is_commit():
607 command = self.command
608 self.combo.setCurrentIndex(COMMAND_IDX[command])
610 def reset_command(self, command):
611 """Set and refresh the item in one shot"""
612 self.set_command(command)
613 self.refresh()
615 def set_command_and_validate(self, combo):
616 command = COMMANDS[combo.currentIndex()]
617 self.set_command(command)
618 self.combo.validate.emit()
621 def show_help(context):
622 help_text = N_("""
623 Commands
624 --------
625 pick = use commit
626 reword = use commit, but edit the commit message
627 edit = use commit, but stop for amending
628 squash = use commit, but meld into previous commit
629 fixup = like "squash", but discard this commit's log message
630 exec = run command (the rest of the line) using shell
632 These lines can be re-ordered; they are executed from top to bottom.
634 If you disable a line here THAT COMMIT WILL BE LOST.
636 However, if you disable everything, the rebase will be aborted.
638 Keyboard Shortcuts
639 ------------------
640 ? = show help
641 j = move down
642 k = move up
643 J = shift row down
644 K = shift row up
646 1, p = pick
647 2, r = reword
648 3, e = edit
649 4, f = fixup
650 5, s = squash
651 spacebar = toggle enabled
653 ctrl+enter = accept changes and rebase
654 ctrl+q = cancel and abort the rebase
655 ctrl+d = launch difftool
656 """)
657 title = N_('Help - git-xbase')
658 return text.text_dialog(context, help_text, title)
661 if __name__ == '__main__':
662 sys.exit(main())