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 ADDED
@@ -0,0 +1,3 @@
1
+ """git-sage: local AI code review for your git workflow."""
2
+
3
+ __version__ = "0.1.0"
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,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
+ git-sage = git_sage.cli:main
@@ -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