Viewing files at commit 62758990fe31Commit detailsView latest

suites/merge/mergesuite/gitops.py 233 lines · 9.7 KB · python
1
"""Git plumbing used as fixture builder and as the merge-ort reference oracle.
2
3
Git is the *oracle* here, never the subject. Every call runs with an isolated
4
config (no system/global config leaks into fixtures or oracle results).
5
"""
6
from __future__ import annotations
7
8
import hashlib
9
import os
10
from pathlib import Path
11
import stat
12
import subprocess
13
from typing import Dict, List, Optional, Tuple
14
15
# A tree value is (kind, sha256-of-bytes). kind: file | exec | symlink | gitlink.
16
# Symlink bytes are the link target; gitlink "bytes" are the commit id text.
17
TreeValue = Tuple[str, str]
18
Tree = Dict[str, TreeValue]
19
20
_MODE_KIND = {"100644": "file", "100755": "exec", "120000": "symlink", "160000": "gitlink"}
21
22
23
def sha256(data: bytes) -> str:
24
    return hashlib.sha256(data).hexdigest()
25
26
27
def git_env(home: Path, extra: Optional[dict] = None) -> dict:
28
    env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
29
    env.update({
30
        "HOME": str(home),
31
        "GIT_CONFIG_NOSYSTEM": "1",
32
        "GIT_CONFIG_GLOBAL": str(home / "gitconfig"),
33
        "GIT_AUTHOR_NAME": "author", "GIT_AUTHOR_EMAIL": "[email protected]",
34
        "GIT_COMMITTER_NAME": "committer", "GIT_COMMITTER_EMAIL": "[email protected]",
35
        "GIT_AUTHOR_DATE": "2000-01-01 00:00:00 +0000",
36
        "GIT_COMMITTER_DATE": "2000-01-01 00:00:00 +0000",
37
        "LC_ALL": "C",
38
    })
39
    if extra:
40
        env.update(extra)
41
    return env
42
43
44
def prepare_git_home(home: Path, git_bin: str) -> None:
45
    home.mkdir(parents=True, exist_ok=True)
46
    cfg = home / "gitconfig"
47
    if not cfg.exists():
48
        cfg.write_text("[init]\n\tdefaultBranch = main\n[protocol \"file\"]\n\tallow = always\n"
49
                       "[advice]\n\tdetachedHead = false\n")
50
51
52
class Git:
53
    def __init__(self, repo: Path, git_bin: str, home: Path):
54
        self.repo, self.bin, self.home = Path(repo), git_bin, Path(home)
55
56
    def run(self, *args: str, input: Optional[bytes] = None, check: bool = True,
57
            env_extra: Optional[dict] = None) -> subprocess.CompletedProcess:
58
        p = subprocess.run([self.bin, *args], cwd=self.repo, env=git_env(self.home, env_extra),
59
                           input=input, capture_output=True, timeout=120)
60
        if check and p.returncode:
61
            raise RuntimeError(f"git {' '.join(args)} failed ({p.returncode}): "
62
                               f"{p.stderr.decode('utf-8', 'replace')[:500]}")
63
        return p
64
65
    def out(self, *args: str, **kw) -> str:
66
        return self.run(*args, **kw).stdout.decode("utf-8", "surrogateescape").strip()
67
68
    def rev(self, ref: str) -> str:
69
        return self.out("rev-parse", "--verify", ref + "^{commit}")
70
71
    def merge_bases(self, a: str, b: str) -> List[str]:
72
        p = self.run("merge-base", "--all", a, b, check=False)
73
        return p.stdout.decode().split() if p.returncode == 0 else []
74
75
    def is_ancestor(self, a: str, b: str) -> bool:
76
        return self.run("merge-base", "--is-ancestor", a, b, check=False).returncode == 0
77
78
    def cat(self, obj: str) -> bytes:
79
        return self.run("cat-file", "-p", obj).stdout
80
81
    def tree(self, treeish: str) -> Tree:
82
        """Full recursive tree as {path: (kind, sha256)}; blobs are read in one batch."""
83
        raw = self.run("ls-tree", "-r", "-z", "--full-tree", treeish).stdout
84
        entries = []
85
        for rec in raw.split(b"\0"):
86
            if not rec:
87
                continue
88
            meta, path = rec.split(b"\t", 1)
89
            mode, _typ, oid = meta.decode().split()
90
            entries.append((path.decode("utf-8", "surrogateescape"), mode, oid))
91
        blobs = self.blob_bytes([oid for _, mode, oid in entries if mode != "160000"])
92
        result: Tree = {}
93
        for path, mode, oid in entries:
94
            kind = _MODE_KIND.get(mode, "unknown:" + mode)
95
            data = oid.encode() if mode == "160000" else blobs[oid]
96
            result[path] = (kind, sha256(data))
97
        return result
98
99
    def blob_bytes(self, oids: List[str]) -> Dict[str, bytes]:
100
        uniq = sorted(set(oids))
101
        if not uniq:
102
            return {}
103
        out = self.run("cat-file", "--batch", input=("\n".join(uniq) + "\n").encode()).stdout
104
        result, pos = {}, 0
105
        for _ in uniq:
106
            nl = out.index(b"\n", pos)
107
            oid, _typ, size = out[pos:nl].decode().split()
108
            start = nl + 1
109
            result[oid] = out[start:start + int(size)]
110
            pos = start + int(size) + 1
111
        return result
112
113
    def materialize(self, treeish: str, dest: Path) -> None:
114
        """Write a git tree into dest (files, exec bits, symlinks). Gitlinks are refused."""
115
        raw = self.run("ls-tree", "-r", "-z", "--full-tree", treeish).stdout
116
        entries = []
