gac 2.2.0__py3-none-any.whl → 2.4.0__py3-none-any.whl

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.

Potentially problematic release.


This version of gac might be problematic. Click here for more details.

gac/__version__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Version information for gac package."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.4.0"
gac/ai.py CHANGED
@@ -42,6 +42,8 @@ def generate_commit_message(
42
42
  max_tokens: int = EnvDefaults.MAX_OUTPUT_TOKENS,
43
43
  max_retries: int = EnvDefaults.MAX_RETRIES,
44
44
  quiet: bool = False,
45
+ is_group: bool = False,
46
+ skip_success_message: bool = False,
45
47
  ) -> str:
46
48
  """Generate a commit message using direct API calls to AI providers.
47
49
 
@@ -116,6 +118,8 @@ def generate_commit_message(
116
118
  max_tokens=max_tokens,
117
119
  max_retries=max_retries,
118
120
  quiet=quiet,
121
+ is_group=is_group,
122
+ skip_success_message=skip_success_message,
119
123
  )
120
124
  except AIError:
121
125
  # Re-raise AIError exceptions as-is to preserve error classification
@@ -123,3 +127,25 @@ def generate_commit_message(
123
127
  except Exception as e:
124
128
  logger.error(f"Failed to generate commit message: {e}")
125
129
  raise AIError.model_error(f"Failed to generate commit message: {e}") from e
130
+
131
+
132
+ def generate_grouped_commits(
133
+ model: str,
134
+ prompt: list[dict[str, str]],
135
+ temperature: float,
136
+ max_tokens: int,
137
+ max_retries: int,
138
+ quiet: bool = False,
139
+ skip_success_message: bool = False,
140
+ ) -> str:
141
+ """Generate grouped commits JSON response."""
142
+ return generate_commit_message(
143
+ model=model,
144
+ prompt=prompt,
145
+ temperature=temperature,
146
+ max_tokens=max_tokens,
147
+ max_retries=max_retries,
148
+ quiet=quiet,
149
+ is_group=True,
150
+ skip_success_message=skip_success_message,
151
+ )
gac/ai_utils.py CHANGED
@@ -83,6 +83,8 @@ def generate_with_retries(
83
83
  max_tokens: int,
84
84
  max_retries: int,
85
85
  quiet: bool = False,
86
+ is_group: bool = False,
87
+ skip_success_message: bool = False,
86
88
  ) -> str:
87
89
  """Generate content with retry logic using direct API calls."""
88
90
  # Parse model string to determine provider and actual model
@@ -121,10 +123,11 @@ def generate_with_retries(
121
123
  raise AIError.model_error("No messages provided for AI generation")
122
124
 
123
125
  # Set up spinner
126
+ message_type = "commit messages" if is_group else "commit message"
124
127
  if quiet:
125
128
  spinner = None
126
129
  else:
127
- spinner = Halo(text=f"Generating commit message with {provider} {model_name}...", spinner="dots")
130
+ spinner = Halo(text=f"Generating {message_type} with {provider} {model_name}...", spinner="dots")
128
131
  spinner.start()
129
132
 
130
133
  last_exception = None
@@ -132,7 +135,7 @@ def generate_with_retries(
132
135
 
133
136
  for attempt in range(max_retries):
134
137
  try:
135
- if not quiet and attempt > 0:
138
+ if not quiet and not skip_success_message and attempt > 0:
136
139
  if spinner:
137
140
  spinner.text = f"Retry {attempt + 1}/{max_retries} with {provider} {model_name}..."
138
141
  logger.info(f"Retry attempt {attempt + 1}/{max_retries}")
@@ -145,7 +148,10 @@ def generate_with_retries(
145
148
  content = provider_func(model=model_name, messages=messages, temperature=temperature, max_tokens=max_tokens)
146
149
 
147
150
  if spinner:
148
- spinner.succeed(f"Generated commit message with {provider} {model_name}")
151
+ if skip_success_message:
152
+ spinner.stop() # Stop spinner without showing success/failure
153
+ else:
154
+ spinner.succeed(f"Generated {message_type} with {provider} {model_name}")
149
155
 
150
156
  if content is not None and content.strip():
151
157
  return content.strip() # type: ignore[no-any-return]
@@ -160,8 +166,8 @@ def generate_with_retries(
160
166
 
161
167
  # For authentication and model errors, don't retry
162
168
  if error_type in ["authentication", "model"]:
163
- if spinner:
164
- spinner.fail(f"Failed to generate commit message with {provider} {model_name}")
169
+ if spinner and not skip_success_message:
170
+ spinner.fail(f"Failed to generate {message_type} with {provider} {model_name}")
165
171
 
166
172
  # Create the appropriate error type based on classification
167
173
  if error_type == "authentication":
@@ -172,23 +178,32 @@ def generate_with_retries(
172
178
  if attempt < max_retries - 1:
173
179
  # Exponential backoff
174
180
  wait_time = 2**attempt
175
- if not quiet:
176
- logger.warning(f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}")
177
-
178
- if spinner:
181
+ if not quiet and not skip_success_message:
182
+ if attempt == 0:
183
+ logger.warning(f"AI generation failed, retrying in {wait_time}s: {str(e)}")
184
+ else:
185
+ logger.warning(
186
+ f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}"
187
+ )
188
+
189
+ if spinner and not skip_success_message:
179
190
  for i in range(wait_time, 0, -1):
180
191
  spinner.text = f"Retry {attempt + 1}/{max_retries} in {i}s..."
181
192
  time.sleep(1)
182
193
  else:
183
194
  time.sleep(wait_time)
184
195
  else:
185
- logger.error(f"AI generation failed after {max_retries} attempts: {str(e)}")
196
+ num_retries = max_retries
197
+ retry_word = "retry" if num_retries == 1 else "retries"
198
+ logger.error(f"AI generation failed after {num_retries} {retry_word}: {str(e)}")
186
199
 
187
- if spinner:
188
- spinner.fail(f"Failed to generate commit message with {provider} {model_name}")
200
+ if spinner and not skip_success_message:
201
+ spinner.fail(f"Failed to generate {message_type} with {provider} {model_name}")
189
202
 
190
203
  # If we get here, all retries failed - use the last classified error type
191
- error_message = f"Failed to generate commit message after {max_retries} attempts"
204
+ num_retries = max_retries
205
+ retry_word = "retry" if num_retries == 1 else "retries"
206
+ error_message = f"Failed to generate {message_type} after {num_retries} {retry_word}"
192
207
  if last_error_type == "authentication":
193
208
  raise AIError.authentication_error(error_message) from last_exception
194
209
  elif last_error_type == "rate_limit":
gac/cli.py CHANGED
@@ -17,6 +17,7 @@ from gac.constants import Languages, Logging
17
17
  from gac.diff_cli import diff as diff_cli
18
18
  from gac.errors import handle_error
19
19
  from gac.init_cli import init as init_cli
20
+ from gac.init_cli import model as model_cli
20
21
  from gac.language_cli import language as language_cli
21
22
  from gac.main import main
22
23
  from gac.utils import setup_logging
@@ -28,6 +29,7 @@ logger = logging.getLogger(__name__)
28
29
  @click.group(invoke_without_command=True, context_settings={"ignore_unknown_options": True})
29
30
  # Git workflow options
30
31
  @click.option("--add-all", "-a", is_flag=True, help="Stage all changes before committing")
32
+ @click.option("--group", "-g", is_flag=True, help="Group changes into multiple logical commits")
31
33
  @click.option("--push", "-p", is_flag=True, help="Push changes to remote after committing")
32
34
  @click.option("--dry-run", is_flag=True, help="Dry run the commit workflow")
33
35
  @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
@@ -70,6 +72,7 @@ logger = logging.getLogger(__name__)
70
72
  def cli(
71
73
  ctx: click.Context,
72
74
  add_all: bool = False,
75
+ group: bool = False,
73
76
  log_level: str = str(config["log_level"]),
74
77
  one_liner: bool = False,
75
78
  push: bool = False,
@@ -109,6 +112,7 @@ def cli(
109
112
  try:
110
113
  main(
111
114
  stage_all=add_all,
115
+ group=group,
112
116
  model=model,
113
117
  hint=hint,
114
118
  one_liner=one_liner,
@@ -131,6 +135,7 @@ def cli(
131
135
 
132
136
  ctx.obj = {
133
137
  "add_all": add_all,
138
+ "group": group,
134
139
  "log_level": log_level,
135
140
  "one_liner": one_liner,
136
141
  "push": push,
@@ -150,9 +155,10 @@ def cli(
150
155
 
151
156
 
152
157
  cli.add_command(config_cli)
158
+ cli.add_command(diff_cli)
153
159
  cli.add_command(init_cli)
154
160
  cli.add_command(language_cli)
155
- cli.add_command(diff_cli)
161
+ cli.add_command(model_cli)
156
162
 
157
163
 
158
164
  @click.command(context_settings=language_cli.context_settings)
gac/config.py CHANGED
@@ -41,6 +41,7 @@ def load_config() -> dict[str, str | int | float | bool | None]:
41
41
  "system_prompt_path": os.getenv("GAC_SYSTEM_PROMPT_PATH"),
42
42
  "language": os.getenv("GAC_LANGUAGE"),
43
43
  "translate_prefixes": os.getenv("GAC_TRANSLATE_PREFIXES", "false").lower() in ("true", "1", "yes", "on"),
44
+ "rtl_confirmed": os.getenv("GAC_RTL_CONFIRMED", "false").lower() in ("true", "1", "yes", "on"),
44
45
  }
45
46
 
46
47
  return config
gac/constants.py CHANGED
@@ -20,7 +20,7 @@ class EnvDefaults:
20
20
 
21
21
  MAX_RETRIES: int = 3
22
22
  TEMPERATURE: float = 1
23
- MAX_OUTPUT_TOKENS: int = 1024 # includes reasoning tokens
23
+ MAX_OUTPUT_TOKENS: int = 4096 # includes reasoning tokens
24
24
  WARNING_LIMIT_TOKENS: int = 32768
25
25
  ALWAYS_INCLUDE_SCOPE: bool = False
26
26
  SKIP_SECRET_SCAN: bool = False
gac/git.py CHANGED
@@ -14,6 +14,63 @@ from gac.utils import run_subprocess
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
+ def run_subprocess_with_encoding_fallback(
18
+ command: list[str], silent: bool = False, timeout: int = 60
19
+ ) -> subprocess.CompletedProcess:
20
+ """Run subprocess with encoding fallback, returning full CompletedProcess object.
21
+
22
+ This is used for cases where we need both stdout and stderr separately,
23
+ like pre-commit and lefthook hook execution.
24
+
25
+ Args:
26
+ command: List of command arguments
27
+ silent: If True, suppress debug logging
28
+ timeout: Command timeout in seconds
29
+
30
+ Returns:
31
+ CompletedProcess object with stdout, stderr, and returncode
32
+ """
33
+ from gac.utils import get_safe_encodings
34
+
35
+ encodings = get_safe_encodings()
36
+ last_exception = None
37
+
38
+ for encoding in encodings:
39
+ try:
40
+ if not silent:
41
+ logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
42
+
43
+ result = subprocess.run(
44
+ command,
45
+ capture_output=True,
46
+ text=True,
47
+ check=False,
48
+ timeout=timeout,
49
+ encoding=encoding,
50
+ errors="replace",
51
+ )
52
+ return result
53
+ except UnicodeError as e:
54
+ last_exception = e
55
+ if not silent:
56
+ logger.debug(f"Failed to decode with {encoding}: {e}")
57
+ continue
58
+ except subprocess.TimeoutExpired:
59
+ raise
60
+ except Exception as e:
61
+ if not silent:
62
+ logger.debug(f"Command error: {e}")
63
+ # Try next encoding for non-timeout errors
64
+ last_exception = e
65
+ continue
66
+
67
+ # If we get here, all encodings failed
68
+ if last_exception:
69
+ raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
70
+ else:
71
+ raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
72
+
73
+
17
74
  def run_git_command(args: list[str], silent: bool = False, timeout: int = 30) -> str:
18
75
  """Run a git command and return the output."""
19
76
  command = ["git"] + args
@@ -50,6 +107,48 @@ def get_staged_files(file_type: str | None = None, existing_only: bool = False)
50
107
  return []
51
108
 
52
109
 
110
+ def get_staged_status() -> str:
111
+ """Get formatted status of staged files only, excluding unstaged/untracked files.
112
+
113
+ Returns:
114
+ Formatted status string with M/A/D/R indicators
115
+ """
116
+ try:
117
+ output = run_git_command(["diff", "--name-status", "--staged"])
118
+ if not output:
119
+ return "No changes staged for commit."
120
+
121
+ status_map = {
122
+ "M": "modified",
123
+ "A": "new file",
124
+ "D": "deleted",
125
+ "R": "renamed",
126
+ "C": "copied",
127
+ "T": "typechange",
128
+ }
129
+
130
+ status_lines = ["Changes to be committed:"]
131
+ for line in output.splitlines():
132
+ line = line.strip()
133
+ if not line:
134
+ continue
135
+
136
+ # Parse status line (e.g., "M\tfile.py" or "R100\told.py\tnew.py")
137
+ parts = line.split("\t")
138
+ if len(parts) < 2:
139
+ continue
140
+
141
+ change_type = parts[0][0] # First char is the status (M, A, D, R, etc.)
142
+ file_path = parts[-1] # Last part is the new/current file path
143
+
144
+ status_label = status_map.get(change_type, "modified")
145
+ status_lines.append(f"\t{status_label}: {file_path}")
146
+
147
+ return "\n".join(status_lines)
148
+ except GitError:
149
+ return "No changes staged for commit."
150
+
151
+
53
152
  def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None, commit2: str | None = None) -> str:
54
153
  """Get the diff between commits or working tree.
