git-sage 0.1.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.
- git_sage/__init__.py +3 -0
- git_sage/cli.py +222 -0
- git_sage/diff.py +76 -0
- git_sage/hook.py +128 -0
- git_sage/ollama.py +123 -0
- git_sage/output.py +130 -0
- git_sage/parser.py +148 -0
- git_sage/prompt.py +100 -0
- git_sage-0.1.0.dist-info/METADATA +176 -0
- git_sage-0.1.0.dist-info/RECORD +14 -0
- git_sage-0.1.0.dist-info/WHEEL +5 -0
- git_sage-0.1.0.dist-info/entry_points.txt +2 -0
- git_sage-0.1.0.dist-info/licenses/LICENSE +21 -0
- git_sage-0.1.0.dist-info/top_level.txt +1 -0
git_sage/__init__.py
ADDED
git_sage/cli.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py
|
|
3
|
+
------
|
|
4
|
+
Click-based CLI entrypoint for git-sage.
|
|
5
|
+
|
|
6
|
+
Commands
|
|
7
|
+
--------
|
|
8
|
+
git-sage review Run a review of staged changes (interactive)
|
|
9
|
+
git-sage review --hook Run a review triggered by the pre-push hook
|
|
10
|
+
git-sage install Install the pre-push hook in the current repo
|
|
11
|
+
git-sage uninstall Remove the pre-push hook
|
|
12
|
+
git-sage status Show tool version, hook status, and Ollama availability
|
|
13
|
+
git-sage models List locally available Ollama models
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from git_sage import __version__
|
|
20
|
+
from git_sage import diff as diff_mod
|
|
21
|
+
from git_sage import hook as hook_mod
|
|
22
|
+
from git_sage import ollama as ollama_mod
|
|
23
|
+
from git_sage import output
|
|
24
|
+
from git_sage.prompt import build_messages
|
|
25
|
+
from git_sage.parser import parse, Verdict
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Root group
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
@click.version_option(__version__, prog_name="git-sage")
|
|
34
|
+
def main() -> None:
|
|
35
|
+
"""git-sage — local AI code review for your git workflow."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# review
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
@main.command()
|
|
43
|
+
@click.option(
|
|
44
|
+
"--model", "-m",
|
|
45
|
+
default=ollama_mod.DEFAULT_MODEL,
|
|
46
|
+
show_default=True,
|
|
47
|
+
help="Ollama model to use for review.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--host",
|
|
51
|
+
default=ollama_mod.DEFAULT_HOST,
|
|
52
|
+
show_default=True,
|
|
53
|
+
help="Ollama server URL.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--context", "-c",
|
|
57
|
+
default=None,
|
|
58
|
+
help='Optional note about this change, e.g. "Adds OAuth login".',
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--hook",
|
|
62
|
+
is_flag=True,
|
|
63
|
+
hidden=True,
|
|
64
|
+
help="Internal flag: invoked from the pre-push hook.",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--diff-mode",
|
|
68
|
+
type=click.Choice(["staged", "head", "branch"]),
|
|
69
|
+
default="staged",
|
|
70
|
+
show_default=True,
|
|
71
|
+
help="Which diff to review.",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--base",
|
|
75
|
+
default="main",
|
|
76
|
+
show_default=True,
|
|
77
|
+
help="Base branch for --diff-mode=branch.",
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--force", "-f",
|
|
81
|
+
is_flag=True,
|
|
82
|
+
help="Do not abort the push even if the verdict is REVISE (hook mode only).",
|
|
83
|
+
)
|
|
84
|
+
def review(model, host, context, hook, diff_mode, base, force) -> None:
|
|
85
|
+
"""Review staged (or recent) changes with a local AI model."""
|
|
86
|
+
|
|
87
|
+
# 1. Check Ollama is running
|
|
88
|
+
if not ollama_mod.is_available(host):
|
|
89
|
+
output.print_error(
|
|
90
|
+
f"Ollama is not running at {host}.\n"
|
|
91
|
+
" Start it with: ollama serve\n"
|
|
92
|
+
f" Then pull a model: ollama pull {ollama_mod.DEFAULT_MODEL}"
|
|
93
|
+
)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
# 2. Extract the diff
|
|
97
|
+
try:
|
|
98
|
+
if diff_mode == "staged":
|
|
99
|
+
diff = diff_mod.get_staged_diff()
|
|
100
|
+
elif diff_mode == "head":
|
|
101
|
+
diff = diff_mod.get_head_diff()
|
|
102
|
+
else:
|
|
103
|
+
diff = diff_mod.get_branch_diff(base)
|
|
104
|
+
except RuntimeError as exc:
|
|
105
|
+
output.print_error(str(exc))
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
if not diff.raw.strip():
|
|
109
|
+
output.print_warning("No changes found to review.")
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
output.print_diff_stats(diff)
|
|
113
|
+
|
|
114
|
+
# 3. Build prompt and call Ollama
|
|
115
|
+
messages = build_messages(diff, context)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
with output.thinking_spinner(f"Reviewing with {model}…"):
|
|
119
|
+
raw_response = ollama_mod.chat(messages, model=model, host=host)
|
|
120
|
+
except ollama_mod.OllamaError as exc:
|
|
121
|
+
output.print_error(f"Ollama error: {exc}")
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
|
|
124
|
+
# 4. Parse and render
|
|
125
|
+
result = parse(raw_response)
|
|
126
|
+
output.print_review(result)
|
|
127
|
+
|
|
128
|
+
# 5. Hook mode: non-zero exit aborts the push
|
|
129
|
+
if hook and result.verdict == Verdict.REVISE and not force:
|
|
130
|
+
click.echo(
|
|
131
|
+
" Push aborted by git-sage. Fix the issues above, or run:\n"
|
|
132
|
+
" git push --no-verify to bypass the hook.\n"
|
|
133
|
+
)
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# install
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
@main.command()
|
|
142
|
+
def install() -> None:
|
|
143
|
+
"""Install the git-sage pre-push hook in the current repository."""
|
|
144
|
+
try:
|
|
145
|
+
hook_path = hook_mod.install()
|
|
146
|
+
output.print_success(f"Hook installed at {hook_path}")
|
|
147
|
+
click.echo(" git-sage will now review your changes before every push.\n")
|
|
148
|
+
except RuntimeError as exc:
|
|
149
|
+
output.print_error(str(exc))
|
|
150
|
+
sys.exit(1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# uninstall
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
@main.command()
|
|
158
|
+
def uninstall() -> None:
|
|
159
|
+
"""Remove the git-sage pre-push hook from the current repository."""
|
|
160
|
+
try:
|
|
161
|
+
removed = hook_mod.uninstall()
|
|
162
|
+
if removed:
|
|
163
|
+
output.print_success("Hook removed.")
|
|
164
|
+
else:
|
|
165
|
+
output.print_warning("No git-sage hook found in this repository.")
|
|
166
|
+
except RuntimeError as exc:
|
|
167
|
+
output.print_error(str(exc))
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# status
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
@main.command()
|
|
176
|
+
@click.option("--host", default=ollama_mod.DEFAULT_HOST, show_default=True)
|
|
177
|
+
def status(host) -> None:
|
|
178
|
+
"""Show the current status of git-sage, the hook, and Ollama."""
|
|
179
|
+
click.echo(f"\n git-sage v{__version__}\n")
|
|
180
|
+
|
|
181
|
+
# Ollama
|
|
182
|
+
if ollama_mod.is_available(host):
|
|
183
|
+
click.echo(f" [✓] Ollama running at {host}")
|
|
184
|
+
models = ollama_mod.list_models(host)
|
|
185
|
+
if models:
|
|
186
|
+
click.echo(f" Models: {', '.join(models)}")
|
|
187
|
+
else:
|
|
188
|
+
click.echo(f" [✗] Ollama not reachable at {host}")
|
|
189
|
+
click.echo( " Start with: ollama serve")
|
|
190
|
+
|
|
191
|
+
# Hook
|
|
192
|
+
if hook_mod.is_installed():
|
|
193
|
+
click.echo(" [✓] pre-push hook installed")
|
|
194
|
+
else:
|
|
195
|
+
click.echo(" [ ] pre-push hook not installed")
|
|
196
|
+
click.echo(" Run: git-sage install")
|
|
197
|
+
|
|
198
|
+
click.echo()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# models
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
@main.command()
|
|
206
|
+
@click.option("--host", default=ollama_mod.DEFAULT_HOST, show_default=True)
|
|
207
|
+
def models(host) -> None:
|
|
208
|
+
"""List locally available Ollama models."""
|
|
209
|
+
if not ollama_mod.is_available(host):
|
|
210
|
+
output.print_error(f"Ollama is not running at {host}.")
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
model_list = ollama_mod.list_models(host)
|
|
214
|
+
if not model_list:
|
|
215
|
+
click.echo("\n No models found. Pull one with:\n")
|
|
216
|
+
click.echo(f" ollama pull {ollama_mod.DEFAULT_MODEL}\n")
|
|
217
|
+
else:
|
|
218
|
+
click.echo(f"\n Available models ({len(model_list)}):\n")
|
|
219
|
+
for m in model_list:
|
|
220
|
+
marker = " ●" if m.startswith("qwen2.5-coder") else " ○"
|
|
221
|
+
click.echo(f"{marker} {m}")
|
|
222
|
+
click.echo()
|
git_sage/diff.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
diff.py
|
|
3
|
+
-------
|
|
4
|
+
Extracts diffs from git using subprocess.
|
|
5
|
+
|
|
6
|
+
Supports two modes:
|
|
7
|
+
- staged: changes added with `git add` (used during pre-push / manual review)
|
|
8
|
+
- head: diff of the last commit vs its parent (useful for post-commit review)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DiffResult:
|
|
17
|
+
raw: str # full unified diff text
|
|
18
|
+
file_count: int # number of changed files
|
|
19
|
+
additions: int # total lines added
|
|
20
|
+
deletions: int # total lines removed
|
|
21
|
+
files: list[str] # list of changed file paths
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_staged_diff() -> DiffResult:
|
|
25
|
+
"""Return the diff of all staged changes (git diff --cached)."""
|
|
26
|
+
return _run_diff(["git", "diff", "--cached"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_head_diff() -> DiffResult:
|
|
30
|
+
"""Return the diff of the last commit vs its parent (git diff HEAD~1 HEAD)."""
|
|
31
|
+
return _run_diff(["git", "diff", "HEAD~1", "HEAD"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_branch_diff(base: str = "main") -> DiffResult:
|
|
35
|
+
"""Return the diff of the current branch vs a base branch."""
|
|
36
|
+
return _run_diff(["git", "diff", f"{base}...HEAD"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run_diff(cmd: list[str]) -> DiffResult:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
cmd,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if result.returncode != 0:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
f"git diff failed:\n{result.stderr.strip()}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
raw = result.stdout
|
|
52
|
+
|
|
53
|
+
if not raw.strip():
|
|
54
|
+
return DiffResult(raw="", file_count=0, additions=0, deletions=0, files=[])
|
|
55
|
+
|
|
56
|
+
additions = sum(1 for line in raw.splitlines() if line.startswith("+") and not line.startswith("+++"))
|
|
57
|
+
deletions = sum(1 for line in raw.splitlines() if line.startswith("-") and not line.startswith("---"))
|
|
58
|
+
files = _extract_files(raw)
|
|
59
|
+
|
|
60
|
+
return DiffResult(
|
|
61
|
+
raw=raw,
|
|
62
|
+
file_count=len(files),
|
|
63
|
+
additions=additions,
|
|
64
|
+
deletions=deletions,
|
|
65
|
+
files=files,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_files(diff_text: str) -> list[str]:
|
|
70
|
+
files = []
|
|
71
|
+
for line in diff_text.splitlines():
|
|
72
|
+
if line.startswith("+++ b/"):
|
|
73
|
+
path = line.removeprefix("+++ b/")
|
|
74
|
+
if path not in files:
|
|
75
|
+
files.append(path)
|
|
76
|
+
return files
|
git_sage/hook.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hook.py
|
|
3
|
+
-------
|
|
4
|
+
Installs and removes the git pre-push hook that triggers git-sage automatically.
|
|
5
|
+
|
|
6
|
+
The hook is a small shell script written to .git/hooks/pre-push. When git push
|
|
7
|
+
is run, git executes this script first. If the script exits with a non-zero
|
|
8
|
+
code, the push is aborted.
|
|
9
|
+
|
|
10
|
+
This is intentionally simple — the hook just calls `git-sage review --hook`
|
|
11
|
+
which handles all the logic in Python.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
HOOK_MARKER = "# git-sage managed hook"
|
|
21
|
+
|
|
22
|
+
HOOK_SCRIPT = """\
|
|
23
|
+
#!/usr/bin/env sh
|
|
24
|
+
{marker}
|
|
25
|
+
# This hook was installed by git-sage.
|
|
26
|
+
# Run: git-sage uninstall to remove it.
|
|
27
|
+
|
|
28
|
+
git-sage review --hook
|
|
29
|
+
""".format(marker=HOOK_MARKER)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Public API
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def install(repo_root: Path | None = None) -> Path:
|
|
37
|
+
"""
|
|
38
|
+
Write the pre-push hook to the git repo's hooks directory.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
repo_root:
|
|
43
|
+
Path to the git repository root. Auto-detected if not provided.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
Path to the installed hook file.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
RuntimeError if we're not inside a git repository.
|
|
52
|
+
"""
|
|
53
|
+
hooks_dir = _get_hooks_dir(repo_root)
|
|
54
|
+
hook_path = hooks_dir / "pre-push"
|
|
55
|
+
|
|
56
|
+
if hook_path.exists():
|
|
57
|
+
existing = hook_path.read_text()
|
|
58
|
+
if HOOK_MARKER in existing:
|
|
59
|
+
# Already installed by us — overwrite silently (idempotent)
|
|
60
|
+
pass
|
|
61
|
+
else:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"A pre-push hook already exists at {hook_path} and was not "
|
|
64
|
+
"created by git-sage. Remove it manually before installing."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
hook_path.write_text(HOOK_SCRIPT)
|
|
68
|
+
# Make the hook executable (equivalent to chmod +x)
|
|
69
|
+
hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
70
|
+
|
|
71
|
+
return hook_path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def uninstall(repo_root: Path | None = None) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Remove the git-sage pre-push hook if it exists.
|
|
77
|
+
|
|
78
|
+
Returns True if removed, False if no hook was found.
|
|
79
|
+
"""
|
|
80
|
+
hooks_dir = _get_hooks_dir(repo_root)
|
|
81
|
+
hook_path = hooks_dir / "pre-push"
|
|
82
|
+
|
|
83
|
+
if not hook_path.exists():
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
existing = hook_path.read_text()
|
|
87
|
+
if HOOK_MARKER not in existing:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
f"The hook at {hook_path} was not created by git-sage. "
|
|
90
|
+
"Remove it manually."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
hook_path.unlink()
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_installed(repo_root: Path | None = None) -> bool:
|
|
98
|
+
"""Return True if the git-sage hook is currently installed."""
|
|
99
|
+
try:
|
|
100
|
+
hooks_dir = _get_hooks_dir(repo_root)
|
|
101
|
+
hook_path = hooks_dir / "pre-push"
|
|
102
|
+
return hook_path.exists() and HOOK_MARKER in hook_path.read_text()
|
|
103
|
+
except RuntimeError:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Helpers
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _get_hooks_dir(repo_root: Path | None) -> Path:
|
|
112
|
+
if repo_root is None:
|
|
113
|
+
repo_root = _find_repo_root()
|
|
114
|
+
hooks_dir = repo_root / ".git" / "hooks"
|
|
115
|
+
if not hooks_dir.exists():
|
|
116
|
+
raise RuntimeError(f"No .git/hooks directory found at {hooks_dir}")
|
|
117
|
+
return hooks_dir
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _find_repo_root() -> Path:
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
)
|
|
126
|
+
if result.returncode != 0:
|
|
127
|
+
raise RuntimeError("Not inside a git repository.")
|
|
128
|
+
return Path(result.stdout.strip())
|
git_sage/ollama.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ollama.py
|
|
3
|
+
---------
|
|
4
|
+
Thin HTTP client for the Ollama local inference server.
|
|
5
|
+
|
|
6
|
+
Ollama exposes a REST API on http://localhost:11434 by default.
|
|
7
|
+
We use httpx (sync) to keep the dependency footprint small.
|
|
8
|
+
|
|
9
|
+
Reference: https://github.com/ollama/ollama/blob/main/docs/api.md
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Iterator
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
DEFAULT_HOST = "http://localhost:11434"
|
|
18
|
+
DEFAULT_MODEL = "qwen2.5-coder:7b"
|
|
19
|
+
|
|
20
|
+
# How long to wait for the first token (seconds).
|
|
21
|
+
# Code review of a large diff can take 20-30 s on CPU.
|
|
22
|
+
TIMEOUT = 120
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OllamaError(Exception):
|
|
26
|
+
"""Raised when the Ollama server returns an error or is unreachable."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_available(host: str = DEFAULT_HOST) -> bool:
|
|
30
|
+
"""Return True if the Ollama server is reachable."""
|
|
31
|
+
try:
|
|
32
|
+
r = httpx.get(f"{host}/api/tags", timeout=3)
|
|
33
|
+
return r.status_code == 200
|
|
34
|
+
except httpx.RequestError:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_models(host: str = DEFAULT_HOST) -> list[str]:
|
|
39
|
+
"""Return the names of locally available models."""
|
|
40
|
+
try:
|
|
41
|
+
r = httpx.get(f"{host}/api/tags", timeout=5)
|
|
42
|
+
r.raise_for_status()
|
|
43
|
+
return [m["name"] for m in r.json().get("models", [])]
|
|
44
|
+
except httpx.RequestError as exc:
|
|
45
|
+
raise OllamaError(f"Cannot reach Ollama at {host}: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def chat(
|
|
49
|
+
messages: list[dict],
|
|
50
|
+
model: str = DEFAULT_MODEL,
|
|
51
|
+
host: str = DEFAULT_HOST,
|
|
52
|
+
stream: bool = False,
|
|
53
|
+
) -> str | Iterator[str]:
|
|
54
|
+
"""
|
|
55
|
+
Send a chat request to Ollama.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
messages:
|
|
60
|
+
List of {"role": ..., "content": ...} dicts (OpenAI-compatible).
|
|
61
|
+
model:
|
|
62
|
+
Local model name, e.g. "qwen2.5-coder:7b".
|
|
63
|
+
host:
|
|
64
|
+
Ollama server base URL.
|
|
65
|
+
stream:
|
|
66
|
+
If True, yield text chunks as they arrive (for live output).
|
|
67
|
+
If False (default), return the complete response string.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
str (stream=False) or Iterator[str] (stream=True)
|
|
72
|
+
"""
|
|
73
|
+
url = f"{host}/api/chat"
|
|
74
|
+
payload = {
|
|
75
|
+
"model": model,
|
|
76
|
+
"messages": messages,
|
|
77
|
+
"stream": stream,
|
|
78
|
+
"options": {
|
|
79
|
+
# Keep temperature low for deterministic code review
|
|
80
|
+
"temperature": 0.2,
|
|
81
|
+
"top_p": 0.9,
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if stream:
|
|
86
|
+
return _stream_response(url, payload)
|
|
87
|
+
else:
|
|
88
|
+
return _blocking_response(url, payload)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _blocking_response(url: str, payload: dict) -> str:
|
|
92
|
+
try:
|
|
93
|
+
with httpx.Client(timeout=TIMEOUT) as client:
|
|
94
|
+
r = client.post(url, json=payload)
|
|
95
|
+
r.raise_for_status()
|
|
96
|
+
data = r.json()
|
|
97
|
+
return data["message"]["content"]
|
|
98
|
+
except httpx.HTTPStatusError as exc:
|
|
99
|
+
raise OllamaError(f"Ollama returned HTTP {exc.response.status_code}") from exc
|
|
100
|
+
except httpx.RequestError as exc:
|
|
101
|
+
raise OllamaError(f"Cannot reach Ollama: {exc}") from exc
|
|
102
|
+
except (KeyError, json.JSONDecodeError) as exc:
|
|
103
|
+
raise OllamaError(f"Unexpected response format: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _stream_response(url: str, payload: dict) -> Iterator[str]:
|
|
107
|
+
try:
|
|
108
|
+
with httpx.Client(timeout=TIMEOUT) as client:
|
|
109
|
+
with client.stream("POST", url, json=payload) as r:
|
|
110
|
+
r.raise_for_status()
|
|
111
|
+
for line in r.iter_lines():
|
|
112
|
+
if not line:
|
|
113
|
+
continue
|
|
114
|
+
chunk = json.loads(line)
|
|
115
|
+
token = chunk.get("message", {}).get("content", "")
|
|
116
|
+
if token:
|
|
117
|
+
yield token
|
|
118
|
+
if chunk.get("done"):
|
|
119
|
+
break
|
|
120
|
+
except httpx.HTTPStatusError as exc:
|
|
121
|
+
raise OllamaError(f"Ollama returned HTTP {exc.response.status_code}") from exc
|
|
122
|
+
except httpx.RequestError as exc:
|
|
123
|
+
raise OllamaError(f"Cannot reach Ollama: {exc}") from exc
|
git_sage/output.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
output.py
|
|
3
|
+
---------
|
|
4
|
+
Terminal output renderer using the `rich` library.
|
|
5
|
+
|
|
6
|
+
Rich gives us coloured panels, icons, and clean formatting with zero
|
|
7
|
+
configuration — ideal for a CLI tool that developers will stare at
|
|
8
|
+
every time they push code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich import box
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
from rich.spinner import Spinner
|
|
17
|
+
from rich.live import Live
|
|
18
|
+
|
|
19
|
+
from git_sage.parser import ReviewResult, Verdict
|
|
20
|
+
from git_sage.diff import DiffResult
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Public render functions
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def print_diff_stats(diff: DiffResult) -> None:
|
|
30
|
+
"""Print a one-line summary of what's staged."""
|
|
31
|
+
stats = Text()
|
|
32
|
+
stats.append(" Staged: ", style="dim")
|
|
33
|
+
stats.append(f"{diff.file_count} file(s)", style="bold")
|
|
34
|
+
stats.append(" ", style="dim")
|
|
35
|
+
stats.append(f"+{diff.additions}", style="bold green")
|
|
36
|
+
stats.append(" / ", style="dim")
|
|
37
|
+
stats.append(f"-{diff.deletions}", style="bold red")
|
|
38
|
+
console.print(stats)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_review(result: ReviewResult) -> None:
|
|
42
|
+
"""Render the full review result to the terminal."""
|
|
43
|
+
_print_summary(result)
|
|
44
|
+
_print_issues(result)
|
|
45
|
+
_print_suggestions(result)
|
|
46
|
+
_print_verdict(result)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def print_error(message: str) -> None:
|
|
50
|
+
console.print(f"\n[bold red]✗[/bold red] {message}\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_success(message: str) -> None:
|
|
54
|
+
console.print(f"\n[bold green]✓[/bold green] {message}\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def print_warning(message: str) -> None:
|
|
58
|
+
console.print(f"\n[bold yellow]⚠[/bold yellow] {message}\n")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def thinking_spinner(label: str = "Reviewing with local AI…") -> Live:
|
|
62
|
+
"""
|
|
63
|
+
Returns a Rich Live context manager showing a spinner.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
with thinking_spinner():
|
|
67
|
+
result = ollama.chat(...)
|
|
68
|
+
"""
|
|
69
|
+
spinner = Spinner("dots", text=f"[dim]{label}[/dim]")
|
|
70
|
+
return Live(spinner, console=console, refresh_per_second=10, transient=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Private section renderers
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _print_summary(result: ReviewResult) -> None:
|
|
78
|
+
if not result.summary:
|
|
79
|
+
return
|
|
80
|
+
panel = Panel(
|
|
81
|
+
f"[dim]{result.summary}[/dim]",
|
|
82
|
+
title="[bold]Summary[/bold]",
|
|
83
|
+
border_style="bright_black",
|
|
84
|
+
padding=(0, 1),
|
|
85
|
+
)
|
|
86
|
+
console.print(panel)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _print_issues(result: ReviewResult) -> None:
|
|
90
|
+
if not result.issues:
|
|
91
|
+
console.print("\n[bold green] No issues found.[/bold green]\n")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
console.print(f"\n[bold red] Issues[/bold red] ({len(result.issues)} found)\n")
|
|
95
|
+
for i, issue in enumerate(result.issues, 1):
|
|
96
|
+
console.print(f" [red]●[/red] [dim]{i}.[/dim] {issue}")
|
|
97
|
+
console.print()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _print_suggestions(result: ReviewResult) -> None:
|
|
101
|
+
if not result.suggestions:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
console.print(f"[bold yellow] Suggestions[/bold yellow] ({len(result.suggestions)})\n")
|
|
105
|
+
for i, sug in enumerate(result.suggestions, 1):
|
|
106
|
+
console.print(f" [yellow]◆[/yellow] [dim]{i}.[/dim] {sug}")
|
|
107
|
+
console.print()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _print_verdict(result: ReviewResult) -> None:
|
|
111
|
+
if result.verdict == Verdict.APPROVE:
|
|
112
|
+
panel = Panel(
|
|
113
|
+
"[bold green] ✓ APPROVE[/bold green]\n[dim] Ready to push.[/dim]",
|
|
114
|
+
border_style="green",
|
|
115
|
+
padding=(0, 1),
|
|
116
|
+
)
|
|
117
|
+
elif result.verdict == Verdict.REVISE:
|
|
118
|
+
panel = Panel(
|
|
119
|
+
"[bold red] ✗ REVISE[/bold red]\n[dim] Address the issues above before pushing.[/dim]",
|
|
120
|
+
border_style="red",
|
|
121
|
+
padding=(0, 1),
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
panel = Panel(
|
|
125
|
+
"[bold yellow] ? UNKNOWN[/bold yellow]\n[dim] The model didn't return a clear verdict.[/dim]",
|
|
126
|
+
border_style="yellow",
|
|
127
|
+
padding=(0, 1),
|
|
128
|
+
)
|
|
129
|
+
console.print(panel)
|
|
130
|
+
console.print()
|
git_sage/parser.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
parser.py
|
|
3
|
+
---------
|
|
4
|
+
Parses the structured text output from the LLM into a ReviewResult dataclass.
|
|
5
|
+
|
|
6
|
+
The system prompt (prompt.py) tells the model to respond with four labelled
|
|
7
|
+
sections: SUMMARY, ISSUES, SUGGESTIONS, VERDICT. This parser extracts each
|
|
8
|
+
section by scanning for those headings, making it tolerant of minor formatting
|
|
9
|
+
variations in the model output.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Verdict(str, Enum):
|
|
18
|
+
APPROVE = "APPROVE"
|
|
19
|
+
REVISE = "REVISE"
|
|
20
|
+
UNKNOWN = "UNKNOWN" # fallback if the model didn't follow instructions
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ReviewResult:
|
|
25
|
+
summary: str
|
|
26
|
+
issues: list[str]
|
|
27
|
+
suggestions: list[str]
|
|
28
|
+
verdict: Verdict
|
|
29
|
+
raw: str # full original LLM response (useful for debugging)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def has_issues(self) -> bool:
|
|
33
|
+
return bool(self.issues)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_approved(self) -> bool:
|
|
37
|
+
return self.verdict == Verdict.APPROVE
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Section heading patterns (case-insensitive, allow trailing colon or whitespace)
|
|
41
|
+
_HEADING = re.compile(
|
|
42
|
+
r"^(SUMMARY|ISSUES|SUGGESTIONS|VERDICT)\s*:?\s*$",
|
|
43
|
+
re.IGNORECASE | re.MULTILINE,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# A numbered list item: "1. text" or "1) text"
|
|
47
|
+
_LIST_ITEM = re.compile(r"^\s*\d+[.)]\s+(.+)$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse(raw: str) -> ReviewResult:
|
|
51
|
+
"""
|
|
52
|
+
Parse the raw LLM response text into a ReviewResult.
|
|
53
|
+
|
|
54
|
+
Tolerates extra whitespace, minor heading variations, and models
|
|
55
|
+
that add a colon after the heading name.
|
|
56
|
+
"""
|
|
57
|
+
sections = _split_sections(raw)
|
|
58
|
+
|
|
59
|
+
summary = _extract_text(sections.get("summary", ""))
|
|
60
|
+
issues = _extract_list(sections.get("issues", ""))
|
|
61
|
+
suggestions = _extract_list(sections.get("suggestions", ""))
|
|
62
|
+
verdict = _extract_verdict(sections.get("verdict", ""))
|
|
63
|
+
|
|
64
|
+
return ReviewResult(
|
|
65
|
+
summary=summary,
|
|
66
|
+
issues=issues,
|
|
67
|
+
suggestions=suggestions,
|
|
68
|
+
verdict=verdict,
|
|
69
|
+
raw=raw,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Helpers
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _split_sections(text: str) -> dict[str, str]:
|
|
78
|
+
"""
|
|
79
|
+
Split the response into a dict keyed by lowercase section name.
|
|
80
|
+
|
|
81
|
+
Example input:
|
|
82
|
+
SUMMARY
|
|
83
|
+
Adds OAuth login via GitHub.
|
|
84
|
+
|
|
85
|
+
ISSUES
|
|
86
|
+
1. Missing CSRF token validation.
|
|
87
|
+
|
|
88
|
+
...
|
|
89
|
+
"""
|
|
90
|
+
result: dict[str, str] = {}
|
|
91
|
+
current_key: str | None = None
|
|
92
|
+
current_lines: list[str] = []
|
|
93
|
+
|
|
94
|
+
for line in text.splitlines():
|
|
95
|
+
m = _HEADING.match(line.strip())
|
|
96
|
+
if m:
|
|
97
|
+
# Save previous section
|
|
98
|
+
if current_key is not None:
|
|
99
|
+
result[current_key] = "\n".join(current_lines).strip()
|
|
100
|
+
current_key = m.group(1).lower()
|
|
101
|
+
current_lines = []
|
|
102
|
+
else:
|
|
103
|
+
if current_key is not None:
|
|
104
|
+
current_lines.append(line)
|
|
105
|
+
|
|
106
|
+
# Save the last section
|
|
107
|
+
if current_key is not None:
|
|
108
|
+
result[current_key] = "\n".join(current_lines).strip()
|
|
109
|
+
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extract_text(section: str) -> str:
|
|
114
|
+
"""Return the section content as a single stripped string."""
|
|
115
|
+
return section.strip()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _extract_list(section: str) -> list[str]:
|
|
119
|
+
"""
|
|
120
|
+
Extract numbered list items from a section.
|
|
121
|
+
|
|
122
|
+
Falls back to plain non-empty lines if no numbered items are found
|
|
123
|
+
(handles models that skip numbering).
|
|
124
|
+
"""
|
|
125
|
+
items = []
|
|
126
|
+
for line in section.splitlines():
|
|
127
|
+
m = _LIST_ITEM.match(line)
|
|
128
|
+
if m:
|
|
129
|
+
items.append(m.group(1).strip())
|
|
130
|
+
|
|
131
|
+
if not items:
|
|
132
|
+
# Fallback: any non-empty line that isn't "None" / "None found."
|
|
133
|
+
items = [
|
|
134
|
+
line.strip()
|
|
135
|
+
for line in section.splitlines()
|
|
136
|
+
if line.strip() and not re.match(r"^none[. ]*(?:found)?\.?$", line.strip(), re.IGNORECASE)
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
return items
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _extract_verdict(section: str) -> Verdict:
|
|
143
|
+
text = section.strip().upper()
|
|
144
|
+
if "APPROVE" in text:
|
|
145
|
+
return Verdict.APPROVE
|
|
146
|
+
if "REVISE" in text:
|
|
147
|
+
return Verdict.REVISE
|
|
148
|
+
return Verdict.UNKNOWN
|
git_sage/prompt.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
prompt.py
|
|
3
|
+
---------
|
|
4
|
+
Builds the system + user prompt that is sent to the local LLM.
|
|
5
|
+
|
|
6
|
+
Keeping prompts in one place makes them easy to tweak, test, and
|
|
7
|
+
document.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from git_sage.diff import DiffResult
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# System prompt
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Instruct the model to act as a senior code reviewer and return structured
|
|
16
|
+
# output the parser can reliably split on.
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
SYSTEM_PROMPT = """\
|
|
20
|
+
You are an expert code reviewer. Your job is to review the git diff provided \
|
|
21
|
+
by the user and give concise, actionable feedback.
|
|
22
|
+
|
|
23
|
+
Your response MUST follow this exact structure — do not deviate:
|
|
24
|
+
|
|
25
|
+
SUMMARY
|
|
26
|
+
<one or two sentences describing what this change does overall>
|
|
27
|
+
|
|
28
|
+
ISSUES
|
|
29
|
+
<a numbered list of concrete problems found; each item on its own line>
|
|
30
|
+
<if no issues found, write: None found.>
|
|
31
|
+
|
|
32
|
+
SUGGESTIONS
|
|
33
|
+
<a numbered list of optional improvements; each item on its own line>
|
|
34
|
+
<if no suggestions, write: None.>
|
|
35
|
+
|
|
36
|
+
VERDICT
|
|
37
|
+
<exactly one word: APPROVE or REVISE>
|
|
38
|
+
|
|
39
|
+
Rules:
|
|
40
|
+
- Be direct. No preamble or closing remarks outside the structure above.
|
|
41
|
+
- Focus on correctness, security, and maintainability — not style.
|
|
42
|
+
- NEVER flag issues in deleted lines (lines starting with `-`). Deleted code is \
|
|
43
|
+
being intentionally removed. Only review lines being added (lines starting with `+`).
|
|
44
|
+
- Flag: hardcoded secrets or tokens, missing error handling, potential \
|
|
45
|
+
null/index errors, SQL injection risks, blocking calls in async code, \
|
|
46
|
+
N+1 query patterns, obvious logic bugs.
|
|
47
|
+
- Do NOT flag: formatting, naming conventions, missing comments (unless \
|
|
48
|
+
a function is genuinely unclear), or subjective preferences.
|
|
49
|
+
- Keep each issue and suggestion to one sentence.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# User message builder
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def build_review_prompt(diff: DiffResult, context: str | None = None) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Construct the user-turn message from a DiffResult.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
diff:
|
|
64
|
+
The DiffResult from diff.py.
|
|
65
|
+
context:
|
|
66
|
+
Optional free-text context the developer can pass (e.g. "This adds
|
|
67
|
+
OAuth support for GitHub"). Helps the model give better feedback.
|
|
68
|
+
"""
|
|
69
|
+
parts: list[str] = []
|
|
70
|
+
|
|
71
|
+
# Stats header — gives the model a quick orientation
|
|
72
|
+
parts.append(
|
|
73
|
+
f"Changed files ({diff.file_count}): {', '.join(diff.files)}\n"
|
|
74
|
+
f"Lines: +{diff.additions} / -{diff.deletions}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# If this is a pure deletion diff, tell the model explicitly
|
|
78
|
+
if diff.additions == 0 and diff.deletions > 0:
|
|
79
|
+
parts.append(
|
|
80
|
+
"Note: This diff contains only deletions (files being removed). "
|
|
81
|
+
"Do not flag issues in deleted code — it is being intentionally removed. "
|
|
82
|
+
"If the change looks clean, approve it."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if context:
|
|
86
|
+
parts.append(f"Developer note: {context}")
|
|
87
|
+
|
|
88
|
+
parts.append("Diff:\n```diff\n" + diff.raw.strip() + "\n```")
|
|
89
|
+
|
|
90
|
+
return "\n\n".join(parts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_messages(diff: DiffResult, context: str | None = None) -> list[dict]:
|
|
94
|
+
"""
|
|
95
|
+
Return the messages array ready to POST to the Ollama /api/chat endpoint.
|
|
96
|
+
"""
|
|
97
|
+
return [
|
|
98
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
99
|
+
{"role": "user", "content": build_review_prompt(diff, context)},
|
|
100
|
+
]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-sage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local AI code reviewer for your git workflow. Powered by Ollama
|
|
5
|
+
Author-email: Joel Adewole <joeladewole3@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: click>=8.1
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: rich>=13.0
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# git-sage
|
|
16
|
+
|
|
17
|
+
> Local AI code review right before you push. No cloud. No subscriptions. No data leaving your machine.
|
|
18
|
+
|
|
19
|
+
`git-sage` hooks into your git workflow and runs a code review using a locally hosted LLM via [Ollama](https://ollama.com). When you run `git push`, the tool intercepts it, sends your staged diff to the model, and either approves the push or asks you to revise, all on your machine, in seconds.
|
|
20
|
+
```
|
|
21
|
+
$ git push
|
|
22
|
+
|
|
23
|
+
Staged: 3 file(s) +47 / -12
|
|
24
|
+
|
|
25
|
+
╭─ Summary ───────────────────────────────────────────────────────────╮
|
|
26
|
+
│ Adds a /login endpoint with bcrypt password hashing. │
|
|
27
|
+
╰─────────────────────────────────────────────────────────────────────╯
|
|
28
|
+
|
|
29
|
+
Issues (2 found)
|
|
30
|
+
|
|
31
|
+
● 1. The SECRET_KEY is hardcoded as a string literal on line 14.
|
|
32
|
+
● 2. There is no rate limiting on the /login route.
|
|
33
|
+
|
|
34
|
+
Suggestions (1)
|
|
35
|
+
|
|
36
|
+
◆ 1. Load SECRET_KEY from os.getenv('SECRET_KEY') instead.
|
|
37
|
+
|
|
38
|
+
╭─────────────────────────────────────────────────────────────────────╮
|
|
39
|
+
│ ✗ REVISE │
|
|
40
|
+
│ Address the issues above before pushing. │
|
|
41
|
+
╰─────────────────────────────────────────────────────────────────────╯
|
|
42
|
+
|
|
43
|
+
Push aborted by git-sage. Fix the issues above, or run:
|
|
44
|
+
git push --no-verify to bypass the hook.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
📖 **[Full documentation →](https://wolz-codelife.github.io/git-sage/)**
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Why git-sage?
|
|
52
|
+
|
|
53
|
+
Most AI code review tools sit at the pull request stage, by then your code has already reached a remote server. A hardcoded secret has already been pushed. A vulnerable dependency is already on a branch other developers may have pulled.
|
|
54
|
+
|
|
55
|
+
`git-sage` moves the review to your local machine, before any code leaves it. If the model finds a problem, the push is aborted and you fix it right there in your editor.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Python 3.9+
|
|
62
|
+
- [Ollama](https://ollama.com) installed and running
|
|
63
|
+
- macOS, Linux, or Windows (WSL2)
|
|
64
|
+
- ~5 GB disk space for the default model
|
|
65
|
+
|
|
66
|
+
No GPU required. Runs on any modern laptop.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
**1. Install Ollama and pull the model**
|
|
73
|
+
```bash
|
|
74
|
+
brew install ollama # macOS — see docs for Linux/Windows
|
|
75
|
+
ollama serve
|
|
76
|
+
ollama pull qwen2.5-coder:7b
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**2. Install git-sage**
|
|
80
|
+
```bash
|
|
81
|
+
pip install git-sage
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**3. Install the hook in your repo**
|
|
85
|
+
```bash
|
|
86
|
+
cd your-project
|
|
87
|
+
git-sage install
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**4. Push as normal**
|
|
91
|
+
```bash
|
|
92
|
+
git push # review runs automatically
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Commands
|
|
98
|
+
|
|
99
|
+
| Command | Description |
|
|
100
|
+
|----------------------------------------------------|-------------------------------------------|
|
|
101
|
+
| `git-sage review` | Manually review staged changes |
|
|
102
|
+
| `git-sage review --model llama3.2` | Use a different local model |
|
|
103
|
+
| `git-sage review --context "Adds OAuth"` | Provide context to the model |
|
|
104
|
+
| `git-sage review --diff-mode head` | Review the last commit instead |
|
|
105
|
+
| `git-sage review --diff-mode branch --base main` | Review the whole branch |
|
|
106
|
+
| `git-sage review --force` | Review but don't abort push on REVISE |
|
|
107
|
+
| `git-sage install` | Install the pre-push hook |
|
|
108
|
+
| `git-sage uninstall` | Remove the pre-push hook |
|
|
109
|
+
| `git-sage status` | Check Ollama availability and hook status |
|
|
110
|
+
| `git-sage models` | List locally available Ollama models |
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## How it works
|
|
115
|
+
```
|
|
116
|
+
git push
|
|
117
|
+
→ .git/hooks/pre-push fires
|
|
118
|
+
→ git-sage review --hook
|
|
119
|
+
→ git diff --cached (extract the staged diff)
|
|
120
|
+
→ build prompt (diff + system instructions)
|
|
121
|
+
→ POST localhost:11434 (Ollama local API)
|
|
122
|
+
→ parse response (SUMMARY / ISSUES / SUGGESTIONS / VERDICT)
|
|
123
|
+
→ render to terminal (rich coloured output)
|
|
124
|
+
→ exit 0 (APPROVE) or exit 1 (REVISE, aborts push)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For a full breakdown of the architecture and each module, see the **[Architecture docs](https://wolz-codelife.github.io/git-sage/docs/architecture)**.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Bypassing the hook
|
|
132
|
+
```bash
|
|
133
|
+
git push --no-verify
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Project structure
|
|
139
|
+
```
|
|
140
|
+
git_sage/
|
|
141
|
+
cli.py CLI entrypoint (click)
|
|
142
|
+
diff.py Git diff extraction
|
|
143
|
+
prompt.py Prompt builder
|
|
144
|
+
ollama.py Ollama HTTP client
|
|
145
|
+
parser.py Response parser
|
|
146
|
+
output.py Terminal renderer (rich)
|
|
147
|
+
hook.py Git hook installer
|
|
148
|
+
tests/
|
|
149
|
+
test_parser.py
|
|
150
|
+
test_diff.py
|
|
151
|
+
test_prompt.py
|
|
152
|
+
docs/ Docusaurus documentation site
|
|
153
|
+
CHANGELOG.md Version history
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Running tests
|
|
159
|
+
```bash
|
|
160
|
+
pip install pytest
|
|
161
|
+
pytest tests/ -v
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Tests are self-contained; no Ollama or git repo needed.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Contributing
|
|
169
|
+
|
|
170
|
+
Contributions are welcome. See the **[Contributing guide](https://wolz-codelife.github.io/git-sage/docs/contributing)** for how to get started, issue templates, and a PR template.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
git_sage/__init__.py,sha256=SxTQOGuVukUfD8aRF6O77e4S4IrtW6-sbykoHk6dh-U,82
|
|
2
|
+
git_sage/cli.py,sha256=r9k93eEcij6S_T-h-n6GSKTUundwHHxw9IToZGREvRs,6987
|
|
3
|
+
git_sage/diff.py,sha256=h422cfBQuxhZr78ciqKzgk-Js57Xcm8FSFpuW1snYhE,2169
|
|
4
|
+
git_sage/hook.py,sha256=ITCUfJltbjgHzEu7lU-JgoIPSptZRFNPTjdtSJheN6k,3625
|
|
5
|
+
git_sage/ollama.py,sha256=hFumyFt0_HMx3aQlkNRMSqnd402RHXZ4kL6KurSCNJM,3792
|
|
6
|
+
git_sage/output.py,sha256=0t4OK5YBfjA6p55J5bY3RDDaKAYAQx1s4h79w051AW0,4063
|
|
7
|
+
git_sage/parser.py,sha256=kytSHo84kASGEt-hfx9dnD3l3Vyw2zy3FDLkNZKxIsk,4092
|
|
8
|
+
git_sage/prompt.py,sha256=hw22J0d7HiF6_vRHrcxEvLJ0Q96HJTAx0lZ-im6VwF4,3520
|
|
9
|
+
git_sage-0.1.0.dist-info/licenses/LICENSE,sha256=R3DS5GnG8Q_xwj1NlBzoZKSUEUirux3V8AdUNiVUeww,1069
|
|
10
|
+
git_sage-0.1.0.dist-info/METADATA,sha256=6JDS5jK9mgXQ21JtYdFFYlEVp-lIl-ApioxLlNCv4M8,6057
|
|
11
|
+
git_sage-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
git_sage-0.1.0.dist-info/entry_points.txt,sha256=p7tOoGykDsSY5ZUU9RL9xuoswMoLJMEZd1SuZGgfunc,47
|
|
13
|
+
git_sage-0.1.0.dist-info/top_level.txt,sha256=l1Tqzz51n6LwLqZkWgyNjWVTRWskcDC6t3D4fsE1zqs,9
|
|
14
|
+
git_sage-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joel Adewole
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
git_sage
|