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
gitmap_core/merge.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Layer-level merge logic for GitMap.
|
|
2
|
+
|
|
3
|
+
Provides merging functionality at the operational layer level,
|
|
4
|
+
treating each layer as an atomic unit for conflict detection.
|
|
5
|
+
|
|
6
|
+
Execution Context:
|
|
7
|
+
Library module - imported by CLI merge command
|
|
8
|
+
|
|
9
|
+
Dependencies:
|
|
10
|
+
- gitmap_core.diff: Diff operations
|
|
11
|
+
- gitmap_core.models: Data models
|
|
12
|
+
|
|
13
|
+
Metadata:
|
|
14
|
+
Version: 0.1.0
|
|
15
|
+
Author: GitMap Team
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from dataclasses import field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---- Data Classes -------------------------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MergeConflict:
|
|
30
|
+
"""Represents a merge conflict for a layer.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
layer_id: ID of the conflicting layer.
|
|
34
|
+
layer_title: Title of the layer.
|
|
35
|
+
ours: Our version of the layer.
|
|
36
|
+
theirs: Their version of the layer.
|
|
37
|
+
base: Common ancestor version (if available).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
layer_id: str
|
|
41
|
+
layer_title: str
|
|
42
|
+
ours: dict[str, Any]
|
|
43
|
+
theirs: dict[str, Any]
|
|
44
|
+
base: dict[str, Any] | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class MergeResult:
|
|
49
|
+
"""Result of a merge operation.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
success: Whether merge completed without conflicts.
|
|
53
|
+
merged_data: Resulting merged map data.
|
|
54
|
+
conflicts: List of unresolved conflicts.
|
|
55
|
+
added_layers: Layers added from source branch.
|
|
56
|
+
removed_layers: Layers removed.
|
|
57
|
+
modified_layers: Layers modified without conflict.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
success: bool = True
|
|
61
|
+
merged_data: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
conflicts: list[MergeConflict] = field(default_factory=list)
|
|
63
|
+
added_layers: list[str] = field(default_factory=list)
|
|
64
|
+
removed_layers: list[str] = field(default_factory=list)
|
|
65
|
+
modified_layers: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def has_conflicts(
|
|
69
|
+
self,
|
|
70
|
+
) -> bool:
|
|
71
|
+
"""Check if there are unresolved conflicts."""
|
|
72
|
+
return len(self.conflicts) > 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---- Merge Functions ----------------------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def merge_maps(
|
|
79
|
+
ours: dict[str, Any],
|
|
80
|
+
theirs: dict[str, Any],
|
|
81
|
+
base: dict[str, Any] | None = None,
|
|
82
|
+
) -> MergeResult:
|
|
83
|
+
"""Merge two web map states.
|
|
84
|
+
|
|
85
|
+
Performs a layer-level merge, treating each operational layer
|
|
86
|
+
as an atomic unit. Conflicts occur when the same layer is
|
|
87
|
+
modified in both maps.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
ours: Our map state (current branch).
|
|
91
|
+
theirs: Their map state (branch being merged).
|
|
92
|
+
base: Common ancestor (for three-way merge).
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
MergeResult with merged data and any conflicts.
|
|
96
|
+
"""
|
|
97
|
+
result = MergeResult()
|
|
98
|
+
|
|
99
|
+
# Start with our map as base
|
|
100
|
+
result.merged_data = _deep_copy(ours)
|
|
101
|
+
|
|
102
|
+
# Get layers from each version
|
|
103
|
+
our_layers = ours.get("operationalLayers", [])
|
|
104
|
+
their_layers = theirs.get("operationalLayers", [])
|
|
105
|
+
base_layers = base.get("operationalLayers", []) if base else []
|
|
106
|
+
|
|
107
|
+
# Index layers by ID
|
|
108
|
+
our_index = {layer.get("id"): layer for layer in our_layers if layer.get("id")}
|
|
109
|
+
their_index = {layer.get("id"): layer for layer in their_layers if layer.get("id")}
|
|
110
|
+
base_index = {layer.get("id"): layer for layer in base_layers if layer.get("id")}
|
|
111
|
+
|
|
112
|
+
# Track which layers to include in merged result
|
|
113
|
+
merged_layers = []
|
|
114
|
+
processed_ids = set()
|
|
115
|
+
|
|
116
|
+
# Process our layers first
|
|
117
|
+
for layer_id, our_layer in our_index.items():
|
|
118
|
+
processed_ids.add(layer_id)
|
|
119
|
+
|
|
120
|
+
if layer_id in their_index:
|
|
121
|
+
their_layer = their_index[layer_id]
|
|
122
|
+
base_layer = base_index.get(layer_id)
|
|
123
|
+
|
|
124
|
+
# Both have this layer - check for conflict
|
|
125
|
+
if our_layer == their_layer:
|
|
126
|
+
# Same content, no conflict
|
|
127
|
+
merged_layers.append(our_layer)
|
|
128
|
+
elif base_layer:
|
|
129
|
+
# Three-way merge
|
|
130
|
+
if our_layer == base_layer:
|
|
131
|
+
# We didn't change, use theirs
|
|
132
|
+
merged_layers.append(their_layer)
|
|
133
|
+
result.modified_layers.append(layer_id)
|
|
134
|
+
elif their_layer == base_layer:
|
|
135
|
+
# They didn't change, use ours
|
|
136
|
+
merged_layers.append(our_layer)
|
|
137
|
+
else:
|
|
138
|
+
# Both changed - conflict
|
|
139
|
+
result.conflicts.append(MergeConflict(
|
|
140
|
+
layer_id=layer_id,
|
|
141
|
+
layer_title=our_layer.get("title", "Untitled"),
|
|
142
|
+
ours=our_layer,
|
|
143
|
+
theirs=their_layer,
|
|
144
|
+
base=base_layer,
|
|
145
|
+
))
|
|
146
|
+
# Keep ours for now, user must resolve
|
|
147
|
+
merged_layers.append(our_layer)
|
|
148
|
+
else:
|
|
149
|
+
# No base, both different - conflict
|
|
150
|
+
result.conflicts.append(MergeConflict(
|
|
151
|
+
layer_id=layer_id,
|
|
152
|
+
layer_title=our_layer.get("title", "Untitled"),
|
|
153
|
+
ours=our_layer,
|
|
154
|
+
theirs=their_layer,
|
|
155
|
+
))
|
|
156
|
+
merged_layers.append(our_layer)
|
|
157
|
+
else:
|
|
158
|
+
# Only we have this layer
|
|
159
|
+
if layer_id in base_index:
|
|
160
|
+
# Was in base, they deleted it
|
|
161
|
+
# Keep it but note the deletion
|
|
162
|
+
merged_layers.append(our_layer)
|
|
163
|
+
else:
|
|
164
|
+
# We added it
|
|
165
|
+
merged_layers.append(our_layer)
|
|
166
|
+
|
|
167
|
+
# Process layers only in theirs
|
|
168
|
+
for layer_id, their_layer in their_index.items():
|
|
169
|
+
if layer_id in processed_ids:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
processed_ids.add(layer_id)
|
|
173
|
+
|
|
174
|
+
if layer_id in base_index:
|
|
175
|
+
# Was in base, we deleted it
|
|
176
|
+
# They may have modified it - treat as conflict if modified
|
|
177
|
+
base_layer = base_index[layer_id]
|
|
178
|
+
if their_layer != base_layer:
|
|
179
|
+
# They modified a layer we deleted - conflict
|
|
180
|
+
result.conflicts.append(MergeConflict(
|
|
181
|
+
layer_id=layer_id,
|
|
182
|
+
layer_title=their_layer.get("title", "Untitled"),
|
|
183
|
+
ours={}, # We deleted it
|
|
184
|
+
theirs=their_layer,
|
|
185
|
+
base=base_layer,
|
|
186
|
+
))
|
|
187
|
+
# Don't add - respect our deletion
|
|
188
|
+
else:
|
|
189
|
+
# They added this layer
|
|
190
|
+
merged_layers.append(their_layer)
|
|
191
|
+
result.added_layers.append(layer_id)
|
|
192
|
+
|
|
193
|
+
# Update merged data with layers
|
|
194
|
+
result.merged_data["operationalLayers"] = merged_layers
|
|
195
|
+
|
|
196
|
+
# Merge tables using the same logic as layers
|
|
197
|
+
our_tables = ours.get("tables", [])
|
|
198
|
+
their_tables = theirs.get("tables", [])
|
|
199
|
+
base_tables = base.get("tables", []) if base else []
|
|
200
|
+
|
|
201
|
+
# Index tables by ID
|
|
202
|
+
our_table_index = {table.get("id"): table for table in our_tables if table.get("id")}
|
|
203
|
+
their_table_index = {table.get("id"): table for table in their_tables if table.get("id")}
|
|
204
|
+
base_table_index = {table.get("id"): table for table in base_tables if table.get("id")}
|
|
205
|
+
|
|
206
|
+
# Track which tables to include in merged result
|
|
207
|
+
merged_tables = []
|
|
208
|
+
processed_table_ids = set()
|
|
209
|
+
|
|
210
|
+
# Process our tables first
|
|
211
|
+
for table_id, our_table in our_table_index.items():
|
|
212
|
+
processed_table_ids.add(table_id)
|
|
213
|
+
|
|
214
|
+
if table_id in their_table_index:
|
|
215
|
+
their_table = their_table_index[table_id]
|
|
216
|
+
base_table = base_table_index.get(table_id)
|
|
217
|
+
|
|
218
|
+
# Both have this table - check for conflict
|
|
219
|
+
if our_table == their_table:
|
|
220
|
+
# Same content, no conflict
|
|
221
|
+
merged_tables.append(our_table)
|
|
222
|
+
elif base_table:
|
|
223
|
+
# Three-way merge
|
|
224
|
+
if our_table == base_table:
|
|
225
|
+
# We didn't change, use theirs
|
|
226
|
+
merged_tables.append(their_table)
|
|
227
|
+
elif their_table == base_table:
|
|
228
|
+
# They didn't change, use ours
|
|
229
|
+
merged_tables.append(our_table)
|
|
230
|
+
else:
|
|
231
|
+
# Both changed - conflict
|
|
232
|
+
result.conflicts.append(MergeConflict(
|
|
233
|
+
layer_id=table_id,
|
|
234
|
+
layer_title=our_table.get("title", "Untitled"),
|
|
235
|
+
ours=our_table,
|
|
236
|
+
theirs=their_table,
|
|
237
|
+
base=base_table,
|
|
238
|
+
))
|
|
239
|
+
# Keep ours for now, user must resolve
|
|
240
|
+
merged_tables.append(our_table)
|
|
241
|
+
else:
|
|
242
|
+
# No base, both different - conflict
|
|
243
|
+
result.conflicts.append(MergeConflict(
|
|
244
|
+
layer_id=table_id,
|
|
245
|
+
layer_title=our_table.get("title", "Untitled"),
|
|
246
|
+
ours=our_table,
|
|
247
|
+
theirs=their_table,
|
|
248
|
+
))
|
|
249
|
+
merged_tables.append(our_table)
|
|
250
|
+
else:
|
|
251
|
+
# Only we have this table
|
|
252
|
+
if table_id in base_table_index:
|
|
253
|
+
# Was in base, they deleted it - keep it
|
|
254
|
+
merged_tables.append(our_table)
|
|
255
|
+
else:
|
|
256
|
+
# We added it
|
|
257
|
+
merged_tables.append(our_table)
|
|
258
|
+
|
|
259
|
+
# Process tables only in theirs
|
|
260
|
+
for table_id, their_table in their_table_index.items():
|
|
261
|
+
if table_id in processed_table_ids:
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
processed_table_ids.add(table_id)
|
|
265
|
+
|
|
266
|
+
if table_id in base_table_index:
|
|
267
|
+
# Was in base, we deleted it
|
|
268
|
+
base_table = base_table_index[table_id]
|
|
269
|
+
if their_table != base_table:
|
|
270
|
+
# They modified a table we deleted - conflict
|
|
271
|
+
result.conflicts.append(MergeConflict(
|
|
272
|
+
layer_id=table_id,
|
|
273
|
+
layer_title=their_table.get("title", "Untitled"),
|
|
274
|
+
ours={}, # We deleted it
|
|
275
|
+
theirs=their_table,
|
|
276
|
+
base=base_table,
|
|
277
|
+
))
|
|
278
|
+
# Don't add - respect our deletion
|
|
279
|
+
else:
|
|
280
|
+
# They added this table
|
|
281
|
+
merged_tables.append(their_table)
|
|
282
|
+
result.added_layers.append(table_id)
|
|
283
|
+
|
|
284
|
+
# Update merged data with tables
|
|
285
|
+
result.merged_data["tables"] = merged_tables
|
|
286
|
+
result.success = not result.has_conflicts
|
|
287
|
+
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _deep_copy(
|
|
292
|
+
obj: dict[str, Any],
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
"""Create a deep copy of a dictionary.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
obj: Dictionary to copy.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Deep copy of the dictionary.
|
|
301
|
+
"""
|
|
302
|
+
import json
|
|
303
|
+
return json.loads(json.dumps(obj))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def resolve_conflict(
|
|
307
|
+
conflict: MergeConflict,
|
|
308
|
+
resolution: str,
|
|
309
|
+
) -> dict[str, Any]:
|
|
310
|
+
"""Resolve a merge conflict.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
conflict: The conflict to resolve.
|
|
314
|
+
resolution: Resolution strategy ('ours', 'theirs', or 'base').
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Resolved layer data.
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ValueError: If resolution strategy is invalid.
|
|
321
|
+
"""
|
|
322
|
+
if resolution == "ours":
|
|
323
|
+
return conflict.ours
|
|
324
|
+
elif resolution == "theirs":
|
|
325
|
+
return conflict.theirs
|
|
326
|
+
elif resolution == "base":
|
|
327
|
+
if conflict.base is None:
|
|
328
|
+
msg = "No base version available"
|
|
329
|
+
raise ValueError(msg)
|
|
330
|
+
return conflict.base
|
|
331
|
+
else:
|
|
332
|
+
msg = f"Invalid resolution strategy: {resolution}"
|
|
333
|
+
raise ValueError(msg)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def apply_resolution(
|
|
337
|
+
merge_result: MergeResult,
|
|
338
|
+
layer_id: str,
|
|
339
|
+
resolved_layer: dict[str, Any],
|
|
340
|
+
) -> MergeResult:
|
|
341
|
+
"""Apply a conflict resolution to merge result.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
merge_result: Current merge result.
|
|
345
|
+
layer_id: ID of layer/table being resolved.
|
|
346
|
+
resolved_layer: Resolved layer/table data.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Updated merge result.
|
|
350
|
+
"""
|
|
351
|
+
# Find and remove the conflict
|
|
352
|
+
merge_result.conflicts = [
|
|
353
|
+
c for c in merge_result.conflicts
|
|
354
|
+
if c.layer_id != layer_id
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
# Check if this is a table or layer
|
|
358
|
+
tables = merge_result.merged_data.get("tables", [])
|
|
359
|
+
table_ids = {table.get("id") for table in tables if table.get("id")}
|
|
360
|
+
is_table = layer_id in table_ids
|
|
361
|
+
|
|
362
|
+
if is_table:
|
|
363
|
+
# Update the table in merged data
|
|
364
|
+
for i, table in enumerate(tables):
|
|
365
|
+
if table.get("id") == layer_id:
|
|
366
|
+
if resolved_layer:
|
|
367
|
+
tables[i] = resolved_layer
|
|
368
|
+
else:
|
|
369
|
+
# Empty resolution means delete
|
|
370
|
+
del tables[i]
|
|
371
|
+
break
|
|
372
|
+
else:
|
|
373
|
+
# Table not found, add it if not empty
|
|
374
|
+
if resolved_layer:
|
|
375
|
+
tables.append(resolved_layer)
|
|
376
|
+
|
|
377
|
+
merge_result.merged_data["tables"] = tables
|
|
378
|
+
else:
|
|
379
|
+
# Update the layer in merged data
|
|
380
|
+
layers = merge_result.merged_data.get("operationalLayers", [])
|
|
381
|
+
for i, layer in enumerate(layers):
|
|
382
|
+
if layer.get("id") == layer_id:
|
|
383
|
+
if resolved_layer:
|
|
384
|
+
layers[i] = resolved_layer
|
|
385
|
+
else:
|
|
386
|
+
# Empty resolution means delete
|
|
387
|
+
del layers[i]
|
|
388
|
+
break
|
|
389
|
+
else:
|
|
390
|
+
# Layer not found, add it if not empty
|
|
391
|
+
if resolved_layer:
|
|
392
|
+
layers.append(resolved_layer)
|
|
393
|
+
|
|
394
|
+
merge_result.merged_data["operationalLayers"] = layers
|
|
395
|
+
|
|
396
|
+
merge_result.success = not merge_result.has_conflicts
|
|
397
|
+
|
|
398
|
+
return merge_result
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---- Formatting Functions -----------------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def format_merge_summary(
|
|
405
|
+
result: MergeResult,
|
|
406
|
+
) -> str:
|
|
407
|
+
"""Format merge result as human-readable summary.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
result: MergeResult object.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Formatted string summary.
|
|
414
|
+
"""
|
|
415
|
+
lines = []
|
|
416
|
+
|
|
417
|
+
if result.success:
|
|
418
|
+
lines.append("Merge completed successfully.")
|
|
419
|
+
else:
|
|
420
|
+
lines.append(f"Merge has {len(result.conflicts)} conflict(s).")
|
|
421
|
+
|
|
422
|
+
if result.added_layers:
|
|
423
|
+
lines.append(f"Added layers: {len(result.added_layers)}")
|
|
424
|
+
for layer_id in result.added_layers:
|
|
425
|
+
lines.append(f" + {layer_id}")
|
|
426
|
+
|
|
427
|
+
if result.removed_layers:
|
|
428
|
+
lines.append(f"Removed layers: {len(result.removed_layers)}")
|
|
429
|
+
for layer_id in result.removed_layers:
|
|
430
|
+
lines.append(f" - {layer_id}")
|
|
431
|
+
|
|
432
|
+
if result.modified_layers:
|
|
433
|
+
lines.append(f"Modified layers: {len(result.modified_layers)}")
|
|
434
|
+
for layer_id in result.modified_layers:
|
|
435
|
+
lines.append(f" ~ {layer_id}")
|
|
436
|
+
|
|
437
|
+
# Count added/removed tables from conflicts and merged data
|
|
438
|
+
merged_tables = result.merged_data.get("tables", [])
|
|
439
|
+
if merged_tables:
|
|
440
|
+
lines.append(f"Merged tables: {len(merged_tables)}")
|
|
441
|
+
|
|
442
|
+
if result.conflicts:
|
|
443
|
+
lines.append("Conflicts:")
|
|
444
|
+
for conflict in result.conflicts:
|
|
445
|
+
lines.append(f" ! {conflict.layer_title} ({conflict.layer_id})")
|
|
446
|
+
|
|
447
|
+
return "\n".join(lines)
|
|
448
|
+
|
|
449
|
+
|