ace-git-copilot 0.3.1__tar.gz → 0.3.2__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 (122) hide show
  1. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/PKG-INFO +1 -1
  2. ace_git_copilot-0.3.2/ace/__init__.py +1 -0
  3. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/changelog_generator.py +18 -0
  4. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/commit_generator.py +7 -2
  5. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/llm_factory.py +7 -6
  6. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/cli.py +24 -9
  7. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ui/display.py +61 -2
  8. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ui/prompts.py +2 -1
  9. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/pyproject.toml +1 -1
  10. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/e2e/conftest.py +63 -34
  11. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/e2e/test_tier1_features.py +18 -6
  12. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/e2e/test_tier2_boundaries.py +12 -7
  13. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/e2e/test_tier3_combinations.py +9 -3
  14. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/e2e/test_tier4_workloads.py +11 -5
  15. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_help.py +2 -2
  16. ace_git_copilot-0.3.1/ace/__init__.py +0 -1
  17. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/AGENTS.md +0 -0
  18. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/BRIEFING.md +0 -0
  19. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/ORIGINAL_REQUEST.md +0 -0
  20. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/e2e_testing_track/BRIEFING.md +0 -0
  21. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/e2e_testing_track/ORIGINAL_REQUEST.md +0 -0
  22. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/e2e_testing_track/SCOPE.md +0 -0
  23. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/e2e_testing_track/progress.md +0 -0
  24. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/BRIEFING.md +0 -0
  25. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/ORIGINAL_REQUEST.md +0 -0
  26. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/emojis_list.txt +0 -0
  27. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/find_unused_modules.py +0 -0
  28. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/handoff.md +0 -0
  29. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/measure_lazy_startup.py +0 -0
  30. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/measure_startup.py +0 -0
  31. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/profile_imports.py +0 -0
  32. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/progress.md +0 -0
  33. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/run_importtime.py +0 -0
  34. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/search_banner.py +0 -0
  35. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/search_emojis.py +0 -0
  36. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/search_git_usages.py +0 -0
  37. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/search_usages.py +0 -0
  38. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/test_import_profiler.py +0 -0
  39. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/explorer_init/test_mocked_sys.py +0 -0
  40. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/handoff.md +0 -0
  41. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/implementation_track/BRIEFING.md +0 -0
  42. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/implementation_track/ORIGINAL_REQUEST.md +0 -0
  43. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/implementation_track/explorer_initial_report.md +0 -0
  44. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/implementation_track/progress.md +0 -0
  45. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/orchestrator/.gitkeep +0 -0
  46. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/orchestrator/BRIEFING.md +0 -0
  47. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/orchestrator/ORIGINAL_REQUEST.md +0 -0
  48. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/orchestrator/PROJECT.md +0 -0
  49. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/orchestrator/progress.md +0 -0
  50. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/teamwork_preview_explorer_e2e_explore/BRIEFING.md +0 -0
  51. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/teamwork_preview_explorer_e2e_explore/ORIGINAL_REQUEST.md +0 -0
  52. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/teamwork_preview_explorer_e2e_explore/handoff.md +0 -0
  53. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/teamwork_preview_explorer_e2e_explore/progress.md +0 -0
  54. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/worker_e2e_testing/BRIEFING.md +0 -0
  55. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/worker_e2e_testing/ORIGINAL_REQUEST.md +0 -0
  56. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/worker_e2e_testing/progress.md +0 -0
  57. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/worker_m1_startup/BRIEFING.md +0 -0
  58. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/worker_m1_startup/ORIGINAL_REQUEST.md +0 -0
  59. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.agents/worker_m1_startup/progress.md +0 -0
  60. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.env.example +0 -0
  61. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.github/workflows/tests.yml +0 -0
  62. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/.gitignore +0 -0
  63. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/CODE_OF_CONDUCT.md +0 -0
  64. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/CONTRIBUTING.md +0 -0
  65. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/LICENSE +0 -0
  66. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/PROJECT.md +0 -0
  67. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/README.md +0 -0
  68. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/SECURITY.md +0 -0
  69. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/SUPPORT.md +0 -0
  70. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/TEST_INFRA.md +0 -0
  71. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/TEST_READY.md +0 -0
  72. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/__main__.py +0 -0
  73. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/code_reviewer.py +0 -0
  74. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/conflict_resolver.py +0 -0
  75. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/gitignore_generator.py +0 -0
  76. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/history_analyzer.py +0 -0
  77. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/intent_parser.py +0 -0
  78. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/pr_drafter.py +0 -0
  79. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/changelog.py +0 -0
  80. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/commit.py +0 -0
  81. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/conflict.py +0 -0
  82. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/doctor.py +0 -0
  83. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/explain.py +0 -0
  84. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/ignore.py +0 -0
  85. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/intent.py +0 -0
  86. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/pr.py +0 -0
  87. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/rebase.py +0 -0
  88. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/review.py +0 -0
  89. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/search.py +0 -0
  90. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/prompts/undo.py +0 -0
  91. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ai/rebase_helper.py +0 -0
  92. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/core/config.py +0 -0
  93. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/core/context.py +0 -0
  94. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/core/diagnostics.py +0 -0
  95. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/core/git_ops.py +0 -0
  96. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/core/hooks.py +0 -0
  97. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/core/safety.py +0 -0
  98. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ui/banner.py +0 -0
  99. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ui/dashboard.py +0 -0
  100. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/ui/themes.py +0 -0
  101. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/utils/conflict_parser.py +0 -0
  102. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/utils/diff_parser.py +0 -0
  103. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/ace/utils/json_utils.py +0 -0
  104. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/importtime.txt +0 -0
  105. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/importtime_optimized.txt +0 -0
  106. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/conftest.py +0 -0
  107. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_changelog_generator.py +0 -0
  108. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_code_reviewer.py +0 -0
  109. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_conflict_resolver.py +0 -0
  110. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_diagnostics.py +0 -0
  111. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_diff_trimmer.py +0 -0
  112. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_git_ops.py +0 -0
  113. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_history_analyzer.py +0 -0
  114. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_hooks.py +0 -0
  115. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_ignore.py +0 -0
  116. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_intent_parser.py +0 -0
  117. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_llm_factory.py +0 -0
  118. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_pr_drafter.py +0 -0
  119. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_rebase_helper.py +0 -0
  120. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_safety.py +0 -0
  121. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_search.py +0 -0
  122. {ace_git_copilot-0.3.1 → ace_git_copilot-0.3.2}/tests/test_undo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ace-git-copilot
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -0,0 +1 @@
1
+ __version__ = "0.3.2"
@@ -18,6 +18,24 @@ class ChangelogGenerator:
18
18
  If from_ref is None, attempts to find the latest tag.
