raggiecode 0.2.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.
Files changed (93) hide show
  1. Agent/__init__.py +0 -0
  2. Agent/agent.py +891 -0
  3. Agent/chat_history_db.py +1500 -0
  4. Agent/command.py +49 -0
  5. Agent/config.py +46 -0
  6. Agent/effort_levels.py +33 -0
  7. Agent/git_manager.py +727 -0
  8. Agent/tools.py +35 -0
  9. Commands/__init__.py +18 -0
  10. Commands/effort.py +42 -0
  11. Commands/global_todo.py +23 -0
  12. Commands/help.py +22 -0
  13. Commands/reasoning.py +24 -0
  14. Commands/redo.py +11 -0
  15. Commands/reindex.py +27 -0
  16. Commands/shell.py +28 -0
  17. Commands/stream.py +24 -0
  18. Commands/undo.py +13 -0
  19. Commands/unlimited_effort.py +8 -0
  20. Commands/window_size.py +29 -0
  21. RAG/__init__.py +0 -0
  22. RAG/document.py +119 -0
  23. RAG/find.py +408 -0
  24. RAG/graph.py +231 -0
  25. Tools/GetFileCodeStructure.py +43 -0
  26. Tools/GetSymbolSourceCode.py +27 -0
  27. Tools/__init__.py +39 -0
  28. Tools/ask_user.py +102 -0
  29. Tools/dispatch_subagent.py +215 -0
  30. Tools/document.py +35 -0
  31. Tools/edit_symbol.py +250 -0
  32. Tools/fuzzy_search.py +119 -0
  33. Tools/list_dir.py +51 -0
  34. Tools/read.py +49 -0
  35. Tools/read_image.py +75 -0
  36. Tools/remove.py +75 -0
  37. Tools/replace.py +305 -0
  38. Tools/search.py +41 -0
  39. Tools/shell.py +149 -0
  40. Tools/shell_kill.py +87 -0
  41. Tools/temp_background_service.py +113 -0
  42. Tools/todo_list.py +481 -0
  43. Tools/utils.py +116 -0
  44. Tools/view_changes.py +179 -0
  45. Tools/walk_call_tree.py +30 -0
  46. Tools/web_fetch.py +175 -0
  47. Tools/web_search.py +69 -0
  48. Tools/write.py +48 -0
  49. cli.py +111 -0
  50. config/__init__.py +0 -0
  51. config/coder_system_prompt.md +119 -0
  52. config/roles.json +43 -0
  53. config/tools.json +709 -0
  54. indexing/__init__.py +0 -0
  55. indexing/cli.py +128 -0
  56. indexing/code_index_sdk.py +832 -0
  57. indexing/code_indexer.py +1763 -0
  58. indexing/db_schema.py +396 -0
  59. indexing/export_to_json.py +346 -0
  60. indexing/extractors.py +189 -0
  61. indexing/file_utils.py +97 -0
  62. indexing/frontend/__init__.py +0 -0
  63. indexing/frontend/css_extractor.py +195 -0
  64. indexing/frontend/css_parser.py +387 -0
  65. indexing/frontend/css_selector_utils.py +226 -0
  66. indexing/frontend/edit_safety.py +573 -0
  67. indexing/frontend/graph.py +838 -0
  68. indexing/frontend/html_extractor.py +496 -0
  69. indexing/frontend/html_parser.py +314 -0
  70. indexing/frontend/jsx_extractor.py +1204 -0
  71. indexing/frontend/location_lookup.py +247 -0
  72. indexing/frontend/resolver.py +485 -0
  73. indexing/frontend/runtime_resolver.py +862 -0
  74. indexing/frontend/semantic_output.py +705 -0
  75. indexing/frontend/source_location.py +69 -0
  76. indexing/frontend_config.py +72 -0
  77. indexing/frontend_models.py +347 -0
  78. indexing/language_config.py +360 -0
  79. indexing/models.py +284 -0
  80. indexing/node_utils.py +1112 -0
  81. indexing/parse_worker.py +1082 -0
  82. indexing/queries.py +1542 -0
  83. indexing/sdk_examples.py +426 -0
  84. interactive.py +248 -0
  85. raggie.py +673 -0
  86. raggiecode-0.2.1.dist-info/METADATA +944 -0
  87. raggiecode-0.2.1.dist-info/RECORD +93 -0
  88. raggiecode-0.2.1.dist-info/WHEEL +5 -0
  89. raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
  90. raggiecode-0.2.1.dist-info/top_level.txt +10 -0
  91. skills/__init__.py +3 -0
  92. skills/manager.py +114 -0
  93. skills/tool.py +121 -0
