agmem 0.1.1__py3-none-any.whl → 0.1.2__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.
Files changed (80) hide show
  1. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/METADATA +20 -3
  2. agmem-0.1.2.dist-info/RECORD +86 -0
  3. memvcs/__init__.py +1 -1
  4. memvcs/cli.py +35 -31
  5. memvcs/commands/__init__.py +9 -9
  6. memvcs/commands/add.py +77 -76
  7. memvcs/commands/blame.py +46 -53
  8. memvcs/commands/branch.py +13 -33
  9. memvcs/commands/checkout.py +27 -32
  10. memvcs/commands/clean.py +18 -23
  11. memvcs/commands/clone.py +4 -1
  12. memvcs/commands/commit.py +40 -39
  13. memvcs/commands/daemon.py +81 -76
  14. memvcs/commands/decay.py +77 -0
  15. memvcs/commands/diff.py +56 -57
  16. memvcs/commands/distill.py +74 -0
  17. memvcs/commands/fsck.py +55 -61
  18. memvcs/commands/garden.py +28 -37
  19. memvcs/commands/graph.py +41 -48
  20. memvcs/commands/init.py +16 -24
  21. memvcs/commands/log.py +25 -40
  22. memvcs/commands/merge.py +16 -28
  23. memvcs/commands/pack.py +129 -0
  24. memvcs/commands/pull.py +4 -1
  25. memvcs/commands/push.py +4 -2
  26. memvcs/commands/recall.py +145 -0
  27. memvcs/commands/reflog.py +13 -22
  28. memvcs/commands/remote.py +1 -0
  29. memvcs/commands/repair.py +66 -0
  30. memvcs/commands/reset.py +23 -33
  31. memvcs/commands/resurrect.py +82 -0
  32. memvcs/commands/search.py +3 -4
  33. memvcs/commands/serve.py +2 -1
  34. memvcs/commands/show.py +66 -36
  35. memvcs/commands/stash.py +34 -34
  36. memvcs/commands/status.py +27 -35
  37. memvcs/commands/tag.py +23 -47
  38. memvcs/commands/test.py +30 -44
  39. memvcs/commands/timeline.py +111 -0
  40. memvcs/commands/tree.py +26 -27
  41. memvcs/commands/verify.py +59 -0
  42. memvcs/commands/when.py +115 -0
  43. memvcs/core/access_index.py +167 -0
  44. memvcs/core/config_loader.py +3 -1
  45. memvcs/core/consistency.py +214 -0
  46. memvcs/core/decay.py +185 -0
  47. memvcs/core/diff.py +158 -143
  48. memvcs/core/distiller.py +277 -0
  49. memvcs/core/gardener.py +164 -132
  50. memvcs/core/hooks.py +48 -14
  51. memvcs/core/knowledge_graph.py +134 -138
  52. memvcs/core/merge.py +248 -171
  53. memvcs/core/objects.py +95 -96
  54. memvcs/core/pii_scanner.py +147 -146
  55. memvcs/core/refs.py +132 -115
  56. memvcs/core/repository.py +174 -164
  57. memvcs/core/schema.py +155 -113
  58. memvcs/core/staging.py +60 -65
  59. memvcs/core/storage/__init__.py +20 -18
  60. memvcs/core/storage/base.py +74 -70
  61. memvcs/core/storage/gcs.py +70 -68
  62. memvcs/core/storage/local.py +42 -40
  63. memvcs/core/storage/s3.py +105 -110
  64. memvcs/core/temporal_index.py +112 -0
  65. memvcs/core/test_runner.py +101 -93
  66. memvcs/core/vector_store.py +41 -35
  67. memvcs/integrations/mcp_server.py +1 -3
  68. memvcs/integrations/web_ui/server.py +25 -26
  69. memvcs/retrieval/__init__.py +22 -0
  70. memvcs/retrieval/base.py +54 -0
  71. memvcs/retrieval/pack.py +128 -0
  72. memvcs/retrieval/recaller.py +105 -0
  73. memvcs/retrieval/strategies.py +314 -0
  74. memvcs/utils/__init__.py +3 -3
  75. memvcs/utils/helpers.py +52 -52
  76. agmem-0.1.1.dist-info/RECORD +0 -67
  77. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/WHEEL +0 -0
  78. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/entry_points.txt +0 -0
  79. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/licenses/LICENSE +0 -0
  80. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/top_level.txt +0 -0
