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,1632 @@
1
+ """Local repository management for GitMap.
2
+
3
+ Handles creation, validation, and manipulation of the .gitmap
4
+ directory structure including refs, objects, and configuration.
5
+
6
+ Execution Context:
7
+ Library module - imported by CLI commands
8
+
9
+ Dependencies:
10
+ - gitmap_core.models: Data models
11
+
12
+ Metadata:
13
+ Version: 0.1.0
14
+ Author: GitMap Team
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ from gitmap_core.context import ContextStore
26
+
27
+ from gitmap_core.models import Branch
28
+ from gitmap_core.models import Commit
29
+ from gitmap_core.models import RepoConfig
30
+
31
+
32
+ # ---- Constants ----------------------------------------------------------------------------------------------
33
+
34
+
35
+ GITMAP_DIR = ".gitmap"
36
+ CONFIG_FILE = "config.json"
37
+ HEAD_FILE = "HEAD"
38
+ INDEX_FILE = "index.json"
39
+ REFS_DIR = "refs"
40
+ HEADS_DIR = "heads"
41
+ REMOTES_DIR = "remotes"
42
+ TAGS_DIR = "tags"
43
+ OBJECTS_DIR = "objects"
44
+ COMMITS_DIR = "commits"
45
+ STASH_DIR = "stash"
46
+ CONTEXT_DB = "context.db"
47
+ STASH_DIR = "stash"
48
+ TAGS_DIR = "tags"
49
+
50
+
51
+ # ---- Repository Class ---------------------------------------------------------------------------------------
52
+
53
+
54
+ class Repository:
55
+ """Manages a local GitMap repository.
56
+
57
+ Provides methods for creating, reading, and manipulating the
58
+ .gitmap directory structure and its contents.
59
+
60
+ Attributes:
61
+ root: Root directory containing .gitmap folder.
62
+ gitmap_dir: Path to .gitmap directory.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ root: Path | str,
68
+ ) -> None:
69
+ """Initialize repository at given root path.
70
+
71
+ Args:
72
+ root: Directory containing or to contain .gitmap folder.
73
+ """
74
+ self.root = Path(root).resolve()
75
+ self.gitmap_dir = self.root / GITMAP_DIR
76
+
77
+ # ---- Path Properties ------------------------------------------------------------------------------------
78
+
79
+ @property
80
+ def config_path(
81
+ self,
82
+ ) -> Path:
83
+ """Path to config.json."""
84
+ return self.gitmap_dir / CONFIG_FILE
85
+
86
+ @property
87
+ def head_path(
88
+ self,
89
+ ) -> Path:
90
+ """Path to HEAD file."""
91
+ return self.gitmap_dir / HEAD_FILE
92
+
93
+ @property
94
+ def index_path(
95
+ self,
96
+ ) -> Path:
97
+ """Path to index.json staging area."""
98
+ return self.gitmap_dir / INDEX_FILE
99
+
100
+ @property
101
+ def refs_dir(
102
+ self,
103
+ ) -> Path:
104
+ """Path to refs directory."""
105
+ return self.gitmap_dir / REFS_DIR
106
+
107
+ @property
108
+ def heads_dir(
109
+ self,
110
+ ) -> Path:
111
+ """Path to refs/heads directory (local branches)."""
112
+ return self.refs_dir / HEADS_DIR
113
+
114
+ @property
115
+ def remotes_dir(
116
+ self,
117
+ ) -> Path:
118
+ """Path to refs/remotes directory."""
119
+ return self.refs_dir / REMOTES_DIR
120
+
121
+ @property
122
+ def stash_dir(
123
+ self,
124
+ ) -> Path:
125
+ """Path to stash directory."""
126
+ return self.gitmap_dir / STASH_DIR
127
+
128
+ @property
129
+ def tags_dir(
130
+ self,
131
+ ) -> Path:
132
+ """Path to refs/tags directory."""
133
+ return self.refs_dir / TAGS_DIR
134
+
135
+ @property
136
+ def objects_dir(
137
+ self,
138
+ ) -> Path:
139
+ """Path to objects directory."""
140
+ return self.gitmap_dir / OBJECTS_DIR
141
+
142
+ @property
143
+ def commits_dir(
144
+ self,
145
+ ) -> Path:
146
+ """Path to objects/commits directory."""
147
+ return self.objects_dir / COMMITS_DIR
148
+
149
+ @property
150
+ def context_db_path(
151
+ self,
152
+ ) -> Path:
153
+ """Path to context.db database."""
154
+ return self.gitmap_dir / CONTEXT_DB
155
+
156
+ def get_context_store(
157
+ self,
158
+ ) -> "ContextStore":
159
+ """Get context store for this repository.
160
+
161
+ Returns:
162
+ ContextStore instance for this repository.
163
+
164
+ Note:
165
+ Caller is responsible for closing the store when done,
166
+ or use it as a context manager.
167
+ """
168
+ from gitmap_core.context import ContextStore
169
+ return ContextStore(self.context_db_path)
170
+
171
+ def regenerate_context_graph(
172
+ self,
173
+ output_file: str = "context-graph.md",
174
+ output_format: str = "mermaid",
175
+ limit: int = 50,
176
+ ) -> Path | None:
177
+ """Regenerate the context graph visualization.
178
+
179
+ Args:
180
+ output_file: Output file name (relative to repo root).
181
+ output_format: Output format ('mermaid', 'html', etc.).
182
+ limit: Maximum events to include.
183
+
184
+ Returns:
185
+ Path to generated file, or None if generation failed.
186
+ """
187
+ try:
188
+ from gitmap_core.visualize import visualize_context
189
+
190
+ config = self.get_config()
191
+ title = f"{config.project_name} Context Graph" if config.project_name else "Context Graph"
192
+
193
+ with self.get_context_store() as store:
194
+ viz = visualize_context(
195
+ store,
196
+ output_format=output_format,
197
+ limit=limit,
198
+ title=title,
199
+ direction="BT", # Bottom-to-top: newest events at top
200
+ show_annotations=True,
201
+ )
202
+
203
+ output_path = self.root / output_file
204
+
205
+ # Wrap Mermaid in markdown code block
206
+ if output_format.startswith("mermaid") and output_path.suffix == ".md":
207
+ content = f"# {title}\n\n```mermaid\n{viz}\n```\n"
208
+ else:
209
+ content = viz
210
+
211
+ output_path.write_text(content, encoding="utf-8")
212
+ return output_path
213
+
214
+ except Exception:
215
+ # Don't fail operations if visualization fails
216
+ return None
217
+
218
+ # ---- Repository State -----------------------------------------------------------------------------------
219
+
220
+ def exists(
221
+ self,
222
+ ) -> bool:
223
+ """Check if repository exists.
224
+
225
+ Returns:
226
+ True if .gitmap directory exists.
227
+ """
228
+ return self.gitmap_dir.is_dir()
229
+
230
+ def is_valid(
231
+ self,
232
+ ) -> bool:
233
+ """Validate repository structure.
234
+
235
+ Returns:
236
+ True if all required files/directories exist.
237
+ """
238
+ required = [
239
+ self.config_path,
240
+ self.head_path,
241
+ self.heads_dir,
242
+ self.commits_dir,
243
+ ]
244
+ return all(p.exists() for p in required)
245
+
246
+ # ---- Initialization -------------------------------------------------------------------------------------
247
+
248
+ def init(
249
+ self,
250
+ project_name: str = "",
251
+ user_name: str = "",
252
+ user_email: str = "",
253
+ ) -> None:
254
+ """Initialize a new GitMap repository.
255
+
256
+ Creates .gitmap directory structure with initial config,
257
+ empty index, and main branch.
258
+
259
+ Args:
260
+ project_name: Name of the project.
261
+ user_name: Default commit author name.
262
+ user_email: Default commit author email.
263
+
264
+ Raises:
265
+ RuntimeError: If repository already exists.
266
+ """
267
+ if self.exists():
268
+ msg = f"GitMap repository already exists at {self.gitmap_dir}"
269
+ raise RuntimeError(msg)
270
+
271
+ try:
272
+ # Create directory structure
273
+ self.gitmap_dir.mkdir(parents=True)
274
+ self.heads_dir.mkdir(parents=True)
275
+ (self.remotes_dir / "origin").mkdir(parents=True)
276
+ self.commits_dir.mkdir(parents=True)
277
+
278
+ # Create config
279
+ config = RepoConfig(
280
+ project_name=project_name or self.root.name,
281
+ user_name=user_name,
282
+ user_email=user_email,
283
+ )
284
+ config.save(self.config_path)
285
+
286
+ # Create HEAD pointing to main
287
+ self._write_head("main")
288
+
289
+ # Create empty index
290
+ self._write_index({})
291
+
292
+ # Create initial main branch file (empty until first commit)
293
+ (self.heads_dir / "main").write_text("")
294
+
295
+ # Initialize context database
296
+ from gitmap_core.context import ContextStore
297
+ with ContextStore(self.context_db_path):
298
+ pass # Schema created on init
299
+
300
+ except Exception as init_error:
301
+ msg = f"Failed to initialize repository: {init_error}"
302
+ raise RuntimeError(msg) from init_error
303
+
304
+ # ---- HEAD Operations ------------------------------------------------------------------------------------
305
+
306
+ def get_current_branch(
307
+ self,
308
+ ) -> str | None:
309
+ """Get name of current branch.
310
+
311
+ Returns:
312
+ Branch name or None if HEAD is detached.
313
+ """
314
+ if not self.head_path.exists():
315
+ return None
316
+
317
+ head_content = self.head_path.read_text().strip()
318
+ if head_content.startswith("ref: refs/heads/"):
319
+ return head_content.replace("ref: refs/heads/", "")
320
+ return None # Detached HEAD
321
+
322
+ def get_head_commit(
323
+ self,
324
+ ) -> str | None:
325
+ """Get commit ID that HEAD points to.
326
+
327
+ Returns:
328
+ Commit ID or None if no commits.
329
+ """
330
+ branch = self.get_current_branch()
331
+ if branch:
332
+ return self.get_branch_commit(branch)
333
+
334
+ # Detached HEAD - contains commit ID directly
335
+ head_content = self.head_path.read_text().strip()
336
+ if not head_content.startswith("ref:"):
337
+ return head_content if head_content else None
338
+ return None
339
+
340
+ def _write_head(
341
+ self,
342
+ branch: str,
343
+ ) -> None:
344
+ """Write branch reference to HEAD.
345
+
346
+ Args:
347
+ branch: Branch name to reference.
348
+ """
349
+ self.head_path.write_text(f"ref: refs/heads/{branch}")
350
+
351
+ def _write_head_detached(
352
+ self,
353
+ commit_id: str,
354
+ ) -> None:
355
+ """Write commit ID directly to HEAD (detached state).
356
+
357
+ Args:
358
+ commit_id: Commit ID to reference.
359
+ """
360
+ self.head_path.write_text(commit_id)
361
+
362
+ # ---- Branch Operations ----------------------------------------------------------------------------------
363
+
364
+ def list_branches(
365
+ self,
366
+ ) -> list[str]:
367
+ """List all local branches.
368
+
369
+ Returns:
370
+ List of branch names.
371
+ """
372
+ if not self.heads_dir.exists():
373
+ return []
374
+
375
+ branches = []
376
+ for path in self.heads_dir.rglob("*"):
377
+ if path.is_file():
378
+ rel_path = path.relative_to(self.heads_dir)
379
+ branches.append(str(rel_path))
380
+ return sorted(branches)
381
+
382
+ def get_branch_commit(
383
+ self,
384
+ branch: str,
385
+ ) -> str | None:
386
+ """Get commit ID for a branch.
387
+
388
+ Args:
389
+ branch: Branch name.
390
+
391
+ Returns:
392
+ Commit ID or None if branch has no commits.
393
+ """
394
+ branch_path = self.heads_dir / branch
395
+ if not branch_path.exists():
396
+ return None
397
+
398
+ content = branch_path.read_text().strip()
399
+ return content if content else None
400
+
401
+ def create_branch(
402
+ self,
403
+ name: str,
404
+ commit_id: str | None = None,
405
+ ) -> Branch:
406
+ """Create a new branch.
407
+
408
+ Args:
409
+ name: Branch name.
410
+ commit_id: Commit to point to (defaults to HEAD).
411
+
412
+ Returns:
413
+ Created Branch object.
414
+
415
+ Raises:
416
+ RuntimeError: If branch already exists or commit not found.
417
+ """
418
+ branch_path = self.heads_dir / name
419
+
420
+ if branch_path.exists():
421
+ msg = f"Branch '{name}' already exists"
422
+ raise RuntimeError(msg)
423
+
424
+ # Use HEAD commit if not specified
425
+ if commit_id is None:
426
+ commit_id = self.get_head_commit()
427
+
428
+ # Create parent directories for nested branch names
429
+ branch_path.parent.mkdir(parents=True, exist_ok=True)
430
+ branch_path.write_text(commit_id or "")
431
+
432
+ return Branch(name=name, commit_id=commit_id or "")
433
+
434
+ def update_branch(
435
+ self,
436
+ name: str,
437
+ commit_id: str,
438
+ ) -> None:
439
+ """Update branch to point to new commit.
440
+
441
+ Args:
442
+ name: Branch name.
443
+ commit_id: New commit ID.
444
+
445
+ Raises:
446
+ RuntimeError: If branch doesn't exist.
447
+ """
448
+ branch_path = self.heads_dir / name
449
+ if not branch_path.exists():
450
+ msg = f"Branch '{name}' does not exist"
451
+ raise RuntimeError(msg)
452
+
453
+ branch_path.write_text(commit_id)
454
+
455
+ def delete_branch(
456
+ self,
457
+ name: str,
458
+ ) -> None:
459
+ """Delete a branch.
460
+
461
+ Args:
462
+ name: Branch name to delete.
463
+
464
+ Raises:
465
+ RuntimeError: If branch is current or doesn't exist.
466
+ """
467
+ if name == self.get_current_branch():
468
+ msg = f"Cannot delete current branch '{name}'"
469
+ raise RuntimeError(msg)
470
+
471
+ branch_path = self.heads_dir / name
472
+ if not branch_path.exists():
473
+ msg = f"Branch '{name}' does not exist"
474
+ raise RuntimeError(msg)
475
+
476
+ branch_path.unlink()
477
+
478
+ def checkout_branch(
479
+ self,
480
+ name: str,
481
+ ) -> None:
482
+ """Switch to a different branch.
483
+
484
+ Args:
485
+ name: Branch name to checkout.
486
+
487
+ Raises:
488
+ RuntimeError: If branch doesn't exist.
489
+ """
490
+ branch_path = self.heads_dir / name
491
+ if not branch_path.exists():
492
+ msg = f"Branch '{name}' does not exist"
493
+ raise RuntimeError(msg)
494
+
495
+ self._write_head(name)
496
+
497
+ # Load branch's commit state to index
498
+ commit_id = self.get_branch_commit(name)
499
+ if commit_id:
500
+ commit = self.get_commit(commit_id)
501
+ if commit:
502
+ self._write_index(commit.map_data)
503
+ else:
504
+ # Branch has no commits - clear index to empty state
505
+ self._write_index({})
506
+
507
+ # ---- Index Operations -----------------------------------------------------------------------------------
508
+
509
+ def get_index(
510
+ self,
511
+ ) -> dict[str, Any]:
512
+ """Get current staging area (index) contents.
513
+
514
+ Returns:
515
+ Map data from index.json.
516
+ """
517
+ if not self.index_path.exists():
518
+ return {}
519
+
520
+ try:
521
+ return json.loads(self.index_path.read_text())
522
+ except json.JSONDecodeError:
523
+ return {}
524
+
525
+ def _write_index(
526
+ self,
527
+ data: dict[str, Any],
528
+ ) -> None:
529
+ """Write data to index.json.
530
+
531
+ Args:
532
+ data: Map data to stage.
533
+ """
534
+ self.index_path.write_text(json.dumps(data, indent=2))
535
+
536
+ def update_index(
537
+ self,
538
+ map_data: dict[str, Any],
539
+ ) -> None:
540
+ """Update staging area with new map data.
541
+
542
+ Args:
543
+ map_data: Web map JSON to stage.
544
+ """
545
+ self._write_index(map_data)
546
+
547
+ # ---- Commit Operations ----------------------------------------------------------------------------------
548
+
549
+ def get_commit(
550
+ self,
551
+ commit_id: str,
552
+ ) -> Commit | None:
553
+ """Load a commit by ID.
554
+
555
+ Args:
556
+ commit_id: Commit identifier.
557
+
558
+ Returns:
559
+ Commit object or None if not found.
560
+ """
561
+ commit_path = self.commits_dir / f"{commit_id}.json"
562
+ if not commit_path.exists():
563
+ return None
564
+
565
+ return Commit.load(commit_path)
566
+
567
+ def create_commit(
568
+ self,
569
+ message: str,
570
+ author: str | None = None,
571
+ rationale: str | None = None,
572
+ ) -> Commit:
573
+ """Create a new commit from current index.
574
+
575
+ Args:
576
+ message: Commit message.
577
+ author: Author name (uses config if not provided).
578
+ rationale: Optional rationale explaining why this change was made.
579
+
580
+ Returns:
581
+ Created Commit object.
582
+
583
+ Raises:
584
+ RuntimeError: If commit creation fails.
585
+ """
586
+ try:
587
+ # Get author from config if not provided
588
+ if not author:
589
+ config = self.get_config()
590
+ author = config.user_name or "Unknown"
591
+
592
+ # Get current state
593
+ map_data = self.get_index()
594
+ parent = self.get_head_commit()
595
+
596
+ # Generate commit ID from content
597
+ commit_id = self._generate_commit_id(message, map_data, parent)
598
+
599
+ # Create commit
600
+ commit = Commit.create(
601
+ commit_id=commit_id,
602
+ message=message,
603
+ author=author,
604
+ parent=parent,
605
+ map_data=map_data,
606
+ )
607
+
608
+ # Save commit
609
+ commit.save(self.commits_dir)
610
+
611
+ # Update current branch
612
+ branch = self.get_current_branch()
613
+ if branch:
614
+ self.update_branch(branch, commit_id)
615
+
616
+ # Record event in context store (non-blocking)
617
+ try:
618
+ with self.get_context_store() as store:
619
+ layers_count = len(map_data.get("operationalLayers", []))
620
+ store.record_event(
621
+ event_type="commit",
622
+ repo=str(self.root),
623
+ ref=commit_id,
624
+ actor=author,
625
+ payload={
626
+ "message": message,
627
+ "parent": parent,
628
+ "parent2": None,
629
+ "layers_count": layers_count,
630
+ "branch": branch, # Track which branch the commit was made on
631
+ },
632
+ rationale=rationale,
633
+ )
634
+
635
+ # Auto-regenerate context graph if enabled
636
+ config = self.get_config()
637
+ if config.auto_visualize:
638
+ self.regenerate_context_graph()
639
+
640
+ except Exception:
641
+ # Don't fail commit if context recording fails
642
+ pass
643
+
644
+ return commit
645
+
646
+ except Exception as commit_error:
647
+ msg = f"Failed to create commit: {commit_error}"
648
+ raise RuntimeError(msg) from commit_error
649
+
650
+ def _generate_commit_id(
651
+ self,
652
+ message: str,
653
+ map_data: dict[str, Any],
654
+ parent: str | None,
655
+ ) -> str:
656
+ """Generate unique commit ID.
657
+
658
+ Args:
659
+ message: Commit message.
660
+ map_data: Map data to hash.
661
+ parent: Parent commit ID.
662
+
663
+ Returns:
664
+ Short hash string for commit ID.
665
+ """
666
+ content = json.dumps({
667
+ "message": message,
668
+ "map_data": map_data,
669
+ "parent": parent,
670
+ }, sort_keys=True)
671
+
672
+ full_hash = hashlib.sha256(content.encode()).hexdigest()
673
+ return full_hash[:12]
674
+
675
+ def get_commit_history(
676
+ self,
677
+ start_commit: str | None = None,
678
+ limit: int | None = None,
679
+ ) -> list[Commit]:
680
+ """Get commit history starting from a commit.
681
+
682
+ Args:
683
+ start_commit: Starting commit ID (defaults to HEAD).
684
+ limit: Maximum number of commits to return.
685
+
686
+ Returns:
687
+ List of commits in reverse chronological order.
688
+ """
689
+ commits: list[Commit] = []
690
+ current_id = start_commit or self.get_head_commit()
691
+
692
+ while current_id:
693
+ if limit and len(commits) >= limit:
694
+ break
695
+
696
+ commit = self.get_commit(current_id)
697
+ if not commit:
698
+ break
699
+
700
+ commits.append(commit)
701
+ current_id = commit.parent
702
+
703
+ return commits
704
+
705
+ # ---- Config Operations ----------------------------------------------------------------------------------
706
+
707
+ def get_config(
708
+ self,
709
+ ) -> RepoConfig:
710
+ """Load repository configuration.
711
+
712
+ Returns:
713
+ RepoConfig object.
714
+
715
+ Raises:
716
+ RuntimeError: If config cannot be loaded.
717
+ """
718
+ if not self.config_path.exists():
719
+ msg = f"Config file not found at {self.config_path}"
720
+ raise RuntimeError(msg)
721
+
722
+ return RepoConfig.load(self.config_path)
723
+
724
+ def update_config(
725
+ self,
726
+ config: RepoConfig,
727
+ ) -> None:
728
+ """Save updated configuration.
729
+
730
+ Args:
731
+ config: RepoConfig to save.
732
+ """
733
+ config.save(self.config_path)
734
+
735
+ # ---- Status Operations ----------------------------------------------------------------------------------
736
+
737
+ def has_uncommitted_changes(
738
+ self,
739
+ ) -> bool:
740
+ """Check if index differs from HEAD commit.
741
+
742
+ Returns:
743
+ True if there are uncommitted changes.
744
+ """
745
+ head_commit_id = self.get_head_commit()
746
+ if not head_commit_id:
747
+ # No commits yet - check if index has data
748
+ index = self.get_index()
749
+ return bool(index)
750
+
751
+ commit = self.get_commit(head_commit_id)
752
+ if not commit:
753
+ return True
754
+
755
+ index = self.get_index()
756
+ return index != commit.map_data
757
+
758
+ # ---- Revert Operations ----------------------------------------------------------------------------------
759
+
760
+ def revert(
761
+ self,
762
+ commit_id: str,
763
+ rationale: str | None = None,
764
+ ) -> Commit:
765
+ """Revert a specific commit by creating an inverse commit.
766
+
767
+ Creates a new commit that undoes the changes introduced by the
768
+ specified commit. Does not remove history - adds a new commit
769
+ that reverses the changes.
770
+
771
+ Args:
772
+ commit_id: ID of the commit to revert.
773
+ rationale: Optional rationale explaining why the revert is being made.
774
+
775
+ Returns:
776
+ The new revert Commit object.
777
+
778
+ Raises:
779
+ RuntimeError: If commit not found or revert fails.
780
+ """
781
+ try:
782
+ # Get the commit to revert
783
+ commit_to_revert = self.get_commit(commit_id)
784
+ if not commit_to_revert:
785
+ msg = f"Commit '{commit_id}' not found"
786
+ raise RuntimeError(msg)
787
+
788
+ # Get the parent state (state before the commit)
789
+ parent_data: dict[str, Any] = {}
790
+ if commit_to_revert.parent:
791
+ parent_commit = self.get_commit(commit_to_revert.parent)
792
+ if parent_commit:
793
+ parent_data = parent_commit.map_data
794
+
795
+ # Get current HEAD state
796
+ current_data = self.get_index()
797
+
798
+ # Compute the reverted state by applying inverse changes
799
+ reverted_data = self._compute_revert(
800
+ current_data=current_data,
801
+ commit_data=commit_to_revert.map_data,
802
+ parent_data=parent_data,
803
+ )
804
+
805
+ # Update index with reverted data
806
+ self.update_index(reverted_data)
807
+
808
+ # Create the revert commit
809
+ config = self.get_config()
810
+ author = config.user_name or "Unknown"
811
+ message = f"Revert \"{commit_to_revert.message}\"\n\nThis reverts commit {commit_id[:8]}."
812
+
813
+ revert_commit = self.create_commit(
814
+ message=message,
815
+ author=author,
816
+ rationale=rationale,
817
+ )
818
+
819
+ # Record revert event in context store
820
+ try:
821
+ with self.get_context_store() as store:
822
+ event = store.record_event(
823
+ event_type="revert",
824
+ repo=str(self.root),
825
+ ref=revert_commit.id,
826
+ actor=author,
827
+ payload={
828
+ "reverted_commit": commit_id,
829
+ "reverted_message": commit_to_revert.message,
830
+ "revert_commit": revert_commit.id,
831
+ "branch": self.get_current_branch(),
832
+ },
833
+ rationale=rationale,
834
+ )
835
+ # Link revert to original commit
836
+ store.add_edge(
837
+ source_id=event.id,
838
+ target_id=commit_id,
839
+ relationship="reverts",
840
+ metadata={"commit_id": revert_commit.id},
841
+ )
842
+ except Exception:
843
+ # Don't fail revert if context recording fails
844
+ pass
845
+
846
+ return revert_commit
847
+
848
+ except Exception as revert_error:
849
+ if isinstance(revert_error, RuntimeError):
850
+ raise
851
+ msg = f"Failed to revert commit: {revert_error}"
852
+ raise RuntimeError(msg) from revert_error
853
+
854
+ def _compute_revert(
855
+ self,
856
+ current_data: dict[str, Any],
857
+ commit_data: dict[str, Any],
858
+ parent_data: dict[str, Any],
859
+ ) -> dict[str, Any]:
860
+ """Compute the reverted state by applying inverse changes.
861
+
862
+ For each change introduced by the commit (comparing commit_data to parent_data),
863
+ apply the inverse change to the current_data.
864
+
865
+ Args:
866
+ current_data: Current HEAD state.
867
+ commit_data: State at the commit to revert.
868
+ parent_data: State before the commit to revert.
869
+
870
+ Returns:
871
+ New state with the commit's changes reverted.
872
+ """
873
+ reverted = json.loads(json.dumps(current_data)) # Deep copy
874
+
875
+ # Handle operationalLayers
876
+ reverted["operationalLayers"] = self._revert_layers(
877
+ current_layers=current_data.get("operationalLayers", []),
878
+ commit_layers=commit_data.get("operationalLayers", []),
879
+ parent_layers=parent_data.get("operationalLayers", []),
880
+ )
881
+
882
+ # Handle tables
883
+ reverted["tables"] = self._revert_layers(
884
+ current_layers=current_data.get("tables", []),
885
+ commit_layers=commit_data.get("tables", []),
886
+ parent_layers=parent_data.get("tables", []),
887
+ )
888
+
889
+ # Handle baseMap (simpler - just restore if changed)
890
+ if commit_data.get("baseMap") != parent_data.get("baseMap"):
891
+ # Commit changed baseMap, revert to parent's baseMap
892
+ if "baseMap" in parent_data:
893
+ reverted["baseMap"] = parent_data["baseMap"]
894
+ elif "baseMap" in reverted:
895
+ del reverted["baseMap"]
896
+
897
+ return reverted
898
+
899
+ def _revert_layers(
900
+ self,
901
+ current_layers: list[dict[str, Any]],
902
+ commit_layers: list[dict[str, Any]],
903
+ parent_layers: list[dict[str, Any]],
904
+ ) -> list[dict[str, Any]]:
905
+ """Revert layer changes from a commit.
906
+
907
+ Args:
908
+ current_layers: Current layers in HEAD.
909
+ commit_layers: Layers at the commit to revert.
910
+ parent_layers: Layers before the commit to revert.
911
+
912
+ Returns:
913
+ Layers with the commit's changes reverted.
914
+ """
915
+ # Build ID maps for efficient lookup
916
+ current_by_id = {l.get("id"): l for l in current_layers if l.get("id")}
917
+ commit_by_id = {l.get("id"): l for l in commit_layers if l.get("id")}
918
+ parent_by_id = {l.get("id"): l for l in parent_layers if l.get("id")}
919
+
920
+ result = []
921
+
922
+ # Process current layers
923
+ for layer in current_layers:
924
+ layer_id = layer.get("id")
925
+ if not layer_id:
926
+ result.append(layer)
927
+ continue
928
+
929
+ # Was this layer added by the commit? (in commit but not in parent)
930
+ if layer_id in commit_by_id and layer_id not in parent_by_id:
931
+ # Skip it - reverting the addition
932
+ continue
933
+
934
+ # Was this layer modified by the commit?
935
+ if layer_id in commit_by_id and layer_id in parent_by_id:
936
+ if commit_by_id[layer_id] != parent_by_id[layer_id]:
937
+ # Restore to parent version
938
+ result.append(parent_by_id[layer_id])
939
+ continue
940
+
941
+ # No changes from this commit, keep as is
942
+ result.append(layer)
943
+
944
+ # Add back any layers that were removed by the commit
945
+ for layer_id, layer in parent_by_id.items():
946
+ if layer_id not in commit_by_id and layer_id not in current_by_id:
947
+ # Layer was removed by the commit, add it back
948
+ result.append(layer)
949
+
950
+ return result
951
+
952
+ # ---- Tag Operations -------------------------------------------------------------------------------------
953
+
954
+ def list_tags(
955
+ self,
956
+ ) -> list[str]:
957
+ """List all tags in the repository.
958
+
959
+ Returns:
960
+ List of tag names sorted alphabetically.
961
+ """
962
+ if not self.tags_dir.exists():
963
+ return []
964
+
965
+ tags = []
966
+ for path in self.tags_dir.rglob("*"):
967
+ if path.is_file():
968
+ rel_path = path.relative_to(self.tags_dir)
969
+ tags.append(str(rel_path))
970
+ return sorted(tags)
971
+
972
+ def get_tag(
973
+ self,
974
+ name: str,
975
+ ) -> str | None:
976
+ """Get the commit ID a tag points to.
977
+
978
+ Args:
979
+ name: Tag name.
980
+
981
+ Returns:
982
+ Commit ID or None if tag doesn't exist.
983
+ """
984
+ tag_path = self.tags_dir / name
985
+ if not tag_path.exists():
986
+ return None
987
+
988
+ return tag_path.read_text().strip()
989
+
990
+ def create_tag(
991
+ self,
992
+ name: str,
993
+ commit_id: str | None = None,
994
+ ) -> str:
995
+ """Create a new tag pointing to a commit.
996
+
997
+ Args:
998
+ name: Tag name (e.g., 'v1.0.0').
999
+ commit_id: Commit to tag (defaults to HEAD).
1000
+
1001
+ Returns:
1002
+ The commit ID the tag points to.
1003
+
1004
+ Raises:
1005
+ RuntimeError: If tag already exists or commit not found.
1006
+ """
1007
+ # Validate tag name (no spaces, no special chars except - _ /)
1008
+ if not name or " " in name:
1009
+ msg = f"Invalid tag name: '{name}'"
1010
+ raise RuntimeError(msg)
1011
+
1012
+ tag_path = self.tags_dir / name
1013
+
1014
+ if tag_path.exists():
1015
+ msg = f"Tag '{name}' already exists"
1016
+ raise RuntimeError(msg)
1017
+
1018
+ # Use HEAD commit if not specified
1019
+ if commit_id is None:
1020
+ commit_id = self.get_head_commit()
1021
+
1022
+ if not commit_id:
1023
+ msg = "Cannot create tag: no commits in repository"
1024
+ raise RuntimeError(msg)
1025
+
1026
+ # Verify commit exists
1027
+ if not self.get_commit(commit_id):
1028
+ msg = f"Commit '{commit_id}' not found"
1029
+ raise RuntimeError(msg)
1030
+
1031
+ # Create tags directory if needed
1032
+ tag_path.parent.mkdir(parents=True, exist_ok=True)
1033
+
1034
+ # Write tag
1035
+ tag_path.write_text(commit_id)
1036
+
1037
+ # Record event in context store
1038
+ try:
1039
+ config = self.get_config()
1040
+ actor = config.user_name if config else None
1041
+ with self.get_context_store() as store:
1042
+ store.record_event(
1043
+ event_type="tag",
1044
+ repo=str(self.root),
1045
+ ref=commit_id,
1046
+ actor=actor,
1047
+ payload={
1048
+ "tag_name": name,
1049
+ "commit_id": commit_id,
1050
+ "action": "create",
1051
+ },
1052
+ )
1053
+ except Exception:
1054
+ pass # Don't fail tag creation if context recording fails
1055
+
1056
+ return commit_id
1057
+
1058
+ def delete_tag(
1059
+ self,
1060
+ name: str,
1061
+ ) -> None:
1062
+ """Delete a tag.
1063
+
1064
+ Args:
1065
+ name: Tag name to delete.
1066
+
1067
+ Raises:
1068
+ RuntimeError: If tag doesn't exist.
1069
+ """
1070
+ tag_path = self.tags_dir / name
1071
+
1072
+ if not tag_path.exists():
1073
+ msg = f"Tag '{name}' does not exist"
1074
+ raise RuntimeError(msg)
1075
+
1076
+ commit_id = tag_path.read_text().strip()
1077
+ tag_path.unlink()
1078
+
1079
+ # Record event in context store
1080
+ try:
1081
+ config = self.get_config()
1082
+ actor = config.user_name if config else None
1083
+ with self.get_context_store() as store:
1084
+ store.record_event(
1085
+ event_type="tag",
1086
+ repo=str(self.root),
1087
+ ref=commit_id,
1088
+ actor=actor,
1089
+ payload={
1090
+ "tag_name": name,
1091
+ "commit_id": commit_id,
1092
+ "action": "delete",
1093
+ },
1094
+ )
1095
+ except Exception:
1096
+ pass # Don't fail tag deletion if context recording fails
1097
+
1098
+
1099
+
1100
+ # ---- Cherry-Pick Operations ---------------------------------------------------------------------------------
1101
+
1102
+ def cherry_pick(
1103
+ self,
1104
+ commit_id: str,
1105
+ rationale: str | None = None,
1106
+ ) -> Commit:
1107
+ """Apply changes from a specific commit to the current branch.
1108
+
1109
+ Creates a new commit with the same changes as the source commit
1110
+ but with a new commit ID. The original commit is not modified.
1111
+
1112
+ Args:
1113
+ commit_id: ID of the commit to cherry-pick.
1114
+ rationale: Optional rationale explaining why this cherry-pick is being made.
1115
+
1116
+ Returns:
1117
+ The new Commit object.
1118
+
1119
+ Raises:
1120
+ RuntimeError: If commit not found or cherry-pick fails.
1121
+ """
1122
+ try:
1123
+ # Get the commit to cherry-pick
1124
+ source_commit = self.get_commit(commit_id)
1125
+ if not source_commit:
1126
+ msg = f"Commit '{commit_id}' not found"
1127
+ raise RuntimeError(msg)
1128
+
1129
+ # Get the parent of the source commit to compute the diff
1130
+ source_parent_data: dict[str, Any] = {}
1131
+ if source_commit.parent:
1132
+ source_parent = self.get_commit(source_commit.parent)
1133
+ if source_parent:
1134
+ source_parent_data = source_parent.map_data
1135
+
1136
+ # Get current HEAD state
1137
+ current_data = self.get_index()
1138
+
1139
+ # Apply the changes from source commit to current state
1140
+ cherry_picked_data = self._apply_cherry_pick(
1141
+ current_data=current_data,
1142
+ commit_data=source_commit.map_data,
1143
+ parent_data=source_parent_data,
1144
+ )
1145
+
1146
+ # Update index with cherry-picked data
1147
+ self.update_index(cherry_picked_data)
1148
+
1149
+ # Create the new commit
1150
+ config = self.get_config()
1151
+ author = config.user_name or "Unknown"
1152
+ message = f"{source_commit.message}\n\n(cherry picked from commit {commit_id[:8]})"
1153
+
1154
+ new_commit = self.create_commit(
1155
+ message=message,
1156
+ author=author,
1157
+ rationale=rationale,
1158
+ )
1159
+
1160
+ # Record cherry-pick event in context store
1161
+ try:
1162
+ with self.get_context_store() as store:
1163
+ event = store.record_event(
1164
+ event_type="cherry-pick",
1165
+ repo=str(self.root),
1166
+ ref=new_commit.id,
1167
+ actor=author,
1168
+ payload={
1169
+ "source_commit": commit_id,
1170
+ "source_message": source_commit.message,
1171
+ "new_commit": new_commit.id,
1172
+ "branch": self.get_current_branch(),
1173
+ },
1174
+ rationale=rationale,
1175
+ )
1176
+ # Link cherry-pick to source commit
1177
+ store.add_edge(
1178
+ source_id=event.id,
1179
+ target_id=commit_id,
1180
+ relationship="cherry_picked_from",
1181
+ metadata={"new_commit_id": new_commit.id},
1182
+ )
1183
+ except Exception:
1184
+ # Don't fail cherry-pick if context recording fails
1185
+ pass
1186
+
1187
+ return new_commit
1188
+
1189
+ except Exception as cherry_pick_error:
1190
+ if isinstance(cherry_pick_error, RuntimeError):
1191
+ raise
1192
+ msg = f"Failed to cherry-pick commit: {cherry_pick_error}"
1193
+ raise RuntimeError(msg) from cherry_pick_error
1194
+
1195
+ def _apply_cherry_pick(
1196
+ self,
1197
+ current_data: dict[str, Any],
1198
+ commit_data: dict[str, Any],
1199
+ parent_data: dict[str, Any],
1200
+ ) -> dict[str, Any]:
1201
+ """Apply changes from a commit to the current state.
1202
+
1203
+ Computes the diff between commit and its parent, then applies
1204
+ those changes to the current state.
1205
+
1206
+ Args:
1207
+ current_data: Current HEAD state.
1208
+ commit_data: State at the commit to cherry-pick.
1209
+ parent_data: State before the commit to cherry-pick (its parent).
1210
+
1211
+ Returns:
1212
+ New state with the commit's changes applied.
1213
+ """
1214
+ result = json.loads(json.dumps(current_data)) # Deep copy
1215
+
1216
+ # Apply layer changes
1217
+ result["operationalLayers"] = self._apply_layer_changes(
1218
+ current_layers=current_data.get("operationalLayers", []),
1219
+ commit_layers=commit_data.get("operationalLayers", []),
1220
+ parent_layers=parent_data.get("operationalLayers", []),
1221
+ )
1222
+
1223
+ # Apply table changes
1224
+ result["tables"] = self._apply_layer_changes(
1225
+ current_layers=current_data.get("tables", []),
1226
+ commit_layers=commit_data.get("tables", []),
1227
+ parent_layers=parent_data.get("tables", []),
1228
+ )
1229
+
1230
+ # Apply baseMap changes (if changed in commit)
1231
+ if commit_data.get("baseMap") != parent_data.get("baseMap"):
1232
+ result["baseMap"] = commit_data.get("baseMap", {})
1233
+
1234
+ return result
1235
+
1236
+ def _apply_layer_changes(
1237
+ self,
1238
+ current_layers: list[dict[str, Any]],
1239
+ commit_layers: list[dict[str, Any]],
1240
+ parent_layers: list[dict[str, Any]],
1241
+ ) -> list[dict[str, Any]]:
1242
+ """Apply layer changes from a commit to current state.
1243
+
1244
+ Args:
1245
+ current_layers: Current layers in HEAD.
1246
+ commit_layers: Layers at the commit to cherry-pick.
1247
+ parent_layers: Layers before the commit to cherry-pick.
1248
+
1249
+ Returns:
1250
+ Layers with the commit's changes applied.
1251
+ """
1252
+ # Build ID maps for efficient lookup
1253
+ current_by_id = {l.get("id"): l for l in current_layers if l.get("id")}
1254
+ commit_by_id = {l.get("id"): l for l in commit_layers if l.get("id")}
1255
+ parent_by_id = {l.get("id"): l for l in parent_layers if l.get("id")}
1256
+
1257
+ result = list(current_layers) # Start with current layers
1258
+
1259
+ # Find layers added by the commit (in commit but not in parent)
1260
+ for layer_id, layer in commit_by_id.items():
1261
+ if layer_id not in parent_by_id:
1262
+ # This layer was added by the commit
1263
+ if layer_id not in current_by_id:
1264
+ # Add it to current if not already present
1265
+ result.append(layer)
1266
+
1267
+ # Find layers modified by the commit
1268
+ for layer_id, layer in commit_by_id.items():
1269
+ if layer_id in parent_by_id and commit_by_id[layer_id] != parent_by_id[layer_id]:
1270
+ # This layer was modified by the commit
1271
+ if layer_id in current_by_id:
1272
+ # Update the layer in result
1273
+ for i, l in enumerate(result):
1274
+ if l.get("id") == layer_id:
1275
+ result[i] = layer
1276
+ break
1277
+
1278
+ # Find layers removed by the commit (in parent but not in commit)
1279
+ for layer_id in parent_by_id:
1280
+ if layer_id not in commit_by_id:
1281
+ # This layer was removed by the commit
1282
+ result = [l for l in result if l.get("id") != layer_id]
1283
+
1284
+ return result
1285
+
1286
+ # ---- Stash Operations -----------------------------------------------------------------------------------
1287
+
1288
+ def _get_stash_list_path(
1289
+ self,
1290
+ ) -> Path:
1291
+ """Get path to stash list file."""
1292
+ return self.stash_dir / "stash_list.json"
1293
+
1294
+ def _load_stash_list(
1295
+ self,
1296
+ ) -> list[dict[str, Any]]:
1297
+ """Load the stash stack from disk.
1298
+
1299
+ Returns:
1300
+ List of stash entries (newest first).
1301
+ """
1302
+ stash_list_path = self._get_stash_list_path()
1303
+ if not stash_list_path.exists():
1304
+ return []
1305
+
1306
+ try:
1307
+ return json.loads(stash_list_path.read_text())
1308
+ except json.JSONDecodeError:
1309
+ return []
1310
+
1311
+ def _save_stash_list(
1312
+ self,
1313
+ stash_list: list[dict[str, Any]],
1314
+ ) -> None:
1315
+ """Save the stash stack to disk.
1316
+
1317
+ Args:
1318
+ stash_list: List of stash entries.
1319
+ """
1320
+ self.stash_dir.mkdir(parents=True, exist_ok=True)
1321
+ self._get_stash_list_path().write_text(json.dumps(stash_list, indent=2))
1322
+
1323
+ def stash_push(
1324
+ self,
1325
+ message: str | None = None,
1326
+ ) -> dict[str, Any]:
1327
+ """Save current index state to the stash stack.
1328
+
1329
+ Args:
1330
+ message: Optional message describing the stash.
1331
+
1332
+ Returns:
1333
+ The stash entry that was created.
1334
+
1335
+ Raises:
1336
+ RuntimeError: If there are no changes to stash.
1337
+ """
1338
+ if not self.has_uncommitted_changes():
1339
+ msg = "No changes to stash"
1340
+ raise RuntimeError(msg)
1341
+
1342
+ # Get current index state
1343
+ index_data = self.get_index()
1344
+ branch = self.get_current_branch()
1345
+ head_commit = self.get_head_commit()
1346
+
1347
+ # Generate stash ID
1348
+ import time
1349
+ stash_id = f"stash@{{{int(time.time())}}}"
1350
+
1351
+ # Create stash entry
1352
+ stash_entry = {
1353
+ "id": stash_id,
1354
+ "timestamp": hashlib.sha256(str(time.time()).encode()).hexdigest()[:8],
1355
+ "message": message or f"WIP on {branch}: {head_commit[:8] if head_commit else 'initial'}",
1356
+ "branch": branch,
1357
+ "head_commit": head_commit,
1358
+ "index_data": index_data,
1359
+ }
1360
+
1361
+ # Save stash data to file
1362
+ self.stash_dir.mkdir(parents=True, exist_ok=True)
1363
+ stash_file = self.stash_dir / f"{stash_entry['timestamp']}.json"
1364
+ stash_file.write_text(json.dumps(stash_entry, indent=2))
1365
+
1366
+ # Update stash list (prepend - newest first)
1367
+ stash_list = self._load_stash_list()
1368
+ stash_list.insert(0, {
1369
+ "id": stash_id,
1370
+ "file": stash_entry["timestamp"],
1371
+ "message": stash_entry["message"],
1372
+ "branch": branch,
1373
+ })
1374
+ self._save_stash_list(stash_list)
1375
+
1376
+ # Restore index to HEAD state
1377
+ if head_commit:
1378
+ commit = self.get_commit(head_commit)
1379
+ if commit:
1380
+ self._write_index(commit.map_data)
1381
+ else:
1382
+ self._write_index({})
1383
+
1384
+ # Record stash event
1385
+ try:
1386
+ config = self.get_config()
1387
+ actor = config.user_name if config else None
1388
+ with self.get_context_store() as store:
1389
+ store.record_event(
1390
+ event_type="stash",
1391
+ repo=str(self.root),
1392
+ ref=stash_id,
1393
+ actor=actor,
1394
+ payload={
1395
+ "action": "push",
1396
+ "stash_id": stash_id,
1397
+ "message": stash_entry["message"],
1398
+ "branch": branch,
1399
+ },
1400
+ )
1401
+ except Exception:
1402
+ pass
1403
+
1404
+ return stash_entry
1405
+
1406
+ def stash_pop(
1407
+ self,
1408
+ index: int = 0,
1409
+ ) -> dict[str, Any]:
1410
+ """Apply and remove a stash entry.
1411
+
1412
+ Args:
1413
+ index: Index in stash list (0 = most recent).
1414
+
1415
+ Returns:
1416
+ The stash entry that was applied.
1417
+
1418
+ Raises:
1419
+ RuntimeError: If stash is empty or index out of range.
1420
+ """
1421
+ stash_list = self._load_stash_list()
1422
+
1423
+ if not stash_list:
1424
+ msg = "No stash entries"
1425
+ raise RuntimeError(msg)
1426
+
1427
+ if index < 0 or index >= len(stash_list):
1428
+ msg = f"Invalid stash index: {index}"
1429
+ raise RuntimeError(msg)
1430
+
1431
+ # Get stash entry
1432
+ stash_ref = stash_list[index]
1433
+ stash_file = self.stash_dir / f"{stash_ref['file']}.json"
1434
+
1435
+ if not stash_file.exists():
1436
+ msg = f"Stash data not found: {stash_ref['id']}"
1437
+ raise RuntimeError(msg)
1438
+
1439
+ stash_entry = json.loads(stash_file.read_text())
1440
+
1441
+ # Apply stash to index
1442
+ self._write_index(stash_entry["index_data"])
1443
+
1444
+ # Remove from stash list and delete file
1445
+ stash_list.pop(index)
1446
+ self._save_stash_list(stash_list)
1447
+ stash_file.unlink()
1448
+
1449
+ # Record stash event
1450
+ try:
1451
+ config = self.get_config()
1452
+ actor = config.user_name if config else None
1453
+ with self.get_context_store() as store:
1454
+ store.record_event(
1455
+ event_type="stash",
1456
+ repo=str(self.root),
1457
+ ref=stash_ref["id"],
1458
+ actor=actor,
1459
+ payload={
1460
+ "action": "pop",
1461
+ "stash_id": stash_ref["id"],
1462
+ "message": stash_entry["message"],
1463
+ },
1464
+ )
1465
+ except Exception:
1466
+ pass
1467
+
1468
+ return stash_entry
1469
+
1470
+ def stash_list(
1471
+ self,
1472
+ ) -> list[dict[str, Any]]:
1473
+ """List all stash entries.
1474
+
1475
+ Returns:
1476
+ List of stash entries (newest first).
1477
+ """
1478
+ return self._load_stash_list()
1479
+
1480
+ def stash_drop(
1481
+ self,
1482
+ index: int = 0,
1483
+ ) -> dict[str, Any]:
1484
+ """Remove a stash entry without applying.
1485
+
1486
+ Args:
1487
+ index: Index in stash list (0 = most recent).
1488
+
1489
+ Returns:
1490
+ The stash entry that was dropped.
1491
+
1492
+ Raises:
1493
+ RuntimeError: If stash is empty or index out of range.
1494
+ """
1495
+ stash_list = self._load_stash_list()
1496
+
1497
+ if not stash_list:
1498
+ msg = "No stash entries"
1499
+ raise RuntimeError(msg)
1500
+
1501
+ if index < 0 or index >= len(stash_list):
1502
+ msg = f"Invalid stash index: {index}"
1503
+ raise RuntimeError(msg)
1504
+
1505
+ # Get stash entry
1506
+ stash_ref = stash_list[index]
1507
+ stash_file = self.stash_dir / f"{stash_ref['file']}.json"
1508
+
1509
+ # Remove from stash list and delete file
1510
+ stash_list.pop(index)
1511
+ self._save_stash_list(stash_list)
1512
+
1513
+ if stash_file.exists():
1514
+ stash_file.unlink()
1515
+
1516
+ # Record stash event
1517
+ try:
1518
+ config = self.get_config()
1519
+ actor = config.user_name if config else None
1520
+ with self.get_context_store() as store:
1521
+ store.record_event(
1522
+ event_type="stash",
1523
+ repo=str(self.root),
1524
+ ref=stash_ref["id"],
1525
+ actor=actor,
1526
+ payload={
1527
+ "action": "drop",
1528
+ "stash_id": stash_ref["id"],
1529
+ "message": stash_ref.get("message", ""),
1530
+ },
1531
+ )
1532
+ except Exception:
1533
+ pass
1534
+
1535
+ return stash_ref
1536
+
1537
+ def stash_clear(
1538
+ self,
1539
+ ) -> int:
1540
+ """Remove all stash entries.
1541
+
1542
+ Returns:
1543
+ Number of stash entries that were removed.
1544
+ """
1545
+ stash_list = self._load_stash_list()
1546
+ count = len(stash_list)
1547
+
1548
+ if count == 0:
1549
+ return 0
1550
+
1551
+ # Delete all stash files
1552
+ for stash_ref in stash_list:
1553
+ stash_file = self.stash_dir / f"{stash_ref['file']}.json"
1554
+ if stash_file.exists():
1555
+ stash_file.unlink()
1556
+
1557
+ # Clear stash list
1558
+ self._save_stash_list([])
1559
+
1560
+ # Record stash event
1561
+ try:
1562
+ config = self.get_config()
1563
+ actor = config.user_name if config else None
1564
+ with self.get_context_store() as store:
1565
+ store.record_event(
1566
+ event_type="stash",
1567
+ repo=str(self.root),
1568
+ ref="all",
1569
+ actor=actor,
1570
+ payload={
1571
+ "action": "clear",
1572
+ "count": count,
1573
+ },
1574
+ )
1575
+ except Exception:
1576
+ pass
1577
+
1578
+ return count
1579
+
1580
+
1581
+
1582
+ # ---- Module Functions ---------------------------------------------------------------------------------------
1583
+
1584
+
1585
+ def find_repository(
1586
+ start_path: Path | str | None = None,
1587
+ ) -> Repository | None:
1588
+ """Find GitMap repository in current or parent directories.
1589
+
1590
+ Args:
1591
+ start_path: Directory to start searching from.
1592
+
1593
+ Returns:
1594
+ Repository if found, None otherwise.
1595
+ """
1596
+ current = Path(start_path or Path.cwd()).resolve()
1597
+
1598
+ while current != current.parent:
1599
+ repo = Repository(current)
1600
+ if repo.exists():
1601
+ return repo
1602
+ current = current.parent
1603
+
1604
+ return None
1605
+
1606
+
1607
+ def init_repository(
1608
+ path: Path | str | None = None,
1609
+ project_name: str = "",
1610
+ user_name: str = "",
1611
+ user_email: str = "",
1612
+ ) -> Repository:
1613
+ """Initialize a new GitMap repository.
1614
+
1615
+ Args:
1616
+ path: Directory for new repository (defaults to cwd).
1617
+ project_name: Project name.
1618
+ user_name: Default author name.
1619
+ user_email: Default author email.
1620
+
1621
+ Returns:
1622
+ Initialized Repository.
1623
+ """
1624
+ repo = Repository(path or Path.cwd())
1625
+ repo.init(
1626
+ project_name=project_name,
1627
+ user_name=user_name,
1628
+ user_email=user_email,
1629
+ )
1630
+ return repo
1631
+
1632
+