3 from argparse
import ArgumentParser
4 from functools
import partial
, reduce
6 from cola
import app
# prints a message if Qt cannot be found
7 from qtpy
import QtCore
9 from qtpy
import QtWidgets
10 from qtpy
.QtCore
import Qt
11 from qtpy
.QtCore
import Signal
13 # pylint: disable=ungrouped-imports
15 from cola
import difftool
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 QtCore
.QThreadPool
.globalInstance().waitForDone()
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
99 default_title
= '%s - git cola sequence editor' % core
.getcwd()
100 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
101 self
.setWindowTitle(title
)
103 self
.show_help_action
= qtutils
.add_action(
104 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
.exit
.connect(self
.exit
)
129 def start(self
, _context
, _view
):
132 def exit(self
, status
):
137 class Editor(QtWidgets
.QWidget
):
140 def __init__(self
, context
, filename
, parent
=None):
141 super().__init
__(parent
)
143 self
.widget_version
= 1
145 self
.context
= context
146 self
.filename
= filename
147 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
148 self
.cancel_action
= core
.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
150 self
.diff
= diff
.DiffWidget(context
, self
)
151 self
.tree
= RebaseTreeWidget(context
, comment_char
, self
)
152 self
.filewidget
= filelist
.FileWidget(context
, self
, remarks
=True)
153 self
.setFocusProxy(self
.tree
)
155 self
.rebase_button
= qtutils
.create_button(
156 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
157 tooltip
=N_('Accept changes and rebase\nShortcut: Ctrl+Enter'),
162 self
.extdiff_button
= qtutils
.create_button(
163 text
=N_('Launch Diff Tool'),
164 tooltip
=N_('Launch external diff tool\nShortcut: Ctrl+D'),
166 self
.extdiff_button
.setEnabled(False)
168 self
.help_button
= qtutils
.create_button(
169 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
172 self
.cancel_button
= qtutils
.create_button(
174 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
178 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
179 top
.setSizes([75, 25])
181 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
182 main_split
.setSizes([25, 75])
184 controls_layout
= qtutils
.hbox(
193 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
194 self
.setLayout(layout
)
196 self
.action_rebase
= qtutils
.add_action(
197 self
, N_('Rebase'), self
.rebase
, hotkeys
.CTRL_RETURN
, hotkeys
.CTRL_ENTER
200 self
.tree
.commits_selected
.connect(self
.commits_selected
)
201 self
.tree
.commits_selected
.connect(self
.filewidget
.commits_selected
)
202 self
.tree
.commits_selected
.connect(self
.diff
.commits_selected
)
203 self
.tree
.external_diff
.connect(self
.external_diff
)
205 self
.filewidget
.files_selected
.connect(self
.diff
.files_selected
)
206 self
.filewidget
.remark_toggled
.connect(self
.remark_toggled_for_files
)
208 qtutils
.connect_button(self
.rebase_button
, self
.rebase
)
209 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
210 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
211 qtutils
.connect_button(self
.cancel_button
, self
.cancel
)
214 insns
= core
.read(self
.filename
)
215 self
.parse_sequencer_instructions(insns
)
218 def commits_selected(self
, commits
):
219 self
.extdiff_button
.setEnabled(bool(commits
))
221 def remark_toggled_for_files(self
, remark
, filenames
):
222 filenames
= set(filenames
)
224 items
= self
.tree
.items()
227 git
= self
.context
.git
230 if not item
.is_commit():
234 status
, out
, _
= git
.show(
235 oid
, z
=True, numstat
=True, oneline
=True, no_renames
=True
239 paths
= [f
for f
in out
.rstrip('\0').split('\0') if f
]
241 # Skip over the summary on the first line.
244 # Drop numbers. Only path is needed.
245 paths
= [f
.split()[-1] for f
in paths
]
247 if filenames
.intersection(paths
):
248 touching_items
.append(item
)
250 self
.tree
.toggle_remark_of_items(remark
, touching_items
)
253 def parse_sequencer_instructions(self
, insns
):
255 re_comment_char
= re
.escape(self
.comment_char
)
256 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
257 update_ref_rgx
= re
.compile(
258 r
'^\s*(%s)?\s*(u|update-ref)\s+(.+)$' % re_comment_char
260 # The upper bound of 40 below must match git.OID_LENGTH.
261 # We'll have to update this to the new hash length when that happens.
262 pick_rgx
= re
.compile(
265 + r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
266 + r
'\s+([0-9a-f]{7,40})'
271 for line
in insns
.splitlines():
272 match
= pick_rgx
.match(line
)
274 enabled
= match
.group(1) is None
275 command
= unabbrev(match
.group(2))
277 summary
= match
.group(4)
278 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
281 match
= exec_rgx
.match(line
)
283 enabled
= match
.group(1) is None
284 command
= unabbrev(match
.group(2))
285 cmdexec
= match
.group(3)
286 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
289 match
= update_ref_rgx
.match(line
)
291 enabled
= match
.group(1) is None
292 command
= unabbrev(match
.group(2))
293 branch
= match
.group(3)
294 self
.tree
.add_item(idx
, enabled
, command
, branch
=branch
)
298 self
.tree
.decorate(self
.tree
.items())
300 self
.tree
.select_first()
304 if self
.cancel_action
== 'save':
305 status
= self
.save('')
310 self
.exit
.emit(status
)
313 lines
= [item
.value() for item
in self
.tree
.items()]
314 sequencer_instructions
= '\n'.join(lines
) + '\n'
315 status
= self
.save(sequencer_instructions
)
317 self
.exit
.emit(status
)
319 def save(self
, string
):
320 """Save the instruction sheet"""
322 core
.write(self
.filename
, string
)
324 except (OSError, ValueError) as exc
:
325 msg
, details
= utils
.format_exception(exc
)
326 sys
.stderr
.write(msg
+ '\n\n' + details
)
330 def external_diff(self
):
331 items
= self
.tree
.selected_items()
335 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
338 # pylint: disable=too-many-ancestors
339 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
340 commits_selected
= Signal(object)
341 external_diff
= Signal()
342 move_rows
= Signal(object, object)
344 def __init__(self
, context
, comment_char
, parent
):
345 super().__init
__(parent
=parent
)
346 self
.context
= context
347 self
.comment_char
= comment_char
349 self
.setHeaderLabels([
357 self
.header().setStretchLastSection(True)
358 self
.setColumnCount(6)
359 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
362 self
.copy_oid_action
= qtutils
.add_action(
363 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
366 self
.external_diff_action
= qtutils
.add_action(
367 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
370 self
.toggle_enabled_action
= qtutils
.add_action(
371 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
374 self
.action_pick
= qtutils
.add_action(
375 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
378 self
.action_reword
= qtutils
.add_action(
381 lambda: self
.set_selected_to(REWORD
),
382 *hotkeys
.REBASE_REWORD
,
385 self
.action_edit
= qtutils
.add_action(
386 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
389 self
.action_fixup
= qtutils
.add_action(
392 lambda: self
.set_selected_to(FIXUP
),
393 *hotkeys
.REBASE_FIXUP
,
396 self
.action_squash
= qtutils
.add_action(
399 lambda: self
.set_selected_to(SQUASH
),
400 *hotkeys
.REBASE_SQUASH
,
403 self
.action_shift_down
= qtutils
.add_action(
404 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
407 self
.action_shift_up
= qtutils
.add_action(
408 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
411 self
.toggle_remark_actions
= tuple(
415 lambda remark
= r
: self
.toggle_remark(remark
),
416 hotkeys
.hotkey(Qt
.CTRL |
getattr(Qt
, "Key_" + r
))
417 ) for r
in map(str, range(10))
420 # pylint: disable=no-member
421 self
.itemChanged
.connect(self
.item_changed
)
422 self
.itemSelectionChanged
.connect(self
.selection_changed
)
423 self
.move_rows
.connect(self
.move
)
424 self
.items_moved
.connect(self
.decorate
)
427 self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
='', branch
=''
429 comment_char
= self
.comment_char
430 item
= RebaseTreeWidgetItem(
438 comment_char
=comment_char
,
440 self
.invisibleRootItem().addChild(item
)
442 def decorate(self
, items
):
447 self
.resizeColumnToContents(0)
448 self
.resizeColumnToContents(1)
449 self
.resizeColumnToContents(2)
450 self
.resizeColumnToContents(3)
451 self
.resizeColumnToContents(4)
452 self
.resizeColumnToContents(5)
455 def item_changed(self
, item
, column
):
456 if column
== item
.ENABLED_COLUMN
:
460 invalid_first_choice
= {FIXUP
, SQUASH
}
461 for item
in self
.items():
462 if item
.is_enabled() and item
.is_commit():
463 if item
.command
in invalid_first_choice
:
464 item
.reset_command(PICK
)
467 def set_selected_to(self
, command
):
468 for i
in self
.selected_items():
469 i
.reset_command(command
)
472 def set_command(self
, item
, command
):
473 item
.reset_command(command
)
477 item
= self
.selected_item()
480 clipboard
= item
.oid
or item
.cmdexec
481 qtutils
.set_clipboard(clipboard
)
483 def selection_changed(self
):
484 item
= self
.selected_item()
485 if item
is None or not item
.is_commit():
487 context
= self
.context
489 params
= dag
.DAG(oid
, 2)
490 repo
= dag
.RepoReader(context
, params
)
492 for commit
in repo
.get():
493 commits
.append(commit
)
495 commits
= commits
[-1:]
496 self
.commits_selected
.emit(commits
)
498 def toggle_enabled(self
):
499 items
= self
.selected_items()
500 logic_or
= reduce(lambda res
, item
: res
or item
.is_enabled(), items
, False)
502 item
.set_enabled(not logic_or
)
504 def select_first(self
):
508 idx
= self
.model().index(0, 0)
510 self
.setCurrentIndex(idx
)
512 def shift_down(self
):
513 sel_items
= self
.selected_items()
514 all_items
= self
.items()
515 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
520 idx
> len(all_items
) - len(sel_items
)
521 or all_items
[sel_idx
[-1]] is all_items
[-1]
523 self
.move_rows
.emit(sel_idx
, idx
)
526 sel_items
= self
.selected_items()
527 all_items
= self
.items()
528 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
533 self
.move_rows
.emit(sel_idx
, idx
)
535 def toggle_remark(self
, remark
):
536 items
= self
.selected_items()
537 self
.toggle_remark_of_items(remark
, items
)
539 def toggle_remark_of_items(self
, remark
, items
):
541 lambda res
, item
: res
or remark
in item
.remarks
,
547 item
.remove_remark(remark
)
550 item
.add_remark(remark
)
552 def move(self
, src_idxs
, dst_idx
):
554 src_base
= sorted(src_idxs
)[0]
555 for idx
in reversed(sorted(src_idxs
)):
556 item
= self
.invisibleRootItem().takeChild(idx
)
557 moved_items
.insert(0, [dst_idx
+ (idx
- src_base
), item
])
559 for item
in moved_items
:
560 self
.invisibleRootItem().insertChild(item
[0], item
[1])
561 self
.setCurrentItem(item
[1])
564 moved_items
= [item
[1] for item
in moved_items
]
565 # If we've moved to the top then we need to re-decorate all items.
566 # Otherwise, we can decorate just the new items.
568 self
.decorate(self
.items())
570 self
.decorate(moved_items
)
572 for item
in moved_items
:
573 item
.setSelected(True)
578 def dropEvent(self
, event
):
579 super().dropEvent(event
)
582 def contextMenuEvent(self
, event
):
583 items
= self
.selected_items()
584 menu
= qtutils
.create_menu(N_('Actions'), self
)
585 menu
.addAction(self
.action_pick
)
586 menu
.addAction(self
.action_reword
)
587 menu
.addAction(self
.action_edit
)
588 menu
.addAction(self
.action_fixup
)
589 menu
.addAction(self
.action_squash
)
591 menu
.addAction(self
.toggle_enabled_action
)
593 menu
.addAction(self
.copy_oid_action
)
594 self
.copy_oid_action
.setDisabled(len(items
) > 1)
595 menu
.addAction(self
.external_diff_action
)
596 self
.external_diff_action
.setDisabled(len(items
) > 1)
598 menu_toggle_remark
= menu
.addMenu(N_('Toggle remark'))
599 tuple(map(menu_toggle_remark
.addAction
, self
.toggle_remark_actions
))
600 menu
.exec_(self
.mapToGlobal(event
.pos()))
603 class ComboBox(QtWidgets
.QComboBox
):
607 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
625 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
627 self
.command
= command
630 self
.summary
= summary
631 self
.cmdexec
= cmdexec
633 self
.comment_char
= comment_char
635 # if core.abbrev is set to a higher value then we will notice by
636 # simply tracking the longest oid we've seen
637 oid_len
= self
.__class
__.OID_LENGTH
638 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
640 self
.setText(0, '%02d' % idx
)
641 self
.set_enabled(enabled
)
646 self
.setText(5, cmdexec
)
647 elif self
.is_update_ref():
649 self
.setText(5, branch
)
652 self
.setText(5, summary
)
654 self
.set_remarks(remarks
)
656 flags
= self
.flags() | Qt
.ItemIsUserCheckable
657 flags
= flags | Qt
.ItemIsDragEnabled
658 flags
= flags
& ~Qt
.ItemIsDropEnabled
661 def __eq__(self
, other
):
668 return self
.__class
__(
673 summary
=self
.summary
,
674 cmdexec
=self
.cmdexec
,
676 comment_char
=self
.comment_char
,
677 remarks
=self
.remarks
,
680 def decorate(self
, parent
):
684 elif self
.is_update_ref():
689 idx
= COMMAND_IDX
[self
.command
]
690 combo
= self
.combo
= ComboBox()
691 combo
.setEditable(False)
692 combo
.addItems(items
)
693 combo
.setCurrentIndex(idx
)
694 combo
.setEnabled(self
.is_commit())
696 signal
= combo
.currentIndexChanged
697 # pylint: disable=no-member
698 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
699 combo
.validate
.connect(parent
.validate
)
701 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
704 return self
.command
== EXEC
706 def is_update_ref(self
):
707 return self
.command
== UPDATE_REF
711 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
715 """Return the serialized representation of an item"""
716 if self
.is_enabled():
719 comment
= self
.comment_char
+ ' '
721 return f
'{comment}{self.command} {self.cmdexec}'
722 if self
.is_update_ref():
723 return f
'{comment}{self.command} {self.branch}'
724 return f
'{comment}{self.command} {self.oid} {self.summary}'
726 def is_enabled(self
):
727 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
729 def set_enabled(self
, enabled
):
730 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
732 def toggle_enabled(self
):
733 self
.set_enabled(not self
.is_enabled())
735 def add_remark(self
, remark
):
736 self
.set_remarks(tuple(sorted(set(self
.remarks
+ (remark
,)))))
738 def remove_remark(self
, remark
):
739 self
.set_remarks(tuple(r
for r
in self
.remarks
if r
!= remark
))
741 def set_remarks(self
, remarks
):
742 self
.remarks
= remarks
743 self
.setText(4, "".join(remarks
))
745 def set_command(self
, command
):
746 """Set the item to a different command, no-op for exec items"""
749 self
.command
= command
752 """Update the view to match the updated state"""
754 command
= self
.command
755 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
757 def reset_command(self
, command
):
758 """Set and refresh the item in one shot"""
759 self
.set_command(command
)
762 def set_command_and_validate(self
, combo
):
763 command
= COMMANDS
[combo
.currentIndex()]
764 self
.set_command(command
)
765 self
.combo
.validate
.emit()
768 def show_help(context
):
774 reword = use commit, but edit the commit message
775 edit = use commit, but stop for amending
776 squash = use commit, but meld into previous commit
777 fixup = like "squash", but discard this commit's log message
778 exec = run command (the rest of the line) using shell
779 update-ref = update branches that point to commits
781 These lines can be re-ordered; they are executed from top to bottom.
783 If you disable a line here THAT COMMIT WILL BE LOST.
785 However, if you disable everything, the rebase will be aborted.
800 spacebar = toggle enabled
802 ctrl+enter = accept changes and rebase
803 ctrl+q = cancel and abort the rebase
804 ctrl+d = launch difftool
807 title
= N_('Help - git-cola-sequence-editor')
808 return text
.text_dialog(context
, help_text
, title
)