memvcs/core/merge.py CHANGED
@@ -18,14 +18,16 @@ from .schema import FrontmatterParser, FrontmatterData, compare_timestamps
18
18
 
19
19
  class MergeStrategy(Enum):
20
20
  """Merge strategies for different memory types."""
21
- EPISODIC = "episodic" # Append chronologically
22
- SEMANTIC = "semantic" # Smart consolidation with conflict detection
21
+
22
+ EPISODIC = "episodic" # Append chronologically
23
+ SEMANTIC = "semantic" # Smart consolidation with conflict detection
23
24
  PROCEDURAL = "procedural" # Prefer newer, validate compatibility
24
25
 
25
26
 
26
27
  @dataclass
27
28
  class Conflict:
28
29
  """Represents a merge conflict."""
30
+
29
31
  path: str
30
32
  base_content: Optional[str]
31
33
  ours_content: Optional[str]
@@ -36,6 +38,7 @@ class Conflict:
36
38
  @dataclass
37
39
  class MergeResult:
38
40
  """Result of a merge operation."""
41
+
39
42
  success: bool
40
43
  commit_hash: Optional[str]
41
44
  conflicts: List[Conflict]
@@ -44,215 +47,283 @@ class MergeResult:
44
47
 
45
48
  class MergeEngine:
46
49
  """Engine for merging memory branches."""
47
-
50
+
48
51
  def __init__(self, repo: Repository):
49
52
  self.repo = repo
50
53
  self.object_store = repo.object_store
51
-
54
+
52
55
  def detect_memory_type(self, filepath: str) -> MergeStrategy:
53
56
  """
54
57
  Detect the memory type from file path.
55
-
58
+
56
59
  Args:
57
60
  filepath: Path to the file
58
-
61
+
59
62
  Returns:
60
63
  MergeStrategy for this file type
61
64
  """
62
65
  path_lower = filepath.lower()
63
-
64
- if 'episodic' in path_lower:
66
+
67
+ if "episodic" in path_lower:
65
68
  return MergeStrategy.EPISODIC
66
- elif 'semantic' in path_lower:
69
+ elif "semantic" in path_lower:
67
70
  return MergeStrategy.SEMANTIC
68
- elif 'procedural' in path_lower or 'workflow' in path_lower:
71
+ elif "procedural" in path_lower or "workflow" in path_lower:
69
72
  return MergeStrategy.PROCEDURAL
70
-
73
+
71
74
  # Default to semantic for unknown types
72
75
  return MergeStrategy.SEMANTIC
73
-
76
+
74
77
  def find_common_ancestor(self, commit1: str, commit2: str) -> Optional[str]:
75
78
  """
76
79
  Find the common ancestor of two commits.
77
-
80
+
78
81
  Args:
79
82
  commit1: First commit hash
80
83
  commit2: Second commit hash
81
-
84
+
82
85
  Returns:
83
86
  Common ancestor commit hash or None
84
87
  """
85
88
  # Build ancestor chain for commit1
86
89
  ancestors1 = set()
87
90
  current = commit1
88
-
91
+
89
92
  while current:
90
93
  ancestors1.add(current)
91
94
  commit = Commit.load(self.object_store, current)
92
95
  if not commit or not commit.parents:
93
96
  break
94
97
  current = commit.parents[0] # Follow first parent
95
-
98
+
96
99
  # Walk back from commit2 and find first common ancestor
97
100
  current = commit2
98
101
  while current:
99
102
  if current in ancestors1:
100
103
  return current
101
-
104
+
102
105
  commit = Commit.load(self.object_store, current)
103
106
  if not commit or not commit.parents:
104
107
  break
105
108
  current = commit.parents[0]
106
-
109
+
107
110
  return None
108
-
111
+
109
112
  def get_tree_files(self, tree_hash: str) -> Dict[str, str]:
110
113
  """
111
114
  Get all files in a tree.
112
-
115
+
113
116
  Args:
114
117
  tree_hash: Hash of tree object
115
-
118
+
116
119
  Returns:
117
120
  Dict mapping file paths to blob hashes
118
121
  """
119
122
  files = {}
120
123
  tree = Tree.load(self.object_store, tree_hash)
121
-
124
+
122
125
  if tree:
123
126
  for entry in tree.entries:
124
- path = entry.path + '/' + entry.name if entry.path else entry.name
127
+ path = entry.path + "/" + entry.name if entry.path else entry.name
125
128
  files[path] = entry.hash
