aboutsummaryrefslogtreecommitdiff
blob: d252bc4d325be151fcdff96664bbc18e2f1e310e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
#!/usr/bin/python3
#
# Copyright 2020-2024 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2 or later

"""
Custom git merge driver for handling conflicts in KEYWORDS assignments

See https://git-scm.com/docs/gitattributes#_defining_a_custom_merge_driver
"""

import difflib
import os
import shutil
import sys
import tempfile

from typing import Optional
from collections.abc import Sequence

from gentoolkit.ekeyword import ekeyword


KeywordChanges = list[tuple[Optional[list[str]], Optional[list[str]]]]


def keyword_array(keyword_line: str) -> list[str]:
    # Find indices of string inside the double-quotes
    i1: int = keyword_line.find('"') + 1
    i2: int = keyword_line.rfind('"')

    # Split into array of KEYWORDS
    return keyword_line[i1:i2].split(' ')


def keyword_line_changes(old: str, new: str) -> KeywordChanges:
    a: list[str] = keyword_array(old)
    b: list[str] = keyword_array(new)

    s = difflib.SequenceMatcher(a=a, b=b)

    changes: KeywordChanges = []
    for tag, i1, i2, j1, j2 in s.get_opcodes():
        if tag == 'replace':
            changes.append((a[i1:i2], b[j1:j2]),)
        elif tag == 'delete':
            changes.append((a[i1:i2], None),)
        elif tag == 'insert':
            changes.append((None, b[j1:j2]),)
        else:
            assert tag == 'equal'
    return changes


def keyword_changes(ebuild1: str, ebuild2: str) -> Optional[KeywordChanges]:
    with open(ebuild1) as e1, open(ebuild2) as e2:
        lines1 = e1.readlines()
        lines2 = e2.readlines()

        diff = difflib.unified_diff(lines1, lines2, n=0)
        assert next(diff) == '--- \n'
        assert next(diff) == '+++ \n'

        hunk: int = 0
        old: str = ''
        new: str = ''

        for line in diff:
            if line.startswith('@@ '):
                if hunk > 0:
                    break
                hunk += 1
            elif line.startswith('-'):
                if old or new:
                    break
                old = line
            elif line.startswith('+'):
                if not old or new:
                    break
                new = line
        else:
            if 'KEYWORDS=' in old and 'KEYWORDS=' in new:
                return keyword_line_changes(old, new)
        return None


def apply_keyword_changes(ebuild: str, pathname: str,
                          changes: KeywordChanges) -> int:
    result: int = 0

    with tempfile.TemporaryDirectory() as tmpdir:
        # ekeyword will only modify files named *.ebuild, so make a symlink
        ebuild_symlink: str = os.path.join(tmpdir, os.path.basename(pathname))
        os.symlink(os.path.join(os.getcwd(), ebuild), ebuild_symlink)

        for removals, additions in changes:
            args = []
            if removals:
                for rem in removals:
                    # Drop leading '~' and '-' characters and prepend '^'
                    i = 1 if rem[0] in ('~', '-') else 0
                    args.append('^' + rem[i:])
            if additions:
                args.extend(additions)
            args.append(ebuild_symlink)

            result = ekeyword.main(args)
            if result != 0:
                break

    return result


def merge_keywords(O, A, B, P) -> int:
    # Get changes to KEYWORDS= from %O to %B
    if changes := keyword_changes(O, B):
        # Apply %O -> %B changes to %A
        return apply_keyword_changes(A, P, changes)
    return -1


def main(argv: Sequence[str]) -> int:
    if len(argv) != 4:
        return -1

    O = argv[0]  # %O - filename of original
    A = argv[1]  # %A - filename of our current version
    B = argv[2]  # %B - filename of the other branch's version
    P = argv[3]  # %P - original path of the file

    if merge_keywords(O, A, B, P) == 0:
        return 0

    # Try in reverse
    if merge_keywords(O, B, A, P) == 0:
        # Merged file should be left in %A
        shutil.move(B, A)
        return 0

    try:
        os.execlp("git", "git", "merge-file", "-L", "HEAD", "-L", "base", "-L", "ours", A, O, B)
    except OSError:
        return -1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))