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