126
-
129
+
127
130
  return files
128
-
129
- def merge_episodic(self, base_content: Optional[str], ours_content: Optional[str],
130
- theirs_content: Optional[str]) -> Tuple[str, bool]:
131
+
132
+ def merge_episodic(
133
+ self,
134
+ base_content: Optional[str],
135
+ ours_content: Optional[str],
136
+ theirs_content: Optional[str],
137
+ ) -> Tuple[str, bool]:
131
138
  """
132
139
  Merge episodic memory (append chronologically).
133
-
140
+
134
141
  Returns:
135
142
  Tuple of (merged_content, had_conflict)
136
143
  """
137
144
  # Episodic logs are append-only
138
145
  parts = []
139
-
146
+
140
147
  if base_content:
141
148
  parts.append(base_content)
142
-
149
+
143
150
  # Add ours if different from base
144
151
  if ours_content and ours_content != base_content:
145
152
  parts.append(ours_content)
146
-
153
+
147
154
  # Add theirs if different from base and ours
148
155
  if theirs_content and theirs_content != base_content and theirs_content != ours_content:
149
156
  parts.append(theirs_content)
150
-
157
+
151
158
  # Combine with clear separators
152
- merged = '\n\n---\n\n'.join(parts)
159
+ merged = "\n\n---\n\n".join(parts)
153
160
  return merged, False # Episodic never conflicts
154
-
155
- def merge_semantic(self, base_content: Optional[str], ours_content: Optional[str],
156
- theirs_content: Optional[str]) -> Tuple[str, bool]:
161
+
162
+ def _get_semantic_merge_config(self) -> Dict[str, Any]:
163
+ """Get merge config for semantic memory."""
164
+ config = self.repo.get_config()
165
+ return config.get("merge", {}).get("semantic", {})
166
+
167
+ def merge_semantic(
168
+ self,
169
+ base_content: Optional[str],
170
+ ours_content: Optional[str],
171
+ theirs_content: Optional[str],
172
+ ) -> Tuple[str, bool]:
157
173
  """
158
174
  Merge semantic memory (smart consolidation).
159
-
160
- Uses frontmatter timestamps for Last-Write-Wins when both sides have valid timestamps.
161
- Falls back to conflict markers for manual review if:
162
- - Neither has frontmatter
163
- - Low confidence scores require review
164
-
165
- Returns:
166
- Tuple of (merged_content, had_conflict)
175
+
176
+ Dispatches to strategy from config: recency-wins, confidence-wins,
177
+ append-both, or llm-arbitrate.
167
178
  """
168
179
  # If ours == theirs, no conflict
169
180
  if ours_content == theirs_content:
170
- return ours_content or '', False
171
-
181
+ return ours_content or "", False
182
+
172
183
  # If one is same as base, use the other
173
184
  if ours_content == base_content:
174
- return theirs_content or '', False
185
+ return theirs_content or "", False
175
186
  if theirs_content == base_content:
176
- return ours_content or '', False
177
-
178
- # Both changed from base - try frontmatter-based resolution
179
- ours_fm, ours_body = FrontmatterParser.parse(ours_content or '')
180
- theirs_fm, theirs_body = FrontmatterParser.parse(theirs_content or '')
181
-
182
- # Check if we can use Last-Write-Wins based on timestamps
187
+ return ours_content or "", False
188
+
189
+ cfg = self._get_semantic_merge_config()
190
+ strategy = cfg.get("strategy", "recency-wins")
191
+ threshold = float(cfg.get("auto_resolve_threshold", 0.8))
192
+
193
+ if strategy == "recency-wins":
194
+ return self._merge_semantic_recency(ours_content, theirs_content)
195
+ if strategy == "confidence-wins":
196
+ return self._merge_semantic_confidence(ours_content, theirs_content, threshold)
197
+ if strategy == "append-both":
198
+ return self._merge_semantic_append(ours_content, theirs_content)
199
+ if strategy == "llm-arbitrate":
200
+ return self._merge_semantic_llm(ours_content, theirs_content)
201
+ # Default
202
+ return self._merge_semantic_recency(ours_content, theirs_content)
203
+
204
+ def _merge_semantic_recency(
205
+ self,
206
+ ours_content: Optional[str],
207
+ theirs_content: Optional[str],
208
+ ) -> Tuple[str, bool]:
209
+ """Recency-wins: newer memory wins, keep older as deprecated."""
210
+ ours_fm, _ = FrontmatterParser.parse(ours_content or "")
211
+ theirs_fm, _ = FrontmatterParser.parse(theirs_content or "")
183
212
  if ours_fm and theirs_fm and ours_fm.last_updated and theirs_fm.last_updated:
