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
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main repository class for agmem.
|
|
3
|
+
|
|
4
|
+
Coordinates object storage, staging area, and references.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, List, Dict, Any
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from .constants import MEMORY_TYPES
|
|
15
|
+
from .config_loader import load_agmem_config
|
|
16
|
+
from .objects import ObjectStore, Blob, Tree, TreeEntry, Commit
|
|
17
|
+
from .staging import StagingArea
|
|
18
|
+
from .refs import RefsManager
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Repository:
|
|
22
|
+
"""Main repository class coordinating all agmem operations."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: Path):
|
|
25
|
+
self.root = Path(path).resolve()
|
|
26
|
+
self.mem_dir = self.root / '.mem'
|
|
27
|
+
self.current_dir = self.root / 'current'
|
|
28
|
+
self.config_file = self.mem_dir / 'config.json'
|
|
29
|
+
|
|
30
|
+
self.object_store: Optional[ObjectStore] = None
|
|
31
|
+
self.staging: Optional[StagingArea] = None
|
|
32
|
+
self.refs: Optional[RefsManager] = None
|
|
33
|
+
|
|
34
|
+
if self.is_valid_repo():
|
|
35
|
+
self._init_components()
|
|
36
|
+
|
|
37
|
+
def _init_components(self):
|
|
38
|
+
"""Initialize repository components."""
|
|
39
|
+
self.object_store = ObjectStore(self.mem_dir / 'objects')
|
|
40
|
+
self.staging = StagingArea(self.mem_dir)
|
|
41
|
+
self.refs = RefsManager(self.mem_dir)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def init(cls, path: Path, author_name: str = 'Agent', author_email: str = 'agent@example.com') -> 'Repository':
|
|
45
|
+
"""
|
|
46
|
+
Initialize a new repository.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
path: Directory to initialize repository in
|
|
50
|
+
author_name: Default author name
|
|
51
|
+
author_email: Default author email
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Initialized Repository instance
|
|
55
|
+
"""
|
|
56
|
+
repo = cls(path)
|
|
57
|
+
|
|
58
|
+
if repo.is_valid_repo():
|
|
59
|
+
raise ValueError(f"Repository already exists at {path}")
|
|
60
|
+
|
|
61
|
+
# Create directory structure
|
|
62
|
+
repo.mem_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
repo.current_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
for mem_type in MEMORY_TYPES:
|
|
66
|
+
(repo.current_dir / mem_type).mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
# Create object store directories
|
|
69
|
+
(repo.mem_dir / 'objects').mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
# Create staging directory
|
|
72
|
+
(repo.mem_dir / 'staging').mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
# Create refs directories
|
|
75
|
+
(repo.mem_dir / 'refs' / 'heads').mkdir(parents=True, exist_ok=True)
|
|
76
|
+
(repo.mem_dir / 'refs' / 'tags').mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
# Create config
|
|
79
|
+
config = {
|
|
80
|
+
'author': {
|
|
81
|
+
'name': author_name,
|
|
82
|
+
'email': author_email
|
|
83
|
+
},
|
|
84
|
+
'core': {
|
|
85
|
+
'default_branch': 'main',
|
|
86
|
+
'compression': True,
|
|
87
|
+
'gc_prune_days': 90
|
|
88
|
+
},
|
|
89
|
+
'memory': {
|
|
90
|
+
'auto_summarize': True,
|
|
91
|
+
'summarizer_model': 'default',
|
|
92
|
+
'max_episode_size': 1024 * 1024, # 1MB
|
|
93
|
+
'consolidation_threshold': 100 # Episodes before consolidation
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
repo.config_file.write_text(json.dumps(config, indent=2))
|
|
97
|
+
|
|
98
|
+
# Initialize components
|
|
99
|
+
repo._init_components()
|
|
100
|
+
|
|
101
|
+
# Initialize HEAD
|
|
102
|
+
repo.refs.init_head('main')
|
|
103
|
+
|
|
104
|
+
return repo
|
|
105
|
+
|
|
106
|
+
def is_valid_repo(self) -> bool:
|
|
107
|
+
"""Check if this is a valid repository."""
|
|
108
|
+
return (
|
|
109
|
+
self.mem_dir.exists() and
|
|
110
|
+
self.config_file.exists() and
|
|
111
|
+
(self.mem_dir / 'objects').exists()
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def get_config(self) -> Dict[str, Any]:
|
|
115
|
+
"""Get repository configuration."""
|
|
116
|
+
if self.config_file.exists():
|
|
117
|
+
return json.loads(self.config_file.read_text())
|
|
118
|
+
return {}
|
|
119
|
+
|
|
120
|
+
def set_config(self, config: Dict[str, Any]):
|
|
121
|
+
"""Set repository configuration."""
|
|
122
|
+
self.config_file.write_text(json.dumps(config, indent=2))
|
|
123
|
+
|
|
124
|
+
def get_author(self) -> str:
|
|
125
|
+
"""Get the configured author string."""
|
|
126
|
+
config = self.get_config()
|
|
127
|
+
author = config.get('author', {})
|
|
128
|
+
name = author.get('name', 'Agent')
|
|
129
|
+
email = author.get('email', 'agent@example.com')
|
|
130
|
+
return f"{name} <{email}>"
|
|
131
|
+
|
|
132
|
+
def get_agmem_config(self) -> Dict[str, Any]:
|
|
133
|
+
"""Get merged agmem config (user + repo). Use for cloud and PII settings."""
|
|
134
|
+
return load_agmem_config(self.root)
|
|
135
|
+
|
|
136
|
+
def get_head_commit(self) -> Optional[Commit]:
|
|
137
|
+
"""Get the current HEAD commit object."""
|
|
138
|
+
if not self.refs:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
head = self.refs.get_head()
|
|
142
|
+
if head['type'] == 'branch':
|
|
143
|
+
commit_hash = self.refs.get_branch_commit(head['value'])
|
|
144
|
+
else:
|
|
145
|
+
commit_hash = head['value']
|
|
146
|
+
|
|
147
|
+
if commit_hash:
|
|
148
|
+
return Commit.load(self.object_store, commit_hash)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def get_commit_tree(self, commit_hash: str) -> Optional[Tree]:
|
|
152
|
+
"""Get the tree for a specific commit."""
|
|
153
|
+
commit = Commit.load(self.object_store, commit_hash)
|
|
154
|
+
if commit:
|
|
155
|
+
return Tree.load(self.object_store, commit.tree)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def resolve_ref(self, ref: str) -> Optional[str]:
|
|
159
|
+
"""Resolve a reference (branch, tag, HEAD, HEAD~n, commit hash) to a commit hash."""
|
|
160
|
+
if not self.refs:
|
|
161
|
+
return None
|
|
162
|
+
return self.refs.resolve_ref(ref, self.object_store)
|
|
163
|
+
|
|
164
|
+
def _path_under_current_dir(self, relative_path: str) -> Optional[Path]:
|
|
165
|
+
"""Resolve path under current/; return None if it escapes (path traversal)."""
|
|
166
|
+
try:
|
|
167
|
+
resolved = (self.current_dir / relative_path).resolve()
|
|
168
|
+
resolved.relative_to(self.current_dir.resolve())
|
|
169
|
+
return resolved
|
|
170
|
+
except ValueError:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def stage_file(self, filepath: str, content: Optional[bytes] = None) -> str:
|
|
174
|
+
"""
|
|
175
|
+
Stage a file for commit.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
filepath: Path relative to current/ directory
|
|
179
|
+
content: File content (if None, reads from current/)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Blob hash of staged content
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
FileNotFoundError: If file does not exist
|
|
186
|
+
ValueError: If filepath escapes current/ (path traversal)
|
|
187
|
+
"""
|
|
188
|
+
if content is None:
|
|
189
|
+
full_path = self._path_under_current_dir(filepath)
|
|
190
|
+
if full_path is None:
|
|
191
|
+
raise ValueError(f"Path escapes current directory: {filepath}")
|
|
192
|
+
if not full_path.exists():
|
|
193
|
+
raise FileNotFoundError(f"File not found: {filepath}")
|
|
194
|
+
content = full_path.read_bytes()
|
|
195
|
+
|
|
196
|
+
# Store as blob
|
|
197
|
+
blob = Blob(content=content)
|
|
198
|
+
blob_hash = blob.store(self.object_store)
|
|
199
|
+
|
|
200
|
+
# Add to staging area
|
|
201
|
+
self.staging.add(filepath, blob_hash, content)
|
|
202
|
+
|
|
203
|
+
return blob_hash
|
|
204
|
+
|
|
205
|
+
def _build_tree_from_staged(self) -> str:
|
|
206
|
+
"""Build and store tree from staged files. Returns tree hash."""
|
|
207
|
+
staged_files = self.staging.get_staged_files()
|
|
208
|
+
entries = []
|
|
209
|
+
for path, sf in staged_files.items():
|
|
210
|
+
path_obj = Path(path)
|
|
211
|
+
entries.append(TreeEntry(
|
|
212
|
+
mode=oct(sf.mode)[2:],
|
|
213
|
+
obj_type='blob',
|
|
214
|
+
hash=sf.blob_hash,
|
|
215
|
+
name=path_obj.name,
|
|
216
|
+
path=str(path_obj.parent) if str(path_obj.parent) != '.' else ''
|
|
217
|
+
))
|
|
218
|
+
tree = Tree(entries=entries)
|
|
219
|
+
return tree.store(self.object_store)
|
|
220
|
+
|
|
221
|
+
def _restore_tree_to_current_dir(self, tree: Tree) -> None:
|
|
222
|
+
"""Clear current dir and restore files from tree."""
|
|
223
|
+
for item in self.current_dir.iterdir():
|
|
224
|
+
if item.is_dir():
|
|
225
|
+
shutil.rmtree(item)
|
|
226
|
+
else:
|
|
227
|
+
item.unlink()
|
|
228
|
+
for mem_type in MEMORY_TYPES:
|
|
229
|
+
(self.current_dir / mem_type).mkdir(exist_ok=True)
|
|
230
|
+
current_resolved = self.current_dir.resolve()
|
|
231
|
+
for entry in tree.entries:
|
|
232
|
+
# Prevent path traversal: ensure entry path stays under current/
|
|
233
|
+
try:
|
|
234
|
+
filepath = (self.current_dir / entry.path / entry.name).resolve()
|
|
235
|
+
filepath.relative_to(current_resolved)
|
|
236
|
+
except (ValueError, RuntimeError):
|
|
237
|
+
continue
|
|
238
|
+
blob = Blob.load(self.object_store, entry.hash)
|
|
239
|
+
if blob:
|
|
240
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
filepath.write_bytes(blob.content)
|
|
242
|
+
|
|
243
|
+
def stage_directory(self, dirpath: str = '') -> Dict[str, str]:
|
|
244
|
+
"""
|
|
245
|
+
Stage all files in a directory.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
dirpath: Directory path relative to current/ (empty for all)
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Dict mapping file paths to blob hashes
|
|
252
|
+
"""
|
|
253
|
+
target_dir = self.current_dir / dirpath if dirpath else self.current_dir
|
|
254
|
+
staged = {}
|
|
255
|
+
|
|
256
|
+
for root, dirs, files in os.walk(target_dir):
|
|
257
|
+
# Skip hidden directories
|
|
258
|
+
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
259
|
+
|
|
260
|
+
for filename in files:
|
|
261
|
+
full_path = Path(root) / filename
|
|
262
|
+
rel_path = full_path.relative_to(self.current_dir)
|
|
263
|
+
|
|
264
|
+
content = full_path.read_bytes()
|
|
265
|
+
blob_hash = self.stage_file(str(rel_path), content)
|
|
266
|
+
staged[str(rel_path)] = blob_hash
|
|
267
|
+
|
|
268
|
+
return staged
|
|
269
|
+
|
|
270
|
+
def commit(self, message: str, metadata: Optional[Dict[str, Any]] = None) -> str:
|
|
271
|
+
"""
|
|
272
|
+
Create a commit from staged changes.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
message: Commit message
|
|
276
|
+
metadata: Additional metadata
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Commit hash
|
|
280
|
+
"""
|
|
281
|
+
staged_files = self.staging.get_staged_files()
|
|
282
|
+
|
|
283
|
+
if not staged_files:
|
|
284
|
+
raise ValueError("No changes staged for commit")
|
|
285
|
+
|
|
286
|
+
tree_hash = self._build_tree_from_staged()
|
|
287
|
+
|
|
288
|
+
# Get parent commit
|
|
289
|
+
head_commit = self.get_head_commit()
|
|
290
|
+
parents = [head_commit.store(self.object_store)] if head_commit else []
|
|
291
|
+
|
|
292
|
+
# Create commit
|
|
293
|
+
commit = Commit(
|
|
294
|
+
tree=tree_hash,
|
|
295
|
+
parents=parents,
|
|
296
|
+
author=self.get_author(),
|
|
297
|
+
timestamp=datetime.utcnow().isoformat() + 'Z',
|
|
298
|
+
message=message,
|
|
299
|
+
metadata=metadata or {}
|
|
300
|
+
)
|
|
301
|
+
commit_hash = commit.store(self.object_store)
|
|
302
|
+
|
|
303
|
+
# Reflog: record HEAD change
|
|
304
|
+
old_hash = parents[0] if parents else '0' * 64
|
|
305
|
+
self.refs.append_reflog('HEAD', old_hash, commit_hash, f'commit: {message}')
|
|
306
|
+
|
|
307
|
+
# Update HEAD
|
|
308
|
+
head = self.refs.get_head()
|
|
309
|
+
if head['type'] == 'branch':
|
|
310
|
+
self.refs.set_branch_commit(head['value'], commit_hash)
|
|
311
|
+
else:
|
|
312
|
+
self.refs.set_head_detached(commit_hash)
|
|
313
|
+
|
|
314
|
+
# Clear staging area
|
|
315
|
+
self.staging.clear()
|
|
316
|
+
|
|
317
|
+
return commit_hash
|
|
318
|
+
|
|
319
|
+
def checkout(self, ref: str, force: bool = False) -> str:
|
|
320
|
+
"""
|
|
321
|
+
Checkout a commit or branch.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
ref: Branch name, tag name, or commit hash
|
|
325
|
+
force: Whether to discard uncommitted changes
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Commit hash that was checked out
|
|
329
|
+
"""
|
|
330
|
+
# Get current HEAD for reflog
|
|
331
|
+
old_head = self.refs.get_head()
|
|
332
|
+
old_hash = None
|
|
333
|
+
if old_head['type'] == 'branch':
|
|
334
|
+
old_hash = self.refs.get_branch_commit(old_head['value'])
|
|
335
|
+
else:
|
|
336
|
+
old_hash = old_head.get('value')
|
|
337
|
+
|
|
338
|
+
# Resolve reference
|
|
339
|
+
commit_hash = self.resolve_ref(ref)
|
|
340
|
+
if not commit_hash:
|
|
341
|
+
raise ValueError(f"Reference not found: {ref}")
|
|
342
|
+
|
|
343
|
+
# Validate that the resolved ref is a valid commit
|
|
344
|
+
tree = self.get_commit_tree(commit_hash)
|
|
345
|
+
if not tree:
|
|
346
|
+
raise ValueError(f"Reference not found: {ref}")
|
|
347
|
+
|
|
348
|
+
# Check for uncommitted changes
|
|
349
|
+
if not force:
|
|
350
|
+
staged = self.staging.get_staged_files()
|
|
351
|
+
if staged:
|
|
352
|
+
raise ValueError(
|
|
353
|
+
"You have uncommitted changes. "
|
|
354
|
+
"Commit them or use --force to discard."
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
self._restore_tree_to_current_dir(tree)
|
|
358
|
+
|
|
359
|
+
# Reflog: record HEAD change
|
|
360
|
+
if old_hash and old_hash != commit_hash:
|
|
361
|
+
self.refs.append_reflog('HEAD', old_hash, commit_hash, f'checkout: moving to {ref}')
|
|
362
|
+
|
|
363
|
+
# Update HEAD
|
|
364
|
+
if self.refs.branch_exists(ref):
|
|
365
|
+
self.refs.set_head_branch(ref)
|
|
366
|
+
else:
|
|
367
|
+
self.refs.set_head_detached(commit_hash)
|
|
368
|
+
|
|
369
|
+
# Clear staging
|
|
370
|
+
self.staging.clear()
|
|
371
|
+
|
|
372
|
+
return commit_hash
|
|
373
|
+
|
|
374
|
+
def get_status(self) -> Dict[str, Any]:
|
|
375
|
+
"""
|
|
376
|
+
Get repository status.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Status dictionary with staged, modified, untracked files
|
|
380
|
+
"""
|
|
381
|
+
staged = self.staging.get_staged_files()
|
|
382
|
+
|
|
383
|
+
# Compare current directory with HEAD
|
|
384
|
+
head_commit = self.get_head_commit()
|
|
385
|
+
head_files = {}
|
|
386
|
+
|
|
387
|
+
if head_commit:
|
|
388
|
+
tree = Tree.load(self.object_store, head_commit.tree)
|
|
389
|
+
if tree:
|
|
390
|
+
for entry in tree.entries:
|
|
391
|
+
path = entry.path + '/' + entry.name if entry.path else entry.name
|
|
392
|
+
head_files[path] = entry.hash
|
|
393
|
+
|
|
394
|
+
# Check working directory
|
|
395
|
+
modified = []
|
|
396
|
+
untracked = []
|
|
397
|
+
|
|
398
|
+
for root, dirs, files in os.walk(self.current_dir):
|
|
399
|
+
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
400
|
+
|
|
401
|
+
for filename in files:
|
|
402
|
+
full_path = Path(root) / filename
|
|
403
|
+
rel_path = str(full_path.relative_to(self.current_dir))
|
|
404
|
+
|
|
405
|
+
if rel_path not in staged:
|
|
406
|
+
content = full_path.read_bytes()
|
|
407
|
+
blob = Blob(content=content)
|
|
408
|
+
blob_hash = blob.store(self.object_store)
|
|
409
|
+
|
|
410
|
+
if rel_path in head_files:
|
|
411
|
+
if head_files[rel_path] != blob_hash:
|
|
412
|
+
modified.append(rel_path)
|
|
413
|
+
else:
|
|
414
|
+
untracked.append(rel_path)
|
|
415
|
+
|
|
416
|
+
# Check for deleted files
|
|
417
|
+
deleted = []
|
|
418
|
+
for path in head_files:
|
|
419
|
+
full_path = self.current_dir / path
|
|
420
|
+
if not full_path.exists() and path not in staged:
|
|
421
|
+
deleted.append(path)
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
'staged': list(staged.keys()),
|
|
425
|
+
'modified': modified,
|
|
426
|
+
'untracked': untracked,
|
|
427
|
+
'deleted': deleted,
|
|
428
|
+
'head': self.refs.get_head(),
|
|
429
|
+
'branch': self.refs.get_current_branch()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
def get_log(self, max_count: int = 10) -> List[Dict[str, Any]]:
|
|
433
|
+
"""
|
|
434
|
+
Get commit history.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
max_count: Maximum number of commits to return
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
List of commit info dictionaries
|
|
441
|
+
"""
|
|
442
|
+
commits = []
|
|
443
|
+
commit_hash = None
|
|
444
|
+
|
|
445
|
+
# Get starting commit
|
|
446
|
+
head = self.refs.get_head()
|
|
447
|
+
if head['type'] == 'branch':
|
|
448
|
+
commit_hash = self.refs.get_branch_commit(head['value'])
|
|
449
|
+
else:
|
|
450
|
+
commit_hash = head['value']
|
|
451
|
+
|
|
452
|
+
# Walk back through parents
|
|
453
|
+
while commit_hash and len(commits) < max_count:
|
|
454
|
+
commit = Commit.load(self.object_store, commit_hash)
|
|
455
|
+
if not commit:
|
|
456
|
+
break
|
|
457
|
+
|
|
458
|
+
commits.append({
|
|
459
|
+
'hash': commit_hash,
|
|
460
|
+
'short_hash': commit_hash[:8],
|
|
461
|
+
'message': commit.message,
|
|
462
|
+
'author': commit.author,
|
|
463
|
+
'timestamp': commit.timestamp,
|
|
464
|
+
'parents': commit.parents
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
# Follow first parent (linear history for now)
|
|
468
|
+
commit_hash = commit.parents[0] if commit.parents else None
|
|
469
|
+
|
|
470
|
+
return commits
|
|
471
|
+
|
|
472
|
+
def stash_create(self, message: str = '') -> Optional[str]:
|
|
473
|
+
"""
|
|
474
|
+
Stash current changes (staged + modified + untracked) and reset to HEAD.
|
|
475
|
+
Returns stash commit hash or None if nothing to stash.
|
|
476
|
+
"""
|
|
477
|
+
status = self.get_status()
|
|
478
|
+
if not status['staged'] and not status['modified'] and not status['untracked']:
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
# Stage everything
|
|
482
|
+
self.stage_directory()
|
|
483
|
+
staged = self.staging.get_staged_files()
|
|
484
|
+
if not staged:
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
# Create stash commit (parent = HEAD)
|
|
488
|
+
head_commit = self.get_head_commit()
|
|
489
|
+
parents = [head_commit.store(self.object_store)] if head_commit else []
|
|
490
|
+
|
|
491
|
+
tree_hash = self._build_tree_from_staged()
|
|
492
|
+
|
|
493
|
+
stash_commit = Commit(
|
|
494
|
+
tree=tree_hash,
|
|
495
|
+
parents=parents,
|
|
496
|
+
author=self.get_author(),
|
|
497
|
+
timestamp=datetime.utcnow().isoformat() + 'Z',
|
|
498
|
+
message=message or 'WIP on ' + (self.refs.get_current_branch() or 'HEAD'),
|
|
499
|
+
metadata={'stash': True}
|
|
500
|
+
)
|
|
501
|
+
stash_hash = stash_commit.store(self.object_store)
|
|
502
|
+
|
|
503
|
+
self.refs.stash_push(stash_hash, message)
|
|
504
|
+
self.staging.clear()
|
|
505
|
+
|
|
506
|
+
head_hash = self.resolve_ref('HEAD')
|
|
507
|
+
if head_hash:
|
|
508
|
+
tree = self.get_commit_tree(head_hash)
|
|
509
|
+
if tree:
|
|
510
|
+
self._restore_tree_to_current_dir(tree)
|
|
511
|
+
|
|
512
|
+
return stash_hash
|
|
513
|
+
|
|
514
|
+
def stash_pop(self, index: int = 0) -> Optional[str]:
|
|
515
|
+
"""Apply stash at index and remove from stash list."""
|
|
516
|
+
stash_hash = self.refs.stash_pop(index)
|
|
517
|
+
if not stash_hash:
|
|
518
|
+
return None
|
|
519
|
+
tree = self.get_commit_tree(stash_hash)
|
|
520
|
+
if tree:
|
|
521
|
+
self._restore_tree_to_current_dir(tree)
|
|
522
|
+
return stash_hash
|