Repository navigation
Expand file tree
/
Copy pathgitr.py
More file actions
executable file
·4562 lines (4097 loc) · 203 KB
/
Copy pathgitr.py
File metadata and controls
executable file
·4562 lines (4097 loc) · 203 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""gitr - lightweight Git diff viewer"""
import argparse
import bisect
import difflib
import hashlib
import json
import os
import re
import signal
import subprocess
import sys
import textwrap
import time
import tkinter as tk
from collections import deque
from itertools import accumulate
from tkinter import font as tkfont
from tkinter import messagebox
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
_CONFIG_PATH = Path.home() / '.config' / 'gitr' / 'config.json'
USAGE = """\
usage:
gitr # git diff (unstaged changes)
gitr master # git diff master (to working tree)
gitr --merge-base master # diff from common ancestor to working tree
gitr master HEAD # git diff master HEAD (committed only)
git diff | gitr # pipe a patch
gitr - # read stdin explicitly
gitr -p patch.diff # read from a patch file
gitr --export master # print the review comments for an agent, no window
gitr --export --kinds bad,note # only these kinds (default: all)
GITR_SCALE=2 gitr master # scale UI up (HiDPI)
"""
# --data structures ----------------------------------------------------------
@dataclass
class FileEntry:
path: str
status: str # A M D R
additions: int = 0
deletions: int = 0
@dataclass
class DiffLine:
text: str
kind: str # added | removed | context | hunk | fileheader
# Post-image line number (None for hunk/fileheader; for removed lines this
# is the new-side position the line would occupy if restored, i.e. the
# post-image line number of the next + or context line in the hunk).
new_line_no: Optional[int] = None
old_line_no: Optional[int] = None # pre-image line number, similarly defined
@dataclass
class DiffFile:
path: str
lines: list[DiffLine] = field(default_factory=list)
status: str = 'M'
old_path: str = ''
index: str = ''
@dataclass
class _CommentEditTarget:
"""In-flight state for the inline comment editor. Set when the editor
opens, consumed (or discarded) when the user confirms or cancels."""
file: str
new_line_no: int
side: str
line_text: str
# Non-None when editing an existing comment; None for a fresh comment
# whose snapshot will be taken at confirm time.
existing_snapshot: Optional[str] = None
existing_snap_line_no: Optional[int] = None
@dataclass
class _ResolvedAnchor:
"""A comment entry mapped through its snapshot to a position in the
current diff. ``target_line_no`` is the post-image line number we expect
to find the line at (after diffing the snapshot vs. the current file);
``moved`` is set when the line shifted or its text changed."""
file: str
snapshot: str
snap_line_no: int # line_no stored with the entry
target_line_no: int # snap_line_no remapped to the current file
side: str # '+', '-', or ' '
line_text: str # diff line text including +/-/ prefix
comment: str
kind: str = 'note'
moved: bool = False
matched: bool = False
src_line: Optional[int] = None # diff Text line number once rendered
frame: Optional[tk.Frame] = None # the rendered comment widget, once embedded
label: Optional[tk.Label] = None # its text, re-wrapped when the width changes
# --git helpers ------------------------------------------------------------
def try_current_branch() -> str:
r = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else ''
# --diff sources ------------------------------------------------------------
class PatchSource:
"""Diff text from stdin or a patch file. Cannot fetch full file contents."""
def __init__(self, text: str, label: str = '') -> None:
self._text = text
self._label = label
def diff_text(self) -> str:
return self._text
def label(self) -> str:
return self._label
def commits(self) -> list[tuple[str, str]]:
return []
def has_staged(self) -> bool:
return False
def has_unstaged(self) -> bool:
return False
def can_reload(self) -> bool:
return False
class GitSource:
"""Diff text from a live git invocation. Can also fetch full file contents."""
def __init__(self, refs: list[str], merge_base: bool = False) -> None:
self._refs = refs
self._merge_base = merge_base
def diff_text(self) -> str:
try:
if self._merge_base:
sha = subprocess.check_output(
['git', 'merge-base', self._refs[0], 'HEAD'],
text=True, stderr=subprocess.PIPE).strip()
return subprocess.check_output(
['git', 'diff', '--no-color', sha], text=True, stderr=subprocess.PIPE)
return subprocess.check_output(
['git', 'diff', '--no-color'] + self._refs, text=True, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
sys.exit(f'gitr: git command failed: {e.stderr.strip()}')
except FileNotFoundError:
sys.exit('gitr: git not found in PATH')
def label(self) -> str:
if self._merge_base:
return f'--merge-base {self._refs[0]}'
return ' '.join(self._refs)
@staticmethod
def _has_changes(cmd: list[str]) -> bool:
try:
return subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) != 0
except FileNotFoundError:
return False
def has_staged(self) -> bool:
return self._has_changes(['git', 'diff', '--cached', '--quiet'])
def has_unstaged(self) -> bool:
return self._has_changes(['git', 'diff', '--quiet'])
def can_reload(self) -> bool:
return True
def commits(self) -> list[tuple[str, str]]:
try:
if self._merge_base:
sha = subprocess.check_output(
['git', 'merge-base', self._refs[0], 'HEAD'],
text=True, stderr=subprocess.PIPE).strip()
range_arg = f'{sha}..HEAD'
elif len(self._refs) == 0:
return []
elif len(self._refs) == 1:
r = self._refs[0]
range_arg = r.replace('...', '..') if '..' in r else f'{r}..HEAD'
elif len(self._refs) == 2:
range_arg = f'{self._refs[0]}..{self._refs[1]}'
else:
return []
out = subprocess.check_output(
['git', 'log', '--pretty=format:%h%x09%s', range_arg],
text=True, stderr=subprocess.PIPE)
return [tuple(line.split('\t', 1)) for line in out.splitlines() if '\t' in line]
except (subprocess.CalledProcessError, FileNotFoundError):
return []
def _find_repo_root() -> 'Path | None':
for d in [Path.cwd(), *Path.cwd().parents]:
if (d / '.git').is_dir() or (d / '.git').is_file():
return d
return None
def _find_gitr_dir() -> 'Path | None':
root = _find_repo_root()
return (root / '.gitr') if root else None
def _read_text_safe(path: Path) -> 'str | None':
try:
return path.read_text()
except (OSError, UnicodeDecodeError):
return None
def _load_window_state() -> 'dict | None':
gitr_dir = _find_gitr_dir()
if not gitr_dir:
return None
p = gitr_dir / 'window.json'
if not p.exists():
return None
try:
obj = json.loads(p.read_text())
except (OSError, json.JSONDecodeError):
return None
return obj if isinstance(obj, dict) else None
def _save_window_state(geometry: str, sash_ratio: float, top_line: int) -> None:
gitr_dir = _find_gitr_dir()
if not gitr_dir:
return
state = {'geometry': geometry, 'sash_ratio': sash_ratio, 'top_line': top_line}
try:
gitr_dir.mkdir(parents=True, exist_ok=True)
(gitr_dir / 'window.json').write_text(json.dumps(state, indent=2))
except OSError:
pass
def _compute_line_map(snapshot: str, current: str) -> dict[int, int]:
"""Return a mapping {snapshot_line_no -> current_line_no} (1-based).
For 'equal' opcode blocks the mapping is exact. For 'replace' / 'delete'
blocks each snapshot line maps to the closest surviving current line
(clamped to the new block's range, or to the line just before for pure
deletes). The caller decides what to do with such mappings (typically
flag the comment as 'moved').
"""
a = snapshot.splitlines()
b = current.splitlines()
sm = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
out: dict[int, int] = {}
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == 'equal':
for k in range(i2 - i1):
out[i1 + k + 1] = j1 + k + 1
elif tag == 'replace':
span_b = max(j2 - j1, 1)
for k in range(i2 - i1):
out[i1 + k + 1] = min(j2, j1 + k * span_b // max(i2 - i1, 1)) + 1
elif tag == 'delete':
# Anchor each deleted snapshot line to the surviving current line
# right before the delete. For a pure prefix delete (j1 == 0)
# there's no surviving line above, so leave it unmapped — the
# caller falls back to the original line_no, which won't match
# any rendered diff line and routes the comment to the orphan
# section instead of attaching it to unrelated code at line 1.
if j1 == 0:
continue
for k in range(i2 - i1):
out[i1 + k + 1] = j1
return out
class ReviewStore:
"""Comments anchored to a snapshot of the file at creation time. On
lookup the snapshot is diffed against the current working-tree file to
remap line numbers, so a comment stays near the right line even after
the file is edited.
JSON layout (.gitr/review.json):
{"files": {"<path>": [
{"snapshot": "<sha1>", "line_no": <int>, "side": "+|-| ",
"line_text": "<diff line text>", "comment": "<text>",
"kind": "note|good|bad"} (absent = note)
]}}
Snapshot blobs live in .gitr/snapshots/<sha1> as plain text. Multiple
comments on the same file at the same time share one snapshot.
TODO: GC unreferenced snapshot files periodically (e.g. on store load
when the count exceeds a threshold).
"""
def __init__(self) -> None:
gitr_dir = _find_gitr_dir()
self._gitr_dir = gitr_dir
self._path = (gitr_dir / 'review.json') if gitr_dir else None
self._snap_dir = (gitr_dir / 'snapshots') if gitr_dir else None
self._data: dict[str, list[dict]] = {}
# Cache of snapshot content keyed by sha; populated on demand.
self._snap_cache: dict[str, str] = {}
self._load()
def _load(self) -> None:
if not (self._path and self._path.exists()):
return
try:
obj = json.loads(self._path.read_text())
except (OSError, json.JSONDecodeError):
return
if not isinstance(obj, dict):
# Old list-shaped review.json from before the snapshot anchor
# rewrite. The comments there don't have snapshots and can't be
# remapped, so they're ignored. Tell the user once so the data
# vanishing isn't a surprise.
print(f'gitr: ignoring legacy {self._path} '
'(snapshot-based anchors required)', file=sys.stderr)
return
files = obj.get('files')
if isinstance(files, dict):
self._data = {f: list(entries) for f, entries in files.items()
if isinstance(entries, list)}
def _save(self) -> None:
if not self._path:
return
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(json.dumps({'files': self._data}, indent=2))
except OSError:
pass
def write_snapshot(self, content: str) -> 'str | None':
"""Persist ``content`` under .gitr/snapshots/<sha1> and return the sha,
or ``None`` if the snapshot can't be persisted. Returning ``None``
prevents callers from storing an entry whose snapshot won't survive
this process — without persistence the in-memory cache is the only
copy and remap on the next run would silently degrade."""
sha = hashlib.sha1(content.encode('utf-8', errors='replace')).hexdigest()
if not self._snap_dir:
return None
try:
self._snap_dir.mkdir(parents=True, exist_ok=True)
p = self._snap_dir / sha
if not p.exists():
p.write_text(content)
except OSError as e:
print(f'gitr: cannot write snapshot {sha}: {e}', file=sys.stderr)
return None
self._snap_cache[sha] = content
return sha
def read_snapshot(self, sha: str) -> 'str | None':
if sha in self._snap_cache:
return self._snap_cache[sha]
if not self._snap_dir:
return None
text = _read_text_safe(self._snap_dir / sha)
if text is not None:
self._snap_cache[sha] = text
return text
def add(self, file: str, snapshot: str, line_no: int, side: str,
line_text: str, comment: str, kind: str = 'note') -> None:
entries = self._data.setdefault(file, [])
for e in entries:
if (e.get('snapshot') == snapshot and e.get('line_no') == line_no
and e.get('side') == side):
e['line_text'] = line_text
e['comment'] = comment
e['kind'] = kind
self._save()
return
entries.append({'snapshot': snapshot, 'line_no': line_no, 'side': side,
'line_text': line_text, 'comment': comment, 'kind': kind})
self._save()
def delete(self, file: str, snapshot: str, line_no: int, side: str) -> None:
entries = self._data.get(file)
if not entries:
return
for i, e in enumerate(entries):
if (e.get('snapshot') == snapshot and e.get('line_no') == line_no
and e.get('side') == side):
del entries[i]
if not entries:
del self._data[file]
self._save()
return
def all_entries(self) -> list[tuple[str, dict]]:
return [(f, e) for f in sorted(self._data) for e in self._data[f]]
def is_empty(self) -> bool:
return not any(self._data.values())
def backup_path(self) -> 'Path | None':
"""Where clear() keeps the previous file: review-<timestamp>.json.bak
beside it, so repeated clears never overwrite an earlier backup."""
if not self._path:
return None
stamp = time.strftime('%Y%m%d-%H%M%S')
return self._path.with_name(f'review-{stamp}.json.bak')
def clear(self, backup: 'Path | None') -> bool:
"""Drop every comment, first moving the current file to `backup` so
a mis-click can be undone by renaming it back. Nothing is dropped
when that move fails; returns whether the store was cleared."""
if not self._data:
return True
if backup is not None and self._path and self._path.exists():
try:
self._path.replace(backup) # one atomic rename, no partial copy
except OSError:
return False
self._data.clear()
self._save()
return True
# --diff parsing ------------------------------------------------------------
_FILEHEADER_PREFIXES = (
'diff ', 'index ', '--- ', '+++ ',
'new file', 'deleted file', 'old mode', 'new mode', 'rename ',
)
def _classify(line: str) -> str:
if line.startswith(_FILEHEADER_PREFIXES):
return 'fileheader'
if line.startswith('@@ '):
return 'hunk'
if line.startswith('+'):
return 'added'
if line.startswith('-'):
return 'removed'
return 'context'
_HUNK_RE = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@')
def parse_diff(text: str) -> list[DiffFile]:
files: list[DiffFile] = []
current: Optional[DiffFile] = None
cur_old = cur_new = 0 # next line numbers within the active hunk
for raw in text.splitlines():
if raw.startswith('diff --git '):
if current is not None:
files.append(current)
b_idx = raw.rfind(' b/')
path = raw[b_idx + 3:] if b_idx != -1 else 'unknown'
current = DiffFile(path)
cur_old = cur_new = 0
if current is not None:
kind = _classify(raw)
new_no: Optional[int] = None
old_no: Optional[int] = None
if kind == 'hunk':
m = _HUNK_RE.match(raw)
if m:
cur_old = int(m.group(1))
cur_new = int(m.group(2))
elif kind == 'context':
old_no, new_no = cur_old, cur_new
cur_old += 1
cur_new += 1
elif kind == 'added':
new_no = cur_new
cur_new += 1
elif kind == 'removed':
old_no = cur_old
# Anchor a removed line to the post-image position it sits
# between (same as the next + / context line that follows it).
new_no = cur_new
cur_old += 1
dl = DiffLine(raw, kind, new_line_no=new_no, old_line_no=old_no)
current.lines.append(dl)
if kind == 'fileheader':
if raw.startswith('new file'):
current.status = 'A'
elif raw.startswith('deleted file'):
current.status = 'D'
elif raw.startswith('rename from '):
current.status = 'R'
current.old_path = raw[len('rename from '):]
elif raw.startswith('index '):
current.index = raw
if current is not None:
files.append(current)
return files
def entries_from_diff(diff_files: list[DiffFile]) -> list[FileEntry]:
return [
FileEntry(df.path, df.status,
sum(1 for l in df.lines if l.kind == 'added'),
sum(1 for l in df.lines if l.kind == 'removed'))
for df in diff_files
]
def _build_tree_rows(
entries: list[FileEntry],
) -> list[tuple[str, int, 'FileEntry | None']]:
"""Return flat render list for tree view: (label, depth, entry_or_None).
Directories with a single subdirectory and no files are folded into their
child, so e.g. src/ -> main/ -> foo.py becomes ('src/main/', 0, None).
Children at each level are ordered by their earliest position in the
original entry list so tree order matches the diff panel order.
"""
trie: dict = {}
for i, e in enumerate(entries):
parts = e.path.split('/')
node = trie
for part in parts[:-1]:
node = node.setdefault(part, {})
node[parts[-1]] = (i, e) # leaf stores original index for ordering
rows: list[tuple[str, int, 'FileEntry | None']] = []
_walk_trie(trie, rows, 0, '')
return rows
def _trie_min_idx(node: dict) -> int:
best = 10 ** 9
for v in node.values():
if isinstance(v, tuple):
best = min(best, v[0])
elif isinstance(v, dict):
best = min(best, _trie_min_idx(v))
return best
def _walk_trie(node: dict, rows: list, depth: int, dir_label: str) -> None:
leaves = [(k, v) for k, v in node.items() if isinstance(v, tuple)]
dirs = [(k, v) for k, v in node.items() if isinstance(v, dict)]
# Fold single-child dir chains that contain no files.
if not leaves and len(dirs) == 1:
name, child = dirs[0]
_walk_trie(child, rows, depth, dir_label + name + '/')
return
if dir_label:
rows.append((dir_label, depth, None))
depth += 1
# Interleave files and subdirs in original diff order.
children: list[tuple[int, str, 'FileEntry | None', 'dict | None']] = []
for name, (idx, entry) in leaves:
children.append((idx, name, entry, None))
for name, child in dirs:
children.append((_trie_min_idx(child), name, None, child))
children.sort(key=lambda x: x[0])
for _, name, entry, child in children:
if entry is not None:
rows.append((name, depth, entry))
else:
_walk_trie(child, rows, depth, name + '/')
def _common_dir_prefix(prev: str, curr: str) -> str:
"""Return the directory prefix shared between prev and curr, at path boundaries."""
prev_dirs = prev.split('/')[:-1]
curr_dirs = curr.split('/')[:-1]
common = []
for a, b in zip(prev_dirs, curr_dirs):
if a == b:
common.append(a)
else:
break
return '/'.join(common) + '/' if common else ''
# --config -------------------------------------------------------------------
class CFG:
font_family = 'monospace'
font_size = 12
menu_font_size = 8
window_scale = 0.75 # fraction of screen size on startup
sash_ratio = 0.70
pane_min_w = 180 # px; neither sash pane collapses below this
scrollbar_w = 16
minimap_w = 160
overview_w = 6 # comment marker strip beside the scrollbar
overview_file_ticks = True # faint tick per file start on the strip
scroll_speed = 8 # lines per mouse-wheel tick
diff_hi_blend = 0.12 # bg intensity for changed lines / word-diff changed words
diff_dim_blend = 0.06 # bg intensity for word-diff unchanged words
diff_dim_fg = 0.50 # fg intensity for word-diff unchanged words
word_diff_min_ratio = 0.35 # below this similarity, fall back to plain line diff
word_diff_autojunk_tokens = 300 # longer lines use difflib's junk heuristic (much faster)
word_diff_max_block = 40000 # removed x added lines above which pairing is sequential
progress_interval_ms = 100 # how often the render progress label is refreshed
sigint_poll_ms = 200 # how often the event loop wakes to notice Ctrl+C
comment_wrap_min_cols = 20 # narrower than this: do not wrap comment text
render_chunk_ms = 30 # word-diff highlighting is applied in chunks of about this long
hover_hide_delay_ms = 150
hover_btn_leave_delay_ms = 80
edit_focus_out_delay_ms = 50
list_pane_max_lines = 10
menu_label_max_len = 80
message_ms = 2500 # how long a top-bar message (e.g. "copied") stays
section_collapsed_arrow = '▶'
section_expanded_arrow = '▼'
# --colour scheme (dracula) --------------------------------------------------
C = {
'bg': '#282a36',
'fg': '#f8f8f2',
'added_fg': '#50fa7b',
'added_bg': '#283636',
'removed_fg': '#ff5555',
'removed_bg': '#342a36',
'hunk_fg': '#ffb86c',
'fileheader_fg': '#bd93f9',
'subdued': '#6272a4',
'topbar_bg': '#44475a',
'selected_bg': '#44475a',
'status_A': '#50fa7b',
'status_M': '#bd93f9',
'status_D': '#ff5555',
'status_R': '#ff79c6',
'comment_fg': '#f1fa8c',
'good_fg': '#8be9fd',
'bad_fg': '#ff79c6',
}
# Review comment kinds: colour and the marker shown before the text (on
# screen and in the terminal dump, so the meaning survives without colour).
# Cyan and pink are the bright tones the diff itself does not use.
COMMENT_KINDS: dict[str, tuple[str, str]] = {
'note': (C['comment_fg'], '>>'),
'good': (C['good_fg'], '++'),
'bad': (C['bad_fg'], '!!'),
}
# One-click comments offered in the diff's context menu.
COMMENT_PRESETS: list[tuple[str, str]] = [
('okay', 'good'), ('wtf', 'bad'), ('why?', 'note'), ('nice', 'good'), ('no', 'bad'),
]
def _comment_kind(name: object) -> str:
return name if isinstance(name, str) and name in COMMENT_KINDS else 'note'
# --review export -------------------------------------------------------------
#
# One text shape for handing comments to a coding agent, shared by the
# clipboard entries in the Review menu and `gitr --export`: grouped by file,
# each item a path:line reference the agent can open, the quoted diff line
# and the comment, with the kind spelled out.
@dataclass
class _ExportItem:
path: str
line: Optional[int] # post-image line number; None when unknown
side: str
kind: str
text: str # diff line text including its +/-/space prefix
comment: str
moved: bool = False
orphaned: bool = False
_EXPORT_NOT_GOOD = [k for k in COMMENT_KINDS if k != 'good'] # what an agent has to act on
def _export_kinds(spec: str) -> list[str]:
"""Kinds named by --kinds: 'all' or a comma-separated list."""
if spec == 'all':
return list(COMMENT_KINDS)
kinds = [k.strip() for k in spec.split(',') if k.strip()]
bad = [k for k in kinds if k not in COMMENT_KINDS]
if bad or not kinds:
sys.exit(f'gitr: --kinds takes "all" or a list of {", ".join(COMMENT_KINDS)}')
return kinds
def _diff_side(kind: str) -> str:
"""The side letter a comment is stored with for a diff line kind."""
if kind == 'added': return '+'
if kind == 'removed': return '-'
return ' '
def _entry_is_valid(entry: dict) -> bool:
"""A stored comment that can be anchored; the others are ignored
everywhere (rendering, export, counts) so no view shows a comment the
others do not."""
return bool(entry.get('snapshot') and entry.get('line_no') and entry.get('comment'))
def _match_anchor(anchors: list['_ResolvedAnchor'], new_line_no: int, side: str,
text: str) -> '_ResolvedAnchor | None':
"""Claim the stored comment for a diff line: the one whose text still
matches wins over one at the same line whose text changed."""
exact: '_ResolvedAnchor | None' = None
loose: '_ResolvedAnchor | None' = None
for a in anchors:
if a.matched or a.target_line_no != new_line_no or a.side != side:
continue
if a.line_text == text:
exact = a
break
loose = loose or a
a = exact or loose
if a is None:
return None
a.matched = True
a.moved = (exact is None) or (a.snap_line_no != a.target_line_no)
return a
def _code_fence(text: str) -> str:
"""A backtick fence longer than any backtick run in `text`, so the
fence cannot be closed early by the content."""
return '`' * max(3, max((len(m) for m in re.findall('`+', text)), default=0) + 1)
def _format_export(items: list[_ExportItem], kinds: list[str]) -> str:
"""Markdown: a heading per file, a list item per comment. The diff line
goes in a fenced code block, since quoted or bare it would be parsed
(a leading + or - is a list bullet, # a heading, indentation a code
block); the fence is longer than any backtick run in the line."""
items = [i for i in items if i.kind in kinds] # in diff order, files contiguous
label = ', '.join(k for k in COMMENT_KINDS if k in kinds)
n = len(items)
out = [f'Review comments: {n} item{"s" if n != 1 else ""} ({label})', '']
path = None
for it in items:
if it.path != path:
path = it.path
out += [f'## {path}', '']
ref = f'{it.path}:{it.line}' if it.line else it.path
side = f' ({it.side})' if it.side in '+-' else ''
flags = [it.kind, 'orphaned'] if it.orphaned else [it.kind, 'moved'] if it.moved else [it.kind]
out.append(f'- {ref}{side} [{", ".join(flags)}]')
fence = _code_fence(it.text)
out += [f' {fence}diff', f' {it.text}', f' {fence}']
out += [f' {l}' for l in (it.comment.splitlines() or [''])]
out.append('')
out.append('---') # marks the end, e.g. among other terminal output
return '\n'.join(out)
def _resolve_anchors(review: 'ReviewStore',
read_current: 'Callable[[str], str | None]') -> dict[str, list['_ResolvedAnchor']]:
"""Map every stored comment through its snapshot to a target line in
the current working tree (via difflib)."""
result: dict[str, list[_ResolvedAnchor]] = {}
current_cache: dict[str, str | None] = {}
map_cache: dict[tuple[str, str], dict[int, int]] = {}
for file, entry in review.all_entries():
snap_sha = str(entry.get('snapshot') or '')
line_no = int(entry.get('line_no') or 0)
side = str(entry.get('side') or ' ')
line_text = str(entry.get('line_text') or '')
comment = str(entry.get('comment') or '')
kind = _comment_kind(entry.get('kind'))
if not _entry_is_valid(entry):
continue
if file not in current_cache:
current_cache[file] = read_current(file)
current = current_cache[file]
snap = review.read_snapshot(snap_sha)
if current is not None and snap is not None:
key = (snap_sha, file)
line_map = map_cache.get(key)
if line_map is None:
line_map = _compute_line_map(snap, current)
map_cache[key] = line_map
target = line_map.get(line_no, line_no)
else:
target = line_no
result.setdefault(file, []).append(_ResolvedAnchor(
file=file, snapshot=snap_sha, snap_line_no=line_no,
target_line_no=target, side=side, line_text=line_text,
comment=comment, kind=kind,
))
return result
def _export_items_from_store(review: 'ReviewStore', repo_root: Path,
diff_files: list[DiffFile]) -> list[_ExportItem]:
"""The export the window would produce, without rendering: anchors are
matched against the parsed diff the way the render does (same target
line and side; the text decides between exact and moved)."""
anchors = _resolve_anchors(review, lambda f: _read_text_safe(repo_root / f))
def item(a: '_ResolvedAnchor') -> _ExportItem:
return _ExportItem(a.file, a.target_line_no, a.side, a.kind, a.line_text,
a.comment, a.moved, orphaned=not a.matched)
items: list[_ExportItem] = []
for df in diff_files: # diff order, each file's orphans after its hunks, as rendered
for dl in df.lines:
if dl.new_line_no is None:
continue
a = _match_anchor(anchors.get(df.path, []), dl.new_line_no, _diff_side(dl.kind), dl.text)
if a is not None:
items.append(item(a))
items += [item(a) for a in anchors.pop(df.path, []) if not a.matched]
for alist in anchors.values(): # files not in this diff
items += [item(a) for a in alist]
return items
def _next_kind(kind: str) -> str:
kinds = list(COMMENT_KINDS)
return kinds[(kinds.index(kind) + 1) % len(kinds)]
def _blend(color: str, factor: float = 0.5) -> str:
"""Blend color toward the canvas background by factor (0=bg, 1=color)."""
bg = C['bg']
def _p(h: str) -> tuple[int, int, int]:
h = h.lstrip('#')
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
r0, g0, b0 = _p(bg)
r1, g1, b1 = _p(color)
r = int(r0 + (r1 - r0) * factor)
g = int(g0 + (g1 - g0) * factor)
b = int(b0 + (b1 - b0) * factor)
return f'#{r:02x}{g:02x}{b:02x}'
def _wheel_ticks(delta: int) -> int:
"""Scroll ticks for a <MouseWheel> delta (positive delta = wheel up).
Windows reports multiples of 120, macOS and precision touchpads report
small values; floor division by 120 would turn those into zero ticks on
one direction and a full tick on the other.
"""
return (-1 if delta > 0 else 1) * max(1, abs(delta) // 120)
def _mix(c1: str, c2: str, t: float) -> str:
"""Linear interpolation between two hex colors (t=0 → c1, t=1 → c2)."""
def _p(h: str) -> tuple[int, int, int]:
h = h.lstrip('#')
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
r1, g1, b1 = _p(c1)
r2, g2, b2 = _p(c2)
return f'#{int(r1+(r2-r1)*t):02x}{int(g1+(g2-g1)*t):02x}{int(b1+(b2-b1)*t):02x}'
# non-whitespace pixel colours in the minimap; None = leave as canvas bg
# Background of a comment's bar in the diff and of its minimap band.
COMMENT_BGS: dict[str, str] = {k: _blend(col, 0.55) for k, (col, _m) in COMMENT_KINDS.items()}
_MINIMAP_COLORS: dict[str, str | None] = {
'added': _blend(C['added_fg'], 0.45),
'removed': _blend(C['removed_fg'], 0.45),
'hunk': _blend(C['hunk_fg'], 0.35),
'filehdr': C['topbar_bg'], # same bar colour as the diff's header rows
'fileidx': C['topbar_bg'],
'context': _blend(C['fg'], 0.18),
'reindent': _blend(C['fg'], 0.18),
'orphan': _blend(C['subdued'], 0.40),
}
# --application ------------------------------------------------------------
def _detect_scale(root: tk.Tk) -> float:
"""Return UI scale factor: 1.0 = 96 DPI (standard), 2.0 = HiDPI, etc.
GITR_SCALE env var overrides auto-detection.
"""
env = os.environ.get('GITR_SCALE')
if env:
try:
return max(0.25, float(env))
except ValueError:
pass
# winfo_fpixels('1i') = pixels per inch; 96 is the baseline for scale=1.
dpi = root.winfo_fpixels('1i')
return dpi / 96.0
def _primary_monitor_size() -> tuple[int, int]:
try:
out = subprocess.run(['xrandr', '--query'], capture_output=True, text=True,
timeout=1).stdout
for line in out.splitlines():
if 'primary' in line:
m = re.search(r'(\d+)x(\d+)', line)
if m:
return int(m.group(1)), int(m.group(2))
# no primary keyword: use the first connected monitor
for line in out.splitlines():
if ' connected' in line:
m = re.search(r'(\d+)x(\d+)', line)
if m:
return int(m.group(1)), int(m.group(2))
except Exception:
pass
return 1920, 1080
_MM_LINE_H = 2 # minimap pixels per source line at 1x scale (matches VS Code)
_MM_LEVELS = (0.0, 0.4, 0.6, 0.8, 1.0) # brightness per character density level
def _mm_density_table() -> bytes:
"""Translate table: latin-1 byte -> density level index into _MM_LEVELS.
Stands in for VS Code's glyph rasterisation: heavier characters get
brighter pixels so the shape of the code stays recognisable at 1px/char.
"""
lvl = bytearray(256)
for b in range(33, 127):
ch = chr(b)
if ch in ".,'`:;":
lvl[b] = 1
elif ch in '-_=~^"*+|/\\<>()[]{}!?':
lvl[b] = 2
elif ch.islower() or ch.isdigit():
lvl[b] = 3
else:
lvl[b] = 4
for b in range(128, 256):
lvl[b] = 3
return bytes(lvl)
def _mm_channel_tables(color: str, base: str = C['bg']) -> tuple[bytes, bytes, bytes]:
"""Per-channel translate tables: density level byte -> colour channel byte.
Level 0 (no ink) is `base`; comment rows use a tinted base so the row
reads as a comment even where its text is short.
"""
levels = bytes(range(len(_MM_LEVELS)))
chans: tuple[list[int], list[int], list[int]] = ([], [], [])
for f in _MM_LEVELS:
h = _mix(base, color, f).lstrip('#')
for k in range(3):
chans[k].append(int(h[2 * k:2 * k + 2], 16))
r, g, b = (bytes.maketrans(levels, bytes(ch)) for ch in chans)
return r, g, b
_MM_DENSITY = _mm_density_table()
# Drawn as full-width bars rather than text density: a one-line label is
# invisible at 1 px per character, and these rows mark boundaries.
_MM_SOLID_KINDS = frozenset(('filehdr', 'fileidx'))
_MM_CHANNELS = {kind: _mm_channel_tables(col)
for kind, col in _MINIMAP_COLORS.items() if col is not None}
# Comment rows mirror the diff's comment bar: dark text on the kind's band.
_MM_COMMENT_CHANNELS = {k: _mm_channel_tables(C['bg'], bg) for k, bg in COMMENT_BGS.items()}
def _use_autojunk(a: list[str], b: list[str]) -> bool:
"""Exact matching is quadratic in line length; on very long lines (prose,
minified data) difflib's autojunk heuristic is ~25x faster at the cost of
slightly fewer matched tokens, so it is switched on only for those."""
return max(len(a), len(b)) > CFG.word_diff_autojunk_tokens
def _word_matcher(a: list[str], b: list[str]) -> difflib.SequenceMatcher:
return difflib.SequenceMatcher(None, a, b, autojunk=_use_autojunk(a, b))
def _pair_lines_for_word_diff(
rem_lines: list[str], add_lines: list[str]
) -> list[tuple]:
"""Order-preserving optimal matching between removed and added lines.
Returns a list of ('pair', old, new, same_tokens), ('rem', old), or
('add', new); same_tokens is True when the lines differ only in whitespace.
Uses DP to maximise total similarity, so a re-indented block mixed with
inserted/deleted lines gets correctly paired rather than sequentially
mis-matched. Lines with similarity below CFG.word_diff_min_ratio are