1+ import asyncio
2+ from pathlib import Path
3+
4+ from pydantic import BaseModel , Field
5+
6+ from beeai_framework .context import RunContext
7+ from beeai_framework .emitter import Emitter
8+ from beeai_framework .tools import JSONToolOutput , Tool , ToolRunOptions
9+
10+
11+ class GitPatchCreationToolInput (BaseModel ):
12+ repository_path : Path = Field (description = "Absolute path to the git repository" )
13+ patch_file_path : Path = Field (description = "Absolute path where the patch file should be saved" )
14+
15+
16+ class GitPatchCreationToolResult (BaseModel ):
17+ success : bool = Field (description = "Whether the patch creation was successful" )
18+ patch_file_path : str = Field (description = "Path to the created patch file" )
19+ error : str | None = Field (description = "Error message if patch creation failed" , default = None )
20+
21+
22+ class GitPatchCreationToolOutput (JSONToolOutput [GitPatchCreationToolResult ]):
23+ """ Returns a dictionary with success or error and the path to the created patch file. """
24+
25+
26+ async def run_command (cmd : list [str ], cwd : Path ) -> dict [str , str | int ]:
27+ proc = await asyncio .create_subprocess_exec (
28+ cmd [0 ],
29+ * cmd [1 :],
30+ stdout = asyncio .subprocess .PIPE ,
31+ stderr = asyncio .subprocess .PIPE ,
32+ cwd = cwd ,
33+ )
34+
35+ stdout , stderr = await proc .communicate ()
36+
37+ return {
38+ "exit_code" : proc .returncode ,
39+ "stdout" : stdout .decode () if stdout else None ,
40+ "stderr" : stderr .decode () if stderr else None ,
41+ }
42+
43+ class GitPatchCreationTool (Tool [GitPatchCreationToolInput , ToolRunOptions , GitPatchCreationToolOutput ]):
44+ name = "git_patch_create"
45+ description = """
46+ Creates a patch file from the specified git repository with an active git-am session
47+ and after you resolved all merge conflicts. The tool generates a patch file that can be
48+ applied later in the RPM build process. Returns a dictionary with success or error and
49+ the path to the created patch file.
50+ """
51+ input_schema = GitPatchCreationToolInput
52+
53+ def _create_emitter (self ) -> Emitter :
54+ return Emitter .root ().child (
55+ namespace = ["tool" , "git" , self .name ],
56+ creator = self ,
57+ )
58+
59+ async def _run (
60+ self , tool_input : GitPatchCreationToolInput , options : ToolRunOptions | None , context : RunContext
61+ ) -> GitPatchCreationToolOutput :
62+ # Ensure the repository path exists and is a git repository
63+ if not tool_input .repository_path .exists ():
64+ return GitPatchCreationToolOutput (
65+ result = GitPatchCreationToolResult (
66+ success = False ,
67+ patch_file_path = "" ,
68+ patch_content = "" ,
69+ error = f"Repository path does not exist: { tool_input .repository_path } "
70+ )
71+ )
72+
73+ git_dir = tool_input .repository_path / ".git"
74+ if not git_dir .exists ():
75+ return GitPatchCreationToolOutput (
76+ result = GitPatchCreationToolResult (
77+ success = False ,
78+ patch_file_path = "" ,
79+ patch_content = "" ,
80+ error = f"Not a git repository: { tool_input .repository_path } "
81+ )
82+ )
83+
84+ # list all untracked files in the repository
85+ cmd = ["git" , "ls-files" , "--others" , "--exclude-standard" ]
86+ result = await run_command (cmd , cwd = tool_input .repository_path )
87+ if result ["exit_code" ] != 0 :
88+ return GitPatchCreationToolOutput (
89+ result = GitPatchCreationToolResult (
90+ success = False ,
91+ patch_file_path = "" ,
92+ patch_content = "" ,
93+ error = f"Git command failed: { result ['stderr' ]} "
94+ )
95+ )
96+ untracked_files = result ["stdout" ].splitlines ()
97+ # list staged as well since that's what the agent usually does after it resolves conflicts
98+ cmd = ["git" , "diff" , "--name-only" , "--cached" ]
99+ result = await run_command (cmd , cwd = tool_input .repository_path )
100+ if result ["exit_code" ] != 0 :
101+ return GitPatchCreationToolOutput (
102+ result = GitPatchCreationToolResult (
103+ success = False ,
104+ patch_file_path = "" ,
105+ patch_content = "" ,
106+ error = f"Git command failed: { result ['stderr' ]} "
107+ )
108+ )
109+ staged_files = result ["stdout" ].splitlines ()
110+ all_files = untracked_files + staged_files
111+ # make sure there are no *.rej files in the repository
112+ rej_files = [file for file in all_files if file .endswith (".rej" )]
113+ if rej_files :
114+ return GitPatchCreationToolOutput (
115+ result = GitPatchCreationToolResult (
116+ success = False ,
117+ patch_file_path = "" ,
118+ patch_content = "" ,
119+ error = "Merge conflicts detected in the repository: "
120+ f"{ tool_input .repository_path } , { rej_files } "
121+ )
122+ )
123+
124+ # git-am leaves the repository in a dirty state, so we need to stage everything
125+ # I considered to inspect the patch and only stage the files that are changed by the patch,
126+ # but the backport process could create new files or change new ones
127+ # so let's go the naive route: git add -A
128+ cmd = ["git" , "add" , "-A" ]
129+ result = await run_command (cmd , cwd = tool_input .repository_path )
130+ if result ["exit_code" ] != 0 :
131+ return GitPatchCreationToolOutput (
132+ result = GitPatchCreationToolResult (
133+ success = False ,
134+ patch_file_path = "" ,
135+ patch_content = "" ,
136+ error = f"Git command failed: { result ['stderr' ]} "
137+ )
138+ )
139+ # continue git-am process
140+ cmd = ["git" , "am" , "--continue" ]
141+ result = await run_command (cmd , cwd = tool_input .repository_path )
142+ if result ["exit_code" ] != 0 :
143+ return GitPatchCreationToolOutput (
144+ result = GitPatchCreationToolResult (
145+ success = False ,
146+ patch_file_path = "" ,
147+ patch_content = "" ,
148+ error = f"git-am failed: { result ['stderr' ]} , out={ result ['stdout' ]} "
149+ )
150+ )
151+ # good, now we should have the patch committed, so let's get the file
152+ cmd = [
153+ "git" , "format-patch" ,
154+ "--output" ,
155+ str (tool_input .patch_file_path ),
156+ "HEAD~1..HEAD"
157+ ]
158+ result = await run_command (cmd , cwd = tool_input .repository_path )
159+ if result ["exit_code" ] != 0 :
160+ return GitPatchCreationToolOutput (
161+ result = GitPatchCreationToolResult (
162+ success = False ,
163+ patch_file_path = "" ,
164+ patch_content = "" ,
165+ error = f"git-format-patch failed: { result ['stderr' ]} "
166+ )
167+ )
168+ return GitPatchCreationToolOutput (
169+ result = GitPatchCreationToolResult (
170+ success = True ,
171+ patch_file_path = str (tool_input .patch_file_path ),
172+ error = None
173+ )
174+ )
0 commit comments