skilleter-thingy 0.0.40__py3-none-any.whl → 0.0.41__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.

Potentially problematic release.


This version of skilleter-thingy might be problematic. Click here for more details.

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