Coverage for src/keel/git.py: 100%
109 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
1"""Thin, fail-soft ``git`` wrappers (argv, no shell).
3These build the exact git command for each backbone operation and run it via the
4injectable ``_run`` seam, so the command construction is unit-tested offline; live
5behaviour is exercised opt-in against a real repo. Each returns a
6:class:`keel.runner.CommandResult` (or a parsed value), never raising.
7"""
9from __future__ import annotations
11import os
12import re
14from . import tdd
15from .runner import CommandResult, run_argv
18def remote_tracking_ref(remote: str, branch: str) -> str:
19 """``refs/remotes/<remote>/<branch>``: the remote-tracking ref, spelled in full.
21 **Never the short ``<remote>/<branch>``.** git resolves a short name through
22 ``refs/tags/`` and ``refs/heads/`` *before* ``refs/remotes/``, so a tag or a local branch
23 literally named ``origin/main`` answers in its place — and a tag arrives unasked with any
24 fetch of a remote whose history carries it. The warning git prints goes to stderr, which
25 :func:`rev_parse` does not read, so nothing notices.
27 Spelled in full, the name finds that ref whenever it exists. When it does **not**, git
28 still falls back through ``refs/tags/`` and ``refs/heads/`` — a local branch literally
29 named ``refs/remotes/origin/main`` answers ``rev-parse`` then (measured) — so ask
30 :func:`resolve_ref`, which matches the exact ref or nothing (#1223).
31 """
32 return f"refs/remotes/{remote}/{branch}"
35def resolve_ref(ref: str, *, cwd: str | None = None, _run=None) -> str | None:
36 """The object a fully spelled ``ref`` names, or ``None`` when that exact ref is absent.
38 ``show-ref --verify`` takes the name as the complete ref, with no fallback: when that ref
39 is missing it answers nothing, where :func:`rev_parse` goes on to try ``refs/tags/<ref>``
40 and ``refs/heads/<ref>`` (#1223).
42 The answer is whatever object the ref names. That is usually a commit, but git does not
43 guarantee it: a symbolic ref, a ref set by hand, or a fetched branch that is itself one of
44 those can name an annotated tag (each measured). A caller that needs a commit must still
45 get one; the landing's ``commit-tree`` refuses a parent that is not (#1225).
46 """
47 result = run_argv(["git", "show-ref", "--verify", "--hash", ref], cwd=cwd, **_kw(_run))
48 output = result.stdout.strip()
49 return output if result.ok and _SHA_RE.match(output) else None
52def remote_url(remote: str, *, cwd: str | None = None, _run=None) -> str | None:
53 """The fetch URL configured for ``remote``; ``None`` when no such remote is configured.
55 git reads an unconfigured remote name as a **path**: ``git fetch origin`` with no remote
56 called ``origin`` fetches from a directory of that name, and ``git push`` to it runs that
57 repository's hooks. A command that means "the remote" asks this first (#1223).
58 """
59 result = run_argv(["git", "config", "--get", f"remote.{remote}.url"], cwd=cwd, **_kw(_run))
60 output = result.stdout.strip()
61 return output if result.ok and output else None
64def owner_repo_from_url(url: str | None) -> tuple[str, str] | None:
65 """``(owner, repo)`` parsed from a git remote URL, or ``None`` when it holds no such pair.
67 Handles the three shapes git hands out — scp-style ``git@host:owner/repo.git``, and
68 ``https://host/owner/repo(.git)`` / ``ssh://git@host/owner/repo(.git)`` — by taking the
69 last two path segments after dropping a single trailing ``.git``. Host-agnostic on
70 purpose: GitHub, a GHE host and a self-hosted forge all read the same, and the result only
71 fills ``owner``/``repo`` in a scaffolded config for the operator to see and correct.
72 """
73 if not isinstance(url, str) or not url.strip():
74 return None
75 text = url.strip()
76 if text.endswith(".git"):
77 text = text[:-4]
78 scp = re.match(r"^[^/@]+@[^:/]+:(?P<path>.+)$", text) # git@host:owner/repo
79 path = scp.group("path") if scp else re.sub(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]+", "", text)
80 parts = [segment for segment in path.split("/") if segment]
81 if len(parts) < 2:
82 return None
83 return parts[-2], parts[-1]
86def fetch(remote: str, ref: str, *, cwd: str | None = None, _run=None) -> CommandResult:
87 """Fetch one branch of ``remote`` into :func:`remote_tracking_ref`.
89 The branch goes as ``refs/heads/<ref>``, never bare: a positional argument that begins
90 with ``-`` is an *option* to git, and ``--upload-pack=<program>`` among those runs a
91 program. The landing plan already refuses such a name; this keeps the wrapper from being
92 the thing that makes a stray value dangerous.
94 **The destination is named, not left to the remote's configured refspec.** The landing
95 resolves exactly that ref next, so what it builds on is what this fetch downloaded on any
96 configuration — a remote whose ``fetch`` refspec does not map the branch would otherwise
97 leave the ref where an older fetch put it. ``+`` because the branch may have been
98 rewritten, which is what the default refspec allows too.
99 """
100 return run_argv(
101 [
102 "git",
103 "fetch",
104 "--quiet",
105 remote,
106 f"+refs/heads/{ref}:{remote_tracking_ref(remote, ref)}",
107 ],
108 cwd=cwd,
109 **_kw(_run),
110 )
113def worktree_add(
114 base: str, branch: str, path: str, *, cwd: str | None = None, _run=None
115) -> CommandResult:
116 return run_argv(["git", "worktree", "add", "-b", branch, path, base], cwd=cwd, **_kw(_run))
119def worktree_remove(path: str, *, cwd: str | None = None, _run=None) -> CommandResult:
120 return run_argv(["git", "worktree", "remove", path, "--force"], cwd=cwd, **_kw(_run))
123def worktree_list(*, cwd: str | None = None, _run=None) -> CommandResult:
124 return run_argv(["git", "worktree", "list", "--porcelain"], cwd=cwd, **_kw(_run))
127def current_branch(*, cwd: str | None = None, _run=None) -> str | None:
128 result = run_argv(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, **_kw(_run))
129 return result.stdout.strip() if result.ok else None
132def list_branches(*, cwd: str | None = None, _run=None) -> CommandResult:
133 """List local + remote branch short names (one per line) as a ``CommandResult``.
135 Returns the raw result (like :func:`worktree_list`) rather than a parsed
136 fail-soft list, so a caller that needs to *distinguish a git error from an
137 empty repo* — e.g. dry-run integrity verification, which must fail closed
138 when it cannot observe — can inspect ``result.ok``. Parsing is the caller's.
139 """
140 return run_argv(
141 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"],
142 cwd=cwd,
143 **_kw(_run),
144 )
147#: A 40- or 64-hex object name (SHA-1 / SHA-256). git may print a ``warning:`` to
148#: stderr while still succeeding; reading ``stdout`` avoids the contamination, and
149#: validating the shape is a second line of defence so a stray token never poses as a SHA.
150_SHA_RE = re.compile(r"\A[0-9a-f]{40}(?:[0-9a-f]{24})?\Z")
153def rev_parse(ref: str, *, cwd: str | None = None, _run=None) -> str | None:
154 """Resolve ``ref`` to a full commit SHA; ``None`` when it cannot be resolved."""
155 result = run_argv(["git", "rev-parse", "--verify", "--quiet", ref], cwd=cwd, **_kw(_run))
156 output = result.stdout.strip()
157 return output if result.ok and _SHA_RE.match(output) else None
160def merge_base(a: str, b: str, *, cwd: str | None = None, _run=None) -> str | None:
161 """Best common ancestor of ``a`` and ``b``; ``None`` when there is none/on error."""
162 result = run_argv(["git", "merge-base", a, b], cwd=cwd, **_kw(_run))
163 output = result.stdout.strip()
164 return output if result.ok and _SHA_RE.match(output) else None
167def rev_count(base: str, head: str, *, cwd: str | None = None, _run=None) -> int | None:
168 """Commits in ``base..head`` (how far ``head`` is ahead of ``base``); ``None`` on error."""
169 result = run_argv(["git", "rev-list", "--count", f"{base}..{head}"], cwd=cwd, **_kw(_run))
170 if not result.ok:
171 return None
172 output = result.stdout.strip()
173 if not output.isdigit():
174 return None
175 return int(output)
178def changed_files(base: str, head: str, *, cwd: str | None = None, _run=None) -> list[str] | None:
179 """Files changed between ``base`` and ``head`` (``base...head``).
181 ``None`` when the git command failed — deliberately distinct from ``[]`` (the
182 command ran and there were no changes), so a caller classifying risk or checking
183 scope can tell "could not read the diff" apart from "the diff is empty" instead of
184 treating an unreadable diff as a clean, empty one.
185 """
186 result = run_argv(["git", "diff", "--name-only", f"{base}...{head}"], cwd=cwd, **_kw(_run))
187 if not result.ok:
188 return None
189 return [line for line in result.stdout.splitlines() if line.strip()]
192def commit_log(base: str, head: str, *, cwd: str | None = None, _run=None) -> str | None:
193 """Raw ``git log`` for ``base..head``: this branch's own commits, oldest first.
195 Returns git's stdout verbatim — :func:`keel.tdd.parse_commits` turns it into records,
196 so the parsing is pure and unit-tested rather than living behind a subprocess. ``None``
197 when the command failed, deliberately distinct from ``""`` (the range is empty): the
198 ``tdd-order`` gate must be able to block on "we could not read the history" instead of
199 reading it as "this branch has no commits".
201 Four flags carry the whole meaning of "this implementer's commit order", and each is
202 load-bearing:
204 ``base..head``
205 not ``base...head`` — the symmetric form would also list the base's side.
206 ``--topo-order``
207 git's default is *commit-date* order. Once the branch integrates its base (ship
208 s10), a base commit dated before the tests commit sorts ahead of it and becomes
209 the "first commit" — so the same topology passed or blocked depending on nothing
210 but timestamps. Topological order asks about ancestry, which is what was meant.
211 ``--first-parent``
212 follows only this branch's own line through its merges, dropping the commits a
213 base merge brought in. Without it a stale local ``base`` ref leaves those commits
214 inside the range, and one of them can be judged as this implementer's first
215 commit. The merge commits themselves stay on the chain and
216 :func:`keel.tdd.check_order` skips them.
217 ``--name-status``
218 not ``--name-only`` — a name alone cannot tell an addition from a deletion, and
219 the gate has to separate "wrote the failing tests" from ``git rm`` over them.
221 ``--reverse`` is applied after ordering and selection, so the output is oldest-first
222 within the topological order.
223 """
224 result = run_argv(
225 [
226 "git",
227 "log",
228 "--topo-order",
229 "--first-parent",
230 "--reverse",
231 "--no-color",
232 f"--format={tdd.LOG_FORMAT}",
233 "--name-status",
234 f"{base}..{head}",
235 ],
236 cwd=cwd,
237 **_kw(_run),
238 )
239 return result.stdout if result.ok else None
242def diff(base: str, head: str, *, cwd: str | None = None, _run=None) -> str | None:
243 """The unified diff between ``base`` and ``head`` (``base...head``).
245 ``None`` when the git command failed — distinct from ``""`` (the command ran and
246 the diff is empty), so a review/gate caller can refuse to treat an unreadable diff
247 as "nothing to review".
248 """
249 result = run_argv(["git", "diff", f"{base}...{head}"], cwd=cwd, **_kw(_run))
250 return result.stdout if result.ok else None
253def revert_diff(base: str, head: str, *, cwd: str | None = None, _run=None) -> str | None:
254 """The diff the ``revert-check`` gate splits into changes (``base...head``, #1289).
256 Every setting that changes the *shape* of the output is pinned, because the parser is
257 :func:`keel.revertcheck.parse_diff` and each hunk is handed back to ``git apply -R``:
258 no colour or external diff driver, ``a/``/``b/`` prefixes whatever ``diff.noprefix`` or
259 ``diff.mnemonicPrefix`` say, no rename detection (a rename is a deletion and an
260 addition, each revertible alone), unquoted non-ASCII paths, and a blank context line
261 written as a space. ``None`` when git failed — distinct from ``""``, an empty diff.
263 **No context lines** (``--unified=0``): with git's default three, two edits six lines
264 apart merge into one hunk, and a per-hunk check then passes when a test notices either
265 of them — the #871 shape, measured on this gate's own first smoke run. Zero context
266 splits every contiguous edit into its own hunk; :func:`apply_reverse` applies them.
268 **The user's configuration cannot widen or rename a hunk.** ``--unified=0`` does not
269 override ``diff.interHunkContext`` (a value of 10000 merged every edit in a file into
270 one change), so it is pinned too, with every other setting the parser depends on:
271 prefixes (``diff.noprefix``, ``diff.mnemonicPrefix``), paths (``diff.relative``,
272 ``core.quotePath``), the diff algorithm, the submodule format, textconv and external
273 drivers. ``GIT_DIFF_OPTS`` outranks even the command line, so it is removed from the
274 child's environment. :func:`keel.revertcheck.context_problem` still refuses a diff
275 whose hunks carry context, whatever widened them.
277 **Line endings are kept** (``keep_line_endings``): read in text mode, a CRLF source's
278 ``\\r`` was dropped, and the reverse patch no longer matched ``HEAD``'s bytes.
279 """
280 result = run_argv(
281 [
282 "git",
283 "-c",
284 "core.quotePath=off",
285 "-c",
286 "diff.suppressBlankEmpty=false",
287 "-c",
288 "diff.interHunkContext=0",
289 "-c",
290 "diff.noprefix=false",
291 "-c",
292 "diff.mnemonicPrefix=false",
293 "-c",
294 "diff.relative=false",
295 "diff",
296 "--no-color",
297 "--no-ext-diff",
298 "--no-textconv",
299 "--no-renames",
300 "--no-relative",
301 "--unified=0",
302 "--inter-hunk-context=0",
303 "--diff-algorithm=myers",
304 "--submodule=short",
305 "--src-prefix=a/",
306 "--dst-prefix=b/",
307 f"{base}...{head}",
308 ],
309 cwd=cwd,
310 env={key: value for key, value in os.environ.items() if key != "GIT_DIFF_OPTS"},
311 keep_line_endings=True,
312 **_kw(_run),
313 )
314 return result.stdout if result.ok else None
317#: What ``git rev-parse --local-env-vars`` lists: the variables that tie a git command to
318#: one repository, work tree or index instead of the directory it runs in. keel run from a
319#: git hook inherits ``GIT_DIR``, and a wrapper may set ``GIT_WORK_TREE``; passed to a
320#: command meant for the scratch worktree, they aimed ``reset --hard`` and ``clean -fdx``
321#: at the operator's checkout (#1289 review).
322REPO_ENV_VARS = frozenset(
323 {
324 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
325 "GIT_CONFIG",
326 "GIT_CONFIG_PARAMETERS",
327 "GIT_CONFIG_COUNT",
328 "GIT_OBJECT_DIRECTORY",
329 "GIT_DIR",
330 "GIT_WORK_TREE",
331 "GIT_IMPLICIT_WORK_TREE",
332 "GIT_GRAFT_FILE",
333 "GIT_INDEX_FILE",
334 "GIT_NO_REPLACE_OBJECTS",
335 "GIT_REPLACE_REF_BASE",
336 "GIT_PREFIX",
337 "GIT_SHALLOW_FILE",
338 "GIT_COMMON_DIR",
339 }
340)
341#: The subset that picks a work tree or an index: dropped even where the repository the
342#: variables name is the right one (``git worktree add``, run in the operator's repository).
343CHECKOUT_ENV_VARS = frozenset(
344 {"GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"}
345)
348def scratch_env(drop: frozenset[str] = REPO_ENV_VARS) -> dict[str, str]:
349 """``os.environ`` without ``drop``: for a command that must act where it runs."""
350 return {key: value for key, value in os.environ.items() if key not in drop}
353def worktree_add_detached(
354 path: str, commit: str, *, hooks_path: str, cwd: str | None = None, _run=None
355) -> CommandResult:
356 """Check ``commit`` out at ``path`` as a detached worktree — no branch is created.
358 ``hooks_path`` replaces the repository's hooks for this one command: a worktree shares
359 its repository's hooks, and a ``post-checkout`` hook would otherwise run in the scratch
360 tree. Pass an empty directory. :data:`CHECKOUT_ENV_VARS` are dropped, so an inherited
361 work tree or index cannot receive the checkout.
362 """
363 return run_argv(
364 [
365 "git",
366 "-c",
367 f"core.hooksPath={hooks_path}",
368 "worktree",
369 "add",
370 "--detach",
371 "--quiet",
372 path,
373 commit,
374 ],
375 cwd=cwd,
376 env=scratch_env(CHECKOUT_ENV_VARS),
377 **_kw(_run),
378 )
381def apply_reverse(patch_path: str, *, cwd: str | None = None, _run=None) -> CommandResult:
382 """Undo the patch at ``patch_path`` in the working tree at ``cwd`` (``git apply -R``).
384 ``--unidiff-zero`` because :func:`revert_diff` writes hunks with no context lines, which
385 git refuses to apply by default. It is exact here: the patch is undone on the very
386 commit it was taken from, so every line number it carries is the tree's own.
388 The user's ``apply.*`` settings are pinned: ``apply.whitespace=error`` would refuse a
389 change whose lines carry trailing blanks (a false "could not undo"), and
390 ``apply.ignoreWhitespace`` or ``apply.3way`` would let a patch land where it does not
391 match exactly. It runs with :func:`scratch_env`, so it patches the tree at ``cwd``.
392 """
393 return run_argv(
394 [
395 "git",
396 "-c",
397 "apply.ignoreWhitespace=no",
398 "apply",
399 "-R",
400 "--unidiff-zero",
401 "--whitespace=nowarn",
402 "--no-3way",
403 "--",
404 patch_path,
405 ],
406 cwd=cwd,
407 env=scratch_env(),
408 **_kw(_run),
409 )
412def reset_clean(*, cwd: str | None = None, _run=None) -> bool:
413 """Put the working tree at ``cwd`` back to ``HEAD`` exactly; ``True`` when both steps ran.
415 ``clean -x`` removes *ignored* files too, and that is the point: a ``__pycache__``
416 written while one change was reverted must not answer for the next run, since a
417 ``.pyc`` whose source was restored within the same second can be served stale.
419 Both run with :func:`scratch_env`: an inherited ``GIT_DIR`` or ``GIT_WORK_TREE`` would
420 otherwise send them to the operator's checkout.
421 """
422 env = scratch_env()
423 reset = run_argv(["git", "reset", "--hard", "--quiet", "HEAD"], cwd=cwd, env=env, **_kw(_run))
424 if not reset.ok:
425 return False
426 return run_argv(["git", "clean", "-fdxq"], cwd=cwd, env=env, **_kw(_run)).ok
429def hash_object(path: str, *, cwd: str | None = None, _run=None) -> str | None:
430 """Write ``path``'s content into the object database; return its blob SHA.
432 ``-w`` is what makes the landing possible without a checkout: the blob exists in
433 the repository before any tree references it, so the commit can be assembled with
434 plumbing and pushed, and a failed push leaves nothing but an unreferenced object
435 that ``git gc`` collects.
436 """
437 result = run_argv(["git", "hash-object", "-w", "--", path], cwd=cwd, **_kw(_run))
438 output = result.stdout.strip()
439 return output if result.ok and _SHA_RE.match(output) else None
442def ls_tree(treeish: str, *, cwd: str | None = None, _run=None) -> str | None:
443 """List one tree's own entries (not recursive); ``None`` when it cannot be read.
445 ``None`` and ``""`` are different answers and both are ordinary here: a sink
446 directory that does not exist on the base branch yet cannot be read (``None``),
447 and an existing but empty one reads as no entries. The caller treats the first as
448 "start a new directory" rather than as an error, which is what makes the very
449 first lesson land as cleanly as the hundredth.
451 ``-z`` for the same reason :func:`mktree` takes it: entries are NUL-terminated,
452 so a name is returned raw instead of C-quoted, and the round trip back through
453 ``mktree`` cannot re-encode one.
454 """
455 result = run_argv(["git", "ls-tree", "-z", treeish], cwd=cwd, **_kw(_run))
456 return result.stdout if result.ok else None
459def mktree(listing: str, *, cwd: str | None = None, _run=None) -> str | None:
460 """Write a tree object from NUL-terminated ``ls-tree``-shaped ``listing``.
462 The listing arrives on **stdin**, never in an argv: it carries object names and
463 file names, and an argv is world-readable in ``ps`` for the life of the process.
465 **``-z``, because a text-mode pipe rewrites newlines on Windows.** Python opens a
466 subprocess's stdin with ``newline=None`` under ``text=True``, which translates
467 every ``\n`` to ``os.linesep`` — so a LF-terminated listing reaches git as CRLF
468 there, and `mktree` does not complain: it writes a tree whose entry is named
469 ``<name>\r``, exits 0, and hands back a different SHA (measured). NUL-terminated
470 input has no newline to translate, so the same bytes arrive on every platform.
471 """
472 result = run_argv(["git", "mktree", "-z"], cwd=cwd, stdin_text=listing, **_kw(_run))
473 output = result.stdout.strip()
474 return output if result.ok and _SHA_RE.match(output) else None
477def commit_tree(
478 tree: str, *, parent: str, message: str, cwd: str | None = None, _run=None
479) -> str | None:
480 """Commit ``tree`` with a single ``parent``; return the new commit SHA.
482 The message goes in the **argv**, not on stdin, for the newline reason in
483 :func:`mktree`: a text-mode pipe turns every ``\n`` into CRLF on Windows, and a
484 commit message is content — it would land on the base branch carrying stray
485 carriage returns and stop being byte-identical across platforms. It is safe
486 there in a way a tree listing is not: this message is a fixed subject plus the
487 artifact path, which is about to be published on the base branch anyway.
488 """
489 result = run_argv(
490 ["git", "commit-tree", tree, "-p", parent, "-m", message],
491 cwd=cwd,
492 **_kw(_run),
493 )
494 output = result.stdout.strip()
495 return output if result.ok and _SHA_RE.match(output) else None
498def diff_names(a: str, b: str, *, cwd: str | None = None, _run=None) -> list[str] | None:
499 """Paths differing between two tree-ish objects (two-dot); ``None`` on error.
501 Two-dot on purpose, unlike :func:`changed_files`: the landing compares a commit
502 against the parent it was *just built on*, so "what did this commit add" is the
503 literal difference between the two trees and not a merge-base question. ``None``
504 stays distinct from ``[]`` so a caller that must fail closed when it cannot
505 observe — the landing's own "this commit changes exactly one file" check — can
506 tell an unreadable diff from an empty one.
507 """
508 # `-z` with `core.quotePath=false`, for the same reason `ls_tree`/`mktree` use it.
509 # Under the default `quotePath=true` git renders a non-ASCII name as a C-quoted
510 # escape — `".keel/learning/caf\\303\\251.md"` — which can never equal the raw path
511 # the landing planned, so the one live safety check refused every such artifact
512 # permanently and blamed the commit for changing a file nobody asked for.
513 #
514 # `--no-renames`, because this is that "exactly one file" check. With rename detection
515 # on — git's default — a commit that deleted a file and added the lesson with the same
516 # content prints as one rename, named by where the file went, so the deletion never
517 # showed. Every path either side touches is listed separately here, the one it left too.
518 result = run_argv(
519 [
520 "git",
521 "-c",
522 "core.quotePath=false",
523 "diff",
524 "--no-renames",
525 "--name-only",
526 "-z",
527 a,
528 b,
529 ],
530 cwd=cwd,
531 **_kw(_run),
532 )
533 if not result.ok:
534 return None
535 # Only the empty record after the final NUL is dropped. `name.strip()` also dropped a
536 # path made of whitespace, which is a legal filename and exactly as much of a change.
537 return [name for name in result.stdout.split("\0") if name]
540def push_commit(
541 remote: str, commit: str, ref: str, *, cwd: str | None = None, _run=None
542) -> CommandResult:
543 """Fast-forward ``ref`` on ``remote`` to ``commit``.
545 Deliberately **not** forced. A rejected push is the concurrency signal the
546 landing is built around: another ship pushed its own lesson first, so this one
547 re-reads the branch and rebuilds its commit on top. Forcing here would discard
548 that ship's lesson — and, on a base branch, whatever else arrived with it.
549 """
550 return run_argv(["git", "push", remote, f"{commit}:{ref}"], cwd=cwd, **_kw(_run))
553def _kw(_run):
554 """Pass ``_run`` through only when provided (so the default subprocess is used otherwise)."""
555 return {"_run": _run} if _run is not None else {}