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.
- Agent/__init__.py +0 -0
- Agent/agent.py +891 -0
- Agent/chat_history_db.py +1500 -0
- Agent/command.py +49 -0
- Agent/config.py +46 -0
- Agent/effort_levels.py +33 -0
- Agent/git_manager.py +727 -0
- Agent/tools.py +35 -0
- Commands/__init__.py +18 -0
- Commands/effort.py +42 -0
- Commands/global_todo.py +23 -0
- Commands/help.py +22 -0
- Commands/reasoning.py +24 -0
- Commands/redo.py +11 -0
- Commands/reindex.py +27 -0
- Commands/shell.py +28 -0
- Commands/stream.py +24 -0
- Commands/undo.py +13 -0
- Commands/unlimited_effort.py +8 -0
- Commands/window_size.py +29 -0
- RAG/__init__.py +0 -0
- RAG/document.py +119 -0
- RAG/find.py +408 -0
- RAG/graph.py +231 -0
- Tools/GetFileCodeStructure.py +43 -0
- Tools/GetSymbolSourceCode.py +27 -0
- Tools/__init__.py +39 -0
- Tools/ask_user.py +102 -0
- Tools/dispatch_subagent.py +215 -0
- Tools/document.py +35 -0
- Tools/edit_symbol.py +250 -0
- Tools/fuzzy_search.py +119 -0
- Tools/list_dir.py +51 -0
- Tools/read.py +49 -0
- Tools/read_image.py +75 -0
- Tools/remove.py +75 -0
- Tools/replace.py +305 -0
- Tools/search.py +41 -0
- Tools/shell.py +149 -0
- Tools/shell_kill.py +87 -0
- Tools/temp_background_service.py +113 -0
- Tools/todo_list.py +481 -0
- Tools/utils.py +116 -0
- Tools/view_changes.py +179 -0
- Tools/walk_call_tree.py +30 -0
- Tools/web_fetch.py +175 -0
- Tools/web_search.py +69 -0
- Tools/write.py +48 -0
- cli.py +111 -0
- config/__init__.py +0 -0
- config/coder_system_prompt.md +119 -0
- config/roles.json +43 -0
- config/tools.json +709 -0
- indexing/__init__.py +0 -0
- indexing/cli.py +128 -0
- indexing/code_index_sdk.py +832 -0
- indexing/code_indexer.py +1763 -0
- indexing/db_schema.py +396 -0
- indexing/export_to_json.py +346 -0
- indexing/extractors.py +189 -0
- indexing/file_utils.py +97 -0
- indexing/frontend/__init__.py +0 -0
- indexing/frontend/css_extractor.py +195 -0
- indexing/frontend/css_parser.py +387 -0
- indexing/frontend/css_selector_utils.py +226 -0
- indexing/frontend/edit_safety.py +573 -0
- indexing/frontend/graph.py +838 -0
- indexing/frontend/html_extractor.py +496 -0
- indexing/frontend/html_parser.py +314 -0
- indexing/frontend/jsx_extractor.py +1204 -0
- indexing/frontend/location_lookup.py +247 -0
- indexing/frontend/resolver.py +485 -0
- indexing/frontend/runtime_resolver.py +862 -0
- indexing/frontend/semantic_output.py +705 -0
- indexing/frontend/source_location.py +69 -0
- indexing/frontend_config.py +72 -0
- indexing/frontend_models.py +347 -0
- indexing/language_config.py +360 -0
- indexing/models.py +284 -0
- indexing/node_utils.py +1112 -0
- indexing/parse_worker.py +1082 -0
- indexing/queries.py +1542 -0
- indexing/sdk_examples.py +426 -0
- interactive.py +248 -0
- raggie.py +673 -0
- raggiecode-0.2.1.dist-info/METADATA +944 -0
- raggiecode-0.2.1.dist-info/RECORD +93 -0
- raggiecode-0.2.1.dist-info/WHEEL +5 -0
- raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
- raggiecode-0.2.1.dist-info/top_level.txt +10 -0
- skills/__init__.py +3 -0
- skills/manager.py +114 -0
- 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
|