ace-git-copilot 0.3.2__tar.gz → 0.3.4__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 (128) hide show
  1. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/PKG-INFO +19 -1
  2. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/README.md +18 -0
  3. ace_git_copilot-0.3.4/ace/__init__.py +1 -0
  4. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/conflict_resolver.py +35 -4
  5. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/llm_factory.py +26 -8
  6. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/cli.py +192 -2
  7. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/config.py +12 -3
  8. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/context.py +5 -1
  9. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/git_ops.py +33 -7
  10. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/pyproject.toml +1 -1
  11. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/test_tier2_boundaries.py +7 -0
  12. ace_git_copilot-0.3.4/tests/test_config_atomic_write.py +41 -0
  13. ace_git_copilot-0.3.4/tests/test_conflict_resolver_backup.py +49 -0
  14. ace_git_copilot-0.3.4/tests/test_context_gitdir_detection.py +45 -0
  15. ace_git_copilot-0.3.4/tests/test_git_ops_execute.py +41 -0
  16. ace_git_copilot-0.3.4/tests/test_llm_factory_ollama.py +48 -0
  17. ace_git_copilot-0.3.4/tests/test_workspace.py +89 -0
  18. ace_git_copilot-0.3.2/ace/__init__.py +0 -1
  19. ace_git_copilot-0.3.2/importtime.txt +0 -0
  20. ace_git_copilot-0.3.2/importtime_optimized.txt +0 -0
  21. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/AGENTS.md +0 -0
  22. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/BRIEFING.md +0 -0
  23. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/ORIGINAL_REQUEST.md +0 -0
  24. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/e2e_testing_track/BRIEFING.md +0 -0
  25. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/e2e_testing_track/ORIGINAL_REQUEST.md +0 -0
  26. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/e2e_testing_track/SCOPE.md +0 -0
  27. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/e2e_testing_track/progress.md +0 -0
  28. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/BRIEFING.md +0 -0
  29. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/ORIGINAL_REQUEST.md +0 -0
  30. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/emojis_list.txt +0 -0
  31. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/find_unused_modules.py +0 -0
  32. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/handoff.md +0 -0
  33. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/measure_lazy_startup.py +0 -0
  34. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/measure_startup.py +0 -0
  35. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/profile_imports.py +0 -0
  36. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/progress.md +0 -0
  37. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/run_importtime.py +0 -0
  38. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/search_banner.py +0 -0
  39. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/search_emojis.py +0 -0
  40. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/search_git_usages.py +0 -0
  41. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/search_usages.py +0 -0
  42. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/test_import_profiler.py +0 -0
  43. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/test_mocked_sys.py +0 -0
  44. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/handoff.md +0 -0
  45. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/implementation_track/BRIEFING.md +0 -0
  46. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/implementation_track/ORIGINAL_REQUEST.md +0 -0
  47. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/implementation_track/explorer_initial_report.md +0 -0
  48. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/implementation_track/progress.md +0 -0
  49. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/.gitkeep +0 -0
  50. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/BRIEFING.md +0 -0
  51. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/ORIGINAL_REQUEST.md +0 -0
  52. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/PROJECT.md +0 -0
  53. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/progress.md +0 -0
  54. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/teamwork_preview_explorer_e2e_explore/BRIEFING.md +0 -0
  55. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/teamwork_preview_explorer_e2e_explore/ORIGINAL_REQUEST.md +0 -0
  56. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/teamwork_preview_explorer_e2e_explore/handoff.md +0 -0
  57. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/teamwork_preview_explorer_e2e_explore/progress.md +0 -0
  58. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_e2e_testing/BRIEFING.md +0 -0
  59. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_e2e_testing/ORIGINAL_REQUEST.md +0 -0
  60. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_e2e_testing/progress.md +0 -0
  61. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_m1_startup/BRIEFING.md +0 -0
  62. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_m1_startup/ORIGINAL_REQUEST.md +0 -0
  63. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_m1_startup/progress.md +0 -0
  64. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.env.example +0 -0
  65. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.github/workflows/tests.yml +0 -0
  66. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.gitignore +0 -0
  67. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/CODE_OF_CONDUCT.md +0 -0
  68. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/CONTRIBUTING.md +0 -0
  69. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/LICENSE +0 -0
  70. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/PROJECT.md +0 -0
  71. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/SECURITY.md +0 -0
  72. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/SUPPORT.md +0 -0
  73. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/TEST_INFRA.md +0 -0
  74. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/TEST_READY.md +0 -0
  75. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/__main__.py +0 -0
  76. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/changelog_generator.py +0 -0
  77. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/code_reviewer.py +0 -0
  78. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/commit_generator.py +0 -0
  79. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/gitignore_generator.py +0 -0
  80. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/history_analyzer.py +0 -0
  81. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/intent_parser.py +0 -0
  82. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/pr_drafter.py +0 -0
  83. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/changelog.py +0 -0
  84. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/commit.py +0 -0
  85. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/conflict.py +0 -0
  86. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/doctor.py +0 -0
  87. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/explain.py +0 -0
  88. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/ignore.py +0 -0
  89. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/intent.py +0 -0
  90. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/pr.py +0 -0
  91. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/rebase.py +0 -0
  92. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/review.py +0 -0
  93. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/search.py +0 -0
  94. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/undo.py +0 -0
  95. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/rebase_helper.py +0 -0
  96. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/diagnostics.py +0 -0
  97. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/hooks.py +0 -0
  98. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/safety.py +0 -0
  99. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/banner.py +0 -0
  100. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/dashboard.py +0 -0
  101. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/display.py +0 -0
  102. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/prompts.py +0 -0
  103. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/themes.py +0 -0
  104. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/utils/conflict_parser.py +0 -0
  105. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/utils/diff_parser.py +0 -0
  106. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/utils/json_utils.py +0 -0
  107. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/conftest.py +0 -0
  108. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/conftest.py +0 -0
  109. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/test_tier1_features.py +0 -0
  110. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/test_tier3_combinations.py +0 -0
  111. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/test_tier4_workloads.py +0 -0
  112. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_changelog_generator.py +0 -0
  113. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_code_reviewer.py +0 -0
  114. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_conflict_resolver.py +0 -0
  115. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_diagnostics.py +0 -0
  116. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_diff_trimmer.py +0 -0
  117. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_git_ops.py +0 -0
  118. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_help.py +0 -0
  119. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_history_analyzer.py +0 -0
  120. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_hooks.py +0 -0
  121. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_ignore.py +0 -0
  122. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_intent_parser.py +0 -0
  123. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_llm_factory.py +0 -0
  124. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_pr_drafter.py +0 -0
  125. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_rebase_helper.py +0 -0
  126. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_safety.py +0 -0
  127. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_search.py +0 -0
  128. {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/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.4
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
@@ -170,6 +170,7 @@ Ace has dedicated subcommands for specific tasks. Here is the complete command l
170
170
  | `ace squash` | — | Automatically analyze commit history and run AI-guided interactive rebases. |
171
171
  | `ace ignore <rule>` | — | Generate and append standard templates to `.gitignore`. |
172
172
  | `ace undo` | — | Safely revert the last action after checking repository status. |
173
+ | `ace workspace` | `ace ws` | Monitor status of multiple repositories and navigate between them. |
173
174
  | `ace dash` | — | Launch the interactive repository management console (TUI). |
174
175
  | `ace config` | — | Print out active configuration settings. |
175
176
  | `ace help` | — | Show user guide and help information on how to use Ace. |
@@ -198,6 +199,23 @@ Distributed under the MIT License. See [LICENSE](LICENSE) for more details.
198
199
 
199
200
  ## Changelog
200
201
 
202
+ ### v0.3.4 — Workspace Management (2026-07-11)
203
+ * Added new `workspace` command (alias `ws`) to scan, monitor, and navigate multiple repositories.
204
+ * Shows active branch, staged/unstaged/untracked file counts, and local upstream sync offsets in a Rich Table.
205
+ * Supports interactive actions to open the TUI dashboard, spawn a nested shell, or run copy-paste commands inside any chosen repository.
206
+
207
+ ### v0.3.3 — Stability & Resilience (2026-07-04)
208
+ * Implemented streaming progress and download percentages for Ollama model pulls to prevent thread hangs.
209
+ * Added atomic file saves and rollback backups for merge conflict resolutions and config modifications to prevent data corruption.
210
+ * Fixed merge/rebase detection in git worktrees and submodules by resolving the authoritative `git_dir` dynamically.
211
+ * Added `shlex` command splitting to safely parse and execute quoted arguments in Git commands.
212
+ * Created python module entry point (`python -m ace`) for nested execution support.
213
+
214
+ ### v0.3.2 — Windows Compatibility & E2E Fixes (2026-07-01)
215
+ * Resolved Windows-specific UTF-8 encoding issues in E2E tests and log parsers.
216
+ * Fixed subprocess natural language command execution paths to execute nested ace commands through Python interpreter contexts.
217
+ * Refactored CLI error panel formatting to match expected exception names.
218
+
201
219
  ### v0.3.1 — Patch (2026-06-30)
202
220
  * Fixed invisible key labels (`[c]`, `[r]`, etc.) in the dashboard and search menus caused by Rich markup tag conflicts.
203
221
  * Fixed `[Y/n]` confirmation prompt rendering invisibly.
@@ -137,6 +137,7 @@ Ace has dedicated subcommands for specific tasks. Here is the complete command l
137
137
  | `ace squash` | — | Automatically analyze commit history and run AI-guided interactive rebases. |
138
138
  | `ace ignore <rule>` | — | Generate and append standard templates to `.gitignore`. |
139
139
  | `ace undo` | — | Safely revert the last action after checking repository status. |
140
+ | `ace workspace` | `ace ws` | Monitor status of multiple repositories and navigate between them. |
140
141
  | `ace dash` | — | Launch the interactive repository management console (TUI). |
141
142
  | `ace config` | — | Print out active configuration settings. |
142
143
  | `ace help` | — | Show user guide and help information on how to use Ace. |
@@ -165,6 +166,23 @@ Distributed under the MIT License. See [LICENSE](LICENSE) for more details.
165
166
 
166
167
  ## Changelog
167
168
 
169
+ ### v0.3.4 — Workspace Management (2026-07-11)
170
+ * Added new `workspace` command (alias `ws`) to scan, monitor, and navigate multiple repositories.
171
+ * Shows active branch, staged/unstaged/untracked file counts, and local upstream sync offsets in a Rich Table.
172
+ * Supports interactive actions to open the TUI dashboard, spawn a nested shell, or run copy-paste commands inside any chosen repository.
173
+
174
+ ### v0.3.3 — Stability & Resilience (2026-07-04)
175
+ * Implemented streaming progress and download percentages for Ollama model pulls to prevent thread hangs.
176
+ * Added atomic file saves and rollback backups for merge conflict resolutions and config modifications to prevent data corruption.
177
+ * Fixed merge/rebase detection in git worktrees and submodules by resolving the authoritative `git_dir` dynamically.
178
+ * Added `shlex` command splitting to safely parse and execute quoted arguments in Git commands.
179
+ * Created python module entry point (`python -m ace`) for nested execution support.
180
+
181
+ ### v0.3.2 — Windows Compatibility & E2E Fixes (2026-07-01)
182
+ * Resolved Windows-specific UTF-8 encoding issues in E2E tests and log parsers.
183
+ * Fixed subprocess natural language command execution paths to execute nested ace commands through Python interpreter contexts.
184
+ * Refactored CLI error panel formatting to match expected exception names.
185
+
168
186
  ### v0.3.1 — Patch (2026-06-30)
169
187
  * Fixed invisible key labels (`[c]`, `[r]`, etc.) in the dashboard and search menus caused by Rich markup tag conflicts.
170
188
  * Fixed `[Y/n]` confirmation prompt rendering invisibly.
@@ -0,0 +1 @@
1
+ __version__ = "0.3.4"
@@ -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,
@@ -1685,6 +1686,195 @@ def hook_cmd(
1685
1686
  show_error_panel("Invalid action. Use 'install' or 'uninstall'.", "Usage Error")
1686
1687
  raise typer.Exit(code=1)
1687
1688
 
1689
+ @app.command(name="workspace", help="Monitor status of multiple repositories and navigate between them.")
1690
+ def workspace_cmd(
1691
+ path: Optional[str] = typer.Argument(
1692
+ None, help="Custom directory to scan (defaults to sibling projects scan)"
1693
+ ),
1694
+ ):
1695
+ from pathlib import Path
1696
+ import os
1697
+ import sys
1698
+ import subprocess
1699
+ from rich.table import Table
1700
+ from ace.core.git_ops import GitOps, NotAGitRepositoryError
1701
+ from ace.ui.display import console, print_info, print_success, print_warning, print_error
1702
+ from ace.ui.prompts import prompt_select, prompt_action
1703
+
1704
+ # 1. Determine target directory to scan
1705
+ if path:
1706
+ scan_dir = Path(path).resolve()
1707
+ else:
1708
+ # Smart default
1709
+ cwd = Path(os.getcwd()).resolve()
1710
+ if (cwd / ".git").exists():
1711
+ scan_dir = cwd.parent
1712
+ else:
1713
+ scan_dir = cwd
1714
+
1715
+ if not scan_dir.exists() or not scan_dir.is_dir():
1716
+ print_error(f"Directory not found: {scan_dir}")
1717
+ raise typer.Exit(code=1)
1718
+
1719
+ print_info(f"Scanning workspace directory: [bold]{scan_dir}[/bold]")
1720
+
1721
+ # 2. Find git repositories
1722
+ repos_found = []
1723
+ try:
1724
+ for child in sorted(scan_dir.iterdir()):
1725
+ if not child.is_dir():
1726
+ continue
1727
+ # Basic validation that it's a git repo
1728
+ if (child / ".git").exists() or (child / "gitdir").exists() or child.name.endswith(".git"):
1729
+ try:
1730
+ git_ops = GitOps(str(child))
1731
+ repos_found.append((child, git_ops))
1732
+ except Exception:
1733
+ pass
1734
+ except Exception as e:
1735
+ print_error(f"Failed to scan directory: {e}")
1736
+ raise typer.Exit(code=1)
1737
+
1738
+ if not repos_found:
1739
+ print_warning(f"No active Git repositories found in: {scan_dir}")
1740
+ return
1741
+
1742
+ # 3. Build Status Table
1743
+ table = Table(
1744
+ title=f"[bold white]Workspace Summary: {scan_dir.name}[/bold white]",
1745
+ title_style="bold",
1746
+ border_style="#FF6D00",
1747
+ expand=True,
1748
+ )
1749
+ table.add_column("[bold #00D5FF]#[/bold #00D5FF]", justify="right", width=4)
1750
+ table.add_column("Repository", style="bold white")
1751
+ table.add_column("Branch", style="bold cyan")
1752
+ table.add_column("Status", justify="center")
1753
+ table.add_column("Sync", justify="center")
1754
+
1755
+ display_options = []
1756
+ for idx, (repo_path, git_ops) in enumerate(repos_found, 1):
1757
+ repo_name = repo_path.name
1758
+
1759
+ # Branch
1760
+ branch_name = git_ops.get_current_branch() or "Detached HEAD"
1761
+
1762
+ # Status counts
1763
+ status = git_ops.get_status()
1764
+ staged = len(status.get("staged", []))
1765
+ unstaged = len(status.get("unstaged", []))
1766
+ untracked = len(status.get("untracked", []))
1767
+
1768
+ if staged == 0 and unstaged == 0 and untracked == 0:
1769
+ status_desc = "[success]Clean[/success]"
1770
+ else:
1771
+ parts = []
1772
+ if staged > 0:
1773
+ parts.append(f"[bold #00E676]+{staged}[/bold #00E676]")
1774
+ if unstaged > 0:
1775
+ parts.append(f"[bold #FFD600]~{unstaged}[/bold #FFD600]")
1776
+ if untracked > 0:
1777
+ parts.append(f"[bold #FF1744]?{untracked}[/bold #FF1744]")
1778
+ status_desc = " ".join(parts)
1779
+
1780
+ # Sync offsets
1781
+ ab = git_ops.get_ahead_behind()
1782
+ ahead = ab.get("ahead", 0)
1783
+ behind = ab.get("behind", 0)
1784
+ tracking = git_ops.get_upstream_tracking()
1785
+
1786
+ if not tracking:
1787
+ sync_desc = "[#666666]No Upstream[/#666666]"
1788
+ elif ahead == 0 and behind == 0:
1789
+ sync_desc = "[success]Up-to-date[/success]"
1790
+ elif ahead > 0 and behind == 0:
1791
+ sync_desc = f"[bold #00D5FF]Ahead {ahead}[/bold #00D5FF]"
1792
+ elif behind > 0 and ahead == 0:
1793
+ sync_desc = f"[bold #FFD600]Behind {behind}[/bold #FFD600]"
1794
+ else:
1795
+ sync_desc = f"[bold #FF1744]Diverged (A{ahead}, B{behind})[/bold #FF1744]"
1796
+
1797
+ table.add_row(
1798
+ str(idx),
1799
+ repo_name,
1800
+ branch_name,
1801
+ status_desc,
1802
+ sync_desc
1803
+ )
1804
+ display_options.append(repo_name)
1805
+
1806
+ console.print()
1807
+ console.print(table)
1808
+ console.print()
1809
+
1810
+ # 4. Prompt selection
1811
+ selected_idx = prompt_select(
1812
+ display_options,
1813
+ prompt_text=" Select repository number to manage (or 'q' to quit)",
1814
+ default="q"
1815
+ )
1816
+
1817
+ if selected_idx < 0:
1818
+ print_info("Exit workspace monitor.")
1819
+ return
1820
+
1821
+ selected_path, _ = repos_found[selected_idx]
1822
+
1823
+ # 5. Prompt action choice
1824
+ console.print(f"\n[bold white] Choose action for [bold cyan]{selected_path.name}[/bold cyan]:[/bold white]")
1825
+ action_options = {
1826
+ "d": ("Dashboard", "Open interactive TUI dashboard"),
1827
+ "s": ("Shell", "Spawn an interactive shell in project directory"),
1828
+ "c": ("Command", "Execute an ace natural-language command"),
1829
+ "q": ("Quit", "Cancel action and exit")
1830
+ }
1831
+
1832
+ action = prompt_action(action_options, default_key="d")
1833
+
1834
+ if action == "d":
1835
+ print_success(f"Launching dashboard in {selected_path.name}...")
1836
+ console.print()
1837
+ try:
1838
+ subprocess.run([sys.executable, "-m", "ace", "dash"], cwd=str(selected_path))
1839
+ except Exception as e:
1840
+ print_error(f"Failed to launch dashboard: {e}")
1841
+
1842
+ elif action == "s":
1843
+ shell = os.environ.get("SHELL")
1844
+ if not shell:
1845
+ if sys.platform == "win32":
1846
+ shell = "powershell.exe"
1847
+ else:
1848
+ shell = "/bin/bash"
1849
+
1850
+ print_success(f"Opening interactive shell in {selected_path.name}...")
1851
+ print_info("Type 'exit' to return to Ace workspace monitor.")
1852
+ console.print()
1853
+
1854
+ try:
1855
+ subprocess.run([shell], cwd=str(selected_path))
1856
+ print_success("Returned to Ace workspace.")
1857
+ except Exception as e:
1858
+ print_error(f"Failed to open shell: {e}")
1859
+
1860
+ elif action == "c":
1861
+ query = typer.prompt(" Enter command for this repo")
1862
+ if query.strip():
1863
+ print_success(f"Running command: {query}")
1864
+ console.print()
1865
+ try:
1866
+ subprocess.run([sys.executable, "-m", "ace", query], cwd=str(selected_path))
1867
+ except Exception as e:
1868
+ print_error(f"Failed to run command: {e}")
1869
+
1870
+ @app.command(name="ws", help="Alias for 'workspace' command.")
1871
+ def ws_cmd(
1872
+ path: Optional[str] = typer.Argument(
1873
+ None, help="Custom directory to scan"
1874
+ ),
1875
+ ):
1876
+ workspace_cmd(path)
1877
+
1688
1878
  if __name__ == "__main__":
1689
1879
  app()
1690
1880
 
@@ -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.4"
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"