184
- # Check confidence scores - if either is low, require manual review
185
- ours_confidence = ours_fm.confidence_score or 1.0
186
- theirs_confidence = theirs_fm.confidence_score or 1.0
187
-
188
- # If both have reasonable confidence (> 0.5), use Last-Write-Wins
189
- if ours_confidence > 0.5 and theirs_confidence > 0.5:
190
- comparison = compare_timestamps(ours_fm.last_updated, theirs_fm.last_updated)
191
-
192
- if comparison > 0:
193
- # Ours is newer
194
- return ours_content or '', False
195
- elif comparison < 0:
196
- # Theirs is newer
197
- return theirs_content or '', False
198
- # Equal timestamps - fall through to conflict
199
- else:
200
- # Low confidence - add note in conflict for review
201
- merged = f"""<<<<<<< OURS (confidence: {ours_confidence})
202
- {ours_content}
203
- =======
204
- {theirs_content}
205
- >>>>>>> THEIRS (confidence: {theirs_confidence})
206
- """
207
- return merged, True
208
-
209
- # No frontmatter or timestamps - use conflict markers
210
- merged = f"""<<<<<<< OURS
211
- {ours_content}
212
- =======
213
- {theirs_content}
214
- >>>>>>> THEIRS
215
- """
216
- return merged, True
217
-
218
- def merge_procedural(self, base_content: Optional[str], ours_content: Optional[str],
219
- theirs_content: Optional[str]) -> Tuple[str, bool]:
213
+ c = compare_timestamps(ours_fm.last_updated, theirs_fm.last_updated)
214
+ if c > 0:
215
+ return ours_content or "", False
216
+ if c < 0:
217
+ return theirs_content or "", False
218
+ return ours_content or "", False # Fallback to ours
219
+
220
+ def _merge_semantic_confidence(
221
+ self,
222
+ ours_content: Optional[str],
223
+ theirs_content: Optional[str],
224
+ threshold: float,
225
+ ) -> Tuple[str, bool]:
226
+ """Confidence-wins: user-stated (high confidence) > inferred."""
227
+ ours_fm, _ = FrontmatterParser.parse(ours_content or "")
228
+ theirs_fm, _ = FrontmatterParser.parse(theirs_content or "")
229
+ ours_conf = ours_fm.confidence_score if ours_fm else 0.5
230
+ theirs_conf = theirs_fm.confidence_score if theirs_fm else 0.5
231
+ if ours_conf >= threshold and theirs_conf < threshold:
232
+ return ours_content or "", False
233
+ if theirs_conf >= threshold and ours_conf < threshold:
234
+ return theirs_content or "", False
235
+ if ours_conf >= theirs_conf:
236
+ return ours_content or "", False
237
+ return theirs_content or "", False
238
+
239
+ def _merge_semantic_append(
240
+ self,
241
+ ours_content: Optional[str],
242
+ theirs_content: Optional[str],
243
+ ) -> Tuple[str, bool]:
244
+ """Append-both: keep both with validity periods."""
245
+ ours_fm, ours_body = FrontmatterParser.parse(ours_content or "")
246
+ theirs_fm, theirs_body = FrontmatterParser.parse(theirs_content or "")
247
+ parts = []
248
+ if ours_content:
249
+ parts.append(f"<!-- valid_from: ours -->\n{ours_content}")
250
+ if theirs_content and theirs_content != ours_content:
251
+ parts.append(f"<!-- valid_from: theirs -->\n{theirs_content}")
252
+ return "\n\n---\n\n".join(parts) if parts else "", False
253
+
254
+ def _merge_semantic_llm(
255
+ self,
256
+ ours_content: Optional[str],
257
+ theirs_content: Optional[str],
258
+ ) -> Tuple[str, bool]:
259
+ """LLM arbitration: call LLM to resolve contradiction."""
260
+ try:
261
+ import openai
262
+
263
+ response = openai.chat.completions.create(
264
+ model="gpt-3.5-turbo",
265
+ messages=[
266
+ {
267
+ "role": "system",
268
+ "content": "Resolve the contradiction between two memory versions. "
269
+ "Output the merged content that best reflects the combined truth.",
270
+ },
271
+ {
272
+ "role": "user",
273
+ "content": f"OURS:\n{ours_content}\n\nTHEIRS:\n{theirs_content}",
274
+ },
275
+ ],
276
+ max_tokens=1000,
277
+ )
278
+ merged = response.choices[0].message.content.strip()
279
+ return merged, False
280
+ except Exception:
281
+ # Fallback to conflict markers
282
+ merged = f"<<<<<<< OURS\n{ours_content}\n=======\n{theirs_content}\n>>>>>>> THEIRS"
283
+ return merged, True
284
+
285
+ def merge_procedural(
286
+ self,
287
+ base_content: Optional[str],
288
+ ours_content: Optional[str],
289
+ theirs_content: Optional[str],
290
+ ) -> Tuple[str, bool]:
220
291
  """
221
292
  Merge procedural memory (prefer newer, validate).
222
-
293
+
223
294
  Uses frontmatter timestamps to determine which version is newer.
224
295
  Procedural memory is more likely to auto-resolve using Last-Write-Wins
225
296
  since workflows typically should be replaced, not merged.
226
-
297
+
227
298
  Returns:
228
299
  Tuple of (merged_content, had_conflict)
229
300
  """
