From f4aaf9642f3df1a93c568cbfd4931ffecfff7cac Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Thu, 26 Mar 2026 22:56:46 -0400 Subject: [PATCH] Fix GitHub Copilot apply_patch hook handling Recognize VS Code Copilot's apply_patch tool name as an edit tool and extract file paths from raw patch-text payloads for pre/post hooks. Refs #814. --- .../checkpoint_agent/agent_presets.rs | 23 ++++++ tests/integration/github_copilot.rs | 78 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index dd5eba4f2..e9c78b831 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -2500,6 +2500,7 @@ impl GithubCopilotPreset { "edit", "multiedit", "applypatch", + "apply_patch", "copilot_insertedit", "copilot_replacestring", "vscode_editfile_internal", @@ -2517,6 +2518,24 @@ impl GithubCopilotPreset { lower.contains("edit") || lower.contains("write") || lower.contains("replace") } + fn collect_apply_patch_paths_from_text(raw: &str, out: &mut Vec) { + for line in raw.lines() { + let trimmed = line.trim(); + let maybe_path = trimmed + .strip_prefix("*** Update File: ") + .or_else(|| trimmed.strip_prefix("*** Add File: ")) + .or_else(|| trimmed.strip_prefix("*** Delete File: ")) + .or_else(|| trimmed.strip_prefix("*** Move to: ")); + + if let Some(path) = maybe_path { + let path = path.trim(); + if !path.is_empty() && !out.iter().any(|existing| existing == path) { + out.push(path.to_string()); + } + } + } + } + fn extract_filepaths_from_vscode_hook_payload( tool_input: Option<&serde_json::Value>, tool_response: Option<&serde_json::Value>, @@ -2584,6 +2603,7 @@ impl GithubCopilotPreset { if s.starts_with("file://") { out.push(s.to_string()); } + Self::collect_apply_patch_paths_from_text(s, out); } _ => {} } @@ -3444,6 +3464,9 @@ impl GithubCopilotPreset { Self::collect_copilot_filepaths(item, out); } } + serde_json::Value::String(s) => { + Self::collect_apply_patch_paths_from_text(s, out); + } _ => {} } } diff --git a/tests/integration/github_copilot.rs b/tests/integration/github_copilot.rs index 9d2401c07..6276b5711 100644 --- a/tests/integration/github_copilot.rs +++ b/tests/integration/github_copilot.rs @@ -1191,6 +1191,38 @@ fn test_copilot_preset_vscode_create_file_tool_is_supported() { ); } +#[test] +fn test_copilot_preset_vscode_apply_patch_tool_is_supported() { + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, + }; + + let hook_input = json!({ + "hookEventName": "PreToolUse", + "cwd": "/Users/test/project", + "toolName": "apply_patch", + "transcript_path": "/Users/test/Library/Application Support/Code/User/workspaceStorage/workspace-id/GitHub.copilot-chat/transcripts/copilot-session-apply-patch.jsonl", + "toolInput": "*** Begin Patch\n*** Update File: src/main.ts\n@@\n-old\n+new\n*** End Patch", + "sessionId": "copilot-session-apply-patch" + }); + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }; + + let preset = GithubCopilotPreset; + let result = preset.run(flags).expect("Expected human checkpoint"); + + assert_eq!( + result.checkpoint_kind, + git_ai::authorship::working_log::CheckpointKind::Human + ); + assert_eq!( + result.will_edit_filepaths, + Some(vec!["/Users/test/project/src/main.ts".to_string()]) + ); +} + #[test] fn test_copilot_preset_vscode_editfiles_files_array_is_supported() { use git_ai::commands::checkpoint_agent::agent_presets::{ @@ -1276,6 +1308,52 @@ fn test_copilot_preset_vscode_posttooluse_ai_checkpoint() { ); } +#[test] +fn test_copilot_preset_vscode_apply_patch_posttooluse_ai_checkpoint() { + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, + }; + + let temp_dir = tempfile::tempdir().unwrap(); + let transcripts_dir = temp_dir + .path() + .join("workspaceStorage") + .join("workspace-id") + .join("GitHub.copilot-chat") + .join("transcripts"); + fs::create_dir_all(&transcripts_dir).unwrap(); + let transcript_path = transcripts_dir.join("copilot-session-apply-patch-post.jsonl"); + fs::write(&transcript_path, r#"{"requests": []}"#).unwrap(); + let session_path = transcript_path.to_string_lossy().to_string(); + + let hook_input = json!({ + "hookEventName": "PostToolUse", + "cwd": "/Users/test/project", + "toolName": "apply_patch", + "toolInput": "*** Begin Patch\n*** Update File: src/main.ts\n@@\n-old\n+new\n*** End Patch", + "sessionId": "copilot-session-apply-patch-post", + "transcript_path": session_path + }); + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }; + + let preset = GithubCopilotPreset; + let result = preset.run(flags).expect("Expected AI checkpoint"); + + assert_eq!( + result.checkpoint_kind, + git_ai::authorship::working_log::CheckpointKind::AiAgent + ); + assert_eq!(result.agent_id.tool, "github-copilot"); + assert_eq!(result.agent_id.id, "copilot-session-apply-patch-post"); + assert_eq!( + result.edited_filepaths, + Some(vec!["/Users/test/project/src/main.ts".to_string()]) + ); +} + #[test] fn test_copilot_preset_vscode_non_edit_tool_is_filtered() { use git_ai::commands::checkpoint_agent::agent_presets::{