3 from __future__
import absolute_import
, division
, unicode_literals
7 from argparse
import ArgumentParser
8 from functools
import partial
11 def setup_environment():
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
):
23 elif path
.exists(unixpkgs
):
25 elif path
.exists(winpkgs
):
30 sys
.path
.insert(1, modules
)
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
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
64 COMMANDS
= (PICK
, REWORD
, EDIT
, FIXUP
, SQUASH
,)
65 COMMAND_IDX
= dict([(cmd_
, idx_
) for idx_
, cmd_
in enumerate(COMMANDS
)])
77 """Start a git-xbase session"""
79 context
= app
.application_init(args
)
80 view
= new_window(context
, args
.filename
)
81 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
85 def stop(_context
, _view
):
86 """All done, cleanup"""
87 QtCore
.QThreadPool
.globalInstance().waitForDone()
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
)
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
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
),
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())
142 def set_editor(self
, editor
):
144 self
.setCentralWidget(editor
)
145 editor
.exit
.connect(self
.exit
)
148 def start(self
, _context
, _view
):
151 def exit(self
, status
):
156 class Editor(QtWidgets
.QWidget
):
159 def __init__(self
, context
, filename
, parent
=None):
160 super(Editor
, self
).__init
__(parent
)
162 self
.widget_version
= 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'),
181 self
.extdiff_button
= qtutils
.create_button(
182 text
=N_('Launch Diff Tool'),
183 tooltip
=N_('Launch external diff tool\n'
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'),
195 splitter
= qtutils
.splitter(Qt
.Vertical
, self
.tree
, self
.diff
)
197 controls_layout
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
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
)
220 insns
= core
.read(self
.filename
)
221 self
.parse_sequencer_instructions(insns
)
224 def commits_selected(self
, commits
):
225 self
.extdiff_button
.setEnabled(bool(commits
))
228 def parse_sequencer_instructions(self
, insns
):
230 re_comment_char
= re
.escape(self
.comment_char
)
231 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$'
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
)
241 enabled
= match
.group(1) is None
242 command
= unabbrev(match
.group(2))
244 summary
= match
.group(4)
245 self
.tree
.add_item(idx
, enabled
, command
,
246 oid
=oid
, summary
=summary
)
249 match
= exec_rgx
.match(line
)
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
)
258 self
.tree
.decorate(self
.tree
.items())
260 self
.tree
.select_first()
264 if self
.cancel_action
== 'save':
265 status
= self
.save('')
270 self
.exit
.emit(status
)
273 lines
= [item
.value() for item
in self
.tree
.items()]
274 sequencer_instructions
= '\n'.join(lines
) + '\n'
275 status
= self
.save(sequencer_instructions
)
277 self
.exit
.emit(status
)
279 def save(self
, string
):
280 """Save the instruction sheet"""
282 core
.write(self
.filename
, string
)
284 except (OSError, IOError, ValueError) as e
:
285 msg
, details
= utils
.format_exception(e
)
286 sys
.stderr
.write(msg
+ '\n\n' + details
)
290 def external_diff(self
):
291 items
= self
.tree
.selected_items()
295 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!',
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
309 self
.setHeaderLabels([
316 self
.header().setStretchLastSection(True)
317 self
.setColumnCount(5)
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
,
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
):
376 self
.resizeColumnToContents(0)
377 self
.resizeColumnToContents(1)
378 self
.resizeColumnToContents(2)
379 self
.resizeColumnToContents(3)
380 self
.resizeColumnToContents(4)
383 def item_changed(self
, item
, column
):
384 if column
== item
.ENABLED_COLUMN
:
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
)
395 def set_selected_to(self
, command
):
396 for i
in self
.selected_items():
397 i
.reset_command(command
)
400 def set_command(self
, item
, command
):
401 item
.reset_command(command
)
405 item
= self
.selected_item()
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():
415 context
= self
.context
417 params
= dag
.DAG(oid
, 2)
418 repo
= dag
.RepoReader(context
, params
)
423 commits
= commits
[-1:]
424 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, commits
)
426 def toggle_enabled(self
):
427 item
= self
.selected_item()
430 item
.toggle_enabled()
432 def select_first(self
):
436 idx
= self
.model().index(0, 0)
438 self
.setCurrentIndex(idx
)
440 def shift_down(self
):
441 item
= self
.selected_item()
445 idx
= items
.index(item
)
446 if idx
< len(items
) - 1:
447 self
.move_rows
.emit([idx
], idx
+ 1)
450 item
= self
.selected_item()
454 idx
= items
.index(item
)
456 self
.move_rows
.emit([idx
], idx
- 1)
458 def move(self
, src_idxs
, dst_idx
):
461 for idx
in reversed(sorted(src_idxs
)):
462 item
= items
[idx
].copy()
463 self
.invisibleRootItem().takeChild(idx
)
464 new_items
.insert(0, item
)
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.
472 self
.decorate(self
.items())
474 self
.decorate(new_items
)
479 def dropEvent(self
, event
):
480 super(RebaseTreeWidget
, self
).dropEvent(event
)
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
)
491 menu
.addAction(self
.toggle_enabled_action
)
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
):
502 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
508 def __init__(self
, idx
, enabled
, command
,
509 oid
='', summary
='', cmdexec
='', comment_char
='#',
511 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
513 self
.command
= command
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
)
531 self
.setText(4, cmdexec
)
534 self
.setText(4, summary
)
536 flags
= self
.flags() | Qt
.ItemIsUserCheckable
537 flags
= flags | Qt
.ItemIsDragEnabled
538 flags
= flags
& ~Qt
.ItemIsDropEnabled
541 def __eq__(self
, other
):
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
):
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
)
572 return self
.command
== EXEC
575 return bool(self
.command
!= EXEC
and self
.oid
and self
.summary
)
578 """Return the serialized representation of an item"""
579 if self
.is_enabled():
582 comment
= self
.comment_char
+ ' '
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"""
602 self
.command
= command
605 """Update the view to match the updated state"""
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
)
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
):
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.
651 spacebar = toggle enabled
653 ctrl+enter = accept changes and rebase
654 ctrl+q = cancel and abort the rebase
655 ctrl+d = launch difftool
657 title
= N_('Help - git-xbase')
658 return text
.text_dialog(context
, help_text
, title
)
661 if __name__
== '__main__':