xref: /reactos/sdk/tools/winesync/winesync.py (revision 003b19dc)
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.safe_load(file_input)
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.safe_load(file_input)
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 = False
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.status == pygit2.GIT_DELTA_ADDED:
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.status == pygit2.GIT_DELTA_DELETED:
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                if delta.status == pygit2.GIT_DELTA_ADDED:
156                    new_blob = self.wine_repo.get(delta.new_file.id)
157                    blob_patch = pygit2.Patch.create_from(
158                        old=None,
159                        new=new_blob,
160                        new_as_path=new_reactos_path)
161                elif delta.status == pygit2.GIT_DELTA_DELETED:
162                    old_blob = self.wine_repo.get(delta.old_file.id)
163                    blob_patch = pygit2.Patch.create_from(
164                        old=old_blob,
165                        new=None,
166                        old_as_path=old_reactos_path)
167                else:
168                    new_blob = self.wine_repo.get(delta.new_file.id)
169                    old_blob = self.wine_repo.get(delta.old_file.id)
170
171                    blob_patch = pygit2.Patch.create_from(
172                        old=old_blob,
173                        new=new_blob,
174                        old_as_path=old_reactos_path,
175                        new_as_path=new_reactos_path)
176
177                # print(str(wine_commit.id))
178                # print(blob_patch.text)
179
180                # this doesn't work
181                # reactos_diff = pygit2.Diff.parse_diff(blob_patch.text)
182                # reactos_repo.apply(reactos_diff)
183                try:
184                    subprocess.run(['git', '-C', self.reactos_src, 'apply', '--reject'], input=blob_patch.data, check=True)
185                except subprocess.CalledProcessError as err:
186                    warning_message += 'Error while applying patch to ' + new_reactos_path + '\n'
187
188                if delta.status == pygit2.GIT_DELTA_DELETED:
189                    self.reactos_index.remove(old_reactos_path)
190                # here we check if the file exists. We don't complain, because applying the patch already failed anyway
191                elif os.path.isfile(os.path.join(self.reactos_src, new_reactos_path)):
192                    self.reactos_index.add(new_reactos_path)
193
194                complete_patch += blob_patch.text
195
196                modified_files = True
197            else:
198                ignored_files += [delta.old_file.path, delta.new_file.path]
199
200        if not modified_files:
201            # We applied nothing
202            return False, ''
203
204        print('Applied patches from wine commit ' + str(wine_commit.id))
205
206        if ignored_files:
207            warning_message += 'WARNING : some files were ignored: ' + ' '.join(ignored_files) + '\n'
208
209        if not in_staging:
210            self.module_cfg['tags']['wine'] = str(wine_commit.id)
211            with open(self.module + '.cfg', 'w') as file_output:
212                yaml.dump(self.module_cfg, file_output)
213            self.reactos_index.add(f'sdk/tools/winesync/{self.module}.cfg')
214        else:
215            # Add the staging patch
216            # do not save the wine commit ID in <module>.cfg, as it's a local one for staging patches
217            if not os.path.isdir(os.path.join(self.reactos_src, self.staged_patch_dir)):
218                os.mkdir(os.path.join(self.reactos_src, self.staged_patch_dir))
219            with open(patch_path, 'w') as file_output:
220                file_output.write(complete_patch)
221            self.reactos_index.add(os.path.join(self.staged_patch_dir, patch_file_name))
222
223        self.reactos_index.write()
224
225        commit_msg = f'[WINESYNC] {wine_commit.message}\n'
226        if (in_staging):
227            commit_msg += f'wine-staging patch by {wine_commit.author.name} <{wine_commit.author.email}>'
228        else:
229            commit_msg += f'wine commit id {str(wine_commit.id)} by {wine_commit.author.name} <{wine_commit.author.email}>'
230
231        self.reactos_repo.create_commit('HEAD',
232            pygit2.Signature('winesync', 'ros-dev@reactos.org'),
233            self.reactos_repo.default_signature,
234            commit_msg,
235            self.reactos_index.write_tree(),
236            [self.reactos_repo.head.target])
237
238        if (warning_message != ''):
239            warning_message += 'If needed, amend the current commit in your reactos tree and start this script again'
240
241            if not in_staging:
242                warning_message += f'\n' \
243                    f'You can see the details of the wine commit here:\n' \
244                    f'    https://source.winehq.org/git/wine.git/commit/{str(wine_commit.id)}\n'
245            else:
246                warning_message += f'\n' \
247                    f'Do not forget to run\n' \
248                    f'    git diff HEAD^ \':(exclude)sdk/tools/winesync/{patch_file_name}\' > sdk/tools/winesync/{patch_file_name}\n' \
249                    f'after your correction and then\n' \
250                    f'    git add sdk/tools/winesync/{patch_file_name}\n' \
251                    f'before running "git commit --amend"'
252
253        return True, warning_message
254
255    def revert_staged_patchset(self):
256        # revert all of this in one commmit
257        staged_patch_dir_path = os.path.join(self.reactos_src, self.staged_patch_dir)
258        if not os.path.isdir(staged_patch_dir_path):
259            return True
260
261        has_patches = False
262
263        for patch_file_name in sorted(os.listdir(staged_patch_dir_path), reverse=True):
264            patch_path = os.path.join(staged_patch_dir_path, patch_file_name)
265            if not os.path.isfile(patch_path):
266                continue
267
268            has_patches = True
269
270            with open(patch_path, 'rb') as patch_file:
271                try:
272                    subprocess.run(['git', '-C', self.reactos_src, 'apply', '-R', '--reject'], stdin=patch_file, check=True)
273                except subprocess.CalledProcessError as err:
274                    print(f'Error while reverting patch {patch_file_name}')
275                    print('Please check, remove the offending patch with git rm, and relaunch this script')
276                    return False
277
278            self.reactos_index.remove(os.path.join(self.staged_patch_dir, patch_file_name))
279            self.reactos_index.write()
280            os.remove(patch_path)
281
282        if not has_patches:
283            return True
284
285        self.reactos_index.add_all([f for f in self.module_cfg['files'].values()])
286        self.reactos_index.add_all([f'{d}/*.*' for d in self.module_cfg['directories'].values()])
287        self.reactos_index.write()
288
289        self.reactos_repo.create_commit(
290            'HEAD',
291            self.reactos_repo.default_signature,
292            self.reactos_repo.default_signature,
293            f'[WINESYNC]: revert wine-staging patchset for {self.module}',
294            self.reactos_index.write_tree(),
295            [self.reactos_repo.head.target])
296        return True
297
298    def sync_to_wine(self, wine_tag, wine_staging_tag):
299        # Get our target commit
300        wine_target_commit = self.wine_repo.revparse_single(wine_tag)
301        if isinstance(wine_target_commit, pygit2.Tag):
302            wine_target_commit = wine_target_commit.target
303        # print(f'wine target commit is {wine_target_commit}')
304
305        # get the wine commit id where we left
306        in_staging = False
307        wine_last_sync = self.wine_repo.revparse_single(self.module_cfg['tags']['wine'])
308        if (isinstance(wine_last_sync, pygit2.Tag)):
309            if not self.revert_staged_patchset():
310                return
311            wine_last_sync = wine_last_sync.target
312        if (isinstance(wine_last_sync, pygit2.Commit)):
313            wine_last_sync = wine_last_sync.id
314
315        # create a branch to keep things clean
316        wine_branch_name = self.create_or_checkout_wine_branch(wine_tag, wine_staging_tag)
317
318        finished_sync = True
319        staging_patch_index = 1
320
321        # walk each commit between last sync and the asked tag/revision
322        wine_commit_walker = self.wine_repo.walk(self.wine_repo.head.target, pygit2.GIT_SORT_TOPOLOGICAL | pygit2.GIT_SORT_REVERSE)
323        wine_commit_walker.hide(wine_last_sync)
324        for wine_commit in wine_commit_walker:
325            applied_patch, warning_message = self.sync_wine_commit(wine_commit, in_staging, staging_patch_index)
326
327            if str(wine_commit.id) == str(wine_target_commit):
328                print('We are now in staging territory')
329                in_staging = True
330
331            if not applied_patch:
332                continue
333
334            if in_staging:
335                staging_patch_index += 1
336
337            if warning_message != '':
338                print("THERE WERE SOME ISSUES WHEN APPLYING THE PATCH\n\n")
339                print(warning_message)
340                print("\n")
341                finished_sync = False
342                break
343
344        # we're done without error
345        if finished_sync:
346            # update wine tag and commit
347            self.module_cfg['tags']['wine'] = wine_tag
348            with open(self.module + '.cfg', 'w') as file_output:
349                yaml.dump(self.module_cfg, file_output)
350
351            self.reactos_index.add(f'sdk/tools/winesync/{self.module}.cfg')
352            self.reactos_index.write()
353            self.reactos_repo.create_commit(
354                'HEAD',
355                self.reactos_repo.default_signature,
356                self.reactos_repo.default_signature,
357                f'[WINESYNC]: {self.module} is now in sync with wine-staging {wine_tag}',
358                self.reactos_index.write_tree(),
359                [self.reactos_repo.head.target])
360
361            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')
362
363def main():
364    parser = argparse.ArgumentParser()
365    parser.add_argument('module', help='The module you want to sync. <module>.cfg must exist in the current directory')
366    parser.add_argument('wine_tag', help='The wine tag or commit id to sync to')
367    parser.add_argument('wine_staging_tag', help='The wine staging tag or commit id to pick wine staged patches from')
368
369    args = parser.parse_args()
370
371    syncator = wine_sync(args.module)
372
373    return syncator.sync_to_wine(args.wine_tag, args.wine_staging_tag)
374
375
376if __name__ == '__main__':
377    main()
378