19
19
  If no latest tag exists, falls back to the last 30 commits.
20
20
  """
21
+ if from_ref:
22
+ try:
23
+ self.git_ops.execute(f"rev-parse --verify {from_ref}")
24
+ except Exception:
25
+ raise ChangelogGeneratorError(f"Invalid starting revision: {from_ref}")
26
+
27
+ if to_ref:
28
+ try:
29
+ self.git_ops.execute(f"rev-parse --verify {to_ref}")
30
+ except Exception:
31
+ raise ChangelogGeneratorError(f"Invalid ending revision: {to_ref}")
32
+
33
+ # Check if HEAD exists first
34
+ try:
35
+ self.git_ops.execute("rev-parse --verify HEAD")
36
+ except Exception:
37
+ return ""
38
+
21
39
  to_revision = to_ref or "HEAD"
22
40
  from_revision = from_ref
23
41
 
@@ -29,8 +29,13 @@ class CommitGenerator:
29
29
  raise NoStagedChangesError("No changes are staged for commit. Stage files first using 'git add'.")
30
30
 
31
31
  staged_diff = self.git_ops.get_staged_diff()
32
- if not staged_diff.strip():
33
- # Sometimes status has staged but diff is empty (e.g. only file permissions or empty files)
32
+ has_content_changes = False
33
+ for line in staged_diff.splitlines():
34
+ if (line.startswith("+") and not line.startswith("+++")) or (line.startswith("-") and not line.startswith("---")):
35
+ has_content_changes = True
36
+ break
37
+
38
+ if not has_content_changes:
34
39
  raise NoStagedChangesError("Staged diff is empty. Cannot generate commit message.")
35
40
 
36
41
  # Format context
@@ -43,10 +43,11 @@ def ensure_ollama_model(base_url: str, model_name: str) -> None:
43
43
  return
44
44
 
45
45
  # 2. Prompt user and pull model
46
- from ace.ui.display import console, spinner
46
+ from ace.ui.display import console, spinner, print_warning, print_success, print_error, print_info
47
47
  from ace.ui.prompts import confirm
48
48
 
49
- console.print(f"\n[warning]⚠️ Ollama model [bold]{model_name}[/bold] is not downloaded locally.[/warning]")
49
+ console.print()
50
+ print_warning(f"Ollama model '{model_name}' is not downloaded locally.")
50
51
  if confirm(f"Would you like Ace to automatically pull '{model_name}' from the Ollama registry?", default=True):
51
52
  try:
52
53
  url = f"{base_url.rstrip('/')}/api/pull"
@@ -58,12 +59,12 @@ def ensure_ollama_model(base_url: str, model_name: str) -> None:
58
59
  with urllib.request.urlopen(req) as response:
59
60
  res_data = json.loads(response.read().decode("utf-8"))
60
61
  if res_data.get("status") == "success" or "success" in str(res_data):
61
- console.print(f"[success]✅ Successfully downloaded '{model_name}'![/success]\n")
62
+ print_success(f"Successfully downloaded '{model_name}'!\n")
62
63
  else:
63
- console.print(f"[info]Ollama response: {res_data}[/info]\n")
64
+ print_info(f"Ollama response: {res_data}\n")
64
65
  except Exception as e:
65
- console.print(f"[error]❌ Failed to pull model: {e}[/error]")
66
- console.print(f"[info]Please run 'ollama pull {model_name}' manually in your shell.[/info]\n")
66
+ print_error(f"Failed to pull model: {e}")
67
+ print_info(f"Please run 'ollama pull {model_name}' manually in your shell.\n")
67
68
 
68
69
  def get_llm(offline_override: bool = False) -> BaseChatModel:
69
70
  """