Agent/git_manager.py ADDED
@@ -0,0 +1,727 @@
1
+ import os
2
+ from dulwich import repo
3
+ from dulwich import objects
4
+ from datetime import datetime
5
+
6
+ RED = "\033[31m"
7
+ RESET = "\033[0m"
8
+
9
+
10
+ class GitManager:
11
+ """Manages git-like operations using dulwich in the .raggie directory."""
12
+
13
+ def __init__(self, root_dir=None):
14
+ """Initialize the GitManager.
15
+
16
+ Args:
17
+ root_dir: The root directory of the project. Defaults to current working directory.
18
+ """
19
+ self.root_dir = root_dir or os.getcwd()
20
+ self.raggie_dir = os.path.join(self.root_dir, ".raggie")
21
+ self.repo_path = os.path.join(self.raggie_dir, "git")
22
+ self._ensure_repo()
23
+
24
+ def _ensure_repo(self):
25
+ """Ensure the git repository exists in .raggie directory."""
26
+ # Check for stale undo marker (crash recovery)
27
+ marker_path = os.path.join(self.raggie_dir, ".undoing")
28
+ if os.path.exists(marker_path):
29
+ print(f"Warning: Found stale undo marker at {marker_path}. "
30
+ f"A previous undo may have been interrupted. "
31
+ f"Some files may be missing.")
32
+ try:
33
+ os.remove(marker_path)
34
+ except (IOError, OSError) as e:
35
+ print(f"{RED}Warning: Failed to remove stale undo marker: {e}{RESET}")
36
+
37
+ # Check for stale redo marker (crash recovery)
38
+ redo_marker_path = os.path.join(self.raggie_dir, ".redoing")
39
+ if os.path.exists(redo_marker_path):
40
+ print(f"Warning: Found stale redo marker at {redo_marker_path}. "
41
+ f"A previous redo may have been interrupted. "
42
+ f"Some files may be missing.")
43
+ try:
44
+ os.remove(redo_marker_path)
45
+ except (IOError, OSError) as e:
46
+ print(f"{RED}Warning: Failed to remove stale redo marker: {e}{RESET}")
47
+
48
+ if not os.path.exists(self.repo_path):
49
+ os.makedirs(self.repo_path, exist_ok=True)
50
+ # Initialize a new git repository
51
+ self.repo = repo.Repo.init(self.repo_path)
52
+ # Create initial commit
53
+ self._create_initial_commit()
54
+ else:
55
+ try:
56
+ self.repo = repo.Repo(self.repo_path)
57
+ except Exception:
58
+ # If it's not a valid repo, reinitialize
59
+ self.repo = repo.Repo.init(self.repo_path)
60
+ self._create_initial_commit()
61
+
62
+ def _create_initial_commit(self):
63
+ """Create an initial empty commit."""
64
+ # Create an empty tree
65
+ tree = objects.Tree()
66
+ self.repo.object_store.add_object(tree)
67
+
68
+ # Create a commit with the empty tree
69
+ commit = objects.Commit()
70
+ commit.tree = tree.id
71
+ commit.author = commit.committer = b"Raggie <raggie@local>"
72
+ commit.commit_time = commit.author_time = int(datetime.now().timestamp())
73
+ commit.commit_timezone = commit.author_timezone = 0
74
+ commit.message = b"Initial commit"
75
+
76
+ self.repo.object_store.add_object(commit)
77
+ self.repo.refs[b"refs/heads/main"] = commit.id
78
+
79
+ def add_changed_files(self):
80
+ """DEPRECATED: This method is a no-op and kept only for API compatibility.
81
+
82
+ The tree is now built from scratch during commit() via _walk_filesystem().
83
+ This method will be removed in a future version.
84
+ """
85
+ pass
86
+
87
+ def _build_nested_tree(self, files_dict):
88
+ """Build nested Tree objects from a {rel_path: blob_id} mapping.
89
+
90
+ Creates proper nested trees for subdirectories, making the repository
91
+ compatible with standard git tools.
92
+
93
+ Args:
94
+ files_dict: Dict mapping relative file paths to blob IDs.
95
+
96
+ Returns:
97
+ Root Tree object.
98
+ """
99
+ if not files_dict:
100
+ return objects.Tree()
101
+
102
+ # Group entries by directory
103
+ dir_entries = {} # {dirname: {basename: blob_id}}
104
+ for rel_path, blob_id in files_dict.items():
105
+ parts = rel_path.split(os.sep)
106
+ dirname = os.sep.join(parts[:-1]) if len(parts) > 1 else ''
107
+ basename = parts[-1]
108
+ dir_entries.setdefault(dirname, {})[basename] = blob_id
109
+
110
+ # Collect all unique directory paths (including intermediates)
111
+ all_dirs = set(dir_entries.keys())
112
+ all_dirs.add('') # Ensure root is always present
113
+ for d in list(all_dirs):
114
+ if d:
115
+ parts = d.split(os.sep)
116
+ for i in range(1, len(parts)):
117
+ all_dirs.add(os.sep.join(parts[:i]))
118
+
119
+ # Build trees bottom-up (deepest directories first)
120
+ sorted_dirs = sorted(
121
+ all_dirs,
122
+ key=lambda d: d.count(os.sep) if d else -1,
123
+ reverse=True
124
+ )
125
+
126
+ tree_cache = {} # {dirname: Tree object}
127
+ for dirname in sorted_dirs:
128
+ t = objects.Tree()
129
+
130
+ # Add file entries in this directory
131
+ for basename, blob_id in dir_entries.get(dirname, {}).items():
132
+ t.add(basename.encode('utf-8'), 0o100644, blob_id)
133
+
134
+ # Add subtree entries (child directories)
135
+ prefix = dirname + os.sep if dirname else ''
136
+ for child_dirname, child_tree in tree_cache.items():
137
+ if child_dirname.startswith(prefix):
138
+ remainder = child_dirname[len(prefix):]
139
+ if remainder and os.sep not in remainder:
140
+ t.add(remainder.encode('utf-8'), 0o040000, child_tree.id)
141
+
142
+ self.repo.object_store.add_object(t)
143
+ tree_cache[dirname] = t
144
+
145
+ return tree_cache.get('', objects.Tree())
146
+
147
+ def commit(self, message):
148
+ """Commit the current state of the working tree.
149
+
150
+ Builds the tree from the filesystem with proper nested tree objects
151
+ for subdirectories, making the repository compatible with standard git.
152
+
153
+ Args:
154
+ message: The commit message.
155
+
156
+ Returns:
157
+ The commit ID.
158
+ """
159
+ # Get the current HEAD
160
+ try:
161
+ head_id = self.repo.refs[b"refs/heads/main"]
162
+ parent_ids = [head_id]
163
+ except KeyError:
164
+ parent_ids = []
165
+
166
+ # Collect all files and their blob IDs from the filesystem
167
+ file_blobs = {} # {rel_path: blob_id}
168
+ for rel_path, full_path in self._walk_filesystem():
169
+ try:
170
+ with open(full_path, 'rb') as f:
171
+ data = f.read()
172
+ blob = objects.Blob.from_string(data)
173
+ self.repo.object_store.add_object(blob)
174
+ file_blobs[rel_path] = blob.id
175
+ except (IOError, OSError):
176
+ continue
177
+
178
+ # Build proper nested tree structure
179
+ root_tree = self._build_nested_tree(file_blobs)
180
+ self.repo.object_store.add_object(root_tree)
181
+
182
+ # Create the commit
183
+ commit = objects.Commit()
184
+ commit.tree = root_tree.id
185
+ commit.parents = parent_ids
186
+ commit.author = commit.committer = b"Raggie <raggie@local>"
187
+ commit.commit_time = commit.author_time = int(datetime.now().timestamp())
188
+ commit.commit_timezone = commit.author_timezone = 0
189
+ commit.message = message.encode('utf-8') if isinstance(message, str) else message
190
+
191
+ self.repo.object_store.add_object(commit)
192
+ self.repo.refs[b"refs/heads/main"] = commit.id
193
+
194
+ # A new commit invalidates the redo history
195
+ self._clear_redo_stack()
196
+
197
+ return bytes(commit.id).decode('utf-8')
198
+
199
+ def _restore_tree_recursive(self, tree_id, base_path=''):
200
+ """Recursively restore files from a tree, handling nested subtrees.
201
+
202
+ Args:
203
+ tree_id: The ID of the tree object to restore from.
204
+ base_path: The relative directory path under root_dir.
205
+ """
206
+ tree = self.repo.object_store[tree_id]
207
+ for item in tree.items():
208
+ name, mode, oid = item
209
+ if isinstance(name, bytes):
210
+ name = name.decode('utf-8')
211
+
212
+ path = os.path.join(base_path, name) if base_path else name
213
+ obj = self.repo.object_store[oid]
214
+
215
+ if isinstance(obj, objects.Tree):
216
+ # Recurse into subtree (directory)
217
+ self._restore_tree_recursive(oid, path)
218
+ else:
219
+ # It's a blob — write the file
220
+ blob = obj
221
+ file_path = os.path.join(self.root_dir, path)
222
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
223
+ with open(file_path, 'wb') as f:
224
+ f.write(blob.data)
225
+
226
+ def _delete_working_files(self):
227
+ """Delete all tracked files from the working directory (respecting exclusions).
228
+
229
+ Uses the same ignore rules as _walk_filesystem() so that gitignored /
230
+ aiignored files (e.g. .env, *.db) are never deleted during undo/redo.
231
+ """
232
+ try:
233
+ from Tools.utils import is_ignored_by_gitignore
234
+ except ImportError:
235
+ def is_ignored_by_gitignore(_path):
236
+ return False
237
+
238
+ excluded_dirs, excluded_exts = self._get_exclusions()
239
+ for root, dirs, files in os.walk(self.root_dir, topdown=False, followlinks=False):
240
+ path_components = root.replace(os.sep, '/').split('/')
241
+ if any(part in excluded_dirs for part in path_components):
242
+ continue
243
+
244
+ for file in files:
245
+ if any(file.endswith(ext) for ext in excluded_exts):
246
+ continue
247
+ file_path = os.path.join(root, file)
248
+ if is_ignored_by_gitignore(file_path):
249
+ continue
250
+ try:
251
+ os.remove(file_path)
252
+ except (IOError, OSError) as e:
253
+ print(f"{RED}Warning: Failed to remove {file_path}: {e}{RESET}")
254
+
255
+ def _redo_stack_path(self):
256
+ """Return the path to the redo stack file."""
257
+ return os.path.join(self.raggie_dir, ".redo_stack")
258
+
259
+ def _read_redo_stack(self):
260
+ """Read the redo stack and return a list of commit SHAs (top of stack last)."""
261
+ path = self._redo_stack_path()
262
+ if not os.path.exists(path):
263
+ return []
264
+ try:
265
+ with open(path, 'r') as f:
266
+ return [line.strip() for line in f if line.strip()]
267
+ except (IOError, OSError):
268
+ return []
269
+
270
+ def _write_redo_stack(self, stack):
271
+ """Write the redo stack (list of commit SHAs) to disk."""
272
+ path = self._redo_stack_path()
273
+ try:
274
+ with open(path, 'w') as f:
275
+ for sha in stack:
276
+ f.write(sha + '\n')
277
+ except (IOError, OSError) as e:
278
+ print(f"{RED}Warning: Failed to write redo stack: {e}{RESET}")
279
+
280
+ def _clear_redo_stack(self):
281
+ """Clear the redo stack file."""
282
+ path = self._redo_stack_path()
283
+ try:
284
+ if os.path.exists(path):
285
+ os.remove(path)
286
+ except (IOError, OSError) as e:
287
+ print(f"{RED}Warning: Failed to clear redo stack: {e}{RESET}")
288
+
289
+ def undo_last_commit(self):
290
+ """Undo the last commit by restoring files from the previous commit.
291
+
292
+ Uses a marker file for crash recovery: if the process is interrupted
293
+ between deletion and restoration, the marker persists and a warning
294
+ is shown on next startup.
295
+
296
+ The undone commit is pushed onto a redo stack so it can be re-applied
297
+ with ``redo_last_commit``.
298
+
299
+ Returns:
300
+ The previous commit ID, or None if there's no previous commit.
301
+ """
302
+ try:
303
+ head_id = self.repo.refs[b"refs/heads/main"]
304
+ head_commit = self.repo.object_store[head_id]
305
+
306
+ if not head_commit.parents:
307
+ # No parent commit, can't undo
308
+ return None
309
+
310
+ # Get the parent commit
311
+ parent_id = head_commit.parents[0]
312
+ parent_commit = self.repo.object_store[parent_id]
313
+ parent_commit_sha = bytes(parent_id).decode('utf-8')
314
+
315
+ # --- Transaction safety: write marker before making changes ---
316
+ marker_path = os.path.join(self.raggie_dir, ".undoing")
317
+ try:
318
+ with open(marker_path, 'w') as f:
319
+ f.write(f"Undoing to {parent_commit_sha}")
320
+ except (IOError, OSError) as e:
321
+ print(f"{RED}Warning: Failed to write undo marker: {e}{RESET}")
322
+
323
+ # Delete all current files and restore from parent tree
324
+ self._delete_working_files()
325
+ self._restore_tree_recursive(parent_commit.tree)
326
+
327
+ # Reset HEAD to the parent
328
+ self.repo.refs[b"refs/heads/main"] = parent_id
329
+
330
+ # Push the undone commit onto the redo stack
331
+ redo_stack = self._read_redo_stack()
332
+ redo_stack.append(bytes(head_id).decode('utf-8'))
333
+ self._write_redo_stack(redo_stack)
334
+
335
+ # --- Transaction safety: remove marker after successful completion ---
336
+ try:
337
+ if os.path.exists(marker_path):
338
+ os.remove(marker_path)
339
+ except (IOError, OSError) as e:
340
+ print(f"{RED}Warning: Failed to remove undo marker after completion: {e}{RESET}")
341
+
342
+ return parent_commit_sha
343
+ except KeyError:
344
+ return None
345
+
346
+ def redo_last_commit(self):
347
+ """Redo the last undone commit.
348
+
349
+ Pops the top commit from the redo stack, restores its files, and
350
+ moves HEAD to it. Uses a marker file for crash recovery.
351
+
352
+ Returns:
353
+ The redone commit ID, or None if there's nothing to redo.
354
+ """
355
+ redo_stack = self._read_redo_stack()
356
+ if not redo_stack:
357
+ return None
358
+
359
+ commit_sha = redo_stack.pop()
360
+ commit_id = commit_sha.encode('utf-8')
361
+
362
+ try:
363
+ commit_obj = self.repo.object_store[commit_id]
364
+ except KeyError:
365
+ # Commit object no longer exists
366
+ self._write_redo_stack(redo_stack)
367
+ return None
368
+
369
+ # --- Transaction safety: write marker before making changes ---
370
+ marker_path = os.path.join(self.raggie_dir, ".redoing")
371
+ try:
372
+ with open(marker_path, 'w') as f:
373
+ f.write(f"Redoing to {commit_sha}")
374
+ except (IOError, OSError) as e:
375
+ print(f"{RED}Warning: Failed to write redo marker: {e}{RESET}")
376
+
377
+ # Delete all current files and restore from the redone commit's tree
378
+ self._delete_working_files()
379
+ self._restore_tree_recursive(commit_obj.tree)
380
+
381
+ # Move HEAD to the redone commit
382
+ self.repo.refs[b"refs/heads/main"] = commit_id
383
+
384
+ # Save the updated redo stack
385
+ self._write_redo_stack(redo_stack)
386
+
387
+ # --- Transaction safety: remove marker after successful completion ---
388
+ try:
389
+ if os.path.exists(marker_path):
390
+ os.remove(marker_path)
391
+ except (IOError, OSError) as e:
392
+ print(f"{RED}Warning: Failed to remove redo marker after completion: {e}{RESET}")
393
+
394
+ return commit_sha
395
+
396
+ def get_last_commit_message(self):
397
+ """Get the message of the last commit.
398
+
399
+ Returns:
400
+ The last commit message, or None if there are no commits.
401
+ """
402
+ try:
403
+ head_id = self.repo.refs[b"refs/heads/main"]
404
+ head_commit = self.repo.object_store[head_id]
405
+ return head_commit.message.decode('utf-8')
406
+ except KeyError:
407
+ return None
408
+
409
+ def get_last_commit_sha(self):
410
+ """Get the full SHA of the last commit.
411
+
412
+ Returns:
413
+ The full hex SHA string of the last commit, or None if no commits exist.
414
+ """
415
+ try:
416
+ head_id = self.repo.refs[b"refs/heads/main"]
417
+ return bytes(head_id).decode('utf-8')
418
+ except KeyError:
419
+ return None
420
+
421
+ def _get_exclusions(self):
422
+ """Return the set of directory names and file extensions to exclude."""
423
+ excluded_dirs = {'.raggie', '.git', '.venv', '__pycache__', 'build', 'dist', '.egg-info'}
424
+ excluded_exts = {'.pyc', '.pyo', '.pyd', '.so', '.dll', '.dylib', '.exe'}
425
+ return excluded_dirs, excluded_exts
426
+
427
+ def _walk_filesystem(self):
428
+ """Walk the root directory and yield (rel_path, full_path) for includable files.
429
+
430
+ Respects .gitignore rules, skips symlinks, and excludes common
431
+ build/artifact directories.
432
+ """
433
+ # Import gitignore checker with graceful fallback
434
+ try:
435
+ from Tools.utils import is_ignored_by_gitignore
436
+ except ImportError:
437
+ def is_ignored_by_gitignore(_path):
438
+ return False
439
+
440
+ excluded_dirs, excluded_exts = self._get_exclusions()
441
+ for root, dirs, files in os.walk(self.root_dir, followlinks=False):
442
+ dirs[:] = [d for d in dirs if d not in excluded_dirs]
443
+ for file in files:
444
+ if any(file.endswith(ext) for ext in excluded_exts):
445
+ continue
446
+ full_path = os.path.join(root, file)
447
+ rel_path = os.path.relpath(full_path, self.root_dir)
448
+ if is_ignored_by_gitignore(full_path):
449
+ continue
450
+ yield rel_path, full_path
451
+
452
+ def _get_last_commit_tree(self):
453
+ """Get the tree object of the last commit, or None if no commits exist."""
454
+ try:
455
+ head_id = self.repo.refs[b"refs/heads/main"]
456
+ head_commit = self.repo.object_store[head_id]
457
+ return self.repo.object_store[head_commit.tree]
458
+ except KeyError:
459
+ return None
460
+
461
+ def _build_tree_lookup(self, tree):
462
+ """Build a dict mapping file paths to blob IDs, recursing into nested subtrees.
463
+
464
+ Handles both the old flat tree format and the new nested tree format
465
+ for backward compatibility.
466
+ """
467
+ lookup = {}
468
+ if tree is None:
469
+ return lookup
470
+
471
+ def _walk(node, prefix=''):
472
+ for item in node.items():
473
+ name, mode, oid = item
474
+ if isinstance(name, bytes):
475
+ name = name.decode('utf-8')
476
+ path = os.path.join(prefix, name) if prefix else name
477
+
478
+ obj = self.repo.object_store[oid]
479
+ if isinstance(obj, objects.Tree):
480
+ # Recurse into subtree
481
+ _walk(obj, path)
482
+ else:
483
+ # It's a blob
484
+ lookup[path] = oid
485
+
486
+ _walk(tree)
487
+ return lookup
488
+
489
+ def _collect_fs_files(self, path_filter=None):
490
+ """Walk the filesystem once and return {rel_path: blob_id}.
491
+
492
+ Uses a single os.walk pass. Returns a dict of relative paths to
493
+ blob SHA hashes for all files in the root directory.
494
+ """
495
+ fs_files = {}
496
+ for rel_path, full_path in self._walk_filesystem():
497
+ if path_filter and path_filter not in rel_path:
498
+ continue
499
+ try:
500
+ with open(full_path, 'rb') as f:
501
+ data = f.read()
502
+ blob = objects.Blob.from_string(data)
503
+ fs_files[rel_path] = blob.id
504
+ except (IOError, OSError):
505
+ continue
506
+ return fs_files
507
+
508
+ def get_status(self, category=None):
509
+ """Get the working tree status compared to the last commit.
510
+
511
+ Args:
512
+ category: Optional. If set to 'added', 'modified', 'deleted', or 'unchanged',
513
+ only return files in that category. The result dict still contains
514
+ all four keys but only the requested one will be populated.
515
+
516
+ Returns:
517
+ A dict with keys 'added', 'modified', 'deleted', 'unchanged' each being
518
+ a list of file paths. Also includes 'commit_id' and 'commit_message'
519
+ of the last commit, or None if no commits exist.
520
+
521
+ Raises:
522
+ ValueError: If category is not None and not one of the valid values.
523
+ """
524
+ VALID_CATEGORIES = {'added', 'modified', 'deleted', 'unchanged'}
525
+ if category is not None and category not in VALID_CATEGORIES:
526
+ raise ValueError(
527
+ f"Invalid category '{category}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}"
528
+ )
529
+ tree = self._get_last_commit_tree()
530
+ tree_files = self._build_tree_lookup(tree)
531
+
532
+ # Single filesystem pass
533
+ fs_files = self._collect_fs_files()
534
+
535
+ result = {
536
+ 'added': [],
537
+ 'modified': [],
538
+ 'deleted': [],
539
+ 'unchanged': [],
540
+ }
541
+
542
+ # Check files in both sets
543
+ all_paths = set(fs_files.keys()) | set(tree_files.keys())
544
+ for path in sorted(all_paths):
545
+ fs_id = fs_files.get(path)
546
+ tree_id = tree_files.get(path)
547
+
548
+ if fs_id is not None and tree_id is None:
549
+ if category is None or category == 'added':
550
+ result['added'].append(path)
551
+ elif fs_id is None and tree_id is not None:
552
+ if category is None or category == 'deleted':
553
+ result['deleted'].append(path)
554
+ elif fs_id != tree_id:
555
+ if category is None or category == 'modified':
556
+ result['modified'].append(path)
557
+ else:
558
+ if category is None or category == 'unchanged':
559
+ result['unchanged'].append(path)
560
+
561
+ # Include info about the last commit
562
+ try:
563
+ head_id = self.repo.refs[b"refs/heads/main"]
564
+ head_commit = self.repo.object_store[head_id]
565
+ result['commit_id'] = bytes(head_id).decode('utf-8')
566
+ result['commit_message'] = head_commit.message.decode('utf-8')
567
+ except KeyError:
568
+ result['commit_id'] = None
569
+ result['commit_message'] = None
570
+
571
+ return result
572
+
573
+ def _truncate_diff(self, text, max_lines):
574
+ """Truncate a diff text to max_lines, keeping head and tail."""
575
+ if max_lines is None or max_lines <= 0:
576
+ return text
577
+ lines = text.splitlines(True)
578
+ if len(lines) <= max_lines:
579
+ return text
580
+ half = max(1, max_lines // 2)
581
+ head = lines[:half]
582
+ tail = lines[-half:]
583
+ truncated = len(lines) - (half + len(tail))
584
+ if truncated <= 0:
585
+ return text
586
+ return ''.join(head) + f"... ({truncated} lines truncated) ...\n" + ''.join(tail)
587
+
588
+ def get_diff(self, path_filter=None, max_diff_lines=500):
589
+ """Get the diff between the working tree and the last commit.
590
+
591
+ Args:
592
+ path_filter: Optional. If set, only show diff for files whose path
593
+ contains this substring.
594
+ max_diff_lines: Optional. Maximum number of lines per diff.
595
+ If exceeded, the middle is truncated with a marker.
596
+ Set to 0 or None for no limit.
597
+
598
+ Returns:
599
+ A list of dicts, each with keys: 'path', 'change_type' ('added'/'modified'/'deleted'),
600
+ 'content' (the diff text for modified, full content for added/deleted).
601
+ Returns empty list if no commits exist.
602
+ """
603
+ tree = self._get_last_commit_tree()
604
+ tree_lookup = self._build_tree_lookup(tree)
605
+
606
+ diffs = []
607
+
608
+ # Single filesystem walk: collect (path, data) for all fs files
609
+ fs_data = {} # {rel_path: raw_bytes}
610
+ for rel_path, full_path in self._walk_filesystem():
611
+ if path_filter and path_filter not in rel_path:
612
+ continue
613
+ try:
614
+ with open(full_path, 'rb') as f:
615
+ fs_data[rel_path] = f.read()
616
+ except (IOError, OSError):
617
+ continue
618
+
619
+ fs_paths = set(fs_data.keys())
620
+
621
+ # Compare each fs file against the tree
622
+ for rel_path, current_data in fs_data.items():
623
+ tree_blob_id = tree_lookup.get(rel_path)
624
+
625
+ if tree_blob_id is None:
626
+ # File is new
627
+ try:
628
+ text = current_data.decode('utf-8')
629
+ except UnicodeDecodeError:
630
+ text = f"[binary file, {len(current_data)} bytes]"
631
+ if max_diff_lines:
632
+ text = self._truncate_diff(text, max_diff_lines)
633
+ diffs.append({
634
+ 'path': rel_path,
635
+ 'change_type': 'added',
636
+ 'content': text,
637
+ })
638
+ else:
639
+ tree_blob = self.repo.object_store[tree_blob_id]
640
+ tree_data = tree_blob.data
641
+
642
+ if tree_data != current_data:
643
+ # File is modified
644
+ try:
645
+ old_text = tree_data.decode('utf-8').splitlines(True)
646
+ new_text = current_data.decode('utf-8').splitlines(True)
647
+ except UnicodeDecodeError:
648
+ diffs.append({
649
+ 'path': rel_path,
650
+ 'change_type': 'modified',
651
+ 'content': f"[binary file, old: {len(tree_data)} bytes, new: {len(current_data)} bytes]",
652
+ })
653
+ continue
654
+
655
+ import difflib
656
+ diff_text = ''.join(difflib.unified_diff(
657
+ old_text, new_text,
658
+ fromfile=f'a/{rel_path}', tofile=f'b/{rel_path}', lineterm=''
659
+ ))
660
+
661
+ if max_diff_lines:
662
+ diff_text = self._truncate_diff(diff_text, max_diff_lines)
663
+
664
+ diffs.append({
665
+ 'path': rel_path,
666
+ 'change_type': 'modified',
667
+ 'content': diff_text,
668
+ })
669
+
670
+ # Check for deleted files (in tree but not on filesystem)
671
+ for tree_path, blob_id in tree_lookup.items():
672
+ if path_filter and path_filter not in tree_path:
673
+ continue
674
+ if tree_path not in fs_paths:
675
+ tree_blob = self.repo.object_store[blob_id]
676
+ try:
677
+ text = tree_blob.data.decode('utf-8')
678
+ except UnicodeDecodeError:
679
+ text = f"[binary file, {len(tree_blob.data)} bytes]"
680
+ if max_diff_lines:
681
+ text = self._truncate_diff(text, max_diff_lines)
682
+ diffs.append({
683
+ 'path': tree_path,
684
+ 'change_type': 'deleted',
685
+ 'content': text,
686
+ })
687
+
688
+ return diffs
689
+
690
+ def get_log(self, max_count=10):
691
+ """Get the commit history.
692
+
693
+ Args:
694
+ max_count: Maximum number of commits to return (default 10).
695
+
696
+ Returns:
697
+ A list of dicts, each with keys: 'commit_id', 'message', 'timestamp', 'author'.
698
+ Most recent commit first.
699
+ """
700
+ commits = []
701
+ try:
702
+ commit_id = self.repo.refs[b"refs/heads/main"]
703
+ except KeyError:
704
+ return commits
705
+
706
+ for _ in range(max_count):
707
+ try:
708
+ commit = self.repo.object_store[commit_id]
709
+ except KeyError:
710
+ break
711
+
712
+ from datetime import timezone
713
+ ts = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc)
714
+
715
+ commits.append({
716
+ 'commit_id': bytes(commit_id).decode('utf-8'),
717
+ 'message': commit.message.decode('utf-8'),
718
+ 'timestamp': ts.isoformat(),
719
+ 'author': commit.author.decode('utf-8'),
720
+ })
721
+
722
+ if commit.parents:
723
+ commit_id = commit.parents[0]
724
+ else:
725
+ break
726
+
727
+ return commits