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/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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nit = nit.app:main