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.
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/PKG-INFO +19 -1
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/README.md +18 -0
- ace_git_copilot-0.3.4/ace/__init__.py +1 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/conflict_resolver.py +35 -4
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/llm_factory.py +26 -8
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/cli.py +192 -2
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/config.py +12 -3
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/context.py +5 -1
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/git_ops.py +33 -7
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/pyproject.toml +1 -1
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/test_tier2_boundaries.py +7 -0
- ace_git_copilot-0.3.4/tests/test_config_atomic_write.py +41 -0
- ace_git_copilot-0.3.4/tests/test_conflict_resolver_backup.py +49 -0
- ace_git_copilot-0.3.4/tests/test_context_gitdir_detection.py +45 -0
- ace_git_copilot-0.3.4/tests/test_git_ops_execute.py +41 -0
- ace_git_copilot-0.3.4/tests/test_llm_factory_ollama.py +48 -0
- ace_git_copilot-0.3.4/tests/test_workspace.py +89 -0
- ace_git_copilot-0.3.2/ace/__init__.py +0 -1
- ace_git_copilot-0.3.2/importtime.txt +0 -0
- ace_git_copilot-0.3.2/importtime_optimized.txt +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/AGENTS.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/e2e_testing_track/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/e2e_testing_track/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/e2e_testing_track/SCOPE.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/e2e_testing_track/progress.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/emojis_list.txt +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/find_unused_modules.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/handoff.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/measure_lazy_startup.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/measure_startup.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/profile_imports.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/progress.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/run_importtime.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/search_banner.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/search_emojis.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/search_git_usages.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/search_usages.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/test_import_profiler.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/explorer_init/test_mocked_sys.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/handoff.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/implementation_track/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/implementation_track/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/implementation_track/explorer_initial_report.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/implementation_track/progress.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/.gitkeep +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/PROJECT.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/orchestrator/progress.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/teamwork_preview_explorer_e2e_explore/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/teamwork_preview_explorer_e2e_explore/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/teamwork_preview_explorer_e2e_explore/handoff.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/teamwork_preview_explorer_e2e_explore/progress.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_e2e_testing/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_e2e_testing/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_e2e_testing/progress.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_m1_startup/BRIEFING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_m1_startup/ORIGINAL_REQUEST.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.agents/worker_m1_startup/progress.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.env.example +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.github/workflows/tests.yml +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/.gitignore +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/CODE_OF_CONDUCT.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/CONTRIBUTING.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/LICENSE +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/PROJECT.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/SECURITY.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/SUPPORT.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/TEST_INFRA.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/TEST_READY.md +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/__main__.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/changelog_generator.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/code_reviewer.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/commit_generator.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/gitignore_generator.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/history_analyzer.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/intent_parser.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/pr_drafter.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/changelog.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/commit.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/conflict.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/doctor.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/explain.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/ignore.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/intent.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/pr.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/rebase.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/review.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/search.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/prompts/undo.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ai/rebase_helper.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/diagnostics.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/hooks.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/core/safety.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/banner.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/dashboard.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/display.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/prompts.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/ui/themes.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/utils/conflict_parser.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/utils/diff_parser.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/ace/utils/json_utils.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/conftest.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/conftest.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/test_tier1_features.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/test_tier3_combinations.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/e2e/test_tier4_workloads.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_changelog_generator.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_code_reviewer.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_conflict_resolver.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_diagnostics.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_diff_trimmer.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_git_ops.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_help.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_history_analyzer.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_hooks.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_ignore.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_intent_parser.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_llm_factory.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_pr_drafter.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_rebase_helper.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_safety.py +0 -0
- {ace_git_copilot-0.3.2 → ace_git_copilot-0.3.4}/tests/test_search.py +0 -0
- {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.
|
|
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
|
-
|
|
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":
|
|
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"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
196
|
+
import shlex
|
|
197
|
+
args = shlex.split(cmd)[1:]
|
|
197
198
|
res_proc = subprocess.run(
|
|
198
|
-
[sys.executable, "-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
+
fd, temp_path = tempfile.mkstemp(dir=DEFAULT_CONFIG_DIR, prefix="config-", suffix=".tmp")
|
|
163
|
+
try:
|
|
164
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
165
|
+
toml.dump(config.to_dict(), f)
|
|
166
|
+
os.replace(temp_path, DEFAULT_CONFIG_PATH)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
if os.path.exists(temp_path):
|
|
169
|
+
os.unlink(temp_path)
|
|
170
|
+
raise e
|
|
162
171
|
except Exception as e:
|
|
163
172
|
raise IOError(f"Could not save configuration: {e}")
|
|
@@ -61,7 +61,11 @@ class RepoContext:
|
|
|
61
61
|
|
|
62
62
|
def check_merge_rebase_state(self) -> Dict[str, Any]:
|
|
63
63
|
"""Check if the repository is currently in a merge, rebase, or cherry-pick state."""
|
|
64
|
-
|
|
64
|
+
try:
|
|
65
|
+
git_dir = Path(self.git_ops.repo.git_dir)
|
|
66
|
+
except Exception:
|
|
67
|
+
git_dir = Path(self.git_ops.working_dir) / ".git"
|
|
68
|
+
|
|
65
69
|
state = {
|
|
66
70
|
"in_progress": False,
|
|
67
71
|
"type": None, # 'merge', 'rebase', 'cherry-pick', 'revert'
|
|
@@ -118,7 +118,13 @@ class GitOps:
|
|
|
118
118
|
def get_branches(self, remote: bool = False) -> List[str]:
|
|
119
119
|
"""List local or remote branches."""
|
|
120
120
|
if remote:
|
|
121
|
-
|
|
121
|
+
branches = []
|
|
122
|
+
for r in self.repo.remotes:
|
|
123
|
+
try:
|
|
124
|
+
branches.extend([b.name for b in r.refs])
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
return branches
|
|
122
128
|
return [b.name for b in self.repo.branches]
|
|
123
129
|
|
|
124
130
|
def get_conflicts(self) -> List[str]:
|
|
@@ -153,14 +159,34 @@ class GitOps:
|
|
|
153
159
|
|
|
154
160
|
def execute(self, command: str) -> str:
|
|
155
161
|
"""Run an arbitrary git command safely (the command string shouldn't include 'git ')."""
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
import shlex
|
|
163
|
+
if not command.strip():
|
|
164
|
+
raise ValueError("Empty Git command provided.")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
parts = shlex.split(command.strip())
|
|
168
|
+
except ValueError:
|
|
169
|
+
parts = command.strip().split()
|
|
170
|
+
|
|
158
171
|
if parts and parts[0] == "git":
|
|
159
172
|
parts = parts[1:]
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
|
|
174
|
+
if not parts:
|
|
175
|
+
raise ValueError("Empty Git command provided.")
|
|
176
|
+
|
|
177
|
+
subcommand = parts[0]
|
|
178
|
+
args = parts[1:]
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
git_func = getattr(self.repo.git, subcommand.replace("-", "_"))
|
|
182
|
+
return git_func(*args)
|
|
183
|
+
except AttributeError:
|
|
184
|
+
try:
|
|
185
|
+
return self.repo.git.execute(["git", subcommand] + args)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
raise ValueError(f"Failed to execute Git command '{command}': {e}")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
raise ValueError(f"Failed to execute Git command '{command}': {e}")
|
|
164
190
|
|
|
165
191
|
def get_upstream_tracking(self) -> Optional[str]:
|
|
166
192
|
"""Get the remote tracking branch of the current branch, e.g. 'origin/main'."""
|
|
@@ -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"
|