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

lmata 2026-03-23 05:04 trunk
Commit 31642ed7375ee36f2c1935deb0a715d65c2a8f116c444f8d00ee3f17496df2d9
--- 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 ───────────────────────────────────�
--- 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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button