skilleter-thingy 0.3.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skilleter_thingy/__init__.py +0 -0
- skilleter_thingy/addpath.py +107 -0
- skilleter_thingy/console_colours.py +63 -0
- skilleter_thingy/ffind.py +535 -0
- skilleter_thingy/ggit.py +88 -0
- skilleter_thingy/ggrep.py +155 -0
- skilleter_thingy/git_br.py +186 -0
- skilleter_thingy/git_ca.py +147 -0
- skilleter_thingy/git_cleanup.py +297 -0
- skilleter_thingy/git_co.py +227 -0
- skilleter_thingy/git_common.py +68 -0
- skilleter_thingy/git_hold.py +162 -0
- skilleter_thingy/git_parent.py +84 -0
- skilleter_thingy/git_retag.py +67 -0
- skilleter_thingy/git_review.py +1450 -0
- skilleter_thingy/git_update.py +398 -0
- skilleter_thingy/git_wt.py +72 -0
- skilleter_thingy/gitcmp_helper.py +328 -0
- skilleter_thingy/gitprompt.py +293 -0
- skilleter_thingy/linecount.py +154 -0
- skilleter_thingy/multigit.py +915 -0
- skilleter_thingy/py_audit.py +133 -0
- skilleter_thingy/remdir.py +127 -0
- skilleter_thingy/rpylint.py +98 -0
- skilleter_thingy/strreplace.py +82 -0
- skilleter_thingy/test.py +34 -0
- skilleter_thingy/tfm.py +948 -0
- skilleter_thingy/tfparse.py +101 -0
- skilleter_thingy/trimpath.py +82 -0
- skilleter_thingy/venv_create.py +47 -0
- skilleter_thingy/venv_template.py +47 -0
- skilleter_thingy/xchmod.py +124 -0
- skilleter_thingy/yamlcheck.py +89 -0
- skilleter_thingy-0.3.14.dist-info/METADATA +606 -0
- skilleter_thingy-0.3.14.dist-info/RECORD +39 -0
- skilleter_thingy-0.3.14.dist-info/WHEEL +5 -0
- skilleter_thingy-0.3.14.dist-info/entry_points.txt +31 -0
- skilleter_thingy-0.3.14.dist-info/licenses/LICENSE +619 -0
- skilleter_thingy-0.3.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
################################################################################
|
|
4
|
+
""" Thingy git-review command - menu-driven code review tool
|
|
5
|
+
|
|
6
|
+
Copyright (C) 2020 John Skilleter
|
|
7
|
+
|
|
8
|
+
TODO:
|
|
9
|
+
* Check for nothing to review before starting curses to avoid screen flicker when we start and immediately quit.
|
|
10
|
+
* Use inotify to watch files in the review and prompt to reload if they have changed
|
|
11
|
+
* Better status line
|
|
12
|
+
* Mouse functionality?
|
|
13
|
+
* delete command
|
|
14
|
+
* revert command
|
|
15
|
+
* reset command
|
|
16
|
+
* sort command
|
|
17
|
+
* info command
|
|
18
|
+
* add command
|
|
19
|
+
* commit command
|
|
20
|
+
* ability to embed colour changes in strings when writing with curses
|
|
21
|
+
* Bottom line of the display should be menu that changes to a prompt if necc when running a command
|
|
22
|
+
* Scroll, rather than jump, when moving down off the bottom of the screen - start scrolling when within 10% of top or bottom
|
|
23
|
+
* Bottom line of display should show details of current file rather than help?
|
|
24
|
+
* Mark/Unmark as reviewed by wildcard
|
|
25
|
+
* Think that maintaining the list of files as a dictionary may be better than a list
|
|
26
|
+
* Filter in/out new/deleted files
|
|
27
|
+
* Universal filter dialog replace individual options and allow selection of:
|
|
28
|
+
* Show: all / deleted / updated files
|
|
29
|
+
* Show: all / wildcard match / wildcard non-match
|
|
30
|
+
* Show: all / reviewed / unreviewed files
|
|
31
|
+
* Tag files for group operations (reset, delete, add, commit)
|
|
32
|
+
* Handle renamed files properly - need to show old and new names, but need to return this info from git.commit_info()
|
|
33
|
+
|
|
34
|
+
BUGS:
|
|
35
|
+
* If console window is too small, popups aren't displayed - should handle this better - scrollable popup?
|
|
36
|
+
* Switching between hiding reviewed files and not does not maintain cursor position
|
|
37
|
+
* In hide mode, ins should not move down
|
|
38
|
+
* "r" resets reviewed status for all files to False
|
|
39
|
+
* If all reviewed then hidden, then move cursor around empty screen then unhide first redraw has blank line at the top.
|
|
40
|
+
* Need better way of handling panels - does not cope with resize when one is active - help sort-of does,
|
|
41
|
+
but doesn't pass the refrefsh down when closing - need flag for it
|
|
42
|
+
"""
|
|
43
|
+
################################################################################
|
|
44
|
+
|
|
45
|
+
import os
|
|
46
|
+
import sys
|
|
47
|
+
import argparse
|
|
48
|
+
import curses
|
|
49
|
+
import curses.panel
|
|
50
|
+
import pickle
|
|
51
|
+
import fnmatch
|
|
52
|
+
import subprocess
|
|
53
|
+
import time
|
|
54
|
+
from enum import IntEnum
|
|
55
|
+
|
|
56
|
+
from skilleter_modules import git
|
|
57
|
+
from skilleter_modules import dc_curses
|
|
58
|
+
from skilleter_modules import colour
|
|
59
|
+
|
|
60
|
+
################################################################################
|
|
61
|
+
# Colour pair codes
|
|
62
|
+
|
|
63
|
+
COLOUR_NORMAL = 1
|
|
64
|
+
COLOUR_STATUS = 2
|
|
65
|
+
COLOUR_REVIEWED = 3
|
|
66
|
+
COLOUR_BACKGROUND = 4
|
|
67
|
+
|
|
68
|
+
RESERVED_COLOURS = 5
|
|
69
|
+
|
|
70
|
+
# Version - used to update old pickle data
|
|
71
|
+
|
|
72
|
+
VERSION = 11
|
|
73
|
+
|
|
74
|
+
# Minimum console window size for functionality
|
|
75
|
+
|
|
76
|
+
MIN_CONSOLE_WIDTH = 32
|
|
77
|
+
MIN_CONSOLE_HEIGHT = 16
|
|
78
|
+
|
|
79
|
+
################################################################################
|
|
80
|
+
|
|
81
|
+
class SortOrder(IntEnum):
|
|
82
|
+
""" Sort order for filename list """
|
|
83
|
+
|
|
84
|
+
PATH = 0
|
|
85
|
+
FILENAME = 1
|
|
86
|
+
EXTENSION = 2
|
|
87
|
+
NUM_SORTS = 3
|
|
88
|
+
|
|
89
|
+
################################################################################
|
|
90
|
+
|
|
91
|
+
class GitReviewError(BaseException):
|
|
92
|
+
""" Exception for the application """
|
|
93
|
+
|
|
94
|
+
def __init__(self, msg, status=1):
|
|
95
|
+
super().__init__(msg)
|
|
96
|
+
self.msg = msg
|
|
97
|
+
self.status = status
|
|
98
|
+
|
|
99
|
+
################################################################################
|
|
100
|
+
|
|
101
|
+
def error(msg, status=1):
|
|
102
|
+
""" Report an error """
|
|
103
|
+
|
|
104
|
+
sys.stderr.write(f'{msg}\n')
|
|
105
|
+
sys.exit(status)
|
|
106
|
+
|
|
107
|
+
################################################################################
|
|
108
|
+
|
|
109
|
+
def in_directory(root, entry):
|
|
110
|
+
""" Return True if a directory lies within another """
|
|
111
|
+
|
|
112
|
+
return os.path.commonpath([root, entry]) == root
|
|
113
|
+
|
|
114
|
+
################################################################################
|
|
115
|
+
|
|
116
|
+
def pickle_filename(working_tree, branch):
|
|
117
|
+
""" Return the name of the pickle file for this git working tree """
|
|
118
|
+
|
|
119
|
+
pickle_dir = os.path.join(os.environ['HOME'], '.config', 'git-review')
|
|
120
|
+
|
|
121
|
+
if not os.path.isdir(pickle_dir):
|
|
122
|
+
os.mkdir(pickle_dir)
|
|
123
|
+
|
|
124
|
+
pickle_file = '%s-%s' % (working_tree.replace('/', '~'), branch.replace('/', '~'))
|
|
125
|
+
|
|
126
|
+
return os.path.join(pickle_dir, pickle_file)
|
|
127
|
+
|
|
128
|
+
################################################################################
|
|
129
|
+
|
|
130
|
+
def read_input(prompt):
|
|
131
|
+
""" Read input from the user """
|
|
132
|
+
|
|
133
|
+
win = curses.newwin(3, 60, 3, 10)
|
|
134
|
+
win.attron(curses.color_pair(COLOUR_STATUS))
|
|
135
|
+
win.box()
|
|
136
|
+
|
|
137
|
+
win.addstr(1, 1, prompt)
|
|
138
|
+
curses.curs_set(2)
|
|
139
|
+
win.refresh()
|
|
140
|
+
curses.echo()
|
|
141
|
+
text = win.getstr().decode(encoding='utf-8')
|
|
142
|
+
curses.noecho()
|
|
143
|
+
curses.curs_set(0)
|
|
144
|
+
|
|
145
|
+
return text
|
|
146
|
+
|
|
147
|
+
################################################################################
|
|
148
|
+
|
|
149
|
+
class PopUp():
|
|
150
|
+
""" Class to enable popup windows to be used via with statements """
|
|
151
|
+
|
|
152
|
+
def __init__(self, screen, msg, colour=COLOUR_STATUS, waitkey=False, sleep=True, centre=True):
|
|
153
|
+
""" Initialisation - just save the popup parameters """
|
|
154
|
+
|
|
155
|
+
self.panel = None
|
|
156
|
+
self.screen = screen
|
|
157
|
+
self.msg = msg
|
|
158
|
+
self.centre = centre
|
|
159
|
+
self.colour = curses.color_pair(colour)
|
|
160
|
+
self.sleep = sleep and not waitkey
|
|
161
|
+
self.waitkey = waitkey
|
|
162
|
+
self.start_time = 0
|
|
163
|
+
|
|
164
|
+
def __enter__(self):
|
|
165
|
+
""" Display the popup """
|
|
166
|
+
|
|
167
|
+
lines = self.msg.split('\n')
|
|
168
|
+
height = len(lines)
|
|
169
|
+
|
|
170
|
+
width = 0
|
|
171
|
+
for line in lines:
|
|
172
|
+
width = max(width, len(line))
|
|
173
|
+
|
|
174
|
+
width += 2
|
|
175
|
+
height += 2
|
|
176
|
+
|
|
177
|
+
size_y, size_x = self.screen.getmaxyx()
|
|
178
|
+
|
|
179
|
+
window = curses.newwin(height, width, (size_y - height) // 2, (size_x - width) // 2)
|
|
180
|
+
self.panel = curses.panel.new_panel(window)
|
|
181
|
+
|
|
182
|
+
window.bkgd(' ', self.colour)
|
|
183
|
+
for y_pos, line in enumerate(lines):
|
|
184
|
+
x_pos = (width - len(line)) // 2 if self.centre else 1
|
|
185
|
+
window.addstr(y_pos + 1, x_pos, line, self.colour)
|
|
186
|
+
|
|
187
|
+
self.panel.top()
|
|
188
|
+
curses.panel.update_panels()
|
|
189
|
+
self.screen.refresh()
|
|
190
|
+
|
|
191
|
+
self.start_time = time.monotonic()
|
|
192
|
+
|
|
193
|
+
if self.waitkey:
|
|
194
|
+
while True:
|
|
195
|
+
keypress = self.screen.getch()
|
|
196
|
+
if keypress == curses.KEY_RESIZE:
|
|
197
|
+
curses.panel.update_panels()
|
|
198
|
+
self.screen.refresh()
|
|
199
|
+
else:
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
def __exit__(self, _exc_type, _exc_value, _exc_traceback):
|
|
203
|
+
""" Remove the popup """
|
|
204
|
+
|
|
205
|
+
if self.panel:
|
|
206
|
+
if self.sleep:
|
|
207
|
+
elapsed = time.monotonic() - self.start_time
|
|
208
|
+
|
|
209
|
+
if elapsed < 1:
|
|
210
|
+
time.sleep(1 - elapsed)
|
|
211
|
+
|
|
212
|
+
del self.panel
|
|
213
|
+
|
|
214
|
+
################################################################################
|
|
215
|
+
|
|
216
|
+
# pylint: disable=too-many-instance-attributes
|
|
217
|
+
class GitReview():
|
|
218
|
+
""" Review function as a class """
|
|
219
|
+
|
|
220
|
+
PATH_CURRENT = 0
|
|
221
|
+
PATH_WORKING_TREE = 1
|
|
222
|
+
PATH_ABSOLUTE = 2
|
|
223
|
+
|
|
224
|
+
NUM_PATH_TYPES = 3
|
|
225
|
+
|
|
226
|
+
def __init__(self, screen, args):
|
|
227
|
+
|
|
228
|
+
# Move to the top-level directory in the working tree
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
self.current_dir = os.getcwd()
|
|
232
|
+
except FileNotFoundError:
|
|
233
|
+
raise GitReviewError('Unable to locate current directory')
|
|
234
|
+
|
|
235
|
+
self.working_tree_dir = git.working_tree()
|
|
236
|
+
|
|
237
|
+
if not self.working_tree_dir:
|
|
238
|
+
raise GitReviewError('Not a git working tree')
|
|
239
|
+
|
|
240
|
+
self.commit = git.branch() or git.current_commit()
|
|
241
|
+
|
|
242
|
+
self.__init_key_despatch_table()
|
|
243
|
+
|
|
244
|
+
# Commits being compared
|
|
245
|
+
|
|
246
|
+
self.commits = args.commits
|
|
247
|
+
self.paths = args.paths
|
|
248
|
+
|
|
249
|
+
# Default sort order
|
|
250
|
+
|
|
251
|
+
self.sort_order = SortOrder.PATH
|
|
252
|
+
self.reverse_sort = False
|
|
253
|
+
|
|
254
|
+
# Get the list of changed files restricted to the specified paths (if any)
|
|
255
|
+
|
|
256
|
+
self.changed_files = []
|
|
257
|
+
self.__update_changed_files()
|
|
258
|
+
|
|
259
|
+
if not self.changed_files:
|
|
260
|
+
msg = [f'There are no changes between {args.commits[0]} and ']
|
|
261
|
+
|
|
262
|
+
if args.commits[1]:
|
|
263
|
+
msg.append(args.commits[1])
|
|
264
|
+
else:
|
|
265
|
+
msg.append('local files')
|
|
266
|
+
|
|
267
|
+
if args.paths:
|
|
268
|
+
msg.append(f' in the {args.paths[0]} directory')
|
|
269
|
+
|
|
270
|
+
raise GitReviewError(''.join(msg))
|
|
271
|
+
|
|
272
|
+
# Get the repo name
|
|
273
|
+
|
|
274
|
+
self.repo_name = git.project()
|
|
275
|
+
|
|
276
|
+
# Set the attributes of the current review (some are initialised
|
|
277
|
+
# when the screen is drawn)
|
|
278
|
+
|
|
279
|
+
self.current = 0
|
|
280
|
+
self.offset = 0
|
|
281
|
+
|
|
282
|
+
self.searchstring = None
|
|
283
|
+
|
|
284
|
+
self.screen = screen
|
|
285
|
+
|
|
286
|
+
self.height = self.width = -1
|
|
287
|
+
self.file_list_y = 1
|
|
288
|
+
self.file_list_h = -1
|
|
289
|
+
|
|
290
|
+
self.file_list = []
|
|
291
|
+
|
|
292
|
+
self.filter_dir = self.filter_in = self.filter_out = None
|
|
293
|
+
self.filter_added = self.filter_modified = self.filter_deleted = self.filter_moved = False
|
|
294
|
+
self.filter_none_whitespace_only = False
|
|
295
|
+
|
|
296
|
+
self.show_none_whitespace_stats = False
|
|
297
|
+
|
|
298
|
+
self.finished = False
|
|
299
|
+
self.status_code = 0
|
|
300
|
+
|
|
301
|
+
# Use paths relative to the current directory
|
|
302
|
+
|
|
303
|
+
self.path_display = self.PATH_CURRENT
|
|
304
|
+
|
|
305
|
+
# Diff tool to use
|
|
306
|
+
|
|
307
|
+
self.diff_tool = args.difftool or os.environ.get('DIFFTOOL', 'diffuse')
|
|
308
|
+
|
|
309
|
+
# Reviewed files are visible
|
|
310
|
+
|
|
311
|
+
self.hide_reviewed_files = False
|
|
312
|
+
|
|
313
|
+
# See if we have saved state for this repo
|
|
314
|
+
|
|
315
|
+
self.__load_state()
|
|
316
|
+
|
|
317
|
+
# Configure the colours, set the background & hide the cursor
|
|
318
|
+
|
|
319
|
+
self.__init_colors()
|
|
320
|
+
|
|
321
|
+
# Generate the list of files to be shown (takes filtering into account)
|
|
322
|
+
|
|
323
|
+
self.__update_file_list()
|
|
324
|
+
|
|
325
|
+
# Get the current console dimensions
|
|
326
|
+
|
|
327
|
+
self.__get_console_size()
|
|
328
|
+
|
|
329
|
+
################################################################################
|
|
330
|
+
|
|
331
|
+
def __constrain_display_parameters(self):
|
|
332
|
+
""" Ensure that the current display parameters are within range - easier
|
|
333
|
+
to do it in one place for all of them than check individually whenever we
|
|
334
|
+
change any of them """
|
|
335
|
+
|
|
336
|
+
self.current = max(min(self.current, len(self.file_list) - 1), 0)
|
|
337
|
+
self.offset = min(len(self.file_list) - 1, max(0, self.offset))
|
|
338
|
+
|
|
339
|
+
# Keep the current entry on-screen
|
|
340
|
+
|
|
341
|
+
if self.current >= self.offset + self.height - 2:
|
|
342
|
+
self.offset = self.current
|
|
343
|
+
elif self.current < self.offset:
|
|
344
|
+
self.offset = self.current
|
|
345
|
+
|
|
346
|
+
################################################################################
|
|
347
|
+
|
|
348
|
+
def __init_key_despatch_table(self):
|
|
349
|
+
""" Initialise the keyboard despatch table """
|
|
350
|
+
|
|
351
|
+
# The table is indexed by the keycode and contains help and a reference to the
|
|
352
|
+
# function that is called when the key is pressed. For clarity, all the function
|
|
353
|
+
# names are prefixed with '__key_'.
|
|
354
|
+
|
|
355
|
+
self.key_despatch_table = \
|
|
356
|
+
{
|
|
357
|
+
curses.KEY_RESIZE: {'function': self.__key_console_resize},
|
|
358
|
+
|
|
359
|
+
curses.KEY_UP: {'key': 'UP', 'help': 'Move up 1 line', 'function': self.__key_move_up},
|
|
360
|
+
curses.KEY_DOWN: {'key': 'DOWN', 'help': 'Move down 1 line', 'function': self.__key_move_down},
|
|
361
|
+
curses.KEY_NPAGE: {'key': 'PGDN', 'help': 'Move down by a page', 'function': self.__key_move_page_down},
|
|
362
|
+
curses.KEY_PPAGE: {'key': 'PGUP', 'help': 'Move up by a page', 'function': self.__key_move_page_up},
|
|
363
|
+
curses.KEY_END: {'key': 'END', 'help': 'Move to the end of the file list', 'function': self.__key_move_end},
|
|
364
|
+
curses.KEY_HOME: {'key': 'HOME', 'help': 'Move to the top of the file list', 'function': self.__key_move_top},
|
|
365
|
+
curses.KEY_IC: {'key': 'INS', 'help': 'Toggle review status for file', 'function': self.__key_toggle_reviewed},
|
|
366
|
+
curses.KEY_F1: {'key': 'F1', 'help': 'Show help', 'function': self.__key_show_help},
|
|
367
|
+
curses.KEY_F3: {'key': 'F3', 'help': 'Search for next match', 'function': self.__key_search_again},
|
|
368
|
+
ord('\n'): {'key': 'ENTER', 'help': 'Review file', 'function': self.__key_review_file},
|
|
369
|
+
ord(' '): {'key': 'SPACE', 'help': 'Show file details', 'function': self.__key_show_file_info},
|
|
370
|
+
ord('/'): {'help': 'Search', 'function': self.__key_search_file},
|
|
371
|
+
ord('F'): {'help': 'Show only files matching a wildcard', 'function': self.__key_filter_in},
|
|
372
|
+
ord('f'): {'help': 'Hide files matching a wildcard', 'function': self.__key_filter_out},
|
|
373
|
+
ord('-'): {'help': 'Toggle hiding deleted files', 'function': self.__key_filter_deleted},
|
|
374
|
+
ord('+'): {'help': 'Toggle hiding moved files', 'function': self.__key_filter_moved},
|
|
375
|
+
ord('a'): {'help': 'Toggle hiding added files', 'function': self.__key_filter_added},
|
|
376
|
+
ord('*'): {'help': 'Toggle hiding modified files', 'function': self.__key_filter_modified},
|
|
377
|
+
ord('w'): {'help': 'Toggle hiding files with only whitespace changes', 'function': self.__key_filter_whitespace},
|
|
378
|
+
ord('W'): {'help': 'Toggle showing non-whitespace diff stats', 'function': self.__key_filter_show_non_whitespace},
|
|
379
|
+
ord('m'): {'help': 'Mark files as reviewed that match a wildcard', 'function': self.__key_mark_reviewed},
|
|
380
|
+
ord('M'): {'help': 'Unmark files as reviewed that match a wildcard', 'function': self.__key_mark_unreviewed},
|
|
381
|
+
ord('d'): {'help': 'Only show files in the directory of the current file and subdirectories', 'function': self.__key_filter_dir},
|
|
382
|
+
ord('D'): {'help': 'Only show files in the current directory and subdirectories', 'function': self.__key_filter_current_dir},
|
|
383
|
+
ord('c'): {'help': 'Clear filtering', 'function': self.__key_clear_filters},
|
|
384
|
+
ord('R'): {'help': 'Reload the changes and reset the review', 'function': self.__key_reload_changes_and_reset},
|
|
385
|
+
ord('h'): {'help': 'Toggle hiding reviewed files', 'function': self.__key_toggle_hide_reviewed_files},
|
|
386
|
+
ord('p'): {'help': 'Toggle path display', 'function': self.__key_toggle_path_display},
|
|
387
|
+
ord('q'): {'help': 'Quit', 'function': self.__key_quit_review},
|
|
388
|
+
ord('r'): {'help': 'Reload the changes', 'function': self.__key_reload_changes},
|
|
389
|
+
ord('$'): {'help': 'Open shell at location of current file', 'function': self.__key_open_shell},
|
|
390
|
+
ord('e'): {'help': 'Edit the current file', 'function': self.__key_edit_file},
|
|
391
|
+
ord('s'): {'help': 'Cycle sort order', 'function': self.__key_cycle_search},
|
|
392
|
+
ord('S'): {'help': 'Reverse sort order', 'function': self.__key_reverse_sort},
|
|
393
|
+
3: {'help': 'Exit', 'function': self.__key_error_review},
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
################################################################################
|
|
397
|
+
|
|
398
|
+
def save_state(self):
|
|
399
|
+
""" Save the current state (normally called on exit) """
|
|
400
|
+
|
|
401
|
+
pickle_file_name = pickle_filename(self.working_tree_dir, self.commit)
|
|
402
|
+
|
|
403
|
+
pickle_data = {'changed_files': self.changed_files,
|
|
404
|
+
'current': self.current,
|
|
405
|
+
'offset': self.offset,
|
|
406
|
+
'searchstring': self.searchstring,
|
|
407
|
+
'filter_in': self.filter_in,
|
|
408
|
+
'filter_out': self.filter_out,
|
|
409
|
+
'filter_dir': self.filter_dir,
|
|
410
|
+
'filter_deleted': self.filter_deleted,
|
|
411
|
+
'filter_moved': self.filter_moved,
|
|
412
|
+
'filter_modified': self.filter_modified,
|
|
413
|
+
'filter_added': self.filter_added,
|
|
414
|
+
'filter_whitespace': self.filter_none_whitespace_only,
|
|
415
|
+
'filter_show_non_whitespace': self.show_none_whitespace_stats,
|
|
416
|
+
'sort_order': self.sort_order,
|
|
417
|
+
'reverse_sort': self.reverse_sort,
|
|
418
|
+
'version': VERSION}
|
|
419
|
+
|
|
420
|
+
with open(pickle_file_name, 'wb') as outfile:
|
|
421
|
+
pickle.dump(pickle_data, outfile)
|
|
422
|
+
|
|
423
|
+
################################################################################
|
|
424
|
+
|
|
425
|
+
def __load_state(self):
|
|
426
|
+
""" Unpickle saved state if it exists """
|
|
427
|
+
|
|
428
|
+
pickle_file_name = pickle_filename(self.working_tree_dir, self.commit)
|
|
429
|
+
|
|
430
|
+
if os.path.isfile(pickle_file_name):
|
|
431
|
+
try:
|
|
432
|
+
with open(pickle_file_name, 'rb') as infile:
|
|
433
|
+
pickle_data = pickle.load(infile)
|
|
434
|
+
|
|
435
|
+
# Extract pickle data, allowing for out-of-date pickle files
|
|
436
|
+
# where the data might be missing.
|
|
437
|
+
|
|
438
|
+
self.current = pickle_data.get('current', self.current)
|
|
439
|
+
self.offset = pickle_data.get('offset', self.offset)
|
|
440
|
+
self.searchstring = pickle_data.get('searchstring', self.searchstring)
|
|
441
|
+
self.filter_in = pickle_data.get('filter_in', self.filter_in)
|
|
442
|
+
self.filter_out = pickle_data.get('filter_out', self.filter_out)
|
|
443
|
+
self.filter_dir = pickle_data.get('filter_dir', self.filter_dir)
|
|
444
|
+
self.filter_deleted = pickle_data.get('filter_deleted', self.filter_deleted)
|
|
445
|
+
self.filter_moved = pickle_data.get('filter_moved', self.filter_moved)
|
|
446
|
+
self.filter_added = pickle_data.get('filter_added', self.filter_added)
|
|
447
|
+
self.filter_modified = pickle_data.get('filter_modified', self.filter_modified)
|
|
448
|
+
self.sort_order = pickle_data.get('sort_order', self.sort_order)
|
|
449
|
+
self.reverse_sort = pickle_data.get('reverse_sort', self.reverse_sort)
|
|
450
|
+
self.filter_none_whitespace_only = pickle_data.get('filter_none_whitespace_only', self.filter_none_whitespace_only)
|
|
451
|
+
self.show_none_whitespace_stats = pickle_data.get('show_none_whitespace_stats', self.show_none_whitespace_stats)
|
|
452
|
+
|
|
453
|
+
# Transfer the reviewed flag for each file in the pickle
|
|
454
|
+
# to the corresponding current file
|
|
455
|
+
|
|
456
|
+
for oldfile in pickle_data['changed_files']:
|
|
457
|
+
for newfile in self.changed_files:
|
|
458
|
+
if oldfile['name'] == newfile['name']:
|
|
459
|
+
newfile['reviewed'] = oldfile['reviewed']
|
|
460
|
+
break
|
|
461
|
+
|
|
462
|
+
except (EOFError, pickle.UnpicklingError, ModuleNotFoundError, AttributeError): # TODO: Why did I get ModuleNotFoundError or AttributeError????
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
self.__constrain_display_parameters()
|
|
466
|
+
|
|
467
|
+
################################################################################
|
|
468
|
+
|
|
469
|
+
def __init_colors(self):
|
|
470
|
+
""" Set up the colours and initialise the display """
|
|
471
|
+
|
|
472
|
+
curses.start_color()
|
|
473
|
+
curses.use_default_colors()
|
|
474
|
+
|
|
475
|
+
curses.init_color(15, 1000, 1000, 1000)
|
|
476
|
+
curses.init_color(7, 500, 500, 500)
|
|
477
|
+
|
|
478
|
+
if os.getenv('THINGY_DARK_MODE'):
|
|
479
|
+
curses.init_pair(COLOUR_NORMAL, 15, curses.COLOR_BLACK)
|
|
480
|
+
curses.init_pair(COLOUR_STATUS, 15, curses.COLOR_GREEN)
|
|
481
|
+
curses.init_pair(COLOUR_REVIEWED, 15, 7)
|
|
482
|
+
curses.init_pair(COLOUR_BACKGROUND, 15, curses.COLOR_BLACK)
|
|
483
|
+
else:
|
|
484
|
+
curses.init_pair(COLOUR_NORMAL, curses.COLOR_BLACK, 15)
|
|
485
|
+
curses.init_pair(COLOUR_STATUS, 15, curses.COLOR_GREEN)
|
|
486
|
+
curses.init_pair(COLOUR_REVIEWED, 7, 15)
|
|
487
|
+
curses.init_pair(COLOUR_BACKGROUND, curses.COLOR_BLACK, 15)
|
|
488
|
+
|
|
489
|
+
self.screen.bkgdset(' ', curses.color_pair(COLOUR_BACKGROUND))
|
|
490
|
+
|
|
491
|
+
curses.curs_set(0)
|
|
492
|
+
|
|
493
|
+
# Set up dircolor highlighting
|
|
494
|
+
|
|
495
|
+
self.dc = dc_curses.CursesDircolors(reserved=RESERVED_COLOURS)
|
|
496
|
+
|
|
497
|
+
# Clear and refresh the screen for a blank canvas
|
|
498
|
+
|
|
499
|
+
self.screen.clear()
|
|
500
|
+
self.screen.refresh()
|
|
501
|
+
|
|
502
|
+
################################################################################
|
|
503
|
+
|
|
504
|
+
def __centre_text(self, y_pos, color, text):
|
|
505
|
+
""" Centre text """
|
|
506
|
+
|
|
507
|
+
if len(text) >= self.width:
|
|
508
|
+
output = text[:self.width - 1]
|
|
509
|
+
else:
|
|
510
|
+
output = text
|
|
511
|
+
|
|
512
|
+
x_pos = max(0, (self.width - len(output)) // 2)
|
|
513
|
+
|
|
514
|
+
self.screen.attron(color)
|
|
515
|
+
self.screen.hline(y_pos, 0, ' ', self.width)
|
|
516
|
+
self.screen.addstr(y_pos, x_pos, output)
|
|
517
|
+
self.screen.attroff(color)
|
|
518
|
+
|
|
519
|
+
################################################################################
|
|
520
|
+
|
|
521
|
+
def show_file_list(self):
|
|
522
|
+
""" Draw the current page of the file list """
|
|
523
|
+
|
|
524
|
+
def format_change(prefix, value):
|
|
525
|
+
"""If value is 0 just return it as a string, otherwise apply the prefix and
|
|
526
|
+
return it (e.g. '+' or '-')"""
|
|
527
|
+
|
|
528
|
+
return f'{prefix}{value}' if value else '0'
|
|
529
|
+
|
|
530
|
+
for ypos in range(0, self.file_list_h):
|
|
531
|
+
|
|
532
|
+
normal_colour = curses.color_pair(COLOUR_NORMAL)
|
|
533
|
+
|
|
534
|
+
if 0 <= self.offset + ypos < len(self.file_list):
|
|
535
|
+
# Work out what colour to render the file details in
|
|
536
|
+
|
|
537
|
+
current_file = self.file_list[self.offset + ypos]
|
|
538
|
+
|
|
539
|
+
current = self.offset + ypos == self.current
|
|
540
|
+
|
|
541
|
+
if current_file['reviewed']:
|
|
542
|
+
normal_colour = curses.color_pair(COLOUR_REVIEWED)
|
|
543
|
+
|
|
544
|
+
# The text to render
|
|
545
|
+
|
|
546
|
+
filename = current_file['name']
|
|
547
|
+
|
|
548
|
+
# Diff stats, with or without non-whitespace changes
|
|
549
|
+
|
|
550
|
+
if self.show_none_whitespace_stats:
|
|
551
|
+
added = format_change('+', current_file["non-ws added"])
|
|
552
|
+
deleted = format_change('-', current_file["non-ws deleted"])
|
|
553
|
+
else:
|
|
554
|
+
added = format_change('+', current_file["added"])
|
|
555
|
+
deleted = format_change('-', current_file["deleted"])
|
|
556
|
+
|
|
557
|
+
status = f'{current_file["status"]} {deleted:>4}/{added:>4}'
|
|
558
|
+
|
|
559
|
+
abspath = os.path.join(self.working_tree_dir, filename)
|
|
560
|
+
|
|
561
|
+
if self.path_display == self.PATH_CURRENT:
|
|
562
|
+
filename = os.path.relpath(abspath, self.current_dir)
|
|
563
|
+
elif self.path_display == self.PATH_WORKING_TREE:
|
|
564
|
+
filename = os.path.relpath(abspath, self.working_tree_dir)
|
|
565
|
+
else:
|
|
566
|
+
filename = abspath
|
|
567
|
+
|
|
568
|
+
data = '%3d %s ' % (self.offset + ypos + 1, status)
|
|
569
|
+
|
|
570
|
+
if len(data) + len(filename) > self.width:
|
|
571
|
+
filename = filename[:self.width - len(data) - 3] + '...'
|
|
572
|
+
|
|
573
|
+
else:
|
|
574
|
+
data = filename = ''
|
|
575
|
+
current = False
|
|
576
|
+
|
|
577
|
+
# Render the current line
|
|
578
|
+
|
|
579
|
+
file_colour = self.dc.get_colour_pair(filename) if filename else normal_colour
|
|
580
|
+
|
|
581
|
+
# Reverse the colours if this the cursor line
|
|
582
|
+
|
|
583
|
+
if current:
|
|
584
|
+
file_colour |= curses.A_REVERSE
|
|
585
|
+
normal_colour |= curses.A_REVERSE
|
|
586
|
+
|
|
587
|
+
# Write the prefix, filename, and, if necessary, padding
|
|
588
|
+
|
|
589
|
+
if data:
|
|
590
|
+
self.screen.addstr(self.file_list_y + ypos, 0, data, normal_colour)
|
|
591
|
+
|
|
592
|
+
if filename:
|
|
593
|
+
self.screen.addstr(self.file_list_y + ypos, len(data), filename, file_colour)
|
|
594
|
+
|
|
595
|
+
if len(data) + len(filename) < self.width:
|
|
596
|
+
self.screen.addstr(self.file_list_y + ypos, len(data) + len(filename), ' ' * (self.width - len(data) - len(filename)), normal_colour)
|
|
597
|
+
|
|
598
|
+
# Pop up a message if there are no visible files
|
|
599
|
+
|
|
600
|
+
if not self.changed_files:
|
|
601
|
+
with PopUp(self.screen, 'There are no changed files in the review'):
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
elif not self.file_list:
|
|
605
|
+
with PopUp(self.screen, 'All files are hidden - Press \'c\' to clear filters.'):
|
|
606
|
+
pass
|
|
607
|
+
|
|
608
|
+
################################################################################
|
|
609
|
+
|
|
610
|
+
def draw_screen(self):
|
|
611
|
+
""" Draw the review screen """
|
|
612
|
+
|
|
613
|
+
# Render status bar
|
|
614
|
+
|
|
615
|
+
reviewed = 0
|
|
616
|
+
for file in self.changed_files:
|
|
617
|
+
if file['reviewed']:
|
|
618
|
+
reviewed += 1
|
|
619
|
+
|
|
620
|
+
status_bar = ['F1=Help, %d file(s), %d visible, %d reviewed, %s' % (len(self.changed_files), len(self.file_list), reviewed, self.__sort_type_msg())]
|
|
621
|
+
|
|
622
|
+
if self.hide_reviewed_files:
|
|
623
|
+
status_bar.append(', hiding reviewed files')
|
|
624
|
+
|
|
625
|
+
if self.__active_filters:
|
|
626
|
+
status_bar.append(', Active filters: %s' % self.__filter_description())
|
|
627
|
+
|
|
628
|
+
self.__centre_text(self.status_y, curses.color_pair(COLOUR_STATUS), ''.join(status_bar))
|
|
629
|
+
|
|
630
|
+
if not self.commits[0] and not self.commits[1]:
|
|
631
|
+
title_bar = 'Reviewing local changes'
|
|
632
|
+
elif not self.commits[1]:
|
|
633
|
+
title_bar = 'Reviewing changes between local working tree and %s' % self.commits[0]
|
|
634
|
+
else:
|
|
635
|
+
title_bar = 'Reviewing changes between %s and %s' % (self.commits[0], self.commits[1])
|
|
636
|
+
|
|
637
|
+
if self.repo_name:
|
|
638
|
+
title_bar = '%s in %s' % (title_bar, self.repo_name)
|
|
639
|
+
|
|
640
|
+
if self.paths:
|
|
641
|
+
title_bar = '%s within path(s) %s' % (title_bar, ', '.join(self.paths))
|
|
642
|
+
|
|
643
|
+
self.__centre_text(0, curses.color_pair(COLOUR_STATUS), title_bar)
|
|
644
|
+
|
|
645
|
+
################################################################################
|
|
646
|
+
|
|
647
|
+
def __active_filters(self):
|
|
648
|
+
""" Return true if any filters are active """
|
|
649
|
+
|
|
650
|
+
return self.hide_reviewed_files or \
|
|
651
|
+
self.filter_out or \
|
|
652
|
+
self.filter_in or \
|
|
653
|
+
self.filter_dir or \
|
|
654
|
+
self.filter_deleted or \
|
|
655
|
+
self.filter_moved or \
|
|
656
|
+
self.filter_added or \
|
|
657
|
+
self.filter_modified or \
|
|
658
|
+
self.filter_none_whitespace_only
|
|
659
|
+
|
|
660
|
+
################################################################################
|
|
661
|
+
|
|
662
|
+
def filtered(self, entry):
|
|
663
|
+
""" Return True if an entry is hidden by one or more filters """
|
|
664
|
+
|
|
665
|
+
result = False
|
|
666
|
+
|
|
667
|
+
if self.hide_reviewed_files and entry['reviewed']:
|
|
668
|
+
result = True
|
|
669
|
+
|
|
670
|
+
elif self.filter_out and fnmatch.fnmatch(entry['name'], self.filter_out):
|
|
671
|
+
result = True
|
|
672
|
+
|
|
673
|
+
elif self.filter_dir and not in_directory(self.filter_dir, os.path.join(self.working_tree_dir, entry['name'])):
|
|
674
|
+
result = True
|
|
675
|
+
|
|
676
|
+
elif self.filter_in and not fnmatch.fnmatch(entry['name'], self.filter_in):
|
|
677
|
+
result = True
|
|
678
|
+
|
|
679
|
+
elif self.filter_moved and entry['status'] == 'R':
|
|
680
|
+
result = True
|
|
681
|
+
|
|
682
|
+
elif self.filter_deleted and entry['status'] == 'D':
|
|
683
|
+
result = True
|
|
684
|
+
|
|
685
|
+
elif self.filter_modified and entry['status'] == 'M':
|
|
686
|
+
result = True
|
|
687
|
+
|
|
688
|
+
elif self.filter_added and entry['status'] == 'A':
|
|
689
|
+
result = True
|
|
690
|
+
|
|
691
|
+
elif self.filter_none_whitespace_only and entry['non-ws added'] == 0 and entry['non-ws deleted'] == 0:
|
|
692
|
+
result = True
|
|
693
|
+
|
|
694
|
+
return result
|
|
695
|
+
|
|
696
|
+
################################################################################
|
|
697
|
+
|
|
698
|
+
def __filter_description(self):
|
|
699
|
+
""" Return a textual description of the active filters """
|
|
700
|
+
|
|
701
|
+
filters = []
|
|
702
|
+
|
|
703
|
+
if self.hide_reviewed_files:
|
|
704
|
+
filters.append('reviewed')
|
|
705
|
+
|
|
706
|
+
if self.filter_out:
|
|
707
|
+
filters.append('-wildcard')
|
|
708
|
+
|
|
709
|
+
if self.filter_in:
|
|
710
|
+
filters.append('+wildcard')
|
|
711
|
+
|
|
712
|
+
if self.filter_dir:
|
|
713
|
+
filters.append('directory')
|
|
714
|
+
|
|
715
|
+
if self.filter_moved:
|
|
716
|
+
filters.append('moved')
|
|
717
|
+
|
|
718
|
+
if self.filter_deleted:
|
|
719
|
+
filters.append('deleted ')
|
|
720
|
+
|
|
721
|
+
if self.filter_added:
|
|
722
|
+
filters.append('added')
|
|
723
|
+
|
|
724
|
+
if self.filter_modified:
|
|
725
|
+
filters.append('modified')
|
|
726
|
+
|
|
727
|
+
if self.filter_none_whitespace_only:
|
|
728
|
+
filters.append('whitespace')
|
|
729
|
+
|
|
730
|
+
if not filters:
|
|
731
|
+
filters = ['none']
|
|
732
|
+
|
|
733
|
+
return ', '.join(filters)
|
|
734
|
+
|
|
735
|
+
################################################################################
|
|
736
|
+
|
|
737
|
+
def __sort_file_list(self):
|
|
738
|
+
""" Sort the file list according to the current sort order """
|
|
739
|
+
|
|
740
|
+
if self.sort_order == SortOrder.PATH:
|
|
741
|
+
self.changed_files.sort(reverse=self.reverse_sort, key=lambda entry: entry['name'])
|
|
742
|
+
elif self.sort_order == SortOrder.FILENAME:
|
|
743
|
+
self.changed_files.sort(reverse=self.reverse_sort, key=lambda entry: os.path.basename(entry['name']))
|
|
744
|
+
elif self.sort_order == SortOrder.EXTENSION:
|
|
745
|
+
self.changed_files.sort(reverse=self.reverse_sort, key=lambda entry: entry['name'].split('.')[-1])
|
|
746
|
+
|
|
747
|
+
################################################################################
|
|
748
|
+
|
|
749
|
+
def __update_changed_files(self):
|
|
750
|
+
""" Update the list of changed files between two commits
|
|
751
|
+
"""
|
|
752
|
+
|
|
753
|
+
# Get the list of changes between the two commits
|
|
754
|
+
|
|
755
|
+
try:
|
|
756
|
+
change_info = git.commit_info(self.commits[0], self.commits[1], self.paths, diff_stats=True)
|
|
757
|
+
except git.GitError as exc:
|
|
758
|
+
raise GitReviewError(exc.msg)
|
|
759
|
+
|
|
760
|
+
# Save the reviewed status of existing files
|
|
761
|
+
|
|
762
|
+
reviewed = []
|
|
763
|
+
for entry in self.changed_files:
|
|
764
|
+
if entry['reviewed']:
|
|
765
|
+
reviewed.append(entry['name'])
|
|
766
|
+
|
|
767
|
+
# Convert the list of changed files from a dictionary to a list, adding the
|
|
768
|
+
# reviewed state of any pre-existing files
|
|
769
|
+
|
|
770
|
+
self.changed_files = []
|
|
771
|
+
for entry in change_info:
|
|
772
|
+
self.changed_files.append({'name': entry,
|
|
773
|
+
'status': change_info[entry]['status'],
|
|
774
|
+
'reviewed': entry in reviewed,
|
|
775
|
+
'oldname': change_info[entry]['oldname'],
|
|
776
|
+
'added': change_info[entry]['added'],
|
|
777
|
+
'deleted': change_info[entry]['deleted'],
|
|
778
|
+
'non-ws added': change_info[entry]['non-ws added'],
|
|
779
|
+
'non-ws deleted': change_info[entry]['non-ws deleted'],
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
self.__sort_file_list()
|
|
783
|
+
|
|
784
|
+
################################################################################
|
|
785
|
+
|
|
786
|
+
def __update_file_list(self):
|
|
787
|
+
""" Generate the file list from the list of current files """
|
|
788
|
+
|
|
789
|
+
self.__sort_file_list()
|
|
790
|
+
|
|
791
|
+
if self.__active_filters():
|
|
792
|
+
self.file_list = [entry for entry in self.changed_files if not self.filtered(entry)]
|
|
793
|
+
else:
|
|
794
|
+
self.file_list = self.changed_files
|
|
795
|
+
|
|
796
|
+
################################################################################
|
|
797
|
+
|
|
798
|
+
def __get_console_size(self):
|
|
799
|
+
""" Get current screen size and set up locations in the display """
|
|
800
|
+
|
|
801
|
+
self.height, self.width = self.screen.getmaxyx()
|
|
802
|
+
|
|
803
|
+
self.status_y = self.height - 1
|
|
804
|
+
self.file_list_h = self.height - 2
|
|
805
|
+
|
|
806
|
+
if self.width < MIN_CONSOLE_WIDTH or self.height < MIN_CONSOLE_HEIGHT:
|
|
807
|
+
raise GitReviewError('Console window is too small!')
|
|
808
|
+
|
|
809
|
+
################################################################################
|
|
810
|
+
|
|
811
|
+
def __review(self):
|
|
812
|
+
""" Diff the current file """
|
|
813
|
+
|
|
814
|
+
if git.config_get('diff', 'tool'):
|
|
815
|
+
msg = 'Running diff on %s' % self.file_list[self.current]['name']
|
|
816
|
+
|
|
817
|
+
with PopUp(self.screen, msg, sleep=False):
|
|
818
|
+
|
|
819
|
+
os.chdir(self.working_tree_dir)
|
|
820
|
+
|
|
821
|
+
files = [self.file_list[self.current]['oldname'], self.file_list[self.current]['name']]
|
|
822
|
+
|
|
823
|
+
git.difftool(self.commits[0], self.commits[1], files, self.diff_tool)
|
|
824
|
+
|
|
825
|
+
os.chdir(self.current_dir)
|
|
826
|
+
|
|
827
|
+
self.file_list[self.current]['reviewed'] = True
|
|
828
|
+
|
|
829
|
+
self.__update_file_list()
|
|
830
|
+
else:
|
|
831
|
+
with PopUp(self.screen, 'No git difftool is configured', sleep=5):
|
|
832
|
+
pass
|
|
833
|
+
|
|
834
|
+
################################################################################
|
|
835
|
+
|
|
836
|
+
def __clear_filters(self):
|
|
837
|
+
""" Clear all filters """
|
|
838
|
+
|
|
839
|
+
if self.filter_out or self.filter_in or self.filter_dir or self.filter_deleted or self.filter_moved or self.filter_added or self.filter_modified or self.filter_none_whitespace_only:
|
|
840
|
+
self.filter_dir = self.filter_out = self.filter_in = None
|
|
841
|
+
self.filter_added = self.filter_modified = self.filter_deleted = self.filter_moved = self.filter_none_whitespace_only = False
|
|
842
|
+
self.__update_file_list()
|
|
843
|
+
|
|
844
|
+
################################################################################
|
|
845
|
+
|
|
846
|
+
def __reload_changes(self):
|
|
847
|
+
""" Update the list of files - reloads the git status in case something
|
|
848
|
+
external has changed it. """
|
|
849
|
+
|
|
850
|
+
self.__update_changed_files()
|
|
851
|
+
self.__update_file_list()
|
|
852
|
+
|
|
853
|
+
################################################################################
|
|
854
|
+
|
|
855
|
+
def __run_external_command(self, cmd):
|
|
856
|
+
""" Run an external command, with the current directory being that of the
|
|
857
|
+
current file and shutting down curses before running the command
|
|
858
|
+
then restarting it """
|
|
859
|
+
|
|
860
|
+
directory = os.path.join(self.working_tree_dir, os.path.dirname(self.file_list[self.current]['name']))
|
|
861
|
+
|
|
862
|
+
# The directory may not exist so hop up until we find one that does
|
|
863
|
+
|
|
864
|
+
while not os.path.isdir(directory):
|
|
865
|
+
directory = os.path.normpath(os.path.join(directory, '..'))
|
|
866
|
+
|
|
867
|
+
# Reset the terminal, run the command and re-initialise the display for review
|
|
868
|
+
|
|
869
|
+
self.screen.erase()
|
|
870
|
+
curses.endwin()
|
|
871
|
+
subprocess.run(cmd, cwd=directory)
|
|
872
|
+
self.screen = curses.initscr()
|
|
873
|
+
self.__reload_changes()
|
|
874
|
+
|
|
875
|
+
################################################################################
|
|
876
|
+
|
|
877
|
+
def __key_console_resize(self):
|
|
878
|
+
""" Update the screen size variables when the console window is resized """
|
|
879
|
+
|
|
880
|
+
self.__get_console_size()
|
|
881
|
+
|
|
882
|
+
################################################################################
|
|
883
|
+
|
|
884
|
+
def __key_show_help(self):
|
|
885
|
+
""" Show help information in a pop-up window """
|
|
886
|
+
|
|
887
|
+
# Compile list of keyboard functions
|
|
888
|
+
|
|
889
|
+
helpinfo = []
|
|
890
|
+
|
|
891
|
+
for key in self.key_despatch_table:
|
|
892
|
+
if 'help' in self.key_despatch_table[key]:
|
|
893
|
+
if 'key' in self.key_despatch_table[key]:
|
|
894
|
+
keyname = self.key_despatch_table[key]['key']
|
|
895
|
+
else:
|
|
896
|
+
keyname = chr(key)
|
|
897
|
+
|
|
898
|
+
helpinfo.append('%-5s - %s' % (keyname, self.key_despatch_table[key]['help']))
|
|
899
|
+
|
|
900
|
+
helptext = '\n'.join(helpinfo)
|
|
901
|
+
|
|
902
|
+
with PopUp(self.screen, helptext, waitkey=True, centre=False):
|
|
903
|
+
pass
|
|
904
|
+
|
|
905
|
+
################################################################################
|
|
906
|
+
|
|
907
|
+
def __key_show_file_info(self):
|
|
908
|
+
""" TODO: Show information about the current file in a pop-up window """
|
|
909
|
+
|
|
910
|
+
pass
|
|
911
|
+
|
|
912
|
+
################################################################################
|
|
913
|
+
|
|
914
|
+
def __key_toggle_path_display(self):
|
|
915
|
+
""" Toggle the way in which file paths are displayed """
|
|
916
|
+
|
|
917
|
+
self.path_display = (self.path_display + 1) % self.NUM_PATH_TYPES
|
|
918
|
+
|
|
919
|
+
################################################################################
|
|
920
|
+
|
|
921
|
+
def __key_quit_review(self):
|
|
922
|
+
""" Quit """
|
|
923
|
+
|
|
924
|
+
self.finished = True
|
|
925
|
+
|
|
926
|
+
################################################################################
|
|
927
|
+
|
|
928
|
+
def __key_error_review(self):
|
|
929
|
+
""" Quit with an error"""
|
|
930
|
+
|
|
931
|
+
self.finished = True
|
|
932
|
+
self.status_code = 1
|
|
933
|
+
|
|
934
|
+
################################################################################
|
|
935
|
+
|
|
936
|
+
def __key_toggle_reviewed(self):
|
|
937
|
+
""" Toggle mark file as reviewed and move down unless hide mode enabled
|
|
938
|
+
and file is now hidden """
|
|
939
|
+
|
|
940
|
+
self.file_list[self.current]['reviewed'] ^= True
|
|
941
|
+
|
|
942
|
+
if not self.hide_reviewed_files:
|
|
943
|
+
self.current += 1
|
|
944
|
+
|
|
945
|
+
self.__update_file_list()
|
|
946
|
+
|
|
947
|
+
################################################################################
|
|
948
|
+
|
|
949
|
+
def __key_toggle_hide_reviewed_files(self):
|
|
950
|
+
""" Toggle the display of reviewed files """
|
|
951
|
+
|
|
952
|
+
self.hide_reviewed_files ^= True
|
|
953
|
+
|
|
954
|
+
with PopUp(self.screen, '%s reviewed files' % ('Hiding' if self.hide_reviewed_files else 'Showing')):
|
|
955
|
+
self.__update_file_list()
|
|
956
|
+
|
|
957
|
+
################################################################################
|
|
958
|
+
|
|
959
|
+
def __key_review_file(self):
|
|
960
|
+
""" Review the current file """
|
|
961
|
+
|
|
962
|
+
self.__review()
|
|
963
|
+
|
|
964
|
+
################################################################################
|
|
965
|
+
|
|
966
|
+
def __key_reload_changes(self):
|
|
967
|
+
""" Reload the changes """
|
|
968
|
+
|
|
969
|
+
with PopUp(self.screen, 'Reload changes'):
|
|
970
|
+
self.__reload_changes()
|
|
971
|
+
|
|
972
|
+
################################################################################
|
|
973
|
+
|
|
974
|
+
def __key_edit_file(self):
|
|
975
|
+
""" Edit the current file """
|
|
976
|
+
|
|
977
|
+
editor = os.environ.get('EDITOR', 'vim')
|
|
978
|
+
self.__run_external_command([editor, os.path.basename(self.file_list[self.current]['name'])])
|
|
979
|
+
|
|
980
|
+
################################################################################
|
|
981
|
+
|
|
982
|
+
def __key_open_shell(self):
|
|
983
|
+
""" Open a shell in the same directory as the current file
|
|
984
|
+
"""
|
|
985
|
+
|
|
986
|
+
self.__run_external_command([os.getenv('SHELL')])
|
|
987
|
+
self.__get_console_size()
|
|
988
|
+
|
|
989
|
+
################################################################################
|
|
990
|
+
|
|
991
|
+
def __key_reload_changes_and_reset(self):
|
|
992
|
+
""" Reload changes and reset the review status of each file,
|
|
993
|
+
the current file and unhide reviewed files """
|
|
994
|
+
|
|
995
|
+
with PopUp(self.screen, 'Reload changes & reset reviewed status'):
|
|
996
|
+
|
|
997
|
+
self.__update_changed_files()
|
|
998
|
+
|
|
999
|
+
for entry in self.changed_files:
|
|
1000
|
+
entry['reviewed'] = False
|
|
1001
|
+
|
|
1002
|
+
self.current = self.offset = 0
|
|
1003
|
+
self.hide_reviewed_files = False
|
|
1004
|
+
self.__clear_filters()
|
|
1005
|
+
self.__update_file_list()
|
|
1006
|
+
|
|
1007
|
+
################################################################################
|
|
1008
|
+
|
|
1009
|
+
def __search_next_match(self):
|
|
1010
|
+
""" Search for the next match with the current search string """
|
|
1011
|
+
|
|
1012
|
+
for i in list(range(self.current + 1, len(self.file_list))) + list(range(0, self.current)):
|
|
1013
|
+
if fnmatch.fnmatch(self.file_list[i]['name'], self.searchstring):
|
|
1014
|
+
self.current = i
|
|
1015
|
+
break
|
|
1016
|
+
|
|
1017
|
+
################################################################################
|
|
1018
|
+
|
|
1019
|
+
def __key_search_file(self):
|
|
1020
|
+
""" Prompt for a search string and find a match """
|
|
1021
|
+
|
|
1022
|
+
self.searchstring = '*' + read_input('Search for: ') + '*'
|
|
1023
|
+
|
|
1024
|
+
self.__search_next_match()
|
|
1025
|
+
|
|
1026
|
+
################################################################################
|
|
1027
|
+
|
|
1028
|
+
def __key_search_again(self):
|
|
1029
|
+
""" Prompt for a search string if none defined then search """
|
|
1030
|
+
|
|
1031
|
+
if self.searchstring:
|
|
1032
|
+
self.__search_next_match()
|
|
1033
|
+
else:
|
|
1034
|
+
self.__key_search_file()
|
|
1035
|
+
|
|
1036
|
+
################################################################################
|
|
1037
|
+
|
|
1038
|
+
def __key_filter_out(self):
|
|
1039
|
+
""" Hide files matching a wildcard """
|
|
1040
|
+
|
|
1041
|
+
filter_out = read_input('Hide files matching: ')
|
|
1042
|
+
|
|
1043
|
+
if filter_out:
|
|
1044
|
+
self.filter_out = filter_out
|
|
1045
|
+
self.filter_in = None
|
|
1046
|
+
self.__update_file_list()
|
|
1047
|
+
|
|
1048
|
+
################################################################################
|
|
1049
|
+
|
|
1050
|
+
def __key_filter_in(self):
|
|
1051
|
+
""" Only show files matching a wildcard """
|
|
1052
|
+
|
|
1053
|
+
filter_in = read_input('Only show files matching: ')
|
|
1054
|
+
|
|
1055
|
+
if filter_in:
|
|
1056
|
+
self.filter_in = filter_in
|
|
1057
|
+
self.filter_out = None
|
|
1058
|
+
self.__update_file_list()
|
|
1059
|
+
|
|
1060
|
+
################################################################################
|
|
1061
|
+
|
|
1062
|
+
def __key_filter_moved(self):
|
|
1063
|
+
""" Show/Hide moved files """
|
|
1064
|
+
|
|
1065
|
+
self.filter_moved = not self.filter_moved
|
|
1066
|
+
|
|
1067
|
+
with PopUp(self.screen, '%s moved files' % ('Hiding' if self.filter_moved else 'Showing')):
|
|
1068
|
+
self.__update_file_list()
|
|
1069
|
+
|
|
1070
|
+
################################################################################
|
|
1071
|
+
|
|
1072
|
+
def __key_filter_deleted(self):
|
|
1073
|
+
""" Show/Hide deleted files """
|
|
1074
|
+
|
|
1075
|
+
self.filter_deleted = not self.filter_deleted
|
|
1076
|
+
|
|
1077
|
+
with PopUp(self.screen, '%s deleted files' % ('Hiding' if self.filter_deleted else 'Showing')):
|
|
1078
|
+
self.__update_file_list()
|
|
1079
|
+
|
|
1080
|
+
################################################################################
|
|
1081
|
+
|
|
1082
|
+
def __key_filter_modified(self):
|
|
1083
|
+
""" Show/Hide modified files """
|
|
1084
|
+
|
|
1085
|
+
self.filter_modified = not self.filter_modified
|
|
1086
|
+
|
|
1087
|
+
with PopUp(self.screen, '%s modified files' % ('Hiding' if self.filter_modified else 'Showing')):
|
|
1088
|
+
self.__update_file_list()
|
|
1089
|
+
|
|
1090
|
+
################################################################################
|
|
1091
|
+
|
|
1092
|
+
def __key_filter_added(self):
|
|
1093
|
+
""" Show/Hide added files """
|
|
1094
|
+
|
|
1095
|
+
self.filter_added = not self.filter_added
|
|
1096
|
+
|
|
1097
|
+
with PopUp(self.screen, '%s added files' % ('Hiding' if self.filter_added else 'Showing')):
|
|
1098
|
+
self.__update_file_list()
|
|
1099
|
+
|
|
1100
|
+
################################################################################
|
|
1101
|
+
|
|
1102
|
+
def __key_filter_whitespace(self):
|
|
1103
|
+
""" Show/Hide files with only whitespace changes """
|
|
1104
|
+
|
|
1105
|
+
self.filter_none_whitespace_only = not self.filter_none_whitespace_only
|
|
1106
|
+
|
|
1107
|
+
with PopUp(self.screen, '%s files with only whitespace changes' % ('Hiding' if self.filter_none_whitespace_only else 'Showing')):
|
|
1108
|
+
self.__update_file_list()
|
|
1109
|
+
|
|
1110
|
+
################################################################################
|
|
1111
|
+
|
|
1112
|
+
def __key_filter_show_non_whitespace(self):
|
|
1113
|
+
""" Show full or non-whitespace diff stats """
|
|
1114
|
+
|
|
1115
|
+
self.show_none_whitespace_stats = not self.show_none_whitespace_stats
|
|
1116
|
+
|
|
1117
|
+
with PopUp(self.screen, 'Showing non-whitespace diff stats' if self.show_none_whitespace_stats else 'Showing full diff stats'):
|
|
1118
|
+
self.__update_file_list()
|
|
1119
|
+
|
|
1120
|
+
################################################################################
|
|
1121
|
+
|
|
1122
|
+
def __key_mark_reviewed(self):
|
|
1123
|
+
""" Mark files as reviewed that match a wildcard """
|
|
1124
|
+
|
|
1125
|
+
reviewed = read_input('Mark files matching: ')
|
|
1126
|
+
|
|
1127
|
+
if reviewed:
|
|
1128
|
+
for entry in self.changed_files:
|
|
1129
|
+
if fnmatch.fnmatch(entry['name'], reviewed):
|
|
1130
|
+
entry['reviewed'] = True
|
|
1131
|
+
self.__update_file_list()
|
|
1132
|
+
|
|
1133
|
+
################################################################################
|
|
1134
|
+
|
|
1135
|
+
def __key_mark_unreviewed(self):
|
|
1136
|
+
""" Unmark files as reviewed that match a wildcard """
|
|
1137
|
+
|
|
1138
|
+
reviewed = read_input('Unmark files matching: ')
|
|
1139
|
+
|
|
1140
|
+
if reviewed:
|
|
1141
|
+
for entry in self.changed_files:
|
|
1142
|
+
if fnmatch.fnmatch(entry['name'], reviewed):
|
|
1143
|
+
entry['reviewed'] = False
|
|
1144
|
+
self.__update_file_list()
|
|
1145
|
+
|
|
1146
|
+
################################################################################
|
|
1147
|
+
|
|
1148
|
+
def __key_filter_dir(self):
|
|
1149
|
+
""" Only show files in or under the current file's directory """
|
|
1150
|
+
|
|
1151
|
+
self.filter_dir = os.path.dirname(os.path.join(self.working_tree_dir, self.file_list[self.current]['name']))
|
|
1152
|
+
|
|
1153
|
+
with PopUp(self.screen, f'Only showing files in {self.filter_dir}'):
|
|
1154
|
+
self.__update_file_list()
|
|
1155
|
+
|
|
1156
|
+
################################################################################
|
|
1157
|
+
|
|
1158
|
+
def __key_filter_current_dir(self):
|
|
1159
|
+
""" Only show files in or under the current directory """
|
|
1160
|
+
|
|
1161
|
+
self.filter_dir = self.current_dir
|
|
1162
|
+
|
|
1163
|
+
with PopUp(self.screen, f'Only showing files in {self.filter_dir}'):
|
|
1164
|
+
self.__update_file_list()
|
|
1165
|
+
|
|
1166
|
+
################################################################################
|
|
1167
|
+
|
|
1168
|
+
def __key_clear_filters(self):
|
|
1169
|
+
""" Clear filters """
|
|
1170
|
+
|
|
1171
|
+
with PopUp(self.screen, 'Cleared all filters'):
|
|
1172
|
+
self.__clear_filters()
|
|
1173
|
+
|
|
1174
|
+
################################################################################
|
|
1175
|
+
|
|
1176
|
+
def __key_move_down(self):
|
|
1177
|
+
""" Move down 1 line """
|
|
1178
|
+
|
|
1179
|
+
self.current += 1
|
|
1180
|
+
|
|
1181
|
+
################################################################################
|
|
1182
|
+
|
|
1183
|
+
def __key_move_up(self):
|
|
1184
|
+
""" Move up 1 line """
|
|
1185
|
+
|
|
1186
|
+
self.current -= 1
|
|
1187
|
+
|
|
1188
|
+
################################################################################
|
|
1189
|
+
|
|
1190
|
+
def __key_move_page_down(self):
|
|
1191
|
+
""" Move down by a page """
|
|
1192
|
+
|
|
1193
|
+
pos = self.current - self.offset
|
|
1194
|
+
self.offset += self.file_list_h - 1
|
|
1195
|
+
self.current = self.offset + pos
|
|
1196
|
+
|
|
1197
|
+
################################################################################
|
|
1198
|
+
|
|
1199
|
+
def __key_move_page_up(self):
|
|
1200
|
+
""" Move up by a page """
|
|
1201
|
+
|
|
1202
|
+
pos = self.current - self.offset
|
|
1203
|
+
self.offset -= self.file_list_h - 1
|
|
1204
|
+
self.current = self.offset + pos
|
|
1205
|
+
|
|
1206
|
+
################################################################################
|
|
1207
|
+
|
|
1208
|
+
def __key_move_top(self):
|
|
1209
|
+
""" Move to the top of the file list """
|
|
1210
|
+
|
|
1211
|
+
self.current = 0
|
|
1212
|
+
|
|
1213
|
+
################################################################################
|
|
1214
|
+
|
|
1215
|
+
def __key_move_end(self):
|
|
1216
|
+
""" Move to the end of the file list """
|
|
1217
|
+
|
|
1218
|
+
self.current = len(self.file_list) - 1
|
|
1219
|
+
|
|
1220
|
+
################################################################################
|
|
1221
|
+
|
|
1222
|
+
def __key_cycle_search(self):
|
|
1223
|
+
""" Cycle through the various sort options """
|
|
1224
|
+
|
|
1225
|
+
self.sort_order = (self.sort_order + 1) % SortOrder.NUM_SORTS
|
|
1226
|
+
|
|
1227
|
+
self.__update_sort()
|
|
1228
|
+
|
|
1229
|
+
################################################################################
|
|
1230
|
+
|
|
1231
|
+
def __sort_type_msg(self):
|
|
1232
|
+
if self.sort_order == SortOrder.PATH:
|
|
1233
|
+
sort_type = 'path'
|
|
1234
|
+
elif self.sort_order == SortOrder.FILENAME:
|
|
1235
|
+
sort_type = 'filename'
|
|
1236
|
+
if self.sort_order == SortOrder.EXTENSION:
|
|
1237
|
+
sort_type = 'extension'
|
|
1238
|
+
|
|
1239
|
+
if self.reverse_sort:
|
|
1240
|
+
msg = f'Reverse-sorting by {sort_type}'
|
|
1241
|
+
else:
|
|
1242
|
+
msg = f'Sorting by {sort_type}'
|
|
1243
|
+
|
|
1244
|
+
return msg
|
|
1245
|
+
|
|
1246
|
+
################################################################################
|
|
1247
|
+
|
|
1248
|
+
def __update_sort(self):
|
|
1249
|
+
|
|
1250
|
+
msg = self.__sort_type_msg()
|
|
1251
|
+
|
|
1252
|
+
with PopUp(self.screen, msg):
|
|
1253
|
+
self.__update_file_list()
|
|
1254
|
+
|
|
1255
|
+
################################################################################
|
|
1256
|
+
|
|
1257
|
+
def __key_reverse_sort(self):
|
|
1258
|
+
""" Reverse the current sort order """
|
|
1259
|
+
|
|
1260
|
+
self.reverse_sort = not self.reverse_sort
|
|
1261
|
+
|
|
1262
|
+
self.__update_sort()
|
|
1263
|
+
|
|
1264
|
+
################################################################################
|
|
1265
|
+
|
|
1266
|
+
def done(self):
|
|
1267
|
+
""" Quit """
|
|
1268
|
+
|
|
1269
|
+
return self.finished
|
|
1270
|
+
|
|
1271
|
+
################################################################################
|
|
1272
|
+
|
|
1273
|
+
def handle_keypress(self, keypress):
|
|
1274
|
+
""" Handle a key press """
|
|
1275
|
+
|
|
1276
|
+
if keypress in self.key_despatch_table:
|
|
1277
|
+
self.key_despatch_table[keypress]['function']()
|
|
1278
|
+
|
|
1279
|
+
# Keep the current entry in range
|
|
1280
|
+
|
|
1281
|
+
self.__constrain_display_parameters()
|
|
1282
|
+
|
|
1283
|
+
################################################################################
|
|
1284
|
+
|
|
1285
|
+
def parse_command_line():
|
|
1286
|
+
""" Parse the command line, return the arguments """
|
|
1287
|
+
|
|
1288
|
+
parser = argparse.ArgumentParser(description='Menu-driven Git code review tool')
|
|
1289
|
+
|
|
1290
|
+
parser.add_argument('--commit', '-c', type=str, help='Compare the specified commit with its parent')
|
|
1291
|
+
parser.add_argument('--branch', '-b', type=str, help='Compare the specified commit to branch point on specified branch')
|
|
1292
|
+
parser.add_argument('--change', '-C', action='store_true', help='Compare the current commit with its parent')
|
|
1293
|
+
parser.add_argument('--debug', '-d', action='store_true', help='Start a debug session over Telnet using pudb')
|
|
1294
|
+
parser.add_argument('--dir', action='store', help='Work in the specified directory')
|
|
1295
|
+
parser.add_argument('--difftool', type=str, default=None, help='Override the default git diff tool')
|
|
1296
|
+
|
|
1297
|
+
parser.add_argument('commits', nargs='*', help='Commit(s) or paths to compare')
|
|
1298
|
+
|
|
1299
|
+
args = parser.parse_args()
|
|
1300
|
+
|
|
1301
|
+
args.paths = None
|
|
1302
|
+
|
|
1303
|
+
if args.debug:
|
|
1304
|
+
from pudb.remote import set_trace
|
|
1305
|
+
set_trace()
|
|
1306
|
+
|
|
1307
|
+
# Move to a new directory, if required
|
|
1308
|
+
|
|
1309
|
+
if args.dir:
|
|
1310
|
+
os.chdir(args.dir)
|
|
1311
|
+
|
|
1312
|
+
# Make sure that we're actually in a git working tree
|
|
1313
|
+
|
|
1314
|
+
if not git.working_tree():
|
|
1315
|
+
colour.error('Not a git repository', prefix=True)
|
|
1316
|
+
|
|
1317
|
+
# -C/--change is shorthand for '--commit HEAD^'
|
|
1318
|
+
|
|
1319
|
+
if args.change:
|
|
1320
|
+
if args.commits:
|
|
1321
|
+
colour.error('The -C/--change option does not take parameters', prefix=True)
|
|
1322
|
+
|
|
1323
|
+
args.commits = ['HEAD^']
|
|
1324
|
+
|
|
1325
|
+
# Validate the parameters (if any) as commits or paths.
|
|
1326
|
+
# If the parameter matches a commit (SHA1, tag or branch) then assume it is one
|
|
1327
|
+
# If it matches an existing path, assume that is what it is and don't permit
|
|
1328
|
+
# following parameters to be commits.
|
|
1329
|
+
# Otherwise fail with an error.
|
|
1330
|
+
|
|
1331
|
+
if args.commits:
|
|
1332
|
+
paths = []
|
|
1333
|
+
commits = []
|
|
1334
|
+
parsing_commits = True
|
|
1335
|
+
|
|
1336
|
+
for entry in args.commits:
|
|
1337
|
+
if parsing_commits:
|
|
1338
|
+
matches = git.matching_commit(entry)
|
|
1339
|
+
|
|
1340
|
+
if len(matches) == 1:
|
|
1341
|
+
commits.append(matches[0])
|
|
1342
|
+
else:
|
|
1343
|
+
parsing_commits = False
|
|
1344
|
+
|
|
1345
|
+
if not parsing_commits:
|
|
1346
|
+
# TODO: Disabled as this does not work with files: elif os.path.exists(entry):
|
|
1347
|
+
if os.path.isdir(entry):
|
|
1348
|
+
paths.append(entry)
|
|
1349
|
+
parsing_commits = False
|
|
1350
|
+
else:
|
|
1351
|
+
colour.error(f'Invalid path/commit: {entry}', prefix=True)
|
|
1352
|
+
|
|
1353
|
+
args.commits = commits
|
|
1354
|
+
args.paths = paths
|
|
1355
|
+
|
|
1356
|
+
# Validate the commits & paths
|
|
1357
|
+
|
|
1358
|
+
if len(args.commits) > 2:
|
|
1359
|
+
colour.error('No more than 2 commits can be specified', prefix=True)
|
|
1360
|
+
|
|
1361
|
+
if (args.branch or args.commit) and args.commits:
|
|
1362
|
+
colour.error('Additional commits should not be specified in conjunction with the -b/--branch option', prefix=True)
|
|
1363
|
+
|
|
1364
|
+
if args.commit and args.branch:
|
|
1365
|
+
colour.error('The -c/--commit and -b/--branch options are mutually exclusive', prefix=True)
|
|
1366
|
+
|
|
1367
|
+
# If the -c/--commit option is used, then review against its parent
|
|
1368
|
+
# If the -b/--branch option is used, then review against the oldest common ancestor
|
|
1369
|
+
# If no parameters or -c/--commit option then review against HEAD
|
|
1370
|
+
|
|
1371
|
+
if args.branch:
|
|
1372
|
+
try:
|
|
1373
|
+
args.commits = [git.find_common_ancestor('HEAD', args.branch)]
|
|
1374
|
+
except git.GitError as exc:
|
|
1375
|
+
colour.error(exc, status=exc.status, prefix=True)
|
|
1376
|
+
|
|
1377
|
+
elif args.commit:
|
|
1378
|
+
args.commits = ['%s^' % args.commit, args.commit]
|
|
1379
|
+
|
|
1380
|
+
elif not args.commits:
|
|
1381
|
+
args.commits = ['HEAD']
|
|
1382
|
+
|
|
1383
|
+
# Validate the commits we are comparing (yes, this partially duplicates the parameter check code but this
|
|
1384
|
+
# covers defaults and the -c/--commit parameter, if used).
|
|
1385
|
+
|
|
1386
|
+
for i, entry in enumerate(args.commits):
|
|
1387
|
+
matches = git.matching_commit(entry)
|
|
1388
|
+
|
|
1389
|
+
if matches:
|
|
1390
|
+
if len(matches) == 1:
|
|
1391
|
+
args.commits[i] = matches[0]
|
|
1392
|
+
else:
|
|
1393
|
+
colour.error(f'Multiple commits match {entry}', prefix=True)
|
|
1394
|
+
else:
|
|
1395
|
+
colour.error(f'{entry} is not a valid commit ID', prefix=True)
|
|
1396
|
+
|
|
1397
|
+
# Things work easier if we always have two commits to compare
|
|
1398
|
+
|
|
1399
|
+
if len(args.commits) == 1:
|
|
1400
|
+
args.commits.append(None)
|
|
1401
|
+
|
|
1402
|
+
return args
|
|
1403
|
+
|
|
1404
|
+
################################################################################
|
|
1405
|
+
|
|
1406
|
+
def main(screen, args):
|
|
1407
|
+
""" Parse the command line and run the review """
|
|
1408
|
+
|
|
1409
|
+
review = GitReview(screen, args)
|
|
1410
|
+
|
|
1411
|
+
while not review.done():
|
|
1412
|
+
review.draw_screen()
|
|
1413
|
+
|
|
1414
|
+
review.show_file_list()
|
|
1415
|
+
|
|
1416
|
+
keypress = screen.getch()
|
|
1417
|
+
|
|
1418
|
+
review.handle_keypress(keypress)
|
|
1419
|
+
|
|
1420
|
+
review.save_state()
|
|
1421
|
+
|
|
1422
|
+
return review.status_code
|
|
1423
|
+
|
|
1424
|
+
################################################################################
|
|
1425
|
+
|
|
1426
|
+
def git_review():
|
|
1427
|
+
"""Entry point"""
|
|
1428
|
+
|
|
1429
|
+
try:
|
|
1430
|
+
command_args = parse_command_line()
|
|
1431
|
+
|
|
1432
|
+
statcode = curses.wrapper(main, command_args)
|
|
1433
|
+
|
|
1434
|
+
sys.exit(statcode)
|
|
1435
|
+
|
|
1436
|
+
except KeyboardInterrupt:
|
|
1437
|
+
sys.exit(1)
|
|
1438
|
+
|
|
1439
|
+
except BrokenPipeError:
|
|
1440
|
+
sys.exit(2)
|
|
1441
|
+
|
|
1442
|
+
except GitReviewError as exc:
|
|
1443
|
+
sys.stderr.write(exc.msg)
|
|
1444
|
+
sys.stderr.write('\n')
|
|
1445
|
+
sys.exit(exc.status)
|
|
1446
|
+
|
|
1447
|
+
################################################################################
|
|
1448
|
+
|
|
1449
|
+
if __name__ == '__main__':
|
|
1450
|
+
git_review()
|