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.
@@ -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