gitmap-core 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.
- gitmap_core/README.md +46 -0
- gitmap_core/__init__.py +100 -0
- gitmap_core/communication.py +346 -0
- gitmap_core/compat.py +408 -0
- gitmap_core/connection.py +232 -0
- gitmap_core/context.py +709 -0
- gitmap_core/diff.py +283 -0
- gitmap_core/maps.py +385 -0
- gitmap_core/merge.py +449 -0
- gitmap_core/models.py +332 -0
- gitmap_core/py.typed +0 -0
- gitmap_core/pyproject.toml +48 -0
- gitmap_core/remote.py +728 -0
- gitmap_core/repository.py +1632 -0
- gitmap_core/tests/__init__.py +1 -0
- gitmap_core/tests/test_communication.py +695 -0
- gitmap_core/tests/test_compat.py +310 -0
- gitmap_core/tests/test_connection.py +314 -0
- gitmap_core/tests/test_context.py +814 -0
- gitmap_core/tests/test_diff.py +567 -0
- gitmap_core/tests/test_init.py +153 -0
- gitmap_core/tests/test_maps.py +642 -0
- gitmap_core/tests/test_merge.py +694 -0
- gitmap_core/tests/test_models.py +410 -0
- gitmap_core/tests/test_remote.py +3014 -0
- gitmap_core/tests/test_repository.py +1639 -0
- gitmap_core/tests/test_visualize.py +902 -0
- gitmap_core/visualize.py +1217 -0
- gitmap_core-0.1.0.dist-info/METADATA +961 -0
- gitmap_core-0.1.0.dist-info/RECORD +32 -0
- gitmap_core-0.1.0.dist-info/WHEEL +4 -0
- gitmap_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1639 @@
|
|
|
1
|
+
"""Tests for local repository management module.
|
|
2
|
+
|
|
3
|
+
Tests Repository class including initialization, branch operations,
|
|
4
|
+
commit operations, index management, and config handling.
|
|
5
|
+
|
|
6
|
+
Execution Context:
|
|
7
|
+
Test module - run via pytest
|
|
8
|
+
|
|
9
|
+
Dependencies:
|
|
10
|
+
- pytest: Test framework
|
|
11
|
+
- gitmap_core.repository: Module under test
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from gitmap_core.models import RepoConfig
|
|
23
|
+
from gitmap_core.repository import (
|
|
24
|
+
COMMITS_DIR,
|
|
25
|
+
CONFIG_FILE,
|
|
26
|
+
CONTEXT_DB,
|
|
27
|
+
GITMAP_DIR,
|
|
28
|
+
HEAD_FILE,
|
|
29
|
+
HEADS_DIR,
|
|
30
|
+
INDEX_FILE,
|
|
31
|
+
OBJECTS_DIR,
|
|
32
|
+
REFS_DIR,
|
|
33
|
+
REMOTES_DIR,
|
|
34
|
+
Repository,
|
|
35
|
+
find_repository,
|
|
36
|
+
init_repository,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---- Fixtures ------------------------------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def temp_repo_dir() -> Path:
|
|
45
|
+
"""Create temporary directory for repository tests."""
|
|
46
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
47
|
+
yield Path(tmpdir)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def initialized_repo(temp_repo_dir: Path) -> Repository:
|
|
52
|
+
"""Create and initialize a repository."""
|
|
53
|
+
repo = Repository(temp_repo_dir)
|
|
54
|
+
repo.init(project_name="TestProject", user_name="Test User")
|
|
55
|
+
return repo
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def sample_map_data() -> dict[str, Any]:
|
|
60
|
+
"""Sample web map data for testing."""
|
|
61
|
+
return {
|
|
62
|
+
"operationalLayers": [
|
|
63
|
+
{"id": "layer-1", "title": "Roads"},
|
|
64
|
+
{"id": "layer-2", "title": "Parcels"},
|
|
65
|
+
],
|
|
66
|
+
"baseMap": {"baseMapLayers": []},
|
|
67
|
+
"spatialReference": {"wkid": 102100},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture
|
|
72
|
+
def repo_with_commit(initialized_repo: Repository, sample_map_data: dict) -> Repository:
|
|
73
|
+
"""Create repository with one commit."""
|
|
74
|
+
initialized_repo.update_index(sample_map_data)
|
|
75
|
+
initialized_repo.create_commit("Initial commit", author="Test User")
|
|
76
|
+
return initialized_repo
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---- Constants Tests ----------------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestConstants:
|
|
83
|
+
"""Tests for module constants."""
|
|
84
|
+
|
|
85
|
+
def test_gitmap_dir(self) -> None:
|
|
86
|
+
"""Test GITMAP_DIR constant."""
|
|
87
|
+
assert GITMAP_DIR == ".gitmap"
|
|
88
|
+
|
|
89
|
+
def test_config_file(self) -> None:
|
|
90
|
+
"""Test CONFIG_FILE constant."""
|
|
91
|
+
assert CONFIG_FILE == "config.json"
|
|
92
|
+
|
|
93
|
+
def test_head_file(self) -> None:
|
|
94
|
+
"""Test HEAD_FILE constant."""
|
|
95
|
+
assert HEAD_FILE == "HEAD"
|
|
96
|
+
|
|
97
|
+
def test_index_file(self) -> None:
|
|
98
|
+
"""Test INDEX_FILE constant."""
|
|
99
|
+
assert INDEX_FILE == "index.json"
|
|
100
|
+
|
|
101
|
+
def test_refs_dir(self) -> None:
|
|
102
|
+
"""Test REFS_DIR constant."""
|
|
103
|
+
assert REFS_DIR == "refs"
|
|
104
|
+
|
|
105
|
+
def test_heads_dir(self) -> None:
|
|
106
|
+
"""Test HEADS_DIR constant."""
|
|
107
|
+
assert HEADS_DIR == "heads"
|
|
108
|
+
|
|
109
|
+
def test_remotes_dir(self) -> None:
|
|
110
|
+
"""Test REMOTES_DIR constant."""
|
|
111
|
+
assert REMOTES_DIR == "remotes"
|
|
112
|
+
|
|
113
|
+
def test_objects_dir(self) -> None:
|
|
114
|
+
"""Test OBJECTS_DIR constant."""
|
|
115
|
+
assert OBJECTS_DIR == "objects"
|
|
116
|
+
|
|
117
|
+
def test_commits_dir(self) -> None:
|
|
118
|
+
"""Test COMMITS_DIR constant."""
|
|
119
|
+
assert COMMITS_DIR == "commits"
|
|
120
|
+
|
|
121
|
+
def test_context_db(self) -> None:
|
|
122
|
+
"""Test CONTEXT_DB constant."""
|
|
123
|
+
assert CONTEXT_DB == "context.db"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---- Repository Initialization Tests ------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestRepositoryInit:
|
|
130
|
+
"""Tests for Repository initialization."""
|
|
131
|
+
|
|
132
|
+
def test_init_creates_instance(self, temp_repo_dir: Path) -> None:
|
|
133
|
+
"""Test Repository constructor."""
|
|
134
|
+
repo = Repository(temp_repo_dir)
|
|
135
|
+
|
|
136
|
+
assert repo.root == temp_repo_dir.resolve()
|
|
137
|
+
assert repo.gitmap_dir == temp_repo_dir.resolve() / ".gitmap"
|
|
138
|
+
|
|
139
|
+
def test_init_with_string_path(self, temp_repo_dir: Path) -> None:
|
|
140
|
+
"""Test Repository with string path."""
|
|
141
|
+
repo = Repository(str(temp_repo_dir))
|
|
142
|
+
|
|
143
|
+
assert repo.root == temp_repo_dir.resolve()
|
|
144
|
+
|
|
145
|
+
def test_path_properties(self, temp_repo_dir: Path) -> None:
|
|
146
|
+
"""Test path property accessors."""
|
|
147
|
+
repo = Repository(temp_repo_dir)
|
|
148
|
+
|
|
149
|
+
assert repo.config_path == repo.gitmap_dir / "config.json"
|
|
150
|
+
assert repo.head_path == repo.gitmap_dir / "HEAD"
|
|
151
|
+
assert repo.index_path == repo.gitmap_dir / "index.json"
|
|
152
|
+
assert repo.refs_dir == repo.gitmap_dir / "refs"
|
|
153
|
+
assert repo.heads_dir == repo.gitmap_dir / "refs" / "heads"
|
|
154
|
+
assert repo.remotes_dir == repo.gitmap_dir / "refs" / "remotes"
|
|
155
|
+
assert repo.objects_dir == repo.gitmap_dir / "objects"
|
|
156
|
+
assert repo.commits_dir == repo.gitmap_dir / "objects" / "commits"
|
|
157
|
+
assert repo.context_db_path == repo.gitmap_dir / "context.db"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---- Repository State Tests ---------------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestRepositoryState:
|
|
164
|
+
"""Tests for repository state checking."""
|
|
165
|
+
|
|
166
|
+
def test_exists_false_when_not_initialized(self, temp_repo_dir: Path) -> None:
|
|
167
|
+
"""Test exists() returns False for uninitialized repo."""
|
|
168
|
+
repo = Repository(temp_repo_dir)
|
|
169
|
+
|
|
170
|
+
assert repo.exists() is False
|
|
171
|
+
|
|
172
|
+
def test_exists_true_when_initialized(self, initialized_repo: Repository) -> None:
|
|
173
|
+
"""Test exists() returns True for initialized repo."""
|
|
174
|
+
assert initialized_repo.exists() is True
|
|
175
|
+
|
|
176
|
+
def test_is_valid_false_when_not_initialized(self, temp_repo_dir: Path) -> None:
|
|
177
|
+
"""Test is_valid() returns False for uninitialized repo."""
|
|
178
|
+
repo = Repository(temp_repo_dir)
|
|
179
|
+
|
|
180
|
+
assert repo.is_valid() is False
|
|
181
|
+
|
|
182
|
+
def test_is_valid_true_when_initialized(self, initialized_repo: Repository) -> None:
|
|
183
|
+
"""Test is_valid() returns True for properly initialized repo."""
|
|
184
|
+
assert initialized_repo.is_valid() is True
|
|
185
|
+
|
|
186
|
+
def test_is_valid_false_when_missing_config(
|
|
187
|
+
self, initialized_repo: Repository
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Test is_valid() returns False when config missing."""
|
|
190
|
+
initialized_repo.config_path.unlink()
|
|
191
|
+
|
|
192
|
+
assert initialized_repo.is_valid() is False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---- Repository Initialization Operations -------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestRepositoryInitialization:
|
|
199
|
+
"""Tests for repository init() method."""
|
|
200
|
+
|
|
201
|
+
def test_init_creates_directory_structure(self, temp_repo_dir: Path) -> None:
|
|
202
|
+
"""Test init creates required directories."""
|
|
203
|
+
repo = Repository(temp_repo_dir)
|
|
204
|
+
repo.init(project_name="Test")
|
|
205
|
+
|
|
206
|
+
assert repo.gitmap_dir.is_dir()
|
|
207
|
+
assert repo.heads_dir.is_dir()
|
|
208
|
+
assert (repo.remotes_dir / "origin").is_dir()
|
|
209
|
+
assert repo.commits_dir.is_dir()
|
|
210
|
+
|
|
211
|
+
def test_init_creates_config_file(self, temp_repo_dir: Path) -> None:
|
|
212
|
+
"""Test init creates config.json."""
|
|
213
|
+
repo = Repository(temp_repo_dir)
|
|
214
|
+
repo.init(project_name="MyProject", user_name="John", user_email="john@test.com")
|
|
215
|
+
|
|
216
|
+
assert repo.config_path.exists()
|
|
217
|
+
config = RepoConfig.load(repo.config_path)
|
|
218
|
+
assert config.project_name == "MyProject"
|
|
219
|
+
assert config.user_name == "John"
|
|
220
|
+
assert config.user_email == "john@test.com"
|
|
221
|
+
|
|
222
|
+
def test_init_creates_head_file(self, temp_repo_dir: Path) -> None:
|
|
223
|
+
"""Test init creates HEAD pointing to main."""
|
|
224
|
+
repo = Repository(temp_repo_dir)
|
|
225
|
+
repo.init()
|
|
226
|
+
|
|
227
|
+
assert repo.head_path.exists()
|
|
228
|
+
content = repo.head_path.read_text()
|
|
229
|
+
assert content == "ref: refs/heads/main"
|
|
230
|
+
|
|
231
|
+
def test_init_creates_empty_index(self, temp_repo_dir: Path) -> None:
|
|
232
|
+
"""Test init creates empty index.json."""
|
|
233
|
+
repo = Repository(temp_repo_dir)
|
|
234
|
+
repo.init()
|
|
235
|
+
|
|
236
|
+
assert repo.index_path.exists()
|
|
237
|
+
index = json.loads(repo.index_path.read_text())
|
|
238
|
+
assert index == {}
|
|
239
|
+
|
|
240
|
+
def test_init_creates_main_branch(self, temp_repo_dir: Path) -> None:
|
|
241
|
+
"""Test init creates main branch file."""
|
|
242
|
+
repo = Repository(temp_repo_dir)
|
|
243
|
+
repo.init()
|
|
244
|
+
|
|
245
|
+
main_branch = repo.heads_dir / "main"
|
|
246
|
+
assert main_branch.exists()
|
|
247
|
+
assert main_branch.read_text() == ""
|
|
248
|
+
|
|
249
|
+
def test_init_creates_context_db(self, temp_repo_dir: Path) -> None:
|
|
250
|
+
"""Test init creates context database."""
|
|
251
|
+
repo = Repository(temp_repo_dir)
|
|
252
|
+
repo.init()
|
|
253
|
+
|
|
254
|
+
assert repo.context_db_path.exists()
|
|
255
|
+
|
|
256
|
+
def test_init_uses_directory_name_as_default_project_name(
|
|
257
|
+
self, temp_repo_dir: Path
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Test init uses directory name when project_name not provided."""
|
|
260
|
+
repo = Repository(temp_repo_dir)
|
|
261
|
+
repo.init()
|
|
262
|
+
|
|
263
|
+
config = repo.get_config()
|
|
264
|
+
assert config.project_name == temp_repo_dir.name
|
|
265
|
+
|
|
266
|
+
def test_init_raises_if_already_exists(self, initialized_repo: Repository) -> None:
|
|
267
|
+
"""Test init raises error if repo already exists."""
|
|
268
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
269
|
+
initialized_repo.init()
|
|
270
|
+
|
|
271
|
+
assert "already exists" in str(exc_info.value)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---- HEAD Operations Tests ----------------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class TestHeadOperations:
|
|
278
|
+
"""Tests for HEAD-related operations."""
|
|
279
|
+
|
|
280
|
+
def test_get_current_branch(self, initialized_repo: Repository) -> None:
|
|
281
|
+
"""Test getting current branch name."""
|
|
282
|
+
branch = initialized_repo.get_current_branch()
|
|
283
|
+
|
|
284
|
+
assert branch == "main"
|
|
285
|
+
|
|
286
|
+
def test_get_current_branch_returns_none_for_detached(
|
|
287
|
+
self, initialized_repo: Repository
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Test returns None for detached HEAD."""
|
|
290
|
+
# Simulate detached HEAD by writing commit ID directly
|
|
291
|
+
initialized_repo.head_path.write_text("abc123")
|
|
292
|
+
|
|
293
|
+
assert initialized_repo.get_current_branch() is None
|
|
294
|
+
|
|
295
|
+
def test_get_current_branch_returns_none_if_no_head(
|
|
296
|
+
self, temp_repo_dir: Path
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Test returns None if HEAD file doesn't exist."""
|
|
299
|
+
repo = Repository(temp_repo_dir)
|
|
300
|
+
|
|
301
|
+
assert repo.get_current_branch() is None
|
|
302
|
+
|
|
303
|
+
def test_get_head_commit_with_branch(
|
|
304
|
+
self, repo_with_commit: Repository
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Test getting HEAD commit when on branch."""
|
|
307
|
+
commit_id = repo_with_commit.get_head_commit()
|
|
308
|
+
|
|
309
|
+
assert commit_id is not None
|
|
310
|
+
assert len(commit_id) == 12 # Short hash
|
|
311
|
+
|
|
312
|
+
def test_get_head_commit_detached(
|
|
313
|
+
self, initialized_repo: Repository
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Test getting HEAD commit when detached."""
|
|
316
|
+
initialized_repo.head_path.write_text("abc123456789")
|
|
317
|
+
|
|
318
|
+
commit_id = initialized_repo.get_head_commit()
|
|
319
|
+
|
|
320
|
+
assert commit_id == "abc123456789"
|
|
321
|
+
|
|
322
|
+
def test_get_head_commit_returns_none_for_empty_branch(
|
|
323
|
+
self, initialized_repo: Repository
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Test returns None for branch with no commits."""
|
|
326
|
+
commit_id = initialized_repo.get_head_commit()
|
|
327
|
+
|
|
328
|
+
assert commit_id is None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---- Branch Operations Tests --------------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class TestBranchOperations:
|
|
335
|
+
"""Tests for branch operations."""
|
|
336
|
+
|
|
337
|
+
def test_list_branches(self, initialized_repo: Repository) -> None:
|
|
338
|
+
"""Test listing branches."""
|
|
339
|
+
branches = initialized_repo.list_branches()
|
|
340
|
+
|
|
341
|
+
assert "main" in branches
|
|
342
|
+
|
|
343
|
+
def test_list_branches_empty_when_no_heads(self, temp_repo_dir: Path) -> None:
|
|
344
|
+
"""Test empty list when no heads directory."""
|
|
345
|
+
repo = Repository(temp_repo_dir)
|
|
346
|
+
|
|
347
|
+
branches = repo.list_branches()
|
|
348
|
+
|
|
349
|
+
assert branches == []
|
|
350
|
+
|
|
351
|
+
def test_get_branch_commit(self, repo_with_commit: Repository) -> None:
|
|
352
|
+
"""Test getting branch commit ID."""
|
|
353
|
+
commit_id = repo_with_commit.get_branch_commit("main")
|
|
354
|
+
|
|
355
|
+
assert commit_id is not None
|
|
356
|
+
assert len(commit_id) == 12
|
|
357
|
+
|
|
358
|
+
def test_get_branch_commit_returns_none_for_nonexistent(
|
|
359
|
+
self, initialized_repo: Repository
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Test returns None for nonexistent branch."""
|
|
362
|
+
commit_id = initialized_repo.get_branch_commit("nonexistent")
|
|
363
|
+
|
|
364
|
+
assert commit_id is None
|
|
365
|
+
|
|
366
|
+
def test_get_branch_commit_returns_none_for_empty_branch(
|
|
367
|
+
self, initialized_repo: Repository
|
|
368
|
+
) -> None:
|
|
369
|
+
"""Test returns None for branch with no commits."""
|
|
370
|
+
commit_id = initialized_repo.get_branch_commit("main")
|
|
371
|
+
|
|
372
|
+
assert commit_id is None
|
|
373
|
+
|
|
374
|
+
def test_create_branch(self, repo_with_commit: Repository) -> None:
|
|
375
|
+
"""Test creating a new branch."""
|
|
376
|
+
head_commit = repo_with_commit.get_head_commit()
|
|
377
|
+
|
|
378
|
+
branch = repo_with_commit.create_branch("feature/test")
|
|
379
|
+
|
|
380
|
+
assert branch.name == "feature/test"
|
|
381
|
+
assert branch.commit_id == head_commit
|
|
382
|
+
assert (repo_with_commit.heads_dir / "feature" / "test").exists()
|
|
383
|
+
|
|
384
|
+
def test_create_branch_with_specific_commit(
|
|
385
|
+
self, repo_with_commit: Repository
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Test creating branch at specific commit."""
|
|
388
|
+
branch = repo_with_commit.create_branch("feature/test", commit_id="custom123")
|
|
389
|
+
|
|
390
|
+
assert branch.commit_id == "custom123"
|
|
391
|
+
|
|
392
|
+
def test_create_branch_raises_if_exists(
|
|
393
|
+
self, initialized_repo: Repository
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Test create_branch raises error if branch exists."""
|
|
396
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
397
|
+
initialized_repo.create_branch("main")
|
|
398
|
+
|
|
399
|
+
assert "already exists" in str(exc_info.value)
|
|
400
|
+
|
|
401
|
+
def test_update_branch(self, repo_with_commit: Repository) -> None:
|
|
402
|
+
"""Test updating branch to new commit."""
|
|
403
|
+
repo_with_commit.update_branch("main", "newcommit123")
|
|
404
|
+
|
|
405
|
+
commit_id = repo_with_commit.get_branch_commit("main")
|
|
406
|
+
assert commit_id == "newcommit123"
|
|
407
|
+
|
|
408
|
+
def test_update_branch_raises_if_not_exists(
|
|
409
|
+
self, initialized_repo: Repository
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Test update_branch raises error if branch doesn't exist."""
|
|
412
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
413
|
+
initialized_repo.update_branch("nonexistent", "abc123")
|
|
414
|
+
|
|
415
|
+
assert "does not exist" in str(exc_info.value)
|
|
416
|
+
|
|
417
|
+
def test_delete_branch(self, repo_with_commit: Repository) -> None:
|
|
418
|
+
"""Test deleting a branch."""
|
|
419
|
+
repo_with_commit.create_branch("to-delete")
|
|
420
|
+
|
|
421
|
+
repo_with_commit.delete_branch("to-delete")
|
|
422
|
+
|
|
423
|
+
assert "to-delete" not in repo_with_commit.list_branches()
|
|
424
|
+
|
|
425
|
+
def test_delete_branch_raises_if_current(
|
|
426
|
+
self, initialized_repo: Repository
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Test delete_branch raises error for current branch."""
|
|
429
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
430
|
+
initialized_repo.delete_branch("main")
|
|
431
|
+
|
|
432
|
+
assert "Cannot delete current branch" in str(exc_info.value)
|
|
433
|
+
|
|
434
|
+
def test_delete_branch_raises_if_not_exists(
|
|
435
|
+
self, initialized_repo: Repository
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Test delete_branch raises error if branch doesn't exist."""
|
|
438
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
439
|
+
initialized_repo.delete_branch("nonexistent")
|
|
440
|
+
|
|
441
|
+
assert "does not exist" in str(exc_info.value)
|
|
442
|
+
|
|
443
|
+
def test_checkout_branch(self, repo_with_commit: Repository) -> None:
|
|
444
|
+
"""Test checking out a branch."""
|
|
445
|
+
repo_with_commit.create_branch("feature/test")
|
|
446
|
+
|
|
447
|
+
repo_with_commit.checkout_branch("feature/test")
|
|
448
|
+
|
|
449
|
+
assert repo_with_commit.get_current_branch() == "feature/test"
|
|
450
|
+
|
|
451
|
+
def test_checkout_branch_loads_commit_to_index(
|
|
452
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
453
|
+
) -> None:
|
|
454
|
+
"""Test checkout loads branch commit state to index."""
|
|
455
|
+
# Modify index
|
|
456
|
+
repo_with_commit.update_index({"modified": True})
|
|
457
|
+
|
|
458
|
+
# Create and checkout new branch
|
|
459
|
+
repo_with_commit.create_branch("feature/test")
|
|
460
|
+
repo_with_commit.checkout_branch("feature/test")
|
|
461
|
+
|
|
462
|
+
# Index should have feature branch state (same as main since just created)
|
|
463
|
+
index = repo_with_commit.get_index()
|
|
464
|
+
assert index == sample_map_data
|
|
465
|
+
|
|
466
|
+
def test_checkout_branch_clears_index_for_empty_branch(
|
|
467
|
+
self, initialized_repo: Repository
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Test checkout clears index for branch with no commits."""
|
|
470
|
+
initialized_repo.update_index({"some": "data"})
|
|
471
|
+
initialized_repo.create_branch("empty-branch")
|
|
472
|
+
|
|
473
|
+
initialized_repo.checkout_branch("empty-branch")
|
|
474
|
+
|
|
475
|
+
index = initialized_repo.get_index()
|
|
476
|
+
assert index == {}
|
|
477
|
+
|
|
478
|
+
def test_checkout_branch_raises_if_not_exists(
|
|
479
|
+
self, initialized_repo: Repository
|
|
480
|
+
) -> None:
|
|
481
|
+
"""Test checkout_branch raises error for nonexistent branch."""
|
|
482
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
483
|
+
initialized_repo.checkout_branch("nonexistent")
|
|
484
|
+
|
|
485
|
+
assert "does not exist" in str(exc_info.value)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ---- Index Operations Tests ---------------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class TestIndexOperations:
|
|
492
|
+
"""Tests for index/staging area operations."""
|
|
493
|
+
|
|
494
|
+
def test_get_index_returns_empty_dict_initially(
|
|
495
|
+
self, initialized_repo: Repository
|
|
496
|
+
) -> None:
|
|
497
|
+
"""Test get_index returns empty dict after init."""
|
|
498
|
+
index = initialized_repo.get_index()
|
|
499
|
+
|
|
500
|
+
assert index == {}
|
|
501
|
+
|
|
502
|
+
def test_get_index_returns_empty_when_no_file(
|
|
503
|
+
self, temp_repo_dir: Path
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Test get_index returns empty dict when file doesn't exist."""
|
|
506
|
+
repo = Repository(temp_repo_dir)
|
|
507
|
+
|
|
508
|
+
index = repo.get_index()
|
|
509
|
+
|
|
510
|
+
assert index == {}
|
|
511
|
+
|
|
512
|
+
def test_get_index_handles_invalid_json(
|
|
513
|
+
self, initialized_repo: Repository
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Test get_index handles invalid JSON gracefully."""
|
|
516
|
+
initialized_repo.index_path.write_text("not valid json")
|
|
517
|
+
|
|
518
|
+
index = initialized_repo.get_index()
|
|
519
|
+
|
|
520
|
+
assert index == {}
|
|
521
|
+
|
|
522
|
+
def test_update_index(
|
|
523
|
+
self, initialized_repo: Repository, sample_map_data: dict
|
|
524
|
+
) -> None:
|
|
525
|
+
"""Test updating index with new map data."""
|
|
526
|
+
initialized_repo.update_index(sample_map_data)
|
|
527
|
+
|
|
528
|
+
index = initialized_repo.get_index()
|
|
529
|
+
assert index == sample_map_data
|
|
530
|
+
|
|
531
|
+
def test_update_index_overwrites_previous(
|
|
532
|
+
self, initialized_repo: Repository
|
|
533
|
+
) -> None:
|
|
534
|
+
"""Test update_index replaces previous content."""
|
|
535
|
+
initialized_repo.update_index({"first": "data"})
|
|
536
|
+
initialized_repo.update_index({"second": "data"})
|
|
537
|
+
|
|
538
|
+
index = initialized_repo.get_index()
|
|
539
|
+
assert index == {"second": "data"}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ---- Commit Operations Tests --------------------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class TestCommitOperations:
|
|
546
|
+
"""Tests for commit operations."""
|
|
547
|
+
|
|
548
|
+
def test_create_commit(
|
|
549
|
+
self, initialized_repo: Repository, sample_map_data: dict
|
|
550
|
+
) -> None:
|
|
551
|
+
"""Test creating a commit."""
|
|
552
|
+
initialized_repo.update_index(sample_map_data)
|
|
553
|
+
|
|
554
|
+
commit = initialized_repo.create_commit("Test commit", author="Tester")
|
|
555
|
+
|
|
556
|
+
assert commit is not None
|
|
557
|
+
assert commit.message == "Test commit"
|
|
558
|
+
assert commit.author == "Tester"
|
|
559
|
+
assert len(commit.id) == 12
|
|
560
|
+
|
|
561
|
+
def test_create_commit_uses_config_author(
|
|
562
|
+
self, initialized_repo: Repository, sample_map_data: dict
|
|
563
|
+
) -> None:
|
|
564
|
+
"""Test commit uses config author when not specified."""
|
|
565
|
+
initialized_repo.update_index(sample_map_data)
|
|
566
|
+
|
|
567
|
+
commit = initialized_repo.create_commit("Test commit")
|
|
568
|
+
|
|
569
|
+
assert commit.author == "Test User"
|
|
570
|
+
|
|
571
|
+
def test_create_commit_updates_branch(
|
|
572
|
+
self, initialized_repo: Repository, sample_map_data: dict
|
|
573
|
+
) -> None:
|
|
574
|
+
"""Test commit updates current branch."""
|
|
575
|
+
initialized_repo.update_index(sample_map_data)
|
|
576
|
+
|
|
577
|
+
commit = initialized_repo.create_commit("Test commit")
|
|
578
|
+
|
|
579
|
+
branch_commit = initialized_repo.get_branch_commit("main")
|
|
580
|
+
assert branch_commit == commit.id
|
|
581
|
+
|
|
582
|
+
def test_create_commit_with_parent(
|
|
583
|
+
self, repo_with_commit: Repository
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Test commit has parent when previous commits exist."""
|
|
586
|
+
first_commit = repo_with_commit.get_head_commit()
|
|
587
|
+
repo_with_commit.update_index({"new": "data"})
|
|
588
|
+
|
|
589
|
+
commit = repo_with_commit.create_commit("Second commit")
|
|
590
|
+
|
|
591
|
+
assert commit.parent == first_commit
|
|
592
|
+
|
|
593
|
+
def test_create_commit_saves_to_objects(
|
|
594
|
+
self, initialized_repo: Repository, sample_map_data: dict
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Test commit is saved to objects directory."""
|
|
597
|
+
initialized_repo.update_index(sample_map_data)
|
|
598
|
+
|
|
599
|
+
commit = initialized_repo.create_commit("Test commit")
|
|
600
|
+
|
|
601
|
+
commit_path = initialized_repo.commits_dir / f"{commit.id}.json"
|
|
602
|
+
assert commit_path.exists()
|
|
603
|
+
|
|
604
|
+
def test_create_commit_with_rationale(
|
|
605
|
+
self, initialized_repo: Repository, sample_map_data: dict
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Test commit with rationale parameter."""
|
|
608
|
+
initialized_repo.update_index(sample_map_data)
|
|
609
|
+
|
|
610
|
+
# Should not raise - rationale is recorded in context store
|
|
611
|
+
commit = initialized_repo.create_commit(
|
|
612
|
+
"Test commit",
|
|
613
|
+
rationale="This explains why we made this change"
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
assert commit is not None
|
|
617
|
+
|
|
618
|
+
def test_get_commit(self, repo_with_commit: Repository) -> None:
|
|
619
|
+
"""Test getting a commit by ID."""
|
|
620
|
+
commit_id = repo_with_commit.get_head_commit()
|
|
621
|
+
|
|
622
|
+
commit = repo_with_commit.get_commit(commit_id)
|
|
623
|
+
|
|
624
|
+
assert commit is not None
|
|
625
|
+
assert commit.id == commit_id
|
|
626
|
+
|
|
627
|
+
def test_get_commit_returns_none_for_nonexistent(
|
|
628
|
+
self, initialized_repo: Repository
|
|
629
|
+
) -> None:
|
|
630
|
+
"""Test get_commit returns None for nonexistent commit."""
|
|
631
|
+
commit = initialized_repo.get_commit("nonexistent123")
|
|
632
|
+
|
|
633
|
+
assert commit is None
|
|
634
|
+
|
|
635
|
+
def test_get_commit_history(self, repo_with_commit: Repository) -> None:
|
|
636
|
+
"""Test getting commit history."""
|
|
637
|
+
repo_with_commit.update_index({"second": "data"})
|
|
638
|
+
repo_with_commit.create_commit("Second commit")
|
|
639
|
+
|
|
640
|
+
history = repo_with_commit.get_commit_history()
|
|
641
|
+
|
|
642
|
+
assert len(history) == 2
|
|
643
|
+
assert history[0].message == "Second commit"
|
|
644
|
+
assert history[1].message == "Initial commit"
|
|
645
|
+
|
|
646
|
+
def test_get_commit_history_with_limit(
|
|
647
|
+
self, repo_with_commit: Repository
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Test commit history respects limit."""
|
|
650
|
+
for i in range(5):
|
|
651
|
+
repo_with_commit.update_index({"num": i})
|
|
652
|
+
repo_with_commit.create_commit(f"Commit {i}")
|
|
653
|
+
|
|
654
|
+
history = repo_with_commit.get_commit_history(limit=3)
|
|
655
|
+
|
|
656
|
+
assert len(history) == 3
|
|
657
|
+
|
|
658
|
+
def test_get_commit_history_from_specific_commit(
|
|
659
|
+
self, repo_with_commit: Repository
|
|
660
|
+
) -> None:
|
|
661
|
+
"""Test history starting from specific commit."""
|
|
662
|
+
first_id = repo_with_commit.get_head_commit()
|
|
663
|
+
repo_with_commit.update_index({"second": "data"})
|
|
664
|
+
repo_with_commit.create_commit("Second commit")
|
|
665
|
+
|
|
666
|
+
history = repo_with_commit.get_commit_history(start_commit=first_id)
|
|
667
|
+
|
|
668
|
+
assert len(history) == 1
|
|
669
|
+
assert history[0].id == first_id
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# ---- Config Operations Tests --------------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
class TestConfigOperations:
|
|
676
|
+
"""Tests for config operations."""
|
|
677
|
+
|
|
678
|
+
def test_get_config(self, initialized_repo: Repository) -> None:
|
|
679
|
+
"""Test getting repository config."""
|
|
680
|
+
config = initialized_repo.get_config()
|
|
681
|
+
|
|
682
|
+
assert config.project_name == "TestProject"
|
|
683
|
+
assert config.user_name == "Test User"
|
|
684
|
+
|
|
685
|
+
def test_get_config_raises_when_missing(self, temp_repo_dir: Path) -> None:
|
|
686
|
+
"""Test get_config raises error when config doesn't exist."""
|
|
687
|
+
repo = Repository(temp_repo_dir)
|
|
688
|
+
|
|
689
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
690
|
+
repo.get_config()
|
|
691
|
+
|
|
692
|
+
assert "not found" in str(exc_info.value)
|
|
693
|
+
|
|
694
|
+
def test_update_config(self, initialized_repo: Repository) -> None:
|
|
695
|
+
"""Test updating repository config."""
|
|
696
|
+
config = initialized_repo.get_config()
|
|
697
|
+
config.user_name = "New Name"
|
|
698
|
+
|
|
699
|
+
initialized_repo.update_config(config)
|
|
700
|
+
|
|
701
|
+
loaded = initialized_repo.get_config()
|
|
702
|
+
assert loaded.user_name == "New Name"
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# ---- Status Operations Tests --------------------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class TestStatusOperations:
|
|
709
|
+
"""Tests for status-related operations."""
|
|
710
|
+
|
|
711
|
+
def test_has_uncommitted_changes_true_with_new_data(
|
|
712
|
+
self, repo_with_commit: Repository
|
|
713
|
+
) -> None:
|
|
714
|
+
"""Test detects uncommitted changes."""
|
|
715
|
+
repo_with_commit.update_index({"new": "changes"})
|
|
716
|
+
|
|
717
|
+
assert repo_with_commit.has_uncommitted_changes() is True
|
|
718
|
+
|
|
719
|
+
def test_has_uncommitted_changes_false_when_clean(
|
|
720
|
+
self, repo_with_commit: Repository
|
|
721
|
+
) -> None:
|
|
722
|
+
"""Test returns False when no changes."""
|
|
723
|
+
assert repo_with_commit.has_uncommitted_changes() is False
|
|
724
|
+
|
|
725
|
+
def test_has_uncommitted_changes_true_when_no_commits(
|
|
726
|
+
self, initialized_repo: Repository
|
|
727
|
+
) -> None:
|
|
728
|
+
"""Test returns True when index has data but no commits."""
|
|
729
|
+
initialized_repo.update_index({"some": "data"})
|
|
730
|
+
|
|
731
|
+
assert initialized_repo.has_uncommitted_changes() is True
|
|
732
|
+
|
|
733
|
+
def test_has_uncommitted_changes_false_when_empty_no_commits(
|
|
734
|
+
self, initialized_repo: Repository
|
|
735
|
+
) -> None:
|
|
736
|
+
"""Test returns False when empty index and no commits."""
|
|
737
|
+
assert initialized_repo.has_uncommitted_changes() is False
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ---- Context Store Tests ------------------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class TestContextStore:
|
|
744
|
+
"""Tests for context store integration."""
|
|
745
|
+
|
|
746
|
+
def test_get_context_store(self, initialized_repo: Repository) -> None:
|
|
747
|
+
"""Test getting context store."""
|
|
748
|
+
store = initialized_repo.get_context_store()
|
|
749
|
+
|
|
750
|
+
assert store is not None
|
|
751
|
+
store.close()
|
|
752
|
+
|
|
753
|
+
def test_regenerate_context_graph(self, repo_with_commit: Repository) -> None:
|
|
754
|
+
"""Test regenerating context graph."""
|
|
755
|
+
result = repo_with_commit.regenerate_context_graph()
|
|
756
|
+
|
|
757
|
+
# Should return path or None depending on implementation
|
|
758
|
+
# The method catches exceptions silently
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# ---- Module Functions Tests ---------------------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class TestFindRepository:
|
|
765
|
+
"""Tests for find_repository function."""
|
|
766
|
+
|
|
767
|
+
def test_find_repository_in_current_dir(
|
|
768
|
+
self, initialized_repo: Repository
|
|
769
|
+
) -> None:
|
|
770
|
+
"""Test finding repo in current directory."""
|
|
771
|
+
repo = find_repository(initialized_repo.root)
|
|
772
|
+
|
|
773
|
+
assert repo is not None
|
|
774
|
+
assert repo.root == initialized_repo.root
|
|
775
|
+
|
|
776
|
+
def test_find_repository_in_parent(
|
|
777
|
+
self, initialized_repo: Repository
|
|
778
|
+
) -> None:
|
|
779
|
+
"""Test finding repo in parent directory."""
|
|
780
|
+
child_dir = initialized_repo.root / "child"
|
|
781
|
+
child_dir.mkdir()
|
|
782
|
+
|
|
783
|
+
repo = find_repository(child_dir)
|
|
784
|
+
|
|
785
|
+
assert repo is not None
|
|
786
|
+
assert repo.root == initialized_repo.root
|
|
787
|
+
|
|
788
|
+
def test_find_repository_returns_none_when_not_found(
|
|
789
|
+
self, temp_repo_dir: Path
|
|
790
|
+
) -> None:
|
|
791
|
+
"""Test returns None when no repo found."""
|
|
792
|
+
repo = find_repository(temp_repo_dir)
|
|
793
|
+
|
|
794
|
+
assert repo is None
|
|
795
|
+
|
|
796
|
+
def test_find_repository_defaults_to_cwd(self) -> None:
|
|
797
|
+
"""Test uses cwd when no path provided."""
|
|
798
|
+
# Just verify it doesn't raise
|
|
799
|
+
result = find_repository()
|
|
800
|
+
# Result depends on whether we're in a repo
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
class TestInitRepository:
|
|
804
|
+
"""Tests for init_repository function."""
|
|
805
|
+
|
|
806
|
+
def test_init_repository_creates_repo(self, temp_repo_dir: Path) -> None:
|
|
807
|
+
"""Test init_repository creates and initializes repo."""
|
|
808
|
+
repo = init_repository(
|
|
809
|
+
path=temp_repo_dir,
|
|
810
|
+
project_name="TestProject",
|
|
811
|
+
user_name="Test User",
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
assert repo.exists()
|
|
815
|
+
assert repo.is_valid()
|
|
816
|
+
|
|
817
|
+
def test_init_repository_defaults_to_cwd(self) -> None:
|
|
818
|
+
"""Test init_repository uses cwd when no path."""
|
|
819
|
+
# Just verify the function signature works
|
|
820
|
+
# Don't actually run as it would create repo in cwd
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# ---- Generate Commit ID Tests -------------------------------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
class TestGenerateCommitId:
|
|
827
|
+
"""Tests for commit ID generation."""
|
|
828
|
+
|
|
829
|
+
def test_generate_commit_id_is_deterministic(
|
|
830
|
+
self, initialized_repo: Repository, sample_map_data: dict
|
|
831
|
+
) -> None:
|
|
832
|
+
"""Test same content produces same ID."""
|
|
833
|
+
id1 = initialized_repo._generate_commit_id("msg", sample_map_data, None)
|
|
834
|
+
id2 = initialized_repo._generate_commit_id("msg", sample_map_data, None)
|
|
835
|
+
|
|
836
|
+
assert id1 == id2
|
|
837
|
+
|
|
838
|
+
def test_generate_commit_id_different_for_different_content(
|
|
839
|
+
self, initialized_repo: Repository
|
|
840
|
+
) -> None:
|
|
841
|
+
"""Test different content produces different ID."""
|
|
842
|
+
id1 = initialized_repo._generate_commit_id("msg", {"a": 1}, None)
|
|
843
|
+
id2 = initialized_repo._generate_commit_id("msg", {"b": 2}, None)
|
|
844
|
+
|
|
845
|
+
assert id1 != id2
|
|
846
|
+
|
|
847
|
+
def test_generate_commit_id_length(
|
|
848
|
+
self, initialized_repo: Repository
|
|
849
|
+
) -> None:
|
|
850
|
+
"""Test commit ID is 12 characters."""
|
|
851
|
+
commit_id = initialized_repo._generate_commit_id("msg", {}, None)
|
|
852
|
+
|
|
853
|
+
assert len(commit_id) == 12
|
|
854
|
+
|
|
855
|
+
def test_generate_commit_id_includes_parent(
|
|
856
|
+
self, initialized_repo: Repository
|
|
857
|
+
) -> None:
|
|
858
|
+
"""Test parent affects commit ID."""
|
|
859
|
+
id1 = initialized_repo._generate_commit_id("msg", {}, None)
|
|
860
|
+
id2 = initialized_repo._generate_commit_id("msg", {}, "parent123")
|
|
861
|
+
|
|
862
|
+
assert id1 != id2
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# ---- Revert Tests -------------------------------------------------------------------------------------------
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
class TestRevert:
|
|
869
|
+
"""Tests for commit revert operations."""
|
|
870
|
+
|
|
871
|
+
def test_revert_commit_not_found(self, initialized_repo: Repository) -> None:
|
|
872
|
+
"""Test revert raises error when commit not found."""
|
|
873
|
+
with pytest.raises(RuntimeError, match="not found"):
|
|
874
|
+
initialized_repo.revert("nonexistent")
|
|
875
|
+
|
|
876
|
+
def test_revert_creates_new_commit(
|
|
877
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
878
|
+
) -> None:
|
|
879
|
+
"""Test revert creates a new commit."""
|
|
880
|
+
# Create a second commit with changes
|
|
881
|
+
modified_data = sample_map_data.copy()
|
|
882
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New Layer"})
|
|
883
|
+
repo_with_commit.update_index(modified_data)
|
|
884
|
+
second_commit = repo_with_commit.create_commit("Add new layer")
|
|
885
|
+
|
|
886
|
+
# Revert the second commit
|
|
887
|
+
revert_commit = repo_with_commit.revert(second_commit.id)
|
|
888
|
+
|
|
889
|
+
assert revert_commit is not None
|
|
890
|
+
assert revert_commit.id != second_commit.id
|
|
891
|
+
assert "Revert" in revert_commit.message
|
|
892
|
+
assert second_commit.id[:8] in revert_commit.message
|
|
893
|
+
|
|
894
|
+
def test_revert_restores_layer_removal(
|
|
895
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
896
|
+
) -> None:
|
|
897
|
+
"""Test revert restores a removed layer."""
|
|
898
|
+
# Create commit that removes a layer
|
|
899
|
+
modified_data = sample_map_data.copy()
|
|
900
|
+
modified_data["operationalLayers"] = [{"id": "layer-1", "title": "Roads"}]
|
|
901
|
+
repo_with_commit.update_index(modified_data)
|
|
902
|
+
removal_commit = repo_with_commit.create_commit("Remove layer-2")
|
|
903
|
+
|
|
904
|
+
# Revert the removal
|
|
905
|
+
revert_commit = repo_with_commit.revert(removal_commit.id)
|
|
906
|
+
|
|
907
|
+
# Check that layer-2 is back
|
|
908
|
+
layers = revert_commit.map_data.get("operationalLayers", [])
|
|
909
|
+
layer_ids = [l.get("id") for l in layers]
|
|
910
|
+
assert "layer-2" in layer_ids
|
|
911
|
+
|
|
912
|
+
def test_revert_removes_added_layer(
|
|
913
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
914
|
+
) -> None:
|
|
915
|
+
"""Test revert removes an added layer."""
|
|
916
|
+
# Create commit that adds a layer
|
|
917
|
+
modified_data = sample_map_data.copy()
|
|
918
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
919
|
+
repo_with_commit.update_index(modified_data)
|
|
920
|
+
addition_commit = repo_with_commit.create_commit("Add layer-3")
|
|
921
|
+
|
|
922
|
+
# Revert the addition
|
|
923
|
+
revert_commit = repo_with_commit.revert(addition_commit.id)
|
|
924
|
+
|
|
925
|
+
# Check that layer-3 is gone
|
|
926
|
+
layers = revert_commit.map_data.get("operationalLayers", [])
|
|
927
|
+
layer_ids = [l.get("id") for l in layers]
|
|
928
|
+
assert "layer-3" not in layer_ids
|
|
929
|
+
assert "layer-1" in layer_ids
|
|
930
|
+
assert "layer-2" in layer_ids
|
|
931
|
+
|
|
932
|
+
def test_revert_restores_modified_layer(
|
|
933
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
934
|
+
) -> None:
|
|
935
|
+
"""Test revert restores a modified layer to original state."""
|
|
936
|
+
# Create commit that modifies a layer
|
|
937
|
+
modified_data = sample_map_data.copy()
|
|
938
|
+
modified_data["operationalLayers"][0]["title"] = "Modified Roads"
|
|
939
|
+
repo_with_commit.update_index(modified_data)
|
|
940
|
+
modification_commit = repo_with_commit.create_commit("Modify layer-1")
|
|
941
|
+
|
|
942
|
+
# Revert the modification
|
|
943
|
+
revert_commit = repo_with_commit.revert(modification_commit.id)
|
|
944
|
+
|
|
945
|
+
# Check that layer-1 has original title
|
|
946
|
+
layers = revert_commit.map_data.get("operationalLayers", [])
|
|
947
|
+
layer_1 = next((l for l in layers if l.get("id") == "layer-1"), None)
|
|
948
|
+
assert layer_1 is not None
|
|
949
|
+
assert layer_1["title"] == "Roads"
|
|
950
|
+
|
|
951
|
+
def test_revert_with_rationale(
|
|
952
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
953
|
+
) -> None:
|
|
954
|
+
"""Test revert accepts rationale parameter."""
|
|
955
|
+
modified_data = sample_map_data.copy()
|
|
956
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
957
|
+
repo_with_commit.update_index(modified_data)
|
|
958
|
+
commit = repo_with_commit.create_commit("Add layer")
|
|
959
|
+
|
|
960
|
+
revert_commit = repo_with_commit.revert(
|
|
961
|
+
commit.id,
|
|
962
|
+
rationale="Reverting because layer was added by mistake",
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
assert revert_commit is not None
|
|
966
|
+
|
|
967
|
+
def test_revert_updates_branch(
|
|
968
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
969
|
+
) -> None:
|
|
970
|
+
"""Test revert updates the current branch."""
|
|
971
|
+
modified_data = sample_map_data.copy()
|
|
972
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
973
|
+
repo_with_commit.update_index(modified_data)
|
|
974
|
+
commit = repo_with_commit.create_commit("Add layer")
|
|
975
|
+
|
|
976
|
+
revert_commit = repo_with_commit.revert(commit.id)
|
|
977
|
+
|
|
978
|
+
# Branch should point to revert commit
|
|
979
|
+
branch_commit = repo_with_commit.get_branch_commit("main")
|
|
980
|
+
assert branch_commit == revert_commit.id
|
|
981
|
+
|
|
982
|
+
def test_revert_initial_commit(self, repo_with_commit: Repository) -> None:
|
|
983
|
+
"""Test reverting the initial commit."""
|
|
984
|
+
# Get the initial commit
|
|
985
|
+
history = repo_with_commit.get_commit_history()
|
|
986
|
+
initial_commit = history[0]
|
|
987
|
+
|
|
988
|
+
# Revert it
|
|
989
|
+
revert_commit = repo_with_commit.revert(initial_commit.id)
|
|
990
|
+
|
|
991
|
+
# All layers should be removed (back to empty state)
|
|
992
|
+
assert revert_commit is not None
|
|
993
|
+
layers = revert_commit.map_data.get("operationalLayers", [])
|
|
994
|
+
assert len(layers) == 0
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
class TestComputeRevert:
|
|
998
|
+
"""Tests for _compute_revert helper method."""
|
|
999
|
+
|
|
1000
|
+
def test_compute_revert_layer_addition(
|
|
1001
|
+
self, initialized_repo: Repository
|
|
1002
|
+
) -> None:
|
|
1003
|
+
"""Test computing revert for layer addition."""
|
|
1004
|
+
parent_data = {"operationalLayers": [{"id": "1", "title": "A"}]}
|
|
1005
|
+
commit_data = {
|
|
1006
|
+
"operationalLayers": [
|
|
1007
|
+
{"id": "1", "title": "A"},
|
|
1008
|
+
{"id": "2", "title": "B"},
|
|
1009
|
+
]
|
|
1010
|
+
}
|
|
1011
|
+
current_data = commit_data.copy()
|
|
1012
|
+
|
|
1013
|
+
result = initialized_repo._compute_revert(
|
|
1014
|
+
current_data, commit_data, parent_data
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
layer_ids = [l["id"] for l in result["operationalLayers"]]
|
|
1018
|
+
assert "1" in layer_ids
|
|
1019
|
+
assert "2" not in layer_ids
|
|
1020
|
+
|
|
1021
|
+
def test_compute_revert_layer_removal(
|
|
1022
|
+
self, initialized_repo: Repository
|
|
1023
|
+
) -> None:
|
|
1024
|
+
"""Test computing revert for layer removal."""
|
|
1025
|
+
parent_data = {
|
|
1026
|
+
"operationalLayers": [
|
|
1027
|
+
{"id": "1", "title": "A"},
|
|
1028
|
+
{"id": "2", "title": "B"},
|
|
1029
|
+
]
|
|
1030
|
+
}
|
|
1031
|
+
commit_data = {"operationalLayers": [{"id": "1", "title": "A"}]}
|
|
1032
|
+
current_data = commit_data.copy()
|
|
1033
|
+
|
|
1034
|
+
result = initialized_repo._compute_revert(
|
|
1035
|
+
current_data, commit_data, parent_data
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
layer_ids = [l["id"] for l in result["operationalLayers"]]
|
|
1039
|
+
assert "1" in layer_ids
|
|
1040
|
+
assert "2" in layer_ids
|
|
1041
|
+
|
|
1042
|
+
def test_compute_revert_layer_modification(
|
|
1043
|
+
self, initialized_repo: Repository
|
|
1044
|
+
) -> None:
|
|
1045
|
+
"""Test computing revert for layer modification."""
|
|
1046
|
+
parent_data = {"operationalLayers": [{"id": "1", "title": "Original"}]}
|
|
1047
|
+
commit_data = {"operationalLayers": [{"id": "1", "title": "Modified"}]}
|
|
1048
|
+
current_data = commit_data.copy()
|
|
1049
|
+
|
|
1050
|
+
result = initialized_repo._compute_revert(
|
|
1051
|
+
current_data, commit_data, parent_data
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
layer = result["operationalLayers"][0]
|
|
1055
|
+
assert layer["title"] == "Original"
|
|
1056
|
+
|
|
1057
|
+
def test_compute_revert_preserves_unrelated_changes(
|
|
1058
|
+
self, initialized_repo: Repository
|
|
1059
|
+
) -> None:
|
|
1060
|
+
"""Test revert preserves changes not from the reverted commit."""
|
|
1061
|
+
parent_data = {"operationalLayers": [{"id": "1", "title": "A"}]}
|
|
1062
|
+
commit_data = {
|
|
1063
|
+
"operationalLayers": [
|
|
1064
|
+
{"id": "1", "title": "A"},
|
|
1065
|
+
{"id": "2", "title": "B"},
|
|
1066
|
+
]
|
|
1067
|
+
}
|
|
1068
|
+
# Current has additional layer-3 that wasn't part of commit
|
|
1069
|
+
current_data = {
|
|
1070
|
+
"operationalLayers": [
|
|
1071
|
+
{"id": "1", "title": "A"},
|
|
1072
|
+
{"id": "2", "title": "B"},
|
|
1073
|
+
{"id": "3", "title": "C"},
|
|
1074
|
+
]
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
result = initialized_repo._compute_revert(
|
|
1078
|
+
current_data, commit_data, parent_data
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
layer_ids = [l["id"] for l in result["operationalLayers"]]
|
|
1082
|
+
assert "1" in layer_ids
|
|
1083
|
+
assert "2" not in layer_ids # Reverted
|
|
1084
|
+
assert "3" in layer_ids # Preserved
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
class TestRevertLayers:
|
|
1088
|
+
"""Tests for _revert_layers helper method."""
|
|
1089
|
+
|
|
1090
|
+
def test_revert_layers_empty(self, initialized_repo: Repository) -> None:
|
|
1091
|
+
"""Test reverting with empty layers."""
|
|
1092
|
+
result = initialized_repo._revert_layers([], [], [])
|
|
1093
|
+
assert result == []
|
|
1094
|
+
|
|
1095
|
+
def test_revert_layers_no_id(self, initialized_repo: Repository) -> None:
|
|
1096
|
+
"""Test layers without id are preserved."""
|
|
1097
|
+
current = [{"title": "No ID"}]
|
|
1098
|
+
result = initialized_repo._revert_layers(current, current, current)
|
|
1099
|
+
assert result == current
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
# ---- Tag Tests ----------------------------------------------------------------------------------------------
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
class TestTags:
|
|
1106
|
+
"""Tests for tag operations."""
|
|
1107
|
+
|
|
1108
|
+
def test_list_tags_empty(self, initialized_repo: Repository) -> None:
|
|
1109
|
+
"""Test listing tags when none exist."""
|
|
1110
|
+
tags = initialized_repo.list_tags()
|
|
1111
|
+
assert tags == []
|
|
1112
|
+
|
|
1113
|
+
def test_create_tag(self, repo_with_commit: Repository) -> None:
|
|
1114
|
+
"""Test creating a tag."""
|
|
1115
|
+
head_commit = repo_with_commit.get_head_commit()
|
|
1116
|
+
commit_id = repo_with_commit.create_tag("v1.0.0")
|
|
1117
|
+
|
|
1118
|
+
assert commit_id == head_commit
|
|
1119
|
+
assert repo_with_commit.tags_dir.exists()
|
|
1120
|
+
assert (repo_with_commit.tags_dir / "v1.0.0").exists()
|
|
1121
|
+
|
|
1122
|
+
def test_create_tag_with_specific_commit(
|
|
1123
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1124
|
+
) -> None:
|
|
1125
|
+
"""Test creating a tag pointing to specific commit."""
|
|
1126
|
+
# Create a second commit
|
|
1127
|
+
modified_data = sample_map_data.copy()
|
|
1128
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
1129
|
+
repo_with_commit.update_index(modified_data)
|
|
1130
|
+
second_commit = repo_with_commit.create_commit("Second commit")
|
|
1131
|
+
|
|
1132
|
+
# Get first commit
|
|
1133
|
+
first_commit = repo_with_commit.get_commit(second_commit.parent)
|
|
1134
|
+
|
|
1135
|
+
# Tag the first commit
|
|
1136
|
+
commit_id = repo_with_commit.create_tag("v0.1.0", first_commit.id)
|
|
1137
|
+
|
|
1138
|
+
assert commit_id == first_commit.id
|
|
1139
|
+
|
|
1140
|
+
def test_create_tag_already_exists(self, repo_with_commit: Repository) -> None:
|
|
1141
|
+
"""Test creating a tag that already exists raises error."""
|
|
1142
|
+
repo_with_commit.create_tag("v1.0.0")
|
|
1143
|
+
|
|
1144
|
+
with pytest.raises(RuntimeError, match="already exists"):
|
|
1145
|
+
repo_with_commit.create_tag("v1.0.0")
|
|
1146
|
+
|
|
1147
|
+
def test_create_tag_no_commits(self, initialized_repo: Repository) -> None:
|
|
1148
|
+
"""Test creating a tag with no commits raises error."""
|
|
1149
|
+
with pytest.raises(RuntimeError, match="no commits"):
|
|
1150
|
+
initialized_repo.create_tag("v1.0.0")
|
|
1151
|
+
|
|
1152
|
+
def test_create_tag_invalid_commit(self, repo_with_commit: Repository) -> None:
|
|
1153
|
+
"""Test creating a tag with invalid commit raises error."""
|
|
1154
|
+
with pytest.raises(RuntimeError, match="not found"):
|
|
1155
|
+
repo_with_commit.create_tag("v1.0.0", "nonexistent123")
|
|
1156
|
+
|
|
1157
|
+
def test_create_tag_invalid_name(self, repo_with_commit: Repository) -> None:
|
|
1158
|
+
"""Test creating a tag with invalid name raises error."""
|
|
1159
|
+
with pytest.raises(RuntimeError, match="Invalid tag name"):
|
|
1160
|
+
repo_with_commit.create_tag("bad tag name")
|
|
1161
|
+
|
|
1162
|
+
with pytest.raises(RuntimeError, match="Invalid tag name"):
|
|
1163
|
+
repo_with_commit.create_tag("")
|
|
1164
|
+
|
|
1165
|
+
def test_get_tag(self, repo_with_commit: Repository) -> None:
|
|
1166
|
+
"""Test getting a tag's commit ID."""
|
|
1167
|
+
head_commit = repo_with_commit.get_head_commit()
|
|
1168
|
+
repo_with_commit.create_tag("v1.0.0")
|
|
1169
|
+
|
|
1170
|
+
commit_id = repo_with_commit.get_tag("v1.0.0")
|
|
1171
|
+
assert commit_id == head_commit
|
|
1172
|
+
|
|
1173
|
+
def test_get_tag_not_found(self, repo_with_commit: Repository) -> None:
|
|
1174
|
+
"""Test getting a non-existent tag returns None."""
|
|
1175
|
+
commit_id = repo_with_commit.get_tag("nonexistent")
|
|
1176
|
+
assert commit_id is None
|
|
1177
|
+
|
|
1178
|
+
def test_list_tags(self, repo_with_commit: Repository) -> None:
|
|
1179
|
+
"""Test listing multiple tags."""
|
|
1180
|
+
repo_with_commit.create_tag("v1.0.0")
|
|
1181
|
+
repo_with_commit.create_tag("v2.0.0")
|
|
1182
|
+
repo_with_commit.create_tag("alpha")
|
|
1183
|
+
|
|
1184
|
+
tags = repo_with_commit.list_tags()
|
|
1185
|
+
|
|
1186
|
+
assert len(tags) == 3
|
|
1187
|
+
assert "alpha" in tags
|
|
1188
|
+
assert "v1.0.0" in tags
|
|
1189
|
+
assert "v2.0.0" in tags
|
|
1190
|
+
# Should be sorted
|
|
1191
|
+
assert tags == sorted(tags)
|
|
1192
|
+
|
|
1193
|
+
def test_delete_tag(self, repo_with_commit: Repository) -> None:
|
|
1194
|
+
"""Test deleting a tag."""
|
|
1195
|
+
repo_with_commit.create_tag("v1.0.0")
|
|
1196
|
+
assert repo_with_commit.get_tag("v1.0.0") is not None
|
|
1197
|
+
|
|
1198
|
+
repo_with_commit.delete_tag("v1.0.0")
|
|
1199
|
+
|
|
1200
|
+
assert repo_with_commit.get_tag("v1.0.0") is None
|
|
1201
|
+
assert "v1.0.0" not in repo_with_commit.list_tags()
|
|
1202
|
+
|
|
1203
|
+
def test_delete_tag_not_found(self, repo_with_commit: Repository) -> None:
|
|
1204
|
+
"""Test deleting a non-existent tag raises error."""
|
|
1205
|
+
with pytest.raises(RuntimeError, match="does not exist"):
|
|
1206
|
+
repo_with_commit.delete_tag("nonexistent")
|
|
1207
|
+
|
|
1208
|
+
def test_tag_nested_name(self, repo_with_commit: Repository) -> None:
|
|
1209
|
+
"""Test creating a tag with nested path name."""
|
|
1210
|
+
repo_with_commit.create_tag("release/v1.0.0")
|
|
1211
|
+
|
|
1212
|
+
tags = repo_with_commit.list_tags()
|
|
1213
|
+
assert "release/v1.0.0" in tags
|
|
1214
|
+
|
|
1215
|
+
commit_id = repo_with_commit.get_tag("release/v1.0.0")
|
|
1216
|
+
assert commit_id == repo_with_commit.get_head_commit()
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
class TestCherryPick:
|
|
1220
|
+
"""Tests for cherry-pick operations."""
|
|
1221
|
+
|
|
1222
|
+
def test_cherry_pick_commit_not_found(self, initialized_repo: Repository) -> None:
|
|
1223
|
+
"""Test cherry-pick raises error when commit not found."""
|
|
1224
|
+
with pytest.raises(RuntimeError, match="not found"):
|
|
1225
|
+
initialized_repo.cherry_pick("nonexistent")
|
|
1226
|
+
|
|
1227
|
+
def test_cherry_pick_creates_new_commit(
|
|
1228
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1229
|
+
) -> None:
|
|
1230
|
+
"""Test cherry-pick creates a new commit."""
|
|
1231
|
+
# Create feature branch with new commit
|
|
1232
|
+
repo_with_commit.create_branch("feature")
|
|
1233
|
+
repo_with_commit.checkout_branch("feature")
|
|
1234
|
+
|
|
1235
|
+
modified_data = sample_map_data.copy()
|
|
1236
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "Feature Layer"})
|
|
1237
|
+
repo_with_commit.update_index(modified_data)
|
|
1238
|
+
feature_commit = repo_with_commit.create_commit("Add feature layer")
|
|
1239
|
+
|
|
1240
|
+
# Switch back to main
|
|
1241
|
+
repo_with_commit.checkout_branch("main")
|
|
1242
|
+
|
|
1243
|
+
# Cherry-pick the feature commit
|
|
1244
|
+
new_commit = repo_with_commit.cherry_pick(feature_commit.id)
|
|
1245
|
+
|
|
1246
|
+
assert new_commit is not None
|
|
1247
|
+
assert new_commit.id != feature_commit.id
|
|
1248
|
+
assert feature_commit.id[:8] in new_commit.message
|
|
1249
|
+
|
|
1250
|
+
def test_cherry_pick_applies_added_layer(
|
|
1251
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1252
|
+
) -> None:
|
|
1253
|
+
"""Test cherry-pick applies layer additions."""
|
|
1254
|
+
# Create feature branch
|
|
1255
|
+
repo_with_commit.create_branch("feature")
|
|
1256
|
+
repo_with_commit.checkout_branch("feature")
|
|
1257
|
+
|
|
1258
|
+
# Add a layer
|
|
1259
|
+
modified_data = sample_map_data.copy()
|
|
1260
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "Feature"})
|
|
1261
|
+
repo_with_commit.update_index(modified_data)
|
|
1262
|
+
feature_commit = repo_with_commit.create_commit("Add layer")
|
|
1263
|
+
|
|
1264
|
+
# Switch back to main
|
|
1265
|
+
repo_with_commit.checkout_branch("main")
|
|
1266
|
+
|
|
1267
|
+
# Verify main doesn't have layer-3
|
|
1268
|
+
current = repo_with_commit.get_index()
|
|
1269
|
+
layer_ids = [l.get("id") for l in current.get("operationalLayers", [])]
|
|
1270
|
+
assert "layer-3" not in layer_ids
|
|
1271
|
+
|
|
1272
|
+
# Cherry-pick
|
|
1273
|
+
new_commit = repo_with_commit.cherry_pick(feature_commit.id)
|
|
1274
|
+
|
|
1275
|
+
# Main should now have layer-3
|
|
1276
|
+
layers = new_commit.map_data.get("operationalLayers", [])
|
|
1277
|
+
layer_ids = [l.get("id") for l in layers]
|
|
1278
|
+
assert "layer-3" in layer_ids
|
|
1279
|
+
|
|
1280
|
+
def test_cherry_pick_applies_removed_layer(
|
|
1281
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1282
|
+
) -> None:
|
|
1283
|
+
"""Test cherry-pick applies layer removals."""
|
|
1284
|
+
# Create feature branch
|
|
1285
|
+
repo_with_commit.create_branch("feature")
|
|
1286
|
+
repo_with_commit.checkout_branch("feature")
|
|
1287
|
+
|
|
1288
|
+
# Remove a layer
|
|
1289
|
+
modified_data = sample_map_data.copy()
|
|
1290
|
+
modified_data["operationalLayers"] = [{"id": "layer-1", "title": "Roads"}]
|
|
1291
|
+
repo_with_commit.update_index(modified_data)
|
|
1292
|
+
feature_commit = repo_with_commit.create_commit("Remove layer-2")
|
|
1293
|
+
|
|
1294
|
+
# Switch back to main
|
|
1295
|
+
repo_with_commit.checkout_branch("main")
|
|
1296
|
+
|
|
1297
|
+
# Cherry-pick
|
|
1298
|
+
new_commit = repo_with_commit.cherry_pick(feature_commit.id)
|
|
1299
|
+
|
|
1300
|
+
# Main should no longer have layer-2
|
|
1301
|
+
layers = new_commit.map_data.get("operationalLayers", [])
|
|
1302
|
+
layer_ids = [l.get("id") for l in layers]
|
|
1303
|
+
assert "layer-2" not in layer_ids
|
|
1304
|
+
assert "layer-1" in layer_ids
|
|
1305
|
+
|
|
1306
|
+
def test_cherry_pick_applies_modified_layer(
|
|
1307
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1308
|
+
) -> None:
|
|
1309
|
+
"""Test cherry-pick applies layer modifications."""
|
|
1310
|
+
# Create feature branch
|
|
1311
|
+
repo_with_commit.create_branch("feature")
|
|
1312
|
+
repo_with_commit.checkout_branch("feature")
|
|
1313
|
+
|
|
1314
|
+
# Modify a layer
|
|
1315
|
+
modified_data = sample_map_data.copy()
|
|
1316
|
+
modified_data["operationalLayers"][0]["title"] = "Modified Roads"
|
|
1317
|
+
repo_with_commit.update_index(modified_data)
|
|
1318
|
+
feature_commit = repo_with_commit.create_commit("Modify layer")
|
|
1319
|
+
|
|
1320
|
+
# Switch back to main
|
|
1321
|
+
repo_with_commit.checkout_branch("main")
|
|
1322
|
+
|
|
1323
|
+
# Cherry-pick
|
|
1324
|
+
new_commit = repo_with_commit.cherry_pick(feature_commit.id)
|
|
1325
|
+
|
|
1326
|
+
# Layer-1 should be modified
|
|
1327
|
+
layers = new_commit.map_data.get("operationalLayers", [])
|
|
1328
|
+
layer_1 = next((l for l in layers if l.get("id") == "layer-1"), None)
|
|
1329
|
+
assert layer_1 is not None
|
|
1330
|
+
assert layer_1["title"] == "Modified Roads"
|
|
1331
|
+
|
|
1332
|
+
def test_cherry_pick_with_rationale(
|
|
1333
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1334
|
+
) -> None:
|
|
1335
|
+
"""Test cherry-pick accepts rationale parameter."""
|
|
1336
|
+
repo_with_commit.create_branch("feature")
|
|
1337
|
+
repo_with_commit.checkout_branch("feature")
|
|
1338
|
+
|
|
1339
|
+
modified_data = sample_map_data.copy()
|
|
1340
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "Fix"})
|
|
1341
|
+
repo_with_commit.update_index(modified_data)
|
|
1342
|
+
feature_commit = repo_with_commit.create_commit("Add fix")
|
|
1343
|
+
|
|
1344
|
+
repo_with_commit.checkout_branch("main")
|
|
1345
|
+
|
|
1346
|
+
new_commit = repo_with_commit.cherry_pick(
|
|
1347
|
+
feature_commit.id,
|
|
1348
|
+
rationale="Backporting critical fix to main",
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
assert new_commit is not None
|
|
1352
|
+
|
|
1353
|
+
def test_cherry_pick_updates_branch(
|
|
1354
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1355
|
+
) -> None:
|
|
1356
|
+
"""Test cherry-pick updates the current branch."""
|
|
1357
|
+
repo_with_commit.create_branch("feature")
|
|
1358
|
+
repo_with_commit.checkout_branch("feature")
|
|
1359
|
+
|
|
1360
|
+
modified_data = sample_map_data.copy()
|
|
1361
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
1362
|
+
repo_with_commit.update_index(modified_data)
|
|
1363
|
+
feature_commit = repo_with_commit.create_commit("Add layer")
|
|
1364
|
+
|
|
1365
|
+
repo_with_commit.checkout_branch("main")
|
|
1366
|
+
old_head = repo_with_commit.get_head_commit()
|
|
1367
|
+
|
|
1368
|
+
new_commit = repo_with_commit.cherry_pick(feature_commit.id)
|
|
1369
|
+
|
|
1370
|
+
# Branch should point to new commit
|
|
1371
|
+
branch_commit = repo_with_commit.get_branch_commit("main")
|
|
1372
|
+
assert branch_commit == new_commit.id
|
|
1373
|
+
assert branch_commit != old_head
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
class TestStash:
|
|
1377
|
+
"""Tests for stash operations."""
|
|
1378
|
+
|
|
1379
|
+
def test_stash_list_empty(self, initialized_repo: Repository) -> None:
|
|
1380
|
+
"""Test listing stashes when none exist."""
|
|
1381
|
+
stashes = initialized_repo.stash_list()
|
|
1382
|
+
assert stashes == []
|
|
1383
|
+
|
|
1384
|
+
def test_stash_push_no_changes(self, repo_with_commit: Repository) -> None:
|
|
1385
|
+
"""Test stash push with no uncommitted changes."""
|
|
1386
|
+
with pytest.raises(RuntimeError, match="No changes to stash"):
|
|
1387
|
+
repo_with_commit.stash_push()
|
|
1388
|
+
|
|
1389
|
+
def test_stash_push(
|
|
1390
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1391
|
+
) -> None:
|
|
1392
|
+
"""Test pushing changes to stash."""
|
|
1393
|
+
# Make changes
|
|
1394
|
+
modified_data = sample_map_data.copy()
|
|
1395
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
1396
|
+
repo_with_commit.update_index(modified_data)
|
|
1397
|
+
|
|
1398
|
+
# Stash changes
|
|
1399
|
+
stash_entry = repo_with_commit.stash_push(message="WIP feature")
|
|
1400
|
+
|
|
1401
|
+
assert stash_entry is not None
|
|
1402
|
+
assert "WIP feature" in stash_entry["message"]
|
|
1403
|
+
assert stash_entry["index_data"] == modified_data
|
|
1404
|
+
|
|
1405
|
+
# Index should be restored to HEAD state
|
|
1406
|
+
current_index = repo_with_commit.get_index()
|
|
1407
|
+
layer_ids = [l.get("id") for l in current_index.get("operationalLayers", [])]
|
|
1408
|
+
assert "layer-3" not in layer_ids
|
|
1409
|
+
|
|
1410
|
+
def test_stash_push_creates_dir(
|
|
1411
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1412
|
+
) -> None:
|
|
1413
|
+
"""Test stash push creates stash directory."""
|
|
1414
|
+
assert not repo_with_commit.stash_dir.exists()
|
|
1415
|
+
|
|
1416
|
+
modified_data = sample_map_data.copy()
|
|
1417
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
1418
|
+
repo_with_commit.update_index(modified_data)
|
|
1419
|
+
|
|
1420
|
+
repo_with_commit.stash_push()
|
|
1421
|
+
|
|
1422
|
+
assert repo_with_commit.stash_dir.exists()
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
class TestApplyLayerChanges:
|
|
1426
|
+
"""Tests for _apply_layer_changes helper method."""
|
|
1427
|
+
|
|
1428
|
+
def test_apply_layer_changes_addition(
|
|
1429
|
+
self, initialized_repo: Repository
|
|
1430
|
+
) -> None:
|
|
1431
|
+
"""Test applying layer additions."""
|
|
1432
|
+
current = [{"id": "1", "title": "A"}]
|
|
1433
|
+
parent = [{"id": "1", "title": "A"}]
|
|
1434
|
+
commit = [{"id": "1", "title": "A"}, {"id": "2", "title": "B"}]
|
|
1435
|
+
|
|
1436
|
+
result = initialized_repo._apply_layer_changes(current, commit, parent)
|
|
1437
|
+
|
|
1438
|
+
layer_ids = [l["id"] for l in result]
|
|
1439
|
+
assert "1" in layer_ids
|
|
1440
|
+
assert "2" in layer_ids
|
|
1441
|
+
|
|
1442
|
+
def test_apply_layer_changes_removal(
|
|
1443
|
+
self, initialized_repo: Repository
|
|
1444
|
+
) -> None:
|
|
1445
|
+
"""Test applying layer removals."""
|
|
1446
|
+
current = [{"id": "1", "title": "A"}, {"id": "2", "title": "B"}]
|
|
1447
|
+
parent = [{"id": "1", "title": "A"}, {"id": "2", "title": "B"}]
|
|
1448
|
+
commit = [{"id": "1", "title": "A"}]
|
|
1449
|
+
|
|
1450
|
+
result = initialized_repo._apply_layer_changes(current, commit, parent)
|
|
1451
|
+
|
|
1452
|
+
layer_ids = [l["id"] for l in result]
|
|
1453
|
+
assert "1" in layer_ids
|
|
1454
|
+
assert "2" not in layer_ids
|
|
1455
|
+
|
|
1456
|
+
def test_apply_layer_changes_modification(
|
|
1457
|
+
self, initialized_repo: Repository
|
|
1458
|
+
) -> None:
|
|
1459
|
+
"""Test applying layer modifications."""
|
|
1460
|
+
current = [{"id": "1", "title": "Original"}]
|
|
1461
|
+
parent = [{"id": "1", "title": "Original"}]
|
|
1462
|
+
commit = [{"id": "1", "title": "Modified"}]
|
|
1463
|
+
|
|
1464
|
+
result = initialized_repo._apply_layer_changes(current, commit, parent)
|
|
1465
|
+
|
|
1466
|
+
assert result[0]["title"] == "Modified"
|
|
1467
|
+
|
|
1468
|
+
def test_apply_layer_changes_no_duplicate(
|
|
1469
|
+
self, initialized_repo: Repository
|
|
1470
|
+
) -> None:
|
|
1471
|
+
"""Test adding a layer that already exists."""
|
|
1472
|
+
current = [{"id": "1", "title": "A"}, {"id": "2", "title": "B"}]
|
|
1473
|
+
parent = [{"id": "1", "title": "A"}]
|
|
1474
|
+
commit = [{"id": "1", "title": "A"}, {"id": "2", "title": "B"}]
|
|
1475
|
+
|
|
1476
|
+
result = initialized_repo._apply_layer_changes(current, commit, parent)
|
|
1477
|
+
|
|
1478
|
+
# Should not duplicate layer-2
|
|
1479
|
+
layer_ids = [l["id"] for l in result]
|
|
1480
|
+
assert layer_ids.count("2") == 1
|
|
1481
|
+
|
|
1482
|
+
def test_stash_list_after_push(
|
|
1483
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1484
|
+
) -> None:
|
|
1485
|
+
"""Test listing stashes after push."""
|
|
1486
|
+
modified_data = sample_map_data.copy()
|
|
1487
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
1488
|
+
repo_with_commit.update_index(modified_data)
|
|
1489
|
+
|
|
1490
|
+
repo_with_commit.stash_push(message="First stash")
|
|
1491
|
+
|
|
1492
|
+
stashes = repo_with_commit.stash_list()
|
|
1493
|
+
assert len(stashes) == 1
|
|
1494
|
+
assert "First stash" in stashes[0]["message"]
|
|
1495
|
+
|
|
1496
|
+
def test_stash_multiple(
|
|
1497
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1498
|
+
) -> None:
|
|
1499
|
+
"""Test pushing multiple stashes."""
|
|
1500
|
+
# First stash
|
|
1501
|
+
modified_data = sample_map_data.copy()
|
|
1502
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "First"})
|
|
1503
|
+
repo_with_commit.update_index(modified_data)
|
|
1504
|
+
repo_with_commit.stash_push(message="First")
|
|
1505
|
+
|
|
1506
|
+
# Second stash
|
|
1507
|
+
modified_data = sample_map_data.copy()
|
|
1508
|
+
modified_data["operationalLayers"].append({"id": "layer-4", "title": "Second"})
|
|
1509
|
+
repo_with_commit.update_index(modified_data)
|
|
1510
|
+
repo_with_commit.stash_push(message="Second")
|
|
1511
|
+
|
|
1512
|
+
stashes = repo_with_commit.stash_list()
|
|
1513
|
+
assert len(stashes) == 2
|
|
1514
|
+
# Newest first
|
|
1515
|
+
assert "Second" in stashes[0]["message"]
|
|
1516
|
+
assert "First" in stashes[1]["message"]
|
|
1517
|
+
|
|
1518
|
+
def test_stash_pop_empty(self, initialized_repo: Repository) -> None:
|
|
1519
|
+
"""Test pop with empty stash list."""
|
|
1520
|
+
with pytest.raises(RuntimeError, match="No stash entries"):
|
|
1521
|
+
initialized_repo.stash_pop()
|
|
1522
|
+
|
|
1523
|
+
def test_stash_pop(
|
|
1524
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1525
|
+
) -> None:
|
|
1526
|
+
"""Test popping a stash."""
|
|
1527
|
+
# Make changes and stash
|
|
1528
|
+
modified_data = sample_map_data.copy()
|
|
1529
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "Stashed"})
|
|
1530
|
+
repo_with_commit.update_index(modified_data)
|
|
1531
|
+
repo_with_commit.stash_push(message="Stash to pop")
|
|
1532
|
+
|
|
1533
|
+
# Verify index is clean
|
|
1534
|
+
current_index = repo_with_commit.get_index()
|
|
1535
|
+
layer_ids = [l.get("id") for l in current_index.get("operationalLayers", [])]
|
|
1536
|
+
assert "layer-3" not in layer_ids
|
|
1537
|
+
|
|
1538
|
+
# Pop stash
|
|
1539
|
+
stash_entry = repo_with_commit.stash_pop()
|
|
1540
|
+
|
|
1541
|
+
# Verify changes are restored
|
|
1542
|
+
current_index = repo_with_commit.get_index()
|
|
1543
|
+
layer_ids = [l.get("id") for l in current_index.get("operationalLayers", [])]
|
|
1544
|
+
assert "layer-3" in layer_ids
|
|
1545
|
+
|
|
1546
|
+
# Stash list should be empty
|
|
1547
|
+
assert len(repo_with_commit.stash_list()) == 0
|
|
1548
|
+
|
|
1549
|
+
def test_stash_pop_specific_index(
|
|
1550
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1551
|
+
) -> None:
|
|
1552
|
+
"""Test popping a specific stash index."""
|
|
1553
|
+
# Create two stashes
|
|
1554
|
+
modified_data = sample_map_data.copy()
|
|
1555
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "First"})
|
|
1556
|
+
repo_with_commit.update_index(modified_data)
|
|
1557
|
+
repo_with_commit.stash_push(message="First")
|
|
1558
|
+
|
|
1559
|
+
modified_data = sample_map_data.copy()
|
|
1560
|
+
modified_data["operationalLayers"].append({"id": "layer-4", "title": "Second"})
|
|
1561
|
+
repo_with_commit.update_index(modified_data)
|
|
1562
|
+
repo_with_commit.stash_push(message="Second")
|
|
1563
|
+
|
|
1564
|
+
# Pop index 1 (first stash, older one)
|
|
1565
|
+
stash_entry = repo_with_commit.stash_pop(index=1)
|
|
1566
|
+
|
|
1567
|
+
assert "First" in stash_entry["message"]
|
|
1568
|
+
|
|
1569
|
+
# Verify layer-3 is restored (from first stash)
|
|
1570
|
+
current_index = repo_with_commit.get_index()
|
|
1571
|
+
layer_ids = [l.get("id") for l in current_index.get("operationalLayers", [])]
|
|
1572
|
+
assert "layer-3" in layer_ids
|
|
1573
|
+
assert "layer-4" not in layer_ids
|
|
1574
|
+
|
|
1575
|
+
# Second stash should still exist
|
|
1576
|
+
stashes = repo_with_commit.stash_list()
|
|
1577
|
+
assert len(stashes) == 1
|
|
1578
|
+
assert "Second" in stashes[0]["message"]
|
|
1579
|
+
|
|
1580
|
+
def test_stash_pop_invalid_index(
|
|
1581
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1582
|
+
) -> None:
|
|
1583
|
+
"""Test pop with invalid index."""
|
|
1584
|
+
modified_data = sample_map_data.copy()
|
|
1585
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
1586
|
+
repo_with_commit.update_index(modified_data)
|
|
1587
|
+
repo_with_commit.stash_push()
|
|
1588
|
+
|
|
1589
|
+
with pytest.raises(RuntimeError, match="Invalid stash index"):
|
|
1590
|
+
repo_with_commit.stash_pop(index=5)
|
|
1591
|
+
|
|
1592
|
+
def test_stash_drop(
|
|
1593
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1594
|
+
) -> None:
|
|
1595
|
+
"""Test dropping a stash."""
|
|
1596
|
+
modified_data = sample_map_data.copy()
|
|
1597
|
+
modified_data["operationalLayers"].append({"id": "layer-3", "title": "New"})
|
|
1598
|
+
repo_with_commit.update_index(modified_data)
|
|
1599
|
+
repo_with_commit.stash_push(message="Stash to drop")
|
|
1600
|
+
|
|
1601
|
+
assert len(repo_with_commit.stash_list()) == 1
|
|
1602
|
+
|
|
1603
|
+
stash_ref = repo_with_commit.stash_drop()
|
|
1604
|
+
|
|
1605
|
+
assert "Stash to drop" in stash_ref.get("message", "")
|
|
1606
|
+
assert len(repo_with_commit.stash_list()) == 0
|
|
1607
|
+
|
|
1608
|
+
# Index should NOT be modified (unlike pop)
|
|
1609
|
+
current_index = repo_with_commit.get_index()
|
|
1610
|
+
layer_ids = [l.get("id") for l in current_index.get("operationalLayers", [])]
|
|
1611
|
+
assert "layer-3" not in layer_ids
|
|
1612
|
+
|
|
1613
|
+
def test_stash_drop_empty(self, initialized_repo: Repository) -> None:
|
|
1614
|
+
"""Test drop with empty stash list."""
|
|
1615
|
+
with pytest.raises(RuntimeError, match="No stash entries"):
|
|
1616
|
+
initialized_repo.stash_drop()
|
|
1617
|
+
|
|
1618
|
+
def test_stash_clear(
|
|
1619
|
+
self, repo_with_commit: Repository, sample_map_data: dict
|
|
1620
|
+
) -> None:
|
|
1621
|
+
"""Test clearing all stashes."""
|
|
1622
|
+
# Create multiple stashes
|
|
1623
|
+
for i in range(3):
|
|
1624
|
+
modified_data = sample_map_data.copy()
|
|
1625
|
+
modified_data["operationalLayers"].append({"id": f"layer-{i+3}", "title": f"New {i}"})
|
|
1626
|
+
repo_with_commit.update_index(modified_data)
|
|
1627
|
+
repo_with_commit.stash_push(message=f"Stash {i}")
|
|
1628
|
+
|
|
1629
|
+
assert len(repo_with_commit.stash_list()) == 3
|
|
1630
|
+
|
|
1631
|
+
count = repo_with_commit.stash_clear()
|
|
1632
|
+
|
|
1633
|
+
assert count == 3
|
|
1634
|
+
assert len(repo_with_commit.stash_list()) == 0
|
|
1635
|
+
|
|
1636
|
+
def test_stash_clear_empty(self, initialized_repo: Repository) -> None:
|
|
1637
|
+
"""Test clear with empty stash list."""
|
|
1638
|
+
count = initialized_repo.stash_clear()
|
|
1639
|
+
assert count == 0
|