agmem 0.1.1__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.
- agmem-0.1.1.dist-info/METADATA +656 -0
- agmem-0.1.1.dist-info/RECORD +67 -0
- agmem-0.1.1.dist-info/WHEEL +5 -0
- agmem-0.1.1.dist-info/entry_points.txt +2 -0
- agmem-0.1.1.dist-info/licenses/LICENSE +21 -0
- agmem-0.1.1.dist-info/top_level.txt +1 -0
- memvcs/__init__.py +9 -0
- memvcs/cli.py +178 -0
- memvcs/commands/__init__.py +23 -0
- memvcs/commands/add.py +258 -0
- memvcs/commands/base.py +23 -0
- memvcs/commands/blame.py +169 -0
- memvcs/commands/branch.py +110 -0
- memvcs/commands/checkout.py +101 -0
- memvcs/commands/clean.py +76 -0
- memvcs/commands/clone.py +91 -0
- memvcs/commands/commit.py +174 -0
- memvcs/commands/daemon.py +267 -0
- memvcs/commands/diff.py +157 -0
- memvcs/commands/fsck.py +203 -0
- memvcs/commands/garden.py +107 -0
- memvcs/commands/graph.py +151 -0
- memvcs/commands/init.py +61 -0
- memvcs/commands/log.py +103 -0
- memvcs/commands/mcp.py +59 -0
- memvcs/commands/merge.py +88 -0
- memvcs/commands/pull.py +65 -0
- memvcs/commands/push.py +143 -0
- memvcs/commands/reflog.py +52 -0
- memvcs/commands/remote.py +51 -0
- memvcs/commands/reset.py +98 -0
- memvcs/commands/search.py +163 -0
- memvcs/commands/serve.py +54 -0
- memvcs/commands/show.py +125 -0
- memvcs/commands/stash.py +97 -0
- memvcs/commands/status.py +112 -0
- memvcs/commands/tag.py +117 -0
- memvcs/commands/test.py +132 -0
- memvcs/commands/tree.py +156 -0
- memvcs/core/__init__.py +21 -0
- memvcs/core/config_loader.py +245 -0
- memvcs/core/constants.py +12 -0
- memvcs/core/diff.py +380 -0
- memvcs/core/gardener.py +466 -0
- memvcs/core/hooks.py +151 -0
- memvcs/core/knowledge_graph.py +381 -0
- memvcs/core/merge.py +474 -0
- memvcs/core/objects.py +323 -0
- memvcs/core/pii_scanner.py +343 -0
- memvcs/core/refs.py +447 -0
- memvcs/core/remote.py +278 -0
- memvcs/core/repository.py +522 -0
- memvcs/core/schema.py +414 -0
- memvcs/core/staging.py +227 -0
- memvcs/core/storage/__init__.py +72 -0
- memvcs/core/storage/base.py +359 -0
- memvcs/core/storage/gcs.py +308 -0
- memvcs/core/storage/local.py +182 -0
- memvcs/core/storage/s3.py +369 -0
- memvcs/core/test_runner.py +371 -0
- memvcs/core/vector_store.py +313 -0
- memvcs/integrations/__init__.py +5 -0
- memvcs/integrations/mcp_server.py +267 -0
- memvcs/integrations/web_ui/__init__.py +1 -0
- memvcs/integrations/web_ui/server.py +352 -0
- memvcs/utils/__init__.py +9 -0
- memvcs/utils/helpers.py +178 -0
memvcs/core/merge.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Merge functionality for agmem.
|
|
3
|
+
|
|
4
|
+
Implements memory-type-aware merging strategies with frontmatter support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
from .objects import ObjectStore, Commit, Tree, TreeEntry, Blob
|
|
15
|
+
from .repository import Repository
|
|
16
|
+
from .schema import FrontmatterParser, FrontmatterData, compare_timestamps
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MergeStrategy(Enum):
|
|
20
|
+
"""Merge strategies for different memory types."""
|
|
21
|
+
EPISODIC = "episodic" # Append chronologically
|
|
22
|
+
SEMANTIC = "semantic" # Smart consolidation with conflict detection
|
|
23
|
+
PROCEDURAL = "procedural" # Prefer newer, validate compatibility
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Conflict:
|
|
28
|
+
"""Represents a merge conflict."""
|
|
29
|
+
path: str
|
|
30
|
+
base_content: Optional[str]
|
|
31
|
+
ours_content: Optional[str]
|
|
32
|
+
theirs_content: Optional[str]
|
|
33
|
+
message: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MergeResult:
|
|
38
|
+
"""Result of a merge operation."""
|
|
39
|
+
success: bool
|
|
40
|
+
commit_hash: Optional[str]
|
|
41
|
+
conflicts: List[Conflict]
|
|
42
|
+
message: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MergeEngine:
|
|
46
|
+
"""Engine for merging memory branches."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, repo: Repository):
|
|
49
|
+
self.repo = repo
|
|
50
|
+
self.object_store = repo.object_store
|
|
51
|
+
|
|
52
|
+
def detect_memory_type(self, filepath: str) -> MergeStrategy:
|
|
53
|
+
"""
|
|
54
|
+
Detect the memory type from file path.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
filepath: Path to the file
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
MergeStrategy for this file type
|
|
61
|
+
"""
|
|
62
|
+
path_lower = filepath.lower()
|
|
63
|
+
|
|
64
|
+
if 'episodic' in path_lower:
|
|
65
|
+
return MergeStrategy.EPISODIC
|
|
66
|
+
elif 'semantic' in path_lower:
|
|
67
|
+
return MergeStrategy.SEMANTIC
|
|
68
|
+
elif 'procedural' in path_lower or 'workflow' in path_lower:
|
|
69
|
+
return MergeStrategy.PROCEDURAL
|
|
70
|
+
|
|
71
|
+
# Default to semantic for unknown types
|
|
72
|
+
return MergeStrategy.SEMANTIC
|
|
73
|
+
|
|
74
|
+
def find_common_ancestor(self, commit1: str, commit2: str) -> Optional[str]:
|
|
75
|
+
"""
|
|
76
|
+
Find the common ancestor of two commits.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
commit1: First commit hash
|
|
80
|
+
commit2: Second commit hash
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Common ancestor commit hash or None
|
|
84
|
+
"""
|
|
85
|
+
# Build ancestor chain for commit1
|
|
86
|
+
ancestors1 = set()
|
|
87
|
+
current = commit1
|
|
88
|
+
|
|
89
|
+
while current:
|
|
90
|
+
ancestors1.add(current)
|
|
91
|
+
commit = Commit.load(self.object_store, current)
|
|
92
|
+
if not commit or not commit.parents:
|
|
93
|
+
break
|
|
94
|
+
current = commit.parents[0] # Follow first parent
|
|
95
|
+
|
|
96
|
+
# Walk back from commit2 and find first common ancestor
|
|
97
|
+
current = commit2
|
|
98
|
+
while current:
|
|
99
|
+
if current in ancestors1:
|
|
100
|
+
return current
|
|
101
|
+
|
|
102
|
+
commit = Commit.load(self.object_store, current)
|
|
103
|
+
if not commit or not commit.parents:
|
|
104
|
+
break
|
|
105
|
+
current = commit.parents[0]
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def get_tree_files(self, tree_hash: str) -> Dict[str, str]:
|
|
110
|
+
"""
|
|
111
|
+
Get all files in a tree.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
tree_hash: Hash of tree object
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Dict mapping file paths to blob hashes
|
|
118
|
+
"""
|
|
119
|
+
files = {}
|
|
120
|
+
tree = Tree.load(self.object_store, tree_hash)
|
|
121
|
+
|
|
122
|
+
if tree:
|
|
123
|
+
for entry in tree.entries:
|
|
124
|
+
path = entry.path + '/' + entry.name if entry.path else entry.name
|
|
125
|
+
files[path] = entry.hash
|
|
126
|
+
|
|
127
|
+
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
|
+
Merge episodic memory (append chronologically).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Tuple of (merged_content, had_conflict)
|
|
136
|
+
"""
|
|
137
|
+
# Episodic logs are append-only
|
|
138
|
+
parts = []
|
|
139
|
+
|
|
140
|
+
if base_content:
|
|
141
|
+
parts.append(base_content)
|
|
142
|
+
|
|
143
|
+
# Add ours if different from base
|
|
144
|
+
if ours_content and ours_content != base_content:
|
|
145
|
+
parts.append(ours_content)
|
|
146
|
+
|
|
147
|
+
# Add theirs if different from base and ours
|
|
148
|
+
if theirs_content and theirs_content != base_content and theirs_content != ours_content:
|
|
149
|
+
parts.append(theirs_content)
|
|
150
|
+
|
|
151
|
+
# Combine with clear separators
|
|
152
|
+
merged = '\n\n---\n\n'.join(parts)
|
|
153
|
+
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]:
|
|
157
|
+
"""
|
|
158
|
+
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)
|
|
167
|
+
"""
|
|
168
|
+
# If ours == theirs, no conflict
|
|
169
|
+
if ours_content == theirs_content:
|
|
170
|
+
return ours_content or '', False
|
|
171
|
+
|
|
172
|
+
# If one is same as base, use the other
|
|
173
|
+
if ours_content == base_content:
|
|
174
|
+
return theirs_content or '', False
|
|
175
|
+
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
|
|
183
|
+
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]:
|
|
220
|
+
"""
|
|
221
|
+
Merge procedural memory (prefer newer, validate).
|
|
222
|
+
|
|
223
|
+
Uses frontmatter timestamps to determine which version is newer.
|
|
224
|
+
Procedural memory is more likely to auto-resolve using Last-Write-Wins
|
|
225
|
+
since workflows typically should be replaced, not merged.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Tuple of (merged_content, had_conflict)
|
|
229
|
+
"""
|
|
230
|
+
# If ours == theirs, no conflict
|
|
231
|
+
if ours_content == theirs_content:
|
|
232
|
+
return ours_content or '', False
|
|
233
|
+
|
|
234
|
+
# If one is same as base, use the other
|
|
235
|
+
if ours_content == base_content:
|
|
236
|
+
return theirs_content or '', False
|
|
237
|
+
if theirs_content == base_content:
|
|
238
|
+
return ours_content or '', False
|
|
239
|
+
|
|
240
|
+
# Both changed - try to use frontmatter timestamps
|
|
241
|
+
ours_fm, _ = FrontmatterParser.parse(ours_content or '')
|
|
242
|
+
theirs_fm, _ = FrontmatterParser.parse(theirs_content or '')
|
|
243
|
+
|
|
244
|
+
# Use timestamps if available
|
|
245
|
+
if ours_fm and theirs_fm and ours_fm.last_updated and theirs_fm.last_updated:
|
|
246
|
+
comparison = compare_timestamps(ours_fm.last_updated, theirs_fm.last_updated)
|
|
247
|
+
|
|
248
|
+
if comparison > 0:
|
|
249
|
+
# Ours is newer - keep it
|
|
250
|
+
return ours_content or '', False
|
|
251
|
+
elif comparison < 0:
|
|
252
|
+
# Theirs is newer - use it
|
|
253
|
+
return theirs_content or '', False
|
|
254
|
+
# Equal timestamps - fall through to conflict
|
|
255
|
+
|
|
256
|
+
# No timestamps or equal - flag for manual review
|
|
257
|
+
merged = f"""<<<<<<< OURS (Current)
|
|
258
|
+
{ours_content}
|
|
259
|
+
=======
|
|
260
|
+
{theirs_content}
|
|
261
|
+
>>>>>>> THEIRS (Incoming)
|
|
262
|
+
"""
|
|
263
|
+
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]]:
|
|
267
|
+
"""
|
|
268
|
+
Merge file sets from three trees.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Tuple of (merged_files, conflicts)
|
|
272
|
+
"""
|
|
273
|
+
merged = {}
|
|
274
|
+
conflicts = []
|
|
275
|
+
|
|
276
|
+
# Get all unique file paths
|
|
277
|
+
all_paths = set(base_files.keys()) | set(ours_files.keys()) | set(theirs_files.keys())
|
|
278
|
+
|
|
279
|
+
for path in all_paths:
|
|
280
|
+
base_hash = base_files.get(path)
|
|
281
|
+
ours_hash = ours_files.get(path)
|
|
282
|
+
theirs_hash = theirs_files.get(path)
|
|
283
|
+
|
|
284
|
+
# Get content
|
|
285
|
+
base_content = None
|
|
286
|
+
ours_content = None
|
|
287
|
+
theirs_content = None
|
|
288
|
+
|
|
289
|
+
if base_hash:
|
|
290
|
+
blob = Blob.load(self.object_store, base_hash)
|
|
291
|
+
if blob:
|
|
292
|
+
base_content = blob.content.decode('utf-8', errors='replace')
|
|
293
|
+
|
|
294
|
+
if ours_hash:
|
|
295
|
+
blob = Blob.load(self.object_store, ours_hash)
|
|
296
|
+
if blob:
|
|
297
|
+
ours_content = blob.content.decode('utf-8', errors='replace')
|
|
298
|
+
|
|
299
|
+
if theirs_hash:
|
|
300
|
+
blob = Blob.load(self.object_store, theirs_hash)
|
|
301
|
+
if blob:
|
|
302
|
+
theirs_content = blob.content.decode('utf-8', errors='replace')
|
|
303
|
+
|
|
304
|
+
# Determine merge strategy
|
|
305
|
+
strategy = self.detect_memory_type(path)
|
|
306
|
+
|
|
307
|
+
# Apply merge
|
|
308
|
+
if strategy == MergeStrategy.EPISODIC:
|
|
309
|
+
merged_content, had_conflict = self.merge_episodic(
|
|
310
|
+
base_content, ours_content, theirs_content
|
|
311
|
+
)
|
|
312
|
+
elif strategy == MergeStrategy.PROCEDURAL:
|
|
313
|
+
merged_content, had_conflict = self.merge_procedural(
|
|
314
|
+
base_content, ours_content, theirs_content
|
|
315
|
+
)
|
|
316
|
+
else: # SEMANTIC
|
|
317
|
+
merged_content, had_conflict = self.merge_semantic(
|
|
318
|
+
base_content, ours_content, theirs_content
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Store merged content
|
|
322
|
+
if merged_content is not None:
|
|
323
|
+
blob = Blob(content=merged_content.encode('utf-8'))
|
|
324
|
+
merged_hash = blob.store(self.object_store)
|
|
325
|
+
merged[path] = merged_hash
|
|
326
|
+
|
|
327
|
+
# Record conflict if any
|
|
328
|
+
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
|
+
|
|
337
|
+
return merged, conflicts
|
|
338
|
+
|
|
339
|
+
def merge(self, source_branch: str, target_branch: Optional[str] = None,
|
|
340
|
+
message: Optional[str] = None) -> MergeResult:
|
|
341
|
+
"""
|
|
342
|
+
Merge source branch into target branch (or current branch).
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
source_branch: Branch to merge from
|
|
346
|
+
target_branch: Branch to merge into (None for current)
|
|
347
|
+
message: Merge commit message
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
MergeResult with success status and conflicts
|
|
351
|
+
"""
|
|
352
|
+
# Resolve branches
|
|
353
|
+
source_commit_hash = self.repo.resolve_ref(source_branch)
|
|
354
|
+
if not source_commit_hash:
|
|
355
|
+
return MergeResult(
|
|
356
|
+
success=False,
|
|
357
|
+
commit_hash=None,
|
|
358
|
+
conflicts=[],
|
|
359
|
+
message=f"Source branch not found: {source_branch}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if target_branch:
|
|
363
|
+
target_commit_hash = self.repo.resolve_ref(target_branch)
|
|
364
|
+
if not target_commit_hash:
|
|
365
|
+
return MergeResult(
|
|
366
|
+
success=False,
|
|
367
|
+
commit_hash=None,
|
|
368
|
+
conflicts=[],
|
|
369
|
+
message=f"Target branch not found: {target_branch}"
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
head = self.repo.refs.get_head()
|
|
373
|
+
if head['type'] == 'branch':
|
|
374
|
+
target_commit_hash = self.repo.refs.get_branch_commit(head['value'])
|
|
375
|
+
else:
|
|
376
|
+
target_commit_hash = head['value']
|
|
377
|
+
|
|
378
|
+
# Find common ancestor
|
|
379
|
+
ancestor_hash = self.find_common_ancestor(source_commit_hash, target_commit_hash)
|
|
380
|
+
|
|
381
|
+
if ancestor_hash == source_commit_hash:
|
|
382
|
+
# Already up to date
|
|
383
|
+
return MergeResult(
|
|
384
|
+
success=True,
|
|
385
|
+
commit_hash=target_commit_hash,
|
|
386
|
+
conflicts=[],
|
|
387
|
+
message="Already up to date"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if ancestor_hash == target_commit_hash:
|
|
391
|
+
# Fast-forward
|
|
392
|
+
if not target_branch:
|
|
393
|
+
target_branch = self.repo.refs.get_current_branch()
|
|
394
|
+
|
|
395
|
+
self.repo.refs.set_branch_commit(target_branch, source_commit_hash)
|
|
396
|
+
|
|
397
|
+
return MergeResult(
|
|
398
|
+
success=True,
|
|
399
|
+
commit_hash=source_commit_hash,
|
|
400
|
+
conflicts=[],
|
|
401
|
+
message=f"Fast-forward to {source_branch}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Three-way merge
|
|
405
|
+
# Get trees
|
|
406
|
+
ancestor_commit = Commit.load(self.object_store, ancestor_hash)
|
|
407
|
+
ours_commit = Commit.load(self.object_store, target_commit_hash)
|
|
408
|
+
theirs_commit = Commit.load(self.object_store, source_commit_hash)
|
|
409
|
+
|
|
410
|
+
base_files = self.get_tree_files(ancestor_commit.tree)
|
|
411
|
+
ours_files = self.get_tree_files(ours_commit.tree)
|
|
412
|
+
theirs_files = self.get_tree_files(theirs_commit.tree)
|
|
413
|
+
|
|
414
|
+
# Merge files
|
|
415
|
+
merged_files, conflicts = self.merge_files(base_files, ours_files, theirs_files)
|
|
416
|
+
|
|
417
|
+
if conflicts:
|
|
418
|
+
# Stage merged files for manual resolution
|
|
419
|
+
for path, hash_id in merged_files.items():
|
|
420
|
+
content = Blob.load(self.object_store, hash_id).content
|
|
421
|
+
self.repo.staging.add(path, hash_id, content)
|
|
422
|
+
|
|
423
|
+
return MergeResult(
|
|
424
|
+
success=False,
|
|
425
|
+
commit_hash=None,
|
|
426
|
+
conflicts=conflicts,
|
|
427
|
+
message=f"Merge conflict in {len(conflicts)} file(s). Resolve conflicts and commit."
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Create merge commit
|
|
431
|
+
# Build tree from merged files
|
|
432
|
+
entries = []
|
|
433
|
+
for path, hash_id in merged_files.items():
|
|
434
|
+
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
|
+
|
|
443
|
+
tree = Tree(entries=entries)
|
|
444
|
+
tree_hash = tree.store(self.object_store)
|
|
445
|
+
|
|
446
|
+
merge_message = message or f"Merge branch '{source_branch}'"
|
|
447
|
+
|
|
448
|
+
merge_commit = Commit(
|
|
449
|
+
tree=tree_hash,
|
|
450
|
+
parents=[target_commit_hash, source_commit_hash],
|
|
451
|
+
author=self.repo.get_author(),
|
|
452
|
+
timestamp=datetime.utcnow().isoformat() + 'Z',
|
|
453
|
+
message=merge_message,
|
|
454
|
+
metadata={'merge': True, 'source_branch': source_branch}
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
merge_hash = merge_commit.store(self.object_store)
|
|
458
|
+
|
|
459
|
+
# Update target branch
|
|
460
|
+
if not target_branch:
|
|
461
|
+
target_branch = self.repo.refs.get_current_branch()
|
|
462
|
+
|
|
463
|
+
if target_branch:
|
|
464
|
+
self.repo.refs.set_branch_commit(target_branch, merge_hash)
|
|
465
|
+
else:
|
|
466
|
+
# Detached HEAD
|
|
467
|
+
self.repo.refs.set_head_detached(merge_hash)
|
|
468
|
+
|
|
469
|
+
return MergeResult(
|
|
470
|
+
success=True,
|
|
471
|
+
commit_hash=merge_hash,
|
|
472
|
+
conflicts=[],
|
|
473
|
+
message=f"Successfully merged {source_branch}"
|
|
474
|
+
)
|