@@ -94,7 +94,17 @@ async def run_code_command(
9494 commit_message : str ,
9595 chat_id : Optional [str ] = None ,
9696) -> str :
97- """Run a code command (lint, format, etc.) and handle git operations.
97+ """Run a code command (lint, format, etc.) and handle git operations using commutable commits.
98+
99+ This function implements a sophisticated auto-commit mechanism that:
100+ 1. Creates a PRE_COMMIT with all pending changes
101+ 2. Resets HEAD/index to the state before making this commit (working tree keeps changes)
102+ 3. Runs the intended command
103+ 4. Assesses the impact of the command:
104+ a. If no changes were made, it does nothing and ignores PRE_COMMIT
105+ b. If changes were made, it creates POST_COMMIT and tries to commute changes:
106+ - If the cherry-pick succeeds, uses the commuted POST_COMMIT
107+ - If the cherry-pick fails, uses the original uncommuted POST_COMMIT
98108
99109 Args:
100110 project_dir: The directory path containing the code to process
@@ -128,18 +138,84 @@ async def run_code_command(
128138 # Check if directory is in a git repository
129139 is_git_repo = await is_git_repository (full_dir_path )
130140
131- # If it's a git repo, commit any pending changes before running the command
141+ # If it's a git repo, handle the commutable auto-commit mechanism
142+ pre_commit_hash = None
143+ original_head_hash = None
132144 if is_git_repo :
133- logging .info (f"Committing any pending changes before { command_name } " )
134- chat_id_str = str (chat_id ) if chat_id is not None else ""
135- commit_result = await commit_changes (
136- full_dir_path ,
137- f"Snapshot before auto-{ command_name } " ,
138- chat_id_str ,
139- commit_all = True ,
140- )
141- if not commit_result [0 ]:
142- logging .warning (f"Failed to commit pending changes: { commit_result [1 ]} " )
145+ try :
146+ git_cwd = await get_repository_root (full_dir_path )
147+
148+ # Get the current HEAD hash
149+ head_hash_result = await run_command (
150+ ["git" , "rev-parse" , "HEAD" ],
151+ cwd = git_cwd ,
152+ capture_output = True ,
153+ text = True ,
154+ check = False ,
155+ )
156+
157+ if head_hash_result .returncode == 0 :
158+ original_head_hash = head_hash_result .stdout .strip ()
159+
160+ # Check if there are any changes to commit
161+ has_initial_changes = await check_for_changes (full_dir_path )
162+
163+ if has_initial_changes :
164+ logging .info (f"Creating PRE_COMMIT before running { command_name } " )
165+ chat_id_str = str (chat_id ) if chat_id is not None else ""
166+
167+ # Create the PRE_COMMIT with all changes
168+ await run_command (
169+ ["git" , "add" , "." ],
170+ cwd = git_cwd ,
171+ capture_output = True ,
172+ text = True ,
173+ check = True ,
174+ )
175+
176+ # Commit all changes (including untracked files)
177+ await run_command (
178+ [
179+ "git" ,
180+ "commit" ,
181+ "--no-gpg-sign" ,
182+ "-m" ,
183+ f"PRE_COMMIT: Snapshot before auto-{ command_name } " ,
184+ ],
185+ cwd = git_cwd ,
186+ capture_output = True ,
187+ text = True ,
188+ check = True ,
189+ )
190+
191+ # Get the hash of our PRE_COMMIT
192+ pre_commit_hash_result = await run_command (
193+ ["git" , "rev-parse" , "HEAD" ],
194+ cwd = git_cwd ,
195+ capture_output = True ,
196+ text = True ,
197+ check = True ,
198+ )
199+ pre_commit_hash = pre_commit_hash_result .stdout .strip ()
200+
201+ logging .info (f"Created PRE_COMMIT: { pre_commit_hash } " )
202+
203+ # Reset HEAD to the previous commit, but keep working tree changes (mixed mode)
204+ # This effectively "uncommits" without losing the changes in the working tree
205+ if original_head_hash :
206+ await run_command (
207+ ["git" , "reset" , original_head_hash ],
208+ cwd = git_cwd ,
209+ capture_output = True ,
210+ text = True ,
211+ check = True ,
212+ )
213+ logging .info (
214+ f"Reset HEAD to { original_head_hash } , keeping changes in working tree"
215+ )
216+ except Exception as e :
217+ logging .warning (f"Failed to set up PRE_COMMIT: { e } " )
218+ # Continue with command execution even if PRE_COMMIT setup fails
143219
144220 # Run the command
145221 try :
@@ -151,13 +227,195 @@ async def run_code_command(
151227 text = True ,
152228 )
153229
154- # Additional logging is already done by run_command
155-
156230 # Truncate the output if needed, prioritizing the end content
157231 truncated_stdout = truncate_output_content (result .stdout , prefer_end = True )
158232
159- # If it's a git repo, commit any changes made by the command
160- if is_git_repo :
233+ # If it's a git repo and PRE_COMMIT was created, handle commutation of changes
234+ if is_git_repo and pre_commit_hash :
235+ git_cwd = await get_repository_root (full_dir_path )
236+
237+ # Check if command made any changes
238+ has_command_changes = await check_for_changes (full_dir_path )
239+
240+ if not has_command_changes :
241+ logging .info (
242+ f"No changes made by { command_name } , ignoring PRE_COMMIT"
243+ )
244+ return f"Code { command_name } successful (no changes made):\n { truncated_stdout } "
245+
246+ logging .info (
247+ f"Changes detected after { command_name } , creating POST_COMMIT"
248+ )
249+
250+ # Create POST_COMMIT with PRE_COMMIT as parent
251+ # First, stage all changes (including untracked files)
252+ await run_command (
253+ ["git" , "add" , "." ],
254+ cwd = git_cwd ,
255+ capture_output = True ,
256+ text = True ,
257+ check = True ,
258+ )
259+
260+ # Create the POST_COMMIT on top of PRE_COMMIT
261+ chat_id_str = str (chat_id ) if chat_id is not None else ""
262+
263+ # Temporarily set HEAD to PRE_COMMIT
264+ await run_command (
265+ ["git" , "update-ref" , "HEAD" , pre_commit_hash ],
266+ cwd = git_cwd ,
267+ capture_output = True ,
268+ text = True ,
269+ check = True ,
270+ )
271+
272+ # Create POST_COMMIT
273+ await run_command (
274+ [
275+ "git" ,
276+ "commit" ,
277+ "--no-gpg-sign" ,
278+ "-m" ,
279+ f"POST_COMMIT: { commit_message } " ,
280+ ],
281+ cwd = git_cwd ,
282+ capture_output = True ,
283+ text = True ,
284+ check = True ,
285+ )
286+
287+ # Get the POST_COMMIT hash
288+ post_commit_hash_result = await run_command (
289+ ["git" , "rev-parse" , "HEAD" ],
290+ cwd = git_cwd ,
291+ capture_output = True ,
292+ text = True ,
293+ check = True ,
294+ )
295+ post_commit_hash = post_commit_hash_result .stdout .strip ()
296+ logging .info (f"Created POST_COMMIT: { post_commit_hash } " )
297+
298+ # Now try to commute the changes
299+ # Reset to original HEAD
300+ await run_command (
301+ ["git" , "reset" , "--hard" , original_head_hash ],
302+ cwd = git_cwd ,
303+ capture_output = True ,
304+ text = True ,
305+ check = True ,
306+ )
307+
308+ # Try to cherry-pick PRE_COMMIT onto original HEAD
309+ try :
310+ await run_command (
311+ ["git" , "cherry-pick" , "--no-gpg-sign" , pre_commit_hash ],
312+ cwd = git_cwd ,
313+ capture_output = True ,
314+ text = True ,
315+ check = True ,
316+ )
317+
318+ # If we get here, PRE_COMMIT applied cleanly
319+ commuted_pre_commit_hash_result = await run_command (
320+ ["git" , "rev-parse" , "HEAD" ],
321+ cwd = git_cwd ,
322+ capture_output = True ,
323+ text = True ,
324+ check = True ,
325+ )
326+ commuted_pre_commit_hash = (
327+ commuted_pre_commit_hash_result .stdout .strip ()
328+ )
329+
330+ # Now try to cherry-pick POST_COMMIT
331+ await run_command (
332+ ["git" , "cherry-pick" , "--no-gpg-sign" , post_commit_hash ],
333+ cwd = git_cwd ,
334+ capture_output = True ,
335+ text = True ,
336+ check = True ,
337+ )
338+
339+ # Get the commuted POST_COMMIT hash
340+ commuted_post_commit_hash_result = await run_command (
341+ ["git" , "rev-parse" , "HEAD" ],
342+ cwd = git_cwd ,
343+ capture_output = True ,
344+ text = True ,
345+ check = True ,
346+ )
347+ commuted_post_commit_hash = (
348+ commuted_post_commit_hash_result .stdout .strip ()
349+ )
350+
351+ # Verify that the final tree is the same
352+ original_tree_result = await run_command (
353+ ["git" , "rev-parse" , f"{ post_commit_hash } ^{{tree}}" ],
354+ cwd = git_cwd ,
355+ capture_output = True ,
356+ text = True ,
357+ check = True ,
358+ )
359+ original_tree = original_tree_result .stdout .strip ()
360+
361+ commuted_tree_result = await run_command (
362+ ["git" , "rev-parse" , f"{ commuted_post_commit_hash } ^{{tree}}" ],
363+ cwd = git_cwd ,
364+ capture_output = True ,
365+ text = True ,
366+ check = True ,
367+ )
368+ commuted_tree = commuted_tree_result .stdout .strip ()
369+
370+ if original_tree == commuted_tree :
371+ # Commutation successful and trees match!
372+ # Make sure we have the same changes uncommitted
373+ await run_command (
374+ ["git" , "reset" , commuted_pre_commit_hash ],
375+ cwd = git_cwd ,
376+ capture_output = True ,
377+ text = True ,
378+ check = True ,
379+ )
380+ logging .info (
381+ f"Commutation successful! Set HEAD to commuted POST_COMMIT and reset to commuted PRE_COMMIT"
382+ )
383+ return f"Code { command_name } successful (changes commuted successfully):\n { truncated_stdout } "
384+ else :
385+ # Trees don't match, go back to unconmuted version
386+ logging .info (
387+ f"Commutation resulted in different trees, using original POST_COMMIT"
388+ )
389+ await run_command (
390+ ["git" , "reset" , "--hard" , post_commit_hash ],
391+ cwd = git_cwd ,
392+ capture_output = True ,
393+ text = True ,
394+ check = True ,
395+ )
396+ return f"Code { command_name } successful (changes don't commute, using original order):\n { truncated_stdout } "
397+
398+ except subprocess .CalledProcessError :
399+ # Cherry-pick failed, go back to unconmuted version
400+ logging .info (f"Cherry-pick failed, using original POST_COMMIT" )
401+ await run_command (
402+ ["git" , "cherry-pick" , "--abort" ],
403+ cwd = git_cwd ,
404+ capture_output = True ,
405+ text = True ,
406+ check = False ,
407+ )
408+ await run_command (
409+ ["git" , "reset" , "--hard" , post_commit_hash ],
410+ cwd = git_cwd ,
411+ capture_output = True ,
412+ text = True ,
413+ check = True ,
414+ )
415+ return f"Code { command_name } successful (changes don't commute, using original order):\n { truncated_stdout } "
416+
417+ # If no PRE_COMMIT was created or not a git repo, handle normally
418+ elif is_git_repo :
161419 has_changes = await check_for_changes (full_dir_path )
162420 if has_changes :
163421 logging .info (f"Changes detected after { command_name } , committing" )
@@ -176,6 +434,32 @@ async def run_code_command(
176434
177435 return f"Code { command_name } successful:\n { truncated_stdout } "
178436 except subprocess .CalledProcessError as e :
437+ # If we were in the middle of the commutation process, try to restore the original state
438+ if is_git_repo and pre_commit_hash and original_head_hash :
439+ try :
440+ git_cwd = await get_repository_root (full_dir_path )
441+
442+ # Abort any in-progress cherry-pick
443+ await run_command (
444+ ["git" , "cherry-pick" , "--abort" ],
445+ cwd = git_cwd ,
446+ capture_output = True ,
447+ text = True ,
448+ check = False ,
449+ )
450+
451+ # Reset to original head
452+ await run_command (
453+ ["git" , "reset" , "--hard" , original_head_hash ],
454+ cwd = git_cwd ,
455+ capture_output = True ,
456+ text = True ,
457+ check = True ,
458+ )
459+ logging .info (f"Restored original state after command failure" )
460+ except Exception as restore_error :
461+ logging .error (f"Failed to restore original state: { restore_error } " )
462+
179463 # Map the command_name to keep backward compatibility with existing tests
180464 command_key = command_name .title ()
181465 if command_name == "linting" :
@@ -212,6 +496,32 @@ async def run_code_command(
212496 return f"Error: { error_msg } "
213497
214498 except Exception as e :
499+ # If we were in the middle of the commutation process, try to restore the original state
500+ if is_git_repo and pre_commit_hash and original_head_hash :
501+ try :
502+ git_cwd = await get_repository_root (full_dir_path )
503+
504+ # Abort any in-progress cherry-pick
505+ await run_command (
506+ ["git" , "cherry-pick" , "--abort" ],
507+ cwd = git_cwd ,
508+ capture_output = True ,
509+ text = True ,
510+ check = False ,
511+ )
512+
513+ # Reset to original head
514+ await run_command (
515+ ["git" , "reset" , "--hard" , original_head_hash ],
516+ cwd = git_cwd ,
517+ capture_output = True ,
518+ text = True ,
519+ check = True ,
520+ )
521+ logging .info (f"Restored original state after exception" )
522+ except Exception as restore_error :
523+ logging .error (f"Failed to restore original state: { restore_error } " )
524+
215525 error_msg = f"Error during { command_name } : { e } "
216526 logging .error (error_msg )
217527 return f"Error: { error_msg } "
0 commit comments