ace-git-copilot 0.3.2__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.2 → ace_git_copilot-0.3.3}/PKG-INFO +13 -1
  2. {ace_git_copilot-0.3.2 → 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.2 → ace_git_copilot-0.3.3}/ace/ai/conflict_resolver.py +35 -4
  5. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/llm_factory.py +26 -8
  6. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/cli.py +3 -2
  7. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/core/config.py +12 -3
  8. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/core/context.py +5 -1
  9. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/core/git_ops.py +33 -7
  10. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/pyproject.toml +1 -1
  11. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/e2e/test_tier2_boundaries.py +7 -0
  12. ace_git_copilot-0.3.3/tests/test_config_atomic_write.py +41 -0
  13. ace_git_copilot-0.3.3/tests/test_conflict_resolver_backup.py +49 -0
  14. ace_git_copilot-0.3.3/tests/test_context_gitdir_detection.py +45 -0
  15. ace_git_copilot-0.3.3/tests/test_git_ops_execute.py +41 -0
  16. ace_git_copilot-0.3.3/tests/test_llm_factory_ollama.py +48 -0
  17. ace_git_copilot-0.3.2/ace/__init__.py +0 -1
  18. ace_git_copilot-0.3.2/importtime.txt +0 -0
  19. ace_git_copilot-0.3.2/importtime_optimized.txt +0 -0
  20. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/AGENTS.md +0 -0
  21. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/BRIEFING.md +0 -0
  22. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/ORIGINAL_REQUEST.md +0 -0
  23. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/BRIEFING.md +0 -0
  24. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/ORIGINAL_REQUEST.md +0 -0
  25. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/SCOPE.md +0 -0
  26. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/e2e_testing_track/progress.md +0 -0
  27. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/BRIEFING.md +0 -0
  28. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/ORIGINAL_REQUEST.md +0 -0
  29. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/emojis_list.txt +0 -0
  30. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/find_unused_modules.py +0 -0
  31. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/handoff.md +0 -0
  32. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/measure_lazy_startup.py +0 -0
  33. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/measure_startup.py +0 -0
  34. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/profile_imports.py +0 -0
  35. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/progress.md +0 -0
  36. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/run_importtime.py +0 -0
  37. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_banner.py +0 -0
  38. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_emojis.py +0 -0
  39. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_git_usages.py +0 -0
  40. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/search_usages.py +0 -0
  41. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/test_import_profiler.py +0 -0
  42. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/explorer_init/test_mocked_sys.py +0 -0
  43. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/handoff.md +0 -0
  44. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/implementation_track/BRIEFING.md +0 -0
  45. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/implementation_track/ORIGINAL_REQUEST.md +0 -0
  46. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/implementation_track/explorer_initial_report.md +0 -0
  47. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/implementation_track/progress.md +0 -0
  48. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/orchestrator/.gitkeep +0 -0
  49. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/orchestrator/BRIEFING.md +0 -0
  50. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/orchestrator/ORIGINAL_REQUEST.md +0 -0
  51. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/orchestrator/PROJECT.md +0 -0
  52. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/orchestrator/progress.md +0 -0
  53. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/BRIEFING.md +0 -0
  54. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/ORIGINAL_REQUEST.md +0 -0
  55. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/handoff.md +0 -0
  56. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/teamwork_preview_explorer_e2e_explore/progress.md +0 -0
  57. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/BRIEFING.md +0 -0
  58. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/ORIGINAL_REQUEST.md +0 -0
  59. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/worker_e2e_testing/progress.md +0 -0
  60. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/BRIEFING.md +0 -0
  61. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/ORIGINAL_REQUEST.md +0 -0
  62. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.agents/worker_m1_startup/progress.md +0 -0
  63. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.env.example +0 -0
  64. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.github/workflows/tests.yml +0 -0
  65. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/.gitignore +0 -0
  66. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/CODE_OF_CONDUCT.md +0 -0
  67. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/CONTRIBUTING.md +0 -0
  68. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/LICENSE +0 -0
  69. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/PROJECT.md +0 -0
  70. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/SECURITY.md +0 -0
  71. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/SUPPORT.md +0 -0
  72. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/TEST_INFRA.md +0 -0
  73. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/TEST_READY.md +0 -0
  74. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/__main__.py +0 -0
  75. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/changelog_generator.py +0 -0
  76. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/code_reviewer.py +0 -0
  77. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/commit_generator.py +0 -0
  78. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/gitignore_generator.py +0 -0
  79. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/history_analyzer.py +0 -0
  80. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/intent_parser.py +0 -0
  81. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/pr_drafter.py +0 -0
  82. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/changelog.py +0 -0
  83. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/commit.py +0 -0
  84. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/conflict.py +0 -0
  85. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/doctor.py +0 -0
  86. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/explain.py +0 -0
  87. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/ignore.py +0 -0
  88. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/intent.py +0 -0
  89. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/pr.py +0 -0
  90. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/rebase.py +0 -0
  91. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/review.py +0 -0
  92. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/search.py +0 -0
  93. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/prompts/undo.py +0 -0
  94. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ai/rebase_helper.py +0 -0
  95. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/core/diagnostics.py +0 -0
  96. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/core/hooks.py +0 -0
  97. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/core/safety.py +0 -0
  98. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ui/banner.py +0 -0
  99. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ui/dashboard.py +0 -0
  100. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ui/display.py +0 -0
  101. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ui/prompts.py +0 -0
  102. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/ui/themes.py +0 -0
  103. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/utils/conflict_parser.py +0 -0
  104. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/utils/diff_parser.py +0 -0
  105. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/ace/utils/json_utils.py +0 -0
  106. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/conftest.py +0 -0
  107. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/e2e/conftest.py +0 -0
  108. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/e2e/test_tier1_features.py +0 -0
  109. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/e2e/test_tier3_combinations.py +0 -0
  110. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/e2e/test_tier4_workloads.py +0 -0
  111. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_changelog_generator.py +0 -0
  112. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_code_reviewer.py +0 -0
  113. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_conflict_resolver.py +0 -0
  114. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_diagnostics.py +0 -0
  115. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_diff_trimmer.py +0 -0
  116. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_git_ops.py +0 -0
  117. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_help.py +0 -0
  118. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_history_analyzer.py +0 -0
  119. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_hooks.py +0 -0
  120. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_ignore.py +0 -0
  121. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_intent_parser.py +0 -0
  122. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_llm_factory.py +0 -0
  123. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_pr_drafter.py +0 -0
  124. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_rebase_helper.py +0 -0
  125. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_safety.py +0 -0
  126. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.3}/tests/test_search.py +0 -0
  127. {ace_git_copilot-0.3.2 → 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.2
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"
@@ -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
@@ -51,17 +51,35 @@ def ensure_ollama_model(base_url: str, model_name: str) -> None:
51
51
  if confirm(f"Would you like Ace to automatically pull '{model_name}' from the Ollama registry?", default=True):
52
52
  try:
53
53
  url = f"{base_url.rstrip('/')}/api/pull"
54
- payload = json.dumps({"name": model_name, "stream": False}).encode("utf-8")
54
+ payload = json.dumps({"name": model_name, "stream": True}).encode("utf-8")
55
55
  req = urllib.request.Request(url, data=payload, method="POST")
56
56
  req.add_header("Content-Type", "application/json")
57
57
 
58
- with spinner(f"Downloading model '{model_name}' (this may take a few minutes)..."):
59
- with urllib.request.urlopen(req) as response:
60
- res_data = json.loads(response.read().decode("utf-8"))
61
- if res_data.get("status") == "success" or "success" in str(res_data):
62
- print_success(f"Successfully downloaded '{model_name}'!\n")
63
- else:
64
- print_info(f"Ollama response: {res_data}\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")
65
83
  except Exception as e:
66
84
  print_error(f"Failed to pull model: {e}")
67
85
  print_info(f"Please run 'ollama pull {model_name}' manually in your shell.\n")
@@ -193,9 +193,10 @@ def main(
193
193
  try:
194
194
  import subprocess
195
195
  import sys
196
- args = cmd.split()[1:]
196
+ import shlex
197
+ args = shlex.split(cmd)[1:]
197
198
  res_proc = subprocess.run(
198
- [sys.executable, "-c", "from ace.cli import app; app()"] + args,
199
+ [sys.executable, "-m", "ace"] + args,
199
200
  stdout=subprocess.PIPE,
200
201
  stderr=subprocess.PIPE,
201
202
  text=True,
@@ -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'."""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ace-git-copilot"
3
- version = "0.3.2"
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"
@@ -379,6 +379,13 @@ def test_undo_nothing_to_undo(git_workspace):
379
379
  assert "Nothing to undo" in res.stdout
380
380
 
381
381
  def test_undo_destructive_confirm_no(git_workspace):
382
+ # Setup commit and ORIG_HEAD to trigger high risk undo
383
+ test_file = git_workspace.workspace / "test.txt"
384
+ test_file.write_text("initial")
385
+ git_workspace.repo.index.add([str(test_file)])
386
+ git_workspace.repo.index.commit("initial commit")
387
+ git_workspace.repo.git.update_ref("ORIG_HEAD", "HEAD")
388
+
382
389
  # Mock server triggers destructive plan
383
390
  res = git_workspace.run(["undo"], stdin_data="n\n")
384
391
  assert res.returncode == 0
@@ -0,0 +1,41 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from unittest.mock import patch, MagicMock
4
+ from ace.core.config import save_config, Config
5
+
6
+ @patch("ace.core.config.DEFAULT_CONFIG_DIR")
7
+ @patch("ace.core.config.DEFAULT_CONFIG_PATH")
8
+ def test_save_config_atomic(mock_config_path, mock_config_dir, tmp_path):
9
+ # Setup tmp directory for config file
10
+ config_dir = tmp_path / ".ace"
11
+ config_dir.mkdir()
12
+ config_file = config_dir / "config.toml"
13
+
14
+ mock_config_dir.mkdir = MagicMock()
15
+ mock_config_dir.__truediv__ = MagicMock(return_value=config_file)
16
+ # Configure path patch
17
+ mock_config_path.open = config_file.open
18
+ mock_config_path.parent = config_dir
19
+ mock_config_path.exists = config_file.exists
20
+
21
+ # We patch DEFAULT_CONFIG_DIR and DEFAULT_CONFIG_PATH directly inside the function
22
+ with patch("ace.core.config.DEFAULT_CONFIG_DIR", config_dir), \
23
+ patch("ace.core.config.DEFAULT_CONFIG_PATH", config_file):
24
+
25
+ cfg = Config({
26
+ "ai": {
27
+ "provider": "openai",
28
+ "openai_model": "gpt-4o-mini"
29
+ }
30
+ })
31
+
32
+ save_config(cfg)
33
+
34
+ # Verify content was written
35
+ assert config_file.exists()
36
+ content = config_file.read_text(encoding="utf-8")
37
+ assert "provider = \"openai\"" in content
38
+
39
+ # Verify no temp files are left in the directory
40
+ temp_files = list(config_dir.glob("config-*.tmp"))
41
+ assert len(temp_files) == 0
@@ -0,0 +1,49 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from unittest.mock import MagicMock
4
+ from ace.ai.conflict_resolver import ConflictResolver, ConflictResolverError
5
+
6
+ def test_apply_resolution_creates_backup_and_atomic_swap(tmp_path):
7
+ # Setup mock git_ops working dir
8
+ mock_git_ops = MagicMock()
9
+ mock_git_ops.working_dir = str(tmp_path)
10
+
11
+ # Create a test conflicted file
12
+ test_file = tmp_path / "conflict.txt"
13
+ test_content = "<<<<<<< HEAD\nlocal changes\n=======\nincoming changes\n>>>>>>> branch\n"
14
+ test_file.write_text(test_content, encoding="utf-8")
15
+
16
+ resolver = ConflictResolver(mock_git_ops)
17
+
18
+ # Replacement block
19
+ block = "<<<<<<< HEAD\nlocal changes\n=======\nincoming changes\n>>>>>>> branch"
20
+ replacement = "resolved changes"
21
+
22
+ # Run resolution
23
+ resolver.apply_resolution("conflict.txt", [(block, replacement)])
24
+
25
+ # Assert resolution succeeded
26
+ assert test_file.read_text(encoding="utf-8") == "resolved changes\n"
27
+
28
+ # Assert backup file was cleaned up (no extra .bak files remain)
29
+ bak_files = list(tmp_path.glob("conflict.txt.bak-*"))
30
+ assert len(bak_files) == 0
31
+
32
+ def test_apply_resolution_fails_and_restores(tmp_path):
33
+ mock_git_ops = MagicMock()
34
+ mock_git_ops.working_dir = str(tmp_path)
35
+
36
+ test_file = tmp_path / "conflict.txt"
37
+ test_content = "<<<<<<< HEAD\noriginal content\n>>>>>>> branch"
38
+ test_file.write_text(test_content, encoding="utf-8")
39
+
40
+ resolver = ConflictResolver(mock_git_ops)
41
+
42
+ # Block that doesn't exist to force failure
43
+ non_existent_block = "no match"
44
+
45
+ with pytest.raises(ConflictResolverError, match="Conflict block not found"):
46
+ resolver.apply_resolution("conflict.txt", [(non_existent_block, "bad replacement")])
47
+
48
+ # Assert original content was preserved/restored
49
+ assert test_file.read_text(encoding="utf-8") == test_content
@@ -0,0 +1,45 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from unittest.mock import MagicMock
4
+ from ace.core.context import RepoContext
5
+
6
+ def test_check_merge_rebase_state_with_git_dir_resolution(tmp_path):
7
+ # Create mock git_dir (which in real git might be inside worktrees/submodules)
8
+ mock_git_dir = tmp_path / "worktree_gitdir"
9
+ mock_git_dir.mkdir()
10
+
11
+ # Simulate a merge in progress
12
+ merge_head = mock_git_dir / "MERGE_HEAD"
13
+ merge_head.write_text("commit123", encoding="utf-8")
14
+
15
+ # Setup mock git_ops and repo
16
+ mock_git_ops = MagicMock()
17
+ mock_git_ops.repo.git_dir = str(mock_git_dir)
18
+ mock_git_ops.working_dir = str(tmp_path)
19
+
20
+ context_builder = RepoContext(mock_git_ops)
21
+ state = context_builder.check_merge_rebase_state()
22
+
23
+ assert state["in_progress"] is True
24
+ assert state["type"] == "merge"
25
+ assert "Merge conflict" in state["detail"]
26
+
27
+ def test_check_merge_rebase_state_fallback(tmp_path):
28
+ # Setup mock git_ops that raises error when accessing git_dir
29
+ mock_git_ops = MagicMock()
30
+ # Delete git_dir attribute to force AttributeError on access
31
+ del mock_git_ops.repo.git_dir
32
+ mock_git_ops.working_dir = str(tmp_path)
33
+
34
+ # Setup local fallback .git directory
35
+ fallback_git = tmp_path / ".git"
36
+ fallback_git.mkdir()
37
+
38
+ rebase_apply = fallback_git / "rebase-apply"
39
+ rebase_apply.mkdir()
40
+
41
+ context_builder = RepoContext(mock_git_ops)
42
+ state = context_builder.check_merge_rebase_state()
43
+
44
+ assert state["in_progress"] is True
45
+ assert state["type"] == "rebase"
@@ -0,0 +1,41 @@
1
+ import pytest
2
+ from unittest.mock import MagicMock
3
+ from ace.core.git_ops import GitOps
4
+
5
+ def test_git_ops_execute_quoted_args():
6
+ # Setup mock repo
7
+ mock_repo = MagicMock()
8
+ mock_git_func = MagicMock(return_value="commit success")
9
+ mock_repo.git.commit = mock_git_func
10
+
11
+ git_ops = GitOps.__new__(GitOps)
12
+ git_ops.repo = mock_repo
13
+
14
+ # Run execute with quotes
15
+ res = git_ops.execute("commit -m 'hello world'")
16
+
17
+ assert res == "commit success"
18
+ mock_git_func.assert_called_once_with("-m", "hello world")
19
+
20
+ def test_git_ops_execute_unknown_command():
21
+ mock_repo = MagicMock()
22
+ # Simulate AttributeError when getting direct attribute
23
+ del mock_repo.git.nonexistent
24
+
25
+ # Mock fallback git.execute
26
+ mock_execute = MagicMock(return_value="executed custom")
27
+ mock_repo.git.execute = mock_execute
28
+
29
+ git_ops = GitOps.__new__(GitOps)
30
+ git_ops.repo = mock_repo
31
+
32
+ res = git_ops.execute("nonexistent -a --foo")
33
+ assert res == "executed custom"
34
+ mock_execute.assert_called_once_with(["git", "nonexistent", "-a", "--foo"])
35
+
36
+ def test_git_ops_execute_empty():
37
+ git_ops = GitOps.__new__(GitOps)
38
+ with pytest.raises(ValueError, match="Empty Git command"):
39
+ git_ops.execute("")
40
+ with pytest.raises(ValueError, match="Empty Git command"):
41
+ git_ops.execute(" ")
@@ -0,0 +1,48 @@
1
+ import pytest
2
+ import json
3
+ from unittest.mock import patch, MagicMock
4
+ from ace.ai.llm_factory import ensure_ollama_model, _checked_ollama_models
5
+
6
+ @pytest.fixture(autouse=True)
7
+ def clear_ollama_cache():
8
+ _checked_ollama_models.clear()
9
+
10
+ @patch("urllib.request.urlopen")
11
+ @patch("urllib.request.Request")
12
+ def test_ensure_ollama_model_streaming_success(mock_request, mock_urlopen):
13
+ # First response: GET /api/tags (model list doesn't contain test-model)
14
+ mock_response_tags = MagicMock()
15
+ mock_response_tags.__enter__.return_value = mock_response_tags
16
+ mock_response_tags.read.return_value = b'{"models": []}'
17
+
18
+ # Second response: POST /api/pull (streaming download chunks)
19
+ mock_response_pull = MagicMock()
20
+ mock_response_pull.__enter__.return_value = mock_response_pull
21
+ mock_response_pull.__iter__.return_value = [
22
+ b'{"status": "pulling manifest"}\n',
23
+ b'{"status": "downloading digest", "completed": 50, "total": 100}\n',
24
+ b'{"status": "success"}\n'
25
+ ]
26
+
27
+ mock_urlopen.side_effect = [mock_response_tags, mock_response_pull]
28
+
29
+ # Mock user input to confirm pulling
30
+ with patch("ace.ui.prompts.confirm", return_value=True):
31
+ ensure_ollama_model("http://localhost:11434", "test-model")
32
+
33
+ assert mock_urlopen.call_count == 2
34
+
35
+ @patch("urllib.request.urlopen")
36
+ @patch("urllib.request.Request")
37
+ def test_ensure_ollama_model_already_exists(mock_request, mock_urlopen):
38
+ # Model is listed in local models, so no pull should be requested
39
+ mock_response_tags = MagicMock()
40
+ mock_response_tags.__enter__.return_value = mock_response_tags
41
+ mock_response_tags.read.return_value = b'{"models": [{"name": "test-model:latest"}]}'
42
+ mock_urlopen.return_value = mock_response_tags
43
+
44
+ with patch("ace.ui.prompts.confirm", return_value=True) as mock_confirm:
45
+ ensure_ollama_model("http://localhost:11434", "test-model")
46
+ mock_confirm.assert_not_called()
47
+
48
+ assert mock_urlopen.call_count == 1
@@ -1 +0,0 @@
1
- __version__ = "0.3.2"
Binary file
File without changes