xref: /reactos/sdk/tools/winesync/winesync.py (revision b5218987)
1#!/usr/bin/env python3
2
3import sys
4import os
5import string
6import argparse
7import subprocess
8import fnmatch
9import pygit2
10import yaml
11
12def string_to_valid_file_name(to_convert):
13    valid_chars = '-_.()' + string.ascii_letters + string.digits
14    result = ''
15    for c in to_convert:
16        if c in valid_chars:
17            result += c
18        else:
19            result += '_'
20    # strip final dot, if any
21    if result.endswith('.'):
22        return result[:-1]
23    return result
24
25class wine_sync:
26    def __init__(self, module):
27        if os.path.isfile('winesync.cfg'):
28            with open('winesync.cfg', 'r') as file_input:
29                config = yaml.load(file_input, Loader=yaml.FullLoader)
30            self.reactos_src = config['repos']['reactos']
31            self.wine_src = config['repos']['wine']
32            self.wine_staging_src = config['repos']['wine-staging']
33        else:
34            config = { }
35            self.reactos_src = input('Please enter the path to the reactos git tree: ')
36            self.wine_src = input('Please enter the path to the wine git tree: ')
37            self.wine_staging_src = input('Please enter the path to the wine-staging git tree: ')
38            config['repos'] = { 'reactos' : self.reactos_src,
39                                'wine': self.wine_src,
40                                'wine-staging': self.wine_staging_src }
41            with open('winesync.cfg', 'w') as file_output:
42                yaml.dump(config, file_output)
43
44        self.wine_repo = pygit2.Repository(self.wine_src)
45        self.wine_staging_repo = pygit2.Repository(self.wine_staging_src)
46        self.reactos_repo = pygit2.Repository(self.reactos_src)
47
48        # read the index from the reactos tree
49        self.reactos_index = self.reactos_repo.index
50        self.reactos_index.read()
51
52        # get the actual state for the asked module
53        self.module = module
54        with open(module + '.cfg', 'r') as file_input:
55            self.module_cfg = yaml.load(file_input, Loader=yaml.FullLoader)
56
57        self.staged_patch_dir = os.path.join('sdk', 'tools', 'winesync', self.module + '_staging')
58
59    def create_or_checkout_wine_branch(self, wine_tag, wine_staging_tag):
60        wine_branch_name = 'winesync-' + wine_tag + '-' + wine_staging_tag
61        branch = self.wine_repo.lookup_branch(wine_branch_name)
62        if branch is None:
63            # get our target commits
64            wine_target_commit = self.wine_repo.revparse_single(wine_tag)
65            if isinstance(wine_target_commit, pygit2.Tag):
66                wine_target_commit = wine_target_commit.target
67
68            wine_staging_target_commit = self.wine_staging_repo.revparse_single(wine_staging_tag)
69            if isinstance(wine_staging_target_commit, pygit2.Tag):
70                wine_staging_target_commit = wine_staging_target_commit.target
71
72            self.wine_repo.branches.local.create(wine_branch_name, self.wine_repo.revparse_single('HEAD'))
73            self.wine_repo.checkout(self.wine_repo.lookup_branch(wine_branch_name))
74            self.wine_repo.reset(wine_target_commit, pygit2.GIT_RESET_HARD)
75
76            # do the same for the wine-staging tree
77            self.wine_staging_repo.branches.local.create(wine_branch_name, self.wine_staging_repo.revparse_single('HEAD'))
78            self.wine_staging_repo.checkout(self.wine_staging_repo.lookup_branch(wine_branch_name))
79            self.wine_staging_repo.reset(wine_staging_target_commit, pygit2.GIT_RESET_HARD)
80
81            # run the wine-staging script
82            subprocess.call(['bash', '-c', self.wine_staging_src + '/patches/patchinstall.sh DESTDIR=' + self.wine_src + ' --all --backend=git-am'])
83
84            # delete the branch we created
85            self.wine_staging_repo.checkout(self.wine_staging_repo.lookup_branch('master'))
86            self.wine_staging_repo.branches.delete(wine_branch_name)
87        else:
88            self.wine_repo.checkout(self.wine_repo.lookup_branch(wine_branch_name))
89
90        return wine_branch_name
91
92    # helper function for resolving wine tree path to reactos one
93    # Note : it doesn't care about the fact that the file actually exists or not
94    def wine_to_reactos_path(self, wine_path):
95        if wine_path in self.module_cfg['files']:
96            # we have a direct mapping
97            return self.module_cfg['files'][wine_path]
98
99        if not '/' in wine_path:
100            # root files should have a direct mapping
101            return None
102
103        wine_dir, wine_file = os.path.split(wine_path)
104        if wine_dir in self.module_cfg['directories']:
105            # we have a mapping for the directory
106            return os.path.join(self.module_cfg['directories'][wine_dir], wine_file)
107
108        # no match
109        return None
110
111    def sync_wine_commit(self, wine_commit, in_staging, staging_patch_index):
112        # Get the diff object
113        diff = self.wine_repo.diff(wine_commit.parents[0], wine_commit)
114
115        modified_files = []
116        ignored_files = []
117        warning_message = ''
118        complete_patch = ''
119
120        if in_staging:
121            # see if we already applied this patch
122            patch_file_name = f'{staging_patch_index:04}-{string_to_valid_file_name(wine_commit.message.splitlines()[0])}.diff'
123            patch_path = os.path.join(self.reactos_src, self.staged_patch_dir, patch_file_name)
124            if os.path.isfile(patch_path):
125                print(f'Skipping patch as {patch_path} already exists')
126                return True, ''
127
128        for delta in diff.deltas:
129            if delta.old_file.path == '/dev/null':
130                # check if we should care
131                new_reactos_path = self.wine_to_reactos_path(delta.new_file.path)
132                if not new_reactos_path is None:
133                    warning_message += 'file ' + delta.new_file.path + ' is added to the wine tree !\n'
134                    old_reactos_path = '/dev/null'
135                else:
136                    old_reactos_path = None
137            elif delta.new_file.path == '/dev/null':
138                # check if we should care
139                old_reactos_path = self.wine_to_reactos_path(delta.old_file.path)
140                if not old_reactos_path is None:
141                    warning_message += 'file ' + delta.old_file.path + ' is removed from the wine tree !\n'
142                    new_reactos_path = '/dev/null'
143                else:
144                    new_reactos_path = None
145            elif delta.new_file.path.endswith('Makefile.in'):
146                warning_message += 'file ' + delta.new_file.path + ' was modified !\n'
147                # no need to warn that those are ignored, we just did.
148                continue
149            else:
150                new_reactos_path = self.wine_to_reactos_path(delta.new_file.path)
151                old_reactos_path = self.wine_to_reactos_path(delta.old_file.path)
152
153            if (new_reactos_path is not None) or (old_reactos_path is not None):
154                # print('Must apply diff: ' + old_reactos_path + ' --> ' + new_reactos_path)
155                new_blob = self.wine_repo.get(wine_commit.tree[delta.new_file.path].id)
156                old_blob = self.wine_repo.get(wine_commit.parents[0].tree[delta.old_file.path].id)
157
158                blob_patch = pygit2.Patch.create_from(
159                    old=old_blob,
160                    new=new_blob,
161                    old_as_path=old_reactos_path,
162                    new_as_path=new_reactos_path)
163
164                # print(str(wine_commit.id))
165                # print(blob_patch.text)
166
167                # this doesn't work
168                # reactos_diff = pygit2.Diff.parse_diff(blob_patch.text)
169                # reactos_repo.apply(reactos_diff)
170                try:
171                    subprocess.run(['git', '-C', self.reactos_src, 'apply', '--reject'], input=blob_patch.data, check=True)
172                except subprocess.CalledProcessError as err:
173                    warning_message += 'Error while applying patch to ' + new_reactos_path + '\n'
174                self.reactos_index.add(new_reactos_path)
175
176                complete_patch += blob_patch.text
177
178                modified_files += [delta.old_file.path, delta.new_file.path]
179            else:
180                ignored_files += [delta.old_file.path, delta.new_file.path]
181
182        if not modified_files:
183            # We applied nothing
184            return False, ''
185
186        print('Applied patches from wine commit ' + str(wine_commit.id))
187
188        if ignored_files:
189            warning_message += 'WARNING : some files were ignored: ' + ' '.join(ignored_files) + '\n'
190
191        if not in_staging:
192            self.module_cfg['tags']['wine'] = str(wine_commit.id)
193            with open(self.module + '.cfg', 'w') as file_output:
194                yaml.dump(self.module_cfg, file_output)
195            self.reactos_index.add(f'sdk/tools/winesync/{self.module}.cfg')
196        else:
197            # Add the staging patch
198            # do not save the wine commit ID in <module>.cfg, as it's a local one for staging patches
199            if not os.path.isdir(os.path.join(self.reactos_src, self.staged_patch_dir)):
200                os.mkdir(os.path.join(self.reactos_src, self.staged_patch_dir))
201            with open(patch_path, 'w') as file_output:
202                file_output.write(complete_patch)
203            self.reactos_index.add(os.path.join(self.staged_patch_dir, patch_file_name))
204
205        self.reactos_index.write()
206
207        commit_msg = f'[WINESYNC] {wine_commit.message}\n'
208        if (in_staging):
209            commit_msg += f'wine-staging patch by {wine_commit.author.name} <{wine_commit.author.email}>'
210        else:
211            commit_msg += f'wine commit id {str(wine_commit.id)} by {wine_commit.author.name} <{wine_commit.author.email}>'
212
213        self.reactos_repo.create_commit('HEAD',
214            pygit2.Signature('winesync', 'ros-dev@reactos.org'),
215            self.reactos_repo.default_signature,
216            commit_msg,
217            self.reactos_index.write_tree(),
218            [self.reactos_repo.head.target])
219
220        if (warning_message != ''):
221            warning_message += 'If needed, amend the current commit in your reactos tree and start this script again'
222
223            if not in_staging:
224                warning_message += f'You can see the details of the wine commit here: https://source.winehq.org/git/wine.git/commit/{str(wine_commit.id)}'
225            else:
226                warning_message += 'Do not forget to run\n'
227                warning_message += f'git diff HEAD^ \':(exclude)sdk/tools/winesync/{patch_file_name}\' > sdk/tools/winesync/{patch_file_name}\n'
228                warning_message += 'after your correction and then\n'
229                warning_message += f'git add sdk/tools/winesync/{patch_file_name}\n'
230                warning_message += 'before running "git commit --amend"'
231
232        return True, warning_message
233
234    def revert_staged_patchset(self):
235        # revert all of this in one commmit
236        staged_patch_dir_path = os.path.join(self.reactos_src, self.staged_patch_dir)
237        if not os.path.isdir(staged_patch_dir_path):
238            return True
239
240        has_patches = False
241
242        for patch_file_name in sorted(os.listdir(staged_patch_dir_path), reverse=True):
243            patch_path = os.path.join(staged_patch_dir_path, patch_file_name)
244            if not os.path.isfile(patch_path):
245                continue
246
247            has_patches = True
248
249            with open(patch_path, 'rb') as patch_file:
250                try:
251                    subprocess.run(['git', '-C', self.reactos_src, 'apply', '-R', '--reject'], stdin=patch_file, check=True)
252                except subprocess.CalledProcessError as err:
253                    print(f'Error while reverting patch {patch_file_name}')
254                    print('Please check, remove the offending patch with git rm, and relaunch this script')
255                    return False
256
257            self.reactos_index.remove(os.path.join(self.staged_patch_dir, patch_file_name))
258            self.reactos_index.write()
259            os.remove(patch_path)
260
261        if not has_patches:
262            return True
263
264        self.reactos_index.add_all([f for f in self.module_cfg['files'].values()])
265        self.reactos_index.add_all([f'{d}/*.*' for d in self.module_cfg['directories'].values()])
266        self.reactos_index.write()
267
268        self.reactos_repo.create_commit(
269            'HEAD',
270            self.reactos_repo.default_signature,
271            self.reactos_repo.default_signature,
272            f'[WINESYNC]: revert wine-staging patchset for {self.module}',
273            self.reactos_index.write_tree(),
274            [self.reactos_repo.head.target])
275        return True
276
277    def sync_to_wine(self, wine_tag, wine_staging_tag):
278        # Get our target commit
279        wine_target_commit = self.wine_repo.revparse_single(wine_tag)
280        if isinstance(wine_target_commit, pygit2.Tag):
281            wine_target_commit = wine_target_commit.target
282        # print(f'wine target commit is {wine_target_commit}')
283
284        # get the wine commit id where we left
285        in_staging = False
286        wine_last_sync = self.wine_repo.revparse_single(self.module_cfg['tags']['wine'])
287        if (isinstance(wine_last_sync, pygit2.Tag)):
288            if not self.revert_staged_patchset():
289                return
290            wine_last_sync = wine_last_sync.target
291        if (isinstance(wine_last_sync, pygit2.Commit)):
292            wine_last_sync = wine_last_sync.id
293
294        # create a branch to keep things clean
295        wine_branch_name = self.create_or_checkout_wine_branch(wine_tag, wine_staging_tag)
296
297        finished_sync = True
298        staging_patch_index = 1
299
300        # walk each commit between last sync and the asked tag/revision
301        wine_commit_walker = self.wine_repo.walk(self.wine_repo.head.target, pygit2.GIT_SORT_TOPOLOGICAL | pygit2.GIT_SORT_REVERSE)
302        wine_commit_walker.hide(wine_last_sync)
303        for wine_commit in wine_commit_walker:
304            applied_patch, warning_message = self.sync_wine_commit(wine_commit, in_staging, staging_patch_index)
305
306            if str(wine_commit.id) == str(wine_target_commit):
307                print('We are now in staging territory')
308                in_staging = True
309
310            if not applied_patch:
311                continue
312
313            if in_staging:
314                staging_patch_index += 1
315
316            if warning_message != '':
317                print(warning_message)
318                finished_sync = False
319                break
320
321        # we're done without error
322        if finished_sync:
323            # update wine tag and commit
324            self.module_cfg['tags']['wine'] = wine_tag
325            with open(self.module + '.cfg', 'w') as file_output:
326                yaml.dump(self.module_cfg, file_output)
327
328            self.reactos_index.add(f'sdk/tools/winesync/{self.module}.cfg')
329            self.reactos_index.write()
330            self.reactos_repo.create_commit(
331                'HEAD',
332                self.reactos_repo.default_signature,
333                self.reactos_repo.default_signature,
334                f'[WINESYNC]: {self.module} is now in sync with wine-staging {wine_tag}',
335                self.reactos_index.write_tree(),
336                [self.reactos_repo.head.target])
337
338            print('The branch ' + wine_branch_name + ' was created in your wine repository. You might want to delete it, but you should keep it in case you want to sync more module up to this wine version')
339
340def main():
341    parser = argparse.ArgumentParser()
342    parser.add_argument('module', help='The module you want to sync. <module>.cfg must exist in the current directory')
343    parser.add_argument('wine_tag', help='The wine tag or commit id to sync to')
344    parser.add_argument('wine_staging_tag', help='The wine staging tag or commit id to pick wine staged patches from')
345
346    args = parser.parse_args()
347
348    syncator = wine_sync(args.module)
349
350    return syncator.sync_to_wine(args.wine_tag, args.wine_staging_tag)
351
352
353if __name__ == '__main__':
354    main()