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