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/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
+