nit-cli 0.2.0__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.
- nit/__init__.py +6 -0
- nit/app.py +656 -0
- nit/cli.py +66 -0
- nit/comments.py +102 -0
- nit/diff_parser.py +126 -0
- nit/git.py +88 -0
- nit/models.py +41 -0
- nit_cli-0.2.0.dist-info/METADATA +121 -0
- nit_cli-0.2.0.dist-info/RECORD +13 -0
- nit_cli-0.2.0.dist-info/WHEEL +5 -0
- nit_cli-0.2.0.dist-info/entry_points.txt +2 -0
- nit_cli-0.2.0.dist-info/licenses/LICENSE +674 -0
- nit_cli-0.2.0.dist-info/top_level.txt +1 -0
nit/cli.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CLIArgs:
|
|
10
|
+
mode: str | None = None
|
|
11
|
+
commit_range: str | None = None
|
|
12
|
+
path_filter: str | None = None
|
|
13
|
+
verbose: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
try:
|
|
18
|
+
version = importlib.metadata.version("nit-cli")
|
|
19
|
+
except importlib.metadata.PackageNotFoundError:
|
|
20
|
+
version = "0.0.0-dev"
|
|
21
|
+
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="nit",
|
|
24
|
+
description="Terminal diff viewer with inline review comments.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--version",
|
|
28
|
+
action="version",
|
|
29
|
+
version=f"nit {version}",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--mode",
|
|
33
|
+
choices=["branch", "unstaged", "all"],
|
|
34
|
+
default=None,
|
|
35
|
+
help="Diff mode (default: branch)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--path",
|
|
39
|
+
default=None,
|
|
40
|
+
help="Filter to specific file or directory path",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"-v",
|
|
44
|
+
"--verbose",
|
|
45
|
+
action="store_true",
|
|
46
|
+
default=False,
|
|
47
|
+
help="Enable verbose logging to stderr",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"commit_range",
|
|
51
|
+
nargs="?",
|
|
52
|
+
default=None,
|
|
53
|
+
help="Git commit range, e.g. HEAD~3..HEAD or main..feature",
|
|
54
|
+
)
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_args(argv: list[str] | None = None) -> CLIArgs:
|
|
59
|
+
parser = build_parser()
|
|
60
|
+
ns = parser.parse_args(argv)
|
|
61
|
+
return CLIArgs(
|
|
62
|
+
mode=ns.mode,
|
|
63
|
+
commit_range=ns.commit_range,
|
|
64
|
+
path_filter=ns.path,
|
|
65
|
+
verbose=ns.verbose,
|
|
66
|
+
)
|
nit/comments.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .models import DiffLine, ReviewComment
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
COMMENT_FILE = ".nit.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _comment_to_dict(c: ReviewComment) -> dict:
|
|
17
|
+
d: dict = {"file": c.file_path, "line_content": c.line_content, "comment": c.comment}
|
|
18
|
+
if c.new_line_no is not None:
|
|
19
|
+
d["line"] = c.new_line_no
|
|
20
|
+
if c.old_line_no is not None:
|
|
21
|
+
d["old_line"] = c.old_line_no
|
|
22
|
+
d["hunk_context"] = c.hunk_context
|
|
23
|
+
d["timestamp"] = c.timestamp
|
|
24
|
+
d["diff_mode"] = c.diff_mode
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _dict_to_comment(d: dict) -> ReviewComment:
|
|
29
|
+
return ReviewComment(
|
|
30
|
+
file_path=d["file"],
|
|
31
|
+
new_line_no=d.get("line"),
|
|
32
|
+
old_line_no=d.get("old_line"),
|
|
33
|
+
line_content=d.get("line_content", ""),
|
|
34
|
+
comment=d["comment"],
|
|
35
|
+
hunk_context=d.get("hunk_context", []),
|
|
36
|
+
timestamp=d.get("timestamp", ""),
|
|
37
|
+
diff_mode=d.get("diff_mode", "branch"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_comments(repo_root: Path) -> list[ReviewComment]:
|
|
42
|
+
path = repo_root / COMMENT_FILE
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return []
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(path.read_text())
|
|
47
|
+
return [_dict_to_comment(c) for c in data.get("comments", [])]
|
|
48
|
+
except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
|
|
49
|
+
logger.warning("Failed to load %s: %s", path, e)
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_comments(
|
|
54
|
+
repo_root: Path,
|
|
55
|
+
comments: list[ReviewComment],
|
|
56
|
+
branch: str = "",
|
|
57
|
+
base: str = "",
|
|
58
|
+
) -> None:
|
|
59
|
+
data = {
|
|
60
|
+
"version": 1,
|
|
61
|
+
"branch": branch,
|
|
62
|
+
"base": base,
|
|
63
|
+
"comments": [_comment_to_dict(c) for c in comments],
|
|
64
|
+
}
|
|
65
|
+
path = repo_root / COMMENT_FILE
|
|
66
|
+
# Atomic write
|
|
67
|
+
fd, tmp = tempfile.mkstemp(dir=repo_root, suffix=".nit.tmp")
|
|
68
|
+
try:
|
|
69
|
+
with open(fd, "w") as f:
|
|
70
|
+
json.dump(data, f, indent=2)
|
|
71
|
+
f.write("\n")
|
|
72
|
+
Path(tmp).replace(path)
|
|
73
|
+
except Exception:
|
|
74
|
+
Path(tmp).unlink(missing_ok=True)
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def comment_matches_line(c: ReviewComment, dl: DiffLine) -> bool:
|
|
79
|
+
if c.new_line_no is not None and dl.new_line_no == c.new_line_no:
|
|
80
|
+
return True
|
|
81
|
+
if c.old_line_no is not None and dl.old_line_no == c.old_line_no and dl.new_line_no is None:
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_comment(
|
|
87
|
+
file_path: str,
|
|
88
|
+
line: DiffLine,
|
|
89
|
+
comment_text: str,
|
|
90
|
+
hunk_context: list[str],
|
|
91
|
+
diff_mode: str = "branch",
|
|
92
|
+
) -> ReviewComment:
|
|
93
|
+
return ReviewComment(
|
|
94
|
+
file_path=file_path,
|
|
95
|
+
new_line_no=line.new_line_no,
|
|
96
|
+
old_line_no=line.old_line_no,
|
|
97
|
+
line_content=line.content,
|
|
98
|
+
comment=comment_text,
|
|
99
|
+
hunk_context=hunk_context,
|
|
100
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
101
|
+
diff_mode=diff_mode,
|
|
102
|
+
)
|
nit/diff_parser.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .models import DiffHunk, DiffLine, FileDiff
|
|
6
|
+
|
|
7
|
+
DIFF_HEADER_RE = re.compile(r"^diff --git a/(.*) b/(.*)")
|
|
8
|
+
HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)")
|
|
9
|
+
RENAME_FROM_RE = re.compile(r"^rename from (.*)")
|
|
10
|
+
RENAME_TO_RE = re.compile(r"^rename to (.*)")
|
|
11
|
+
NEW_FILE_RE = re.compile(r"^new file mode")
|
|
12
|
+
DELETED_FILE_RE = re.compile(r"^deleted file mode")
|
|
13
|
+
BINARY_RE = re.compile(r"^Binary files")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_diff(text: str) -> list[FileDiff]:
|
|
17
|
+
if not text.strip():
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
files: list[FileDiff] = []
|
|
21
|
+
current_file: FileDiff | None = None
|
|
22
|
+
current_hunk: DiffHunk | None = None
|
|
23
|
+
old_no = 0
|
|
24
|
+
new_no = 0
|
|
25
|
+
|
|
26
|
+
for line in text.splitlines():
|
|
27
|
+
# New file diff
|
|
28
|
+
m = DIFF_HEADER_RE.match(line)
|
|
29
|
+
if m:
|
|
30
|
+
current_file = FileDiff(path=m.group(2), old_path=m.group(1))
|
|
31
|
+
if m.group(1) != m.group(2):
|
|
32
|
+
current_file.status = "renamed"
|
|
33
|
+
files.append(current_file)
|
|
34
|
+
current_hunk = None
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
if current_file is None:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# File metadata
|
|
41
|
+
if NEW_FILE_RE.match(line):
|
|
42
|
+
current_file.status = "added"
|
|
43
|
+
continue
|
|
44
|
+
if DELETED_FILE_RE.match(line):
|
|
45
|
+
current_file.status = "deleted"
|
|
46
|
+
continue
|
|
47
|
+
m = RENAME_FROM_RE.match(line)
|
|
48
|
+
if m:
|
|
49
|
+
current_file.old_path = m.group(1)
|
|
50
|
+
current_file.status = "renamed"
|
|
51
|
+
continue
|
|
52
|
+
m = RENAME_TO_RE.match(line)
|
|
53
|
+
if m:
|
|
54
|
+
current_file.path = m.group(1)
|
|
55
|
+
continue
|
|
56
|
+
if BINARY_RE.match(line):
|
|
57
|
+
current_file.is_binary = True
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Skip index and ---/+++ lines
|
|
61
|
+
if line.startswith("index ") or line.startswith("--- ") or line.startswith("+++ "):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# Hunk header
|
|
65
|
+
m = HUNK_HEADER_RE.match(line)
|
|
66
|
+
if m:
|
|
67
|
+
old_no = int(m.group(1))
|
|
68
|
+
new_no = int(m.group(2))
|
|
69
|
+
current_hunk = DiffHunk(
|
|
70
|
+
header=line,
|
|
71
|
+
old_start=old_no,
|
|
72
|
+
new_start=new_no,
|
|
73
|
+
)
|
|
74
|
+
current_hunk.lines.append(
|
|
75
|
+
DiffLine(
|
|
76
|
+
content=m.group(3).strip(),
|
|
77
|
+
line_type="hunk_header",
|
|
78
|
+
raw=line,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
current_file.hunks.append(current_hunk)
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if current_hunk is None:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Diff lines
|
|
88
|
+
if line.startswith("+"):
|
|
89
|
+
current_hunk.lines.append(
|
|
90
|
+
DiffLine(
|
|
91
|
+
content=line[1:],
|
|
92
|
+
line_type="add",
|
|
93
|
+
new_line_no=new_no,
|
|
94
|
+
raw=line,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
new_no += 1
|
|
98
|
+
elif line.startswith("-"):
|
|
99
|
+
current_hunk.lines.append(
|
|
100
|
+
DiffLine(
|
|
101
|
+
content=line[1:],
|
|
102
|
+
line_type="remove",
|
|
103
|
+
old_line_no=old_no,
|
|
104
|
+
raw=line,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
old_no += 1
|
|
108
|
+
elif line.startswith("\\"):
|
|
109
|
+
# ""
|
|
110
|
+
continue
|
|
111
|
+
else:
|
|
112
|
+
# Context line (starts with space or is empty)
|
|
113
|
+
content = line[1:] if line.startswith(" ") else line
|
|
114
|
+
current_hunk.lines.append(
|
|
115
|
+
DiffLine(
|
|
116
|
+
content=content,
|
|
117
|
+
line_type="context",
|
|
118
|
+
old_line_no=old_no,
|
|
119
|
+
new_line_no=new_no,
|
|
120
|
+
raw=line,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
old_no += 1
|
|
124
|
+
new_no += 1
|
|
125
|
+
|
|
126
|
+
return files
|
nit/git.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
GIT_TIMEOUT = 30
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run(args: list[str], cwd: Path | None = None) -> str:
|
|
13
|
+
logger.debug("Running: %s", " ".join(args))
|
|
14
|
+
try:
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
args,
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
cwd=cwd,
|
|
20
|
+
timeout=GIT_TIMEOUT,
|
|
21
|
+
)
|
|
22
|
+
except subprocess.TimeoutExpired:
|
|
23
|
+
logger.warning("Git command timed out: %s", " ".join(args))
|
|
24
|
+
raise subprocess.CalledProcessError(1, args, "", "Command timed out")
|
|
25
|
+
if result.returncode != 0:
|
|
26
|
+
raise subprocess.CalledProcessError(
|
|
27
|
+
result.returncode,
|
|
28
|
+
args,
|
|
29
|
+
result.stdout,
|
|
30
|
+
result.stderr,
|
|
31
|
+
)
|
|
32
|
+
return result.stdout
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_repo_root(cwd: Path | None = None) -> Path:
|
|
36
|
+
out = _run(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
|
|
37
|
+
return Path(out.strip())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_current_branch(cwd: Path | None = None) -> str:
|
|
41
|
+
return _run(["git", "branch", "--show-current"], cwd=cwd).strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_main_branch(cwd: Path | None = None) -> str:
|
|
45
|
+
for name in ("main", "master"):
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["git", "rev-parse", "--verify", f"refs/heads/{name}"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
timeout=GIT_TIMEOUT,
|
|
52
|
+
)
|
|
53
|
+
if result.returncode == 0:
|
|
54
|
+
return name
|
|
55
|
+
return "main"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_merge_base(base: str, cwd: Path | None = None) -> str:
|
|
59
|
+
return _run(["git", "merge-base", base, "HEAD"], cwd=cwd).strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _append_path_filter(cmd: list[str], path_filter: str | None) -> list[str]:
|
|
63
|
+
if path_filter:
|
|
64
|
+
return cmd + ["--", path_filter]
|
|
65
|
+
return cmd
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_branch_diff(cwd: Path | None = None, path_filter: str | None = None) -> str:
|
|
69
|
+
base = get_main_branch(cwd)
|
|
70
|
+
cmd = ["git", "diff", f"{base}...HEAD"]
|
|
71
|
+
return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_unstaged_diff(cwd: Path | None = None, path_filter: str | None = None) -> str:
|
|
75
|
+
cmd = ["git", "diff"]
|
|
76
|
+
return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_all_uncommitted_diff(cwd: Path | None = None, path_filter: str | None = None) -> str:
|
|
80
|
+
cmd = ["git", "diff", "HEAD"]
|
|
81
|
+
return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_commit_range_diff(
|
|
85
|
+
commit_range: str, cwd: Path | None = None, path_filter: str | None = None
|
|
86
|
+
) -> str:
|
|
87
|
+
cmd = ["git", "diff", commit_range]
|
|
88
|
+
return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
|
nit/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class DiffLine:
|
|
8
|
+
content: str
|
|
9
|
+
line_type: str # "context", "add", "remove", "hunk_header"
|
|
10
|
+
old_line_no: int | None = None
|
|
11
|
+
new_line_no: int | None = None
|
|
12
|
+
raw: str = ""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DiffHunk:
|
|
17
|
+
header: str
|
|
18
|
+
lines: list[DiffLine] = field(default_factory=list)
|
|
19
|
+
old_start: int = 0
|
|
20
|
+
new_start: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FileDiff:
|
|
25
|
+
path: str
|
|
26
|
+
old_path: str | None = None
|
|
27
|
+
status: str = "modified" # "modified", "added", "deleted", "renamed"
|
|
28
|
+
hunks: list[DiffHunk] = field(default_factory=list)
|
|
29
|
+
is_binary: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ReviewComment:
|
|
34
|
+
file_path: str
|
|
35
|
+
new_line_no: int | None
|
|
36
|
+
old_line_no: int | None
|
|
37
|
+
line_content: str
|
|
38
|
+
comment: str
|
|
39
|
+
hunk_context: list[str] = field(default_factory=list)
|
|
40
|
+
timestamp: str = ""
|
|
41
|
+
diff_mode: str = "branch"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nit-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Terminal diff viewer with inline review comments
|
|
5
|
+
Author: Joshua Zink-Duda
|
|
6
|
+
License-Expression: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/joshuazd/nit
|
|
8
|
+
Project-URL: Repository, https://github.com/joshuazd/nit
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: textual<1.0,>=0.47.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# nit
|
|
29
|
+
|
|
30
|
+
Terminal diff viewer with inline review comments.
|
|
31
|
+
|
|
32
|
+
Navigate diffs with vim-style keybindings, leave comments on specific lines, and persist them as structured JSON. Useful for code review workflows, self-review before committing, and giving feedback to AI coding tools.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install nit-cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Review current branch changes (vs main/master)
|
|
44
|
+
nit
|
|
45
|
+
|
|
46
|
+
# Review unstaged changes
|
|
47
|
+
nit --mode unstaged
|
|
48
|
+
|
|
49
|
+
# Review all uncommitted changes
|
|
50
|
+
nit --mode all
|
|
51
|
+
|
|
52
|
+
# Review a specific commit range
|
|
53
|
+
nit HEAD~3..HEAD
|
|
54
|
+
nit main..feature
|
|
55
|
+
|
|
56
|
+
# Filter to specific files or directories
|
|
57
|
+
nit --path src/
|
|
58
|
+
nit --path src/nit/app.py main..feature
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Keybindings
|
|
62
|
+
|
|
63
|
+
| Key | Action |
|
|
64
|
+
|-----|--------|
|
|
65
|
+
| `j` / `k` | Move cursor down / up |
|
|
66
|
+
| `J` / `K` | Jump to next / previous hunk |
|
|
67
|
+
| `n` / `p` | Next / previous file |
|
|
68
|
+
| `]` / `[` | Jump to next / previous comment |
|
|
69
|
+
| `c` | Add comment on current line |
|
|
70
|
+
| `d` | Delete comment on current line |
|
|
71
|
+
| `m` | Cycle diff mode (branch / unstaged / all) |
|
|
72
|
+
| `r` | Refresh diff |
|
|
73
|
+
| `q` | Quit |
|
|
74
|
+
|
|
75
|
+
## Comment Storage
|
|
76
|
+
|
|
77
|
+
Comments are saved to `.nit.json` at the root of your git repository. The format is structured JSON:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"version": 1,
|
|
82
|
+
"branch": "my-feature",
|
|
83
|
+
"base": "main",
|
|
84
|
+
"comments": [
|
|
85
|
+
{
|
|
86
|
+
"file": "src/app.py",
|
|
87
|
+
"line": 42,
|
|
88
|
+
"line_content": " return result",
|
|
89
|
+
"comment": "Should handle the empty case here",
|
|
90
|
+
"hunk_context": ["...", "..."],
|
|
91
|
+
"timestamp": "2025-01-15T10:30:00+00:00",
|
|
92
|
+
"diff_mode": "branch"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Add `.nit.json` to your global gitignore to keep comments local:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
echo '.nit.json' >> ~/.config/git/ignore
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Claude Code Integration
|
|
105
|
+
|
|
106
|
+
nit works as a review tool for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Leave comments on Claude's changes using nit, then have Claude read them with the `/review-feedback` skill. This creates a feedback loop where you can guide Claude's work through inline code review.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
git clone https://github.com/joshuazd/nit.git
|
|
112
|
+
cd nit
|
|
113
|
+
pip install -e ".[dev]"
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The `./nit` bootstrap script auto-creates a virtualenv for quick local use without manual setup.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
GPL-3.0 - see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
nit/__init__.py,sha256=ItrZPRnS5iTpsbefdIGOsHfcTPSyNahRYu3hmaD0j5s,163
|
|
2
|
+
nit/app.py,sha256=yYF4Y1QT7EE4sfUyyDF6h-8LAjkYV3EEreOerD9H53Q,21879
|
|
3
|
+
nit/cli.py,sha256=xuwyqoDScZ17oJ6OuFTNkOuPMQXuFRZ6ZXAP16C3Npw,1616
|
|
4
|
+
nit/comments.py,sha256=FyP60vtHBCVZFXsY7Mcz7hA_RJ4Kuy2pN4AI3ga1InI,2869
|
|
5
|
+
nit/diff_parser.py,sha256=uwOeRbGRNLlXWk7Uvn2SIDTIvZDIPQaeDUbvnmlOZ44,3741
|
|
6
|
+
nit/git.py,sha256=KZ7MljE0N-74eaiArhKvGbObj_EQJ1TRJsT2tA5MK5A,2616
|
|
7
|
+
nit/models.py,sha256=bI8EYXN3ac4qm8GMqZuyhZkb24UwSJs4ab3lBCjghgc,915
|
|
8
|
+
nit_cli-0.2.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
9
|
+
nit_cli-0.2.0.dist-info/METADATA,sha256=V5A0e2f1cdxB4eDPW6Z2qGVT7dCW8s9t_l4TQYL8byg,3273
|
|
10
|
+
nit_cli-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
nit_cli-0.2.0.dist-info/entry_points.txt,sha256=t2GZfgXHdiO58CuJAcrHDz8pcWAbHRsTcQ5F504xNWw,37
|
|
12
|
+
nit_cli-0.2.0.dist-info/top_level.txt,sha256=HPE4deTgjYUBT7wH6DHtIVjNnO6KsJnbLn2z8Bx2aCc,4
|
|
13
|
+
nit_cli-0.2.0.dist-info/RECORD,,
|