230
301
  # If ours == theirs, no conflict
231
302
  if ours_content == theirs_content:
232
- return ours_content or '', False
233
-
303
+ return ours_content or "", False
304
+
234
305
  # If one is same as base, use the other
235
306
  if ours_content == base_content:
236
- return theirs_content or '', False
307
+ return theirs_content or "", False
237
308
  if theirs_content == base_content:
238
- return ours_content or '', False
239
-
309
+ return ours_content or "", False
310
+
240
311
  # Both changed - try to use frontmatter timestamps
241
- ours_fm, _ = FrontmatterParser.parse(ours_content or '')
242
- theirs_fm, _ = FrontmatterParser.parse(theirs_content or '')
243
-
312
+ ours_fm, _ = FrontmatterParser.parse(ours_content or "")
313
+ theirs_fm, _ = FrontmatterParser.parse(theirs_content or "")
314
+
244
315
  # Use timestamps if available
245
316
  if ours_fm and theirs_fm and ours_fm.last_updated and theirs_fm.last_updated:
246
317
  comparison = compare_timestamps(ours_fm.last_updated, theirs_fm.last_updated)
247
-
318
+
248
319
  if comparison > 0:
249
320
  # Ours is newer - keep it
250
- return ours_content or '', False
321
+ return ours_content or "", False
251
322
  elif comparison < 0:
252
323
  # Theirs is newer - use it
253
- return theirs_content or '', False
324
+ return theirs_content or "", False
254
325
  # Equal timestamps - fall through to conflict
255
-
326
+
256
327
  # No timestamps or equal - flag for manual review
257
328
  merged = f"""<<<<<<< OURS (Current)
258
329
  {ours_content}
@@ -261,49 +332,50 @@ class MergeEngine:
261
332
  >>>>>>> THEIRS (Incoming)
262
333
  """
263
334
  return merged, True
264
-
265
- def merge_files(self, base_files: Dict[str, str], ours_files: Dict[str, str],
266
- theirs_files: Dict[str, str]) -> Tuple[Dict[str, str], List[Conflict]]:
335
+
336
+ def merge_files(
337
+ self, base_files: Dict[str, str], ours_files: Dict[str, str], theirs_files: Dict[str, str]
338
+ ) -> Tuple[Dict[str, str], List[Conflict]]:
267
339
  """
268
340
  Merge file sets from three trees.
269
-
341
+
270
342
  Returns:
271
343
  Tuple of (merged_files, conflicts)
272
344
  """
273
345
  merged = {}
274
346
  conflicts = []
275
-
347
+
276
348
  # Get all unique file paths
277
349
  all_paths = set(base_files.keys()) | set(ours_files.keys()) | set(theirs_files.keys())
278
-
350
+
279
351
  for path in all_paths:
280
352
  base_hash = base_files.get(path)
281
353
  ours_hash = ours_files.get(path)
282
354
  theirs_hash = theirs_files.get(path)
283
-
355
+
284
356
  # Get content
285
357
  base_content = None
286
358
  ours_content = None
287
359
  theirs_content = None
288
-
360
+
289
361
  if base_hash:
290
362
  blob = Blob.load(self.object_store, base_hash)
291
363
  if blob:
