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.
Files changed (39) hide show
  1. skilleter_thingy/__init__.py +0 -0
  2. skilleter_thingy/addpath.py +107 -0
  3. skilleter_thingy/console_colours.py +63 -0
  4. skilleter_thingy/ffind.py +535 -0
  5. skilleter_thingy/ggit.py +88 -0
  6. skilleter_thingy/ggrep.py +155 -0
  7. skilleter_thingy/git_br.py +186 -0
  8. skilleter_thingy/git_ca.py +147 -0
  9. skilleter_thingy/git_cleanup.py +297 -0
  10. skilleter_thingy/git_co.py +227 -0
  11. skilleter_thingy/git_common.py +68 -0
  12. skilleter_thingy/git_hold.py +162 -0
  13. skilleter_thingy/git_parent.py +84 -0
  14. skilleter_thingy/git_retag.py +67 -0
  15. skilleter_thingy/git_review.py +1450 -0
  16. skilleter_thingy/git_update.py +398 -0
  17. skilleter_thingy/git_wt.py +72 -0
  18. skilleter_thingy/gitcmp_helper.py +328 -0
  19. skilleter_thingy/gitprompt.py +293 -0
  20. skilleter_thingy/linecount.py +154 -0
  21. skilleter_thingy/multigit.py +915 -0
  22. skilleter_thingy/py_audit.py +133 -0
  23. skilleter_thingy/remdir.py +127 -0
  24. skilleter_thingy/rpylint.py +98 -0
  25. skilleter_thingy/strreplace.py +82 -0
  26. skilleter_thingy/test.py +34 -0
  27. skilleter_thingy/tfm.py +948 -0
  28. skilleter_thingy/tfparse.py +101 -0
  29. skilleter_thingy/trimpath.py +82 -0
  30. skilleter_thingy/venv_create.py +47 -0
  31. skilleter_thingy/venv_template.py +47 -0
  32. skilleter_thingy/xchmod.py +124 -0
  33. skilleter_thingy/yamlcheck.py +89 -0
  34. skilleter_thingy-0.3.14.dist-info/METADATA +606 -0
  35. skilleter_thingy-0.3.14.dist-info/RECORD +39 -0
  36. skilleter_thingy-0.3.14.dist-info/WHEEL +5 -0
  37. skilleter_thingy-0.3.14.dist-info/entry_points.txt +31 -0
  38. skilleter_thingy-0.3.14.dist-info/licenses/LICENSE +619 -0
  39. 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()