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