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/diff.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diff functionality for agmem.
|
|
3
|
+
|
|
4
|
+
Compare commits, trees, and files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from .objects import ObjectStore, Commit, Tree, Blob
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiffType(Enum):
|
|
16
|
+
"""Types of differences."""
|
|
17
|
+
ADDED = "added"
|
|
18
|
+
DELETED = "deleted"
|
|
19
|
+
MODIFIED = "modified"
|
|
20
|
+
UNCHANGED = "unchanged"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FileDiff:
|
|
25
|
+
"""Difference for a single file."""
|
|
26
|
+
path: str
|
|
27
|
+
diff_type: DiffType
|
|
28
|
+
old_hash: Optional[str]
|
|
29
|
+
new_hash: Optional[str]
|
|
30
|
+
old_content: Optional[str]
|
|
31
|
+
new_content: Optional[str]
|
|
32
|
+
diff_lines: List[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TreeDiff:
|
|
37
|
+
"""Difference between two trees."""
|
|
38
|
+
files: List[FileDiff]
|
|
39
|
+
added_count: int
|
|
40
|
+
deleted_count: int
|
|
41
|
+
modified_count: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DiffEngine:
|
|
45
|
+
"""Engine for computing differences."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, object_store: ObjectStore):
|
|
48
|
+
self.object_store = object_store
|
|
49
|
+
|
|
50
|
+
def get_tree_files(self, tree_hash: str) -> Dict[str, str]:
|
|
51
|
+
"""Get all files in a tree with their blob hashes."""
|
|
52
|
+
files = {}
|
|
53
|
+
tree = Tree.load(self.object_store, tree_hash)
|
|
54
|
+
|
|
55
|
+
if tree:
|
|
56
|
+
for entry in tree.entries:
|
|
57
|
+
path = entry.path + '/' + entry.name if entry.path else entry.name
|
|
58
|
+
files[path] = entry.hash
|
|
59
|
+
|
|
60
|
+
return files
|
|
61
|
+
|
|
62
|
+
def get_blob_content(self, hash_id: Optional[str]) -> Optional[str]:
|
|
63
|
+
"""Get blob content as string."""
|
|
64
|
+
if not hash_id:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
blob = Blob.load(self.object_store, hash_id)
|
|
68
|
+
if blob:
|
|
69
|
+
return blob.content.decode('utf-8', errors='replace')
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def compute_line_diff(self, old_content: Optional[str], new_content: Optional[str]) -> List[str]:
|
|
73
|
+
"""
|
|
74
|
+
Compute line-by-line diff between two contents.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of diff lines with +/- prefixes
|
|
78
|
+
"""
|
|
79
|
+
old_lines = (old_content or '').splitlines(keepends=True)
|
|
80
|
+
new_lines = (new_content or '').splitlines(keepends=True)
|
|
81
|
+
|
|
82
|
+
# Simple diff algorithm (LCS-based would be better)
|
|
83
|
+
diff_lines = []
|
|
84
|
+
|
|
85
|
+
# Handle empty cases
|
|
86
|
+
if not old_lines or old_lines == ['']:
|
|
87
|
+
for line in new_lines:
|
|
88
|
+
diff_lines.append(f'+ {line.rstrip()}')
|
|
89
|
+
return diff_lines
|
|
90
|
+
|
|
91
|
+
if not new_lines or new_lines == ['']:
|
|
92
|
+
for line in old_lines:
|
|
93
|
+
diff_lines.append(f'- {line.rstrip()}')
|
|
94
|
+
return diff_lines
|
|
95
|
+
|
|
96
|
+
# Use unified diff style
|
|
97
|
+
max_lines = max(len(old_lines), len(new_lines))
|
|
98
|
+
|
|
99
|
+
i, j = 0, 0
|
|
100
|
+
while i < len(old_lines) or j < len(new_lines):
|
|
101
|
+
if i < len(old_lines) and j < len(new_lines):
|
|
102
|
+
old_line = old_lines[i].rstrip()
|
|
103
|
+
new_line = new_lines[j].rstrip()
|
|
104
|
+
|
|
105
|
+
if old_line == new_line:
|
|
106
|
+
diff_lines.append(f' {old_line}')
|
|
107
|
+
i += 1
|
|
108
|
+
j += 1
|
|
109
|
+
else:
|
|
110
|
+
# Find if this line exists later in new
|
|
111
|
+
found = False
|
|
112
|
+
for k in range(j + 1, min(j + 5, len(new_lines))):
|
|
113
|
+
if new_lines[k].rstrip() == old_line:
|
|
114
|
+
# Lines were added
|
|
115
|
+
for l in range(j, k):
|
|
116
|
+
diff_lines.append(f'+ {new_lines[l].rstrip()}')
|
|
117
|
+
j = k
|
|
118
|
+
found = True
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
if not found:
|
|
122
|
+
# Line was removed
|
|
123
|
+
diff_lines.append(f'- {old_line}')
|
|
124
|
+
i += 1
|
|
125
|
+
elif i < len(old_lines):
|
|
126
|
+
diff_lines.append(f'- {old_lines[i].rstrip()}')
|
|
127
|
+
i += 1
|
|
128
|
+
else:
|
|
129
|
+
diff_lines.append(f'+ {new_lines[j].rstrip()}')
|
|
130
|
+
j += 1
|
|
131
|
+
|
|
132
|
+
return diff_lines
|
|
133
|
+
|
|
134
|
+
def diff_trees(self, old_tree_hash: Optional[str], new_tree_hash: Optional[str]) -> TreeDiff:
|
|
135
|
+
"""
|
|
136
|
+
Compute diff between two trees.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
old_tree_hash: Hash of old tree (None for empty)
|
|
140
|
+
new_tree_hash: Hash of new tree (None for empty)
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
TreeDiff with file differences
|
|
144
|
+
"""
|
|
145
|
+
old_files = self.get_tree_files(old_tree_hash) if old_tree_hash else {}
|
|
146
|
+
new_files = self.get_tree_files(new_tree_hash) if new_tree_hash else {}
|
|
147
|
+
|
|
148
|
+
all_paths = set(old_files.keys()) | set(new_files.keys())
|
|
149
|
+
|
|
150
|
+
file_diffs = []
|
|
151
|
+
added = 0
|
|
152
|
+
deleted = 0
|
|
153
|
+
modified = 0
|
|
154
|
+
|
|
155
|
+
for path in sorted(all_paths):
|
|
156
|
+
old_hash = old_files.get(path)
|
|
157
|
+
new_hash = new_files.get(path)
|
|
158
|
+
|
|
159
|
+
if not old_hash and new_hash:
|
|
160
|
+
# Added
|
|
161
|
+
new_content = self.get_blob_content(new_hash)
|
|
162
|
+
diff_lines = self.compute_line_diff(None, new_content)
|
|
163
|
+
|
|
164
|
+
file_diffs.append(FileDiff(
|
|
165
|
+
path=path,
|
|
166
|
+
diff_type=DiffType.ADDED,
|
|
167
|
+
old_hash=None,
|
|
168
|
+
new_hash=new_hash,
|
|
169
|
+
old_content=None,
|
|
170
|
+
new_content=new_content,
|
|
171
|
+
diff_lines=diff_lines
|
|
172
|
+
))
|
|
173
|
+
added += 1
|
|
174
|
+
|
|
175
|
+
elif old_hash and not new_hash:
|
|
176
|
+
# Deleted
|
|
177
|
+
old_content = self.get_blob_content(old_hash)
|
|
178
|
+
diff_lines = self.compute_line_diff(old_content, None)
|
|
179
|
+
|
|
180
|
+
file_diffs.append(FileDiff(
|
|
181
|
+
path=path,
|
|
182
|
+
diff_type=DiffType.DELETED,
|
|
183
|
+
old_hash=old_hash,
|
|
184
|
+
new_hash=None,
|
|
185
|
+
old_content=old_content,
|
|
186
|
+
new_content=None,
|
|
187
|
+
diff_lines=diff_lines
|
|
188
|
+
))
|
|
189
|
+
deleted += 1
|
|
190
|
+
|
|
191
|
+
elif old_hash != new_hash:
|
|
192
|
+
# Modified
|
|
193
|
+
old_content = self.get_blob_content(old_hash)
|
|
194
|
+
new_content = self.get_blob_content(new_hash)
|
|
195
|
+
diff_lines = self.compute_line_diff(old_content, new_content)
|
|
196
|
+
|
|
197
|
+
file_diffs.append(FileDiff(
|
|
198
|
+
path=path,
|
|
199
|
+
diff_type=DiffType.MODIFIED,
|
|
200
|
+
old_hash=old_hash,
|
|
201
|
+
new_hash=new_hash,
|
|
202
|
+
old_content=old_content,
|
|
203
|
+
new_content=new_content,
|
|
204
|
+
diff_lines=diff_lines
|
|
205
|
+
))
|
|
206
|
+
modified += 1
|
|
207
|
+
|
|
208
|
+
return TreeDiff(
|
|
209
|
+
files=file_diffs,
|
|
210
|
+
added_count=added,
|
|
211
|
+
deleted_count=deleted,
|
|
212
|
+
modified_count=modified
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def diff_commits(self, old_commit_hash: Optional[str], new_commit_hash: Optional[str]) -> TreeDiff:
|
|
216
|
+
"""
|
|
217
|
+
Compute diff between two commits.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
old_commit_hash: Hash of old commit (None for empty)
|
|
221
|
+
new_commit_hash: Hash of new commit (None for empty)
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
TreeDiff with file differences
|
|
225
|
+
"""
|
|
226
|
+
old_tree_hash = None
|
|
227
|
+
new_tree_hash = None
|
|
228
|
+
|
|
229
|
+
if old_commit_hash:
|
|
230
|
+
old_commit = Commit.load(self.object_store, old_commit_hash)
|
|
231
|
+
if old_commit:
|
|
232
|
+
old_tree_hash = old_commit.tree
|
|
233
|
+
|
|
234
|
+
if new_commit_hash:
|
|
235
|
+
new_commit = Commit.load(self.object_store, new_commit_hash)
|
|
236
|
+
if new_commit:
|
|
237
|
+
new_tree_hash = new_commit.tree
|
|
238
|
+
|
|
239
|
+
return self.diff_trees(old_tree_hash, new_tree_hash)
|
|
240
|
+
|
|
241
|
+
def format_diff(self, tree_diff: TreeDiff, old_ref: str = 'a', new_ref: str = 'b') -> str:
|
|
242
|
+
"""
|
|
243
|
+
Format tree diff as unified diff string.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
tree_diff: TreeDiff to format
|
|
247
|
+
old_ref: Label for old version
|
|
248
|
+
new_ref: Label for new version
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Formatted diff string
|
|
252
|
+
"""
|
|
253
|
+
lines = []
|
|
254
|
+
|
|
255
|
+
for file_diff in tree_diff.files:
|
|
256
|
+
if file_diff.diff_type == DiffType.UNCHANGED:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# File header
|
|
260
|
+
lines.append(f"diff --agmem {old_ref}/{file_diff.path} {new_ref}/{file_diff.path}")
|
|
261
|
+
|
|
262
|
+
if file_diff.diff_type == DiffType.ADDED:
|
|
263
|
+
lines.append(f"new file mode 100644")
|
|
264
|
+
lines.append(f"index 0000000..{file_diff.new_hash[:7]}")
|
|
265
|
+
lines.append(f"--- /dev/null")
|
|
266
|
+
lines.append(f"+++ {new_ref}/{file_diff.path}")
|
|
267
|
+
elif file_diff.diff_type == DiffType.DELETED:
|
|
268
|
+
lines.append(f"deleted file mode 100644")
|
|
269
|
+
lines.append(f"index {file_diff.old_hash[:7]}..0000000")
|
|
270
|
+
lines.append(f"--- {old_ref}/{file_diff.path}")
|
|
271
|
+
lines.append(f"+++ /dev/null")
|
|
272
|
+
else: # MODIFIED
|
|
273
|
+
lines.append(f"index {file_diff.old_hash[:7]}..{file_diff.new_hash[:7]}")
|
|
274
|
+
lines.append(f"--- {old_ref}/{file_diff.path}")
|
|
275
|
+
lines.append(f"+++ {new_ref}/{file_diff.path}")
|
|
276
|
+
|
|
277
|
+
# Diff content
|
|
278
|
+
lines.append("@@ -1 +1 @@")
|
|
279
|
+
for diff_line in file_diff.diff_lines:
|
|
280
|
+
lines.append(diff_line)
|
|
281
|
+
|
|
282
|
+
lines.append("") # Empty line between files
|
|
283
|
+
|
|
284
|
+
# Summary
|
|
285
|
+
lines.append(f"# {tree_diff.added_count} file(s) added")
|
|
286
|
+
lines.append(f"# {tree_diff.deleted_count} file(s) deleted")
|
|
287
|
+
lines.append(f"# {tree_diff.modified_count} file(s) modified")
|
|
288
|
+
|
|
289
|
+
return '\n'.join(lines)
|
|
290
|
+
|
|
291
|
+
def diff_working_dir(self, commit_hash: str, working_files: Dict[str, bytes]) -> TreeDiff:
|
|
292
|
+
"""
|
|
293
|
+
Compute diff between a commit and working directory.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
commit_hash: Commit to compare against
|
|
297
|
+
working_files: Dict mapping paths to file contents
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
TreeDiff with differences
|
|
301
|
+
"""
|
|
302
|
+
# Get commit files
|
|
303
|
+
commit = Commit.load(self.object_store, commit_hash)
|
|
304
|
+
if not commit:
|
|
305
|
+
return TreeDiff(files=[], added_count=0, deleted_count=0, modified_count=0)
|
|
306
|
+
|
|
307
|
+
commit_files = self.get_tree_files(commit.tree)
|
|
308
|
+
|
|
309
|
+
file_diffs = []
|
|
310
|
+
added = 0
|
|
311
|
+
deleted = 0
|
|
312
|
+
modified = 0
|
|
313
|
+
|
|
314
|
+
all_paths = set(commit_files.keys()) | set(working_files.keys())
|
|
315
|
+
|
|
316
|
+
for path in sorted(all_paths):
|
|
317
|
+
commit_hash_id = commit_files.get(path)
|
|
318
|
+
working_content = working_files.get(path)
|
|
319
|
+
|
|
320
|
+
# Compute working file hash
|
|
321
|
+
working_hash = None
|
|
322
|
+
if working_content is not None:
|
|
323
|
+
blob = Blob(content=working_content)
|
|
324
|
+
working_hash = blob.store(self.object_store)
|
|
325
|
+
|
|
326
|
+
if not commit_hash_id and working_hash:
|
|
327
|
+
# Added
|
|
328
|
+
new_content = working_content.decode('utf-8', errors='replace') if working_content else None
|
|
329
|
+
diff_lines = self.compute_line_diff(None, new_content)
|
|
330
|
+
|
|
331
|
+
file_diffs.append(FileDiff(
|
|
332
|
+
path=path,
|
|
333
|
+
diff_type=DiffType.ADDED,
|
|
334
|
+
old_hash=None,
|
|
335
|
+
new_hash=working_hash,
|
|
336
|
+
old_content=None,
|
|
337
|
+
new_content=new_content,
|
|
338
|
+
diff_lines=diff_lines
|
|
339
|
+
))
|
|
340
|
+
added += 1
|
|
341
|
+
|
|
342
|
+
elif commit_hash_id and not working_hash:
|
|
343
|
+
# Deleted
|
|
344
|
+
old_content = self.get_blob_content(commit_hash_id)
|
|
345
|
+
diff_lines = self.compute_line_diff(old_content, None)
|
|
346
|
+
|
|
347
|
+
file_diffs.append(FileDiff(
|
|
348
|
+
path=path,
|
|
349
|
+
diff_type=DiffType.DELETED,
|
|
350
|
+
old_hash=commit_hash_id,
|
|
351
|
+
new_hash=None,
|
|
352
|
+
old_content=old_content,
|
|
353
|
+
new_content=None,
|
|
354
|
+
diff_lines=diff_lines
|
|
355
|
+
))
|
|
356
|
+
deleted += 1
|
|
357
|
+
|
|
358
|
+
elif commit_hash_id != working_hash:
|
|
359
|
+
# Modified
|
|
360
|
+
old_content = self.get_blob_content(commit_hash_id)
|
|
361
|
+
new_content = working_content.decode('utf-8', errors='replace') if working_content else None
|
|
362
|
+
diff_lines = self.compute_line_diff(old_content, new_content)
|
|
363
|
+
|
|
364
|
+
file_diffs.append(FileDiff(
|
|
365
|
+
path=path,
|
|
366
|
+
diff_type=DiffType.MODIFIED,
|
|
367
|
+
old_hash=commit_hash_id,
|
|
368
|
+
new_hash=working_hash,
|
|
369
|
+
old_content=old_content,
|
|
370
|
+
new_content=new_content,
|
|
371
|
+
diff_lines=diff_lines
|
|
372
|
+
))
|
|
373
|
+
modified += 1
|
|
374
|
+
|
|
375
|
+
return TreeDiff(
|
|
376
|
+
files=file_diffs,
|
|
377
|
+
added_count=added,
|
|
378
|
+
deleted_count=deleted,
|
|
379
|
+
modified_count=modified
|
|
380
|
+
)
|