117
        for rec in raw.split(b"\0"):
118
            if rec:
119
                meta, path = rec.split(b"\t", 1)
120
                mode, _typ, oid = meta.decode().split()
121
                entries.append((os.fsdecode(path), mode, oid))
122
        if any(mode == "160000" for _, mode, _ in entries):
123
            raise ValueError("gitlink entries cannot be materialized")
124
        blobs = self.blob_bytes([oid for _, _, oid in entries])
125
        for path, mode, oid in entries:
126
            target = dest / path
127
            target.parent.mkdir(parents=True, exist_ok=True)
128
            if mode == "120000":
129
                os.symlink(os.fsdecode(blobs[oid]), target)
130
            else:
131
                target.write_bytes(blobs[oid])
132
                if mode == "100755":
133
                    target.chmod(target.stat().st_mode | 0o111)
134
135
    def merge_tree(self, ours: str, theirs: str) -> dict:
136
        """git merge-ort via `merge-tree --write-tree -z --messages` on two commits."""
137
        p = self.run("-c", "merge.conflictStyle=merge", "merge-tree", "-z", "--write-tree",
138
                     "--allow-unrelated-histories", "--messages", ours, theirs, check=False)
139
        if p.returncode not in (0, 1):
140
            raise RuntimeError("git merge-tree failed: " + p.stderr.decode("utf-8", "replace")[:500])
141
        parsed = parse_merge_tree_z(p.stdout)
142
        parsed["clean"] = p.returncode == 0
143
        parsed["tree"] = self.tree(parsed["tree_oid"])
144
        return parsed
145
146
147
def parse_merge_tree_z(out: bytes) -> dict:
148
    """Parse `git merge-tree -z --write-tree --messages` output.
149
150
    Layout: <tree>NUL, then conflicted stage records `<mode> <oid> <stage>\\t<path>NUL`,
151
    then an empty record, then messages `<n>NUL<path>NUL...<type>NUL<message>NUL`.
152
    """
153
    fields = out.split(b"\0")
154
    tree_oid = fields[0].decode()
155
    i = 1
156
    stages: Dict[str, List[int]] = {}
157
    while i < len(fields) and fields[i]:
158
        meta, path = fields[i].split(b"\t", 1)
159
        _mode, _oid, stage = meta.decode().split()
160
        stages.setdefault(os.fsdecode(path), []).append(int(stage))
161
        i += 1
162
    i += 1  # skip section separator
163
    messages = []
164
    while i < len(fields) and fields[i]:
165
        count = int(fields[i])
166
        paths = [os.fsdecode(fields[i + 1 + k]) for k in range(count)]
167
        ctype = fields[i + 1 + count].decode("utf-8", "replace")
168
        msg = fields[i + 2 + count].decode("utf-8", "replace")
169
        messages.append({"paths": paths, "type": ctype, "message": msg.strip()})
170
        i += 3 + count
171
    return {"tree_oid": tree_oid, "conflict_stages": stages, "messages": messages}
172
173
174
def normalize_git_conflict_type(ctype: str) -> Optional[str]:
175
    """Map a merge-ort message type to the suite's conflict vocabulary (None = informational)."""
176
    t = ctype.lower()
177
    if not t.startswith("conflict"):
178
        return None
179
    inner = t[t.find("(") + 1:t.rfind(")")] if "(" in t else t
180
    table = {
181
        "contents": "content", "content": "content", "binary": "binary",
182
        "add/add": "add/add", "modify/delete": "modify/delete",
183
        "rename/delete": "rename/delete", "rename/rename": "rename/rename",
184
        "rename involved in collision": "rename/add", "file/directory": "file/directory",
185
        "directory/file": "file/directory", "distinct types": "distinct-types",
186
        "submodule": "submodule",
187
    }
188
    if inner in table:
189
        return table[inner]
190
    if "dir" in inner and "rename" in inner:
191
        return "directory-rename"
192
    if "rename" in inner:
193
        return "rename/other"
194
    return "other:" + inner
195
196
197
def git_conflicts(merge: dict) -> Dict[str, List[str]]:
198
    """{conflicted path: sorted normalized types}. Paths come from the stage section
199
    (what the index would mark unmerged) plus paths named by CONFLICT messages."""
200
    result: Dict[str, set] = {p: set() for p in merge["conflict_stages"]}
201
    for m in merge["messages"]:
202
        kind = normalize_git_conflict_type(m["type"])
203
        if kind is None:
204
            continue
205
        for p in m["paths"]:
206
            if p in result:
207
                result[p].add(kind)
208
        if not any(p in result for p in m["paths"]) and m["paths"]:
209
            # Message-only conflicts (e.g. directory rename split) still count.
210
            result.setdefault(m["paths"][0], set()).add(kind)
211
    return {p: sorted(v) for p, v in sorted(result.items())}
212
213
214
def worktree_manifest(root: Path) -> Tree:
215
    """Oak working-tree projection in the same (kind, sha256) shape as Git.tree()."""
216
    result: Tree = {}
217
218
    def visit(directory: Path) -> None:
219
        for path in sorted(directory.iterdir()):
220
            if directory == root and path.name in {".git", ".oak"}:
221
                continue
222
            mode = path.lstat().st_mode
223
            name = path.relative_to(root).as_posix()
224
            if stat.S_ISLNK(mode):
225
                result[name] = ("symlink", sha256(os.fsencode(os.readlink(path))))
226
            elif stat.S_ISDIR(mode):
227
                visit(path)
228
            elif stat.S_ISREG(mode):
229
                result[name] = ("exec" if mode & 0o111 else "file", sha256(path.read_bytes()))
230
            else:
231
                result[name] = ("unknown", "")
232
    visit(root)
233
    return result