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.
Files changed (127) hide show
  1. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/PKG-INFO +13 -1
  2. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/README.md +12 -0
  3. ace_git_copilot-0.3.3/ace/__init__.py +1 -0
  4. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/changelog_generator.py +18 -0
  5. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/commit_generator.py +7 -2
  6. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/conflict_resolver.py +35 -4
  7. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/llm_factory.py +31 -12
  8. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/cli.py +25 -9
  9. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/config.py +12 -3
  10. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/context.py +5 -1
  11. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/git_ops.py +33 -7
  12. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/display.py +61 -2
  13. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/prompts.py +2 -1
  14. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/pyproject.toml +1 -1
  15. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/conftest.py +63 -34
  16. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/test_tier1_features.py +18 -6
  17. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/test_tier2_boundaries.py +19 -7
  18. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/test_tier3_combinations.py +9 -3
  19. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/e2e/test_tier4_workloads.py +11 -5
  20. ace_git_copilot-0.3.3/tests/test_config_atomic_write.py +41 -0
  21. ace_git_copilot-0.3.3/tests/test_conflict_resolver_backup.py +49 -0
  22. ace_git_copilot-0.3.3/tests/test_context_gitdir_detection.py +45 -0
  23. ace_git_copilot-0.3.3/tests/test_git_ops_execute.py +41 -0
  24. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_help.py +2 -2
  25. ace_git_copilot-0.3.3/tests/test_llm_factory_ollama.py +48 -0
  26. ace_git_copilot-0.3.1/ace/__init__.py +0 -1
  27. ace_git_copilot-0.3.1/importtime.txt +0 -0
  28. ace_git_copilot-0.3.1/importtime_optimized.txt +0 -0
  29. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/AGENTS.md +0 -0
  30. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/BRIEFING.md +0 -0
  31. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/ORIGINAL_REQUEST.md +0 -0
  32. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/BRIEFING.md +0 -0
  33. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/ORIGINAL_REQUEST.md +0 -0
  34. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/SCOPE.md +0 -0
  35. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/progress.md +0 -0
  36. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/BRIEFING.md +0 -0
  37. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/ORIGINAL_REQUEST.md +0 -0
  38. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/emojis_list.txt +0 -0
  39. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/find_unused_modules.py +0 -0
  40. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/handoff.md +0 -0
  41. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/measure_lazy_startup.py +0 -0
  42. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/measure_startup.py +0 -0
  43. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/profile_imports.py +0 -0
  44. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/progress.md +0 -0
  45. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/run_importtime.py +0 -0
  46. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_banner.py +0 -0
  47. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_emojis.py +0 -0
  48. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_git_usages.py +0 -0
  49. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_usages.py +0 -0
  50. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/test_import_profiler.py +0 -0
  51. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/explorer_init/test_mocked_sys.py +0 -0
  52. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/handoff.md +0 -0
  53. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/implementation_track/BRIEFING.md +0 -0
  54. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/implementation_track/ORIGINAL_REQUEST.md +0 -0
  55. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/implementation_track/explorer_initial_report.md +0 -0
  56. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/implementation_track/progress.md +0 -0
  57. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/.gitkeep +0 -0
  58. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/BRIEFING.md +0 -0
  59. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/ORIGINAL_REQUEST.md +0 -0
  60. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/PROJECT.md +0 -0
  61. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/orchestrator/progress.md +0 -0
  62. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/BRIEFING.md +0 -0
  63. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/ORIGINAL_REQUEST.md +0 -0
  64. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/handoff.md +0 -0
  65. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/progress.md +0 -0
  66. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/BRIEFING.md +0 -0
  67. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/ORIGINAL_REQUEST.md +0 -0
  68. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/progress.md +0 -0
  69. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/BRIEFING.md +0 -0
  70. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/ORIGINAL_REQUEST.md +0 -0
  71. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/progress.md +0 -0
  72. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.env.example +0 -0
  73. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.github/workflows/tests.yml +0 -0
  74. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/.gitignore +0 -0
  75. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/CODE_OF_CONDUCT.md +0 -0
  76. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/CONTRIBUTING.md +0 -0
  77. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/LICENSE +0 -0
  78. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/PROJECT.md +0 -0
  79. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/SECURITY.md +0 -0
  80. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/SUPPORT.md +0 -0
  81. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/TEST_INFRA.md +0 -0
  82. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/TEST_READY.md +0 -0
  83. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/__main__.py +0 -0
  84. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/code_reviewer.py +0 -0
  85. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/gitignore_generator.py +0 -0
  86. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/history_analyzer.py +0 -0
  87. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/intent_parser.py +0 -0
  88. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/pr_drafter.py +0 -0
  89. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/changelog.py +0 -0
  90. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/commit.py +0 -0
  91. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/conflict.py +0 -0
  92. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/doctor.py +0 -0
  93. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/explain.py +0 -0
  94. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/ignore.py +0 -0
  95. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/intent.py +0 -0
  96. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/pr.py +0 -0
  97. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/rebase.py +0 -0
  98. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/review.py +0 -0
  99. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/search.py +0 -0
  100. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/prompts/undo.py +0 -0
  101. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ai/rebase_helper.py +0 -0
  102. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/diagnostics.py +0 -0
  103. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/hooks.py +0 -0
  104. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/core/safety.py +0 -0
  105. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/banner.py +0 -0
  106. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/dashboard.py +0 -0
  107. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/ui/themes.py +0 -0
  108. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/utils/conflict_parser.py +0 -0
  109. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/utils/diff_parser.py +0 -0
  110. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/ace/utils/json_utils.py +0 -0
  111. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/conftest.py +0 -0
  112. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_changelog_generator.py +0 -0
  113. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_code_reviewer.py +0 -0
  114. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_conflict_resolver.py +0 -0
  115. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_diagnostics.py +0 -0
  116. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_diff_trimmer.py +0 -0
  117. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_git_ops.py +0 -0
  118. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_history_analyzer.py +0 -0
  119. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_hooks.py +0 -0
  120. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_ignore.py +0 -0
  121. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_intent_parser.py +0 -0
  122. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_llm_factory.py +0 -0
  123. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_pr_drafter.py +0 -0
  124. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_rebase_helper.py +0 -0
  125. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_safety.py +0 -0
  126. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.3}/tests/test_search.py +0 -0
  127. {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.1
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
- if not staged_diff.strip():
33
- # Sometimes status has staged but diff is empty (e.g. only file permissions or empty files)
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
- full_path.write_text(content, encoding="utf-8")
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(f"\n[warning]⚠️ Ollama model [bold]{model_name}[/bold] is not downloaded locally.[/warning]")
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": False}).encode("utf-8")
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"Downloading model '{model_name}' (this may take a few minutes)..."):
58
- with urllib.request.urlopen(req) as response:
59
- res_data = json.loads(response.read().decode("utf-8"))
60
- if res_data.get("status") == "success" or "success" in str(res_data):
61
- console.print(f"[success]✅ Successfully downloaded '{model_name}'![/success]\n")
62
- else:
63
- console.print(f"[info]Ollama response: {res_data}[/info]\n")
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
- console.print(f"[error]❌ Failed to pull model: {e}[/error]")
66
- console.print(f"[info]Please run 'ollama pull {model_name}' manually in your shell.[/info]\n")
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:[/bold] {cmd}\n[bold red]Risk:[/bold] {r_desc}")
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:[/bold] {alt}")
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
- res = git_ops.execute(cmd)
195
- outputs.append(res)
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
- show_error_panel(f"Failed to generate changelog: {e}", "AI Error")
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:[/bold] {cmd}\n[bold red]Risk:[/bold] {r_desc}")
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:[/bold] {alt}")
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
- show_error_panel(f"Failed to generate PR description: {e}", "AI Error")
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
- with open(DEFAULT_CONFIG_PATH, "w", encoding="utf-8") as f:
161
- toml.dump(config.to_dict(), f)
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
- git_dir = Path(self.git_ops.working_dir) / ".git"
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
- return [b.name for b in self.repo.remotes.origin.refs] if "origin" in self.repo.remotes else []
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
- # Split command into parts
157
- parts = command.strip().split()
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
- # Use git command runner directly
162
- git_func = getattr(self.repo.git, parts[0].replace("-", "_"))
163
- return git_func(*parts[1:])
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(message),
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
- return default
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ace-git-copilot"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  description = "AI-powered Git copilot — talk to Git in plain English"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"