Skip to content

Commit 6981e4c

Browse files
committed
feat: implement commutable auto-commit mechanism
We're going to tighten up our auto-commit mechanism. Currently, before we run a command, we commit ALL pending changes (including untracked files) before running the command. What I want to do now is create this commit, to be called PRE_COMMIT, but then reset the HEAD/index to the state it was before doing this commit (commit-tree might work too although we'll have to handle new untracked files correctly. They should remain untracked after the commit.) We run the command as usual. Now, we must assess the impact of the command. Easy case: no changes were made by command. Then we do nothing and can ignore the commit we made. Harder case: some changes were made. We now need to understand if the local changes and the new changes commute. First, synthetically construct the new change POST_COMMIT by creating a new commit (including all untracked files) but having its base be the PRE_COMMIT. This gives us a commit history POST_COMMIT -> PRE_COMMIT (where the arrow denotes parent). Now, we try to commute them, so we have PRE_COMMIT -> POST_COMMIT, using git cherry-pick. If the cherry-pick fails, or the new commuted patches have a different final tree than the original, we abort, and move HEAD to the un-commuted POST_COMMIT. However, if the cherry-pick succeeds, we can directly reset HEAD to be the *commuted* POST_COMMIT, in particular, the working tree will now have PRE_COMMIT's changes uncommitted, just like they were before hand. ```git-revs 4e2423b (Base revision) 2513f81 Implement commutable auto-commit mechanism in run_code_command 5f456d2 Implement error handling for commutable auto-commit mechanism dbf98a0 Create test file for commutable auto-commit mechanism 47ccb43 Create an improved test for commutable auto-commit mechanism 09bae9a Auto-commit lint changes 74efb3f Auto-commit format changes 18348d5 Snapshot before auto-accept a2a093f Update format test to handle both commit message formats 9eec6ee Snapshot before codemcp change 6b0e0c9 Update lint test to handle both commit message formats HEAD Auto-commit format changes ``` codemcp-id: 250-feat-implement-commutable-auto-commit-mechanism ghstack-source-id: 8466882 Pull-Request-resolved: #235
1 parent e11f866 commit 6981e4c

File tree

4 files changed

+560
-22
lines changed

4 files changed

+560
-22
lines changed

codemcp/code_command.py

Lines changed: 326 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)