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.
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/METADATA +20 -3
- agmem-0.1.2.dist-info/RECORD +86 -0
- memvcs/__init__.py +1 -1
- memvcs/cli.py +35 -31
- memvcs/commands/__init__.py +9 -9
- memvcs/commands/add.py +77 -76
- memvcs/commands/blame.py +46 -53
- memvcs/commands/branch.py +13 -33
- memvcs/commands/checkout.py +27 -32
- memvcs/commands/clean.py +18 -23
- memvcs/commands/clone.py +4 -1
- memvcs/commands/commit.py +40 -39
- memvcs/commands/daemon.py +81 -76
- memvcs/commands/decay.py +77 -0
- memvcs/commands/diff.py +56 -57
- memvcs/commands/distill.py +74 -0
- memvcs/commands/fsck.py +55 -61
- memvcs/commands/garden.py +28 -37
- memvcs/commands/graph.py +41 -48
- memvcs/commands/init.py +16 -24
- memvcs/commands/log.py +25 -40
- memvcs/commands/merge.py +16 -28
- memvcs/commands/pack.py +129 -0
- memvcs/commands/pull.py +4 -1
- memvcs/commands/push.py +4 -2
- memvcs/commands/recall.py +145 -0
- memvcs/commands/reflog.py +13 -22
- memvcs/commands/remote.py +1 -0
- memvcs/commands/repair.py +66 -0
- memvcs/commands/reset.py +23 -33
- memvcs/commands/resurrect.py +82 -0
- memvcs/commands/search.py +3 -4
- memvcs/commands/serve.py +2 -1
- memvcs/commands/show.py +66 -36
- memvcs/commands/stash.py +34 -34
- memvcs/commands/status.py +27 -35
- memvcs/commands/tag.py +23 -47
- memvcs/commands/test.py +30 -44
- memvcs/commands/timeline.py +111 -0
- memvcs/commands/tree.py +26 -27
- memvcs/commands/verify.py +59 -0
- memvcs/commands/when.py +115 -0
- memvcs/core/access_index.py +167 -0
- memvcs/core/config_loader.py +3 -1
- memvcs/core/consistency.py +214 -0
- memvcs/core/decay.py +185 -0
- memvcs/core/diff.py +158 -143
- memvcs/core/distiller.py +277 -0
- memvcs/core/gardener.py +164 -132
- memvcs/core/hooks.py +48 -14
- memvcs/core/knowledge_graph.py +134 -138
- memvcs/core/merge.py +248 -171
- memvcs/core/objects.py +95 -96
- memvcs/core/pii_scanner.py +147 -146
- memvcs/core/refs.py +132 -115
- memvcs/core/repository.py +174 -164
- memvcs/core/schema.py +155 -113
- memvcs/core/staging.py +60 -65
- memvcs/core/storage/__init__.py +20 -18
- memvcs/core/storage/base.py +74 -70
- memvcs/core/storage/gcs.py +70 -68
- memvcs/core/storage/local.py +42 -40
- memvcs/core/storage/s3.py +105 -110
- memvcs/core/temporal_index.py +112 -0
- memvcs/core/test_runner.py +101 -93
- memvcs/core/vector_store.py +41 -35
- memvcs/integrations/mcp_server.py +1 -3
- memvcs/integrations/web_ui/server.py +25 -26
- memvcs/retrieval/__init__.py +22 -0
- memvcs/retrieval/base.py +54 -0
- memvcs/retrieval/pack.py +128 -0
- memvcs/retrieval/recaller.py +105 -0
- memvcs/retrieval/strategies.py +314 -0
- memvcs/utils/__init__.py +3 -3
- memvcs/utils/helpers.py +52 -52
- agmem-0.1.1.dist-info/RECORD +0 -67
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/WHEEL +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/entry_points.txt +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
22
|
-
|
|
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
|
|
66
|
+
|
|
67
|
+
if "episodic" in path_lower:
|
|
65
68
|
return MergeStrategy.EPISODIC
|
|
66
|
-
elif
|
|
69
|
+
elif "semantic" in path_lower:
|
|
67
70
|
return MergeStrategy.SEMANTIC
|
|
68
|
-
elif
|
|
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 +
|
|
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(
|
|
130
|
-
|
|
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 =
|
|
159
|
+
merged = "\n\n---\n\n".join(parts)
|
|
153
160
|
return merged, False # Episodic never conflicts
|
|
154
|
-
|
|
155
|
-
def
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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
|
|
185
|
+
return theirs_content or "", False
|
|
175
186
|
if theirs_content == base_content:
|
|
176
|
-
return ours_content or
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
"""
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
|
307
|
+
return theirs_content or "", False
|
|
237
308
|
if theirs_content == base_content:
|
|
238
|
-
return ours_content or
|
|
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
|
|
321
|
+
return ours_content or "", False
|
|
251
322
|
elif comparison < 0:
|
|
252
323
|
# Theirs is newer - use it
|
|
253
|
-
return theirs_content or
|
|
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(
|
|
266
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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(
|
|
340
|
-
|
|
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[
|
|
374
|
-
target_commit_hash = self.repo.refs.get_branch_commit(head[
|
|
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[
|
|
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(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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() +
|
|
529
|
+
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
453
530
|
message=merge_message,
|
|
454
|
-
metadata={
|
|
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
|
)
|