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.
@@ -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)
@@ -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()