xbase: add flake8-suggested whitespace
[git-cola.git] / share / git-cola / bin / git-xbase
blob1f82c40cec4fa91a2a03541a5ec1019fdc62167c
1 #!/usr/bin/env python
2 from __future__ import absolute_import, division, unicode_literals
3 import os
4 import sys
5 import re
6 from argparse import ArgumentParser
9 def setup_environment():
10 abspath = os.path.abspath
11 dirname = os.path.dirname
12 prefix = dirname(dirname(dirname(dirname(abspath(__file__)))))
13 source_tree = os.path.join(prefix, 'cola', '__init__.py')
14 if os.path.exists(source_tree):
15 modules = prefix
16 else:
17 modules = os.path.join(prefix, 'share', 'git-cola', 'lib')
18 sys.path.insert(1, modules)
20 setup_environment()
22 from cola import app
24 from qtpy import QtGui
25 from qtpy import QtWidgets
26 from qtpy.QtCore import Qt
27 from qtpy.QtCore import Signal
29 from cola import core
30 from cola import difftool
31 from cola import hotkeys
32 from cola import icons
33 from cola import observable
34 from cola import qtutils
35 from cola import utils
36 from cola.i18n import N_
37 from cola.models import dag
38 from cola.models import prefs
39 from cola.widgets import defs
40 from cola.widgets import diff
41 from cola.widgets import standard
42 from cola.widgets import text
45 PICK = 'pick'
46 REWORD = 'reword'
47 EDIT = 'edit'
48 FIXUP = 'fixup'
49 SQUASH = 'squash'
50 EXEC = 'exec'
51 COMMANDS = (PICK, REWORD, EDIT, FIXUP, SQUASH,)
52 COMMAND_IDX = dict([(cmd, idx) for idx, cmd in enumerate(COMMANDS)])
53 ABBREV = {
54 'p': PICK,
55 'r': REWORD,
56 'e': EDIT,
57 'f': FIXUP,
58 's': SQUASH,
59 'x': EXEC,
63 def main():
64 args = parse_args()
65 context = app.application_init(args)
67 desktop = context.app.desktop()
68 window = new_window(args.filename)
69 window.resize(desktop.width(), desktop.height())
70 window.show()
71 window.raise_()
72 context.app.exec_()
73 return window.status
76 def parse_args():
77 parser = ArgumentParser()
78 parser.add_argument('filename', metavar='<filename>',
79 help='git-rebase-todo file to edit')
80 app.add_common_arguments(parser)
81 return parser.parse_args()
84 def new_window(filename):
85 window = MainWindow()
86 editor = Editor(filename, parent=window)
87 window.set_editor(editor)
88 return window
91 def unabbrev(cmd):
92 """Expand shorthand commands into their full name"""
93 return ABBREV.get(cmd, cmd)
96 class MainWindow(QtWidgets.QMainWindow):
98 def __init__(self, parent=None):
99 super(MainWindow, self).__init__(parent)
100 self.status = 1
101 self.editor = None
102 default_title = '%s - git xbase' % core.getcwd()
103 title = core.getenv('GIT_XBASE_TITLE', default_title)
104 self.setAttribute(Qt.WA_MacMetalStyle)
105 self.setWindowTitle(title)
107 self.show_help_action = qtutils.add_action(
108 self, N_('Show Help'), show_help, hotkeys.QUESTION)
110 self.menubar = QtWidgets.QMenuBar(self)
111 self.help_menu = self.menubar.addMenu(N_('Help'))
112 self.help_menu.addAction(self.show_help_action)
113 self.setMenuBar(self.menubar)
115 qtutils.add_close_action(self)
117 def set_editor(self, editor):
118 self.editor = editor
119 self.setCentralWidget(editor)
120 editor.exit.connect(self.exit)
121 editor.setFocus()
123 def exit(self, status):
124 self.status = status
125 self.close()
128 class Editor(QtWidgets.QWidget):
129 exit = Signal(int)
131 def __init__(self, filename, parent=None):
132 super(Editor, self).__init__(parent)
134 self.widget_version = 1
135 self.status = 1
136 self.filename = filename
137 self.comment_char = comment_char = prefs.comment_char()
139 self.notifier = notifier = observable.Observable()
140 self.diff = diff.DiffWidget(notifier, self)
141 self.tree = RebaseTreeWidget(notifier, comment_char, self)
142 self.setFocusProxy(self.tree)
144 self.rebase_button = qtutils.create_button(
145 text=core.getenv('GIT_XBASE_ACTION', N_('Rebase')),
146 tooltip=N_('Accept changes and rebase\n'
147 'Shortcut: Ctrl+Enter'),
148 icon=icons.ok())
150 self.extdiff_button = qtutils.create_button(
151 text=N_('External Diff'),
152 tooltip=N_('Launch external diff\n'
153 'Shortcut: Ctrl+D'))
154 self.extdiff_button.setEnabled(False)
156 self.help_button = qtutils.create_button(
157 text=N_('Help'), tooltip=N_('Show help\nShortcut: ?'),
158 icon=icons.question())
160 self.cancel_button = qtutils.create_button(
161 text=N_('Cancel'), tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
162 icon=icons.close())
164 splitter = qtutils.splitter(Qt.Vertical, self.tree, self.diff)
166 controls_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
167 self.rebase_button, self.extdiff_button,
168 self.help_button, qtutils.STRETCH,
169 self.cancel_button)
170 layout = qtutils.vbox(defs.no_margin, defs.spacing,
171 splitter, controls_layout)
172 self.setLayout(layout)
174 self.action_rebase = qtutils.add_action(
175 self, N_('Rebase'), self.rebase, 'Ctrl+Return')
177 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
178 self.tree.external_diff.connect(self.external_diff)
180 qtutils.connect_button(self.rebase_button, self.rebase)
181 qtutils.connect_button(self.extdiff_button, self.external_diff)
182 qtutils.connect_button(self.help_button, show_help)
183 qtutils.connect_button(self.cancel_button, self.cancel)
185 insns = core.read(filename)
186 self.parse_sequencer_instructions(insns)
188 # notifier callbacks
189 def commits_selected(self, commits):
190 self.extdiff_button.setEnabled(bool(commits))
192 # helpers
193 def parse_sequencer_instructions(self, insns):
194 idx = 1
195 re_comment_char = re.escape(self.comment_char)
196 exec_rgx = re.compile(r'^\s*(%s)?\s*(x|exec)\s+(.+)$'
197 % re_comment_char)
198 pick_rgx = re.compile((r'^\s*(%s)?\s*'
199 r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
200 r'\s+([0-9a-f]{7,40})'
201 r'\s+(.+)$') % re_comment_char)
202 for line in insns.splitlines():
203 match = pick_rgx.match(line)
204 if match:
205 enabled = match.group(1) is None
206 command = unabbrev(match.group(2))
207 sha1hex = match.group(3)
208 summary = match.group(4)
209 self.tree.add_item(idx, enabled, command,
210 sha1hex=sha1hex, summary=summary)
211 idx += 1
212 continue
213 match = exec_rgx.match(line)
214 if match:
215 enabled = match.group(1) is None
216 command = unabbrev(match.group(2))
217 cmdexec = match.group(3)
218 self.tree.add_item(idx, enabled, command, cmdexec=cmdexec)
219 idx += 1
220 continue
222 self.tree.decorate(self.tree.items())
223 self.tree.refit()
224 self.tree.select_first()
226 # actions
227 def cancel(self):
228 self.status = 1
229 self.exit.emit(1)
231 def rebase(self):
232 lines = [item.value() for item in self.tree.items()]
233 sequencer_instructions = '\n'.join(lines) + '\n'
234 try:
235 core.write(self.filename, sequencer_instructions)
236 self.status = 0
237 self.exit.emit(0)
238 except Exception as e:
239 msg, details = utils.format_exception(e)
240 sys.stderr.write(msg + '\n\n' + details)
241 self.status = 128
242 self.exit.emit(128)
244 def external_diff(self):
245 items = self.tree.selected_items()
246 if not items:
247 return
248 item = items[0]
249 difftool.diff_expression(self, item.sha1hex + '^!',
250 hide_expr=True)
253 class RebaseTreeWidget(standard.DraggableTreeWidget):
254 external_diff = Signal()
255 move_rows = Signal(object, object)
257 def __init__(self, notifier, comment_char, parent=None):
258 super(RebaseTreeWidget, self).__init__(parent=parent)
259 self.notifier = notifier
260 self.comment_char = comment_char
261 # header
262 self.setHeaderLabels([N_('#'),
263 N_('Enabled'),
264 N_('Command'),
265 N_('SHA-1'),
266 N_('Summary')])
267 self.header().setStretchLastSection(True)
268 self.setColumnCount(5)
270 # actions
271 self.copy_sha1_action = qtutils.add_action(
272 self, N_('Copy SHA-1'), self.copy_sha1, QtGui.QKeySequence.Copy)
274 self.external_diff_action = qtutils.add_action(
275 self, N_('External Diff'), self.external_diff.emit,
276 hotkeys.DIFF)
278 self.toggle_enabled_action = qtutils.add_action(
279 self, N_('Toggle Enabled'), self.toggle_enabled,
280 hotkeys.PRIMARY_ACTION)
282 self.action_pick = qtutils.add_action(
283 self, N_('Pick'), lambda: self.set_selected_to(PICK),
284 *hotkeys.REBASE_PICK)
286 self.action_reword = qtutils.add_action(
287 self, N_('Reword'), lambda: self.set_selected_to(REWORD),
288 *hotkeys.REBASE_REWORD)
290 self.action_edit = qtutils.add_action(
291 self, N_('Edit'), lambda: self.set_selected_to(EDIT),
292 *hotkeys.REBASE_EDIT)
294 self.action_fixup = qtutils.add_action(
295 self, N_('Fixup'), lambda: self.set_selected_to(FIXUP),
296 *hotkeys.REBASE_FIXUP)
298 self.action_squash = qtutils.add_action(
299 self, N_('Squash'), lambda: self.set_selected_to(SQUASH),
300 *hotkeys.REBASE_SQUASH)
302 self.action_shift_down = qtutils.add_action(
303 self, N_('Shift Down'), self.shift_down,
304 hotkeys.MOVE_DOWN_TERTIARY)
306 self.action_shift_up = qtutils.add_action(
307 self, N_('Shift Up'), self.shift_up, hotkeys.MOVE_UP_TERTIARY)
309 self.itemChanged.connect(self.item_changed)
310 self.itemSelectionChanged.connect(self.selection_changed)
311 self.move_rows.connect(self.move)
312 self.items_moved.connect(self.decorate)
314 def add_item(self, idx, enabled, command,
315 sha1hex='', summary='', cmdexec=''):
316 comment_char = self.comment_char
317 item = RebaseTreeWidgetItem(idx, enabled, command,
318 sha1hex=sha1hex, summary=summary,
319 cmdexec=cmdexec, comment_char=comment_char)
320 self.invisibleRootItem().addChild(item)
322 def decorate(self, items):
323 for item in items:
324 item.decorate(self)
326 def refit(self):
327 self.resizeColumnToContents(0)
328 self.resizeColumnToContents(1)
329 self.resizeColumnToContents(2)
330 self.resizeColumnToContents(3)
331 self.resizeColumnToContents(4)
333 # actions
334 def item_changed(self, item, column):
335 if column == item.ENABLED_COLUMN:
336 self.validate()
338 def validate(self):
339 invalid_first_choice = set([FIXUP, SQUASH])
340 for item in self.items():
341 if item.is_enabled() and item.is_commit():
342 if item.command in invalid_first_choice:
343 item.reset_command(PICK)
344 break
346 def set_selected_to(self, command):
347 for i in self.selected_items():
348 i.reset_command(command)
349 self.validate()
351 def set_command(self, item, command):
352 item.reset_command(command)
353 self.validate()
355 def copy_sha1(self):
356 item = self.selected_item()
357 if item is None:
358 return
359 clipboard = item.sha1hex or item.cmdexec
360 qtutils.set_clipboard(clipboard)
362 def selection_changed(self):
363 item = self.selected_item()
364 if item is None or not item.is_commit():
365 return
366 sha1hex = item.sha1hex
367 ctx = dag.DAG(sha1hex, 2)
368 repo = dag.RepoReader(ctx)
369 commits = []
370 for c in repo:
371 commits.append(c)
372 if commits:
373 commits = commits[-1:]
374 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
376 def toggle_enabled(self):
377 item = self.selected_item()
378 if item is None:
379 return
380 item.toggle_enabled()
382 def select_first(self):
383 items = self.items()
384 if not items:
385 return
386 idx = self.model().index(0, 0)
387 if idx.isValid():
388 self.setCurrentIndex(idx)
390 def shift_down(self):
391 item = self.selected_item()
392 if item is None:
393 return
394 items = self.items()
395 idx = items.index(item)
396 if idx < len(items) - 1:
397 self.move_rows.emit([idx], idx + 1)
399 def shift_up(self):
400 item = self.selected_item()
401 if item is None:
402 return
403 items = self.items()
404 idx = items.index(item)
405 if idx > 0:
406 self.move_rows.emit([idx], idx - 1)
408 def move(self, src_idxs, dst_idx):
409 new_items = []
410 items = self.items()
411 for idx in reversed(sorted(src_idxs)):
412 item = items[idx].copy()
413 self.invisibleRootItem().takeChild(idx)
414 new_items.insert(0, item)
416 if new_items:
417 self.invisibleRootItem().insertChildren(dst_idx, new_items)
418 self.setCurrentItem(new_items[0])
419 # If we've moved to the top then we need to re-decorate all items.
420 # Otherwise, we can decorate just the new items.
421 if dst_idx == 0:
422 self.decorate(self.items())
423 else:
424 self.decorate(new_items)
425 self.validate()
427 # Qt events
429 def dropEvent(self, event):
430 super(RebaseTreeWidget, self).dropEvent(event)
431 self.validate()
433 def contextMenuEvent(self, event):
434 menu = qtutils.create_menu(N_('Actions'), self)
435 menu.addAction(self.action_pick)
436 menu.addAction(self.action_reword)
437 menu.addAction(self.action_edit)
438 menu.addAction(self.action_fixup)
439 menu.addAction(self.action_squash)
440 menu.addSeparator()
441 menu.addAction(self.toggle_enabled_action)
442 menu.addSeparator()
443 menu.addAction(self.copy_sha1_action)
444 menu.addAction(self.external_diff_action)
445 menu.exec_(self.mapToGlobal(event.pos()))
448 class ComboBox(QtWidgets.QComboBox):
449 validate = Signal()
452 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
454 ENABLED_COLUMN = 1
455 COMMAND_COLUMN = 2
456 SHA1LEN = 7
458 def __init__(self, idx, enabled, command,
459 sha1hex='', summary='', cmdexec='', comment_char='#',
460 parent=None):
461 QtWidgets.QTreeWidgetItem.__init__(self, parent)
462 self.combo = None
463 self.command = command
464 self.idx = idx
465 self.sha1hex = sha1hex
466 self.summary = summary
467 self.cmdexec = cmdexec
468 self.comment_char = comment_char
470 # if core.abbrev is set to a higher value then we will notice by
471 # simply tracking the longest sha1 we've seen
472 sha1len = self.__class__.SHA1LEN
473 sha1len = self.__class__.SHA1LEN = max(len(sha1hex), sha1len)
475 self.setText(0, '%02d' % idx)
476 self.set_enabled(enabled)
477 # checkbox on 1
478 # combo box on 2
479 if self.is_exec():
480 self.setText(3, '')
481 self.setText(4, cmdexec)
482 else:
483 self.setText(3, sha1hex)
484 self.setText(4, summary)
486 flags = self.flags() | Qt.ItemIsUserCheckable
487 flags = flags | Qt.ItemIsDragEnabled
488 flags = flags & ~Qt.ItemIsDropEnabled
489 self.setFlags(flags)
491 def copy(self):
492 return self.__class__(self.idx, self.is_enabled(), self.command,
493 sha1hex=self.sha1hex, summary=self.summary,
494 cmdexec=self.cmdexec)
496 def decorate(self, parent):
497 if self.is_exec():
498 items = [EXEC]
499 idx = 0
500 else:
501 items = COMMANDS
502 idx = COMMAND_IDX[self.command]
503 combo = self.combo = ComboBox()
504 combo.setEditable(False)
505 combo.addItems(items)
506 combo.setCurrentIndex(idx)
507 combo.setEnabled(self.is_commit())
509 signal = combo.currentIndexChanged['QString']
510 signal.connect(self.set_command_and_validate)
511 combo.validate.connect(parent.validate)
513 parent.setItemWidget(self, self.COMMAND_COLUMN, combo)
515 def is_exec(self):
516 return self.command == EXEC
518 def is_commit(self):
519 return bool(self.command != EXEC and self.sha1hex and self.summary)
521 def value(self):
522 """Return the serialized representation of an item"""
523 if self.is_enabled():
524 comment = ''
525 else:
526 comment = self.comment_char + ' '
527 if self.is_exec():
528 return ('%s%s %s' % (comment, self.command, self.cmdexec))
529 return ('%s%s %s %s' %
530 (comment, self.command, self.sha1hex, self.summary))
532 def is_enabled(self):
533 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
535 def set_enabled(self, enabled):
536 self.setCheckState(self.ENABLED_COLUMN,
537 enabled and Qt.Checked or Qt.Unchecked)
539 def toggle_enabled(self):
540 self.set_enabled(not self.is_enabled())
542 def set_command(self, command):
543 """Set the item to a different command, no-op for exec items"""
544 if self.is_exec():
545 return
546 self.command = command
548 def refresh(self):
549 """Update the view to match the updated state"""
550 command = self.command
551 self.combo.setCurrentIndex(COMMAND_IDX[command])
553 def reset_command(self, command):
554 """Set and refresh the item in one shot"""
555 self.set_command(command)
556 self.refresh()
558 def set_command_and_validate(self, command):
559 self.set_command(command)
560 self.combo.validate.emit()
563 def show_help():
564 help_text = N_("""
565 Commands
566 --------
567 pick = use commit
568 reword = use commit, but edit the commit message
569 edit = use commit, but stop for amending
570 squash = use commit, but meld into previous commit
571 fixup = like "squash", but discard this commit's log message
572 exec = run command (the rest of the line) using shell
574 These lines can be re-ordered; they are executed from top to bottom.
576 If you disable a line here THAT COMMIT WILL BE LOST.
578 However, if you disable everything, the rebase will be aborted.
580 Keyboard Shortcuts
581 ------------------
582 ? = show help
583 j = move down
584 k = move up
585 J = shift row down
586 K = shift row up
588 1, p = pick
589 2, r = reword
590 3, e = edit
591 4, f = fixup
592 5, s = squash
593 spacebar = toggle enabled
595 ctrl+enter = accept changes and rebase
596 ctrl+q = cancel and abort the rebase
597 ctrl+d = launch external diff
598 """)
599 title = N_('Help - git-xbase')
600 return text.text_dialog(help_text, title)
603 if __name__ == '__main__':
604 sys.exit(main())