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/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 / '.mem'
27
- self.current_dir = self.root / 'current'
28
- self.config_file = self.mem_dir / 'config.json'
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 / 'objects')
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(cls, path: Path, author_name: str = 'Agent', author_email: str = 'agent@example.com') -> 'Repository':
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 / 'objects').mkdir(parents=True, exist_ok=True)
70
-
71
+ (repo.mem_dir / "objects").mkdir(parents=True, exist_ok=True)
72
+
71
73
  # Create staging directory
72
- (repo.mem_dir / 'staging').mkdir(parents=True, exist_ok=True)
73
-
74
+ (repo.mem_dir / "staging").mkdir(parents=True, exist_ok=True)
75
+
74
76
  # 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
-
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
- 'author': {
81
- 'name': author_name,
82
- 'email': author_email
83
- },
84
- 'core': {
85
- 'default_branch': 'main',
86
- 'compression': True,
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('main')
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() and
110
- self.config_file.exists() and
111
- (self.mem_dir / 'objects').exists()
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('author', {})
128
- name = author.get('name', 'Agent')
129
- email = author.get('email', 'agent@example.com')
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['type'] == 'branch':
143
- commit_hash = self.refs.get_branch_commit(head['value'])
137
+ if head["type"] == "branch":
138
+ commit_hash = self.refs.get_branch_commit(head["value"])
144
139
  else:
145
- commit_hash = head['value']
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
- return self.refs.resolve_ref(ref, self.object_store)
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(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
+ 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 = '') -> Dict[str, 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() + 'Z',
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 '0' * 64
305
- self.refs.append_reflog('HEAD', old_hash, commit_hash, f'commit: {message}')
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['type'] == 'branch':
310
- self.refs.set_branch_commit(head['value'], commit_hash)
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['type'] == 'branch':
334
- old_hash = self.refs.get_branch_commit(old_head['value'])
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('value')
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('HEAD', old_hash, commit_hash, f'checkout: moving to {ref}')
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 + '/' + entry.name if entry.path else entry.name
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
- '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()
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['type'] == 'branch':
448
- commit_hash = self.refs.get_branch_commit(head['value'])
455
+ if head["type"] == "branch":
456
+ commit_hash = self.refs.get_branch_commit(head["value"])
449
457
  else:
450
- commit_hash = head['value']
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
- '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
-
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 = '') -> Optional[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['staged'] and not status['modified'] and not status['untracked']:
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() + 'Z',
498
- message=message or 'WIP on ' + (self.refs.get_current_branch() or 'HEAD'),
499
- metadata={'stash': True}
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('HEAD')
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)