git-overlap 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_overlap/__init__.py +20 -0
- git_overlap/_common.py +517 -0
- git_overlap/footprint.py +152 -0
- git_overlap/overlap.py +118 -0
- git_overlap/pairmerge.py +573 -0
- git_overlap-0.2.0.dist-info/METADATA +128 -0
- git_overlap-0.2.0.dist-info/RECORD +11 -0
- git_overlap-0.2.0.dist-info/WHEEL +5 -0
- git_overlap-0.2.0.dist-info/entry_points.txt +4 -0
- git_overlap-0.2.0.dist-info/licenses/LICENSE +202 -0
- git_overlap-0.2.0.dist-info/top_level.txt +1 -0
git_overlap/overlap.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""git overlap [--with REF]: report pairs of refs that touch the same path.
|
|
3
|
+
|
|
4
|
+
Reads footprint records (`ref TAB status TAB path`) on stdin and writes
|
|
5
|
+
`refA TAB refB TAB path` records with refA < refB bytewise. Needs no repository.
|
|
6
|
+
SPEC: "git overlap [--with REF]".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from ._common import (EXIT_FOUND, EXIT_NOTHING, Fail, check_safe, parse_args, read_records,
|
|
13
|
+
run, show, usage_and_version, write_record)
|
|
14
|
+
|
|
15
|
+
NAME = "overlap"
|
|
16
|
+
USAGE = """usage: git overlap [-z] [--with REF] < footprint
|
|
17
|
+
|
|
18
|
+
Read git footprint records (ref TAB status TAB path) and report each pair of refs that
|
|
19
|
+
touch the same path:
|
|
20
|
+
refA TAB refB TAB path (refA < refB bytewise; exit 1 if any, else 0)
|
|
21
|
+
|
|
22
|
+
--with REF only pairs that include REF
|
|
23
|
+
-z records end with NUL instead of LF, on input and output
|
|
24
|
+
-h show this help
|
|
25
|
+
--version show the version"""
|
|
26
|
+
|
|
27
|
+
def split_record(record):
|
|
28
|
+
"""Return (ref, path) of one `ref TAB status TAB path` record, or None if malformed.
|
|
29
|
+
|
|
30
|
+
One rule in both modes: the ref ends at the first TAB, the status (ignored) at the
|
|
31
|
+
second, and the path is everything after it, so a -z path may itself contain TAB.
|
|
32
|
+
Fewer than 3 fields, or an empty ref or path, is malformed.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
record: One input record, as bytes.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A (ref, path) bytes tuple, or None if the record is malformed.
|
|
39
|
+
|
|
40
|
+
SPEC: "git overlap" (Input).
|
|
41
|
+
"""
|
|
42
|
+
# maxsplit=2: any TAB after the second belongs to the path.
|
|
43
|
+
fields = record.split(b"\t", 2)
|
|
44
|
+
if len(fields) < 3 or fields[0] == b"" or fields[2] == b"":
|
|
45
|
+
return None
|
|
46
|
+
return fields[0], fields[2]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def overlap(argv):
|
|
50
|
+
"""Run git overlap.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
argv: The arguments, without the program name.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
EXIT_FOUND if any pair was written, else EXIT_NOTHING.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
Fail: A usage error, a malformed record or an unsafe name.
|
|
60
|
+
|
|
61
|
+
SPEC: "git overlap [--with REF]".
|
|
62
|
+
"""
|
|
63
|
+
opts, positionals = parse_args(argv, ("-z", "-h", "--version"), ("--with",))
|
|
64
|
+
if usage_and_version(opts, NAME, USAGE):
|
|
65
|
+
return EXIT_NOTHING
|
|
66
|
+
if positionals:
|
|
67
|
+
raise Fail("unexpected argument: %s (see -h)" % show(positionals[0]))
|
|
68
|
+
zero = bool(opts.get("-z"))
|
|
69
|
+
# The command line is text; os.fsencode gives back the bytes the caller typed.
|
|
70
|
+
with_ref = os.fsencode(opts["--with"]) if "--with" in opts else None
|
|
71
|
+
# An empty REF could never match a ref: it is a usage error, not "nothing found".
|
|
72
|
+
if with_ref == b"":
|
|
73
|
+
raise Fail("--with needs a non-empty REF (see -h)")
|
|
74
|
+
# path -> {ref: number of the first record naming that ref and path}.
|
|
75
|
+
touched = {}
|
|
76
|
+
for number, record in read_records(zero):
|
|
77
|
+
parsed = split_record(record)
|
|
78
|
+
if parsed is None:
|
|
79
|
+
raise Fail("record %d: expected ref TAB status TAB path, with a non-empty ref and path"
|
|
80
|
+
% number)
|
|
81
|
+
ref, path = parsed
|
|
82
|
+
touched.setdefault(path, {}).setdefault(ref, number)
|
|
83
|
+
# A set of (refA, refB, path) writes each duplicate once; a ref never pairs with itself.
|
|
84
|
+
pairs = set()
|
|
85
|
+
for path, refs in touched.items():
|
|
86
|
+
# Sorted, so each pair is built once with refA < refB bytewise.
|
|
87
|
+
ordered = sorted(refs)
|
|
88
|
+
for i, ref_a in enumerate(ordered):
|
|
89
|
+
for ref_b in ordered[i + 1:]:
|
|
90
|
+
if with_ref is None or with_ref in (ref_a, ref_b):
|
|
91
|
+
pairs.add((ref_a, ref_b, path))
|
|
92
|
+
# Tuples of bytes sort bytewise by (refA, refB, path), as the SPEC requires.
|
|
93
|
+
for pair in sorted(pairs):
|
|
94
|
+
ref_a, ref_b, path = pair
|
|
95
|
+
try:
|
|
96
|
+
for field in pair:
|
|
97
|
+
check_safe(field, zero, "ref or path")
|
|
98
|
+
except Fail as exc:
|
|
99
|
+
# Name the earliest input record behind this pair (SPEC "Record errors").
|
|
100
|
+
number = min(touched[path][ref_a], touched[path][ref_b])
|
|
101
|
+
raise Fail("record %d: %s" % (number, exc))
|
|
102
|
+
write_record(pair, zero)
|
|
103
|
+
return EXIT_FOUND if pairs else EXIT_NOTHING
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
"""Console-script entry point: run on sys.argv and exit with the SPEC's status.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
SystemExit: Always (0, 1 or 2).
|
|
111
|
+
|
|
112
|
+
SPEC: "Common rules" (console scripts) and "Exit status".
|
|
113
|
+
"""
|
|
114
|
+
run(NAME, lambda: overlap(sys.argv[1:]))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
git_overlap/pairmerge.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""git pairmerge [--attributes FILE] [--onto BASE]: report whether each pair of refs conflicts.
|
|
3
|
+
|
|
4
|
+
Reads records whose first two fields are refs and writes `refA TAB refB TAB clean TAB` or one
|
|
5
|
+
`refA TAB refB TAB conflict TAB path` per conflicted path. With --onto BASE a pair is merged as
|
|
6
|
+
the two refs would land on BASE, first one then the other, and a ref that no longer merges into
|
|
7
|
+
BASE makes its pairs `stale`. Merges run in memory with `git merge-tree --write-tree`; every
|
|
8
|
+
object git writes lands in a private temporary object directory, so the repository is never
|
|
9
|
+
changed. SPEC: "git pairmerge", "--onto BASE" and "Merge mechanism".
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
|
|
19
|
+
from ._common import (EXIT_FOUND, EXIT_NOTHING, OID, Fail, check_safe, git, git_error,
|
|
20
|
+
ignore_signals, install_signal_handlers, parse_args, read_records,
|
|
21
|
+
resolve, run, show, usage_and_version, write_record,
|
|
22
|
+
write_stderr_line, write_stdout)
|
|
23
|
+
|
|
24
|
+
NAME = "pairmerge"
|
|
25
|
+
USAGE = """usage: git pairmerge [-z] [--attributes FILE] [--onto BASE] < pairs
|
|
26
|
+
|
|
27
|
+
For each pair of refs (the first two fields of each record), merge them in memory:
|
|
28
|
+
refA TAB refB TAB clean TAB (the pair merges cleanly)
|
|
29
|
+
refA TAB refB TAB conflict TAB path (one record per conflicted path; exit 1)
|
|
30
|
+
With --onto BASE, a pair whose refA or refB no longer merges into BASE is written as
|
|
31
|
+
refA TAB refB TAB stale TAB path (one record per path conflicting with BASE; exit 1)
|
|
32
|
+
|
|
33
|
+
--attributes FILE merge as if FILE were the repository's .gitattributes (replacing it)
|
|
34
|
+
--onto BASE merge each pair as both refs would land on BASE, refA first
|
|
35
|
+
-z records end with NUL instead of LF, on input and output
|
|
36
|
+
-h show this help
|
|
37
|
+
--version show the version
|
|
38
|
+
Without --attributes, the repository's .gitattributes files are ignored (a plain line merge);
|
|
39
|
+
$GIT_DIR/info/attributes, core.attributesFile and configured merge drivers still apply."""
|
|
40
|
+
|
|
41
|
+
# --attr-source arrived in git 2.41; merge-tree crashed with it until 2.43.
|
|
42
|
+
GIT_FLOOR = (2, 43)
|
|
43
|
+
|
|
44
|
+
# Merge settings pinned to git's documented defaults, so user configuration cannot flip a
|
|
45
|
+
# verdict (SPEC "Merge settings are pinned"). From Documentation/config/merge.txt (identical
|
|
46
|
+
# in git 2.43 and 2.50):
|
|
47
|
+
# merge.renames "Defaults to the value of diff.renames", and diff.renames
|
|
48
|
+
# (Documentation/config/diff.txt) "Defaults to true".
|
|
49
|
+
# merge.directoryRenames "Defaults to conflict."
|
|
50
|
+
# merge.renameLimit "If not specified, defaults to the value of diff.renameLimit. If
|
|
51
|
+
# neither merge.renameLimit nor diff.renameLimit are specified,
|
|
52
|
+
# currently defaults to 7000." Setting merge.renameLimit explicitly
|
|
53
|
+
# ends that fallback chain, so diff.renameLimit (whose own default,
|
|
54
|
+
# 1000, applies to diff only) needs no pin.
|
|
55
|
+
PINNED_CONFIG = ("-c", "merge.renames=true", "-c", "merge.directoryRenames=conflict",
|
|
56
|
+
"-c", "merge.renameLimit=7000")
|
|
57
|
+
|
|
58
|
+
# The landing commit M_R of --onto: fixed author, committer and dates, so its id (which git
|
|
59
|
+
# embeds in PATH~<id> names) is the same on every run (SPEC "--onto BASE", Mechanism). The
|
|
60
|
+
# .invalid domain (RFC 2606) can never be a real address.
|
|
61
|
+
LANDING_IDENTITY = {"GIT_AUTHOR_NAME": "git-overlap", "GIT_AUTHOR_EMAIL": "git-overlap@invalid",
|
|
62
|
+
"GIT_AUTHOR_DATE": "@0 +0000", "GIT_COMMITTER_NAME": "git-overlap",
|
|
63
|
+
"GIT_COMMITTER_EMAIL": "git-overlap@invalid",
|
|
64
|
+
"GIT_COMMITTER_DATE": "@0 +0000"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_git_version():
|
|
68
|
+
"""Fail unless `git version` reports at least GIT_FLOOR.
|
|
69
|
+
|
|
70
|
+
Accepts forms such as "git version 2.50.1 (Apple Git-155)" and
|
|
71
|
+
"git version 2.43.0.windows.1": only the first two numbers after "git version" count.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
Fail: git version fails, prints no version, or is older than GIT_FLOOR.
|
|
75
|
+
|
|
76
|
+
SPEC: "Merge mechanism" (version floor for git pairmerge).
|
|
77
|
+
"""
|
|
78
|
+
code, out, err = git(["version"])
|
|
79
|
+
if code != 0:
|
|
80
|
+
raise Fail("cannot run git version: %s" % git_error(err, code))
|
|
81
|
+
# search, not match: text before the version number is tolerated.
|
|
82
|
+
match = re.search(rb"git version (\d+)\.(\d+)", out)
|
|
83
|
+
if not match:
|
|
84
|
+
raise Fail("cannot read the git version from %r; git pairmerge needs git %d.%d or later"
|
|
85
|
+
% (show(out.strip()), GIT_FLOOR[0], GIT_FLOOR[1]))
|
|
86
|
+
found = (int(match.group(1)), int(match.group(2)))
|
|
87
|
+
if found < GIT_FLOOR:
|
|
88
|
+
raise Fail("git %d.%d is too old; git pairmerge needs git %d.%d or later"
|
|
89
|
+
% (found + GIT_FLOOR))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_pairs(zero):
|
|
93
|
+
"""Read the pairs from stdin: the first two fields of each record, the rest ignored.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
zero: True for -z mode, False for LF mode.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
A list of (record number, refA, refB) tuples, refs as bytes, in input order.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
Fail: A record is malformed or, in LF mode, a ref holds CR.
|
|
103
|
+
|
|
104
|
+
SPEC: "git pairmerge" (Input) and "Malformed input".
|
|
105
|
+
"""
|
|
106
|
+
pairs = []
|
|
107
|
+
for number, record in read_records(zero):
|
|
108
|
+
# Split on every TAB: fields after the second are ignored, so overlap output pipes in.
|
|
109
|
+
fields = record.split(b"\t")
|
|
110
|
+
# Fewer than 2 fields, or an empty ref, is malformed input.
|
|
111
|
+
if len(fields) < 2 or fields[0] == b"" or fields[1] == b"":
|
|
112
|
+
raise Fail("record %d: expected two TAB-separated refs" % number)
|
|
113
|
+
# In LF mode a CR inside a ref could not be written back out as given.
|
|
114
|
+
for ref in fields[:2]:
|
|
115
|
+
try:
|
|
116
|
+
check_safe(ref, zero, "ref")
|
|
117
|
+
except Fail as exc:
|
|
118
|
+
raise Fail("record %d: %s" % (number, exc))
|
|
119
|
+
pairs.append((number, fields[0], fields[1]))
|
|
120
|
+
return pairs
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Variables a caller may set to relative paths, which git resolves against its working
|
|
124
|
+
# directory. merge-tree runs at the repository root, so they are made absolute first.
|
|
125
|
+
_PATH_VARIABLES = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR",
|
|
126
|
+
"GIT_OBJECT_DIRECTORY")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def absolutise_git_paths():
|
|
130
|
+
"""Make relative GIT_DIR-style variables absolute, relative to our (the caller's) cwd.
|
|
131
|
+
|
|
132
|
+
Changes os.environ in place, so every later git call inherits the absolute paths.
|
|
133
|
+
|
|
134
|
+
SPEC: "Merge mechanism" (merge-tree runs at the repository root).
|
|
135
|
+
"""
|
|
136
|
+
for name in _PATH_VARIABLES:
|
|
137
|
+
value = os.environ.get(name)
|
|
138
|
+
if value and not os.path.isabs(value):
|
|
139
|
+
os.environ[name] = os.path.abspath(value)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def c_quote(path):
|
|
143
|
+
"""Quote one GIT_ALTERNATE_OBJECT_DIRECTORIES entry C-style in double quotes.
|
|
144
|
+
|
|
145
|
+
git splits that variable on the path separator, but an entry starting with '"' is first
|
|
146
|
+
unquoted C-style (object-file.c, parse_alt_odb_entry), so a quoted entry may hold the
|
|
147
|
+
separator. Backslash and '"' get a backslash; control characters become octal escapes.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
path: The directory, as str.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The quoted entry, including its surrounding double quotes.
|
|
154
|
+
|
|
155
|
+
SPEC: "Merge mechanism" (GIT_ALTERNATE_OBJECT_DIRECTORIES quoting).
|
|
156
|
+
"""
|
|
157
|
+
out = []
|
|
158
|
+
for char in path:
|
|
159
|
+
if char in '\\"':
|
|
160
|
+
out.append("\\" + char)
|
|
161
|
+
elif ord(char) < 0x20 or ord(char) == 0x7F:
|
|
162
|
+
out.append("\\%03o" % ord(char))
|
|
163
|
+
else:
|
|
164
|
+
out.append(char)
|
|
165
|
+
return '"' + "".join(out) + '"'
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def repository_layout():
|
|
169
|
+
"""Return (objects dir, root dir), both absolute, asked before any environment override.
|
|
170
|
+
|
|
171
|
+
The objects dir is `git rev-parse --git-path objects` (it honours a caller's own
|
|
172
|
+
GIT_OBJECT_DIRECTORY). The root is the top of the worktree, or the git directory when
|
|
173
|
+
there is no worktree (a bare repository, or running inside .git), so that merge-tree,
|
|
174
|
+
run there, prints root-relative paths.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
A tuple (objects directory, root directory), both absolute str.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
Fail: Not inside a git repository, or the root cannot be found.
|
|
181
|
+
|
|
182
|
+
SPEC: "Merge mechanism" and "Paths are relative to the repository root".
|
|
183
|
+
"""
|
|
184
|
+
code, out, err = git(["rev-parse", "--git-path", "objects"])
|
|
185
|
+
if code != 0:
|
|
186
|
+
raise Fail("not a git repository: %s" % git_error(err, code))
|
|
187
|
+
# abspath: git may print a path relative to our cwd, but merge-tree runs elsewhere.
|
|
188
|
+
objects = os.path.abspath(os.fsdecode(out.rstrip(b"\r\n")))
|
|
189
|
+
code, out, _ = git(["rev-parse", "--show-toplevel"])
|
|
190
|
+
root = out.rstrip(b"\r\n")
|
|
191
|
+
if code != 0 or not root:
|
|
192
|
+
code, out, err = git(["rev-parse", "--absolute-git-dir"])
|
|
193
|
+
if code != 0:
|
|
194
|
+
raise Fail("cannot find the repository root: %s" % git_error(err, code))
|
|
195
|
+
root = out.rstrip(b"\r\n")
|
|
196
|
+
return objects, os.fsdecode(root)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def private_object_env(tempdir, objects):
|
|
200
|
+
"""Return the environment that sends new objects to `tempdir` and reads `objects`.
|
|
201
|
+
|
|
202
|
+
`objects` is C-quoted when it holds the path separator or starts with '"'. An alternates
|
|
203
|
+
list the caller already set is kept after it.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
tempdir: The private temporary object directory.
|
|
207
|
+
objects: The repository's absolute object directory.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
A dict of the two environment variables to pass to git.
|
|
211
|
+
|
|
212
|
+
SPEC: "Merge mechanism" (nothing is written to the repository).
|
|
213
|
+
"""
|
|
214
|
+
entry = objects
|
|
215
|
+
# Quote only when needed (SPEC): a plain entry is read verbatim.
|
|
216
|
+
if os.pathsep in entry or entry.startswith('"'):
|
|
217
|
+
entry = c_quote(entry)
|
|
218
|
+
existing = os.environ.get("GIT_ALTERNATE_OBJECT_DIRECTORIES")
|
|
219
|
+
if existing:
|
|
220
|
+
entry = entry + os.pathsep + existing
|
|
221
|
+
return {"GIT_OBJECT_DIRECTORY": tempdir, "GIT_ALTERNATE_OBJECT_DIRECTORIES": entry}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def check_object_access(env):
|
|
225
|
+
"""Fail, naming the set-up, if the repository's objects are unreadable through `env`.
|
|
226
|
+
|
|
227
|
+
Probes one object a ref points at (read without the override) through the private
|
|
228
|
+
environment, so a broken alternates entry is never reported as an unknown ref. A
|
|
229
|
+
repository with no refs has nothing to probe.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
env: The private object environment from private_object_env.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
Fail: The probe object cannot be read through `env`.
|
|
236
|
+
|
|
237
|
+
SPEC: "Merge mechanism" (a set-up failure is never reported as an unknown ref).
|
|
238
|
+
"""
|
|
239
|
+
# Ask without `env`, so the probe id comes from the repository as the caller sees it.
|
|
240
|
+
code, out, _ = git(["for-each-ref", "--count=1", "--format=%(objectname)"])
|
|
241
|
+
oid = out.strip()
|
|
242
|
+
if code != 0 or not OID.match(oid):
|
|
243
|
+
return
|
|
244
|
+
code, _, err = git(["cat-file", "-e", oid], env)
|
|
245
|
+
if code != 0:
|
|
246
|
+
raise Fail("cannot set up the private object directory (repository objects not "
|
|
247
|
+
"readable through GIT_ALTERNATE_OBJECT_DIRECTORIES): %s" % git_error(err, code))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def attribute_tree(attributes, env):
|
|
251
|
+
"""Write the pinned attribute-source tree into the private object dir; return its id.
|
|
252
|
+
|
|
253
|
+
Without FILE it is the empty tree (written explicitly: it need not exist as an object).
|
|
254
|
+
With FILE it is a tree holding FILE's bytes as its root .gitattributes.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
attributes: FILE's bytes, or None without --attributes.
|
|
258
|
+
env: The private object environment.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
The tree id (bytes).
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
Fail: git cannot write the blob or the tree.
|
|
265
|
+
|
|
266
|
+
SPEC: "Merge mechanism" (the attribute source is always pinned).
|
|
267
|
+
"""
|
|
268
|
+
if attributes is None:
|
|
269
|
+
# The empty tree is hashed from empty input, so it is the same id in any repository.
|
|
270
|
+
code, out, err = git(["hash-object", "-w", "-t", "tree", "--stdin"], env, b"")
|
|
271
|
+
else:
|
|
272
|
+
# --no-filters: FILE is stored as its exact bytes, untouched by clean filters or EOL.
|
|
273
|
+
code, out, err = git(["hash-object", "-w", "--no-filters", "--stdin"], env, attributes)
|
|
274
|
+
if code == 0:
|
|
275
|
+
# mktree -z reads one NUL-terminated "mode type oid TAB name" entry.
|
|
276
|
+
entry = b"100644 blob " + out.strip() + b"\t.gitattributes\0"
|
|
277
|
+
code, out, err = git(["mktree", "-z"], env, entry)
|
|
278
|
+
if code != 0:
|
|
279
|
+
raise Fail("cannot build the attribute tree: %s" % git_error(err, code))
|
|
280
|
+
return out.strip()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def check_merge_base(sha_a, sha_b, env, ref_a, ref_b):
|
|
284
|
+
"""Fail with "no merge base" if two commits share no history (decided by exit status).
|
|
285
|
+
|
|
286
|
+
`git merge-base` exits 1 with no output when there is no common ancestor; checking first
|
|
287
|
+
means git's own refusal message from merge-tree is never parsed.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
sha_a: The first commit id (bytes).
|
|
291
|
+
sha_b: The second commit id (bytes).
|
|
292
|
+
env: The private object environment.
|
|
293
|
+
ref_a: The first ref as written, for messages.
|
|
294
|
+
ref_b: The second ref as written, for messages.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
Fail: No merge base (naming both refs), or git merge-base fails.
|
|
298
|
+
|
|
299
|
+
SPEC: "Shallow or unrelated histories".
|
|
300
|
+
"""
|
|
301
|
+
code, out, err = git(["merge-base", sha_a, sha_b], env)
|
|
302
|
+
if code == 1 and not out.strip():
|
|
303
|
+
raise Fail("no merge base for %s and %s" % (show(ref_a), show(ref_b)))
|
|
304
|
+
if code != 0:
|
|
305
|
+
raise Fail("git merge-base failed for %s and %s: %s"
|
|
306
|
+
% (show(ref_a), show(ref_b), git_error(err, code)))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def merge(tree, sha_a, sha_b, env, root, ref_a, ref_b):
|
|
310
|
+
"""Merge two commits in memory; return the merged tree id and the conflicted paths.
|
|
311
|
+
|
|
312
|
+
Clean/conflict is decided by the exit code and the tree id, never by git's messages. A
|
|
313
|
+
conflict with no listed paths gives [b""], written as one record with an empty path.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
tree: The attribute-source tree id (bytes).
|
|
317
|
+
sha_a: The first commit id (bytes).
|
|
318
|
+
sha_b: The second commit id (bytes).
|
|
319
|
+
env: The private object environment.
|
|
320
|
+
root: The repository root, where merge-tree runs.
|
|
321
|
+
ref_a: The first ref as written, for messages.
|
|
322
|
+
ref_b: The second ref as written, for messages.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
A tuple (tree id, paths): paths is None if the pair merges cleanly, else a sorted list
|
|
326
|
+
of conflicted paths (bytes). The tree id is merge-tree's first field, as bytes.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
Fail: merge-tree exits with anything but a clean result or a conflict.
|
|
330
|
+
|
|
331
|
+
SPEC: "Merge mechanism" (Per pair).
|
|
332
|
+
"""
|
|
333
|
+
# Run at the repository root, so the paths merge-tree prints are root-relative.
|
|
334
|
+
code, out, err = git(list(PINNED_CONFIG) +
|
|
335
|
+
["--attr-source=" + os.fsdecode(tree), "merge-tree", "--write-tree",
|
|
336
|
+
"-z", "--name-only", "--no-messages", "--end-of-options",
|
|
337
|
+
sha_a, sha_b], env, cwd=root)
|
|
338
|
+
# Output is "TREE NUL PATH NUL PATH NUL ..."; a clean merge prints the tree alone.
|
|
339
|
+
fields = out.split(b"\0")
|
|
340
|
+
if code == 0:
|
|
341
|
+
return fields[0], None
|
|
342
|
+
# A conflict needs a real tree id first.
|
|
343
|
+
if code == 1 and OID.match(fields[0]):
|
|
344
|
+
# set(): never write one path twice, whatever git lists; sorted() is bytewise.
|
|
345
|
+
paths = sorted(set(path for path in fields[1:] if path != b""))
|
|
346
|
+
return fields[0], paths or [b""]
|
|
347
|
+
raise Fail("git merge-tree failed for %s and %s: %s"
|
|
348
|
+
% (show(ref_a), show(ref_b), git_error(err, code)))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def land(tree, base, sha, env, root, onto, ref):
|
|
352
|
+
"""Merge one ref onto BASE on its own; return its landing commit, or its stale paths.
|
|
353
|
+
|
|
354
|
+
A clean merge's tree becomes the commit M_R = commit-tree TREE -p BASE -p R, the merge
|
|
355
|
+
commit landing R would create, written to the private object directory with a fixed
|
|
356
|
+
identity, date, encoding and no signature, so its id is the same on every run.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
tree: The attribute-source tree id (bytes).
|
|
360
|
+
base: BASE's commit id (bytes).
|
|
361
|
+
sha: The ref's commit id (bytes).
|
|
362
|
+
env: The private object environment.
|
|
363
|
+
root: The repository root, where merge-tree runs.
|
|
364
|
+
onto: BASE as the caller wrote it, for messages.
|
|
365
|
+
ref: The ref as written, for messages.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
A tuple (M_R id, None) if the ref merges cleanly into BASE, else (None, paths), the
|
|
369
|
+
sorted paths on which it conflicts with BASE ([b""] if git lists none).
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
Fail: No merge base with BASE, or merge-tree or commit-tree fails.
|
|
373
|
+
|
|
374
|
+
SPEC: "--onto BASE" (Stale refs, Mechanism).
|
|
375
|
+
"""
|
|
376
|
+
check_merge_base(base, sha, env, onto, ref)
|
|
377
|
+
merged, paths = merge(tree, base, sha, env, root, onto, ref)
|
|
378
|
+
if paths is not None:
|
|
379
|
+
return None, paths
|
|
380
|
+
# i18n.commitEncoding and --no-gpg-sign pinned: user configuration cannot change the id.
|
|
381
|
+
code, out, err = git(["-c", "i18n.commitEncoding=UTF-8", "commit-tree", "--no-gpg-sign",
|
|
382
|
+
merged, "-p", base, "-p", sha, "-m", "pairmerge onto"],
|
|
383
|
+
dict(env, **LANDING_IDENTITY))
|
|
384
|
+
commit = out.strip()
|
|
385
|
+
if code != 0 or not OID.match(commit):
|
|
386
|
+
raise Fail("git commit-tree failed for %s onto %s: %s"
|
|
387
|
+
% (show(ref), show(onto), git_error(err, code)))
|
|
388
|
+
return commit, None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def merge_onto(tree, base, landed, sha_a, sha_b, env, root, onto, ref_a, ref_b):
|
|
392
|
+
"""Merge a pair as it would land on BASE, refA first; return (status, paths).
|
|
393
|
+
|
|
394
|
+
Each distinct commit is landed once (cached in `landed`, keyed by commit id) and reused
|
|
395
|
+
across pairs. If either side is stale the pair is stale, with the union of the stale
|
|
396
|
+
side(s)' paths; otherwise the pair is merge-tree M_A B, with git's own merge base.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
tree: The attribute-source tree id (bytes).
|
|
400
|
+
base: BASE's commit id (bytes).
|
|
401
|
+
landed: The cache {commit id: land() result}, filled as refs are first used.
|
|
402
|
+
sha_a: The first ref's commit id (bytes), which lands first.
|
|
403
|
+
sha_b: The second ref's commit id (bytes).
|
|
404
|
+
env: The private object environment.
|
|
405
|
+
root: The repository root, where merge-tree runs.
|
|
406
|
+
onto: BASE as the caller wrote it, for messages.
|
|
407
|
+
ref_a: The first ref as written, for messages.
|
|
408
|
+
ref_b: The second ref as written, for messages.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
A tuple (status, paths): (b"clean", None), or b"conflict" or b"stale" with a sorted
|
|
412
|
+
list of paths ([b""] if there are none to list).
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
Fail: A merge-tree, merge-base or commit-tree call fails.
|
|
416
|
+
|
|
417
|
+
SPEC: "--onto BASE" (Output per pair, Mechanism, Order).
|
|
418
|
+
"""
|
|
419
|
+
for sha, ref in ((sha_a, ref_a), (sha_b, ref_b)):
|
|
420
|
+
if sha not in landed:
|
|
421
|
+
landed[sha] = land(tree, base, sha, env, root, onto, ref)
|
|
422
|
+
# The paths of each stale side (a side that landed has None there).
|
|
423
|
+
stale_sides = [landed[sha][1] for sha in (sha_a, sha_b) if landed[sha][1] is not None]
|
|
424
|
+
if stale_sides:
|
|
425
|
+
# b"" marks "git listed no path"; it is not a path, so it joins no union.
|
|
426
|
+
union = set(path for paths in stale_sides for path in paths if path != b"")
|
|
427
|
+
return b"stale", sorted(union) or [b""]
|
|
428
|
+
# No merge-base check: M_A has BASE as a parent, and B merged with BASE, so they share one.
|
|
429
|
+
_, paths = merge(tree, landed[sha_a][0], sha_b, env, root, ref_a, ref_b)
|
|
430
|
+
if paths is None:
|
|
431
|
+
return b"clean", None
|
|
432
|
+
return b"conflict", paths
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def remove_tree(path):
|
|
436
|
+
"""Delete `path` recursively, making read-only files writable first (Windows needs it).
|
|
437
|
+
|
|
438
|
+
If anything is left, warn on stderr naming the directory; the exit status is unchanged.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
path: The directory to remove.
|
|
442
|
+
|
|
443
|
+
SPEC: "Merge mechanism" (the temporary directory is removed).
|
|
444
|
+
"""
|
|
445
|
+
for root, dirs, files in os.walk(path):
|
|
446
|
+
for name in dirs + files:
|
|
447
|
+
try:
|
|
448
|
+
os.chmod(os.path.join(root, name), stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
|
|
449
|
+
except OSError:
|
|
450
|
+
pass
|
|
451
|
+
# ignore_errors: what cannot be removed is reported below, not raised mid-cleanup.
|
|
452
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
453
|
+
if os.path.exists(path):
|
|
454
|
+
# Not fatal (the result stands), but the caller should know what was left behind.
|
|
455
|
+
write_stderr_line("git-%s: warning: could not remove temporary directory %s"
|
|
456
|
+
% (NAME, show(path)))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def pairmerge(argv):
|
|
460
|
+
"""Run git pairmerge.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
argv: The arguments, without the program name.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
EXIT_FOUND if any pair conflicts (or, with --onto, is stale), else EXIT_NOTHING.
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
Fail: A usage error, git too old, an unreadable FILE, an unknown BASE, a malformed
|
|
470
|
+
record, an unknown ref, no merge base, or a merge-tree or commit-tree failure.
|
|
471
|
+
Interrupted: SIGINT, SIGTERM or SIGHUP arrived (after the cleanup ran).
|
|
472
|
+
|
|
473
|
+
SPEC: "git pairmerge", "--onto BASE" and "Merge mechanism".
|
|
474
|
+
"""
|
|
475
|
+
opts, positionals = parse_args(argv, ("-z", "-h", "--version"),
|
|
476
|
+
("--attributes", "--onto"))
|
|
477
|
+
if usage_and_version(opts, NAME, USAGE):
|
|
478
|
+
return EXIT_NOTHING
|
|
479
|
+
if positionals:
|
|
480
|
+
raise Fail("unexpected argument: %s (see -h)" % show(positionals[0]))
|
|
481
|
+
zero = bool(opts.get("-z"))
|
|
482
|
+
# From here on SIGINT/SIGTERM/SIGHUP unwind through the cleanup below (exit 128 + n).
|
|
483
|
+
install_signal_handlers()
|
|
484
|
+
check_git_version()
|
|
485
|
+
# FILE is read as bytes, once, before anything else touches git objects.
|
|
486
|
+
attributes = None
|
|
487
|
+
if "--attributes" in opts:
|
|
488
|
+
try:
|
|
489
|
+
with open(opts["--attributes"], "rb") as handle:
|
|
490
|
+
attributes = handle.read()
|
|
491
|
+
except OSError as exc:
|
|
492
|
+
raise Fail("cannot read --attributes file: %s" % exc)
|
|
493
|
+
onto = opts.get("--onto")
|
|
494
|
+
base = None
|
|
495
|
+
if onto is not None:
|
|
496
|
+
# Both spellings, "--onto ''" and "--onto=", reach this usage error.
|
|
497
|
+
if onto == "":
|
|
498
|
+
raise Fail("--onto needs a non-empty BASE (see -h)")
|
|
499
|
+
# BASE is resolved before stdin is read, as every command's BASE is.
|
|
500
|
+
base = resolve(onto)
|
|
501
|
+
pairs = read_pairs(zero)
|
|
502
|
+
# Before any git call that may run elsewhere: relative paths from the caller must not
|
|
503
|
+
# change meaning when merge-tree runs at the repository root.
|
|
504
|
+
absolutise_git_paths()
|
|
505
|
+
objects, root = repository_layout()
|
|
506
|
+
tempdir = None
|
|
507
|
+
try:
|
|
508
|
+
tempdir = tempfile.mkdtemp(prefix="git-pairmerge-")
|
|
509
|
+
env = private_object_env(tempdir, objects)
|
|
510
|
+
check_object_access(env)
|
|
511
|
+
# Check every ref before any merge runs; a ref is resolved once however often used.
|
|
512
|
+
shas = {}
|
|
513
|
+
for number, ref_a, ref_b in pairs:
|
|
514
|
+
for ref in (ref_a, ref_b):
|
|
515
|
+
if ref not in shas:
|
|
516
|
+
try:
|
|
517
|
+
shas[ref] = resolve(ref, env)
|
|
518
|
+
except Fail as exc:
|
|
519
|
+
raise Fail("record %d: %s" % (number, exc))
|
|
520
|
+
tree = attribute_tree(attributes, env)
|
|
521
|
+
seen = set()
|
|
522
|
+
# --onto only: each distinct commit's land() result, computed once for the whole run.
|
|
523
|
+
landed = {}
|
|
524
|
+
found = False
|
|
525
|
+
for number, ref_a, ref_b in pairs:
|
|
526
|
+
# A pair is the same in either order: process it once, at its first occurrence.
|
|
527
|
+
key = (min(ref_a, ref_b), max(ref_a, ref_b))
|
|
528
|
+
if key in seen:
|
|
529
|
+
continue
|
|
530
|
+
seen.add(key)
|
|
531
|
+
try:
|
|
532
|
+
if base is None:
|
|
533
|
+
check_merge_base(shas[ref_a], shas[ref_b], env, ref_a, ref_b)
|
|
534
|
+
_, paths = merge(tree, shas[ref_a], shas[ref_b], env, root, ref_a, ref_b)
|
|
535
|
+
status = b"clean" if paths is None else b"conflict"
|
|
536
|
+
else:
|
|
537
|
+
status, paths = merge_onto(tree, base, landed, shas[ref_a], shas[ref_b],
|
|
538
|
+
env, root, onto, ref_a, ref_b)
|
|
539
|
+
if paths is None:
|
|
540
|
+
write_record((ref_a, ref_b, b"clean", b""), zero)
|
|
541
|
+
else:
|
|
542
|
+
found = True
|
|
543
|
+
for path in paths:
|
|
544
|
+
check_safe(path, zero, "path")
|
|
545
|
+
write_record((ref_a, ref_b, status, path), zero)
|
|
546
|
+
# Hand each finished pair to the reader now: a long run shows progress, and a
|
|
547
|
+
# run that is stopped keeps every pair it completed (SPEC "Output timing").
|
|
548
|
+
write_stdout(None)
|
|
549
|
+
except Fail as exc:
|
|
550
|
+
# Errors about this pair name its record (SPEC "Record errors").
|
|
551
|
+
raise Fail("record %d: %s" % (number, exc))
|
|
552
|
+
finally:
|
|
553
|
+
# The private object directory goes on every exit path, errors and signals included;
|
|
554
|
+
# further signals are ignored so they cannot cut the cleanup short.
|
|
555
|
+
ignore_signals()
|
|
556
|
+
if tempdir is not None:
|
|
557
|
+
remove_tree(tempdir)
|
|
558
|
+
return EXIT_FOUND if found else EXIT_NOTHING
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def main():
|
|
562
|
+
"""Console-script entry point: run on sys.argv and exit with the SPEC's status.
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
SystemExit: Always (0, 1, 2, or 128 + signal number).
|
|
566
|
+
|
|
567
|
+
SPEC: "Common rules" (console scripts) and "Exit status".
|
|
568
|
+
"""
|
|
569
|
+
run(NAME, lambda: pairmerge(sys.argv[1:]))
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
if __name__ == "__main__":
|
|
573
|
+
main()
|