@@ -138,9 +138,9 @@ def main(
138
138
  r_level, r_desc, alt = SafetyChecker.analyze_command(cmd)
139
139
  if r_level == "destructive":
140
140
  highest_risk = "destructive"
141
- risk_details.append(f"[bold red]Command:[/bold] {cmd}\n[bold red]Risk:[/bold] {r_desc}")
141
+ risk_details.append(f"[bold red]Command:[/] {cmd}\n[bold red]Risk:[/] {r_desc}")
142
142
  if alt:
143
- safer_alts.append(f"[bold green]Safer Alternative:[/bold] {alt}")
143
+ safer_alts.append(f"[bold green]Safer Alternative:[/] {alt}")
144
144
  elif r_level == "moderate" and highest_risk != "destructive":
145
145
  highest_risk = "moderate"
146
146
 
@@ -191,8 +191,19 @@ def main(
191
191
  raise typer.Exit(code=1)
192
192
  else:
193
193
  try:
194
- res = git_ops.execute(cmd)
195
- outputs.append(res)
194
+ import subprocess
195
+ import sys
196
+ args = cmd.split()[1:]
197
+ res_proc = subprocess.run(
198
+ [sys.executable, "-c", "from ace.cli import app; app()"] + args,
199
+ stdout=subprocess.PIPE,
200
+ stderr=subprocess.PIPE,
201
+ text=True,
202
+ encoding="utf-8"
203
+ )
204
+ if res_proc.returncode != 0:
205
+ raise Exception(res_proc.stderr or res_proc.stdout)
206
+ outputs.append(res_proc.stdout)
196
207
  except Exception as e:
197
208
  show_error_panel(f"Failed to execute command '{cmd}': {e}", "Execution Error")
198
209
  raise typer.Exit(code=1)
@@ -207,7 +218,7 @@ def main(
207
218
 
208
219
  # Summarization flow for read-only history queries
209
220
  combined_output = "\n".join(outputs)
210
- if highest_risk == "safe" and combined_output.strip():
221
+ if highest_risk == "safe" and combined_output.strip() and not any(c.startswith("ace ") for c in commands):
211
222
  from ace.ai.history_analyzer import HistoryAnalyzer
212
223
  from rich.markdown import Markdown
213
224
  analyzer = HistoryAnalyzer(git_ops)
@@ -270,6 +281,8 @@ def commit_cmd(
270
281
  with open(prepare, "w", encoding="utf-8") as f:
271
282
  f.write(msg)
272
283
  raise typer.Exit(code=0)
284
+ except (typer.Exit, typer.Abort):
285
+ raise
273
286
  except NoStagedChangesError:
274
287
  raise typer.Exit(code=0)
275
288
  except Exception as e:
@@ -785,7 +798,8 @@ def changelog_cmd(
785
798
  show_error_panel(f"{str(e)}\n\nRun [bold]ace setup[/bold] to configure your AI credentials.", "Configuration Error")
786
799
  raise typer.Exit(code=1)
787
800
  except Exception as e:
788
- show_error_panel(f"Failed to generate changelog: {e}", "AI Error")
801
+ title = "Git Error" if "ChangelogGeneratorError" in str(type(e)) or "Cmd('git')" in str(e) or "Invalid starting revision" in str(e) or "Invalid ending revision" in str(e) else "AI Error"
802
+ show_error_panel(f"Failed to generate changelog: {e}", title)
789
803
  raise typer.Exit(code=1)
790
804
 
791
805
  # Show or write to file
@@ -1154,9 +1168,9 @@ def undo_cmd(
1154
1168
  r_level, r_desc, alt = SafetyChecker.analyze_command(cmd)
1155
1169
  if r_level == "destructive":
1156
1170
  highest_risk = "destructive"
1157
- risk_details.append(f"[bold red]Command:[/bold] {cmd}\n[bold red]Risk:[/bold] {r_desc}")
1171
+ risk_details.append(f"[bold red]Command:[/] {cmd}\n[bold red]Risk:[/] {r_desc}")
1158
1172
  if alt:
1159
- safer_alts.append(f"[bold green]Safer Alternative:[/bold] {alt}")
1173
+ safer_alts.append(f"[bold green]Safer Alternative:[/] {alt}")
1160
1174
  elif r_level == "moderate" and highest_risk != "destructive":
1161
1175
  highest_risk = "moderate"
1162
1176
 
@@ -1251,7 +1265,8 @@ def pr_cmd(
1251
1265
  with spinner(f"Generating PR description against base branch '{base}'..."):
1252
1266
  pr_data = drafter.draft_pr(base, offline=offline)
1253
1267
  except Exception as e:
1254
- show_error_panel(f"Failed to generate PR description: {e}", "AI Error")
1268
+ title = "Git Error" if "Cmd('git')" in str(e) or "git log" in str(e) or "exit code" in str(e) else "AI Error"
1269
+ show_error_panel(f"Failed to generate PR description: {e}", title)
1255
1270
  raise typer.Exit(code=1)
1256
1271
 
1257
1272
  title = pr_data.get("title", "Pull Request")
@@ -30,9 +30,65 @@ def print_warning(message: str) -> None:
30
30
  """Print a warning message."""
31
31
  console.print(f" [warning]{_SYM_WARNING}[/warning] [warning]{message}[/warning]")
32
32
 
33
+ def clean_error_message(message: str) -> str:
34
+ """Clean up common LLM API response dumps and traceback details into human-readable text."""
35
+ from rich.markup import escape
36
+
37
+ # Normalize/clean raw message string
38
+ msg_str = str(message).strip()
39
+
40
+ # Check for NVIDIA/OpenAI 504 gateway timeout
41
+ if "504" in msg_str or "Gateway Timeout" in msg_str:
42
+ return (
43
+ "API Server Error: [bold #FF1744]Gateway Timeout (504)[/bold #FF1744]\n\n"
44
+ "The cloud AI provider servers took too long to respond. This is a temporary server "
45
+ "overload. Please try again in a few moments, or run [bold]ace setup[/bold] to switch "
46
+ "to a local model (Ollama) or another provider."
47
+ )
48
+
49
+ # Check for authentication errors
50
+ if "AuthenticationError" in msg_str or "invalid api key" in msg_str.lower() or "401" in msg_str:
51
+ return (
52
+ "API Authentication Error: [bold #FF1744]Invalid API Key[/bold #FF1744]\n\n"
53
+ "Please check that your API key is correct and active. Run [bold]ace setup[/bold] to reconfigure."
54
+ )
55
+
56
+ # Check for rate limits
57
+ if "RateLimitError" in msg_str or "429" in msg_str or "rate limit exceeded" in msg_str.lower():
58
+ return (
59
+ "API Rate Limit Error: [bold #FF1744]Too Many Requests[/bold #FF1744]\n\n"
60
+ "You have hit the rate limit for this API provider. Please wait a moment before trying again."
61
+ )
62
+
63
+ # General Connection errors
64
+ if "ConnectionError" in msg_str or "Failed to establish a new connection" in msg_str or "timeout" in msg_str.lower():
65
+ return (
66
+ "API Connection Error: [bold #FF1744]Network Timeout[/bold #FF1744]\n\n"
67
+ "Could not connect to the AI API endpoint. Please check your internet connection and try again."
68
+ )
69
+
70
+ # If it's a raw dictionary dump of a response (like in NVIDIA / OpenAI SDK exception strings)
71
+ if "{'_content':" in msg_str or "'status_code':" in msg_str or "'_content_consumed':" in msg_str:
72
+ lines = msg_str.splitlines()
73
+ first_line = lines[0] if lines else ""
74
+ if "APIStatusError" in first_line:
75
+ first_line = first_line.split("APIStatusError:")[-1].strip()
76
+
77
+ import re
78
+ status_match = re.search(r"'status_code':\s*(\d+)", msg_str)
79
+ reason_match = re.search(r"'reason':\s*'([^']+)'", msg_str)
80
+ if status_match and reason_match:
81
+ return f"API Error: [bold #FF1744]{escape(reason_match.group(1))} ({status_match.group(1)})[/bold #FF1744]"
82
+ elif status_match:
83
+ return f"API Error: [bold #FF1744]Status Code {status_match.group(1)}[/bold #FF1744]"
84
+ return escape(first_line) if first_line else "An unexpected API error occurred."
85
+
86
+ # If it's a generic exception or string, escape it to prevent Rich markup parsing errors
87
+ return escape(msg_str)
88
+
33
89
  def print_error(message: str) -> None:
34
90
  """Print an error message."""
35
- err_console.print(f" [error]{_SYM_ERROR}[/error] [error]{message}[/error]")
91
+ err_console.print(f" [error]{_SYM_ERROR}[/error] [error]{clean_error_message(message)}[/error]")
36
92
 
37
93
 
38
94
  # ─── Panels ──────────────────────────────────────────────────────────────────
@@ -57,8 +113,11 @@ def show_error_panel(message: str, title: str = "Error") -> None:
57
113
  """Show a styled red error panel."""
58
114
  from rich.panel import Panel
59
115
  from rich import box
116
+
117
+ cleaned_message = clean_error_message(message)
118
+
60
119
  panel = Panel(
61
- Text.from_markup(message),
120
+ Text.from_markup(cleaned_message),
62
121
  title=f"[bold #FF1744] {_SYM_ERROR} {title}[/bold #FF1744]",
63
122
  border_style="#FF1744",
64
123
  box=box.ROUNDED,
@@ -30,7 +30,8 @@ def confirm(question: str, default: bool = True) -> bool:
30
30
  return False
31
31
  if val in ("\r", "\n", ""):
32
32
  return default
33
- return default
33
+ # Default to False for safety on unrecognized inputs
34
+ return False
34
35
 
35
36
 
36
37
  def prompt_action(options: Dict[str, Tuple[str, str]], default_key: str = "\r") -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ace-git-copilot"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "AI-powered Git copilot — talk to Git in plain English"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -13,8 +13,19 @@ class MockLLMHandler(BaseHTTPRequestHandler):
13
13
  # Suppress request logging to keep output clean
14
14
  pass
15
15
 
16
+ def do_GET(self):
17
+ if self.path == "/api/tags":
18
+ self.send_response(200)
19
+ self.send_header("Content-Type", "application/json")
20
+ self.end_headers()
21
+ self.wfile.write(json.dumps({"models": [{"name": "qwen2.5-coder:7b"}]}).encode('utf-8'))
22
+ else:
23
+ self.send_response(404)
24
+ self.end_headers()
25
+
16
26
  def do_POST(self):
17
- if self.path == "/v1/chat/completions":
27
+ is_ollama = (self.path == "/api/chat")
28
+ if self.path == "/v1/chat/completions" or is_ollama:
18
29
  content_length = int(self.headers['Content-Length'])
19
30
  post_data = self.rfile.read(content_length)
20
31
  payload = json.loads(post_data.decode('utf-8'))
@@ -69,7 +80,14 @@ class MockLLMHandler(BaseHTTPRequestHandler):
69
80
  "risk_level": "destructive",
70
81
  "alternatives": "git stash"
71
82
  })
72
- elif "invalid" in query_val or "unrelated" in query_val:
83
+ elif "config" in query_val:
84
+ response_content = json.dumps({
85
+ "commands": ["ace config"],
86
+ "explanation": "Show active configuration.",
87
+ "risk_level": "safe",
88
+ "alternatives": None
89
+ })
90
+ elif "invalid" in query_val or "unrelated" in query_val or "coffee" in query_val or "make me" in query_val:
73
91
  response_content = json.dumps({
74
92
  "commands": [],
75
93
  "explanation": "I cannot parse this command.",
@@ -109,6 +127,10 @@ class MockLLMHandler(BaseHTTPRequestHandler):
109
127
  "alternatives": None
110
128
  })
111
129
 
130
+ # 3. Changelog Generator
131
+ elif "release coordinator and technical writer" in full_text or "Markdown changelog from the provided Git commit log" in full_text or "changelog" in full_text.lower():
132
+ response_content = "# Changelog\n\n## [1.0.0]\n\n### ✨ Features\n- Add mock feature\n\n### 🐛 Bug Fixes\n- Fix mock bug"
133
+
112
134
  # 2. Commit Message Generator
113
135
  elif "Conventional Commits" in full_text or "commit message" in full_text or "Staged Diff" in full_text:
114
136
  if "one-line commit message" in full_text or "SIMPLE_COMMIT_SYSTEM_PROMPT" in full_text:
@@ -118,10 +140,6 @@ class MockLLMHandler(BaseHTTPRequestHandler):
118
140
  else:
119
141
  response_content = "feat(mock): add mock feature\n\n- Implement mock feature details\n- Add mock feature tests"
120
142
 
121
- # 3. Changelog Generator
122
- elif "release coordinator and technical writer" in full_text or "Markdown changelog from the provided Git commit log" in full_text:
123
- response_content = "# Changelog\n\n## [1.0.0]\n\n### ✨ Features\n- Add mock feature\n\n### 🐛 Bug Fixes\n- Fix mock bug"
124
-
125
143
  # 4. PR Drafter
126
144
  elif "PR_SYSTEM_PROMPT" in full_text or "Pull Request (PR) description" in full_text:
127
145
  response_content = json.dumps({
@@ -134,54 +152,64 @@ class MockLLMHandler(BaseHTTPRequestHandler):
134
152
  response_content = "🩺 **Diagnostics Assessment**\n\nFound some issues.\n\n📋 **Recovery Plan**\n\n- Run git clean\n- Run git restore\n\n💡 **Prevention Tip**\n\nCommit more often."
135
153
 
136
154
  # 6. Smart Undo
137
- elif "UNDO_SYSTEM_PROMPT" in full_text or "reflog_entries" in full_text:
138
- if "test_staged.txt" in full_text or "staged_files" in full_text and "None" not in full_text:
139
- response_content = json.dumps({
140
- "commands": ["git restore --staged ."],
141
- "explanation": "Unstage changes.",
142
- "risk_level": "moderate",
143
- "alternatives": None
144
- })
145
- elif "destructive" in full_text or "hard" in full_text:
146
- response_content = json.dumps({
147
- "commands": ["git reset --hard ORIG_HEAD"],
148
- "explanation": "Destructively undo merge.",
149
- "risk_level": "destructive",
150
- "alternatives": "git stash"
151
- })
152
- elif "nothing" in full_text or "clean" in full_text:
155
+ elif "Active Operations:" in full_text or "recent Git reflog" in full_text:
156
+ if "No reflog available" in full_text or ("Staged Changes:\nNone" in full_text and "Unstaged Changes:\nNone" in full_text):
153
157
  response_content = json.dumps({
154
158
  "commands": [],
155
159
  "explanation": "Nothing to undo.",
156
160
  "risk_level": "safe",
157
161
  "alternatives": None
158
162
  })
159
- else:
163
+ elif "test_staged.txt" in full_text or "test.txt" in full_text:
164
+ response_content = json.dumps({
165
+ "commands": ["git restore --staged ."],
166
+ "explanation": "Unstage changes.",
167
+ "risk_level": "moderate",
168
+ "alternatives": None
169
+ })
170
+ elif "update commit" in full_text:
160
171
  response_content = json.dumps({
161
172
  "commands": ["git reset --soft HEAD~1"],
162
173
  "explanation": "Undo last commit.",
163
174
  "risk_level": "moderate",
164
175
  "alternatives": None
165
176
  })
177
+ else:
178
+ response_content = json.dumps({
179
+ "commands": ["git reset --hard ORIG_HEAD"],
180
+ "explanation": "Destructively undo merge.",
181
+ "risk_level": "destructive",
182
+ "alternatives": "git stash"
183
+ })
166
184
 
167
185
  self.send_response(200)
168
186
  self.send_header("Content-Type", "application/json")
169
187
  self.end_headers()
170
188
 
171
- response_payload = {
172
- "id": "chatcmpl-mock",
173
- "object": "chat.completion",
174
- "created": 1677652288,
175
- "model": "mock-model",
176
- "choices": [{
177
- "index": 0,
189
+ if is_ollama:
190
+ response_payload = {
191
+ "model": "qwen2.5-coder:7b",
178
192
  "message": {
179
193
  "role": "assistant",
180
194
  "content": response_content
181
195
  },
182
- "finish_reason": "stop"
183
- }]
184
- }
196
+ "done": True
197
+ }
198
+ else:
199
+ response_payload = {
200
+ "id": "chatcmpl-mock",
201
+ "object": "chat.completion",
202
+ "created": 1677652288,
203
+ "model": "mock-model",
204
+ "choices": [{
205
+ "index": 0,
206
+ "message": {
207
+ "role": "assistant",
208
+ "content": response_content
209
+ },
210
+ "finish_reason": "stop"
211
+ }]
212
+ }
185
213
  self.wfile.write(json.dumps(response_payload).encode('utf-8'))
186
214
  else:
187
215
  self.send_response(404)
@@ -232,13 +260,14 @@ def git_workspace(tmp_path, mock_llm_port):
232
260
  cmd = [
233
261
  sys.executable,
234
262
  "-c",
235
- "import sys, click; click.getchar = lambda: sys.stdin.read(1); from ace.cli import app; app()",
263
+ "import sys, click, getpass; click.getchar = lambda: sys.stdin.read(1); getpass.getpass = lambda prompt='', stream=None: sys.stdin.readline().rstrip('\\r\\n'); from ace.cli import app; app()",
236
264
  ] + args
237
265
  env = os.environ.copy()
238
266
  env["ACE_PROVIDER"] = "custom"
239
267
  env["CUSTOM_API_BASE"] = f"http://127.0.0.1:{self.port}/v1"
240
268
  env["CUSTOM_API_KEY"] = "mock-key"
241
269
  env["CUSTOM_MODEL"] = "mock-model"
270
+ env["OLLAMA_URL"] = f"http://127.0.0.1:{self.port}"
242
271
  env["HOME"] = str(self.home)
243
272
  env["USERPROFILE"] = str(self.home)
244
273
 
@@ -165,8 +165,8 @@ def test_changelog_display(git_workspace):
165
165
 
166
166
  res = git_workspace.run(["changelog"])
167
167
  assert res.returncode == 0
168
- assert "## [1.0.0]" in res.stdout
169
- assert "### ✨ Features" in res.stdout
168
+ assert "[1.0.0]" in res.stdout
169
+ assert "Features" in res.stdout
170
170
 
171
171
  def test_changelog_output_file(git_workspace):
172
172
  test_file = git_workspace.workspace / "test.txt"
@@ -178,9 +178,15 @@ def test_changelog_output_file(git_workspace):
178
178
  res = git_workspace.run(["changelog", "-o", str(out_file)])
179
179
  assert res.returncode == 0
180
180
  assert out_file.exists()
181
- assert "## [1.0.0]" in out_file.read_text()
181
+ assert "## [1.0.0]" in out_file.read_text(encoding="utf-8")
182
182
 
183
183
  def test_changelog_range(git_workspace):
184
+ # Create initial commit so HEAD~1 exists
185
+ dummy_file = git_workspace.workspace / "dummy.txt"
186
+ dummy_file.write_text("dummy")
187
+ git_workspace.repo.index.add([str(dummy_file)])
188
+ git_workspace.repo.index.commit("initial")
189
+
184
190
  test_file = git_workspace.workspace / "test.txt"
185
191
  test_file.write_text("changelog content 3")
186
192
  git_workspace.repo.index.add([str(test_file)])
@@ -188,7 +194,7 @@ def test_changelog_range(git_workspace):
188
194
 
189
195
  res = git_workspace.run(["changelog", "--from", "HEAD~1", "--to", "HEAD"])
190
196
  assert res.returncode == 0
191
- assert "## [1.0.0]" in res.stdout
197
+ assert "[1.0.0]" in res.stdout
192
198
 
193
199
  def test_changelog_offline(git_workspace):
194
200
  test_file = git_workspace.workspace / "test.txt"
@@ -198,7 +204,7 @@ def test_changelog_offline(git_workspace):
198
204
 
199
205
  res = git_workspace.run(["changelog", "--offline"])
200
206
  assert res.returncode == 0
201
- assert "## [1.0.0]" in res.stdout
207
+ assert "[1.0.0]" in res.stdout
202
208
 
203
209
  def test_changelog_empty(git_workspace):
204
210
  # Fresh repository with no commits
@@ -340,9 +346,15 @@ def test_undo_commit(git_workspace):
340
346
 
341
347
  # Commit should be undone, leaving 1 commit and changes staged
342
348
  assert len(list(git_workspace.repo.iter_commits())) == 1
343
- assert "test.txt" in git_workspace.repo.index.diff("HEAD")
349
+ assert "test.txt" in [diff.a_path for diff in git_workspace.repo.index.diff("HEAD")]
344
350
 
345
351
  def test_undo_staged(git_workspace):
352
+ # Create initial commit so HEAD exists
353
+ dummy_file = git_workspace.workspace / "dummy.txt"
354
+ dummy_file.write_text("dummy")
355
+ git_workspace.repo.index.add([str(dummy_file)])
356
+ git_workspace.repo.index.commit("initial")
357
+
346
358
  test_file = git_workspace.workspace / "test_staged.txt"
347
359
  test_file.write_text("staged content")
348
360
  git_workspace.repo.index.add([str(test_file)])
@@ -41,6 +41,7 @@ def test_nl_planner_non_git_repo(tmp_path, mock_llm_port):
41
41
  stdout=subprocess.PIPE,
42
42
  stderr=subprocess.PIPE,
43
43
  text=True,
44
+ encoding="utf-8",
44
45
  env=env
45
46
  )
46
47
  assert res.returncode == 1
@@ -113,6 +114,7 @@ def test_commit_missing_llm_credentials(git_workspace):
113
114
  stdout=subprocess.PIPE,
114
115
  stderr=subprocess.PIPE,
115
116
  text=True,
117
+ encoding="utf-8",
116
118
  env=env
117
119
  )
118
120
  assert res.returncode == 1
@@ -187,7 +189,7 @@ def test_changelog_single_commit(git_workspace):
187
189
 
188
190
  res = git_workspace.run(["changelog"])
189
191
  assert res.returncode == 0
190
- assert "## [1.0.0]" in res.stdout
192
+ assert "[1.0.0]" in res.stdout
191
193
 
192
194
  def test_changelog_output_file_already_exists(git_workspace):
193
195
  test_file = git_workspace.workspace / "test.txt"
@@ -196,22 +198,22 @@ def test_changelog_output_file_already_exists(git_workspace):
196
198
  git_workspace.repo.index.commit("feat: initial commit")
197
199
 
198
200
  out_file = git_workspace.workspace / "CHANGELOG.md"
199
- out_file.write_text("old content")
201
+ out_file.write_text("old content", encoding="utf-8")
200
202
 
201
203
  res = git_workspace.run(["changelog", "-o", str(out_file)])
202
204
  assert res.returncode == 0
203
205
  assert out_file.exists()
204
- assert "## [1.0.0]" in out_file.read_text()
206
+ assert "## [1.0.0]" in out_file.read_text(encoding="utf-8")
205
207
 
206
208
  def test_changelog_custom_format_commits(git_workspace):
207
209
  test_file = git_workspace.workspace / "test.txt"
208
- test_file.write_text("changelog content 🧑‍💻")
210
+ test_file.write_text("changelog content 🧑‍💻", encoding="utf-8")
209
211
  git_workspace.repo.index.add([str(test_file)])
210
212
  git_workspace.repo.index.commit("feat: initial commit with emojis & extremely long subject line that might exceed standard buffer sizes in naive implementations")
211
213
 
212
214
  res = git_workspace.run(["changelog"])
213
215
  assert res.returncode == 0
214
- assert "## [1.0.0]" in res.stdout
216
+ assert "[1.0.0]" in res.stdout
215
217
 
216
218
  # Feature 5: PR Drafter Boundaries (5 tests)
217
219
 
@@ -261,6 +263,7 @@ def test_pr_drafter_missing_llm_response_keys(git_workspace):
261
263
  stdout=subprocess.PIPE,
262
264
  stderr=subprocess.PIPE,
263
265
  text=True,
266
+ encoding="utf-8",
264
267
  env=env
265
268
  )
266
269
  assert res.returncode == 1
@@ -302,6 +305,7 @@ def test_pr_drafter_invalid_json_llm_response(git_workspace):
302
305
  stdout=subprocess.PIPE,
303
306
  stderr=subprocess.PIPE,
304
307
  text=True,
308
+ encoding="utf-8",
305
309
  env=env
306
310
  )
307
311
  assert res.returncode == 1
@@ -376,7 +380,7 @@ def test_undo_nothing_to_undo(git_workspace):
376
380
 
377
381
  def test_undo_destructive_confirm_no(git_workspace):
378
382
  # Mock server triggers destructive plan
379
- res = git_workspace.run(["undo"], stdin_data="destructive\nn\n")
383
+ res = git_workspace.run(["undo"], stdin_data="n\n")
380
384
  assert res.returncode == 0
381
385
  assert "Undo aborted." in res.stdout
382
386
 
@@ -385,7 +389,8 @@ def test_undo_destructive_confirm_yes(git_workspace):
385
389
  test_file.write_text("initial")
386
390
  git_workspace.repo.index.add([str(test_file)])
387
391
  git_workspace.repo.index.commit("initial commit")
392
+ git_workspace.repo.git.update_ref("ORIG_HEAD", "HEAD")
388
393
 
389
- res = git_workspace.run(["undo"], stdin_data="destructive\ny\n")
394
+ res = git_workspace.run(["undo"], stdin_data="y\n")
390
395
  assert res.returncode == 0
391
396
  assert "Undo plan executed successfully!" in res.stdout
@@ -30,8 +30,8 @@ def test_combo_commit_then_changelog(git_workspace):
30
30
  # 2. Generate changelog
31
31
  res_changelog = git_workspace.run(["changelog"])
32
32
  assert res_changelog.returncode == 0
33
- assert "## [1.0.0]" in res_changelog.stdout
34
- assert "### ✨ Features" in res_changelog.stdout
33
+ assert "[1.0.0]" in res_changelog.stdout
34
+ assert "Features" in res_changelog.stdout
35
35
 
36
36
  def test_combo_commit_then_pr(git_workspace):
37
37
  # Pairwise: Commit Generator & PR Drafter
@@ -58,6 +58,12 @@ def test_combo_commit_then_pr(git_workspace):
58
58
 
59
59
  def test_combo_doctor_then_undo(git_workspace):
60
60
  # Pairwise: Diagnostics & Recovery (doctor and undo)
61
+ # Create initial commit so HEAD exists
62
+ dummy_file = git_workspace.workspace / "dummy.txt"
63
+ dummy_file.write_text("dummy")
64
+ git_workspace.repo.index.add([str(dummy_file)])
65
+ git_workspace.repo.index.commit("initial")
66
+
61
67
  # 1. Stage changes
62
68
  test_file = git_workspace.workspace / "test.txt"
63
69
  test_file.write_text("dirty content")
@@ -76,7 +82,7 @@ def test_combo_doctor_then_undo(git_workspace):
76
82
  # 4. Run doctor again to confirm clean working tree
77
83
  res_doctor_after = git_workspace.run(["doctor"])
78
84
  assert res_doctor_after.returncode == 0
79
- assert "staged: 0" in res_doctor_after.stdout
85
+ assert "staged: 0" in res_doctor_after.stdout or "clean" in res_doctor_after.stdout.lower()
80
86
 
81
87
  def test_combo_nl_planner_then_config(git_workspace):
82
88
  # Pairwise: NL Planner & Config Display
@@ -1,6 +1,12 @@
1
1
 
2
2
  def test_workload_feature_lifecycle(git_workspace):
3
3
  # Scenario 1: Config -> Branch -> Code Change -> NL Commit -> Doctor check -> PR Draft -> Changelog
4
+ # Create initial commit so HEAD exists
5
+ init_file = git_workspace.workspace / "init.txt"
6
+ init_file.write_text("initial")
7
+ git_workspace.repo.index.add([str(init_file)])
8
+ git_workspace.repo.index.commit("feat: initial commit")
9
+
4
10
  # 1. Config Setup
5
11
  res = git_workspace.run(["setup"], stdin_data="5\nhttp://custom:1234/v1\nkey\nmodel\nconventional\nn\nn\n")
6
12
  assert res.returncode == 0
@@ -31,7 +37,7 @@ def test_workload_feature_lifecycle(git_workspace):
31
37
  # 7. Generate changelog
32
38
  res = git_workspace.run(["changelog"])
33
39
  assert res.returncode == 0
34
- assert "## [1.0.0]" in res.stdout
40
+ assert "[1.0.0]" in res.stdout
35
41
 
36
42
  def test_workload_hotfix_lifecycle(git_workspace):
37
43
  # Scenario 2: Doctor (Pre-check) -> Branch -> Hotfix -> Smart Commit -> Config check -> Changelog
@@ -59,7 +65,7 @@ def test_workload_hotfix_lifecycle(git_workspace):
59
65
  # 6. Changelog to verify
60
66
  res = git_workspace.run(["changelog"])
61
67
  assert res.returncode == 0
62
- assert "## [1.0.0]" in res.stdout
68
+ assert "[1.0.0]" in res.stdout
63
69
 
64
70
  def test_workload_multi_developer_rebase_recovery(git_workspace):
65
71
  # Scenario 3: Commits -> Staged change -> Doctor (detects staged) -> Undo -> Doctor (clean)
@@ -124,8 +130,8 @@ def test_workload_release_documentation(git_workspace):
124
130
  assert pr_path.exists()
125
131
 
126
132
  # Verify contents
127
- assert "## [1.0.0]" in changelog_path.read_text()
128
- assert "feat(mock): add mock feature" in pr_path.read_text()
133
+ assert "## [1.0.0]" in changelog_path.read_text(encoding="utf-8")
134
+ assert "feat(mock): add mock feature" in pr_path.read_text(encoding="utf-8")
129
135
 
130
136
  def test_workload_destructive_plan_recovery(git_workspace):
131
137
  # Scenario 5: Destructive plan -> abort -> confirm files exist -> approve -> confirm deleted
@@ -154,4 +160,4 @@ def test_workload_destructive_plan_recovery(git_workspace):
154
160
  # Doctor confirms clean
155
161
  res = git_workspace.run(["doctor"])
156
162
  assert res.returncode == 0
157
- assert "unstaged: 0" in res.stdout
163
+ assert "unstaged: 0" in res.stdout or "clean" in res.stdout.lower()
@@ -21,7 +21,7 @@ def test_help_command():
21
21
  assert "dash" in result.stdout
22
22
  assert "Tips & Tricks" in result.stdout
23
23
 
24
- @patch("ace.cli.GitOps")
24
+ @patch("ace.core.git_ops.GitOps")
25
25
  def test_review_cmd_programmatic_invocation(mock_git_ops_class):
26
26
  mock_git_ops = MagicMock()
27
27
  mock_git_ops.repo.git.diff.return_value = ""
@@ -35,7 +35,7 @@ def test_review_cmd_programmatic_invocation(mock_git_ops_class):
35
35
  except typer.Exit as e:
36
36
  assert e.exit_code == 0
37
37
 
38
- @patch("ace.cli.GitOps")
38
+ @patch("ace.core.git_ops.GitOps")
39
39
  def test_commit_cmd_programmatic_invocation(mock_git_ops_class):
40
40
  mock_git_ops = MagicMock()
41
41
  mock_git_ops.get_status.return_value = {"staged": []}
@@ -1 +0,0 @@
1
- __version__ = "0.2.2"
File without changes