nit-cli 0.3.0__tar.gz → 0.4.1__tar.gz
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.
- {nit_cli-0.3.0/src/nit_cli.egg-info → nit_cli-0.4.1}/PKG-INFO +32 -2
- {nit_cli-0.3.0 → nit_cli-0.4.1}/README.md +31 -1
- {nit_cli-0.3.0 → nit_cli-0.4.1}/pyproject.toml +1 -1
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/app.py +181 -24
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/cli.py +32 -3
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/comments.py +25 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/diff_parser.py +25 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/git.py +47 -15
- nit_cli-0.4.1/src/nit/syntax.py +33 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1/src/nit_cli.egg-info}/PKG-INFO +32 -2
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/SOURCES.txt +3 -1
- {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_app.py +16 -1
- {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_comments.py +52 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_git.py +4 -35
- nit_cli-0.4.1/tests/test_syntax.py +26 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/LICENSE +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/setup.cfg +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/__init__.py +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/models.py +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/dependency_links.txt +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/entry_points.txt +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/requires.txt +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/top_level.txt +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_cli.py +0 -0
- {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_diff_parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nit-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Terminal diff viewer with inline review comments
|
|
5
5
|
Author: Joshua Zink-Duda
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -33,6 +33,12 @@ Navigate diffs with vim-style keybindings, leave comments on specific lines, and
|
|
|
33
33
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
|
+
```bash
|
|
37
|
+
brew install joshuazd/tap/nit
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or via pip/pipx:
|
|
41
|
+
|
|
36
42
|
```bash
|
|
37
43
|
pipx install nit-cli
|
|
38
44
|
```
|
|
@@ -60,11 +66,20 @@ nit --mode all
|
|
|
60
66
|
nit HEAD~3..HEAD
|
|
61
67
|
nit main..feature
|
|
62
68
|
|
|
69
|
+
# Review any file (read-only, no git required)
|
|
70
|
+
nit path/to/file.py
|
|
71
|
+
|
|
63
72
|
# Filter to specific files or directories
|
|
64
73
|
nit --path src/
|
|
65
74
|
nit --path src/nit/app.py main..feature
|
|
75
|
+
|
|
76
|
+
# Export comments on quit
|
|
77
|
+
nit --export-comments comments.md
|
|
78
|
+
nit --export-comments - --export-format json # stdout as JSON
|
|
66
79
|
```
|
|
67
80
|
|
|
81
|
+
On main/master, nit defaults to unstaged mode. On feature branches, it defaults to branch diff. The diff auto-refreshes every 5 seconds.
|
|
82
|
+
|
|
68
83
|
## Keybindings
|
|
69
84
|
|
|
70
85
|
| Key | Action |
|
|
@@ -73,12 +88,27 @@ nit --path src/nit/app.py main..feature
|
|
|
73
88
|
| `J` / `K` | Jump to next / previous hunk |
|
|
74
89
|
| `n` / `p` | Next / previous file |
|
|
75
90
|
| `]` / `[` | Jump to next / previous comment |
|
|
91
|
+
| `gg` / `G` | Jump to top / bottom |
|
|
76
92
|
| `c` | Add comment on current line |
|
|
77
93
|
| `d` | Delete comment on current line |
|
|
78
|
-
| `
|
|
94
|
+
| `e` | Export comments to clipboard |
|
|
95
|
+
| `m` | Cycle diff mode (branch / unstaged / staged / all) |
|
|
96
|
+
| `s` | Toggle side-by-side diff view |
|
|
97
|
+
| `w` | Toggle word-level diff highlighting |
|
|
98
|
+
| `W` | Toggle hide whitespace-only changes |
|
|
99
|
+
| `h` | Toggle syntax highlighting |
|
|
79
100
|
| `r` | Refresh diff |
|
|
80
101
|
| `q` | Quit |
|
|
81
102
|
|
|
103
|
+
### Git operations (g-prefixed)
|
|
104
|
+
|
|
105
|
+
| Key | Action |
|
|
106
|
+
|-----|--------|
|
|
107
|
+
| `ga` | Stage hunk under cursor |
|
|
108
|
+
| `gu` | Unstage hunk (staged mode) |
|
|
109
|
+
| `gx` | Discard hunk |
|
|
110
|
+
| `gc` | Commit with message prompt |
|
|
111
|
+
|
|
82
112
|
## Comment Storage
|
|
83
113
|
|
|
84
114
|
Comments are saved to `.nit.json` at the root of your git repository. The format is structured JSON:
|
|
@@ -6,6 +6,12 @@ Navigate diffs with vim-style keybindings, leave comments on specific lines, and
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
+
```bash
|
|
10
|
+
brew install joshuazd/tap/nit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or via pip/pipx:
|
|
14
|
+
|
|
9
15
|
```bash
|
|
10
16
|
pipx install nit-cli
|
|
11
17
|
```
|
|
@@ -33,11 +39,20 @@ nit --mode all
|
|
|
33
39
|
nit HEAD~3..HEAD
|
|
34
40
|
nit main..feature
|
|
35
41
|
|
|
42
|
+
# Review any file (read-only, no git required)
|
|
43
|
+
nit path/to/file.py
|
|
44
|
+
|
|
36
45
|
# Filter to specific files or directories
|
|
37
46
|
nit --path src/
|
|
38
47
|
nit --path src/nit/app.py main..feature
|
|
48
|
+
|
|
49
|
+
# Export comments on quit
|
|
50
|
+
nit --export-comments comments.md
|
|
51
|
+
nit --export-comments - --export-format json # stdout as JSON
|
|
39
52
|
```
|
|
40
53
|
|
|
54
|
+
On main/master, nit defaults to unstaged mode. On feature branches, it defaults to branch diff. The diff auto-refreshes every 5 seconds.
|
|
55
|
+
|
|
41
56
|
## Keybindings
|
|
42
57
|
|
|
43
58
|
| Key | Action |
|
|
@@ -46,12 +61,27 @@ nit --path src/nit/app.py main..feature
|
|
|
46
61
|
| `J` / `K` | Jump to next / previous hunk |
|
|
47
62
|
| `n` / `p` | Next / previous file |
|
|
48
63
|
| `]` / `[` | Jump to next / previous comment |
|
|
64
|
+
| `gg` / `G` | Jump to top / bottom |
|
|
49
65
|
| `c` | Add comment on current line |
|
|
50
66
|
| `d` | Delete comment on current line |
|
|
51
|
-
| `
|
|
67
|
+
| `e` | Export comments to clipboard |
|
|
68
|
+
| `m` | Cycle diff mode (branch / unstaged / staged / all) |
|
|
69
|
+
| `s` | Toggle side-by-side diff view |
|
|
70
|
+
| `w` | Toggle word-level diff highlighting |
|
|
71
|
+
| `W` | Toggle hide whitespace-only changes |
|
|
72
|
+
| `h` | Toggle syntax highlighting |
|
|
52
73
|
| `r` | Refresh diff |
|
|
53
74
|
| `q` | Quit |
|
|
54
75
|
|
|
76
|
+
### Git operations (g-prefixed)
|
|
77
|
+
|
|
78
|
+
| Key | Action |
|
|
79
|
+
|-----|--------|
|
|
80
|
+
| `ga` | Stage hunk under cursor |
|
|
81
|
+
| `gu` | Unstage hunk (staged mode) |
|
|
82
|
+
| `gx` | Discard hunk |
|
|
83
|
+
| `gc` | Commit with message prompt |
|
|
84
|
+
|
|
55
85
|
## Comment Storage
|
|
56
86
|
|
|
57
87
|
Comments are saved to `.nit.json` at the root of your git repository. The format is structured JSON:
|
|
@@ -2,8 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
+
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
from collections import Counter
|
|
8
|
+
from pathlib import Path
|
|
7
9
|
|
|
8
10
|
from rich.style import Style
|
|
9
11
|
from rich.text import Text
|
|
@@ -18,9 +20,10 @@ from textual.widgets._tree import TreeNode
|
|
|
18
20
|
from . import comments as comments_mod
|
|
19
21
|
from . import git
|
|
20
22
|
from .cli import CLIArgs
|
|
21
|
-
from .diff_parser import align_hunk_lines, build_patch, parse_diff, word_diff_segments
|
|
23
|
+
from .diff_parser import align_hunk_lines, build_patch, file_to_diff, parse_diff, word_diff_segments
|
|
22
24
|
from .models import DiffHunk, FileDiff, ReviewComment, SideBySideRow
|
|
23
25
|
from .models import DiffLine as DiffLineModel
|
|
26
|
+
from .syntax import detect_language, highlight_line
|
|
24
27
|
|
|
25
28
|
logger = logging.getLogger(__name__)
|
|
26
29
|
|
|
@@ -214,6 +217,7 @@ class DiffView(VerticalScroll):
|
|
|
214
217
|
cursor_index: reactive[int] = reactive(0)
|
|
215
218
|
side_by_side: reactive[bool] = reactive(False)
|
|
216
219
|
word_diff: reactive[bool] = reactive(False)
|
|
220
|
+
syntax_highlight: reactive[bool] = reactive(False)
|
|
217
221
|
|
|
218
222
|
def __init__(self, *args, **kwargs) -> None:
|
|
219
223
|
super().__init__(*args, **kwargs)
|
|
@@ -295,6 +299,8 @@ class DiffView(VerticalScroll):
|
|
|
295
299
|
else:
|
|
296
300
|
i += 1
|
|
297
301
|
|
|
302
|
+
lang = detect_language(file_diff.path) if self.syntax_highlight else None
|
|
303
|
+
|
|
298
304
|
flat_idx = 0
|
|
299
305
|
for hunk in file_diff.hunks:
|
|
300
306
|
for dl in hunk.lines:
|
|
@@ -307,11 +313,22 @@ class DiffView(VerticalScroll):
|
|
|
307
313
|
text = self._format_word_diff_line(
|
|
308
314
|
dl, word_pairs[flat_idx], line_no_str, prefix
|
|
309
315
|
)
|
|
316
|
+
elif lang and dl.line_type == "context":
|
|
317
|
+
text = self._format_syntax_line(dl, line_no_str, prefix, lang)
|
|
318
|
+
elif lang and dl.line_type in ("add", "remove"):
|
|
319
|
+
style = "reverse green" if dl.line_type == "add" else "reverse red"
|
|
320
|
+
text = Text(f"{line_no_str}{prefix}{dl.content}", style=style)
|
|
310
321
|
else:
|
|
311
|
-
text =
|
|
322
|
+
text = Text()
|
|
323
|
+
if dl.line_type == "context":
|
|
324
|
+
text.append(line_no_str, style="dim")
|
|
325
|
+
else:
|
|
326
|
+
text.append(line_no_str)
|
|
327
|
+
text.append(f"{prefix}{dl.content}")
|
|
312
328
|
|
|
313
329
|
w = DiffLineWidget(text)
|
|
314
|
-
|
|
330
|
+
if not (lang and dl.line_type in ("add", "remove", "context")):
|
|
331
|
+
w.add_class(dl.line_type.replace("_", "-"))
|
|
315
332
|
self._line_widgets.append(w)
|
|
316
333
|
self.mount(w)
|
|
317
334
|
|
|
@@ -330,6 +347,7 @@ class DiffView(VerticalScroll):
|
|
|
330
347
|
comment_map: dict[int | None, list[ReviewComment]],
|
|
331
348
|
) -> None:
|
|
332
349
|
half = max(40, (self.size.width - 15) // 2) # each side's content width
|
|
350
|
+
lang = detect_language(file_diff.path) if self.syntax_highlight else None
|
|
333
351
|
|
|
334
352
|
for hunk in file_diff.hunks:
|
|
335
353
|
rows = align_hunk_lines(hunk.lines)
|
|
@@ -343,7 +361,7 @@ class DiffView(VerticalScroll):
|
|
|
343
361
|
self.diff_lines.append(primary)
|
|
344
362
|
|
|
345
363
|
# Build Rich Text for the row
|
|
346
|
-
text = self._format_sbs_row(row, half)
|
|
364
|
+
text = self._format_sbs_row(row, half, lang if row.row_type == "context" else None)
|
|
347
365
|
w = DiffLineWidget(text)
|
|
348
366
|
|
|
349
367
|
w.add_class("sbs")
|
|
@@ -351,7 +369,6 @@ class DiffView(VerticalScroll):
|
|
|
351
369
|
w.add_class("hunk-header")
|
|
352
370
|
elif row.row_type == "context":
|
|
353
371
|
w.add_class("context")
|
|
354
|
-
# For change rows, coloring is in the Rich Text itself
|
|
355
372
|
|
|
356
373
|
self._line_widgets.append(w)
|
|
357
374
|
self.mount(w)
|
|
@@ -365,7 +382,7 @@ class DiffView(VerticalScroll):
|
|
|
365
382
|
block = CommentBlock(f"-- {c.comment}")
|
|
366
383
|
self.mount(block)
|
|
367
384
|
|
|
368
|
-
def _format_sbs_row(self, row: SideBySideRow, half: int) -> Text:
|
|
385
|
+
def _format_sbs_row(self, row: SideBySideRow, half: int, language: str | None = None) -> Text:
|
|
369
386
|
"""Format a side-by-side row as Rich Text with per-side coloring."""
|
|
370
387
|
if row.row_type == "hunk_header":
|
|
371
388
|
left = row.left
|
|
@@ -400,6 +417,10 @@ class DiffView(VerticalScroll):
|
|
|
400
417
|
if left_len >= half:
|
|
401
418
|
break
|
|
402
419
|
text.append(" " * max(0, half - left_len))
|
|
420
|
+
elif language:
|
|
421
|
+
highlighted = highlight_line(row.left.content[:half], language)
|
|
422
|
+
text.append_text(highlighted)
|
|
423
|
+
text.append(" " * max(0, half - highlighted.cell_len))
|
|
403
424
|
else:
|
|
404
425
|
left_content = row.left.content[:half]
|
|
405
426
|
style = "red" if row.left.line_type == "remove" else ""
|
|
@@ -418,6 +439,9 @@ class DiffView(VerticalScroll):
|
|
|
418
439
|
if do_word_diff:
|
|
419
440
|
for seg_text, changed in new_segs:
|
|
420
441
|
text.append(seg_text[:half], style="bold reverse green" if changed else "green")
|
|
442
|
+
elif language:
|
|
443
|
+
highlighted = highlight_line(row.right.content[:half], language)
|
|
444
|
+
text.append_text(highlighted)
|
|
421
445
|
else:
|
|
422
446
|
right_content = row.right.content[:half]
|
|
423
447
|
style = "green" if row.right.line_type == "add" else ""
|
|
@@ -443,6 +467,20 @@ class DiffView(VerticalScroll):
|
|
|
443
467
|
return ""
|
|
444
468
|
return " "
|
|
445
469
|
|
|
470
|
+
def _format_syntax_line(
|
|
471
|
+
self,
|
|
472
|
+
dl: DiffLineModel,
|
|
473
|
+
line_no_str: str,
|
|
474
|
+
prefix: str,
|
|
475
|
+
language: str,
|
|
476
|
+
) -> Text:
|
|
477
|
+
"""Render a context line with syntax highlighting."""
|
|
478
|
+
text = Text()
|
|
479
|
+
text.append(f"{line_no_str}{prefix}", style="dim")
|
|
480
|
+
highlighted = highlight_line(dl.content, language)
|
|
481
|
+
text.append_text(highlighted)
|
|
482
|
+
return text
|
|
483
|
+
|
|
446
484
|
def _format_word_diff_line(
|
|
447
485
|
self,
|
|
448
486
|
dl: DiffLineModel,
|
|
@@ -621,7 +659,7 @@ class NitApp(App):
|
|
|
621
659
|
"""
|
|
622
660
|
|
|
623
661
|
BINDINGS = [
|
|
624
|
-
Binding("q", "quit", "Quit"),
|
|
662
|
+
Binding("q", "quit", "Quit", show=False),
|
|
625
663
|
Binding("j", "cursor_down", "Down", show=False),
|
|
626
664
|
Binding("k", "cursor_up", "Up", show=False),
|
|
627
665
|
Binding("J", "next_hunk", "Next hunk", show=False),
|
|
@@ -629,17 +667,21 @@ class NitApp(App):
|
|
|
629
667
|
Binding("n", "next_file", "Next file"),
|
|
630
668
|
Binding("p", "prev_file", "Prev file"),
|
|
631
669
|
Binding("c", "comment", "Comment"),
|
|
632
|
-
Binding("d", "delete_comment", "Delete
|
|
670
|
+
Binding("d", "delete_comment", "Delete"),
|
|
633
671
|
Binding("m", "cycle_mode", "Mode"),
|
|
634
|
-
Binding("r", "refresh", "Refresh"),
|
|
672
|
+
Binding("r", "refresh", "Refresh", show=False),
|
|
635
673
|
Binding("right_square_bracket", "next_comment", "]", show=False),
|
|
636
674
|
Binding("left_square_bracket", "prev_comment", "[", show=False),
|
|
637
675
|
Binding("G", "cursor_end", "End", show=False),
|
|
638
676
|
Binding("s", "toggle_side_by_side", "Split"),
|
|
639
|
-
Binding("w", "toggle_word_diff", "Word
|
|
677
|
+
Binding("w", "toggle_word_diff", "Word"),
|
|
678
|
+
Binding("W", "toggle_whitespace", "Whitespace"),
|
|
679
|
+
Binding("e", "export_comments", "Export", show=False),
|
|
680
|
+
Binding("h", "toggle_syntax", "Highlight", show=False),
|
|
640
681
|
]
|
|
641
682
|
|
|
642
683
|
diff_mode: reactive[str] = reactive("branch")
|
|
684
|
+
ignore_whitespace: reactive[bool] = reactive(False)
|
|
643
685
|
|
|
644
686
|
def __init__(self, cli_args: CLIArgs | None = None, *args, **kwargs) -> None:
|
|
645
687
|
kwargs.setdefault("ansi_color", True)
|
|
@@ -654,6 +696,8 @@ class NitApp(App):
|
|
|
654
696
|
self.branch = ""
|
|
655
697
|
self.base = ""
|
|
656
698
|
self._file_index: int = 0
|
|
699
|
+
self._last_raw_diff: str = ""
|
|
700
|
+
self._file_review_mode: bool = False
|
|
657
701
|
|
|
658
702
|
def compose(self) -> ComposeResult:
|
|
659
703
|
with Vertical(id="frame"):
|
|
@@ -670,6 +714,9 @@ class NitApp(App):
|
|
|
670
714
|
yield Footer()
|
|
671
715
|
|
|
672
716
|
def on_mount(self) -> None:
|
|
717
|
+
if self._cli_args.file_path:
|
|
718
|
+
self._mount_file_review()
|
|
719
|
+
return
|
|
673
720
|
try:
|
|
674
721
|
self.repo_root = git.get_repo_root()
|
|
675
722
|
except Exception:
|
|
@@ -679,10 +726,12 @@ class NitApp(App):
|
|
|
679
726
|
try:
|
|
680
727
|
self.branch = git.get_current_branch(self.repo_root)
|
|
681
728
|
except Exception:
|
|
682
|
-
self.branch = ""
|
|
729
|
+
self.branch = "(detached HEAD)"
|
|
683
730
|
self.base = git.get_main_branch(self.repo_root)
|
|
684
731
|
if self._cli_args.mode:
|
|
685
732
|
self.diff_mode = self._cli_args.mode
|
|
733
|
+
elif self.branch and self.branch == self.base:
|
|
734
|
+
self.diff_mode = "unstaged"
|
|
686
735
|
try:
|
|
687
736
|
self.comments = comments_mod.load_comments(self.repo_root)
|
|
688
737
|
except Exception:
|
|
@@ -690,30 +739,79 @@ class NitApp(App):
|
|
|
690
739
|
self.notify("Could not load .nit.json — starting with no comments", severity="warning")
|
|
691
740
|
self.comments = []
|
|
692
741
|
self._load_diff()
|
|
742
|
+
self.set_interval(5.0, self._auto_refresh_poll)
|
|
693
743
|
|
|
694
|
-
def
|
|
695
|
-
|
|
744
|
+
def _mount_file_review(self) -> None:
|
|
745
|
+
from pathlib import Path
|
|
746
|
+
|
|
747
|
+
file_path = self._cli_args.file_path
|
|
748
|
+
p = Path(file_path)
|
|
749
|
+
if not p.exists():
|
|
750
|
+
self.notify(f"File not found: {file_path}", severity="error")
|
|
751
|
+
self.exit()
|
|
752
|
+
return
|
|
753
|
+
self._file_review_mode = True
|
|
754
|
+
self.branch = str(p.name)
|
|
755
|
+
self.diff_mode = "file"
|
|
756
|
+
try:
|
|
757
|
+
self.repo_root = git.get_repo_root()
|
|
758
|
+
except Exception:
|
|
759
|
+
self.repo_root = p.parent
|
|
760
|
+
try:
|
|
761
|
+
self.comments = comments_mod.load_comments(self.repo_root)
|
|
762
|
+
except Exception:
|
|
763
|
+
self.comments = []
|
|
764
|
+
content = p.read_text()
|
|
765
|
+
self.file_diffs = file_to_diff(str(p), content)
|
|
766
|
+
self._update_file_list()
|
|
767
|
+
self._update_status()
|
|
768
|
+
|
|
769
|
+
def _auto_refresh_poll(self) -> None:
|
|
770
|
+
if self._file_review_mode:
|
|
771
|
+
return
|
|
772
|
+
if self._commit_input is not None:
|
|
773
|
+
return
|
|
774
|
+
dv = self.query_one("#diff-view", DiffView)
|
|
775
|
+
if dv._active_input is not None:
|
|
776
|
+
return
|
|
777
|
+
try:
|
|
778
|
+
raw = self._get_raw_diff()
|
|
779
|
+
except Exception:
|
|
696
780
|
return
|
|
781
|
+
if raw != self._last_raw_diff:
|
|
782
|
+
self._last_raw_diff = raw
|
|
783
|
+
self.file_diffs = parse_diff(raw)
|
|
784
|
+
self._update_file_list()
|
|
785
|
+
self._update_status()
|
|
786
|
+
|
|
787
|
+
def _get_raw_diff(self) -> str:
|
|
788
|
+
if self.repo_root is None:
|
|
789
|
+
return ""
|
|
697
790
|
cwd = self.repo_root
|
|
698
791
|
path_filter = self._cli_args.path_filter
|
|
699
|
-
|
|
700
792
|
try:
|
|
793
|
+
ws = self.ignore_whitespace
|
|
701
794
|
if self._cli_args.commit_range:
|
|
702
|
-
|
|
795
|
+
return git.get_commit_range_diff(
|
|
796
|
+
self._cli_args.commit_range, cwd, path_filter, ignore_whitespace=ws
|
|
797
|
+
)
|
|
703
798
|
elif self.diff_mode == "branch":
|
|
704
|
-
|
|
799
|
+
return git.get_branch_diff(cwd, path_filter, ignore_whitespace=ws)
|
|
705
800
|
elif self.diff_mode == "unstaged":
|
|
706
|
-
|
|
801
|
+
return git.get_unstaged_diff(cwd, path_filter, ignore_whitespace=ws)
|
|
707
802
|
elif self.diff_mode == "staged":
|
|
708
|
-
|
|
803
|
+
return git.get_staged_diff(cwd, path_filter, ignore_whitespace=ws)
|
|
709
804
|
else:
|
|
710
|
-
|
|
805
|
+
return git.get_all_uncommitted_diff(cwd, path_filter, ignore_whitespace=ws)
|
|
711
806
|
except subprocess.CalledProcessError as e:
|
|
712
807
|
msg = (e.stderr or "").strip() or "Failed to load diff"
|
|
713
808
|
logger.warning("Git diff failed: %s", msg)
|
|
714
809
|
self.notify(msg, severity="error")
|
|
715
|
-
|
|
810
|
+
return ""
|
|
716
811
|
|
|
812
|
+
def _load_diff(self) -> None:
|
|
813
|
+
raw = self._get_raw_diff()
|
|
814
|
+
self._last_raw_diff = raw
|
|
717
815
|
self.file_diffs = parse_diff(raw)
|
|
718
816
|
self._update_file_list()
|
|
719
817
|
self._update_status()
|
|
@@ -777,7 +875,8 @@ class NitApp(App):
|
|
|
777
875
|
n_comments = len(self.comments)
|
|
778
876
|
n_files = len(self.file_diffs)
|
|
779
877
|
self.query_one("#seg-branch", Label).update(f"⎇ {self.branch}")
|
|
780
|
-
|
|
878
|
+
ws_indicator = " [no-ws]" if self.ignore_whitespace else ""
|
|
879
|
+
self.query_one("#seg-mode", Label).update(f"⇄ {mode_label}{ws_indicator}")
|
|
781
880
|
self.query_one("#seg-files", Label).update(f"▤ {n_files} files")
|
|
782
881
|
self.query_one("#seg-comments", Label).update(f"✎ {n_comments} comments")
|
|
783
882
|
|
|
@@ -832,7 +931,7 @@ class NitApp(App):
|
|
|
832
931
|
action()
|
|
833
932
|
event.prevent_default()
|
|
834
933
|
event.stop()
|
|
835
|
-
|
|
934
|
+
return
|
|
836
935
|
if event.key == "g":
|
|
837
936
|
self._pending_g = True
|
|
838
937
|
event.prevent_default()
|
|
@@ -866,7 +965,7 @@ class NitApp(App):
|
|
|
866
965
|
self.query_one("#diff-view", DiffView).jump_to_next_comment(forward=False)
|
|
867
966
|
|
|
868
967
|
def action_cycle_mode(self) -> None:
|
|
869
|
-
if self._cli_args.commit_range:
|
|
968
|
+
if self._file_review_mode or self._cli_args.commit_range:
|
|
870
969
|
return
|
|
871
970
|
idx = DIFF_MODES.index(self.diff_mode)
|
|
872
971
|
self.diff_mode = DIFF_MODES[(idx + 1) % len(DIFF_MODES)]
|
|
@@ -914,7 +1013,12 @@ class NitApp(App):
|
|
|
914
1013
|
diff_mode=self.diff_mode,
|
|
915
1014
|
)
|
|
916
1015
|
self.comments.append(comment)
|
|
917
|
-
|
|
1016
|
+
try:
|
|
1017
|
+
comments_mod.save_comments(
|
|
1018
|
+
self.repo_root, self.comments, self.branch, self.base
|
|
1019
|
+
)
|
|
1020
|
+
except Exception as e:
|
|
1021
|
+
self.notify(f"Failed to save comment: {e}", severity="error")
|
|
918
1022
|
saved_cursor = diff_view.cursor_index
|
|
919
1023
|
diff_view.hide_comment_input()
|
|
920
1024
|
if self.current_file:
|
|
@@ -923,6 +1027,10 @@ class NitApp(App):
|
|
|
923
1027
|
self._update_status()
|
|
924
1028
|
self._refresh_file_labels()
|
|
925
1029
|
|
|
1030
|
+
def action_toggle_whitespace(self) -> None:
|
|
1031
|
+
self.ignore_whitespace = not self.ignore_whitespace
|
|
1032
|
+
self._load_diff()
|
|
1033
|
+
|
|
926
1034
|
def action_toggle_word_diff(self) -> None:
|
|
927
1035
|
dv = self.query_one("#diff-view", DiffView)
|
|
928
1036
|
dv.word_diff = not dv.word_diff
|
|
@@ -931,6 +1039,14 @@ class NitApp(App):
|
|
|
931
1039
|
file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
|
|
932
1040
|
dv.load_file_diff(self.current_file, file_comments, restore_cursor=saved_cursor)
|
|
933
1041
|
|
|
1042
|
+
def action_toggle_syntax(self) -> None:
|
|
1043
|
+
dv = self.query_one("#diff-view", DiffView)
|
|
1044
|
+
dv.syntax_highlight = not dv.syntax_highlight
|
|
1045
|
+
if self.current_file:
|
|
1046
|
+
saved_cursor = dv.cursor_index
|
|
1047
|
+
file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
|
|
1048
|
+
dv.load_file_diff(self.current_file, file_comments, restore_cursor=saved_cursor)
|
|
1049
|
+
|
|
934
1050
|
def action_toggle_side_by_side(self) -> None:
|
|
935
1051
|
dv = self.query_one("#diff-view", DiffView)
|
|
936
1052
|
dv.side_by_side = not dv.side_by_side
|
|
@@ -942,6 +1058,8 @@ class NitApp(App):
|
|
|
942
1058
|
# --- Git operations (g-prefixed chords) ---
|
|
943
1059
|
|
|
944
1060
|
def _action_stage_hunk(self) -> None:
|
|
1061
|
+
if self._file_review_mode:
|
|
1062
|
+
return
|
|
945
1063
|
if self.diff_mode not in ("unstaged", "all"):
|
|
946
1064
|
self.notify("Stage: switch to unstaged/all mode", severity="warning")
|
|
947
1065
|
return
|
|
@@ -960,6 +1078,8 @@ class NitApp(App):
|
|
|
960
1078
|
self._load_diff()
|
|
961
1079
|
|
|
962
1080
|
def _action_unstage_hunk(self) -> None:
|
|
1081
|
+
if self._file_review_mode:
|
|
1082
|
+
return
|
|
963
1083
|
if self.diff_mode != "staged":
|
|
964
1084
|
self.notify("Unstage: switch to staged mode", severity="warning")
|
|
965
1085
|
return
|
|
@@ -978,6 +1098,8 @@ class NitApp(App):
|
|
|
978
1098
|
self._load_diff()
|
|
979
1099
|
|
|
980
1100
|
def _action_discard_hunk(self) -> None:
|
|
1101
|
+
if self._file_review_mode:
|
|
1102
|
+
return
|
|
981
1103
|
if self.diff_mode not in ("unstaged", "all"):
|
|
982
1104
|
self.notify("Discard: switch to unstaged/all mode", severity="warning")
|
|
983
1105
|
return
|
|
@@ -996,6 +1118,8 @@ class NitApp(App):
|
|
|
996
1118
|
self._load_diff()
|
|
997
1119
|
|
|
998
1120
|
def _action_commit_prompt(self) -> None:
|
|
1121
|
+
if self._file_review_mode:
|
|
1122
|
+
return
|
|
999
1123
|
if self._commit_input is not None:
|
|
1000
1124
|
return
|
|
1001
1125
|
self._commit_input = CommitInput(
|
|
@@ -1032,6 +1156,38 @@ class NitApp(App):
|
|
|
1032
1156
|
self._update_status()
|
|
1033
1157
|
self._refresh_file_labels()
|
|
1034
1158
|
|
|
1159
|
+
def action_export_comments(self) -> None:
|
|
1160
|
+
if not self.comments:
|
|
1161
|
+
self.notify("No comments to export", severity="warning")
|
|
1162
|
+
return
|
|
1163
|
+
text = comments_mod.export_comments_markdown(self.comments)
|
|
1164
|
+
clip_cmd = None
|
|
1165
|
+
for cmd in ("pbcopy", "xclip", "xsel"):
|
|
1166
|
+
if shutil.which(cmd):
|
|
1167
|
+
clip_cmd = [cmd] if cmd == "pbcopy" else [cmd, "-selection", "clipboard"]
|
|
1168
|
+
break
|
|
1169
|
+
if clip_cmd is None:
|
|
1170
|
+
self.notify("No clipboard command found (pbcopy/xclip/xsel)", severity="error")
|
|
1171
|
+
return
|
|
1172
|
+
try:
|
|
1173
|
+
subprocess.run(clip_cmd, input=text, text=True, check=True, timeout=5)
|
|
1174
|
+
self.notify(f"Copied {len(self.comments)} comments to clipboard")
|
|
1175
|
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
1176
|
+
self.notify("Clipboard copy failed", severity="error")
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _export_on_quit(args: CLIArgs, comments: list[ReviewComment]) -> None:
|
|
1180
|
+
if not args.export_comments or not comments:
|
|
1181
|
+
return
|
|
1182
|
+
if args.export_format == "json":
|
|
1183
|
+
text = comments_mod.export_comments_json(comments)
|
|
1184
|
+
else:
|
|
1185
|
+
text = comments_mod.export_comments_markdown(comments)
|
|
1186
|
+
if args.export_comments == "-":
|
|
1187
|
+
print(text)
|
|
1188
|
+
else:
|
|
1189
|
+
Path(args.export_comments).write_text(text)
|
|
1190
|
+
|
|
1035
1191
|
|
|
1036
1192
|
def main() -> None:
|
|
1037
1193
|
from .cli import parse_args
|
|
@@ -1044,6 +1200,7 @@ def main() -> None:
|
|
|
1044
1200
|
)
|
|
1045
1201
|
app = NitApp(cli_args=args)
|
|
1046
1202
|
app.run()
|
|
1203
|
+
_export_on_quit(args, app.comments)
|
|
1047
1204
|
|
|
1048
1205
|
|
|
1049
1206
|
if __name__ == "__main__":
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
import importlib.metadata
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
@dataclass
|
|
@@ -11,6 +12,9 @@ class CLIArgs:
|
|
|
11
12
|
commit_range: str | None = None
|
|
12
13
|
path_filter: str | None = None
|
|
13
14
|
verbose: bool = False
|
|
15
|
+
file_path: str | None = None
|
|
16
|
+
export_comments: str | None = None
|
|
17
|
+
export_format: str = "markdown"
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -47,10 +51,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
47
51
|
help="Enable verbose logging to stderr",
|
|
48
52
|
)
|
|
49
53
|
parser.add_argument(
|
|
50
|
-
"
|
|
54
|
+
"--export-comments",
|
|
51
55
|
nargs="?",
|
|
56
|
+
const="-",
|
|
52
57
|
default=None,
|
|
53
|
-
|
|
58
|
+
metavar="FILE",
|
|
59
|
+
help="Export comments on quit (- for stdout, or file path)",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--export-format",
|
|
63
|
+
choices=["markdown", "json"],
|
|
64
|
+
default="markdown",
|
|
65
|
+
help="Comment export format (default: markdown)",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"target",
|
|
69
|
+
nargs="?",
|
|
70
|
+
default=None,
|
|
71
|
+
help="File path (review mode) or git commit range (e.g. HEAD~3..HEAD)",
|
|
54
72
|
)
|
|
55
73
|
return parser
|
|
56
74
|
|
|
@@ -58,9 +76,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
58
76
|
def parse_args(argv: list[str] | None = None) -> CLIArgs:
|
|
59
77
|
parser = build_parser()
|
|
60
78
|
ns = parser.parse_args(argv)
|
|
79
|
+
target = ns.target
|
|
80
|
+
file_path = None
|
|
81
|
+
commit_range = None
|
|
82
|
+
if target:
|
|
83
|
+
if Path(target).exists():
|
|
84
|
+
file_path = target
|
|
85
|
+
else:
|
|
86
|
+
commit_range = target
|
|
61
87
|
return CLIArgs(
|
|
62
88
|
mode=ns.mode,
|
|
63
|
-
commit_range=
|
|
89
|
+
commit_range=commit_range,
|
|
64
90
|
path_filter=ns.path,
|
|
65
91
|
verbose=ns.verbose,
|
|
92
|
+
file_path=file_path,
|
|
93
|
+
export_comments=ns.export_comments,
|
|
94
|
+
export_format=ns.export_format,
|
|
66
95
|
)
|
|
@@ -75,6 +75,31 @@ def save_comments(
|
|
|
75
75
|
raise
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def export_comments_markdown(comments: list[ReviewComment]) -> str:
|
|
79
|
+
if not comments:
|
|
80
|
+
return ""
|
|
81
|
+
by_file: dict[str, list[ReviewComment]] = {}
|
|
82
|
+
for c in comments:
|
|
83
|
+
by_file.setdefault(c.file_path, []).append(c)
|
|
84
|
+
lines = ["# Code Review Comments", ""]
|
|
85
|
+
for path in sorted(by_file):
|
|
86
|
+
lines.append(f"## {path}")
|
|
87
|
+
lines.append("")
|
|
88
|
+
for c in sorted(by_file[path], key=lambda x: x.new_line_no or x.old_line_no or 0):
|
|
89
|
+
line_ref = f"L{c.new_line_no}" if c.new_line_no else f"old L{c.old_line_no}"
|
|
90
|
+
lines.append(f"- **{line_ref}**: {c.comment}")
|
|
91
|
+
if c.line_content:
|
|
92
|
+
lines.append(" ```")
|
|
93
|
+
lines.append(f" {c.line_content}")
|
|
94
|
+
lines.append(" ```")
|
|
95
|
+
lines.append("")
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def export_comments_json(comments: list[ReviewComment]) -> str:
|
|
100
|
+
return json.dumps([_comment_to_dict(c) for c in comments], indent=2)
|
|
101
|
+
|
|
102
|
+
|
|
78
103
|
def comment_matches_line(c: ReviewComment, dl: DiffLine) -> bool:
|
|
79
104
|
if c.new_line_no is not None and dl.new_line_no == c.new_line_no:
|
|
80
105
|
return True
|
|
@@ -127,6 +127,31 @@ def parse_diff(text: str) -> list[FileDiff]:
|
|
|
127
127
|
return files
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
def file_to_diff(path: str, content: str) -> list[FileDiff]:
|
|
131
|
+
"""Create a synthetic FileDiff showing all lines as context for file review."""
|
|
132
|
+
lines_text = content.splitlines()
|
|
133
|
+
n = len(lines_text)
|
|
134
|
+
diff_lines = [
|
|
135
|
+
DiffLine(
|
|
136
|
+
content=f"@@ -1,{n} +1,{n} @@",
|
|
137
|
+
line_type="hunk_header",
|
|
138
|
+
raw=f"@@ -1,{n} +1,{n} @@",
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
for i, line in enumerate(lines_text, 1):
|
|
142
|
+
diff_lines.append(
|
|
143
|
+
DiffLine(
|
|
144
|
+
content=line,
|
|
145
|
+
line_type="context",
|
|
146
|
+
old_line_no=i,
|
|
147
|
+
new_line_no=i,
|
|
148
|
+
raw=f" {line}",
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
hunk = DiffHunk(header=diff_lines[0].content, lines=diff_lines, old_start=1, new_start=1)
|
|
152
|
+
return [FileDiff(path=path, status="modified", hunks=[hunk])]
|
|
153
|
+
|
|
154
|
+
|
|
130
155
|
def align_hunk_lines(lines: list[DiffLine]) -> list[SideBySideRow]:
|
|
131
156
|
"""Pair remove/add lines for side-by-side display."""
|
|
132
157
|
rows: list[SideBySideRow] = []
|
|
@@ -65,25 +65,54 @@ def _append_path_filter(cmd: list[str], path_filter: str | None) -> list[str]:
|
|
|
65
65
|
return cmd
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
def
|
|
68
|
+
def _append_whitespace_flag(cmd: list[str], ignore_whitespace: bool) -> list[str]:
|
|
69
|
+
if ignore_whitespace:
|
|
70
|
+
cmd = cmd + ["-w"]
|
|
71
|
+
return cmd
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _build_diff_cmd(
|
|
75
|
+
base_cmd: list[str], ignore_whitespace: bool, path_filter: str | None
|
|
76
|
+
) -> list[str]:
|
|
77
|
+
cmd = _append_whitespace_flag(base_cmd, ignore_whitespace)
|
|
78
|
+
return _append_path_filter(cmd, path_filter)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_branch_diff(
|
|
82
|
+
cwd: Path | None = None,
|
|
83
|
+
path_filter: str | None = None,
|
|
84
|
+
ignore_whitespace: bool = False,
|
|
85
|
+
) -> str:
|
|
69
86
|
base = get_main_branch(cwd)
|
|
70
|
-
cmd = ["git", "diff", f"{base}...HEAD"]
|
|
71
|
-
return _run(
|
|
87
|
+
cmd = _build_diff_cmd(["git", "diff", f"{base}...HEAD"], ignore_whitespace, path_filter)
|
|
88
|
+
return _run(cmd, cwd=cwd)
|
|
72
89
|
|
|
73
90
|
|
|
74
|
-
def get_unstaged_diff(
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
def get_unstaged_diff(
|
|
92
|
+
cwd: Path | None = None,
|
|
93
|
+
path_filter: str | None = None,
|
|
94
|
+
ignore_whitespace: bool = False,
|
|
95
|
+
) -> str:
|
|
96
|
+
cmd = _build_diff_cmd(["git", "diff"], ignore_whitespace, path_filter)
|
|
97
|
+
return _run(cmd, cwd=cwd)
|
|
77
98
|
|
|
78
99
|
|
|
79
|
-
def get_all_uncommitted_diff(
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
def get_all_uncommitted_diff(
|
|
101
|
+
cwd: Path | None = None,
|
|
102
|
+
path_filter: str | None = None,
|
|
103
|
+
ignore_whitespace: bool = False,
|
|
104
|
+
) -> str:
|
|
105
|
+
cmd = _build_diff_cmd(["git", "diff", "HEAD"], ignore_whitespace, path_filter)
|
|
106
|
+
return _run(cmd, cwd=cwd)
|
|
82
107
|
|
|
83
108
|
|
|
84
|
-
def get_staged_diff(
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
def get_staged_diff(
|
|
110
|
+
cwd: Path | None = None,
|
|
111
|
+
path_filter: str | None = None,
|
|
112
|
+
ignore_whitespace: bool = False,
|
|
113
|
+
) -> str:
|
|
114
|
+
cmd = _build_diff_cmd(["git", "diff", "--cached"], ignore_whitespace, path_filter)
|
|
115
|
+
return _run(cmd, cwd=cwd)
|
|
87
116
|
|
|
88
117
|
|
|
89
118
|
def apply_patch(
|
|
@@ -116,7 +145,10 @@ def commit(message: str, cwd: Path | None = None) -> str:
|
|
|
116
145
|
|
|
117
146
|
|
|
118
147
|
def get_commit_range_diff(
|
|
119
|
-
commit_range: str,
|
|
148
|
+
commit_range: str,
|
|
149
|
+
cwd: Path | None = None,
|
|
150
|
+
path_filter: str | None = None,
|
|
151
|
+
ignore_whitespace: bool = False,
|
|
120
152
|
) -> str:
|
|
121
|
-
cmd = ["git", "diff", commit_range]
|
|
122
|
-
return _run(
|
|
153
|
+
cmd = _build_diff_cmd(["git", "diff", commit_range], ignore_whitespace, path_filter)
|
|
154
|
+
return _run(cmd, cwd=cwd)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
from rich.syntax import Syntax
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=32)
|
|
11
|
+
def _get_syntax(language: str) -> Syntax:
|
|
12
|
+
"""Get a cached Syntax object for a language."""
|
|
13
|
+
theme = "ansi_dark"
|
|
14
|
+
if os.environ.get("NO_COLOR"):
|
|
15
|
+
theme = "default"
|
|
16
|
+
return Syntax("", language, theme=theme, line_numbers=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_language(file_path: str) -> str | None:
|
|
20
|
+
"""Detect language from file extension. Returns None if unknown."""
|
|
21
|
+
try:
|
|
22
|
+
lexer = Syntax.guess_lexer(file_path)
|
|
23
|
+
return lexer if lexer not in ("text", "default") else None
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def highlight_line(code: str, language: str) -> Text:
|
|
29
|
+
"""Return a Rich Text with syntax highlighting for a single line."""
|
|
30
|
+
syntax = _get_syntax(language)
|
|
31
|
+
text = syntax.highlight(code)
|
|
32
|
+
text.rstrip()
|
|
33
|
+
return text
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nit-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Terminal diff viewer with inline review comments
|
|
5
5
|
Author: Joshua Zink-Duda
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -33,6 +33,12 @@ Navigate diffs with vim-style keybindings, leave comments on specific lines, and
|
|
|
33
33
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
|
+
```bash
|
|
37
|
+
brew install joshuazd/tap/nit
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or via pip/pipx:
|
|
41
|
+
|
|
36
42
|
```bash
|
|
37
43
|
pipx install nit-cli
|
|
38
44
|
```
|
|
@@ -60,11 +66,20 @@ nit --mode all
|
|
|
60
66
|
nit HEAD~3..HEAD
|
|
61
67
|
nit main..feature
|
|
62
68
|
|
|
69
|
+
# Review any file (read-only, no git required)
|
|
70
|
+
nit path/to/file.py
|
|
71
|
+
|
|
63
72
|
# Filter to specific files or directories
|
|
64
73
|
nit --path src/
|
|
65
74
|
nit --path src/nit/app.py main..feature
|
|
75
|
+
|
|
76
|
+
# Export comments on quit
|
|
77
|
+
nit --export-comments comments.md
|
|
78
|
+
nit --export-comments - --export-format json # stdout as JSON
|
|
66
79
|
```
|
|
67
80
|
|
|
81
|
+
On main/master, nit defaults to unstaged mode. On feature branches, it defaults to branch diff. The diff auto-refreshes every 5 seconds.
|
|
82
|
+
|
|
68
83
|
## Keybindings
|
|
69
84
|
|
|
70
85
|
| Key | Action |
|
|
@@ -73,12 +88,27 @@ nit --path src/nit/app.py main..feature
|
|
|
73
88
|
| `J` / `K` | Jump to next / previous hunk |
|
|
74
89
|
| `n` / `p` | Next / previous file |
|
|
75
90
|
| `]` / `[` | Jump to next / previous comment |
|
|
91
|
+
| `gg` / `G` | Jump to top / bottom |
|
|
76
92
|
| `c` | Add comment on current line |
|
|
77
93
|
| `d` | Delete comment on current line |
|
|
78
|
-
| `
|
|
94
|
+
| `e` | Export comments to clipboard |
|
|
95
|
+
| `m` | Cycle diff mode (branch / unstaged / staged / all) |
|
|
96
|
+
| `s` | Toggle side-by-side diff view |
|
|
97
|
+
| `w` | Toggle word-level diff highlighting |
|
|
98
|
+
| `W` | Toggle hide whitespace-only changes |
|
|
99
|
+
| `h` | Toggle syntax highlighting |
|
|
79
100
|
| `r` | Refresh diff |
|
|
80
101
|
| `q` | Quit |
|
|
81
102
|
|
|
103
|
+
### Git operations (g-prefixed)
|
|
104
|
+
|
|
105
|
+
| Key | Action |
|
|
106
|
+
|-----|--------|
|
|
107
|
+
| `ga` | Stage hunk under cursor |
|
|
108
|
+
| `gu` | Unstage hunk (staged mode) |
|
|
109
|
+
| `gx` | Discard hunk |
|
|
110
|
+
| `gc` | Commit with message prompt |
|
|
111
|
+
|
|
82
112
|
## Comment Storage
|
|
83
113
|
|
|
84
114
|
Comments are saved to `.nit.json` at the root of your git repository. The format is structured JSON:
|
|
@@ -8,6 +8,7 @@ src/nit/comments.py
|
|
|
8
8
|
src/nit/diff_parser.py
|
|
9
9
|
src/nit/git.py
|
|
10
10
|
src/nit/models.py
|
|
11
|
+
src/nit/syntax.py
|
|
11
12
|
src/nit_cli.egg-info/PKG-INFO
|
|
12
13
|
src/nit_cli.egg-info/SOURCES.txt
|
|
13
14
|
src/nit_cli.egg-info/dependency_links.txt
|
|
@@ -18,4 +19,5 @@ tests/test_app.py
|
|
|
18
19
|
tests/test_cli.py
|
|
19
20
|
tests/test_comments.py
|
|
20
21
|
tests/test_diff_parser.py
|
|
21
|
-
tests/test_git.py
|
|
22
|
+
tests/test_git.py
|
|
23
|
+
tests/test_syntax.py
|
|
@@ -92,4 +92,19 @@ async def test_app_detached_head(mock_git, mock_comments):
|
|
|
92
92
|
mock_git.get_current_branch.side_effect = Exception("not on branch")
|
|
93
93
|
app = NitApp()
|
|
94
94
|
async with app.run_test():
|
|
95
|
-
assert app.branch == ""
|
|
95
|
+
assert app.branch == "(detached HEAD)"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_g_chord_unrecognized_key_not_swallowed(mock_git, mock_comments):
|
|
99
|
+
app = NitApp()
|
|
100
|
+
async with app.run_test() as pilot:
|
|
101
|
+
# Press g — sets pending flag
|
|
102
|
+
await pilot.press("g")
|
|
103
|
+
assert app._pending_g is True
|
|
104
|
+
# Press unrecognized key — flag resets, key not swallowed
|
|
105
|
+
await pilot.press("z")
|
|
106
|
+
assert app._pending_g is False
|
|
107
|
+
# Valid chord still works after: gg = cursor to start
|
|
108
|
+
await pilot.press("g", "g")
|
|
109
|
+
dv = app.query_one("#diff-view")
|
|
110
|
+
assert dv.cursor_index == 0
|
|
@@ -2,6 +2,8 @@ import json
|
|
|
2
2
|
|
|
3
3
|
from nit.comments import (
|
|
4
4
|
comment_matches_line,
|
|
5
|
+
export_comments_json,
|
|
6
|
+
export_comments_markdown,
|
|
5
7
|
load_comments,
|
|
6
8
|
make_comment,
|
|
7
9
|
save_comments,
|
|
@@ -114,3 +116,53 @@ def test_load_missing_comment_fields(tmp_path):
|
|
|
114
116
|
loaded = load_comments(tmp_path)
|
|
115
117
|
assert len(loaded) == 1
|
|
116
118
|
assert loaded[0].file_path == "x.py"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _make_review_comment(**overrides):
|
|
122
|
+
defaults = dict(
|
|
123
|
+
file_path="src/app.py",
|
|
124
|
+
new_line_no=10,
|
|
125
|
+
old_line_no=None,
|
|
126
|
+
line_content="return result",
|
|
127
|
+
comment="Fix this",
|
|
128
|
+
hunk_context=["line1", "return result"],
|
|
129
|
+
timestamp="2025-01-15T10:00:00+00:00",
|
|
130
|
+
diff_mode="branch",
|
|
131
|
+
)
|
|
132
|
+
defaults.update(overrides)
|
|
133
|
+
return ReviewComment(**defaults)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_export_comments_markdown_empty():
|
|
137
|
+
assert export_comments_markdown([]) == ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_export_comments_markdown():
|
|
141
|
+
comments = [
|
|
142
|
+
_make_review_comment(file_path="b.py", new_line_no=5, comment="second file"),
|
|
143
|
+
_make_review_comment(file_path="a.py", new_line_no=10, comment="note here"),
|
|
144
|
+
_make_review_comment(
|
|
145
|
+
file_path="a.py", new_line_no=None, old_line_no=3, comment="old line comment"
|
|
146
|
+
),
|
|
147
|
+
]
|
|
148
|
+
md = export_comments_markdown(comments)
|
|
149
|
+
assert md.startswith("# Code Review Comments")
|
|
150
|
+
# Files sorted alphabetically
|
|
151
|
+
assert md.index("## a.py") < md.index("## b.py")
|
|
152
|
+
assert "**L10**: note here" in md
|
|
153
|
+
assert "**old L3**: old line comment" in md
|
|
154
|
+
assert "**L5**: second file" in md
|
|
155
|
+
assert "```" in md # code fence for line_content
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_export_comments_json():
|
|
159
|
+
comments = [
|
|
160
|
+
_make_review_comment(comment="test comment"),
|
|
161
|
+
]
|
|
162
|
+
result = export_comments_json(comments)
|
|
163
|
+
parsed = json.loads(result)
|
|
164
|
+
assert isinstance(parsed, list)
|
|
165
|
+
assert len(parsed) == 1
|
|
166
|
+
assert parsed[0]["comment"] == "test comment"
|
|
167
|
+
assert parsed[0]["file"] == "src/app.py"
|
|
168
|
+
assert parsed[0]["line"] == 10
|
|
@@ -10,21 +10,11 @@ def git_repo(tmp_path):
|
|
|
10
10
|
"""Create a temporary git repo with one commit."""
|
|
11
11
|
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True)
|
|
12
12
|
subprocess.run(["git", "checkout", "-b", "main"], cwd=tmp_path, capture_output=True)
|
|
13
|
+
subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, capture_output=True)
|
|
14
|
+
subprocess.run(["git", "config", "user.email", "t@t"], cwd=tmp_path, capture_output=True)
|
|
13
15
|
(tmp_path / "file.txt").write_text("hello\n")
|
|
14
16
|
subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True)
|
|
15
|
-
subprocess.run(
|
|
16
|
-
["git", "commit", "-m", "init"],
|
|
17
|
-
cwd=tmp_path,
|
|
18
|
-
capture_output=True,
|
|
19
|
-
env={
|
|
20
|
-
"GIT_AUTHOR_NAME": "test",
|
|
21
|
-
"GIT_AUTHOR_EMAIL": "t@t",
|
|
22
|
-
"GIT_COMMITTER_NAME": "test",
|
|
23
|
-
"GIT_COMMITTER_EMAIL": "t@t",
|
|
24
|
-
"HOME": str(tmp_path),
|
|
25
|
-
"PATH": subprocess.check_output(["bash", "-c", "echo $PATH"], text=True).strip(),
|
|
26
|
-
},
|
|
27
|
-
)
|
|
17
|
+
subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True)
|
|
28
18
|
return tmp_path
|
|
29
19
|
|
|
30
20
|
|
|
@@ -70,22 +60,6 @@ def test_detached_head(git_repo):
|
|
|
70
60
|
assert branch == ""
|
|
71
61
|
|
|
72
62
|
|
|
73
|
-
GIT_ENV_KEYS = {
|
|
74
|
-
"GIT_AUTHOR_NAME": "test",
|
|
75
|
-
"GIT_AUTHOR_EMAIL": "t@t",
|
|
76
|
-
"GIT_COMMITTER_NAME": "test",
|
|
77
|
-
"GIT_COMMITTER_EMAIL": "t@t",
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _git_env(tmp_path):
|
|
82
|
-
return {
|
|
83
|
-
**GIT_ENV_KEYS,
|
|
84
|
-
"HOME": str(tmp_path),
|
|
85
|
-
"PATH": subprocess.check_output(["bash", "-c", "echo $PATH"], text=True).strip(),
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
63
|
def test_get_staged_diff_empty(git_repo):
|
|
90
64
|
diff = git.get_staged_diff(cwd=git_repo)
|
|
91
65
|
assert diff == ""
|
|
@@ -132,12 +106,7 @@ def test_commit(git_repo):
|
|
|
132
106
|
(git_repo / "file.txt").write_text("committed\n")
|
|
133
107
|
subprocess.run(["git", "add", "file.txt"], cwd=git_repo, capture_output=True)
|
|
134
108
|
git.commit("test commit", cwd=git_repo)
|
|
135
|
-
log = subprocess.check_output(
|
|
136
|
-
["git", "log", "--oneline", "-1"],
|
|
137
|
-
cwd=git_repo,
|
|
138
|
-
text=True,
|
|
139
|
-
env=_git_env(git_repo),
|
|
140
|
-
)
|
|
109
|
+
log = subprocess.check_output(["git", "log", "--oneline", "-1"], cwd=git_repo, text=True)
|
|
141
110
|
assert "test commit" in log
|
|
142
111
|
|
|
143
112
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from rich.text import Text
|
|
2
|
+
|
|
3
|
+
from nit.syntax import detect_language, highlight_line
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_detect_language_python():
|
|
7
|
+
assert detect_language("app.py") == "python"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_detect_language_javascript():
|
|
11
|
+
assert detect_language("index.js") == "javascript"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_detect_language_unknown():
|
|
15
|
+
assert detect_language("file.xyzunknown") is None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_highlight_line_returns_text():
|
|
19
|
+
result = highlight_line("x = 1", "python")
|
|
20
|
+
assert isinstance(result, Text)
|
|
21
|
+
assert str(result).strip() == "x = 1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_highlight_line_empty():
|
|
25
|
+
result = highlight_line("", "python")
|
|
26
|
+
assert isinstance(result, Text)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|