292
- base_content = blob.content.decode('utf-8', errors='replace')
293
-
364
+ base_content = blob.content.decode("utf-8", errors="replace")
365
+
294
366
  if ours_hash:
295
367
  blob = Blob.load(self.object_store, ours_hash)
296
368
  if blob:
297
- ours_content = blob.content.decode('utf-8', errors='replace')
298
-
369
+ ours_content = blob.content.decode("utf-8", errors="replace")
370
+
299
371
  if theirs_hash:
300
372
  blob = Blob.load(self.object_store, theirs_hash)
301
373
  if blob:
302
- theirs_content = blob.content.decode('utf-8', errors='replace')
303
-
374
+ theirs_content = blob.content.decode("utf-8", errors="replace")
375
+
304
376
  # Determine merge strategy
305
377
  strategy = self.detect_memory_type(path)
306
-
378
+
307
379
  # Apply merge
308
380
  if strategy == MergeStrategy.EPISODIC:
309
381
  merged_content, had_conflict = self.merge_episodic(
@@ -317,35 +389,38 @@ class MergeEngine:
317
389
  merged_content, had_conflict = self.merge_semantic(
318
390
  base_content, ours_content, theirs_content
319
391
  )
320
-
392
+
321
393
  # Store merged content
322
394
  if merged_content is not None:
323
- blob = Blob(content=merged_content.encode('utf-8'))
395
+ blob = Blob(content=merged_content.encode("utf-8"))
324
396
  merged_hash = blob.store(self.object_store)
325
397
  merged[path] = merged_hash
326
-
398
+
327
399
  # Record conflict if any
328
400
  if had_conflict:
329
- conflicts.append(Conflict(
330
- path=path,
331
- base_content=base_content,
332
- ours_content=ours_content,
333
- theirs_content=theirs_content,
334
- message=f"{strategy.value} merge conflict in {path}"
335
- ))
336
-
401
+ conflicts.append(
402
+ Conflict(
403
+ path=path,
404
+ base_content=base_content,
405
+ ours_content=ours_content,
406
+ theirs_content=theirs_content,
407
+ message=f"{strategy.value} merge conflict in {path}",
408
+ )
409
+ )
410
+
337
411
  return merged, conflicts
338
-
339
- def merge(self, source_branch: str, target_branch: Optional[str] = None,
340
- message: Optional[str] = None) -> MergeResult:
412
+
413
+ def merge(
414
+ self, source_branch: str, target_branch: Optional[str] = None, message: Optional[str] = None
415
+ ) -> MergeResult:
341
416
  """
342
417
  Merge source branch into target branch (or current branch).
343
-
418
+
344
419
  Args:
345
420
  source_branch: Branch to merge from
346
421
  target_branch: Branch to merge into (None for current)
347
422
  message: Merge commit message
348
-
423
+
349
424
  Returns:
350
425
  MergeResult with success status and conflicts
351
426
  """
@@ -356,9 +431,9 @@ class MergeEngine:
356
431
  success=False,
357
432
  commit_hash=None,
358
433
  conflicts=[],
359
- message=f"Source branch not found: {source_branch}"
434
+ message=f"Source branch not found: {source_branch}",
360
435
  )
361
-
436
+
362
437
  if target_branch:
363
438
  target_commit_hash = self.repo.resolve_ref(target_branch)
364
439
  if not target_commit_hash:
@@ -366,109 +441,111 @@ class MergeEngine:
366
441
  success=False,
367
442
  commit_hash=None,
368
443
  conflicts=[],
369
- message=f"Target branch not found: {target_branch}"
444
+ message=f"Target branch not found: {target_branch}",
370
445
  )
371
446
  else:
372
447
  head = self.repo.refs.get_head()
373
- if head['type'] == 'branch':
374
- target_commit_hash = self.repo.refs.get_branch_commit(head['value'])
448
+ if head["type"] == "branch":
449
+ target_commit_hash = self.repo.refs.get_branch_commit(head["value"])
375
450
  else:
376
- target_commit_hash = head['value']
377
-
451
+ target_commit_hash = head["value"]
452
+
378
453
  # Find common ancestor
379
454
  ancestor_hash = self.find_common_ancestor(source_commit_hash, target_commit_hash)
380
-
455
+
381
456
  if ancestor_hash == source_commit_hash:
382
457
  # Already up to date
