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 for action
in self
.toggle_remark_actions
:
629 menu_toggle_remark
.addAction(action
)
630 menu
.exec_(self
.mapToGlobal(event
.pos()))
633 class ComboBox(QtWidgets
.QComboBox
):
637 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
655 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
657 self
.command
= command
660 self
.summary
= summary
661 self
.cmdexec
= cmdexec
663 self
.comment_char
= comment_char
665 # if core.abbrev is set to a higher value then we will notice by
666 # simply tracking the longest oid we've seen
667 oid_len
= self
.__class
__.OID_LENGTH
668 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
670 self
.setText(0, '%02d' % idx
)
671 self
.set_enabled(enabled
)
676 self
.setText(5, cmdexec
)
677 elif self
.is_update_ref():
679 self
.setText(5, branch
)
682 self
.setText(5, summary
)
684 self
.set_remarks(remarks
)
686 flags
= self
.flags() | Qt
.ItemIsUserCheckable
687 flags
= flags | Qt
.ItemIsDragEnabled
688 flags
= flags
& ~Qt
.ItemIsDropEnabled
691 def __eq__(self
, other
):
698 return self
.__class
__(
703 summary
=self
.summary
,
704 cmdexec
=self
.cmdexec
,
706 comment_char
=self
.comment_char
,
707 remarks
=self
.remarks
,
710 def decorate(self
, parent
):
714 elif self
.is_update_ref():
719 idx
= COMMAND_IDX
[self
.command
]
720 combo
= self
.combo
= ComboBox()
721 combo
.setEditable(False)
722 combo
.addItems(items
)
723 combo
.setCurrentIndex(idx
)
724 combo
.setEnabled(self
.is_commit())
726 signal
= combo
.currentIndexChanged
727 # pylint: disable=no-member
728 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
729 combo
.validate
.connect(parent
.validate
)
731 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
734 return self
.command
== EXEC
736 def is_update_ref(self
):
737 return self
.command
== UPDATE_REF
741 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
745 """Return the serialized representation of an item"""
746 if self
.is_enabled():
749 comment
= self
.comment_char
+ ' '
751 return f
'{comment}{self.command} {self.cmdexec}'
752 if self
.is_update_ref():
753 return f
'{comment}{self.command} {self.branch}'
754 return f
'{comment}{self.command} {self.oid} {self.summary}'
756 def is_enabled(self
):
757 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
759 def set_enabled(self
, enabled
):
760 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
762 def toggle_enabled(self
):
763 self
.set_enabled(not self
.is_enabled())
765 def add_remark(self
, remark
):
766 self
.set_remarks(tuple(sorted(set(self
.remarks
+ (remark
,)))))
768 def remove_remark(self
, remark
):
769 self
.set_remarks(tuple(r
for r
in self
.remarks
if r
!= remark
))
771 def set_remarks(self
, remarks
):
772 self
.remarks
= remarks
773 self
.setText(4, ''.join(remarks
))
775 def set_command(self
, command
):
776 """Set the item to a different command, no-op for exec items"""
779 self
.command
= command
782 """Update the view to match the updated state"""
784 command
= self
.command
785 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
787 def reset_command(self
, command
):
788 """Set and refresh the item in one shot"""
789 self
.set_command(command
)
792 def set_command_and_validate(self
, combo
):
793 command
= COMMANDS
[combo
.currentIndex()]
794 self
.set_command(command
)
795 self
.combo
.validate
.emit()
798 def show_help(context
):
804 reword = use commit, but edit the commit message
805 edit = use commit, but stop for amending
806 squash = use commit, but meld into previous commit
807 fixup = like "squash", but discard this commit's log message
808 exec = run command (the rest of the line) using shell
809 update-ref = update branches that point to commits
811 These lines can be re-ordered; they are executed from top to bottom.
813 If you disable a line here THAT COMMIT WILL BE LOST.
815 However, if you disable everything, the rebase will be aborted.
830 spacebar = toggle enabled
832 ctrl+enter = accept changes and rebase
833 ctrl+q = cancel and abort the rebase
834 ctrl+d = launch difftool
837 title
= N_('Help - git-cola-sequence-editor')
838 return text
.text_dialog(context
, help_text
, title
)