nc1709 1.15.4__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.
- nc1709/__init__.py +13 -0
- nc1709/agent/__init__.py +36 -0
- nc1709/agent/core.py +505 -0
- nc1709/agent/mcp_bridge.py +245 -0
- nc1709/agent/permissions.py +298 -0
- nc1709/agent/tools/__init__.py +21 -0
- nc1709/agent/tools/base.py +440 -0
- nc1709/agent/tools/bash_tool.py +367 -0
- nc1709/agent/tools/file_tools.py +454 -0
- nc1709/agent/tools/notebook_tools.py +516 -0
- nc1709/agent/tools/search_tools.py +322 -0
- nc1709/agent/tools/task_tool.py +284 -0
- nc1709/agent/tools/web_tools.py +555 -0
- nc1709/agents/__init__.py +17 -0
- nc1709/agents/auto_fix.py +506 -0
- nc1709/agents/test_generator.py +507 -0
- nc1709/checkpoints.py +372 -0
- nc1709/cli.py +3380 -0
- nc1709/cli_ui.py +1080 -0
- nc1709/cognitive/__init__.py +149 -0
- nc1709/cognitive/anticipation.py +594 -0
- nc1709/cognitive/context_engine.py +1046 -0
- nc1709/cognitive/council.py +824 -0
- nc1709/cognitive/learning.py +761 -0
- nc1709/cognitive/router.py +583 -0
- nc1709/cognitive/system.py +519 -0
- nc1709/config.py +155 -0
- nc1709/custom_commands.py +300 -0
- nc1709/executor.py +333 -0
- nc1709/file_controller.py +354 -0
- nc1709/git_integration.py +308 -0
- nc1709/github_integration.py +477 -0
- nc1709/image_input.py +446 -0
- nc1709/linting.py +519 -0
- nc1709/llm_adapter.py +667 -0
- nc1709/logger.py +192 -0
- nc1709/mcp/__init__.py +18 -0
- nc1709/mcp/client.py +370 -0
- nc1709/mcp/manager.py +407 -0
- nc1709/mcp/protocol.py +210 -0
- nc1709/mcp/server.py +473 -0
- nc1709/memory/__init__.py +20 -0
- nc1709/memory/embeddings.py +325 -0
- nc1709/memory/indexer.py +474 -0
- nc1709/memory/sessions.py +432 -0
- nc1709/memory/vector_store.py +451 -0
- nc1709/models/__init__.py +86 -0
- nc1709/models/detector.py +377 -0
- nc1709/models/formats.py +315 -0
- nc1709/models/manager.py +438 -0
- nc1709/models/registry.py +497 -0
- nc1709/performance/__init__.py +343 -0
- nc1709/performance/cache.py +705 -0
- nc1709/performance/pipeline.py +611 -0
- nc1709/performance/tiering.py +543 -0
- nc1709/plan_mode.py +362 -0
- nc1709/plugins/__init__.py +17 -0
- nc1709/plugins/agents/__init__.py +18 -0
- nc1709/plugins/agents/django_agent.py +912 -0
- nc1709/plugins/agents/docker_agent.py +623 -0
- nc1709/plugins/agents/fastapi_agent.py +887 -0
- nc1709/plugins/agents/git_agent.py +731 -0
- nc1709/plugins/agents/nextjs_agent.py +867 -0
- nc1709/plugins/base.py +359 -0
- nc1709/plugins/manager.py +411 -0
- nc1709/plugins/registry.py +337 -0
- nc1709/progress.py +443 -0
- nc1709/prompts/__init__.py +22 -0
- nc1709/prompts/agent_system.py +180 -0
- nc1709/prompts/task_prompts.py +340 -0
- nc1709/prompts/unified_prompt.py +133 -0
- nc1709/reasoning_engine.py +541 -0
- nc1709/remote_client.py +266 -0
- nc1709/shell_completions.py +349 -0
- nc1709/slash_commands.py +649 -0
- nc1709/task_classifier.py +408 -0
- nc1709/version_check.py +177 -0
- nc1709/web/__init__.py +8 -0
- nc1709/web/server.py +950 -0
- nc1709/web/templates/index.html +1127 -0
- nc1709-1.15.4.dist-info/METADATA +858 -0
- nc1709-1.15.4.dist-info/RECORD +86 -0
- nc1709-1.15.4.dist-info/WHEEL +5 -0
- nc1709-1.15.4.dist-info/entry_points.txt +2 -0
- nc1709-1.15.4.dist-info/licenses/LICENSE +9 -0
- nc1709-1.15.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filesystem Controller with Safety Features
|
|
3
|
+
Handles all file operations with automatic backups and atomic transactions
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import hashlib
|
|
8
|
+
import difflib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from .config import get_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileController:
|
|
17
|
+
"""Manages safe filesystem operations"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
"""Initialize the file controller"""
|
|
21
|
+
self.config = get_config()
|
|
22
|
+
# Get backup directory from config, with fallback to default
|
|
23
|
+
backup_path = self.config.get("safety.backup_dir", "~/.nc1709/backups")
|
|
24
|
+
self.backup_dir = Path(backup_path).expanduser().resolve()
|
|
25
|
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
def read_file(self, file_path: str) -> str:
|
|
28
|
+
"""Read a file safely
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
file_path: Path to the file
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
File contents as string
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
FileNotFoundError: If file doesn't exist
|
|
38
|
+
PermissionError: If file can't be read
|
|
39
|
+
"""
|
|
40
|
+
path = Path(file_path).expanduser().resolve()
|
|
41
|
+
|
|
42
|
+
if not path.exists():
|
|
43
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
44
|
+
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise ValueError(f"Not a file: {file_path}")
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
50
|
+
return f.read()
|
|
51
|
+
except UnicodeDecodeError:
|
|
52
|
+
# Try reading as binary if UTF-8 fails
|
|
53
|
+
with open(path, 'rb') as f:
|
|
54
|
+
return f.read().decode('utf-8', errors='replace')
|
|
55
|
+
|
|
56
|
+
def write_file(
|
|
57
|
+
self,
|
|
58
|
+
file_path: str,
|
|
59
|
+
content: str,
|
|
60
|
+
create_backup: bool = True,
|
|
61
|
+
confirm: bool = True
|
|
62
|
+
) -> bool:
|
|
63
|
+
"""Write content to a file with safety checks
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
file_path: Path to the file
|
|
67
|
+
content: Content to write
|
|
68
|
+
create_backup: Whether to create a backup first
|
|
69
|
+
confirm: Whether to ask for confirmation
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
True if write was successful
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
PermissionError: If file can't be written
|
|
76
|
+
"""
|
|
77
|
+
path = Path(file_path).expanduser().resolve()
|
|
78
|
+
|
|
79
|
+
# Check if we should confirm
|
|
80
|
+
if confirm and self.config.get("safety.confirm_writes", True):
|
|
81
|
+
if path.exists():
|
|
82
|
+
print(f"\n⚠️ File exists: {path}")
|
|
83
|
+
print("This will overwrite the existing file.")
|
|
84
|
+
response = input("Continue? [y/N]: ").strip().lower()
|
|
85
|
+
if response != 'y':
|
|
86
|
+
print("Write cancelled.")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
# Create backup if file exists
|
|
90
|
+
if create_backup and path.exists() and self.config.get("safety.auto_backup", True):
|
|
91
|
+
self._create_backup(path)
|
|
92
|
+
|
|
93
|
+
# Ensure parent directory exists
|
|
94
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
# Write the file
|
|
97
|
+
try:
|
|
98
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
99
|
+
f.write(content)
|
|
100
|
+
print(f"✅ File written: {path}")
|
|
101
|
+
return True
|
|
102
|
+
except Exception as e:
|
|
103
|
+
print(f"❌ Failed to write file: {e}")
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
def edit_file(
|
|
107
|
+
self,
|
|
108
|
+
file_path: str,
|
|
109
|
+
old_content: str,
|
|
110
|
+
new_content: str,
|
|
111
|
+
create_backup: bool = True
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""Edit a file by replacing old content with new content
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
file_path: Path to the file
|
|
117
|
+
old_content: Content to replace
|
|
118
|
+
new_content: New content
|
|
119
|
+
create_backup: Whether to create a backup first
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if edit was successful
|
|
123
|
+
"""
|
|
124
|
+
path = Path(file_path).expanduser().resolve()
|
|
125
|
+
|
|
126
|
+
if not path.exists():
|
|
127
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
128
|
+
|
|
129
|
+
# Read current content
|
|
130
|
+
current_content = self.read_file(file_path)
|
|
131
|
+
|
|
132
|
+
# Check if old_content exists in file
|
|
133
|
+
if old_content not in current_content:
|
|
134
|
+
print(f"⚠️ Content to replace not found in {file_path}")
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
# Create backup
|
|
138
|
+
if create_backup and self.config.get("safety.auto_backup", True):
|
|
139
|
+
self._create_backup(path)
|
|
140
|
+
|
|
141
|
+
# Replace content
|
|
142
|
+
updated_content = current_content.replace(old_content, new_content, 1)
|
|
143
|
+
|
|
144
|
+
# Show diff
|
|
145
|
+
self._show_diff(current_content, updated_content, str(path))
|
|
146
|
+
|
|
147
|
+
# Confirm
|
|
148
|
+
if self.config.get("safety.confirm_writes", True):
|
|
149
|
+
response = input("\nApply these changes? [y/N]: ").strip().lower()
|
|
150
|
+
if response != 'y':
|
|
151
|
+
print("Edit cancelled.")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
# Write updated content
|
|
155
|
+
return self.write_file(file_path, updated_content, create_backup=False, confirm=False)
|
|
156
|
+
|
|
157
|
+
def create_file(self, file_path: str, content: str = "") -> bool:
|
|
158
|
+
"""Create a new file
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
file_path: Path to the file
|
|
162
|
+
content: Initial content (default: empty)
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if creation was successful
|
|
166
|
+
"""
|
|
167
|
+
path = Path(file_path).expanduser().resolve()
|
|
168
|
+
|
|
169
|
+
if path.exists():
|
|
170
|
+
print(f"⚠️ File already exists: {file_path}")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
return self.write_file(file_path, content, create_backup=False, confirm=False)
|
|
174
|
+
|
|
175
|
+
def delete_file(self, file_path: str, confirm: bool = True) -> bool:
|
|
176
|
+
"""Delete a file with confirmation
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
file_path: Path to the file
|
|
180
|
+
confirm: Whether to ask for confirmation
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True if deletion was successful
|
|
184
|
+
"""
|
|
185
|
+
path = Path(file_path).expanduser().resolve()
|
|
186
|
+
|
|
187
|
+
if not path.exists():
|
|
188
|
+
print(f"⚠️ File not found: {file_path}")
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
# Create backup before deleting
|
|
192
|
+
if self.config.get("safety.auto_backup", True):
|
|
193
|
+
self._create_backup(path)
|
|
194
|
+
|
|
195
|
+
# Confirm deletion
|
|
196
|
+
if confirm and self.config.get("safety.confirm_destructive", True):
|
|
197
|
+
print(f"\n⚠️ About to delete: {path}")
|
|
198
|
+
response = input("Are you sure? [y/N]: ").strip().lower()
|
|
199
|
+
if response != 'y':
|
|
200
|
+
print("Deletion cancelled.")
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
path.unlink()
|
|
205
|
+
print(f"✅ File deleted: {path}")
|
|
206
|
+
return True
|
|
207
|
+
except Exception as e:
|
|
208
|
+
print(f"❌ Failed to delete file: {e}")
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def list_files(self, directory: str, pattern: str = "*") -> List[str]:
|
|
212
|
+
"""List files in a directory
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
directory: Directory path
|
|
216
|
+
pattern: Glob pattern (default: all files)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of file paths
|
|
220
|
+
"""
|
|
221
|
+
path = Path(directory).expanduser().resolve()
|
|
222
|
+
|
|
223
|
+
if not path.exists():
|
|
224
|
+
raise FileNotFoundError(f"Directory not found: {directory}")
|
|
225
|
+
|
|
226
|
+
if not path.is_dir():
|
|
227
|
+
raise ValueError(f"Not a directory: {directory}")
|
|
228
|
+
|
|
229
|
+
return [str(p) for p in path.glob(pattern) if p.is_file()]
|
|
230
|
+
|
|
231
|
+
def get_file_info(self, file_path: str) -> dict:
|
|
232
|
+
"""Get information about a file
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
file_path: Path to the file
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Dictionary with file information
|
|
239
|
+
"""
|
|
240
|
+
path = Path(file_path).expanduser().resolve()
|
|
241
|
+
|
|
242
|
+
if not path.exists():
|
|
243
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
244
|
+
|
|
245
|
+
stat = path.stat()
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"path": str(path),
|
|
249
|
+
"name": path.name,
|
|
250
|
+
"size": stat.st_size,
|
|
251
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
252
|
+
"is_file": path.is_file(),
|
|
253
|
+
"is_dir": path.is_dir(),
|
|
254
|
+
"extension": path.suffix
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
def _create_backup(self, file_path: Path) -> Path:
|
|
258
|
+
"""Create a backup of a file
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
file_path: Path to the file
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Path to the backup file
|
|
265
|
+
"""
|
|
266
|
+
# Generate backup filename with timestamp
|
|
267
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
268
|
+
file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
|
|
269
|
+
backup_name = f"{file_path.name}.{timestamp}.{file_hash}.backup"
|
|
270
|
+
backup_path = self.backup_dir / backup_name
|
|
271
|
+
|
|
272
|
+
# Copy file to backup
|
|
273
|
+
shutil.copy2(file_path, backup_path)
|
|
274
|
+
|
|
275
|
+
return backup_path
|
|
276
|
+
|
|
277
|
+
def _show_diff(self, old_content: str, new_content: str, filename: str) -> None:
|
|
278
|
+
"""Show a diff between old and new content
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
old_content: Original content
|
|
282
|
+
new_content: New content
|
|
283
|
+
filename: Name of the file (for display)
|
|
284
|
+
"""
|
|
285
|
+
old_lines = old_content.splitlines(keepends=True)
|
|
286
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
287
|
+
|
|
288
|
+
diff = difflib.unified_diff(
|
|
289
|
+
old_lines,
|
|
290
|
+
new_lines,
|
|
291
|
+
fromfile=f"{filename} (original)",
|
|
292
|
+
tofile=f"{filename} (modified)",
|
|
293
|
+
lineterm=''
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
print("\n" + "="*60)
|
|
297
|
+
print("PROPOSED CHANGES:")
|
|
298
|
+
print("="*60)
|
|
299
|
+
for line in diff:
|
|
300
|
+
if line.startswith('+') and not line.startswith('+++'):
|
|
301
|
+
print(f"\033[92m{line}\033[0m", end='') # Green for additions
|
|
302
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
303
|
+
print(f"\033[91m{line}\033[0m", end='') # Red for deletions
|
|
304
|
+
else:
|
|
305
|
+
print(line, end='')
|
|
306
|
+
print("="*60)
|
|
307
|
+
|
|
308
|
+
def restore_from_backup(self, backup_path: str, target_path: Optional[str] = None) -> bool:
|
|
309
|
+
"""Restore a file from backup
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
backup_path: Path to the backup file
|
|
313
|
+
target_path: Target path (default: original location)
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
True if restore was successful
|
|
317
|
+
"""
|
|
318
|
+
backup = Path(backup_path)
|
|
319
|
+
|
|
320
|
+
if not backup.exists():
|
|
321
|
+
print(f"⚠️ Backup not found: {backup_path}")
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
if target_path is None:
|
|
325
|
+
# Extract original filename from backup name
|
|
326
|
+
original_name = backup.name.split('.')[0]
|
|
327
|
+
target_path = Path.cwd() / original_name
|
|
328
|
+
else:
|
|
329
|
+
target_path = Path(target_path)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
shutil.copy2(backup, target_path)
|
|
333
|
+
print(f"✅ File restored from backup: {target_path}")
|
|
334
|
+
return True
|
|
335
|
+
except Exception as e:
|
|
336
|
+
print(f"❌ Failed to restore from backup: {e}")
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
def list_backups(self) -> List[Tuple[str, datetime]]:
|
|
340
|
+
"""List all available backups
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
List of (backup_path, timestamp) tuples
|
|
344
|
+
"""
|
|
345
|
+
backups = []
|
|
346
|
+
for backup_file in self.backup_dir.glob("*.backup"):
|
|
347
|
+
stat = backup_file.stat()
|
|
348
|
+
timestamp = datetime.fromtimestamp(stat.st_mtime)
|
|
349
|
+
backups.append((str(backup_file), timestamp))
|
|
350
|
+
|
|
351
|
+
# Sort by timestamp (newest first)
|
|
352
|
+
backups.sort(key=lambda x: x[1], reverse=True)
|
|
353
|
+
|
|
354
|
+
return backups
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git Integration for NC1709
|
|
3
|
+
|
|
4
|
+
Provides automatic git commits and git-related utilities.
|
|
5
|
+
Similar to Aider's git integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, List, Dict, Any
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class GitStatus:
|
|
18
|
+
"""Status of a git repository"""
|
|
19
|
+
is_repo: bool
|
|
20
|
+
branch: str
|
|
21
|
+
has_changes: bool
|
|
22
|
+
staged_files: List[str]
|
|
23
|
+
modified_files: List[str]
|
|
24
|
+
untracked_files: List[str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitIntegration:
|
|
28
|
+
"""
|
|
29
|
+
Git integration for automatic commits and git operations.
|
|
30
|
+
|
|
31
|
+
Features:
|
|
32
|
+
- Automatic commits after file changes
|
|
33
|
+
- Smart commit message generation
|
|
34
|
+
- Git status and diff display
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, repo_path: Optional[str] = None, auto_commit: bool = True):
|
|
38
|
+
"""
|
|
39
|
+
Initialize git integration.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
repo_path: Path to git repository (defaults to cwd)
|
|
43
|
+
auto_commit: Whether to auto-commit changes
|
|
44
|
+
"""
|
|
45
|
+
self.repo_path = Path(repo_path) if repo_path else Path.cwd()
|
|
46
|
+
self.auto_commit = auto_commit
|
|
47
|
+
self._is_repo = self._check_git_repo()
|
|
48
|
+
|
|
49
|
+
def _check_git_repo(self) -> bool:
|
|
50
|
+
"""Check if current directory is a git repository"""
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["git", "rev-parse", "--git-dir"],
|
|
54
|
+
cwd=str(self.repo_path),
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True
|
|
57
|
+
)
|
|
58
|
+
return result.returncode == 0
|
|
59
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_repo(self) -> bool:
|
|
64
|
+
"""Check if we're in a git repository"""
|
|
65
|
+
return self._is_repo
|
|
66
|
+
|
|
67
|
+
def _run_git(self, *args, check: bool = True) -> subprocess.CompletedProcess:
|
|
68
|
+
"""Run a git command"""
|
|
69
|
+
return subprocess.run(
|
|
70
|
+
["git"] + list(args),
|
|
71
|
+
cwd=str(self.repo_path),
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
check=check
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def get_status(self) -> GitStatus:
|
|
78
|
+
"""Get current git status"""
|
|
79
|
+
if not self._is_repo:
|
|
80
|
+
return GitStatus(
|
|
81
|
+
is_repo=False,
|
|
82
|
+
branch="",
|
|
83
|
+
has_changes=False,
|
|
84
|
+
staged_files=[],
|
|
85
|
+
modified_files=[],
|
|
86
|
+
untracked_files=[]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Get branch name
|
|
90
|
+
try:
|
|
91
|
+
result = self._run_git("branch", "--show-current")
|
|
92
|
+
branch = result.stdout.strip()
|
|
93
|
+
except subprocess.CalledProcessError:
|
|
94
|
+
branch = "unknown"
|
|
95
|
+
|
|
96
|
+
# Get status --porcelain
|
|
97
|
+
result = self._run_git("status", "--porcelain", check=False)
|
|
98
|
+
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
99
|
+
|
|
100
|
+
staged_files = []
|
|
101
|
+
modified_files = []
|
|
102
|
+
untracked_files = []
|
|
103
|
+
|
|
104
|
+
for line in lines:
|
|
105
|
+
if len(line) < 3:
|
|
106
|
+
continue
|
|
107
|
+
index_status = line[0]
|
|
108
|
+
worktree_status = line[1]
|
|
109
|
+
filename = line[3:]
|
|
110
|
+
|
|
111
|
+
if index_status in "MADRC":
|
|
112
|
+
staged_files.append(filename)
|
|
113
|
+
if worktree_status == "M":
|
|
114
|
+
modified_files.append(filename)
|
|
115
|
+
if index_status == "?" and worktree_status == "?":
|
|
116
|
+
untracked_files.append(filename)
|
|
117
|
+
|
|
118
|
+
return GitStatus(
|
|
119
|
+
is_repo=True,
|
|
120
|
+
branch=branch,
|
|
121
|
+
has_changes=bool(staged_files or modified_files or untracked_files),
|
|
122
|
+
staged_files=staged_files,
|
|
123
|
+
modified_files=modified_files,
|
|
124
|
+
untracked_files=untracked_files
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def get_diff(self, staged: bool = False) -> str:
|
|
128
|
+
"""Get git diff"""
|
|
129
|
+
if not self._is_repo:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
args = ["diff"]
|
|
133
|
+
if staged:
|
|
134
|
+
args.append("--staged")
|
|
135
|
+
|
|
136
|
+
result = self._run_git(*args, check=False)
|
|
137
|
+
return result.stdout
|
|
138
|
+
|
|
139
|
+
def stage_file(self, file_path: str) -> bool:
|
|
140
|
+
"""Stage a file for commit"""
|
|
141
|
+
if not self._is_repo:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
self._run_git("add", file_path)
|
|
146
|
+
return True
|
|
147
|
+
except subprocess.CalledProcessError:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def stage_all(self) -> bool:
|
|
151
|
+
"""Stage all changes"""
|
|
152
|
+
if not self._is_repo:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
self._run_git("add", "-A")
|
|
157
|
+
return True
|
|
158
|
+
except subprocess.CalledProcessError:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def commit(self, message: str, files: Optional[List[str]] = None) -> bool:
|
|
162
|
+
"""
|
|
163
|
+
Create a commit.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
message: Commit message
|
|
167
|
+
files: Specific files to commit (stages them first)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if commit was successful
|
|
171
|
+
"""
|
|
172
|
+
if not self._is_repo:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
# Stage specific files if provided
|
|
177
|
+
if files:
|
|
178
|
+
for f in files:
|
|
179
|
+
self.stage_file(f)
|
|
180
|
+
|
|
181
|
+
# Check if there's anything to commit
|
|
182
|
+
status = self.get_status()
|
|
183
|
+
if not status.staged_files:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
# Create commit
|
|
187
|
+
self._run_git("commit", "-m", message)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
except subprocess.CalledProcessError:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def auto_commit_files(
|
|
194
|
+
self,
|
|
195
|
+
files: List[str],
|
|
196
|
+
tool_name: str = "NC1709",
|
|
197
|
+
description: str = ""
|
|
198
|
+
) -> Optional[str]:
|
|
199
|
+
"""
|
|
200
|
+
Automatically commit changed files with a generated message.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
files: List of file paths that were modified
|
|
204
|
+
tool_name: Name of the tool that made the change
|
|
205
|
+
description: Optional description of what changed
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Commit hash if successful, None otherwise
|
|
209
|
+
"""
|
|
210
|
+
if not self._is_repo or not self.auto_commit:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
# Filter to files that actually have changes
|
|
214
|
+
changed_files = []
|
|
215
|
+
for f in files:
|
|
216
|
+
result = self._run_git("status", "--porcelain", f, check=False)
|
|
217
|
+
if result.stdout.strip():
|
|
218
|
+
changed_files.append(f)
|
|
219
|
+
|
|
220
|
+
if not changed_files:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
# Generate commit message
|
|
224
|
+
file_names = [Path(f).name for f in changed_files]
|
|
225
|
+
if len(file_names) == 1:
|
|
226
|
+
file_desc = file_names[0]
|
|
227
|
+
elif len(file_names) <= 3:
|
|
228
|
+
file_desc = ", ".join(file_names)
|
|
229
|
+
else:
|
|
230
|
+
file_desc = f"{file_names[0]} and {len(file_names) - 1} other files"
|
|
231
|
+
|
|
232
|
+
if description:
|
|
233
|
+
message = f"{description}\n\nModified: {file_desc}\n\n[{tool_name}]"
|
|
234
|
+
else:
|
|
235
|
+
message = f"Update {file_desc}\n\n[{tool_name}]"
|
|
236
|
+
|
|
237
|
+
# Stage and commit
|
|
238
|
+
for f in changed_files:
|
|
239
|
+
self.stage_file(f)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
self._run_git("commit", "-m", message)
|
|
243
|
+
|
|
244
|
+
# Get the commit hash
|
|
245
|
+
result = self._run_git("rev-parse", "--short", "HEAD")
|
|
246
|
+
return result.stdout.strip()
|
|
247
|
+
|
|
248
|
+
except subprocess.CalledProcessError:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def get_recent_commits(self, limit: int = 10) -> List[Dict[str, Any]]:
|
|
252
|
+
"""Get recent commit history"""
|
|
253
|
+
if not self._is_repo:
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
result = self._run_git(
|
|
258
|
+
"log",
|
|
259
|
+
f"-{limit}",
|
|
260
|
+
"--pretty=format:%h|%s|%an|%ar",
|
|
261
|
+
check=False
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
commits = []
|
|
265
|
+
for line in result.stdout.strip().split("\n"):
|
|
266
|
+
if "|" in line:
|
|
267
|
+
parts = line.split("|", 3)
|
|
268
|
+
if len(parts) >= 4:
|
|
269
|
+
commits.append({
|
|
270
|
+
"hash": parts[0],
|
|
271
|
+
"message": parts[1],
|
|
272
|
+
"author": parts[2],
|
|
273
|
+
"time_ago": parts[3]
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
return commits
|
|
277
|
+
|
|
278
|
+
except subprocess.CalledProcessError:
|
|
279
|
+
return []
|
|
280
|
+
|
|
281
|
+
def init_repo(self) -> bool:
|
|
282
|
+
"""Initialize a new git repository"""
|
|
283
|
+
try:
|
|
284
|
+
self._run_git("init")
|
|
285
|
+
self._is_repo = True
|
|
286
|
+
return True
|
|
287
|
+
except subprocess.CalledProcessError:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# Global git integration instance
|
|
292
|
+
_git_integration: Optional[GitIntegration] = None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_git_integration(auto_commit: bool = True) -> GitIntegration:
|
|
296
|
+
"""Get or create the global git integration"""
|
|
297
|
+
global _git_integration
|
|
298
|
+
if _git_integration is None:
|
|
299
|
+
_git_integration = GitIntegration(auto_commit=auto_commit)
|
|
300
|
+
return _git_integration
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def auto_commit_after_edit(file_path: str, tool_name: str = "Edit") -> Optional[str]:
|
|
304
|
+
"""Convenience function to auto-commit after editing a file"""
|
|
305
|
+
git = get_git_integration()
|
|
306
|
+
if git.is_repo and git.auto_commit:
|
|
307
|
+
return git.auto_commit_files([file_path], tool_name=tool_name)
|
|
308
|
+
return None
|