cade-cli 0.3.3__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.
- cade_cli-0.3.3.dist-info/METADATA +151 -0
- cade_cli-0.3.3.dist-info/RECORD +44 -0
- cade_cli-0.3.3.dist-info/WHEEL +4 -0
- cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
- cadecoder/__init__.py +1 -0
- cadecoder/ai/__init__.py +6 -0
- cadecoder/ai/prompts.py +572 -0
- cadecoder/cli/__init__.py +0 -0
- cadecoder/cli/app.py +147 -0
- cadecoder/cli/auth.py +483 -0
- cadecoder/cli/commands/__init__.py +5 -0
- cadecoder/cli/commands/auth.py +143 -0
- cadecoder/cli/commands/chat.py +264 -0
- cadecoder/cli/commands/mcp.py +477 -0
- cadecoder/cli/commands/tools.py +226 -0
- cadecoder/core/__init__.py +12 -0
- cadecoder/core/config.py +380 -0
- cadecoder/core/constants.py +281 -0
- cadecoder/core/errors.py +145 -0
- cadecoder/core/logging.py +148 -0
- cadecoder/core/types.py +235 -0
- cadecoder/core/utils.py +279 -0
- cadecoder/execution/__init__.py +46 -0
- cadecoder/execution/context_window.py +521 -0
- cadecoder/execution/orchestrator.py +562 -0
- cadecoder/execution/parallel.py +287 -0
- cadecoder/providers/__init__.py +60 -0
- cadecoder/providers/base.py +294 -0
- cadecoder/providers/openai.py +251 -0
- cadecoder/storage/__init__.py +0 -0
- cadecoder/storage/threads.py +489 -0
- cadecoder/templates/login_failed.html +21 -0
- cadecoder/templates/login_success.html +21 -0
- cadecoder/templates/styles.css +87 -0
- cadecoder/tools/__init__.py +19 -0
- cadecoder/tools/builtin.py +644 -0
- cadecoder/tools/filesystem.py +315 -0
- cadecoder/tools/git.py +221 -0
- cadecoder/tools/manager.py +1635 -0
- cadecoder/ui/__init__.py +7 -0
- cadecoder/ui/display.py +338 -0
- cadecoder/ui/input.py +145 -0
- cadecoder/ui/session.py +455 -0
- cadecoder/ui/state.py +20 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Filesystem operations for CadeCoder."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import fnmatch
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cadecoder.core.errors import FileOpsError, FileSystemError
|
|
10
|
+
from cadecoder.core.logging import log
|
|
11
|
+
|
|
12
|
+
# --- File Finding and Discovery ---
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_files(
|
|
16
|
+
directory: Path,
|
|
17
|
+
ignore_patterns: list[str] | None = None,
|
|
18
|
+
include_directories: bool = False,
|
|
19
|
+
recursive: bool = True,
|
|
20
|
+
) -> list[Path]:
|
|
21
|
+
"""Find files (and optionally directories) recursively, respecting ignore patterns."""
|
|
22
|
+
found_paths: list[Path] = []
|
|
23
|
+
normalized_ignore_patterns = ignore_patterns or []
|
|
24
|
+
|
|
25
|
+
if not directory.is_dir():
|
|
26
|
+
log.warning(f"Directory not found or not accessible: {directory}")
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
# Heuristic: patterns without typical glob wildcards are treated as potential directory names
|
|
30
|
+
# for the component-wise check. All patterns are still used for full/name glob matching.
|
|
31
|
+
ignorable_dir_components = {
|
|
32
|
+
pattern
|
|
33
|
+
for pattern in normalized_ignore_patterns
|
|
34
|
+
if "*" not in pattern and "?" not in pattern and "[" not in pattern and "]" not in pattern
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
items_to_iterate = list(directory.rglob("*")) if recursive else list(directory.glob("*"))
|
|
38
|
+
|
|
39
|
+
for item in items_to_iterate:
|
|
40
|
+
relative_path = item.relative_to(directory)
|
|
41
|
+
|
|
42
|
+
# Stage 1: Check if the item is within an explicitly named ignored directory component
|
|
43
|
+
is_in_ignored_dir_component = False
|
|
44
|
+
# Check item.name itself if it's a directory and matches a dir component pattern
|
|
45
|
+
if item.is_dir() and item.name in ignorable_dir_components:
|
|
46
|
+
is_in_ignored_dir_component = True
|
|
47
|
+
else: # Check parent parts if the item itself (if a dir) wasn't a direct match
|
|
48
|
+
# For a file like 'some_dir/another_dir/file.txt', parts are ('some_dir', 'another_dir', 'file.txt')
|
|
49
|
+
# We check parts[:-1] which would be ('some_dir', 'another_dir')
|
|
50
|
+
for part in relative_path.parts[:-1]:
|
|
51
|
+
if part in ignorable_dir_components:
|
|
52
|
+
is_in_ignored_dir_component = True
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
if is_in_ignored_dir_component:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Stage 2: Check full relative path and item name against all original glob patterns.
|
|
59
|
+
# This handles file-specific patterns (e.g., *.pyc) and more complex/wildcarded directory patterns.
|
|
60
|
+
is_glob_ignored = False
|
|
61
|
+
for pattern in normalized_ignore_patterns:
|
|
62
|
+
# Use POSIX separators for matching, even on Windows, for the full relative path
|
|
63
|
+
relative_path_posix = str(relative_path).replace(os.sep, "/")
|
|
64
|
+
if fnmatch.fnmatch(relative_path_posix, pattern) or fnmatch.fnmatch(item.name, pattern):
|
|
65
|
+
is_glob_ignored = True
|
|
66
|
+
log.debug(
|
|
67
|
+
f"Ignoring '{relative_path}' due to glob pattern '{pattern}' matching either full path or item name."
|
|
68
|
+
)
|
|
69
|
+
break
|
|
70
|
+
if is_glob_ignored:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# If not ignored by any rule, add it based on its type
|
|
74
|
+
if item.is_file():
|
|
75
|
+
found_paths.append(item)
|
|
76
|
+
elif item.is_dir() and include_directories:
|
|
77
|
+
# Only add if explicitly requested and it passed all ignore checks
|
|
78
|
+
found_paths.append(item)
|
|
79
|
+
|
|
80
|
+
return found_paths
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- File Reading and Writing ---
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_text_file(file_path: Path) -> str:
|
|
87
|
+
"""Read a text file, attempting common encodings."""
|
|
88
|
+
encodings = ["utf-8", "latin-1", "cp1252"]
|
|
89
|
+
for encoding in encodings:
|
|
90
|
+
try:
|
|
91
|
+
with file_path.open("r", encoding=encoding) as f:
|
|
92
|
+
return f.read()
|
|
93
|
+
except UnicodeDecodeError:
|
|
94
|
+
continue
|
|
95
|
+
except Exception as e:
|
|
96
|
+
log.warning(f"Error reading file {file_path} with encoding {encoding}: {e}")
|
|
97
|
+
# Propagate other errors (e.g., permission denied)
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
# If all encodings fail
|
|
101
|
+
log.warning(f"Could not decode file {file_path} with standard encodings.")
|
|
102
|
+
# Return empty string or raise a custom error?
|
|
103
|
+
# For robustness, let's try reading as binary and decoding with replacement
|
|
104
|
+
try:
|
|
105
|
+
with file_path.open("rb") as f:
|
|
106
|
+
binary_content = f.read()
|
|
107
|
+
return binary_content.decode("utf-8", errors="replace")
|
|
108
|
+
except Exception as e:
|
|
109
|
+
log.error(f"Failed to read file {file_path} even as binary: {e}")
|
|
110
|
+
raise FileSystemError(f"Could not read file: {file_path}") from e
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def write_text_file(file_path: Path, content: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Write text content to a file, overwriting if it exists.
|
|
116
|
+
|
|
117
|
+
Security: The target path is first resolved and verified to be within the
|
|
118
|
+
project's root directory (the current working directory). Any attempt to
|
|
119
|
+
write outside this root—whether through '..' path traversal, absolute paths,
|
|
120
|
+
or symlink resolution—results in a FileSystemError.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
# Resolve both the project root and the requested file path
|
|
124
|
+
project_root = Path.cwd().resolve()
|
|
125
|
+
resolved_path = file_path.expanduser().resolve()
|
|
126
|
+
|
|
127
|
+
# Ensure the resolved path is within the project root
|
|
128
|
+
if project_root not in resolved_path.parents and resolved_path != project_root:
|
|
129
|
+
raise FileSystemError(f"Refusing to write outside project root: '{file_path}'.")
|
|
130
|
+
|
|
131
|
+
# Ensure parent directory exists
|
|
132
|
+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
# Write content to file
|
|
135
|
+
with resolved_path.open("w", encoding="utf-8") as f:
|
|
136
|
+
f.write(content)
|
|
137
|
+
log.debug(f"Successfully wrote content to {resolved_path}")
|
|
138
|
+
except FileSystemError:
|
|
139
|
+
# Re‑raise custom filesystem errors without modification
|
|
140
|
+
raise
|
|
141
|
+
except OSError as e:
|
|
142
|
+
log.error(f"Failed to write to file {file_path}: {e}")
|
|
143
|
+
raise FileSystemError(f"Could not write file: {file_path}") from e
|
|
144
|
+
except Exception as e:
|
|
145
|
+
log.error(f"An unexpected error occurred writing to file {file_path}: {e}")
|
|
146
|
+
raise FileSystemError(f"Could not write file: {file_path}") from e
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- File Existence Checks ---
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def file_exists(file_path: Path) -> bool:
|
|
153
|
+
"""Check if a file exists and is a file."""
|
|
154
|
+
return file_path.is_file()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def dir_exists(dir_path: Path) -> bool:
|
|
158
|
+
"""Check if a directory exists and is a directory."""
|
|
159
|
+
return dir_path.is_dir()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- Diff Generation ---
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _generate_diff_base(
|
|
166
|
+
lines1: list[str],
|
|
167
|
+
lines2: list[str],
|
|
168
|
+
fromfile: str,
|
|
169
|
+
tofile: str,
|
|
170
|
+
context_lines: int = 3,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Base function for generating unified diffs."""
|
|
173
|
+
try:
|
|
174
|
+
diff = difflib.unified_diff(
|
|
175
|
+
lines1,
|
|
176
|
+
lines2,
|
|
177
|
+
fromfile=fromfile,
|
|
178
|
+
tofile=tofile,
|
|
179
|
+
n=context_lines,
|
|
180
|
+
lineterm="", # Avoid extra newlines in diff output
|
|
181
|
+
)
|
|
182
|
+
return "\n".join(diff)
|
|
183
|
+
except Exception as e:
|
|
184
|
+
log.error(f"Failed to generate diff: {e}")
|
|
185
|
+
raise FileOpsError(f"Could not generate diff: {e}") from e
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def generate_diff(path1: Path, path2: Path, context_lines: int = 3) -> str:
|
|
189
|
+
"""Generates a unified diff between two files."""
|
|
190
|
+
try:
|
|
191
|
+
content1 = read_text_file(path1).splitlines()
|
|
192
|
+
content2 = read_text_file(path2).splitlines()
|
|
193
|
+
return _generate_diff_base(content1, content2, str(path1), str(path2), context_lines)
|
|
194
|
+
except FileOpsError:
|
|
195
|
+
raise
|
|
196
|
+
except Exception as e:
|
|
197
|
+
log.error(f"Failed to generate diff between {path1} and {path2}: {e}")
|
|
198
|
+
raise FileOpsError(f"Could not generate diff: {e}") from e
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def generate_diff_from_content(
|
|
202
|
+
original_content: str,
|
|
203
|
+
new_content: str,
|
|
204
|
+
filename: str = "file",
|
|
205
|
+
context_lines: int = 3,
|
|
206
|
+
) -> str:
|
|
207
|
+
"""Generates a unified diff between two strings of content."""
|
|
208
|
+
lines1 = original_content.splitlines()
|
|
209
|
+
lines2 = new_content.splitlines()
|
|
210
|
+
return _generate_diff_base(
|
|
211
|
+
lines1,
|
|
212
|
+
lines2,
|
|
213
|
+
f"a/{filename}", # Standard diff format convention
|
|
214
|
+
f"b/{filename}", # Standard diff format convention
|
|
215
|
+
context_lines,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --- Patch Application ---
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def apply_patch(original_content: str, patch_content: str) -> str:
|
|
223
|
+
"""Applies a unified diff patch to string content.
|
|
224
|
+
|
|
225
|
+
Note: This is a basic implementation using difflib. It might not handle
|
|
226
|
+
all patch complexities (fuzziness, binary files, etc.).
|
|
227
|
+
Consider using a dedicated patch library for more robustness if needed.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
original_content: The original string content.
|
|
231
|
+
patch_content: The unified diff patch string.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The patched string content.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
FileOpsError: If the patch cannot be applied cleanly.
|
|
238
|
+
"""
|
|
239
|
+
# Basic check for patch format
|
|
240
|
+
if not patch_content.strip() or not patch_content.startswith(("+++", "---")):
|
|
241
|
+
log.warning("Patch content appears empty or invalid.")
|
|
242
|
+
# Decide whether to return original or raise error
|
|
243
|
+
# For safety, let's return original if patch is clearly empty
|
|
244
|
+
if not patch_content.strip():
|
|
245
|
+
return original_content
|
|
246
|
+
raise FileOpsError("Invalid patch format provided.")
|
|
247
|
+
|
|
248
|
+
# difflib.patch expects list of lines
|
|
249
|
+
original_lines = original_content.splitlines(keepends=True)
|
|
250
|
+
patch_content.splitlines(keepends=True)
|
|
251
|
+
|
|
252
|
+
# Use difflib._patch_apply which underlies the `patch` command-line tool logic
|
|
253
|
+
# It's an internal function but provides the core logic.
|
|
254
|
+
# It returns a tuple: (new_lines, boolean_list_of_success)
|
|
255
|
+
try:
|
|
256
|
+
# difflib does not provide a public patch application API.
|
|
257
|
+
# We'll use difflib.restore to reconstruct the new content from the diff.
|
|
258
|
+
# This only works for diffs generated by difflib.unified_diff or ndiff.
|
|
259
|
+
# We check if the patch is a unified diff and apply accordingly.
|
|
260
|
+
|
|
261
|
+
# Try to use difflib.restore for ndiff-style diffs
|
|
262
|
+
if patch_content.startswith("---") or patch_content.startswith("+++"):
|
|
263
|
+
# Unified diff: use difflib.unified_diff to get the diff, but no direct apply
|
|
264
|
+
# We'll use patch-like logic to apply the diff
|
|
265
|
+
try:
|
|
266
|
+
patched_lines = list(
|
|
267
|
+
difflib.restore(
|
|
268
|
+
difflib.unified_diff(
|
|
269
|
+
original_lines,
|
|
270
|
+
[], # empty, just to get the diff format
|
|
271
|
+
lineterm="",
|
|
272
|
+
),
|
|
273
|
+
2, # 2 = get the "to" file
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
# However, this doesn't actually apply the patch_content, so we need to use ndiff
|
|
277
|
+
# Instead, let's use ndiff and restore if possible
|
|
278
|
+
diff_lines = patch_content.splitlines(keepends=True)
|
|
279
|
+
patched_lines = list(difflib.restore(diff_lines, 2))
|
|
280
|
+
log.debug("Patch applied using difflib.restore.")
|
|
281
|
+
return "".join(patched_lines)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
log.error(f"Error applying patch with difflib.restore: {e}")
|
|
284
|
+
raise FileOpsError(f"Failed during patch application process: {e}") from e
|
|
285
|
+
else:
|
|
286
|
+
log.error("Patch format not supported for difflib.restore.")
|
|
287
|
+
raise FileOpsError("Patch format not supported for difflib.restore.")
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
# Catch potential errors from the internal function
|
|
291
|
+
log.error(f"Error applying patch: {e}")
|
|
292
|
+
raise FileOpsError(f"Failed during patch application process: {e}") from e
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --- Code Extraction ---
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def extract_code_from_markdown(markdown_content: str) -> str | None:
|
|
299
|
+
"""Extracts the first code block content from markdown text.
|
|
300
|
+
|
|
301
|
+
Handles common code block markers like ``` and ```<language>.
|
|
302
|
+
Returns None if no code block is found.
|
|
303
|
+
"""
|
|
304
|
+
# Regex to find fenced code blocks, possibly with language specifier
|
|
305
|
+
# It captures the content inside the first block found.
|
|
306
|
+
# DOTALL flag makes . match newlines.
|
|
307
|
+
match = re.search(r"^```(?:\w+)?\n(.*?)\n^```", markdown_content, re.MULTILINE | re.DOTALL)
|
|
308
|
+
|
|
309
|
+
if match:
|
|
310
|
+
return match.group(1).strip() # Return the captured group content
|
|
311
|
+
else:
|
|
312
|
+
log.debug("No markdown code block found in content.")
|
|
313
|
+
# Should we return the whole content if no block found?
|
|
314
|
+
# For now, return None if no explicit block.
|
|
315
|
+
return None
|
cadecoder/tools/git.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Git operations for the CadeCoder chat application.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import pathlib
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from cadecoder.core.logging import log
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_project_root() -> pathlib.Path:
|
|
13
|
+
"""Get the project root directory."""
|
|
14
|
+
return pathlib.Path.cwd()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PROJECT_ROOT = get_project_root()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sanitize_branch_name(name: str) -> str:
|
|
21
|
+
"""Sanitizes a string to be a valid Git branch name."""
|
|
22
|
+
if not name:
|
|
23
|
+
return "unnamed-chat-branch"
|
|
24
|
+
|
|
25
|
+
name = name.replace(" ", "-").replace("/", "-")
|
|
26
|
+
name = name.replace("..", "-").replace(".lock", "")
|
|
27
|
+
|
|
28
|
+
sanitized = "".join(c if c.isalnum() or c in "-_" else "-" for c in name)
|
|
29
|
+
|
|
30
|
+
if sanitized.startswith("-"):
|
|
31
|
+
sanitized = "b-" + sanitized[1:] if len(sanitized) > 1 else "b-branch"
|
|
32
|
+
|
|
33
|
+
while "--" in sanitized:
|
|
34
|
+
sanitized = sanitized.replace("--", "-")
|
|
35
|
+
|
|
36
|
+
sanitized = sanitized.strip("-")
|
|
37
|
+
|
|
38
|
+
if sanitized.endswith("."):
|
|
39
|
+
sanitized = sanitized[:-1] + "-dot"
|
|
40
|
+
|
|
41
|
+
if not sanitized:
|
|
42
|
+
return "sanitized-empty-branch"
|
|
43
|
+
|
|
44
|
+
if len(sanitized) > 100:
|
|
45
|
+
sanitized = sanitized[:100]
|
|
46
|
+
if "@{" in sanitized:
|
|
47
|
+
sanitized = sanitized.replace("@{", "at-")
|
|
48
|
+
|
|
49
|
+
return sanitized
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_git_command(git_command_args: list[str]) -> tuple[str, str | None]:
|
|
53
|
+
"""
|
|
54
|
+
Runs a Git command and returns its output.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
git_command_args: Git command and arguments (e.g., ["status", "--short"]).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (stdout, stderr_message). stderr_message is None on success.
|
|
61
|
+
"""
|
|
62
|
+
command = ["git"] + git_command_args
|
|
63
|
+
command_str = " ".join(command)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
log.debug(f"Running git command: {command_str}")
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
command,
|
|
69
|
+
cwd=str(PROJECT_ROOT),
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
check=False,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
stdout_val = result.stdout.strip()
|
|
76
|
+
|
|
77
|
+
if result.returncode != 0:
|
|
78
|
+
err_msg = (
|
|
79
|
+
result.stderr.strip()
|
|
80
|
+
if result.stderr
|
|
81
|
+
else (
|
|
82
|
+
stdout_val
|
|
83
|
+
if stdout_val
|
|
84
|
+
else f"Command '{command_str}' failed: exit code {result.returncode}"
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return stdout_val, err_msg
|
|
88
|
+
|
|
89
|
+
return stdout_val, None
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
log.error(f"Unexpected error running git command: {e}")
|
|
93
|
+
return "", f"Error running command '{command_str}': {str(e)}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def create_or_switch_branch(chat_id: str, chat_name: str) -> tuple[str, str | None, str | None]:
|
|
97
|
+
"""
|
|
98
|
+
Creates or switches to a Git branch for the chat session.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Tuple of (message_to_display, error_message_if_any, branch_name).
|
|
102
|
+
"""
|
|
103
|
+
base_name = chat_name if chat_name and chat_name.lower() != "new chat" else chat_id
|
|
104
|
+
branch_name = _sanitize_branch_name(f"cadechat/{base_name}")
|
|
105
|
+
|
|
106
|
+
list_stdout, _ = run_git_command(["branch", "--list", branch_name])
|
|
107
|
+
branch_exists_locally = branch_name in list_stdout.replace("*", "").strip().split("\n")
|
|
108
|
+
|
|
109
|
+
if branch_exists_locally:
|
|
110
|
+
stdout, err = run_git_command(["checkout", branch_name])
|
|
111
|
+
if err:
|
|
112
|
+
return stdout, f"Failed to switch to branch '{branch_name}': {err}", None
|
|
113
|
+
return f"Switched to existing branch: {branch_name}", None, branch_name
|
|
114
|
+
else:
|
|
115
|
+
stdout, err = run_git_command(["checkout", "-b", branch_name])
|
|
116
|
+
if err:
|
|
117
|
+
return stdout, f"Failed to create branch '{branch_name}': {err}", None
|
|
118
|
+
return f"Created and switched to new branch: {branch_name}", None, branch_name
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_status() -> tuple[str, str | None]:
|
|
122
|
+
"""Gets repository status using 'git status --short'."""
|
|
123
|
+
return run_git_command(["status", "--short"])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def stage_files(files: list[str] | None = None) -> tuple[str, str | None]:
|
|
127
|
+
"""Stages specified files or all changes if no files are provided."""
|
|
128
|
+
cmd_args = ["add", "--"] + files if files else ["add", "."]
|
|
129
|
+
stdout, stderr = run_git_command(cmd_args)
|
|
130
|
+
|
|
131
|
+
if stderr:
|
|
132
|
+
return stdout, f"Error staging files: {stderr}"
|
|
133
|
+
if not files:
|
|
134
|
+
return "All changes staged.", None
|
|
135
|
+
return f"Staged: {', '.join(files)}", None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _explain_pre_commit_failure(error_message: str) -> str:
|
|
139
|
+
"""Provides a basic explanation for common pre-commit errors."""
|
|
140
|
+
explanation = "Pre-commit hook failed."
|
|
141
|
+
if "lint" in error_message.lower():
|
|
142
|
+
explanation += " Linting issues found. Please fix code style."
|
|
143
|
+
elif (
|
|
144
|
+
"unstaged" in error_message.lower() or "no changes added to commit" in error_message.lower()
|
|
145
|
+
):
|
|
146
|
+
explanation += " Unstaged changes or no changes added. Stage first."
|
|
147
|
+
else:
|
|
148
|
+
explanation += " Check pre-commit scripts and error messages."
|
|
149
|
+
return explanation
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def commit_staged_changes(message: str, max_iterations: int = 2) -> tuple[str, str | None]:
|
|
153
|
+
"""Attempts to commit staged changes."""
|
|
154
|
+
stdout, stderr = run_git_command(["commit", "-m", message])
|
|
155
|
+
|
|
156
|
+
if stderr:
|
|
157
|
+
is_hook_failure = "hook" in stderr.lower() or "failed" in stderr.lower()
|
|
158
|
+
if is_hook_failure and max_iterations > 1:
|
|
159
|
+
explanation = _explain_pre_commit_failure(stderr)
|
|
160
|
+
no_v_stdout, no_v_stderr = run_git_command(["commit", "--no-verify", "-m", message])
|
|
161
|
+
if no_v_stderr:
|
|
162
|
+
full_err_msg = (
|
|
163
|
+
f"Initial commit failed: {stderr}\n{explanation}\n"
|
|
164
|
+
f"Commit with --no-verify also failed: {no_v_stderr}"
|
|
165
|
+
)
|
|
166
|
+
return stdout, full_err_msg
|
|
167
|
+
success_msg = (
|
|
168
|
+
f"Initial commit failed: {stderr}\n{explanation}\n"
|
|
169
|
+
f"Successfully committed with --no-verify."
|
|
170
|
+
)
|
|
171
|
+
return no_v_stdout or success_msg, None
|
|
172
|
+
return stdout, stderr
|
|
173
|
+
|
|
174
|
+
return stdout or "Changes committed successfully.", None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def reset_local_changes() -> tuple[str, str | None]:
|
|
178
|
+
"""Resets uncommitted changes by stashing them."""
|
|
179
|
+
branch_name, _ = get_current_branch_name()
|
|
180
|
+
s_branch = _sanitize_branch_name(branch_name or "unknownbranch")
|
|
181
|
+
proj_id = PROJECT_ROOT.name
|
|
182
|
+
stash_msg = f"cadecoder-chat-stash/{proj_id}/{s_branch}"
|
|
183
|
+
|
|
184
|
+
stdout, stderr = run_git_command(["stash", "push", "-u", "-m", stash_msg])
|
|
185
|
+
|
|
186
|
+
if stderr:
|
|
187
|
+
return stdout, f"Error stashing changes: {stderr}"
|
|
188
|
+
if "No local changes to save" in stdout:
|
|
189
|
+
return "No local changes to reset (stash).", None
|
|
190
|
+
return f"Uncommitted changes stashed: '{stash_msg}'.", None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_current_branch_name() -> tuple[str, str | None]:
|
|
194
|
+
"""Gets the current active Git branch name."""
|
|
195
|
+
return run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def reset_chat():
|
|
199
|
+
"""Use git stash to reset changes."""
|
|
200
|
+
stdout, stderr = run_git_command(["stash"])
|
|
201
|
+
if stderr:
|
|
202
|
+
print(f"Error during git stash: {stderr}")
|
|
203
|
+
else:
|
|
204
|
+
print("Chat context changes stashed (reset).")
|
|
205
|
+
return stdout, stderr
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def clear_chat_context():
|
|
209
|
+
"""Clear current chat context."""
|
|
210
|
+
print("Chat context cleared (SQLite history preserved).")
|
|
211
|
+
return "Context cleared"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def stage_command(files=None):
|
|
215
|
+
"""Stage files command."""
|
|
216
|
+
return stage_files(files)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def commit_command(message):
|
|
220
|
+
"""Commit command."""
|
|
221
|
+
return commit_staged_changes(message)
|