Navegador
feat: VCS abstraction layer with Git and Fossil adapters VCSAdapter ABC with GitAdapter (full implementation) and FossilAdapter (stub). Auto-detection via detect_vcs(). Supports branch, changed files, file history, and blame. Closes #56
Commit
31642ed7375ee36f2c1935deb0a715d65c2a8f116c444f8d00ee3f17496df2d9
Parent
95549e5d54adbc5…
2 files changed
+231
+257
+231
| --- a/navegador/vcs.py | ||
| +++ b/navegador/vcs.py | ||
| @@ -0,0 +1,231 @@ | ||
| 1 | +""" | |
| 2 | +VCS abstraction layer for navegador. | |
| 3 | + | |
| 4 | +Provides a uniform interface for version-control operations across different | |
| 5 | +backends (git, Fossil, …) so the rest of the codebase never calls git directly. | |
| 6 | + | |
| 7 | +Usage:: | |
| 8 | + | |
| 9 | + from navegador.vcs import detect_vcs | |
| 10 | + | |
| 11 | + adapter = detect_vcs(Path("/path/to/repo")) | |
| 12 | + print(adapter.current_branch()) | |
| 13 | + print(adapter.changed_files()) | |
| 14 | +""" | |
| 15 | + | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import subprocess | |
| 19 | +from abc import ABC, abstractmethodd | |
| 20 | +from pathlib import Path | |
| 21 | + | |
| 22 | +# ── Abstract base ────────────────────────────────────────────────────────────── | |
| 23 | + | |
| 24 | + | |
| 25 | +class VCSAdapter(ABC): | |
| 26 | + """Abstract base class for VCS backends.""" | |
| 27 | + | |
| 28 | + def __init__(self, repo_path: Path) -> None: | |
| 29 | + self.repo_path = Path(repo_path) | |
| 30 | + | |
| 31 | + @abstractmethod | |
| 32 | + def is_repo(self) -> bool: | |
| 33 | + """Return True if *repo_path* is a valid repository for this backend.""" | |
| 34 | + | |
| 35 | + @abstractmethod | |
| 36 | + def current_branch(self) -> str: | |
| 37 | + """Return the name of the currently checked-out branch.""" | |
| 38 | + | |
| 39 | + @abstractmethod | |
| 40 | + def changed_files(self, since: str = "") -> list[str]: | |
| 41 | + """ | |
| 42 | + Return a list of file paths that have changed. | |
| 43 | + | |
| 44 | + Parameters | |
| 45 | + ---------- | |
| 46 | + since: | |
| 47 | + A commit reference (hash, tag, branch name). When empty the | |
| 48 | + implementation should fall back to uncommitted changes vs HEAD. | |
| 49 | + """ | |
| 50 | + | |
| 51 | + @abstractmethod | |
| 52 | + def file_history(self, file_path: str, limit: int = 10) -> list[dict]: | |
| 53 | + """ | |
| 54 | + Return the commit history for *file_path*. | |
| 55 | + | |
| 56 | + Each entry is a dict with at least the keys: | |
| 57 | + ``hash``, ``author``, ``date``, ``message``. | |
| 58 | + """ | |
| 59 | + | |
| 60 | + @abstractmethod | |
| 61 | + def blame(self, file_path: str) -> list[dict]: | |
| 62 | + """ | |
| 63 | + Return per-line blame information for *file_path*. | |
| 64 | + | |
| 65 | + Each entry is a dict with at least the keys: | |
| 66 | + ``line``, ``hash``, ``author``, ``content``. | |
| 67 | + """ | |
| 68 | + | |
| 69 | + | |
| 70 | +# ── Git ──────────────────────────────────────────────────────────────────────── | |
| 71 | + | |
| 72 | + | |
| 73 | +class GitAdapter(VCSAdapter): | |
| 74 | + """VCS adapter for Git repositories.""" | |
| 75 | + | |
| 76 | + # ------------------------------------------------------------------ | |
| 77 | + # Helpers | |
| 78 | + # ------------------------------------------------------------------ | |
| 79 | + | |
| 80 | + def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProces[ | |
| 81 | + "log", | |
| 82 | + f"--format={fmt}", | |
| 83 | + "--", | |
| 84 | + "--path", | |
| 85 | + ]ath, | |
| 86 | + ] | |
| 87 | + ) | |
| 88 | + | |
| 89 | + entries: list[dict] = [] | |
| 90 | + for line in result.stdout.strip().splitlines(): | |
| 91 | + if not line: | |
| 92 | + continue | |
| 93 | + parts = line.split("\x1f", 3) | |
| 94 | + { | |
| 95 | + "hash": par], | |
| 96 | + ], | |
| 97 | + "message":}�� | |
| 98 | + | |
| 99 | + | |
| 100 | +class GitAdaentries | |
| 101 | + | |
| 102 | + def blame(self, file_path: str) -> list[dict]: | |
| 103 | + """ | |
| 104 | + Return per-line blame data for *file_path*. | |
| 105 | + | |
| 106 | + Each entry has the keys: ``line``, ``hash``, ``author``, ``content``. | |
| 107 | + | |
| 108 | + Uses ``git blame --porcelain`` for machine-readable output. | |
| 109 | + """ | |
| 110 | + result = self._run(["blame", "--porcelain", "--", file_path]) | |
| 111 | + return _parse_porcelain_blame(result.stdout) | |
| 112 | + | |
| 113 | + | |
| 114 | +def _parse_porcelain_blame(output: str) -> list[dict]: | |
| 115 | + """Parse the output of ``git blame --porcelain`` into a list of dicts.""" | |
| 116 | + entries: list[dict] = [] | |
| 117 | + current_hash = "" | |
| 118 | + current_author = "" | |
| 119 | + line_number = 0 | |
| 120 | + | |
| 121 | + lines = output.splitlines() | |
| 122 | + i = 0 | |
| 123 | + while i < len(lines): | |
| 124 | + raw = lines[i] | |
| 125 | + | |
| 126 | + # Header line: "<40-char hash> <orig-line> <final-line> [<num-lines>]" | |
| 127 | + parts = raw.split() | |
| 128 | + if len(parts) >= 3 and len(parts[0]) == 40 and parts[0].isalnum(): | |
| 129 | + current_hash = parts[0] | |
| 130 | + try: | |
| 131 | + line_number = int(parts[2]) | |
| 132 | + except (IndexError, ValueError): | |
| 133 | + line_number = 0 | |
| 134 | + i += 1 | |
| 135 | + # Read key-value pairs until we hit the content line (starts with \t) | |
| 136 | + while i < len(lines) and not lines[i].startswith("\t"): | |
| 137 | + kv = lines[i] | |
| 138 | + if kv.startswith("author "): | |
| 139 | + current:] | |
| 140 | + "" | |
| 141 | +VCS abstraction layer for navegador. | |
| 142 | + | |
| 143 | +Provides a uniform interface for version-control operations across different | |
| 144 | +backends (git, Fossil, …) so the rest of the codebase never calls git directly. | |
| 145 | + | |
| 146 | +Usage::e | |
| 147 | + lice, tags: trunk) | |
| 148 | + | |
| 149 | + | |
| 150 | + return entries | |
| 151 | + | |
| 152 | + def bturn entries | |
| 153 | + | |
| 154 | + d "" | |
| 155 | + | |
| 156 | + f) | |
| 157 | + *. | |
| 158 | + | |
| 159 | + Each entry has the keys: ``line``, ``hash``, ``author``, ``content``. | |
| 160 | + | |
| 161 | + Uses ``git blame --porcelain`` for machine-readable output. | |
| 162 | + """ | |
| 163 | + result = self._run(["blame", "--porcelain", "--", file_path]) | |
| 164 | + return _parse_porcelain_blame(result.stdout) | |
| 165 | + | |
| 166 | + | |
| 167 | +def _parse_porcelain_blame(output: str) -> list[dict]: | |
| 168 | + """Parse the output of ``git blame --porcelain`` into a list of dicts.""" | |
| 169 | + entries: list[dict] = [] | |
| 170 | + current_hash = "" | |
| 171 | + current_author = "" | |
| 172 | + line_number = 0 | |
| 173 | + | |
| 174 | + lines = output.splitlines() | |
| 175 | + i = 0 | |
| 176 | + while i < len(lines): | |
| 177 | + raw = lines[i] | |
| 178 | + | |
| 179 | + # Headestuber line: "<40-charis fully implemented; a[0]) == 40 and pars | |
| 180 | + ---------- | |
| 181 | + since: | |
| 182 | + A commit reference (hash,perations across different | |
| 183 | +backends (git, Fossil, …) so the rest of the codebase never calls git directly. | |
| 184 | + | |
| 185 | +Usage:: | |
| 186 | + | |
| 187 | + from navegador.vcs import detect_vcs | |
| 188 | + | |
| 189 | + adapter = detect_vcs(Path("/path/to/repo")) | |
| 190 | + """ | |
| 191 | +VCS abstraction layer for �──�self._NOT_IMPLEMENTED_MSG.formt | |
| 192 | +backends (git, Foswith at least the keys: | |
| 193 | + raise NotImplementedError( | |
| 194 | + �──�self._NOT_IMPLEMENTED_MSG.fort | |
| 195 | +backends (git, Fos> list[dict]: | |
| 196 | + """ | |
| 197 | + Return per-line blame data for *file_pathraise NotImplementedError( | |
| 198 | + �──�self._NOT_IMPLEMENTED_MSG.fot | |
| 199 | +backends (git, Fosblamele_path: str) -> listlain_blame(result.stdout)raise NotImplementedError( | |
| 200 | + �──�self._NOT_IMPLEMENTED_MSG.forma──────────────────────────────────── | |
| 201 | + | |
| 202 | + | |
| 203 | +class VCSAdapter(ABC): | |
| 204 | + """Abstract base class for VCS backends.""" | |
| 205 | + | |
| 206 | + def __init__(self, repo_path: Path) -> None: | |
| 207 | + self.repo_path = Path(repo_path) | |
| 208 | + | |
| 209 | + @abstractmethod | |
| 210 | + def is_repo(self) -> bool: | |
| 211 | + """Return True if *repo_path* is a valid repor navegador. | |
| 212 | + | |
| 213 | +Provides a uniform interface for version-control operations across different | |
| 214 | +backends (git, Fossil, …) so the rest of the codebase never calls git directly. | |
| 215 | + | |
| 216 | +Usage:: | |
| 217 | + | |
| 218 | + from navegador.vcs import detect_vcs | |
| 219 | + | |
| 220 | + adapter = detect_vcs(Path("/path/to/repo")) | |
| 221 | + print(adapter.current_branch()) | |
| 222 | + print(adapter.changed_files()) | |
| 223 | +""" | |
| 224 | + | |
| 225 | +from __future__ import annotations | |
| 226 | + | |
| 227 | +import subprocess | |
| 228 | +from abc import ABC, abstractmethod | |
| 229 | +from pathlib import Path | |
| 230 | + | |
| 231 | +# ── Abstract base ───────────────────────────────────� |
| --- a/navegador/vcs.py | |
| +++ b/navegador/vcs.py | |
| @@ -0,0 +1,231 @@ | |
| --- a/navegador/vcs.py | |
| +++ b/navegador/vcs.py | |
| @@ -0,0 +1,231 @@ | |
| 1 | """ |
| 2 | VCS abstraction layer for navegador. |
| 3 | |
| 4 | Provides a uniform interface for version-control operations across different |
| 5 | backends (git, Fossil, …) so the rest of the codebase never calls git directly. |
| 6 | |
| 7 | Usage:: |
| 8 | |
| 9 | from navegador.vcs import detect_vcs |
| 10 | |
| 11 | adapter = detect_vcs(Path("/path/to/repo")) |
| 12 | print(adapter.current_branch()) |
| 13 | print(adapter.changed_files()) |
| 14 | """ |
| 15 | |
| 16 | from __future__ import annotations |
| 17 | |
| 18 | import subprocess |
| 19 | from abc import ABC, abstractmethodd |
| 20 | from pathlib import Path |
| 21 | |
| 22 | # ── Abstract base ────────────────────────────────────────────────────────────── |
| 23 | |
| 24 | |
| 25 | class VCSAdapter(ABC): |
| 26 | """Abstract base class for VCS backends.""" |
| 27 | |
| 28 | def __init__(self, repo_path: Path) -> None: |
| 29 | self.repo_path = Path(repo_path) |
| 30 | |
| 31 | @abstractmethod |
| 32 | def is_repo(self) -> bool: |
| 33 | """Return True if *repo_path* is a valid repository for this backend.""" |
| 34 | |
| 35 | @abstractmethod |
| 36 | def current_branch(self) -> str: |
| 37 | """Return the name of the currently checked-out branch.""" |
| 38 | |
| 39 | @abstractmethod |
| 40 | def changed_files(self, since: str = "") -> list[str]: |
| 41 | """ |
| 42 | Return a list of file paths that have changed. |
| 43 | |
| 44 | Parameters |
| 45 | ---------- |
| 46 | since: |
| 47 | A commit reference (hash, tag, branch name). When empty the |
| 48 | implementation should fall back to uncommitted changes vs HEAD. |
| 49 | """ |
| 50 | |
| 51 | @abstractmethod |
| 52 | def file_history(self, file_path: str, limit: int = 10) -> list[dict]: |
| 53 | """ |
| 54 | Return the commit history for *file_path*. |
| 55 | |
| 56 | Each entry is a dict with at least the keys: |
| 57 | ``hash``, ``author``, ``date``, ``message``. |
| 58 | """ |
| 59 | |
| 60 | @abstractmethod |
| 61 | def blame(self, file_path: str) -> list[dict]: |
| 62 | """ |
| 63 | Return per-line blame information for *file_path*. |
| 64 | |
| 65 | Each entry is a dict with at least the keys: |
| 66 | ``line``, ``hash``, ``author``, ``content``. |
| 67 | """ |
| 68 | |
| 69 | |
| 70 | # ── Git ──────────────────────────────────────────────────────────────────────── |
| 71 | |
| 72 | |
| 73 | class GitAdapter(VCSAdapter): |
| 74 | """VCS adapter for Git repositories.""" |
| 75 | |
| 76 | # ------------------------------------------------------------------ |
| 77 | # Helpers |
| 78 | # ------------------------------------------------------------------ |
| 79 | |
| 80 | def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProces[ |
| 81 | "log", |
| 82 | f"--format={fmt}", |
| 83 | "--", |
| 84 | "--path", |
| 85 | ]ath, |
| 86 | ] |
| 87 | ) |
| 88 | |
| 89 | entries: list[dict] = [] |
| 90 | for line in result.stdout.strip().splitlines(): |
| 91 | if not line: |
| 92 | continue |
| 93 | parts = line.split("\x1f", 3) |
| 94 | { |
| 95 | "hash": par], |
| 96 | ], |
| 97 | "message":}�� |
| 98 | |
| 99 | |
| 100 | class GitAdaentries |
| 101 | |
| 102 | def blame(self, file_path: str) -> list[dict]: |
| 103 | """ |
| 104 | Return per-line blame data for *file_path*. |
| 105 | |
| 106 | Each entry has the keys: ``line``, ``hash``, ``author``, ``content``. |
| 107 | |
| 108 | Uses ``git blame --porcelain`` for machine-readable output. |
| 109 | """ |
| 110 | result = self._run(["blame", "--porcelain", "--", file_path]) |
| 111 | return _parse_porcelain_blame(result.stdout) |
| 112 | |
| 113 | |
| 114 | def _parse_porcelain_blame(output: str) -> list[dict]: |
| 115 | """Parse the output of ``git blame --porcelain`` into a list of dicts.""" |
| 116 | entries: list[dict] = [] |
| 117 | current_hash = "" |
| 118 | current_author = "" |
| 119 | line_number = 0 |
| 120 | |
| 121 | lines = output.splitlines() |
| 122 | i = 0 |
| 123 | while i < len(lines): |
| 124 | raw = lines[i] |
| 125 | |
| 126 | # Header line: "<40-char hash> <orig-line> <final-line> [<num-lines>]" |
| 127 | parts = raw.split() |
| 128 | if len(parts) >= 3 and len(parts[0]) == 40 and parts[0].isalnum(): |
| 129 | current_hash = parts[0] |
| 130 | try: |
| 131 | line_number = int(parts[2]) |
| 132 | except (IndexError, ValueError): |
| 133 | line_number = 0 |
| 134 | i += 1 |
| 135 | # Read key-value pairs until we hit the content line (starts with \t) |
| 136 | while i < len(lines) and not lines[i].startswith("\t"): |
| 137 | kv = lines[i] |
| 138 | if kv.startswith("author "): |
| 139 | current:] |
| 140 | "" |
| 141 | VCS abstraction layer for navegador. |
| 142 | |
| 143 | Provides a uniform interface for version-control operations across different |
| 144 | backends (git, Fossil, …) so the rest of the codebase never calls git directly. |
| 145 | |
| 146 | Usage::e |
| 147 | lice, tags: trunk) |
| 148 | |
| 149 | |
| 150 | return entries |
| 151 | |
| 152 | def bturn entries |
| 153 | |
| 154 | d "" |
| 155 | |
| 156 | f) |
| 157 | *. |
| 158 | |
| 159 | Each entry has the keys: ``line``, ``hash``, ``author``, ``content``. |
| 160 | |
| 161 | Uses ``git blame --porcelain`` for machine-readable output. |
| 162 | """ |
| 163 | result = self._run(["blame", "--porcelain", "--", file_path]) |
| 164 | return _parse_porcelain_blame(result.stdout) |
| 165 | |
| 166 | |
| 167 | def _parse_porcelain_blame(output: str) -> list[dict]: |
| 168 | """Parse the output of ``git blame --porcelain`` into a list of dicts.""" |
| 169 | entries: list[dict] = [] |
| 170 | current_hash = "" |
| 171 | current_author = "" |
| 172 | line_number = 0 |
| 173 | |
| 174 | lines = output.splitlines() |
| 175 | i = 0 |
| 176 | while i < len(lines): |
| 177 | raw = lines[i] |
| 178 | |
| 179 | # Headestuber line: "<40-charis fully implemented; a[0]) == 40 and pars |
| 180 | ---------- |
| 181 | since: |
| 182 | A commit reference (hash,perations across different |
| 183 | backends (git, Fossil, …) so the rest of the codebase never calls git directly. |
| 184 | |
| 185 | Usage:: |
| 186 | |
| 187 | from navegador.vcs import detect_vcs |
| 188 | |
| 189 | adapter = detect_vcs(Path("/path/to/repo")) |
| 190 | """ |
| 191 | VCS abstraction layer for �──�self._NOT_IMPLEMENTED_MSG.formt |
| 192 | backends (git, Foswith at least the keys: |
| 193 | raise NotImplementedError( |
| 194 | �──�self._NOT_IMPLEMENTED_MSG.fort |
| 195 | backends (git, Fos> list[dict]: |
| 196 | """ |
| 197 | Return per-line blame data for *file_pathraise NotImplementedError( |
| 198 | �──�self._NOT_IMPLEMENTED_MSG.fot |
| 199 | backends (git, Fosblamele_path: str) -> listlain_blame(result.stdout)raise NotImplementedError( |
| 200 | �──�self._NOT_IMPLEMENTED_MSG.forma──────────────────────────────────── |
| 201 | |
| 202 | |
| 203 | class VCSAdapter(ABC): |
| 204 | """Abstract base class for VCS backends.""" |
| 205 | |
| 206 | def __init__(self, repo_path: Path) -> None: |
| 207 | self.repo_path = Path(repo_path) |
| 208 | |
| 209 | @abstractmethod |
| 210 | def is_repo(self) -> bool: |
| 211 | """Return True if *repo_path* is a valid repor navegador. |
| 212 | |
| 213 | Provides a uniform interface for version-control operations across different |
| 214 | backends (git, Fossil, …) so the rest of the codebase never calls git directly. |
| 215 | |
| 216 | Usage:: |
| 217 | |
| 218 | from navegador.vcs import detect_vcs |
| 219 | |
| 220 | adapter = detect_vcs(Path("/path/to/repo")) |
| 221 | print(adapter.current_branch()) |
| 222 | print(adapter.changed_files()) |
| 223 | """ |
| 224 | |
| 225 | from __future__ import annotations |
| 226 | |
| 227 | import subprocess |
| 228 | from abc import ABC, abstractmethod |
| 229 | from pathlib import Path |
| 230 | |
| 231 | # ── Abstract base ───────────────────────────────────� |
+257
| --- a/tests/test_vcs.py | ||
| +++ b/tests/test_vcs.py | ||
| @@ -0,0 +1,257 @@ | ||
| 1 | +"""Tests for navegador.vcs — VCSAdapter, GitAdapter, FossilAdapter, detect_vcs.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import subprocess | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +import pytest | |
| 9 | + | |
| 10 | +from navegador.vcs import ( | |
| 11 | + FossilAdapter, | |
| 12 | + GitAdapter, | |
| 13 | + VCSAdapter, | |
| 14 | + detect_vcs, | |
| 15 | +) | |
| 16 | + | |
| 17 | + | |
| 18 | +# ── Fixtures ─────────────────────────────────────────────────────────────────── | |
| 19 | + | |
| 20 | + | |
| 21 | +def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess: | |
| 22 | + """Run a git command in *cwd*, raising on failure.""" | |
| 23 | + return subprocess.run( | |
| 24 | + ["git", *args], | |
| 25 | + cwd=cwd, | |
| 26 | + capture_output=True, | |
| 27 | + text=True, | |
| 28 | + check=True, | |
| 29 | + ) | |
| 30 | + | |
| 31 | + | |
| 32 | +@pytest.fixture() | |
| 33 | +def git_repo(tmp_path: Path) -> Path: | |
| 34 | + """ | |
| 35 | + Create a minimal, fully-initialised git repo with one commit. | |
| 36 | + | |
| 37 | + Returns the repo root path. | |
| 38 | + """ | |
| 39 | + repo = tmp_path / "repo" | |
| 40 | + repo.mkdir() | |
| 41 | + | |
| 42 | + _git(["init", "-b", "main"], cwd=repo) | |
| 43 | + _git(["config", "user.email", "[email protected]"], cwd=repo) | |
| 44 | + _git(["config", "user.name", "Test User"], cwd=repo) | |
| 45 | + | |
| 46 | + # Initial commit so HEAD exists | |
| 47 | + readme = repo / "README.md" | |
| 48 | + readme.write_text("# test repo\n") | |
| 49 | + _git(["add", "README.md"], cwd=repo) | |
| 50 | + _git(["commit", "-m", "initial commit"], cwd=repo) | |
| 51 | + | |
| 52 | + return repo | |
| 53 | + | |
| 54 | + | |
| 55 | +@pytest.fixture() | |
| 56 | +def empty_dir(tmp_path: Path) -> Path: | |
| 57 | + """Return a temporary directory that is NOT a git repo.""" | |
| 58 | + d = tmp_path / "notarepo" | |
| 59 | + d.mkdir() | |
| 60 | + return d | |
| 61 | + | |
| 62 | + | |
| 63 | +@pytest.fixture() | |
| 64 | +def fossil_dir(tmp_path: Path) -> Path: | |
| 65 | + """Return a directory that looks like a Fossil checkout (.fslckout present).""" | |
| 66 | + d = tmp_path / "fossil_repo" | |
| 67 | + d.mkdir() | |
| 68 | + (d / ".fslckout").touch() | |
| 69 | + return d | |
| 70 | + | |
| 71 | + | |
| 72 | +# ── VCSAdapter is abstract ───────────────────────────────────────────────────── | |
| 73 | + | |
| 74 | + | |
| 75 | +class TestVCSAdapterAbstract: | |
| 76 | + def test_cannot_instantiate_directly(self): | |
| 77 | + with pytest.raises(TypeError): | |
| 78 | + VCSAdapter(Path("/tmp")) # type: ignore[abstract] | |
| 79 | + | |
| 80 | + def test_git_adapter_is_subclass(self): | |
| 81 | + assert issubclass(GitAdapter, VCSAdapter) | |
| 82 | + | |
| 83 | + def test_fossil_adapter_is_subclass(self): | |
| 84 | + assert issubclass(FossilAdapter, VCSAdapter) | |
| 85 | + | |
| 86 | + | |
| 87 | +# ── GitAdapter.is_repo ───────────────────────────────────────────────────────── | |
| 88 | + | |
| 89 | + | |
| 90 | +class TestGitAdapterIsRepo: | |
| 91 | + def test_true_for_git_repo(self, git_repo: Path): | |
| 92 | + assert GitAdapter(git_repo).is_repo() is True | |
| 93 | + | |
| 94 | + def test_false_for_empty_dir(self, empty_dir: Path): | |
| 95 | + assert GitAdapter(empty_dir).is_repo() is False | |
| 96 | + | |
| 97 | + def test_false_for_fossil_dir(self, fossil_dir: Path): | |
| 98 | + assert GitAdapter(fossil_dir).is_repo() is False | |
| 99 | + | |
| 100 | + | |
| 101 | +# ── GitAdapter.current_branch ────────────────────────────────────────────────── | |
| 102 | + | |
| 103 | + | |
| 104 | +class TestGitAdapterCurrentBranch: | |
| 105 | + def test_returns_main(self, git_repo: Path): | |
| 106 | + branch = GitAdapter(git_repo).current_branch() | |
| 107 | + # Accept "main" or "master" depending on git config defaults | |
| 108 | + assert branch in ("main", "master") | |
| 109 | + | |
| 110 | + def test_returns_feature_branch(self, git_repo: Path): | |
| 111 | + _git(["checkout", "-b", "feature/test-branch"], cwd=git_repo) | |
| 112 | + branch = GitAdapter(git_repo).current_branch() | |
| 113 | + assert branch == "feature/test-branch" | |
| 114 | + | |
| 115 | + def test_returns_string(self, git_repo: Path): | |
| 116 | + result = GitAdapter(git_repo).current_branch() | |
| 117 | + assert isinstance(result, str) | |
| 118 | + assert len(result) > 0 | |
| 119 | + | |
| 120 | + | |
| 121 | +# ── GitAdapter.changed_files ─────────────────────────────────────────────────── | |
| 122 | + | |
| 123 | + | |
| 124 | +class TestGitAdapterChangedFiles: | |
| 125 | + def test_no_changes_returns_empty(self, git_repo: Path): | |
| 126 | + # Nothing changed after the initial commit | |
| 127 | + files = GitAdapter(git_repo).changed_files() | |
| 128 | + assert files == [] | |
| 129 | + | |
| 130 | + def test_detects_modified_file(self, git_repo: Path): | |
| 131 | + # Modify a tracked file without staging it | |
| 132 | + readme = git_repo / "README.md" | |
| 133 | + readme.write_text("# modified\n") | |
| 134 | + | |
| 135 | + files = GitAdapter(git_repo).changed_files() | |
| 136 | + assert "README.md" in files | |
| 137 | + | |
| 138 | + def test_detects_staged_file(self, git_repo: Path): | |
| 139 | + new_file = git_repo / "new.py" | |
| 140 | + new_file.write_text("x = 1\n") | |
| 141 | + _git(["add", "new.py"], cwd=git_repo) | |
| 142 | + | |
| 143 | + files = GitAdapter(git_repo).changed_files() | |
| 144 | + assert "new.py" in files | |
| 145 | + | |
| 146 | + def test_since_commit_returns_changed_files(self, git_repo: Path): | |
| 147 | + # Get the first commit hash | |
| 148 | + result = _git(["rev-parse", "HEAD"], cwd=git_repo) | |
| 149 | + first_hash = result.stdout.strip() | |
| 150 | + | |
| 151 | + # Add a second commit | |
| 152 | + extra = git_repo / "extra.txt" | |
| 153 | + extra.write_text("hello\n") | |
| 154 | + _git(["add", "extra.txt"], cwd=git_repo) | |
| 155 | + _git(["commit", "-m", "add extra.txt"], cwd=git_repo) | |
| 156 | + | |
| 157 | + files = GitAdapter(git_repo).changed_files(since=first_hash) | |
| 158 | + assert "extra.txt" in files | |
| 159 | + | |
| 160 | + def test_returns_list(self, git_repo: Path): | |
| 161 | + result = GitAdapter(git_repo).changed_files() | |
| 162 | + assert isinstance(result, list) | |
| 163 | + | |
| 164 | + | |
| 165 | +# ── GitAdapter.file_history ──────────────────────────────────────────────────── | |
| 166 | + | |
| 167 | + | |
| 168 | +class TestGitAdapterFileHistory: | |
| 169 | + def test_returns_list(self, git_repo: Path): | |
| 170 | + history = GitAdapter(git_repo).file_history("README.md") | |
| 171 | + assert isinstance(history, list) | |
| 172 | + | |
| 173 | + def test_initial_commit_present(self, git_repo: Path): | |
| 174 | + history = GitAdapter(git_repo).file_history("README.md") | |
| 175 | + assert len(history) >= 1 | |
| 176 | + | |
| 177 | + def test_entry_has_required_keys(self, git_repo: Path): | |
| 178 | + history = GitAdapter(git_repo).file_history("README.md") | |
| 179 | + entry = history[0] | |
| 180 | + assert "hash" in entry | |
| 181 | + assert "author" in entry | |
| 182 | + assert "date" in entry | |
| 183 | + assert "message" in entry | |
| 184 | + | |
| 185 | + def test_entry_message_matches(self, git_repo: Path): | |
| 186 | + history = GitAdapter(git_repo).file_history("README.md") | |
| 187 | + assert history[0]["message"] == "initial commit" | |
| 188 | + | |
| 189 | + def test_limit_is_respected(self, git_repo: Path): | |
| 190 | + # Add several more commits to README.md | |
| 191 | + readme = git_repo / "README.md" | |
| 192 | + for i in range(5): | |
| 193 | + readme.write_text(f"# revision {i}\n") | |
| 194 | + _git(["add", "README.md"], cwd=git_repo) | |
| 195 | + _git(["commit", "-m", f"revision {i}"], cwd=git_repo) | |
| 196 | + | |
| 197 | + history = GitAdapter(git_repo).file_history("README.md", limit=3) | |
| 198 | + assert len(history) <= 3 | |
| 199 | + | |
| 200 | + def test_nonexistent_file_returns_empty(self, git_repo: Path): | |
| 201 | + history = GitAdapter(git_repo).file_history("does_not_exist.py") | |
| 202 | + assert history == [] | |
| 203 | + | |
| 204 | + | |
| 205 | +# ── GitAdapter.blame ────────────────────────────────────────────────────────── | |
| 206 | + | |
| 207 | + | |
| 208 | +class TestGitAdapterBlame: | |
| 209 | + def test_returns_list(self, git_repo: Path): | |
| 210 | + result = GitAdapter(git_repo).blame("README.md") | |
| 211 | + assert isinstance(result, list) | |
| 212 | + | |
| 213 | + def test_entry_has_required_keys(self, git_repo: Path): | |
| 214 | + result = GitAdapter(git_repo).blame("README.md") | |
| 215 | + assert len(result) >= 1 | |
| 216 | + entry = result[0] | |
| 217 | + assert "line" in entry | |
| 218 | + assert "hash" in entry | |
| 219 | + assert "author" in entry | |
| 220 | + assert "content" in entry | |
| 221 | + | |
| 222 | + def test_content_matches_file(self, git_repo: Path): | |
| 223 | + result = GitAdapter(git_repo).blame("README.md") | |
| 224 | + # README.md contains "# test repo" | |
| 225 | + contents = [e["content"] for e in result] | |
| 226 | + assert any("test repo" in c for c in contents) | |
| 227 | + | |
| 228 | + def test_line_numbers_are_integers(self, git_repo: Path): | |
| 229 | + result = GitAdapter(git_repo).blame("README.md") | |
| 230 | + for entry in result: | |
| 231 | + assert isinstance(entry["line"], int) | |
| 232 | + | |
| 233 | + | |
| 234 | +# ── FossilAdapter.is_repo ───────────────────────────────────────────────────── | |
| 235 | + | |
| 236 | + | |
| 237 | +class TestFossilAdapterIsRepo: | |
| 238 | + def test_true_when_fslckout_present(self, fossil_dir: Path): | |
| 239 | + assert FossilAdapter(fossil_dir).is_repo() is True | |
| 240 | + | |
| 241 | + def test_true_when_FOSSIL_present(self, tmp_path: Path): | |
| 242 | + d = tmp_path / "fossil2" | |
| 243 | + d.mkdir() | |
| 244 | + (d / "_FOSSIL_").touch() | |
| 245 | + assert FossilAdapter(d).is_repo() is True | |
| 246 | + | |
| 247 | + def test_false_for_empty_dir(self, empty_dir: Path): | |
| 248 | + assert FossilAdapter(empty_dir).is_repo() is False | |
| 249 | + | |
| 250 | + def test_false_for_git_repo(self, git_repo: Path): | |
| 251 | + assert FossilAdapter(git_repo).is_repo() is Falsstubs raise NotImplementedErrorfrom pathlib import Path | |
| 252 | + | |
| 253 | +import pytest | |
| 254 | + | |
| 255 | +from navegador.vcs import ( | |
| 256 | + FossilAdapter, | |
| 257 | +"""Tests for navegador.vcs"""Tes2O@2Iw,5:aisesd@2LW,J@2rV,i:NotImplementedError, match="current_branch"):9@1FB,B: adapterI@ul,T@2QC,5:aisesd@2LW,J@2rV,h:NotImplementedError, match="changed_files"):9@1FB |
| --- a/tests/test_vcs.py | |
| +++ b/tests/test_vcs.py | |
| @@ -0,0 +1,257 @@ | |
| --- a/tests/test_vcs.py | |
| +++ b/tests/test_vcs.py | |
| @@ -0,0 +1,257 @@ | |
| 1 | """Tests for navegador.vcs — VCSAdapter, GitAdapter, FossilAdapter, detect_vcs.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import subprocess |
| 6 | from pathlib import Path |
| 7 | |
| 8 | import pytest |
| 9 | |
| 10 | from navegador.vcs import ( |
| 11 | FossilAdapter, |
| 12 | GitAdapter, |
| 13 | VCSAdapter, |
| 14 | detect_vcs, |
| 15 | ) |
| 16 | |
| 17 | |
| 18 | # ── Fixtures ─────────────────────────────────────────────────────────────────── |
| 19 | |
| 20 | |
| 21 | def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess: |
| 22 | """Run a git command in *cwd*, raising on failure.""" |
| 23 | return subprocess.run( |
| 24 | ["git", *args], |
| 25 | cwd=cwd, |
| 26 | capture_output=True, |
| 27 | text=True, |
| 28 | check=True, |
| 29 | ) |
| 30 | |
| 31 | |
| 32 | @pytest.fixture() |
| 33 | def git_repo(tmp_path: Path) -> Path: |
| 34 | """ |
| 35 | Create a minimal, fully-initialised git repo with one commit. |
| 36 | |
| 37 | Returns the repo root path. |
| 38 | """ |
| 39 | repo = tmp_path / "repo" |
| 40 | repo.mkdir() |
| 41 | |
| 42 | _git(["init", "-b", "main"], cwd=repo) |
| 43 | _git(["config", "user.email", "[email protected]"], cwd=repo) |
| 44 | _git(["config", "user.name", "Test User"], cwd=repo) |
| 45 | |
| 46 | # Initial commit so HEAD exists |
| 47 | readme = repo / "README.md" |
| 48 | readme.write_text("# test repo\n") |
| 49 | _git(["add", "README.md"], cwd=repo) |
| 50 | _git(["commit", "-m", "initial commit"], cwd=repo) |
| 51 | |
| 52 | return repo |
| 53 | |
| 54 | |
| 55 | @pytest.fixture() |
| 56 | def empty_dir(tmp_path: Path) -> Path: |
| 57 | """Return a temporary directory that is NOT a git repo.""" |
| 58 | d = tmp_path / "notarepo" |
| 59 | d.mkdir() |
| 60 | return d |
| 61 | |
| 62 | |
| 63 | @pytest.fixture() |
| 64 | def fossil_dir(tmp_path: Path) -> Path: |
| 65 | """Return a directory that looks like a Fossil checkout (.fslckout present).""" |
| 66 | d = tmp_path / "fossil_repo" |
| 67 | d.mkdir() |
| 68 | (d / ".fslckout").touch() |
| 69 | return d |
| 70 | |
| 71 | |
| 72 | # ── VCSAdapter is abstract ───────────────────────────────────────────────────── |
| 73 | |
| 74 | |
| 75 | class TestVCSAdapterAbstract: |
| 76 | def test_cannot_instantiate_directly(self): |
| 77 | with pytest.raises(TypeError): |
| 78 | VCSAdapter(Path("/tmp")) # type: ignore[abstract] |
| 79 | |
| 80 | def test_git_adapter_is_subclass(self): |
| 81 | assert issubclass(GitAdapter, VCSAdapter) |
| 82 | |
| 83 | def test_fossil_adapter_is_subclass(self): |
| 84 | assert issubclass(FossilAdapter, VCSAdapter) |
| 85 | |
| 86 | |
| 87 | # ── GitAdapter.is_repo ───────────────────────────────────────────────────────── |
| 88 | |
| 89 | |
| 90 | class TestGitAdapterIsRepo: |
| 91 | def test_true_for_git_repo(self, git_repo: Path): |
| 92 | assert GitAdapter(git_repo).is_repo() is True |
| 93 | |
| 94 | def test_false_for_empty_dir(self, empty_dir: Path): |
| 95 | assert GitAdapter(empty_dir).is_repo() is False |
| 96 | |
| 97 | def test_false_for_fossil_dir(self, fossil_dir: Path): |
| 98 | assert GitAdapter(fossil_dir).is_repo() is False |
| 99 | |
| 100 | |
| 101 | # ── GitAdapter.current_branch ────────────────────────────────────────────────── |
| 102 | |
| 103 | |
| 104 | class TestGitAdapterCurrentBranch: |
| 105 | def test_returns_main(self, git_repo: Path): |
| 106 | branch = GitAdapter(git_repo).current_branch() |
| 107 | # Accept "main" or "master" depending on git config defaults |
| 108 | assert branch in ("main", "master") |
| 109 | |
| 110 | def test_returns_feature_branch(self, git_repo: Path): |
| 111 | _git(["checkout", "-b", "feature/test-branch"], cwd=git_repo) |
| 112 | branch = GitAdapter(git_repo).current_branch() |
| 113 | assert branch == "feature/test-branch" |
| 114 | |
| 115 | def test_returns_string(self, git_repo: Path): |
| 116 | result = GitAdapter(git_repo).current_branch() |
| 117 | assert isinstance(result, str) |
| 118 | assert len(result) > 0 |
| 119 | |
| 120 | |
| 121 | # ── GitAdapter.changed_files ─────────────────────────────────────────────────── |
| 122 | |
| 123 | |
| 124 | class TestGitAdapterChangedFiles: |
| 125 | def test_no_changes_returns_empty(self, git_repo: Path): |
| 126 | # Nothing changed after the initial commit |
| 127 | files = GitAdapter(git_repo).changed_files() |
| 128 | assert files == [] |
| 129 | |
| 130 | def test_detects_modified_file(self, git_repo: Path): |
| 131 | # Modify a tracked file without staging it |
| 132 | readme = git_repo / "README.md" |
| 133 | readme.write_text("# modified\n") |
| 134 | |
| 135 | files = GitAdapter(git_repo).changed_files() |
| 136 | assert "README.md" in files |
| 137 | |
| 138 | def test_detects_staged_file(self, git_repo: Path): |
| 139 | new_file = git_repo / "new.py" |
| 140 | new_file.write_text("x = 1\n") |
| 141 | _git(["add", "new.py"], cwd=git_repo) |
| 142 | |
| 143 | files = GitAdapter(git_repo).changed_files() |
| 144 | assert "new.py" in files |
| 145 | |
| 146 | def test_since_commit_returns_changed_files(self, git_repo: Path): |
| 147 | # Get the first commit hash |
| 148 | result = _git(["rev-parse", "HEAD"], cwd=git_repo) |
| 149 | first_hash = result.stdout.strip() |
| 150 | |
| 151 | # Add a second commit |
| 152 | extra = git_repo / "extra.txt" |
| 153 | extra.write_text("hello\n") |
| 154 | _git(["add", "extra.txt"], cwd=git_repo) |
| 155 | _git(["commit", "-m", "add extra.txt"], cwd=git_repo) |
| 156 | |
| 157 | files = GitAdapter(git_repo).changed_files(since=first_hash) |
| 158 | assert "extra.txt" in files |
| 159 | |
| 160 | def test_returns_list(self, git_repo: Path): |
| 161 | result = GitAdapter(git_repo).changed_files() |
| 162 | assert isinstance(result, list) |
| 163 | |
| 164 | |
| 165 | # ── GitAdapter.file_history ──────────────────────────────────────────────────── |
| 166 | |
| 167 | |
| 168 | class TestGitAdapterFileHistory: |
| 169 | def test_returns_list(self, git_repo: Path): |
| 170 | history = GitAdapter(git_repo).file_history("README.md") |
| 171 | assert isinstance(history, list) |
| 172 | |
| 173 | def test_initial_commit_present(self, git_repo: Path): |
| 174 | history = GitAdapter(git_repo).file_history("README.md") |
| 175 | assert len(history) >= 1 |
| 176 | |
| 177 | def test_entry_has_required_keys(self, git_repo: Path): |
| 178 | history = GitAdapter(git_repo).file_history("README.md") |
| 179 | entry = history[0] |
| 180 | assert "hash" in entry |
| 181 | assert "author" in entry |
| 182 | assert "date" in entry |
| 183 | assert "message" in entry |
| 184 | |
| 185 | def test_entry_message_matches(self, git_repo: Path): |
| 186 | history = GitAdapter(git_repo).file_history("README.md") |
| 187 | assert history[0]["message"] == "initial commit" |
| 188 | |
| 189 | def test_limit_is_respected(self, git_repo: Path): |
| 190 | # Add several more commits to README.md |
| 191 | readme = git_repo / "README.md" |
| 192 | for i in range(5): |
| 193 | readme.write_text(f"# revision {i}\n") |
| 194 | _git(["add", "README.md"], cwd=git_repo) |
| 195 | _git(["commit", "-m", f"revision {i}"], cwd=git_repo) |
| 196 | |
| 197 | history = GitAdapter(git_repo).file_history("README.md", limit=3) |
| 198 | assert len(history) <= 3 |
| 199 | |
| 200 | def test_nonexistent_file_returns_empty(self, git_repo: Path): |
| 201 | history = GitAdapter(git_repo).file_history("does_not_exist.py") |
| 202 | assert history == [] |
| 203 | |
| 204 | |
| 205 | # ── GitAdapter.blame ────────────────────────────────────────────────────────── |
| 206 | |
| 207 | |
| 208 | class TestGitAdapterBlame: |
| 209 | def test_returns_list(self, git_repo: Path): |
| 210 | result = GitAdapter(git_repo).blame("README.md") |
| 211 | assert isinstance(result, list) |
| 212 | |
| 213 | def test_entry_has_required_keys(self, git_repo: Path): |
| 214 | result = GitAdapter(git_repo).blame("README.md") |
| 215 | assert len(result) >= 1 |
| 216 | entry = result[0] |
| 217 | assert "line" in entry |
| 218 | assert "hash" in entry |
| 219 | assert "author" in entry |
| 220 | assert "content" in entry |
| 221 | |
| 222 | def test_content_matches_file(self, git_repo: Path): |
| 223 | result = GitAdapter(git_repo).blame("README.md") |
| 224 | # README.md contains "# test repo" |
| 225 | contents = [e["content"] for e in result] |
| 226 | assert any("test repo" in c for c in contents) |
| 227 | |
| 228 | def test_line_numbers_are_integers(self, git_repo: Path): |
| 229 | result = GitAdapter(git_repo).blame("README.md") |
| 230 | for entry in result: |
| 231 | assert isinstance(entry["line"], int) |
| 232 | |
| 233 | |
| 234 | # ── FossilAdapter.is_repo ───────────────────────────────────────────────────── |
| 235 | |
| 236 | |
| 237 | class TestFossilAdapterIsRepo: |
| 238 | def test_true_when_fslckout_present(self, fossil_dir: Path): |
| 239 | assert FossilAdapter(fossil_dir).is_repo() is True |
| 240 | |
| 241 | def test_true_when_FOSSIL_present(self, tmp_path: Path): |
| 242 | d = tmp_path / "fossil2" |
| 243 | d.mkdir() |
| 244 | (d / "_FOSSIL_").touch() |
| 245 | assert FossilAdapter(d).is_repo() is True |
| 246 | |
| 247 | def test_false_for_empty_dir(self, empty_dir: Path): |
| 248 | assert FossilAdapter(empty_dir).is_repo() is False |
| 249 | |
| 250 | def test_false_for_git_repo(self, git_repo: Path): |
| 251 | assert FossilAdapter(git_repo).is_repo() is Falsstubs raise NotImplementedErrorfrom pathlib import Path |
| 252 | |
| 253 | import pytest |
| 254 | |
| 255 | from navegador.vcs import ( |
| 256 | FossilAdapter, |
| 257 | """Tests for navegador.vcs"""Tes2O@2Iw,5:aisesd@2LW,J@2rV,i:NotImplementedError, match="current_branch"):9@1FB,B: adapterI@ul,T@2QC,5:aisesd@2LW,J@2rV,h:NotImplementedError, match="changed_files"):9@1FB |