| 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
|