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/refs.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reference management for agmem.
|
|
3
|
+
|
|
4
|
+
Manages HEAD, branches, tags, stash, and reflog.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, List, Dict
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
# Minimum length for partial commit hash; full SHA-256 hex is 64 chars
|
|
13
|
+
COMMIT_HASH_MIN_LEN = 4
|
|
14
|
+
COMMIT_HASH_MAX_LEN = 64
|
|
15
|
+
COMMIT_HASH_HEX_CHARS = set('0123456789abcdef')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_ref_name(name: str) -> bool:
|
|
19
|
+
"""Return True if name is safe for single-component ref (reflog, HEAD file). No slashes."""
|
|
20
|
+
if not name or name in ('.', '..'):
|
|
21
|
+
return False
|
|
22
|
+
if '/' in name or '\\' in name or '\0' in name:
|
|
23
|
+
return False
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ref_path_under_root(name: str, base_dir: Path) -> bool:
|
|
28
|
+
"""Return True if name is a valid ref name and (base_dir / name) stays under base_dir (Git-style)."""
|
|
29
|
+
if not name or name in ('.', '..') or '\0' in name or '\\' in name:
|
|
30
|
+
return False
|
|
31
|
+
try:
|
|
32
|
+
resolved = (base_dir / name).resolve()
|
|
33
|
+
base_resolved = base_dir.resolve()
|
|
34
|
+
return resolved == base_resolved or base_resolved in resolved.parents
|
|
35
|
+
except (ValueError, RuntimeError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _valid_commit_hash(candidate: str) -> bool:
|
|
40
|
+
"""Return True if candidate looks like a commit hash (hex, within length)."""
|
|
41
|
+
if not candidate or len(candidate) < COMMIT_HASH_MIN_LEN or len(candidate) > COMMIT_HASH_MAX_LEN:
|
|
42
|
+
return False
|
|
43
|
+
return all(c in COMMIT_HASH_HEX_CHARS for c in candidate.lower())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RefsManager:
|
|
47
|
+
"""Manages references (HEAD, branches, tags)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, mem_dir: Path):
|
|
50
|
+
self.mem_dir = Path(mem_dir)
|
|
51
|
+
self.refs_dir = self.mem_dir / 'refs'
|
|
52
|
+
self.heads_dir = self.refs_dir / 'heads'
|
|
53
|
+
self.tags_dir = self.refs_dir / 'tags'
|
|
54
|
+
self.remotes_dir = self.refs_dir / 'remotes'
|
|
55
|
+
self.head_file = self.mem_dir / 'HEAD'
|
|
56
|
+
self.stash_file = self.mem_dir / 'stash'
|
|
57
|
+
self.reflog_dir = self.mem_dir / 'logs'
|
|
58
|
+
self._ensure_directories()
|
|
59
|
+
|
|
60
|
+
def _ensure_directories(self):
|
|
61
|
+
"""Create reference directories."""
|
|
62
|
+
self.heads_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
self.tags_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
self.remotes_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
def init_head(self, branch_name: str = 'main'):
|
|
67
|
+
"""Initialize HEAD to point to a branch."""
|
|
68
|
+
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
69
|
+
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
70
|
+
self.head_file.write_text(f'ref: refs/heads/{branch_name}\n')
|
|
71
|
+
branch_file = self.heads_dir / branch_name
|
|
72
|
+
if not branch_file.exists():
|
|
73
|
+
branch_file.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
branch_file.write_text('')
|
|
75
|
+
|
|
76
|
+
def get_head(self) -> Dict[str, str]:
|
|
77
|
+
"""
|
|
78
|
+
Get current HEAD reference.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Dict with 'type' ('branch' or 'commit') and 'value'
|
|
82
|
+
"""
|
|
83
|
+
if not self.head_file.exists():
|
|
84
|
+
return {'type': 'branch', 'value': 'main'}
|
|
85
|
+
|
|
86
|
+
content = self.head_file.read_text().strip()
|
|
87
|
+
|
|
88
|
+
if content.startswith('ref: '):
|
|
89
|
+
# Points to a branch (e.g. refs/heads/main or refs/heads/feature/test)
|
|
90
|
+
ref_path = content[5:].strip()
|
|
91
|
+
branch_name = ref_path[len('refs/heads/'):] if ref_path.startswith('refs/heads/') else ref_path.split('/')[-1]
|
|
92
|
+
return {'type': 'branch', 'value': branch_name}
|
|
93
|
+
elif content:
|
|
94
|
+
# Detached HEAD - points directly to commit
|
|
95
|
+
return {'type': 'commit', 'value': content}
|
|
96
|
+
|
|
97
|
+
return {'type': 'branch', 'value': 'main'}
|
|
98
|
+
|
|
99
|
+
def set_head_branch(self, branch_name: str):
|
|
100
|
+
"""Set HEAD to point to a branch."""
|
|
101
|
+
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
102
|
+
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
103
|
+
self.head_file.write_text(f'ref: refs/heads/{branch_name}\n')
|
|
104
|
+
|
|
105
|
+
def set_head_detached(self, commit_hash: str):
|
|
106
|
+
"""Set HEAD to point directly to a commit (detached)."""
|
|
107
|
+
self.head_file.write_text(f'{commit_hash}\n')
|
|
108
|
+
|
|
109
|
+
def get_branch_commit(self, branch_name: str) -> Optional[str]:
|
|
110
|
+
"""Get the commit hash for a branch."""
|
|
111
|
+
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
112
|
+
return None
|
|
113
|
+
branch_file = self.heads_dir / branch_name
|
|
114
|
+
if branch_file.exists():
|
|
115
|
+
content = branch_file.read_text().strip()
|
|
116
|
+
return content if content else None
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def set_branch_commit(self, branch_name: str, commit_hash: str):
|
|
120
|
+
"""Set the commit hash for a branch."""
|
|
121
|
+
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
122
|
+
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
123
|
+
branch_file = self.heads_dir / branch_name
|
|
124
|
+
branch_file.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
branch_file.write_text(f'{commit_hash}\n')
|
|
126
|
+
|
|
127
|
+
def create_branch(self, branch_name: str, commit_hash: Optional[str] = None) -> bool:
|
|
128
|
+
"""
|
|
129
|
+
Create a new branch.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
branch_name: Name of the new branch
|
|
133
|
+
commit_hash: Commit to point to (None for current HEAD)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if created, False if branch already exists
|
|
137
|
+
"""
|
|
138
|
+
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
139
|
+
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
140
|
+
branch_file = self.heads_dir / branch_name
|
|
141
|
+
if branch_file.exists():
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
branch_file.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
|
|
146
|
+
if commit_hash is None:
|
|
147
|
+
# Point to current HEAD commit
|
|
148
|
+
head = self.get_head()
|
|
149
|
+
if head['type'] == 'branch':
|
|
150
|
+
commit_hash = self.get_branch_commit(head['value'])
|
|
151
|
+
else:
|
|
152
|
+
commit_hash = head['value']
|
|
153
|
+
|
|
154
|
+
branch_file.write_text(f'{commit_hash}\n' if commit_hash else '')
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
def delete_branch(self, branch_name: str) -> bool:
|
|
158
|
+
"""
|
|
159
|
+
Delete a branch.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if deleted, False if branch doesn't exist
|
|
163
|
+
"""
|
|
164
|
+
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
165
|
+
return False
|
|
166
|
+
branch_file = self.heads_dir / branch_name
|
|
167
|
+
if branch_file.exists():
|
|
168
|
+
branch_file.unlink()
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
def list_branches(self) -> List[str]:
|
|
173
|
+
"""List all branch names (supports nested names like feature/test)."""
|
|
174
|
+
if not self.heads_dir.exists():
|
|
175
|
+
return []
|
|
176
|
+
branches = []
|
|
177
|
+
for p in self.heads_dir.rglob('*'):
|
|
178
|
+
if p.is_file():
|
|
179
|
+
branches.append(str(p.relative_to(self.heads_dir)))
|
|
180
|
+
return sorted(branches)
|
|
181
|
+
|
|
182
|
+
def branch_exists(self, branch_name: str) -> bool:
|
|
183
|
+
"""Check if a branch exists."""
|
|
184
|
+
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
185
|
+
return False
|
|
186
|
+
return (self.heads_dir / branch_name).exists()
|
|
187
|
+
|
|
188
|
+
def get_current_branch(self) -> Optional[str]:
|
|
189
|
+
"""Get the name of the current branch, or None if detached."""
|
|
190
|
+
head = self.get_head()
|
|
191
|
+
if head['type'] == 'branch':
|
|
192
|
+
return head['value']
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
def is_detached(self) -> bool:
|
|
196
|
+
"""Check if HEAD is detached."""
|
|
197
|
+
head = self.get_head()
|
|
198
|
+
return head['type'] == 'commit'
|
|
199
|
+
|
|
200
|
+
# Tag management
|
|
201
|
+
|
|
202
|
+
def create_tag(self, tag_name: str, commit_hash: str, message: str = '') -> bool:
|
|
203
|
+
"""
|
|
204
|
+
Create a new tag.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
tag_name: Name of the tag
|
|
208
|
+
commit_hash: Commit to tag
|
|
209
|
+
message: Optional tag message
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if created, False if tag already exists
|
|
213
|
+
"""
|
|
214
|
+
if not _ref_path_under_root(tag_name, self.tags_dir):
|
|
215
|
+
raise ValueError(f"Invalid tag name: {tag_name!r}")
|
|
216
|
+
tag_file = self.tags_dir / tag_name
|
|
217
|
+
if tag_file.exists():
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
tag_file.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
tag_file.write_text(f'{commit_hash}\n')
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
def delete_tag(self, tag_name: str) -> bool:
|
|
225
|
+
"""
|
|
226
|
+
Delete a tag.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if deleted, False if tag doesn't exist
|
|
230
|
+
"""
|
|
231
|
+
if not _ref_path_under_root(tag_name, self.tags_dir):
|
|
232
|
+
return False
|
|
233
|
+
tag_file = self.tags_dir / tag_name
|
|
234
|
+
if tag_file.exists():
|
|
235
|
+
tag_file.unlink()
|
|
236
|
+
return True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
def get_tag_commit(self, tag_name: str) -> Optional[str]:
|
|
240
|
+
"""Get the commit hash for a tag."""
|
|
241
|
+
if not _ref_path_under_root(tag_name, self.tags_dir):
|
|
242
|
+
return None
|
|
243
|
+
tag_file = self.tags_dir / tag_name
|
|
244
|
+
if tag_file.exists():
|
|
245
|
+
return tag_file.read_text().strip()
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def list_tags(self) -> List[str]:
|
|
249
|
+
"""List all tag names (supports nested names)."""
|
|
250
|
+
if not self.tags_dir.exists():
|
|
251
|
+
return []
|
|
252
|
+
tags = []
|
|
253
|
+
for p in self.tags_dir.rglob('*'):
|
|
254
|
+
if p.is_file():
|
|
255
|
+
tags.append(str(p.relative_to(self.tags_dir)))
|
|
256
|
+
return sorted(tags)
|
|
257
|
+
|
|
258
|
+
def tag_exists(self, tag_name: str) -> bool:
|
|
259
|
+
"""Check if a tag exists."""
|
|
260
|
+
if not _ref_path_under_root(tag_name, self.tags_dir):
|
|
261
|
+
return False
|
|
262
|
+
return (self.tags_dir / tag_name).exists()
|
|
263
|
+
|
|
264
|
+
def get_remote_branch_commit(self, remote_name: str, branch_name: str) -> Optional[str]:
|
|
265
|
+
"""Get commit hash for a remote-tracking branch (e.g. refs/remotes/origin/main)."""
|
|
266
|
+
if not _safe_ref_name(remote_name) or not _ref_path_under_root(branch_name, self.heads_dir):
|
|
267
|
+
return None
|
|
268
|
+
remote_refs = self.remotes_dir / remote_name
|
|
269
|
+
ref_file = remote_refs / branch_name
|
|
270
|
+
if ref_file.exists() and ref_file.is_file():
|
|
271
|
+
content = ref_file.read_text().strip()
|
|
272
|
+
return content if content else None
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
def set_remote_branch_commit(self, remote_name: str, branch_name: str, commit_hash: str) -> None:
|
|
276
|
+
"""Set remote-tracking branch (e.g. after fetch)."""
|
|
277
|
+
if not _safe_ref_name(remote_name):
|
|
278
|
+
raise ValueError(f"Invalid remote name: {remote_name!r}")
|
|
279
|
+
remote_refs = self.remotes_dir / remote_name
|
|
280
|
+
if not _ref_path_under_root(branch_name, remote_refs):
|
|
281
|
+
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
282
|
+
ref_file = self.remotes_dir / remote_name / branch_name
|
|
283
|
+
ref_file.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
ref_file.write_text(commit_hash + '\n')
|
|
285
|
+
|
|
286
|
+
def resolve_ref(self, ref: str, object_store=None) -> Optional[str]:
|
|
287
|
+
"""
|
|
288
|
+
Resolve a reference to a commit hash.
|
|
289
|
+
|
|
290
|
+
Supports:
|
|
291
|
+
- Branch names
|
|
292
|
+
- Tag names
|
|
293
|
+
- Partial commit hashes
|
|
294
|
+
- HEAD
|
|
295
|
+
- HEAD~n (nth parent)
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
ref: Reference to resolve
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Commit hash or None if not found
|
|
302
|
+
"""
|
|
303
|
+
ref = ref.strip()
|
|
304
|
+
|
|
305
|
+
# Handle HEAD
|
|
306
|
+
if ref == 'HEAD':
|
|
307
|
+
head = self.get_head()
|
|
308
|
+
if head['type'] == 'commit':
|
|
309
|
+
return head['value']
|
|
310
|
+
else:
|
|
311
|
+
return self.get_branch_commit(head['value'])
|
|
312
|
+
|
|
313
|
+
# Handle HEAD~n
|
|
314
|
+
if ref.startswith('HEAD~'):
|
|
315
|
+
try:
|
|
316
|
+
n = int(ref[5:])
|
|
317
|
+
if n < 0:
|
|
318
|
+
return None
|
|
319
|
+
head = self.get_head()
|
|
320
|
+
if head['type'] == 'branch':
|
|
321
|
+
commit_hash = self.get_branch_commit(head['value'])
|
|
322
|
+
else:
|
|
323
|
+
commit_hash = head['value']
|
|
324
|
+
if not commit_hash:
|
|
325
|
+
return None
|
|
326
|
+
# Walk back n parents when object_store is available
|
|
327
|
+
if object_store is not None and n > 0:
|
|
328
|
+
from .objects import Commit
|
|
329
|
+
for _ in range(n):
|
|
330
|
+
commit = Commit.load(object_store, commit_hash)
|
|
331
|
+
if not commit or not commit.parents:
|
|
332
|
+
return None
|
|
333
|
+
commit_hash = commit.parents[0]
|
|
334
|
+
return commit_hash
|
|
335
|
+
except ValueError:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
# Check branches
|
|
339
|
+
if self.branch_exists(ref):
|
|
340
|
+
return self.get_branch_commit(ref)
|
|
341
|
+
|
|
342
|
+
# Check remote-tracking refs (e.g. origin/main)
|
|
343
|
+
if '/' in ref:
|
|
344
|
+
parts = ref.split('/', 1)
|
|
345
|
+
if len(parts) == 2:
|
|
346
|
+
remote_name, branch_name = parts
|
|
347
|
+
if _safe_ref_name(remote_name) and _ref_path_under_root(branch_name, self.heads_dir):
|
|
348
|
+
remote_hash = self.get_remote_branch_commit(remote_name, branch_name)
|
|
349
|
+
if remote_hash:
|
|
350
|
+
return remote_hash
|
|
351
|
+
|
|
352
|
+
# Check tags
|
|
353
|
+
if self.tag_exists(ref):
|
|
354
|
+
return self.get_tag_commit(ref)
|
|
355
|
+
|
|
356
|
+
# Check stash refs (stash@{n})
|
|
357
|
+
if ref.startswith('stash@'):
|
|
358
|
+
if ref == 'stash':
|
|
359
|
+
return self.get_stash_commit(0)
|
|
360
|
+
if ref.startswith('stash@{') and ref.endswith('}'):
|
|
361
|
+
try:
|
|
362
|
+
n = int(ref[7:-1])
|
|
363
|
+
return self.get_stash_commit(n)
|
|
364
|
+
except ValueError:
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
# Assume it's a commit hash (full or partial); validate to avoid path/injection
|
|
368
|
+
return ref if _valid_commit_hash(ref) else None
|
|
369
|
+
|
|
370
|
+
# Reflog - log of HEAD changes
|
|
371
|
+
def append_reflog(self, ref_name: str, old_hash: str, new_hash: str, message: str):
|
|
372
|
+
"""Append entry to reflog."""
|
|
373
|
+
if not _safe_ref_name(ref_name):
|
|
374
|
+
raise ValueError(f"Invalid ref name for reflog: {ref_name!r}")
|
|
375
|
+
self.reflog_dir.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
log_file = self.reflog_dir / ref_name
|
|
377
|
+
timestamp = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ')
|
|
378
|
+
line = f"{new_hash} {old_hash} {timestamp} {message}\n"
|
|
379
|
+
with open(log_file, 'a') as f:
|
|
380
|
+
f.write(line)
|
|
381
|
+
|
|
382
|
+
def get_reflog(self, ref_name: str = 'HEAD', max_count: int = 20) -> List[Dict]:
|
|
383
|
+
"""Get reflog entries for a reference."""
|
|
384
|
+
if not _safe_ref_name(ref_name):
|
|
385
|
+
return []
|
|
386
|
+
log_file = self.reflog_dir / ref_name
|
|
387
|
+
if not log_file.exists():
|
|
388
|
+
return []
|
|
389
|
+
entries = []
|
|
390
|
+
for line in reversed(log_file.read_text().strip().split('\n')):
|
|
391
|
+
if not line:
|
|
392
|
+
continue
|
|
393
|
+
parts = line.split(' ', 3)
|
|
394
|
+
if len(parts) >= 4:
|
|
395
|
+
entries.append({
|
|
396
|
+
'hash': parts[0],
|
|
397
|
+
'old_hash': parts[1],
|
|
398
|
+
'timestamp': parts[2],
|
|
399
|
+
'message': parts[3]
|
|
400
|
+
})
|
|
401
|
+
if len(entries) >= max_count:
|
|
402
|
+
break
|
|
403
|
+
return entries
|
|
404
|
+
|
|
405
|
+
# Stash - stack of stashed changes
|
|
406
|
+
def stash_push(self, commit_hash: str, message: str = '') -> int:
|
|
407
|
+
"""Push a commit onto the stash stack. Returns stash index."""
|
|
408
|
+
stashes = self._load_stash_list()
|
|
409
|
+
stashes.insert(0, {'hash': commit_hash, 'message': message or 'WIP'})
|
|
410
|
+
self._save_stash_list(stashes)
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
def stash_pop(self, index: int = 0) -> Optional[str]:
|
|
414
|
+
"""Pop stash at index. Returns commit hash or None."""
|
|
415
|
+
stashes = self._load_stash_list()
|
|
416
|
+
if index >= len(stashes):
|
|
417
|
+
return None
|
|
418
|
+
entry = stashes.pop(index)
|
|
419
|
+
self._save_stash_list(stashes)
|
|
420
|
+
return entry['hash']
|
|
421
|
+
|
|
422
|
+
def stash_list(self) -> List[Dict]:
|
|
423
|
+
"""List all stashes."""
|
|
424
|
+
return self._load_stash_list()
|
|
425
|
+
|
|
426
|
+
def get_stash_commit(self, index: int) -> Optional[str]:
|
|
427
|
+
"""Get commit hash for stash at index."""
|
|
428
|
+
stashes = self._load_stash_list()
|
|
429
|
+
if 0 <= index < len(stashes):
|
|
430
|
+
return stashes[index]['hash']
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
def _load_stash_list(self) -> List[Dict]:
|
|
434
|
+
"""Load stash list from disk."""
|
|
435
|
+
if not self.stash_file.exists():
|
|
436
|
+
return []
|
|
437
|
+
try:
|
|
438
|
+
import json
|
|
439
|
+
data = json.loads(self.stash_file.read_text())
|
|
440
|
+
return data if isinstance(data, list) else []
|
|
441
|
+
except (json.JSONDecodeError, TypeError):
|
|
442
|
+
return []
|
|
443
|
+
|
|
444
|
+
def _save_stash_list(self, stashes: List[Dict]):
|
|
445
|
+
"""Save stash list to disk."""
|
|
446
|
+
import json
|
|
447
|
+
self.stash_file.write_text(json.dumps(stashes, indent=2))
|