gitmap-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitmap_core/README.md +46 -0
- gitmap_core/__init__.py +100 -0
- gitmap_core/communication.py +346 -0
- gitmap_core/compat.py +408 -0
- gitmap_core/connection.py +232 -0
- gitmap_core/context.py +709 -0
- gitmap_core/diff.py +283 -0
- gitmap_core/maps.py +385 -0
- gitmap_core/merge.py +449 -0
- gitmap_core/models.py +332 -0
- gitmap_core/py.typed +0 -0
- gitmap_core/pyproject.toml +48 -0
- gitmap_core/remote.py +728 -0
- gitmap_core/repository.py +1632 -0
- gitmap_core/tests/__init__.py +1 -0
- gitmap_core/tests/test_communication.py +695 -0
- gitmap_core/tests/test_compat.py +310 -0
- gitmap_core/tests/test_connection.py +314 -0
- gitmap_core/tests/test_context.py +814 -0
- gitmap_core/tests/test_diff.py +567 -0
- gitmap_core/tests/test_init.py +153 -0
- gitmap_core/tests/test_maps.py +642 -0
- gitmap_core/tests/test_merge.py +694 -0
- gitmap_core/tests/test_models.py +410 -0
- gitmap_core/tests/test_remote.py +3014 -0
- gitmap_core/tests/test_repository.py +1639 -0
- gitmap_core/tests/test_visualize.py +902 -0
- gitmap_core/visualize.py +1217 -0
- gitmap_core-0.1.0.dist-info/METADATA +961 -0
- gitmap_core-0.1.0.dist-info/RECORD +32 -0
- gitmap_core-0.1.0.dist-info/WHEEL +4 -0
- gitmap_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
|