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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""git-overlap: git subcommands that read and write TAB-separated records.
|
|
3
|
+
|
|
4
|
+
The package holds three commands, each a module with a ``main()`` console-script entry point:
|
|
5
|
+
``footprint`` (SPEC.md "git footprint BASE"), ``overlap`` (SPEC.md "git overlap") and
|
|
6
|
+
``pairmerge`` (SPEC.md "git pairmerge"). ``_common`` holds what they share: records,
|
|
7
|
+
arguments, errors and running git (SPEC.md "Common rules").
|
|
8
|
+
|
|
9
|
+
Documentation conventions, enforced by tests/test_documentation.py:
|
|
10
|
+
|
|
11
|
+
* Every module, class and function has a PEP 257 docstring in Google style: a one-line
|
|
12
|
+
summary, then, where relevant, ``Args:``, ``Returns:``, ``Raises:`` and a ``SPEC:`` line
|
|
13
|
+
naming the SPEC.md section the code implements.
|
|
14
|
+
* Comments explain why (git quirks, platform behaviour, byte handling, ordering), never
|
|
15
|
+
restate what the code says. Each file carries at least one comment line per ten code lines.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Single source of the version string, printed by every command's --version
|
|
19
|
+
# (SPEC.md "Common rules": `--version` prints `git-<name> <version>`).
|
|
20
|
+
__version__ = "0.2.0"
|
git_overlap/_common.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Shared plumbing for the git-overlap commands: records, arguments, errors and git.
|
|
3
|
+
|
|
4
|
+
Everything that crosses a process boundary (stdin, stdout, git's output) is bytes. Nothing is
|
|
5
|
+
decoded except to build a human-readable error message for stderr.
|
|
6
|
+
|
|
7
|
+
SPEC: "Common rules (all three commands)".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import errno
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
|
|
19
|
+
# Exit codes, as grep and diff use them (SPEC "Exit status").
|
|
20
|
+
EXIT_NOTHING = 0 # ran, nothing found
|
|
21
|
+
EXIT_FOUND = 1 # ran, something found
|
|
22
|
+
EXIT_ERROR = 2 # error
|
|
23
|
+
|
|
24
|
+
# Bytes that cannot appear inside a field of an LF-terminated record (SPEC "Unsafe names").
|
|
25
|
+
_UNSAFE_LF = (b"\t", b"\n", b"\r")
|
|
26
|
+
|
|
27
|
+
# One object id: 40 hex digits (SHA-1) or 64 (SHA-256). \A and \Z, not ^ and $, because $
|
|
28
|
+
# also matches before a trailing LF, which would let "oid\n" through as an object id.
|
|
29
|
+
OID = re.compile(rb"\A(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
|
|
30
|
+
|
|
31
|
+
# Escapes for error messages (SPEC "Errors"); other unprintable bytes become \xHH.
|
|
32
|
+
_ESCAPES = {0x09: "\\t", 0x0A: "\\n", 0x0D: "\\r", 0x5C: "\\\\"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Fail(Exception):
|
|
36
|
+
"""A user-facing error: `run` prints `git-<name>: <message>` and exits 2.
|
|
37
|
+
|
|
38
|
+
SPEC: "Errors" and "Exit status".
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PipeClosed(Exception):
|
|
43
|
+
"""Raised when a write to stdout finds the reader gone; `run` exits 0 quietly.
|
|
44
|
+
|
|
45
|
+
SPEC: "Standard streams" (EPIPE is not an error).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Interrupted(BaseException):
|
|
50
|
+
"""Raised by a signal handler so `finally` blocks run; `run` exits 128 + signum.
|
|
51
|
+
|
|
52
|
+
A BaseException, not an Exception, so the catch-all `except Exception` in `run` and any
|
|
53
|
+
`except Fail` on the way up cannot swallow it before cleanup runs.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
signum: The number of the signal that arrived.
|
|
57
|
+
|
|
58
|
+
SPEC: "Merge mechanism" (cleanup on SIGINT, SIGTERM and SIGHUP).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, signum):
|
|
62
|
+
"""Remember which signal arrived.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
signum: The signal number passed to the handler.
|
|
66
|
+
"""
|
|
67
|
+
BaseException.__init__(self, signum)
|
|
68
|
+
self.signum = signum
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def show(data):
|
|
72
|
+
"""Escape a ref or path for an error message, so it stays on one line.
|
|
73
|
+
|
|
74
|
+
TAB, LF, CR and backslash become backslash escapes; any other byte that is not printable
|
|
75
|
+
ASCII becomes \\xHH. Used for stderr only, never for records.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
data: The name, as bytes (from a record or git) or str (from the command line).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
A str of printable ASCII only.
|
|
82
|
+
|
|
83
|
+
SPEC: "Errors" (a quoted ref or path is escaped).
|
|
84
|
+
"""
|
|
85
|
+
if not isinstance(data, bytes):
|
|
86
|
+
# Command-line text: os.fsencode gives back the bytes the caller typed.
|
|
87
|
+
data = os.fsencode(data)
|
|
88
|
+
out = []
|
|
89
|
+
# bytearray iterates as ints, one per byte, so each byte is judged on its own.
|
|
90
|
+
for byte in bytearray(data):
|
|
91
|
+
if byte in _ESCAPES:
|
|
92
|
+
out.append(_ESCAPES[byte])
|
|
93
|
+
elif 0x20 <= byte < 0x7F:
|
|
94
|
+
out.append(chr(byte))
|
|
95
|
+
else:
|
|
96
|
+
out.append("\\x%02x" % byte)
|
|
97
|
+
return "".join(out)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_args(argv, flags, valued):
|
|
101
|
+
"""Split argv into options and positionals without treating refs as options.
|
|
102
|
+
|
|
103
|
+
Any argument that is not a documented option, even one starting with "-", is a
|
|
104
|
+
positional: a ref starting with "-" is a ref, never an option. "--" ends option parsing.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
argv: The arguments, without the program name.
|
|
108
|
+
flags: Options that take no value, e.g. ("-z", "-h").
|
|
109
|
+
valued: Options that take one value, as "--with REF" or "--with=REF".
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A tuple ({option: True or value}, [positionals]).
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
Fail: A valued option is last with no value, and neither -h nor --version was given.
|
|
116
|
+
|
|
117
|
+
SPEC: "Options" and "Refs".
|
|
118
|
+
"""
|
|
119
|
+
opts = {}
|
|
120
|
+
positionals = []
|
|
121
|
+
missing = None
|
|
122
|
+
i = 0
|
|
123
|
+
# Walk the arguments by index, because a valued option consumes the next one.
|
|
124
|
+
while i < len(argv):
|
|
125
|
+
arg = argv[i]
|
|
126
|
+
if arg == "--":
|
|
127
|
+
# Everything after "--" is positional, whatever it looks like.
|
|
128
|
+
positionals.extend(argv[i + 1:])
|
|
129
|
+
break
|
|
130
|
+
if arg in flags:
|
|
131
|
+
opts[arg] = True
|
|
132
|
+
elif arg in valued:
|
|
133
|
+
# The value is the next argument verbatim, so "--with -x" means ref "-x".
|
|
134
|
+
if i + 1 >= len(argv):
|
|
135
|
+
# Raised after the loop, so a -h or --version anywhere still wins.
|
|
136
|
+
missing = arg
|
|
137
|
+
break
|
|
138
|
+
opts[arg] = argv[i + 1]
|
|
139
|
+
i += 1
|
|
140
|
+
elif "=" in arg and arg.split("=", 1)[0] in valued:
|
|
141
|
+
# The "--with=REF" spelling; split once, because REF itself may hold "=".
|
|
142
|
+
key, value = arg.split("=", 1)
|
|
143
|
+
opts[key] = value
|
|
144
|
+
else:
|
|
145
|
+
positionals.append(arg)
|
|
146
|
+
i += 1
|
|
147
|
+
# -h and --version take precedence over any other argument (SPEC "Standard streams").
|
|
148
|
+
if missing and not (opts.get("-h") or opts.get("--version")):
|
|
149
|
+
raise Fail("option %s needs a value (see -h)" % missing)
|
|
150
|
+
return opts, positionals
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def read_records(zero):
|
|
154
|
+
"""Read all of stdin and return its records, empty records skipped.
|
|
155
|
+
|
|
156
|
+
LF mode splits on LF and removes one trailing CR; -z mode splits on NUL and removes
|
|
157
|
+
nothing. Record numbers count every record, skipped ones included, from 1 (like line
|
|
158
|
+
numbers), so an error message points at the line the caller sees in their file.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
zero: True for -z mode (NUL-terminated records), False for LF mode.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
A list of (record number, record bytes) tuples, in input order.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
Fail: File descriptor 0 is not open.
|
|
168
|
+
|
|
169
|
+
SPEC: "Records", "Input tolerance", "Unterminated last record", "Standard streams".
|
|
170
|
+
"""
|
|
171
|
+
# Python sets sys.stdin to None when file descriptor 0 is not open at all.
|
|
172
|
+
if sys.stdin is None:
|
|
173
|
+
raise Fail("standard input is not open")
|
|
174
|
+
# The binary buffer, so no decoding and no newline translation happen on any platform.
|
|
175
|
+
data = sys.stdin.buffer.read()
|
|
176
|
+
terminator = b"\0" if zero else b"\n"
|
|
177
|
+
parts = data.split(terminator)
|
|
178
|
+
# A final terminator leaves one empty tail, which is not a record. Without a final
|
|
179
|
+
# terminator the last part is the unterminated last record, which is accepted.
|
|
180
|
+
if parts and parts[-1] == b"":
|
|
181
|
+
parts.pop()
|
|
182
|
+
records = []
|
|
183
|
+
for number, record in enumerate(parts, 1):
|
|
184
|
+
# Input tolerance applies to LF mode only (SPEC "Input tolerance").
|
|
185
|
+
if not zero and record.endswith(b"\r"):
|
|
186
|
+
record = record[:-1]
|
|
187
|
+
# Empty records are skipped in both modes.
|
|
188
|
+
if record == b"":
|
|
189
|
+
continue
|
|
190
|
+
records.append((number, record))
|
|
191
|
+
return records
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_safe(field, zero, what):
|
|
195
|
+
"""Fail if `field` cannot be written in an LF record; in -z mode every field is safe.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
field: The ref or path, as bytes.
|
|
199
|
+
zero: True in -z mode, where nothing is unsafe.
|
|
200
|
+
what: How to name the field in the message, e.g. "ref" or "path".
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
Fail: In LF mode, `field` holds TAB, LF or CR; the message names -z.
|
|
204
|
+
|
|
205
|
+
SPEC: "Unsafe names".
|
|
206
|
+
"""
|
|
207
|
+
if zero:
|
|
208
|
+
return
|
|
209
|
+
for byte in _UNSAFE_LF:
|
|
210
|
+
if byte in field:
|
|
211
|
+
# Never quote or escape: point the caller at -z instead.
|
|
212
|
+
raise Fail("%s contains TAB, LF or CR and cannot be written as a line; "
|
|
213
|
+
"use -z: %s" % (what, show(field)))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def write_stdout(data):
|
|
217
|
+
"""Write bytes to stdout, or flush it; a reader that went away becomes PipeClosed.
|
|
218
|
+
|
|
219
|
+
EPIPE is the closed pipe; Windows reports the same as EINVAL. Only here, on stdout
|
|
220
|
+
writes and flushes, are those errors read as "the reader left". (Not covered by a test:
|
|
221
|
+
EINVAL from a closed pipe happens only on Windows, and provoking EINVAL from another
|
|
222
|
+
call, to show it is not misread, would need a mock, which the test rules forbid.)
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
data: The bytes to write, or None to flush.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
PipeClosed: The reader closed the pipe.
|
|
229
|
+
OSError: Any other write error, unchanged.
|
|
230
|
+
|
|
231
|
+
SPEC: "Standard streams" (EPIPE is a quiet exit 0).
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
if data is None:
|
|
235
|
+
sys.stdout.flush()
|
|
236
|
+
else:
|
|
237
|
+
# The binary buffer: records are bytes and must not be re-encoded.
|
|
238
|
+
sys.stdout.buffer.write(data)
|
|
239
|
+
except OSError as exc:
|
|
240
|
+
# Python 3 raises BrokenPipeError for EPIPE; the errno check also covers EINVAL.
|
|
241
|
+
if isinstance(exc, BrokenPipeError) or exc.errno in (errno.EPIPE, errno.EINVAL):
|
|
242
|
+
raise PipeClosed()
|
|
243
|
+
raise
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def write_record(fields, zero):
|
|
247
|
+
"""Write one record to stdout, TAB-separated, LF- or NUL-terminated.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
fields: The fields, as bytes.
|
|
251
|
+
zero: True to end the record with NUL, False with LF.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
PipeClosed: The reader closed the pipe.
|
|
255
|
+
|
|
256
|
+
SPEC: "Records".
|
|
257
|
+
"""
|
|
258
|
+
write_stdout(b"\t".join(fields) + (b"\0" if zero else b"\n"))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def git(args, extra_env=None, data=None, cwd=None):
|
|
262
|
+
"""Run git with list args (never a shell) and LC_ALL=C.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
args: The arguments after "git", as str or bytes. Bytes are converted with
|
|
266
|
+
os.fsdecode, which gives the original bytes back to git on POSIX and UTF-8 on
|
|
267
|
+
Windows.
|
|
268
|
+
extra_env: Environment variables to add, or None.
|
|
269
|
+
data: Bytes fed to git's stdin, or None for an empty stdin.
|
|
270
|
+
cwd: The directory git runs in, or None for ours.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
A tuple (exit code, stdout bytes, stderr bytes).
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
Fail: git cannot be run, or (on Windows) an argument is not valid UTF-8.
|
|
277
|
+
|
|
278
|
+
SPEC: "Common rules" (no shell, LC_ALL=C, git on PATH) and "Resolving a ref" (Windows).
|
|
279
|
+
"""
|
|
280
|
+
env = dict(os.environ)
|
|
281
|
+
env.update(extra_env or {})
|
|
282
|
+
# A fixed locale keeps git's behaviour and messages independent of the user's settings.
|
|
283
|
+
env["LC_ALL"] = "C"
|
|
284
|
+
argv = ["git"]
|
|
285
|
+
for arg in args:
|
|
286
|
+
if isinstance(arg, bytes):
|
|
287
|
+
try:
|
|
288
|
+
arg = os.fsdecode(arg)
|
|
289
|
+
except UnicodeDecodeError:
|
|
290
|
+
# Windows passes arguments as UTF-16, so other bytes cannot reach git there.
|
|
291
|
+
raise Fail("cannot pass a name that is not valid UTF-8 to git on this "
|
|
292
|
+
"platform: %s" % show(arg))
|
|
293
|
+
argv.append(arg)
|
|
294
|
+
# subprocess.run refuses stdin= together with input=, so pick exactly one. DEVNULL, not
|
|
295
|
+
# inheritance, so git never reads the records meant for us.
|
|
296
|
+
stdin_kw = {"input": data} if data is not None else {"stdin": subprocess.DEVNULL}
|
|
297
|
+
try:
|
|
298
|
+
proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env,
|
|
299
|
+
cwd=cwd, **stdin_kw)
|
|
300
|
+
except OSError as exc:
|
|
301
|
+
# git missing from PATH, or not executable.
|
|
302
|
+
raise Fail("cannot run git: %s" % exc)
|
|
303
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def resolve(ref, extra_env=None):
|
|
307
|
+
"""Return the commit id that `ref` names.
|
|
308
|
+
|
|
309
|
+
Two steps: `rev-parse --verify` must print exactly one object id, which rejects "^a",
|
|
310
|
+
ranges and other non-single revisions, and that id must then peel to a commit.
|
|
311
|
+
--end-of-options makes a ref starting with "-" a ref, never an option.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
ref: The ref as the caller wrote it (bytes or str).
|
|
315
|
+
extra_env: Environment variables for git, or None.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
The commit id, as hex bytes.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
Fail: `ref` is unknown or not a commit.
|
|
322
|
+
|
|
323
|
+
SPEC: "Resolving a ref" and "Refs".
|
|
324
|
+
"""
|
|
325
|
+
error = Fail("unknown ref or not a commit: %s" % show(ref))
|
|
326
|
+
code, out, _ = git(["rev-parse", "--verify", "--quiet", "--end-of-options", ref],
|
|
327
|
+
extra_env)
|
|
328
|
+
# Strip exactly one LF: anything else left over (a second line) must fail the OID match.
|
|
329
|
+
oid = out[:-1] if out.endswith(b"\n") else out
|
|
330
|
+
if code != 0 or not OID.match(oid):
|
|
331
|
+
raise error
|
|
332
|
+
# Peel the id, not the spelling, so the ref is never parsed a second time.
|
|
333
|
+
code, out, _ = git(["rev-parse", "--verify", "--quiet", oid + b"^{commit}"], extra_env)
|
|
334
|
+
sha = out.strip()
|
|
335
|
+
if code != 0 or not OID.match(sha):
|
|
336
|
+
raise error
|
|
337
|
+
return sha
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def git_error(stderr, code):
|
|
341
|
+
"""Describe a git failure for an error message: the first line of stderr, escaped.
|
|
342
|
+
|
|
343
|
+
With nothing on stderr, the exit status is the only clue; a negative status from
|
|
344
|
+
subprocess means git was killed by that signal number.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
stderr: git's stderr, as bytes.
|
|
348
|
+
code: git's exit status, as subprocess reports it.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
A one-line str, safe to put in an error message.
|
|
352
|
+
|
|
353
|
+
SPEC: "Errors" (one line, escaped).
|
|
354
|
+
"""
|
|
355
|
+
lines = stderr.strip().splitlines()
|
|
356
|
+
if lines:
|
|
357
|
+
# Only the first line: the SPEC's error is exactly one line.
|
|
358
|
+
return show(lines[0])
|
|
359
|
+
if code < 0:
|
|
360
|
+
return "git was killed by signal %d" % -code
|
|
361
|
+
return "git exited with status %d" % code
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def usage_and_version(opts, name, usage):
|
|
365
|
+
"""Handle -h and --version by printing to stdout.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
opts: The options from parse_args.
|
|
369
|
+
name: The command name without "git-", e.g. "footprint".
|
|
370
|
+
usage: The usage text, ASCII.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
True if -h or --version was handled and the command should stop, else False.
|
|
374
|
+
|
|
375
|
+
SPEC: "Standard streams" and "-h prints usage and exits 0".
|
|
376
|
+
"""
|
|
377
|
+
if opts.get("-h"):
|
|
378
|
+
write_stdout((usage + "\n").encode("ascii"))
|
|
379
|
+
return True
|
|
380
|
+
if opts.get("--version"):
|
|
381
|
+
write_stdout(("git-%s %s\n" % (name, __version__)).encode("ascii"))
|
|
382
|
+
return True
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def install_signal_handlers():
|
|
387
|
+
"""Turn SIGINT, SIGTERM and SIGHUP into Interrupted, so cleanup in `finally` runs.
|
|
388
|
+
|
|
389
|
+
SIGHUP does not exist on Windows. SIGPIPE stays as Python sets it (ignored), so a reader
|
|
390
|
+
closing the pipe arrives as EPIPE, which the SPEC treats as a quiet exit 0.
|
|
391
|
+
|
|
392
|
+
SPEC: "Merge mechanism" (the temporary directory is removed on these signals).
|
|
393
|
+
"""
|
|
394
|
+
def handler(signum, _frame):
|
|
395
|
+
"""Unwind the stack; `run` turns this into exit 128 + signum.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
signum: The signal number.
|
|
399
|
+
_frame: The interrupted stack frame (unused).
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
Interrupted: Always.
|
|
403
|
+
"""
|
|
404
|
+
raise Interrupted(signum)
|
|
405
|
+
# getattr, because a signal missing on this platform is simply not installed.
|
|
406
|
+
for name in ("SIGINT", "SIGTERM", "SIGHUP"):
|
|
407
|
+
signum = getattr(signal, name, None)
|
|
408
|
+
if signum is not None:
|
|
409
|
+
signal.signal(signum, handler)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def ignore_signals():
|
|
413
|
+
"""Ignore the signals install_signal_handlers catches, so cleanup is not cut short.
|
|
414
|
+
|
|
415
|
+
SPEC: "Merge mechanism" (the temporary directory is removed on every exit path).
|
|
416
|
+
"""
|
|
417
|
+
# Same names, same getattr guard as install_signal_handlers (no SIGHUP on Windows).
|
|
418
|
+
for name in ("SIGINT", "SIGTERM", "SIGHUP"):
|
|
419
|
+
signum = getattr(signal, name, None)
|
|
420
|
+
if signum is not None:
|
|
421
|
+
signal.signal(signum, signal.SIG_IGN)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def write_stderr_line(text):
|
|
425
|
+
"""Write `text` and one LF to stderr as bytes, if stderr exists.
|
|
426
|
+
|
|
427
|
+
Bytes, because a text-mode stderr on Windows turns LF into CR LF, and the SPEC's error
|
|
428
|
+
is one line ending in LF on every platform.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
text: The line, without its LF.
|
|
432
|
+
|
|
433
|
+
SPEC: "Errors" (the line ends in LF on every platform).
|
|
434
|
+
"""
|
|
435
|
+
# Python sets sys.stderr to None when file descriptor 2 is not open: nothing to say.
|
|
436
|
+
if sys.stderr is None:
|
|
437
|
+
return
|
|
438
|
+
# Flush pending text first, so bytes written to the buffer cannot overtake it.
|
|
439
|
+
sys.stderr.flush()
|
|
440
|
+
stream = getattr(sys.stderr, "buffer", None)
|
|
441
|
+
if stream is None:
|
|
442
|
+
# A replaced stderr without a byte buffer: text is all it takes.
|
|
443
|
+
sys.stderr.write(text + "\n")
|
|
444
|
+
sys.stderr.flush()
|
|
445
|
+
return
|
|
446
|
+
# backslashreplace: a message must never fail to print because of its own encoding.
|
|
447
|
+
encoding = getattr(sys.stderr, "encoding", None) or "utf-8"
|
|
448
|
+
stream.write((text + "\n").encode(encoding, "backslashreplace"))
|
|
449
|
+
stream.flush()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _error(name, message):
|
|
453
|
+
"""Write `git-<name>: <message>` to stderr as exactly one line, if stderr exists.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
name: The command name without "git-".
|
|
457
|
+
message: The message, or an exception whose str() is the message.
|
|
458
|
+
|
|
459
|
+
SPEC: "Errors".
|
|
460
|
+
"""
|
|
461
|
+
# Belt and braces: messages are built escaped, but never let one span two lines.
|
|
462
|
+
message = str(message).replace("\r", "\\r").replace("\n", "\\n")
|
|
463
|
+
write_stderr_line("git-%s: %s" % (name, message))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _discard_stdout():
|
|
467
|
+
"""Point file descriptor 1 at the null device, so leftover buffered output goes nowhere.
|
|
468
|
+
|
|
469
|
+
SPEC: "Standard streams" (a closed pipe ends quietly).
|
|
470
|
+
"""
|
|
471
|
+
# dup2 onto the descriptor itself, so Python's own exit-time flush also lands there.
|
|
472
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
473
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def run(name, body):
|
|
477
|
+
"""Call body() and exit with its code; map every failure to the SPEC's exit and message.
|
|
478
|
+
|
|
479
|
+
Fail -> one stderr line and exit 2. A closed stdout pipe -> quiet exit 0. A signal ->
|
|
480
|
+
exit 128 + signum. Anything else unexpected -> one stderr line and exit 2, never a
|
|
481
|
+
traceback.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
name: The command name without "git-", for error messages.
|
|
485
|
+
body: A callable taking no arguments and returning the exit code.
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
SystemExit: Always, with the exit code.
|
|
489
|
+
|
|
490
|
+
SPEC: "Exit status", "Errors" and "Standard streams".
|
|
491
|
+
"""
|
|
492
|
+
# Python sets sys.stdout to None when file descriptor 1 is not open at all.
|
|
493
|
+
if sys.stdout is None:
|
|
494
|
+
_error(name, "standard output is not open")
|
|
495
|
+
sys.exit(EXIT_ERROR)
|
|
496
|
+
try:
|
|
497
|
+
code = body()
|
|
498
|
+
# Flush inside the try, so a pipe closed during the final flush is caught here.
|
|
499
|
+
write_stdout(None)
|
|
500
|
+
except Fail as exc:
|
|
501
|
+
_error(name, exc)
|
|
502
|
+
code = EXIT_ERROR
|
|
503
|
+
except Interrupted as exc:
|
|
504
|
+
# Drop unwritten output: the exit-time flush must not block on a reader that stalls.
|
|
505
|
+
_discard_stdout()
|
|
506
|
+
code = 128 + exc.signum
|
|
507
|
+
except KeyboardInterrupt:
|
|
508
|
+
# SIGINT where no handler was installed: same exit status as a handled one.
|
|
509
|
+
code = 128 + 2
|
|
510
|
+
except PipeClosed:
|
|
511
|
+
# Python's exit-time flush would hit the closed pipe again; send it nowhere instead.
|
|
512
|
+
_discard_stdout()
|
|
513
|
+
code = EXIT_NOTHING
|
|
514
|
+
except Exception as exc: # noqa: BLE001 - the SPEC forbids tracebacks
|
|
515
|
+
_error(name, "internal error: %s" % exc)
|
|
516
|
+
code = EXIT_ERROR
|
|
517
|
+
sys.exit(code)
|
git_overlap/footprint.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""git footprint BASE: list every file each input ref changes since its merge base with BASE.
|
|
3
|
+
|
|
4
|
+
Reads one ref per record on stdin and writes `ref TAB status TAB path` records, the same
|
|
5
|
+
set of files a pull request's "files changed" tab shows. SPEC: "git footprint BASE".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from ._common import (EXIT_NOTHING, Fail, check_safe, git, git_error, parse_args,
|
|
11
|
+
read_records, resolve, run, show, usage_and_version, write_record)
|
|
12
|
+
|
|
13
|
+
NAME = "footprint"
|
|
14
|
+
USAGE = """usage: git footprint [-z] BASE < refs
|
|
15
|
+
|
|
16
|
+
For each ref on stdin, list the files it changes compared with its merge base with BASE:
|
|
17
|
+
ref TAB status TAB path (status: A, M, D or T; renames are D + A)
|
|
18
|
+
|
|
19
|
+
-z records end with NUL instead of LF, on input and output
|
|
20
|
+
-h show this help
|
|
21
|
+
--version show the version"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def merge_base(base_sha, sha, ref, base):
|
|
25
|
+
"""Return the merge base of BASE and one ref.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
base_sha: BASE's commit id (bytes).
|
|
29
|
+
sha: The ref's commit id (bytes).
|
|
30
|
+
ref: The ref as the caller wrote it, for messages.
|
|
31
|
+
base: BASE as the caller wrote it, for messages.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The merge base commit id (bytes).
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
Fail: The two share no history ("no merge base"), or git merge-base fails.
|
|
38
|
+
|
|
39
|
+
SPEC: "Shallow or unrelated histories".
|
|
40
|
+
"""
|
|
41
|
+
code, out, err = git(["merge-base", base_sha, sha])
|
|
42
|
+
# merge-base exits 1 with no output when there is no common ancestor.
|
|
43
|
+
if code == 1 and not out.strip():
|
|
44
|
+
raise Fail("no merge base between %s and %s" % (show(base), show(ref)))
|
|
45
|
+
if code != 0:
|
|
46
|
+
raise Fail("git merge-base failed for %s: %s" % (show(ref), git_error(err, code)))
|
|
47
|
+
# Without --all, merge-base prints one best common ancestor; keep just its id.
|
|
48
|
+
return out.split(b"\n")[0].strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def changed_files(mb, sha):
|
|
52
|
+
"""Return [(status, path)] of what `sha` changes relative to `mb`, sorted by path bytewise.
|
|
53
|
+
|
|
54
|
+
Renames and copies are off, so a rename is D of the old path plus A of the new one.
|
|
55
|
+
External diff drivers and textconv are off: only names are wanted. Paths are relative to
|
|
56
|
+
the repository root whatever diff.relative says, and a submodule pointer change counts
|
|
57
|
+
whatever diff.ignoreSubmodules says.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
mb: The merge base commit id (bytes).
|
|
61
|
+
sha: The ref's commit id (bytes).
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A list of (status, path) bytes tuples, sorted by path bytewise.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
Fail: git diff fails or prints output that is not status/path pairs.
|
|
68
|
+
|
|
69
|
+
SPEC: "git footprint BASE" (the pinned diff) and "Determinism".
|
|
70
|
+
"""
|
|
71
|
+
# Each flag overrides a config setting that could otherwise change the file list.
|
|
72
|
+
code, out, err = git(["diff", "--name-status", "-z", "--no-renames", "--no-ext-diff",
|
|
73
|
+
"--no-textconv", "--no-relative", "--ignore-submodules=none", mb, sha])
|
|
74
|
+
if code != 0:
|
|
75
|
+
raise Fail("git diff failed: %s" % git_error(err, code))
|
|
76
|
+
# -z name-status output is "STATUS NUL PATH NUL" repeated; drop the final empty tail.
|
|
77
|
+
parts = out.split(b"\0")
|
|
78
|
+
if parts and parts[-1] == b"":
|
|
79
|
+
parts.pop()
|
|
80
|
+
# An odd count means the output is not status/path pairs: refuse rather than misalign.
|
|
81
|
+
if len(parts) % 2:
|
|
82
|
+
raise Fail("unexpected git diff output")
|
|
83
|
+
pairs = [(parts[i], parts[i + 1]) for i in range(0, len(parts), 2)]
|
|
84
|
+
# Sort by path bytewise (SPEC "Determinism"); git's own order may follow diff.orderFile.
|
|
85
|
+
pairs.sort(key=lambda pair: pair[1])
|
|
86
|
+
return pairs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def footprint(argv):
|
|
90
|
+
"""Run git footprint.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
argv: The arguments, without the program name.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
EXIT_NOTHING: a footprint is not a finding.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
Fail: A usage error, an unknown BASE or ref, an unsafe name or no merge base.
|
|
100
|
+
|
|
101
|
+
SPEC: "git footprint BASE".
|
|
102
|
+
"""
|
|
103
|
+
opts, positionals = parse_args(argv, ("-z", "-h", "--version"), ())
|
|
104
|
+
if usage_and_version(opts, NAME, USAGE):
|
|
105
|
+
return EXIT_NOTHING
|
|
106
|
+
if len(positionals) != 1:
|
|
107
|
+
raise Fail("expected exactly one BASE argument (see -h)")
|
|
108
|
+
zero = bool(opts.get("-z"))
|
|
109
|
+
# Resolve BASE before reading stdin, so a bad BASE fails even with no input.
|
|
110
|
+
base_sha = resolve(positionals[0])
|
|
111
|
+
# First pass: check and resolve every ref, so a bad one fails before any diff runs.
|
|
112
|
+
refs = []
|
|
113
|
+
seen = set()
|
|
114
|
+
for number, ref in read_records(zero):
|
|
115
|
+
# A repeated ref (same spelling) is processed once, at its first occurrence.
|
|
116
|
+
if ref in seen:
|
|
117
|
+
continue
|
|
118
|
+
seen.add(ref)
|
|
119
|
+
try:
|
|
120
|
+
# A record is the whole ref; in LF mode a TAB or CR in it cannot round-trip.
|
|
121
|
+
check_safe(ref, zero, "ref")
|
|
122
|
+
refs.append((number, ref, resolve(ref)))
|
|
123
|
+
except Fail as exc:
|
|
124
|
+
raise Fail("record %d: %s" % (number, exc))
|
|
125
|
+
# Second pass: list each ref's files, refs in input order.
|
|
126
|
+
for number, ref, sha in refs:
|
|
127
|
+
try:
|
|
128
|
+
mb = merge_base(base_sha, sha, ref, positionals[0])
|
|
129
|
+
for status, path in changed_files(mb, sha):
|
|
130
|
+
check_safe(path, zero, "path")
|
|
131
|
+
# The ref is printed exactly as the caller wrote it.
|
|
132
|
+
write_record((ref, status, path), zero)
|
|
133
|
+
except Fail as exc:
|
|
134
|
+
# Errors about this ref name its record (SPEC "Record errors").
|
|
135
|
+
raise Fail("record %d: %s" % (number, exc))
|
|
136
|
+
# A footprint is not a finding: success is always 0.
|
|
137
|
+
return EXIT_NOTHING
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main():
|
|
141
|
+
"""Console-script entry point: run on sys.argv and exit with the SPEC's status.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
SystemExit: Always (0 or 2).
|
|
145
|
+
|
|
146
|
+
SPEC: "Common rules" (console scripts) and "Exit status".
|
|
147
|
+
"""
|
|
148
|
+
run(NAME, lambda: footprint(sys.argv[1:]))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
main()
|