55
154
 
@@ -90,20 +189,20 @@ def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None
90
189
 
91
190
  def get_repo_root() -> str:
92
191
  """Get absolute path of repository root."""
93
- result = subprocess.check_output(["git", "rev-parse", "--show-toplevel"])
94
- return result.decode().strip()
192
+ result = run_git_command(["rev-parse", "--show-toplevel"])
193
+ return result
95
194
 
96
195
 
97
196
  def get_current_branch() -> str:
98
197
  """Get name of current git branch."""
99
- result = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
100
- return result.decode().strip()
198
+ result = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
199
+ return result
101
200
 
102
201
 
103
202
  def get_commit_hash() -> str:
104
203
  """Get SHA-1 hash of current commit."""
105
- result = subprocess.check_output(["git", "rev-parse", "HEAD"])
106
- return result.decode().strip()
204
+ result = run_git_command(["rev-parse", "HEAD"])
205
+ return result
107
206
 
108
207
 
109
208
  def run_pre_commit_hooks() -> bool:
@@ -128,7 +227,7 @@ def run_pre_commit_hooks() -> bool:
128
227
  # Run pre-commit hooks on staged files
129
228
  logger.info("Running pre-commit hooks...")
130
229
  # Run pre-commit and capture both stdout and stderr
131
- result = subprocess.run(["pre-commit", "run"], capture_output=True, text=True, check=False)
230
+ result = run_subprocess_with_encoding_fallback(["pre-commit", "run"])
132
231
 
133
232
  if result.returncode == 0:
134
233
  # All hooks passed
@@ -178,7 +277,7 @@ def run_lefthook_hooks() -> bool:
178
277
  # Run lefthook hooks on staged files
179
278
  logger.info("Running Lefthook hooks...")
180
279
  # Run lefthook and capture both stdout and stderr
181
- result = subprocess.run(["lefthook", "run", "pre-commit"], capture_output=True, text=True, check=False)
280
+ result = run_subprocess_with_encoding_fallback(["lefthook", "run", "pre-commit"])
182
281
 
183
282
  if result.returncode == 0:
184
283
  # All hooks passed