3 from argparse
import ArgumentParser
4 from functools
import partial
6 from cola
import app
# prints a message if Qt cannot be found
8 from qtpy
import QtWidgets
9 from qtpy
.QtCore
import Qt
10 from qtpy
.QtCore
import Signal
12 # pylint: disable=ungrouped-imports
14 from cola
import difftool
15 from cola
import gitcmds
16 from cola
import hotkeys
17 from cola
import icons
18 from cola
import qtutils
19 from cola
import utils
20 from cola
.i18n
import N_
21 from cola
.models
import dag
22 from cola
.models
import prefs
23 from cola
.widgets
import defs
24 from cola
.widgets
import filelist
25 from cola
.widgets
import diff
26 from cola
.widgets
import standard
27 from cola
.widgets
import text
35 UPDATE_REF
= 'update-ref'
44 COMMAND_IDX
= {cmd_
: idx_
for idx_
, cmd_
in enumerate(COMMANDS
)}
57 """Start a git-cola-sequence-editor session"""
59 context
= app
.application_init(args
)
60 view
= new_window(context
, args
.filename
)
61 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
65 def stop(context
, _view
):
66 """All done, cleanup"""
67 context
.runtask
.wait()
71 parser
= ArgumentParser()
73 'filename', metavar
='<filename>', help='git-rebase-todo file to edit'
75 app
.add_common_arguments(parser
)
76 return parser
.parse_args()
79 def new_window(context
, filename
):
80 window
= MainWindow(context
)
81 editor
= Editor(context
, filename
, parent
=window
)
82 window
.set_editor(editor
)
87 """Expand shorthand commands into their full name"""
88 return ABBREV
.get(cmd
, cmd
)
91 class MainWindow(standard
.MainWindow
):
92 """The main git-cola application window"""
94 def __init__(self
, context
, parent
=None):
95 super().__init
__(parent
)
96 self
.context
= context
98 # If user closed the window without confirmation it's considered cancelled.
99 self
.cancelled
= False
101 default_title
= '%s - git cola sequence editor' % core
.getcwd()
102 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
103 self
.setWindowTitle(title
)
104 self
.show_help_action
= qtutils
.add_action(
105 self
, N_('Show Help'), partial(show_help
, context
), hotkeys
.QUESTION
107 self
.menubar
= QtWidgets
.QMenuBar(self
)
108 self
.help_menu
= self
.menubar
.addMenu(N_('Help'))
109 self
.help_menu
.addAction(self
.show_help_action
)
110 self
.setMenuBar(self
.menubar
)
112 qtutils
.add_close_action(self
)
113 self
.init_state(context
.settings
, self
.init_window_size
)
115 def init_window_size(self
):
116 """Set the window size on the first initial view"""
117 if utils
.is_darwin():
118 width
, height
= qtutils
.desktop_size()
119 self
.resize(width
, height
)
123 def set_editor(self
, editor
):
125 self
.setCentralWidget(editor
)
126 editor
.cancel
.connect(self
.cancel
)
127 editor
.rebase
.connect(self
.rebase
)
130 def start(self
, _context
, _view
):
134 self
.cancelled
= True
138 self
.cancelled
= False
141 def closeEvent(self
, event
):
144 cancel_action
= core
.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
146 if cancel_action
== 'save':
147 status
= self
.editor
.save('')
151 status
= self
.editor
.save()
153 stop(self
.context
, self
)
155 super().closeEvent(event
)
158 class Editor(QtWidgets
.QWidget
):
162 def __init__(self
, context
, filename
, parent
=None):
163 super().__init
__(parent
)
165 self
.widget_version
= 1
166 self
.context
= context
167 self
.filename
= filename
168 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
170 self
.diff
= diff
.DiffWidget(context
, self
)
171 self
.tree
= RebaseTreeWidget(context
, comment_char
, self
)
172 self
.filewidget
= filelist
.FileWidget(context
, self
, remarks
=True)
173 self
.setFocusProxy(self
.tree
)
175 self
.rebase_button
= qtutils
.create_button(
176 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
177 tooltip
=N_('Accept changes and rebase\nShortcut: Ctrl+Enter'),
182 self
.extdiff_button
= qtutils
.create_button(
183 text
=N_('Launch Diff Tool'),
184 tooltip
=N_('Launch external diff tool\nShortcut: Ctrl+D'),
186 self
.extdiff_button
.setEnabled(False)
188 self
.help_button
= qtutils
.create_button(
189 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
192 self
.cancel_button
= qtutils
.create_button(
194 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
198 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
199 top
.setSizes([75, 25])
201 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
202 main_split
.setSizes([25, 75])
204 controls_layout
= qtutils
.hbox(
213 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
214 self
.setLayout(layout
)
216 self
.action_rebase
= qtutils
.add_action(
224 self
.tree
.commits_selected
.connect(self
.commits_selected
)
225 self
.tree
.commits_selected
.connect(self
.filewidget
.commits_selected
)
226 self
.tree
.commits_selected
.connect(self
.diff
.commits_selected
)
227 self
.tree
.external_diff
.connect(self
.external_diff
)
229 self
.filewidget
.files_selected
.connect(self
.diff
.files_selected
)
230 self
.filewidget
.remark_toggled
.connect(self
.remark_toggled_for_files
)
232 # `git` calls are expensive. When user toggles a remark of all commits touching
233 # selected paths the GUI freezes for a while on a big enough sequence. This
234 # cache is used (commit ID to paths tuple) to minimize calls to git.
235 self
.oid_to_paths
= {}
236 self
.task
= None # A task fills the cache in the background.
237 self
.running
= False # This flag stops it.
239 qtutils
.connect_button(self
.rebase_button
, self
.rebase
.emit
)
240 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
241 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
242 qtutils
.connect_button(self
.cancel_button
, self
.cancel
.emit
)
245 insns
= core
.read(self
.filename
)
246 self
.parse_sequencer_instructions(insns
)
248 # Assume that the tree is filled at this point.
250 self
.task
= qtutils
.SimpleTask(self
.calculate_oid_to_paths
)
251 self
.context
.runtask
.start(self
.task
)
257 def commits_selected(self
, commits
):
258 self
.extdiff_button
.setEnabled(bool(commits
))
260 def remark_toggled_for_files(self
, remark
, filenames
):
261 filenames
= set(filenames
)
263 items
= self
.tree
.items()
267 if not item
.is_commit():
270 paths
= self
.paths_touched_by_oid(oid
)
271 if filenames
.intersection(paths
):
272 touching_items
.append(item
)
274 self
.tree
.toggle_remark_of_items(remark
, touching_items
)
276 def external_diff(self
):
277 items
= self
.tree
.selected_items()
281 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
285 def paths_touched_by_oid(self
, oid
):
287 return self
.oid_to_paths
[oid
]
291 paths
= gitcmds
.changed_files(self
.context
, oid
)
292 self
.oid_to_paths
[oid
] = paths
296 def calculate_oid_to_paths(self
):
297 """Fills the oid_to_paths cache in the background"""
298 for item
in self
.tree
.items():
301 self
.paths_touched_by_oid(item
.oid
)
303 def parse_sequencer_instructions(self
, insns
):
305 re_comment_char
= re
.escape(self
.comment_char
)
306 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
307 update_ref_rgx
= re
.compile(
308 r
'^\s*(%s)?\s*(u|update-ref)\s+(.+)$' % re_comment_char
310 # The upper bound of 40 below must match git.OID_LENGTH.
311 # We'll have to update this to the new hash length when that happens.
312 pick_rgx
= re
.compile(
315 + r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
316 + r
'\s+([0-9a-f]{7,40})'
321 for line
in insns
.splitlines():
322 match
= pick_rgx
.match(line
)
324 enabled
= match
.group(1) is None
325 command
= unabbrev(match
.group(2))
327 summary
= match
.group(4)
328 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
331 match
= exec_rgx
.match(line
)
333 enabled
= match
.group(1) is None
334 command
= unabbrev(match
.group(2))
335 cmdexec
= match
.group(3)
336 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
339 match
= update_ref_rgx
.match(line
)
341 enabled
= match
.group(1) is None
342 command
= unabbrev(match
.group(2))
343 branch
= match
.group(3)
344 self
.tree
.add_item(idx
, enabled
, command
, branch
=branch
)
348 self
.tree
.decorate(self
.tree
.items())
350 self
.tree
.select_first()
352 def save(self
, string
=None):
353 """Save the instruction sheet"""
356 lines
= [item
.value() for item
in self
.tree
.items()]
357 # sequencer instructions
358 string
= '\n'.join(lines
) + '\n'
361 core
.write(self
.filename
, string
)
363 except (OSError, ValueError) as exc
:
364 msg
, details
= utils
.format_exception(exc
)
365 sys
.stderr
.write(msg
+ '\n\n' + details
)
370 # pylint: disable=too-many-ancestors
371 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
372 commits_selected
= Signal(object)
373 external_diff
= Signal()
374 move_rows
= Signal(object, object)
376 def __init__(self
, context
, comment_char
, parent
):
377 super().__init
__(parent
=parent
)
378 self
.context
= context
379 self
.comment_char
= comment_char
381 self
.setHeaderLabels([
389 self
.header().setStretchLastSection(True)
390 self
.setColumnCount(6)
391 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
394 self
.copy_oid_action
= qtutils
.add_action(
395 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
398 self
.external_diff_action
= qtutils
.add_action(
399 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
402 self
.toggle_enabled_action
= qtutils
.add_action(
403 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
406 self
.action_pick
= qtutils
.add_action(
407 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
410 self
.action_reword
= qtutils
.add_action(
413 lambda: self
.set_selected_to(REWORD
),
414 *hotkeys
.REBASE_REWORD
,
417 self
.action_edit
= qtutils
.add_action(
418 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
421 self
.action_fixup
= qtutils
.add_action(
424 lambda: self
.set_selected_to(FIXUP
),
425 *hotkeys
.REBASE_FIXUP
,
428 self
.action_squash
= qtutils
.add_action(
431 lambda: self
.set_selected_to(SQUASH
),
432 *hotkeys
.REBASE_SQUASH
,
435 self
.action_shift_down
= qtutils
.add_action(
436 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
439 self
.action_shift_up
= qtutils
.add_action(
440 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
443 self
.toggle_remark_actions
= tuple(
447 lambda remark
=r
: self
.toggle_remark(remark
),
448 hotkeys
.hotkey(Qt
.CTRL |
getattr(Qt
, 'Key_' + r
)),
450 for r
in map(str, range(10))
453 # pylint: disable=no-member
454 self
.itemChanged
.connect(self
.item_changed
)
455 self
.itemSelectionChanged
.connect(self
.selection_changed
)
456 self
.move_rows
.connect(self
.move
)
457 self
.items_moved
.connect(self
.decorate
)
460 self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
='', branch
=''
462 comment_char
= self
.comment_char
463 item
= RebaseTreeWidgetItem(
471 comment_char
=comment_char
,
473 self
.invisibleRootItem().addChild(item
)
475 def decorate(self
, items
):
480 self
.resizeColumnToContents(0)
481 self
.resizeColumnToContents(1)
482 self
.resizeColumnToContents(2)
483 self
.resizeColumnToContents(3)
484 self
.resizeColumnToContents(4)
485 self
.resizeColumnToContents(5)
488 def item_changed(self
, item
, column
):
489 if column
== item
.ENABLED_COLUMN
:
493 invalid_first_choice
= {FIXUP
, SQUASH
}
494 for item
in self
.items():
495 if item
.is_enabled() and item
.is_commit():
496 if item
.command
in invalid_first_choice
:
497 item
.reset_command(PICK
)
500 def set_selected_to(self
, command
):
501 for i
in self
.selected_items():
502 i
.reset_command(command
)
505 def set_command(self
, item
, command
):
506 item
.reset_command(command
)
510 item
= self
.selected_item()
513 clipboard
= item
.oid
or item
.cmdexec
514 qtutils
.set_clipboard(clipboard
)
516 def selection_changed(self
):
517 item
= self
.selected_item()
518 if item
is None or not item
.is_commit():
520 context
= self
.context
522 params
= dag
.DAG(oid
, 2)
523 repo
= dag
.RepoReader(context
, params
)
525 for commit
in repo
.get():
526 commits
.append(commit
)
528 commits
= commits
[-1:]
529 self
.commits_selected
.emit(commits
)
531 def toggle_enabled(self
):
532 items
= self
.selected_items()
533 logic_or
= reduce(lambda res
, item
: res
or item
.is_enabled(), items
, False)
535 item
.set_enabled(not logic_or
)
537 def select_first(self
):
541 idx
= self
.model().index(0, 0)
543 self
.setCurrentIndex(idx
)
545 def shift_down(self
):
546 sel_items
= self
.selected_items()
547 all_items
= self
.items()
548 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
553 idx
> len(all_items
) - len(sel_items
)
554 or all_items
[sel_idx
[-1]] is all_items
[-1]
556 self
.move_rows
.emit(sel_idx
, idx
)
559 sel_items
= self
.selected_items()
560 all_items
= self
.items()
561 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
566 self
.move_rows
.emit(sel_idx
, idx
)
568 def toggle_remark(self
, remark
):
569 items
= self
.selected_items()
570 self
.toggle_remark_of_items(remark
, items
)
572 def toggle_remark_of_items(self
, remark
, items
):
573 logic_or
= reduce(lambda res
, item
: res
or remark
in item
.remarks
, items
, False)
576 item
.remove_remark(remark
)
579 item
.add_remark(remark
)
581 def move(self
, src_idxs
, dst_idx
):
583 src_base
= sorted(src_idxs
)[0]
584 for idx
in reversed(sorted(src_idxs
)):
585 item
= self
.invisibleRootItem().takeChild(idx
)
586 moved_items
.insert(0, [dst_idx
+ (idx
- src_base
), item
])
588 for item
in moved_items
:
589 self
.invisibleRootItem().insertChild(item
[0], item
[1])
590 self
.setCurrentItem(item
[1])
593 moved_items
= [item
[1] for item
in moved_items
]
594 # If we've moved to the top then we need to re-decorate all items.
595 # Otherwise, we can decorate just the new items.
597 self
.decorate(self
.items())
599 self
.decorate(moved_items
)
601 for item
in moved_items
:
602 item
.setSelected(True)
607 def dropEvent(self
, event
):
608 super().dropEvent(event
)
611 def contextMenuEvent(self
, event
):
612 items
= self
.selected_items()
613 menu
= qtutils
.create_menu(N_('Actions'), self
)
614 menu
.addAction(self
.action_pick
)
615 menu
.addAction(self
.action_reword
)
616 menu
.addAction(self
.action_edit
)
617 menu
.addAction(self
.action_fixup
)
618 menu
.addAction(self
.action_squash
)
620 menu
.addAction(self
.toggle_enabled_action
)
622 menu
.addAction(self
.copy_oid_action
)
623 self
.copy_oid_action
.setDisabled(len(items
) > 1)
624 menu
.addAction(self
.external_diff_action
)
625 self
.external_diff_action
.setDisabled(len(items
) > 1)
627 menu_toggle_remark
= menu
.addMenu(N_('Toggle remark'))
628 tuple(map(menu_toggle_remark
.addAction
, self
.toggle_remark_actions
))
629 menu
.exec_(self
.mapToGlobal(event
.pos()))
632 class ComboBox(QtWidgets
.QComboBox
):
636 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
654 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
656 self
.command
= command
659 self
.summary
= summary
660 self
.cmdexec
= cmdexec
662 self
.comment_char
= comment_char
664 # if core.abbrev is set to a higher value then we will notice by
665 # simply tracking the longest oid we've seen
666 oid_len
= self
.__class
__.OID_LENGTH
667 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
669 self
.setText(0, '%02d' % idx
)
670 self
.set_enabled(enabled
)
675 self
.setText(5, cmdexec
)
676 elif self
.is_update_ref():
678 self
.setText(5, branch
)
681 self
.setText(5, summary
)
683 self
.set_remarks(remarks
)
685 flags
= self
.flags() | Qt
.ItemIsUserCheckable
686 flags
= flags | Qt
.ItemIsDragEnabled
687 flags
= flags
& ~Qt
.ItemIsDropEnabled
690 def __eq__(self
, other
):
697 return self
.__class
__(
702 summary
=self
.summary
,
703 cmdexec
=self
.cmdexec
,
705 comment_char
=self
.comment_char
,
706 remarks
=self
.remarks
,
709 def decorate(self
, parent
):
713 elif self
.is_update_ref():
718 idx
= COMMAND_IDX
[self
.command
]
719 combo
= self
.combo
= ComboBox()
720 combo
.setEditable(False)
721 combo
.addItems(items
)
722 combo
.setCurrentIndex(idx
)
723 combo
.setEnabled(self
.is_commit())
725 signal
= combo
.currentIndexChanged
726 # pylint: disable=no-member
727 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
728 combo
.validate
.connect(parent
.validate
)
730 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
733 return self
.command
== EXEC
735 def is_update_ref(self
):
736 return self
.command
== UPDATE_REF
740 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
744 """Return the serialized representation of an item"""
745 if self
.is_enabled():
748 comment
= self
.comment_char
+ ' '
750 return f
'{comment}{self.command} {self.cmdexec}'
751 if self
.is_update_ref():
752 return f
'{comment}{self.command} {self.branch}'
753 return f
'{comment}{self.command} {self.oid} {self.summary}'
755 def is_enabled(self
):
756 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
758 def set_enabled(self
, enabled
):
759 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
761 def toggle_enabled(self
):
762 self
.set_enabled(not self
.is_enabled())
764 def add_remark(self
, remark
):
765 self
.set_remarks(tuple(sorted(set(self
.remarks
+ (remark
,)))))
767 def remove_remark(self
, remark
):
768 self
.set_remarks(tuple(r
for r
in self
.remarks
if r
!= remark
))
770 def set_remarks(self
, remarks
):
771 self
.remarks
= remarks
772 self
.setText(4, ''.join(remarks
))
774 def set_command(self
, command
):
775 """Set the item to a different command, no-op for exec items"""
778 self
.command
= command
781 """Update the view to match the updated state"""
783 command
= self
.command
784 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
786 def reset_command(self
, command
):
787 """Set and refresh the item in one shot"""
788 self
.set_command(command
)
791 def set_command_and_validate(self
, combo
):
792 command
= COMMANDS
[combo
.currentIndex()]
793 self
.set_command(command
)
794 self
.combo
.validate
.emit()
797 def show_help(context
):
803 reword = use commit, but edit the commit message
804 edit = use commit, but stop for amending
805 squash = use commit, but meld into previous commit
806 fixup = like "squash", but discard this commit's log message
807 exec = run command (the rest of the line) using shell
808 update-ref = update branches that point to commits
810 These lines can be re-ordered; they are executed from top to bottom.
812 If you disable a line here THAT COMMIT WILL BE LOST.
814 However, if you disable everything, the rebase will be aborted.
829 spacebar = toggle enabled
831 ctrl+enter = accept changes and rebase
832 ctrl+q = cancel and abort the rebase
833 ctrl+d = launch difftool
836 title
= N_('Help - git-cola-sequence-editor')
837 return text
.text_dialog(context
, help_text
, title
)