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