383
458
  return MergeResult(
384
459
  success=True,
385
460
  commit_hash=target_commit_hash,
386
461
  conflicts=[],
387
- message="Already up to date"
462
+ message="Already up to date",
388
463
  )
389
-
464
+
390
465
  if ancestor_hash == target_commit_hash:
391
466
  # Fast-forward
392
467
  if not target_branch:
393
468
  target_branch = self.repo.refs.get_current_branch()
394
-
469
+
395
470
  self.repo.refs.set_branch_commit(target_branch, source_commit_hash)
396
-
471
+
397
472
  return MergeResult(
398
473
  success=True,
399
474
  commit_hash=source_commit_hash,
400
475
  conflicts=[],
401
- message=f"Fast-forward to {source_branch}"
476
+ message=f"Fast-forward to {source_branch}",
402
477
  )
403
-
478
+
404
479
  # Three-way merge
405
480
  # Get trees
406
481
  ancestor_commit = Commit.load(self.object_store, ancestor_hash)
407
482
  ours_commit = Commit.load(self.object_store, target_commit_hash)
408
483
  theirs_commit = Commit.load(self.object_store, source_commit_hash)
409
-
484
+
410
485
  base_files = self.get_tree_files(ancestor_commit.tree)
411
486
  ours_files = self.get_tree_files(ours_commit.tree)
412
487
  theirs_files = self.get_tree_files(theirs_commit.tree)
413
-
488
+
414
489
  # Merge files
415
490
  merged_files, conflicts = self.merge_files(base_files, ours_files, theirs_files)
416
-
491
+
417
492
  if conflicts:
418
493
  # Stage merged files for manual resolution
419
494
  for path, hash_id in merged_files.items():
420
495
  content = Blob.load(self.object_store, hash_id).content
421
496
  self.repo.staging.add(path, hash_id, content)
422
-
497
+
423
498
  return MergeResult(
424
499
  success=False,
425
500
  commit_hash=None,
426
501
  conflicts=conflicts,
427
- message=f"Merge conflict in {len(conflicts)} file(s). Resolve conflicts and commit."
502
+ message=f"Merge conflict in {len(conflicts)} file(s). Resolve conflicts and commit.",
428
503
  )
429
-
504
+
430
505
  # Create merge commit
431
506
  # Build tree from merged files
432
507
  entries = []
433
508
  for path, hash_id in merged_files.items():
434
509
  path_obj = Path(path)
435
- entries.append(TreeEntry(
436
- mode='100644',
437
- obj_type='blob',
438
- hash=hash_id,
439
- name=path_obj.name,
440
- path=str(path_obj.parent) if str(path_obj.parent) != '.' else ''
441
- ))
442
-
510
+ entries.append(
511
+ TreeEntry(
512
+ mode="100644",
513
+ obj_type="blob",
514
+ hash=hash_id,
515
+ name=path_obj.name,
516
+ path=str(path_obj.parent) if str(path_obj.parent) != "." else "",
517
+ )
518
+ )
519
+
443
520
  tree = Tree(entries=entries)
444
521
  tree_hash = tree.store(self.object_store)
445
522
 
446
523
  merge_message = message or f"Merge branch '{source_branch}'"
447
-
524
+
448
525
  merge_commit = Commit(
449
526
  tree=tree_hash,
450
527
  parents=[target_commit_hash, source_commit_hash],
451
528
  author=self.repo.get_author(),
452
- timestamp=datetime.utcnow().isoformat() + 'Z',
529
+ timestamp=datetime.utcnow().isoformat() + "Z",
453
530
  message=merge_message,
454
- metadata={'merge': True, 'source_branch': source_branch}
531
+ metadata={"merge": True, "source_branch": source_branch},
455
532
  )
456
-
533
+
457
534
  merge_hash = merge_commit.store(self.object_store)
458
-
535
+
459
536
  # Update target branch
460
537
  if not target_branch:
461
538
  target_branch = self.repo.refs.get_current_branch()
462
-
539
+
463
540
  if target_branch:
464
541
  self.repo.refs.set_branch_commit(target_branch, merge_hash)
465
542
  else:
466
543
  # Detached HEAD
467
544
  self.repo.refs.set_head_detached(merge_hash)
468
-
545
+
469
546
  return MergeResult(
470
547
  success=True,
471
548
  commit_hash=merge_hash,
472
549
  conflicts=[],
473
- message=f"Successfully merged {source_branch}"
550
+ message=f"Successfully merged {source_branch}",
474
551
  )