ace-git-copilot 0.3.1__tar.gz → 0.3.3__tar.gz
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.
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/PKG-INFO +13 -1
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/README.md +12 -0
- ace_git_copilot-0.3.3/ace/__init__.py +1 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/changelog_generator.py +18 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/commit_generator.py +7 -2
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/conflict_resolver.py +35 -4
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/llm_factory.py +31 -12
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/cli.py +25 -9
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/config.py +12 -3
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/context.py +5 -1
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/git_ops.py +33 -7
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/display.py +61 -2
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/prompts.py +2 -1
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/pyproject.toml +1 -1
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/conftest.py +63 -34
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/test_tier1_features.py +18 -6
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/test_tier2_boundaries.py +19 -7
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/test_tier3_combinations.py +9 -3
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/test_tier4_workloads.py +11 -5
- ace_git_copilot-0.3.3/tests/test_config_atomic_write.py +41 -0
- ace_git_copilot-0.3.3/tests/test_conflict_resolver_backup.py +49 -0
- ace_git_copilot-0.3.3/tests/test_context_gitdir_detection.py +45 -0
- ace_git_copilot-0.3.3/tests/test_git_ops_execute.py +41 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_help.py +2 -2
- ace_git_copilot-0.3.3/tests/test_llm_factory_ollama.py +48 -0
- ace_git_copilot-0.3.1/ace/__init__.py +0 -1
- ace_git_copilot-0.3.1/importtime.txt +0 -0
- ace_git_copilot-0.3.1/importtime_optimized.txt +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/AGENTS.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/SCOPE.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/progress.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/emojis_list.txt +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/find_unused_modules.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/handoff.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/measure_lazy_startup.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/measure_startup.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/profile_imports.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/progress.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/run_importtime.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_banner.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_emojis.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_git_usages.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_usages.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/test_import_profiler.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/test_mocked_sys.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/handoff.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/implementation_track/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/implementation_track/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/implementation_track/explorer_initial_report.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/implementation_track/progress.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/.gitkeep +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/PROJECT.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/progress.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/handoff.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/progress.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/progress.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/progress.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.env.example +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.github/workflows/tests.yml +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.gitignore +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/CODE_OF_CONDUCT.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/CONTRIBUTING.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/LICENSE +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/PROJECT.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/SECURITY.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/SUPPORT.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/TEST_INFRA.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/TEST_READY.md +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/__main__.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/code_reviewer.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/gitignore_generator.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/history_analyzer.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/intent_parser.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/pr_drafter.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/changelog.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/commit.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/conflict.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/doctor.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/explain.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/ignore.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/intent.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/pr.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/rebase.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/review.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/search.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/undo.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/rebase_helper.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/diagnostics.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/hooks.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/safety.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/banner.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/dashboard.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/themes.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/utils/conflict_parser.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/utils/diff_parser.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/utils/json_utils.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/conftest.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_changelog_generator.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_code_reviewer.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_conflict_resolver.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_diagnostics.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_diff_trimmer.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_git_ops.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_history_analyzer.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_hooks.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_ignore.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_intent_parser.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_llm_factory.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_pr_drafter.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_rebase_helper.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_safety.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_search.py +0 -0
- {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_undo.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ace-git-copilot
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: AI-powered Git copilot — talk to Git in plain English
|
|
5
5
|
Project-URL: Homepage, https://github.com/jachinsamuel/Ace
|
|
6
6
|
Project-URL: Documentation, https://github.com/jachinsamuel/Ace#readme
|
|
@@ -198,6 +198,18 @@ Distributed under the MIT License. See [LICENSE](LICENSE) for more details.
|
|
|
198
198
|
|
|
199
199
|
## Changelog
|
|
200
200
|
|
|
201
|
+
### v0.3.3 — Stability & Resilience (2026-07-04)
|
|
202
|
+
* Implemented streaming progress and download percentages for Ollama model pulls to prevent thread hangs.
|
|
203
|
+
* Added atomic file saves and rollback backups for merge conflict resolutions and config modifications to prevent data corruption.
|
|
204
|
+
* Fixed merge/rebase detection in git worktrees and submodules by resolving the authoritative `git_dir` dynamically.
|
|
205
|
+
* Added `shlex` command splitting to safely parse and execute quoted arguments in Git commands.
|
|
206
|
+
* Created python module entry point (`python -m ace`) for nested execution support.
|
|
207
|
+
|
|
208
|
+
### v0.3.2 — Windows Compatibility & E2E Fixes (2026-07-01)
|
|
209
|
+
* Resolved Windows-specific UTF-8 encoding issues in E2E tests and log parsers.
|
|
210
|
+
* Fixed subprocess natural language command execution paths to execute nested ace commands through Python interpreter contexts.
|
|
211
|
+
* Refactored CLI error panel formatting to match expected exception names.
|
|
212
|
+
|
|
201
213
|
### v0.3.1 — Patch (2026-06-30)
|
|
202
214
|
* Fixed invisible key labels (`[c]`, `[r]`, etc.) in the dashboard and search menus caused by Rich markup tag conflicts.
|
|
203
215
|
* Fixed `[Y/n]` confirmation prompt rendering invisibly.
|
|
@@ -165,6 +165,18 @@ Distributed under the MIT License. See [LICENSE](LICENSE) for more details.
|
|
|
165
165
|
|
|
166
166
|
## Changelog
|
|
167
167
|
|
|
168
|
+
### v0.3.3 — Stability & Resilience (2026-07-04)
|
|
169
|
+
* Implemented streaming progress and download percentages for Ollama model pulls to prevent thread hangs.
|
|
170
|
+
* Added atomic file saves and rollback backups for merge conflict resolutions and config modifications to prevent data corruption.
|
|
171
|
+
* Fixed merge/rebase detection in git worktrees and submodules by resolving the authoritative `git_dir` dynamically.
|
|
172
|
+
* Added `shlex` command splitting to safely parse and execute quoted arguments in Git commands.
|
|
173
|
+
* Created python module entry point (`python -m ace`) for nested execution support.
|
|
174
|
+
|
|
175
|
+
### v0.3.2 — Windows Compatibility & E2E Fixes (2026-07-01)
|
|
176
|
+
* Resolved Windows-specific UTF-8 encoding issues in E2E tests and log parsers.
|
|
177
|
+
* Fixed subprocess natural language command execution paths to execute nested ace commands through Python interpreter contexts.
|
|
178
|
+
* Refactored CLI error panel formatting to match expected exception names.
|
|
179
|
+
|
|
168
180
|
### v0.3.1 — Patch (2026-06-30)
|
|
169
181
|
* Fixed invisible key labels (`[c]`, `[r]`, etc.) in the dashboard and search menus caused by Rich markup tag conflicts.
|
|
170
182
|
* Fixed `[Y/n]` confirmation prompt rendering invisibly.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.3"
|
|
@@ -18,6 +18,24 @@ class ChangelogGenerator:
|
|
|
18
18
|
If from_ref is None, attempts to find the latest tag.
|
|
19
19
|
If no latest tag exists, falls back to the last 30 commits.
|
|
20
20
|
"""
|
|
21
|
+
if from_ref:
|
|
22
|
+
try:
|
|
23
|
+
self.git_ops.execute(f"rev-parse --verify {from_ref}")
|
|
24
|
+
except Exception:
|
|
25
|
+
raise ChangelogGeneratorError(f"Invalid starting revision: {from_ref}")
|
|
26
|
+
|
|
27
|
+
if to_ref:
|
|
28
|
+
try:
|
|
29
|
+
self.git_ops.execute(f"rev-parse --verify {to_ref}")
|
|
30
|
+
except Exception:
|
|
31
|
+
raise ChangelogGeneratorError(f"Invalid ending revision: {to_ref}")
|
|
32
|
+
|
|
33
|
+
# Check if HEAD exists first
|
|
34
|
+
try:
|
|
35
|
+
self.git_ops.execute("rev-parse --verify HEAD")
|
|
36
|
+
except Exception:
|
|
37
|
+
return ""
|
|
38
|
+
|
|
21
39
|
to_revision = to_ref or "HEAD"
|
|
22
40
|
from_revision = from_ref
|
|
23
41
|
|
|
@@ -29,8 +29,13 @@ class CommitGenerator:
|
|
|
29
29
|
raise NoStagedChangesError("No changes are staged for commit. Stage files first using 'git add'.")
|
|
30
30
|
|
|
31
31
|
staged_diff = self.git_ops.get_staged_diff()
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
has_content_changes = False
|
|
33
|
+
for line in staged_diff.splitlines():
|
|
34
|
+
if (line.startswith("+") and not line.startswith("+++")) or (line.startswith("-") and not line.startswith("---")):
|
|
35
|
+
has_content_changes = True
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
if not has_content_changes:
|
|
34
39
|
raise NoStagedChangesError("Staged diff is empty. Cannot generate commit message.")
|
|
35
40
|
|
|
36
41
|
# Format context
|
|
@@ -83,10 +83,15 @@ class ConflictResolver:
|
|
|
83
83
|
|
|
84
84
|
def apply_resolution(self, file_path: str, block_replacements: List[Tuple[str, str]]) -> None:
|
|
85
85
|
"""
|
|
86
|
-
Apply resolutions to a conflicted file.
|
|
86
|
+
Apply resolutions to a conflicted file safely.
|
|
87
87
|
|
|
88
88
|
block_replacements: List of tuples (full_conflict_block, replacement_content)
|
|
89
89
|
"""
|
|
90
|
+
import shutil
|
|
91
|
+
import tempfile
|
|
92
|
+
import os
|
|
93
|
+
import time
|
|
94
|
+
|
|
90
95
|
full_path = Path(self.git_ops.working_dir) / file_path
|
|
91
96
|
if not full_path.exists():
|
|
92
97
|
raise FileNotFoundError(f"File not found: {file_path}")
|
|
@@ -95,13 +100,13 @@ class ConflictResolver:
|
|
|
95
100
|
|
|
96
101
|
for full_block, replacement in block_replacements:
|
|
97
102
|
if full_block in content:
|
|
98
|
-
content = content.replace(full_block, replacement)
|
|
103
|
+
content = content.replace(full_block, replacement, 1)
|
|
99
104
|
else:
|
|
100
105
|
# Try with normalized line endings
|
|
101
106
|
norm_block = full_block.replace("\r\n", "\n")
|
|
102
107
|
norm_content = content.replace("\r\n", "\n")
|
|
103
108
|
if norm_block in norm_content:
|
|
104
|
-
norm_content = norm_content.replace(norm_block, replacement)
|
|
109
|
+
norm_content = norm_content.replace(norm_block, replacement, 1)
|
|
105
110
|
# Restore Windows line endings if they were originally present
|
|
106
111
|
if "\r\n" in content:
|
|
107
112
|
content = norm_content.replace("\n", "\r\n")
|
|
@@ -112,4 +117,30 @@ class ConflictResolver:
|
|
|
112
117
|
"Conflict block not found in file. Has it been edited already?"
|
|
113
118
|
)
|
|
114
119
|
|
|
115
|
-
|
|
120
|
+
# Create backup copy
|
|
121
|
+
backup_path = full_path.with_suffix(full_path.suffix + f".bak-{int(time.time())}")
|
|
122
|
+
try:
|
|
123
|
+
shutil.copy2(full_path, backup_path)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
raise ConflictResolverError(f"Failed to create backup copy of {file_path}: {e}")
|
|
126
|
+
|
|
127
|
+
# Atomic replacement
|
|
128
|
+
try:
|
|
129
|
+
fd, temp_path_str = tempfile.mkstemp(dir=full_path.parent, prefix="resolved-", suffix=".tmp")
|
|
130
|
+
temp_path = Path(temp_path_str)
|
|
131
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
132
|
+
f.write(content)
|
|
133
|
+
os.replace(temp_path, full_path)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
# Restore from backup
|
|
136
|
+
try:
|
|
137
|
+
shutil.copy2(backup_path, full_path)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
raise ConflictResolverError(f"Failed to apply conflict resolution to {file_path}: {e}")
|
|
141
|
+
finally:
|
|
142
|
+
if backup_path.exists():
|
|
143
|
+
try:
|
|
144
|
+
backup_path.unlink()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
@@ -43,27 +43,46 @@ def ensure_ollama_model(base_url: str, model_name: str) -> None:
|
|
|
43
43
|
return
|
|
44
44
|
|
|
45
45
|
# 2. Prompt user and pull model
|
|
46
|
-
from ace.ui.display import console, spinner
|
|
46
|
+
from ace.ui.display import console, spinner, print_warning, print_success, print_error, print_info
|
|
47
47
|
from ace.ui.prompts import confirm
|
|
48
48
|
|
|
49
|
-
console.print(
|
|
49
|
+
console.print()
|
|
50
|
+
print_warning(f"Ollama model '{model_name}' is not downloaded locally.")
|
|
50
51
|
if confirm(f"Would you like Ace to automatically pull '{model_name}' from the Ollama registry?", default=True):
|
|
51
52
|
try:
|
|
52
53
|
url = f"{base_url.rstrip('/')}/api/pull"
|
|
53
|
-
payload = json.dumps({"name": model_name, "stream":
|
|
54
|
+
payload = json.dumps({"name": model_name, "stream": True}).encode("utf-8")
|
|
54
55
|
req = urllib.request.Request(url, data=payload, method="POST")
|
|
55
56
|
req.add_header("Content-Type", "application/json")
|
|
56
57
|
|
|
57
|
-
with spinner(f"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
with spinner(f"Initiating download of model '{model_name}'..."):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
with urllib.request.urlopen(req, timeout=60) as response:
|
|
62
|
+
import sys
|
|
63
|
+
for line in response:
|
|
64
|
+
if not line.strip():
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(line.decode("utf-8"))
|
|
68
|
+
status = data.get("status", "")
|
|
69
|
+
completed = data.get("completed", 0)
|
|
70
|
+
total = data.get("total", 0)
|
|
71
|
+
if total > 0:
|
|
72
|
+
pct = (completed / total) * 100
|
|
73
|
+
sys.stdout.write(f"\r\033[K[Ollama] {status} ({pct:.1f}%)")
|
|
74
|
+
sys.stdout.flush()
|
|
75
|
+
else:
|
|
76
|
+
sys.stdout.write(f"\r\033[K[Ollama] {status}")
|
|
77
|
+
sys.stdout.flush()
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
sys.stdout.write("\n")
|
|
81
|
+
sys.stdout.flush()
|
|
82
|
+
print_success(f"Successfully downloaded '{model_name}'!\n")
|
|
64
83
|
except Exception as e:
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
print_error(f"Failed to pull model: {e}")
|
|
85
|
+
print_info(f"Please run 'ollama pull {model_name}' manually in your shell.\n")
|
|
67
86
|
|
|
68
87
|
def get_llm(offline_override: bool = False) -> BaseChatModel:
|
|
69
88
|
"""
|
|
@@ -138,9 +138,9 @@ def main(
|
|
|
138
138
|
r_level, r_desc, alt = SafetyChecker.analyze_command(cmd)
|
|
139
139
|
if r_level == "destructive":
|
|
140
140
|
highest_risk = "destructive"
|
|
141
|
-
risk_details.append(f"[bold red]Command:[/
|
|
141
|
+
risk_details.append(f"[bold red]Command:[/] {cmd}\n[bold red]Risk:[/] {r_desc}")
|
|
142
142
|
if alt:
|
|
143
|
-
safer_alts.append(f"[bold green]Safer Alternative:[/
|
|
143
|
+
safer_alts.append(f"[bold green]Safer Alternative:[/] {alt}")
|
|
144
144
|
elif r_level == "moderate" and highest_risk != "destructive":
|
|
145
145
|
highest_risk = "moderate"
|
|
146
146
|
|
|
@@ -191,8 +191,20 @@ def main(
|
|
|
191
191
|
raise typer.Exit(code=1)
|
|
192
192
|
else:
|
|
193
193
|
try:
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
import subprocess
|
|
195
|
+
import sys
|
|
196
|
+
import shlex
|
|
197
|
+
args = shlex.split(cmd)[1:]
|
|
198
|
+
res_proc = subprocess.run(
|
|
199
|
+
[sys.executable, "-m", "ace"] + args,
|
|
200
|
+
stdout=subprocess.PIPE,
|
|
201
|
+
stderr=subprocess.PIPE,
|
|
202
|
+
text=True,
|
|
203
|
+
encoding="utf-8"
|
|
204
|
+
)
|
|
205
|
+
if res_proc.returncode != 0:
|
|
206
|
+
raise Exception(res_proc.stderr or res_proc.stdout)
|
|
207
|
+
outputs.append(res_proc.stdout)
|
|
196
208
|
except Exception as e:
|
|
197
209
|
show_error_panel(f"Failed to execute command '{cmd}': {e}", "Execution Error")
|
|
198
210
|
raise typer.Exit(code=1)
|
|
@@ -207,7 +219,7 @@ def main(
|
|
|
207
219
|
|
|
208
220
|
# Summarization flow for read-only history queries
|
|
209
221
|
combined_output = "\n".join(outputs)
|
|
210
|
-
if highest_risk == "safe" and combined_output.strip():
|
|
222
|
+
if highest_risk == "safe" and combined_output.strip() and not any(c.startswith("ace ") for c in commands):
|
|
211
223
|
from ace.ai.history_analyzer import HistoryAnalyzer
|
|
212
224
|
from rich.markdown import Markdown
|
|
213
225
|
analyzer = HistoryAnalyzer(git_ops)
|
|
@@ -270,6 +282,8 @@ def commit_cmd(
|
|
|
270
282
|
with open(prepare, "w", encoding="utf-8") as f:
|
|
271
283
|
f.write(msg)
|
|
272
284
|
raise typer.Exit(code=0)
|
|
285
|
+
except (typer.Exit, typer.Abort):
|
|
286
|
+
raise
|
|
273
287
|
except NoStagedChangesError:
|
|
274
288
|
raise typer.Exit(code=0)
|
|
275
289
|
except Exception as e:
|
|
@@ -785,7 +799,8 @@ def changelog_cmd(
|
|
|
785
799
|
show_error_panel(f"{str(e)}\n\nRun [bold]ace setup[/bold] to configure your AI credentials.", "Configuration Error")
|
|
786
800
|
raise typer.Exit(code=1)
|
|
787
801
|
except Exception as e:
|
|
788
|
-
|
|
802
|
+
title = "Git Error" if "ChangelogGeneratorError" in str(type(e)) or "Cmd('git')" in str(e) or "Invalid starting revision" in str(e) or "Invalid ending revision" in str(e) else "AI Error"
|
|
803
|
+
show_error_panel(f"Failed to generate changelog: {e}", title)
|
|
789
804
|
raise typer.Exit(code=1)
|
|
790
805
|
|
|
791
806
|
# Show or write to file
|
|
@@ -1154,9 +1169,9 @@ def undo_cmd(
|
|
|
1154
1169
|
r_level, r_desc, alt = SafetyChecker.analyze_command(cmd)
|
|
1155
1170
|
if r_level == "destructive":
|
|
1156
1171
|
highest_risk = "destructive"
|
|
1157
|
-
risk_details.append(f"[bold red]Command:[/
|
|
1172
|
+
risk_details.append(f"[bold red]Command:[/] {cmd}\n[bold red]Risk:[/] {r_desc}")
|
|
1158
1173
|
if alt:
|
|
1159
|
-
safer_alts.append(f"[bold green]Safer Alternative:[/
|
|
1174
|
+
safer_alts.append(f"[bold green]Safer Alternative:[/] {alt}")
|
|
1160
1175
|
elif r_level == "moderate" and highest_risk != "destructive":
|
|
1161
1176
|
highest_risk = "moderate"
|
|
1162
1177
|
|
|
@@ -1251,7 +1266,8 @@ def pr_cmd(
|
|
|
1251
1266
|
with spinner(f"Generating PR description against base branch '{base}'..."):
|
|
1252
1267
|
pr_data = drafter.draft_pr(base, offline=offline)
|
|
1253
1268
|
except Exception as e:
|
|
1254
|
-
|
|
1269
|
+
title = "Git Error" if "Cmd('git')" in str(e) or "git log" in str(e) or "exit code" in str(e) else "AI Error"
|
|
1270
|
+
show_error_panel(f"Failed to generate PR description: {e}", title)
|
|
1255
1271
|
raise typer.Exit(code=1)
|
|
1256
1272
|
|
|
1257
1273
|
title = pr_data.get("title", "Pull Request")
|
|
@@ -154,10 +154,19 @@ def get_config() -> Config:
|
|
|
154
154
|
return Config(data)
|
|
155
155
|
|
|
156
156
|
def save_config(config: Config) -> None:
|
|
157
|
-
"""Save the current configuration back to ~/.ace/config.toml."""
|
|
157
|
+
"""Save the current configuration back to ~/.ace/config.toml atomically."""
|
|
158
|
+
import tempfile
|
|
159
|
+
import os
|
|
158
160
|
try:
|
|
159
161
|
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
fd, temp_path = tempfile.mkstemp(dir=DEFAULT_CONFIG_DIR, prefix="config-", suffix=".tmp")
|
|
163
|
+
try:
|
|
164
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
165
|
+
toml.dump(config.to_dict(), f)
|
|
166
|
+
os.replace(temp_path, DEFAULT_CONFIG_PATH)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
if os.path.exists(temp_path):
|
|
169
|
+
os.unlink(temp_path)
|
|
170
|
+
raise e
|
|
162
171
|
except Exception as e:
|
|
163
172
|
raise IOError(f"Could not save configuration: {e}")
|
|
@@ -61,7 +61,11 @@ class RepoContext:
|
|
|
61
61
|
|
|
62
62
|
def check_merge_rebase_state(self) -> Dict[str, Any]:
|
|
63
63
|
"""Check if the repository is currently in a merge, rebase, or cherry-pick state."""
|
|
64
|
-
|
|
64
|
+
try:
|
|
65
|
+
git_dir = Path(self.git_ops.repo.git_dir)
|
|
66
|
+
except Exception:
|
|
67
|
+
git_dir = Path(self.git_ops.working_dir) / ".git"
|
|
68
|
+
|
|
65
69
|
state = {
|
|
66
70
|
"in_progress": False,
|
|
67
71
|
"type": None, # 'merge', 'rebase', 'cherry-pick', 'revert'
|
|
@@ -118,7 +118,13 @@ class GitOps:
|
|
|
118
118
|
def get_branches(self, remote: bool = False) -> List[str]:
|
|
119
119
|
"""List local or remote branches."""
|
|
120
120
|
if remote:
|
|
121
|
-
|
|
121
|
+
branches = []
|
|
122
|
+
for r in self.repo.remotes:
|
|
123
|
+
try:
|
|
124
|
+
branches.extend([b.name for b in r.refs])
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
return branches
|
|
122
128
|
return [b.name for b in self.repo.branches]
|
|
123
129
|
|
|
124
130
|
def get_conflicts(self) -> List[str]:
|
|
@@ -153,14 +159,34 @@ class GitOps:
|
|
|
153
159
|
|
|
154
160
|
def execute(self, command: str) -> str:
|
|
155
161
|
"""Run an arbitrary git command safely (the command string shouldn't include 'git ')."""
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
import shlex
|
|
163
|
+
if not command.strip():
|
|
164
|
+
raise ValueError("Empty Git command provided.")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
parts = shlex.split(command.strip())
|
|
168
|
+
except ValueError:
|
|
169
|
+
parts = command.strip().split()
|
|
170
|
+
|
|
158
171
|
if parts and parts[0] == "git":
|
|
159
172
|
parts = parts[1:]
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
|
|
174
|
+
if not parts:
|
|
175
|
+
raise ValueError("Empty Git command provided.")
|
|
176
|
+
|
|
177
|
+
subcommand = parts[0]
|
|
178
|
+
args = parts[1:]
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
git_func = getattr(self.repo.git, subcommand.replace("-", "_"))
|
|
182
|
+
return git_func(*args)
|
|
183
|
+
except AttributeError:
|
|
184
|
+
try:
|
|
185
|
+
return self.repo.git.execute(["git", subcommand] + args)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
raise ValueError(f"Failed to execute Git command '{command}': {e}")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
raise ValueError(f"Failed to execute Git command '{command}': {e}")
|
|
164
190
|
|
|
165
191
|
def get_upstream_tracking(self) -> Optional[str]:
|
|
166
192
|
"""Get the remote tracking branch of the current branch, e.g. 'origin/main'."""
|
|
@@ -30,9 +30,65 @@ def print_warning(message: str) -> None:
|
|
|
30
30
|
"""Print a warning message."""
|
|
31
31
|
console.print(f" [warning]{_SYM_WARNING}[/warning] [warning]{message}[/warning]")
|
|
32
32
|
|
|
33
|
+
def clean_error_message(message: str) -> str:
|
|
34
|
+
"""Clean up common LLM API response dumps and traceback details into human-readable text."""
|
|
35
|
+
from rich.markup import escape
|
|
36
|
+
|
|
37
|
+
# Normalize/clean raw message string
|
|
38
|
+
msg_str = str(message).strip()
|
|
39
|
+
|
|
40
|
+
# Check for NVIDIA/OpenAI 504 gateway timeout
|
|
41
|
+
if "504" in msg_str or "Gateway Timeout" in msg_str:
|
|
42
|
+
return (
|
|
43
|
+
"API Server Error: [bold #FF1744]Gateway Timeout (504)[/bold #FF1744]\n\n"
|
|
44
|
+
"The cloud AI provider servers took too long to respond. This is a temporary server "
|
|
45
|
+
"overload. Please try again in a few moments, or run [bold]ace setup[/bold] to switch "
|
|
46
|
+
"to a local model (Ollama) or another provider."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Check for authentication errors
|
|
50
|
+
if "AuthenticationError" in msg_str or "invalid api key" in msg_str.lower() or "401" in msg_str:
|
|
51
|
+
return (
|
|
52
|
+
"API Authentication Error: [bold #FF1744]Invalid API Key[/bold #FF1744]\n\n"
|
|
53
|
+
"Please check that your API key is correct and active. Run [bold]ace setup[/bold] to reconfigure."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Check for rate limits
|
|
57
|
+
if "RateLimitError" in msg_str or "429" in msg_str or "rate limit exceeded" in msg_str.lower():
|
|
58
|
+
return (
|
|
59
|
+
"API Rate Limit Error: [bold #FF1744]Too Many Requests[/bold #FF1744]\n\n"
|
|
60
|
+
"You have hit the rate limit for this API provider. Please wait a moment before trying again."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# General Connection errors
|
|
64
|
+
if "ConnectionError" in msg_str or "Failed to establish a new connection" in msg_str or "timeout" in msg_str.lower():
|
|
65
|
+
return (
|
|
66
|
+
"API Connection Error: [bold #FF1744]Network Timeout[/bold #FF1744]\n\n"
|
|
67
|
+
"Could not connect to the AI API endpoint. Please check your internet connection and try again."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# If it's a raw dictionary dump of a response (like in NVIDIA / OpenAI SDK exception strings)
|
|
71
|
+
if "{'_content':" in msg_str or "'status_code':" in msg_str or "'_content_consumed':" in msg_str:
|
|
72
|
+
lines = msg_str.splitlines()
|
|
73
|
+
first_line = lines[0] if lines else ""
|
|
74
|
+
if "APIStatusError" in first_line:
|
|
75
|
+
first_line = first_line.split("APIStatusError:")[-1].strip()
|
|
76
|
+
|
|
77
|
+
import re
|
|
78
|
+
status_match = re.search(r"'status_code':\s*(\d+)", msg_str)
|
|
79
|
+
reason_match = re.search(r"'reason':\s*'([^']+)'", msg_str)
|
|
80
|
+
if status_match and reason_match:
|
|
81
|
+
return f"API Error: [bold #FF1744]{escape(reason_match.group(1))} ({status_match.group(1)})[/bold #FF1744]"
|
|
82
|
+
elif status_match:
|
|
83
|
+
return f"API Error: [bold #FF1744]Status Code {status_match.group(1)}[/bold #FF1744]"
|
|
84
|
+
return escape(first_line) if first_line else "An unexpected API error occurred."
|
|
85
|
+
|
|
86
|
+
# If it's a generic exception or string, escape it to prevent Rich markup parsing errors
|
|
87
|
+
return escape(msg_str)
|
|
88
|
+
|
|
33
89
|
def print_error(message: str) -> None:
|
|
34
90
|
"""Print an error message."""
|
|
35
|
-
err_console.print(f" [error]{_SYM_ERROR}[/error] [error]{message}[/error]")
|
|
91
|
+
err_console.print(f" [error]{_SYM_ERROR}[/error] [error]{clean_error_message(message)}[/error]")
|
|
36
92
|
|
|
37
93
|
|
|
38
94
|
# ─── Panels ──────────────────────────────────────────────────────────────────
|
|
@@ -57,8 +113,11 @@ def show_error_panel(message: str, title: str = "Error") -> None:
|
|
|
57
113
|
"""Show a styled red error panel."""
|
|
58
114
|
from rich.panel import Panel
|
|
59
115
|
from rich import box
|
|
116
|
+
|
|
117
|
+
cleaned_message = clean_error_message(message)
|
|
118
|
+
|
|
60
119
|
panel = Panel(
|
|
61
|
-
Text.from_markup(
|
|
120
|
+
Text.from_markup(cleaned_message),
|
|
62
121
|
title=f"[bold #FF1744] {_SYM_ERROR} {title}[/bold #FF1744]",
|
|
63
122
|
border_style="#FF1744",
|
|
64
123
|
box=box.ROUNDED,
|
|
@@ -30,7 +30,8 @@ def confirm(question: str, default: bool = True) -> bool:
|
|
|
30
30
|
return False
|
|
31
31
|
if val in ("\r", "\n", ""):
|
|
32
32
|
return default
|
|
33
|
-
|
|
33
|
+
# Default to False for safety on unrecognized inputs
|
|
34
|
+
return False
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
def prompt_action(options: Dict[str, Tuple[str, str]], default_key: str = "\r") -> str:
|