lockwire 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.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/forces.py ADDED
@@ -0,0 +1,334 @@
1
+ """The force numbers the Design panel renders — every one carrying its kind.
2
+
3
+ The panel lives inside Design: no eighth mode and **no new CLI command**. These
4
+ numbers ride in the documents ``lockwire check --json`` and ``lockwire
5
+ describe`` already produce, so every figure on the panel has a CLI twin.
6
+
7
+ Every number on screen is declared, measured, derived by a named check,
8
+ catalog or projected — and says which. ``number`` is the single builder every
9
+ figure here goes through, so a number with no kind cannot be constructed.
10
+
11
+ Stage one: what cannot be answered reads NOT SCORED with the reason
12
+ named. The CG is **reused** from ``checks.compute_system_cg``, never
13
+ recomputed; reactions come from ``statics.support_reactions``, whose ceilings
14
+ are documented in that module and repeated in this document's ``not_scored``
15
+ lane so a reader of the payload alone still sees them.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from lockwire.checks import _fixed_frame_transforms, _point_in_root
23
+ from lockwire.report import CGReport
24
+ from lockwire.schema import Contract
25
+ from lockwire.statics import STANDARD_G, Indeterminate, support_reactions, weight_n
26
+
27
+ # A source of a figure -> the kind it must be labelled with. The whole
28
+ # point of the mapping is that "catalog" and "projected" cannot be spelled
29
+ # "measured" in one caller and "catalog" in another.
30
+ _KIND_BY_SOURCE = {
31
+ "contract": "declared", # written in the lockfile by a human
32
+ "recording": "measured", # read off a bag or a live sample
33
+ "catalog": "catalog", # a part-catalog figure — never "measured"
34
+ "estimate": "projected", # a projection, not an observation
35
+ }
36
+
37
+ _KINDS = set(_KIND_BY_SOURCE.values()) | {"not_scored"}
38
+
39
+ # Stage one is slender, in-plane, static. Named
40
+ # here so the payload discloses its own ceilings instead of a reader inferring
41
+ # that an absent number means "fine".
42
+ _STAGE_ONE_CEILINGS = (
43
+ (
44
+ "stress",
45
+ "no stress is computed: stage one solves reactions, not plates, shells or bolt "
46
+ "preload, and a mount shape is a plate rather than a beam",
47
+ ),
48
+ (
49
+ "deflection",
50
+ "no deflection is computed: it needs Young's modulus and a section, which the "
51
+ "contract schema declares nowhere",
52
+ ),
53
+ (
54
+ "shear_dominated_members",
55
+ "a stubby member (span/depth under about 10) is shear-dominated and would read "
56
+ "stiffer than reality under the Euler-Bernoulli beam model",
57
+ ),
58
+ (
59
+ "dynamics",
60
+ "static loads only: no vibration, shock, fatigue or servo transient is scored",
61
+ ),
62
+ )
63
+
64
+
65
+ def kind_for_source(source: str) -> str:
66
+ """The kind a figure from ``source`` must carry."""
67
+ return _KIND_BY_SOURCE[source]
68
+
69
+
70
+ def derived_by(check: str) -> str:
71
+ """The kind for a figure a named check produced."""
72
+ return f"derived-by-{check}"
73
+
74
+
75
+ def number(value: Any, unit: str, kind: str, **extra: Any) -> dict[str, Any]:
76
+ """The one builder every force figure goes through.
77
+
78
+ Refuses a kind it does not recognise, so a typo cannot become a new
79
+ provenance word that no reader can interpret.
80
+ """
81
+ if kind not in _KINDS and not kind.startswith("derived-by-"):
82
+ raise ValueError(
83
+ f"unknown kind {kind!r}: expected one of {sorted(_KINDS)} or 'derived-by-<check>'"
84
+ )
85
+ return {"value": value, "unit": unit, "kind": kind, **extra}
86
+
87
+
88
+ def not_scored(unit: str, because: str, **extra: Any) -> dict[str, Any]:
89
+ """A figure that is deliberately absent, carrying the reason."""
90
+ return number(None, unit, "not_scored", not_scored_because=because, **extra)
91
+
92
+
93
+ def _mass_rows(contract: Contract, sources: str) -> list[dict[str, Any]]:
94
+ """Per-part mass, declared where declared and NOT SCORED where absent."""
95
+ rows: list[dict[str, Any]] = []
96
+ if sources in ("devices", "links+devices"):
97
+ for device in contract.devices:
98
+ physical = device.physical
99
+ if physical is None:
100
+ continue
101
+ quantity = f"devices/{device.id}/physical/mass"
102
+ if physical.mass is None:
103
+ rows.append(
104
+ not_scored(
105
+ "kg",
106
+ f"device {device.id!r} declares no mass, so it is not summed as 0 kg",
107
+ id=quantity,
108
+ )
109
+ )
110
+ else:
111
+ rows.append(
112
+ number(physical.mass.base(), "kg", kind_for_source("contract"), id=quantity)
113
+ )
114
+ if sources in ("links", "links+devices") and contract.model is not None:
115
+ for link in contract.model.links:
116
+ quantity = f"model/links/{link.name}/mass"
117
+ if link.mass is None:
118
+ rows.append(
119
+ not_scored(
120
+ "kg",
121
+ f"link {link.name!r} declares no mass, so it is not summed as 0 kg",
122
+ id=quantity,
123
+ )
124
+ )
125
+ else:
126
+ rows.append(
127
+ number(link.mass.base(), "kg", kind_for_source("contract"), id=quantity)
128
+ )
129
+ return rows
130
+
131
+
132
+ def _mount_rows(contract: Contract, report: CGReport | None) -> list[dict[str, Any]]:
133
+ """Static reaction at each declared mount frame, or NOT SCORED naming why."""
134
+ frames = sorted(
135
+ {
136
+ device.physical.mount_frame
137
+ for device in contract.devices
138
+ if device.physical is not None and device.physical.mount_frame is not None
139
+ }
140
+ )
141
+ if not frames:
142
+ return []
143
+
144
+ def refuse(because: str) -> list[dict[str, Any]]:
145
+ return [not_scored("N", because, id=frame) for frame in frames]
146
+
147
+ if report is None:
148
+ return refuse("no mass_total budget exists, so no reacted load is defined")
149
+ if report.cg_m is None:
150
+ return refuse(
151
+ "the centre of gravity is unresolved, so the load has no position"
152
+ + (f" ({report.limitation})" if report.limitation else "")
153
+ )
154
+ if not report.complete:
155
+ return refuse(
156
+ "the centre of gravity is incomplete, so the reacted load is unknown"
157
+ + (f" ({report.limitation})" if report.limitation else "")
158
+ )
159
+
160
+ _, transforms, _ = _fixed_frame_transforms(contract)
161
+ placed = {frame: _point_in_root(frame, (0.0, 0.0, 0.0), transforms) for frame in frames}
162
+ unresolved = sorted(frame for frame, point in placed.items() if point is None)
163
+ if unresolved:
164
+ return refuse(
165
+ f"mount frame(s) {', '.join(repr(name) for name in unresolved)} cannot be placed "
166
+ "in the root frame, so the support set is incomplete"
167
+ )
168
+
169
+ points = [placed[frame] for frame in frames]
170
+ assert all(point is not None for point in points)
171
+ heights = {round(point[2], 12) for point in points} # type: ignore[index]
172
+ if len(heights) > 1:
173
+ return refuse(
174
+ "the mount points sit at different heights; stage one solves level, in-plane "
175
+ "support sets only"
176
+ )
177
+ varying = [
178
+ axis
179
+ for axis in (0, 1)
180
+ if max([report.cg_m[axis]] + [point[axis] for point in points]) # type: ignore[index]
181
+ - min([report.cg_m[axis]] + [point[axis] for point in points]) # type: ignore[index]
182
+ > 0.0
183
+ ]
184
+ if len(varying) > 1:
185
+ return refuse(
186
+ "the mount points and the centre of gravity are not in one vertical plane; "
187
+ "stage one solves slender in-plane statics only"
188
+ )
189
+ axis = varying[0] if varying else 0
190
+
191
+ load = weight_n(report.total_mass_kg)
192
+ try:
193
+ reactions = support_reactions(load, report.cg_m[axis], [point[axis] for point in points])
194
+ except Indeterminate as refusal:
195
+ return refuse(str(refusal))
196
+ kind = derived_by("statics.support_reactions")
197
+ # A reaction is built from report.total_mass_kg and report.cg_m — the same
198
+ # numbers `mass_total` and `cg` carry, so the same limitation (e.g. a
199
+ # device with no declared mass, silently excluded from the total rather
200
+ # than positioned) travels onto every reaction too.
201
+ return [
202
+ number(
203
+ force,
204
+ "N",
205
+ kind,
206
+ id=frame,
207
+ reaction_n=force,
208
+ moment_nm=moment,
209
+ span_axis="xyz"[axis],
210
+ complete=report.limitation is None,
211
+ limitation=report.limitation,
212
+ )
213
+ for frame, (force, moment) in zip(frames, reactions, strict=True)
214
+ ]
215
+
216
+
217
+ def _mount_shape_rows(contract: Contract) -> list[dict[str, Any]]:
218
+ """Declared mounting geometry consumed by the force panel."""
219
+ rows: list[dict[str, Any]] = []
220
+ for device in contract.devices:
221
+ physical = device.physical
222
+ if physical is None or physical.mount_shape is None:
223
+ continue
224
+ shape = physical.mount_shape
225
+ rows.append(
226
+ {
227
+ "id": device.id,
228
+ "kind": shape.kind,
229
+ "dimensions": number(list(shape.dimensions_mm), "mm", kind_for_source("contract")),
230
+ "fastener_pattern": shape.fastener_pattern,
231
+ "mesh_path": shape.mesh_path,
232
+ }
233
+ )
234
+ return rows
235
+
236
+
237
+ def force_document(contract: Contract, cg_reports: list[CGReport]) -> dict[str, Any]:
238
+ """The force lane of the check document: mass, CG and mount reactions with kinds."""
239
+ report = cg_reports[0] if cg_reports else None
240
+ sources = report.sources if report is not None else "links+devices"
241
+ if report is None:
242
+ mass_total = not_scored("kg", "no mass_total budget is declared")
243
+ centre = not_scored("m", "no mass_total budget is declared")
244
+ else:
245
+ # report.limitation fires for a gap `complete` alone misses: a device
246
+ # with no declared mass is dropped from `contributors` entirely, so it
247
+ # never counts against `complete` (position-completeness) even though
248
+ # the total below is missing that mass, not summing it as zero. Carry
249
+ # the limitation onto the number itself so the mark travels with it
250
+ # (a figure this shape must not print as if it were whole).
251
+ mass_total = number(
252
+ report.total_mass_kg,
253
+ "kg",
254
+ derived_by("compute_system_cg"),
255
+ quantity=report.quantity,
256
+ complete=report.limitation is None,
257
+ limitation=report.limitation,
258
+ )
259
+ centre = (
260
+ number(
261
+ list(report.cg_m),
262
+ "m",
263
+ derived_by("compute_system_cg"),
264
+ frame=report.frame,
265
+ quantity=report.quantity,
266
+ complete=report.complete,
267
+ limitation=report.limitation,
268
+ )
269
+ if report.cg_m is not None
270
+ else not_scored(
271
+ "m",
272
+ report.limitation or "no positioned mass contributor",
273
+ quantity=report.quantity,
274
+ )
275
+ )
276
+ return {
277
+ "gravity": number(
278
+ STANDARD_G,
279
+ "m/s^2",
280
+ kind_for_source("contract"),
281
+ note="standard gravity, the constant preflight scores |g| against",
282
+ ),
283
+ "mass_total": mass_total,
284
+ "cg": centre,
285
+ "masses": _mass_rows(contract, sources),
286
+ "mounts": _mount_rows(contract, report),
287
+ "mount_shapes": _mount_shape_rows(contract),
288
+ "not_scored": [not_scored("", because, id=name) for name, because in _STAGE_ONE_CEILINGS],
289
+ }
290
+
291
+
292
+ def _rendered(entry: dict[str, Any]) -> str:
293
+ """One figure as a reader sees it — the number and its kind, or why it is absent."""
294
+ if entry["kind"] == "not_scored":
295
+ return f"NOT SCORED — {entry['not_scored_because']}"
296
+ value = entry["value"]
297
+ text = (
298
+ "(" + ", ".join(f"{component:.3f}" for component in value) + ")"
299
+ if isinstance(value, list)
300
+ else f"{value:g}"
301
+ )
302
+ rendered = f"{text} {entry['unit']}".strip() + f" (kind: {entry['kind']})"
303
+ limitation = entry.get("limitation")
304
+ if limitation:
305
+ rendered += f" — INCOMPLETE: {limitation}"
306
+ return rendered
307
+
308
+
309
+ def render_markdown(document: dict[str, Any]) -> list[str]:
310
+ """The Markdown face of ``force_document`` — the payload's numbers, not a second set.
311
+
312
+ ``describe`` renders through here so the reference file and the check
313
+ document cannot print different figures (or a figure without its kind).
314
+ """
315
+ lines = ["## Forces", ""]
316
+ lines.append(f"- total mass: {_rendered(document['mass_total'])}")
317
+ lines.append(f"- centre of gravity: {_rendered(document['cg'])}")
318
+ lines.append(f"- gravity: {_rendered(document['gravity'])}")
319
+ for row in document["masses"]:
320
+ lines.append(f"- mass {row['id']}: {_rendered(row)}")
321
+ for row in document["mounts"]:
322
+ moment = f", moment {row['moment_nm']:g} N*m" if row["kind"] != "not_scored" else ""
323
+ lines.append(f"- reaction at mount {row['id']}: {_rendered(row)}{moment}")
324
+ for row in document["mount_shapes"]:
325
+ suffix = f", fasteners {row['fastener_pattern']}" if row["fastener_pattern"] else ""
326
+ if row["mesh_path"]:
327
+ suffix += f", mesh {row['mesh_path']}"
328
+ lines.append(
329
+ f"- mount shape {row['id']}: {row['kind']}, {_rendered(row['dimensions'])}{suffix}"
330
+ )
331
+ for row in document["not_scored"]:
332
+ lines.append(f"- {row['id']}: {_rendered(row)}")
333
+ lines.append("")
334
+ return lines
lockwire/gitref.py ADDED
@@ -0,0 +1,188 @@
1
+ """The one door to git. Nothing else in lockwire may shell out to git.
2
+
3
+ Every call goes through :func:`_git`: a list argv, ``shell=False``, no string
4
+ interpolation anywhere. A ref that arrives from the command line is validated
5
+ by :func:`valid_ref` before it can become an argument, so a hostile name
6
+ (``; rm -rf /``, ``--upload-pack=touch pwned``, ``--output=…``) is refused
7
+ rather than parsed as an option — argv already defeats the shell, and the
8
+ leading-dash rule defeats the option smuggling argv does not.
9
+
10
+ Repo questions are answered by ``git rev-parse``, never by looking for a
11
+ ``.git`` directory: inside a worktree ``.git`` is a *file* holding a
12
+ ``gitdir:`` pointer, and every lockwire worktree is laid out that way.
13
+
14
+ `log` and `blame` belong to the commands that need them; they add
15
+ their command here, on top of :func:`_git`, rather than opening a second door.
16
+ """
17
+
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import Sequence
21
+
22
+ TIMEOUT_S = 20
23
+
24
+
25
+ def valid_ref(ref: str) -> bool:
26
+ """True when ``ref`` is safe to pass to git as an argument.
27
+
28
+ Rejects the empty string, anything starting with ``-`` (git would read it
29
+ as an option) and any control character. Whether the ref *exists* is
30
+ ``rev_parse``'s question, not this one's.
31
+ """
32
+ if not ref or ref.startswith("-"):
33
+ return False
34
+ return all(ch.isprintable() for ch in ref) and " " not in ref
35
+
36
+
37
+ def _git(args: Sequence[str], *, cwd: Path) -> tuple[int, str]:
38
+ """Run git with a list argv. Returns (returncode, stdout); no git -> (127, "")."""
39
+ try:
40
+ done = subprocess.run(
41
+ ["git", *args],
42
+ cwd=cwd,
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=TIMEOUT_S,
46
+ check=False,
47
+ )
48
+ except (FileNotFoundError, OSError, subprocess.SubprocessError):
49
+ return 127, ""
50
+ return done.returncode, done.stdout
51
+
52
+
53
+ def repo_root(path: Path) -> Path | None:
54
+ """Root of the working tree containing ``path``, or None outside a repo.
55
+
56
+ ``path`` may be a file or a directory; a missing directory is not a repo.
57
+ """
58
+ start = path if path.is_dir() else path.parent
59
+ if not start.is_dir():
60
+ return None
61
+ code, out = _git(["rev-parse", "--show-toplevel"], cwd=start)
62
+ if code != 0 or not out.strip():
63
+ return None
64
+ return Path(out.strip())
65
+
66
+
67
+ def rev_parse(ref: str, *, repo: Path) -> str | None:
68
+ """Full commit sha for ``ref``, or None when the ref is unsafe or unknown."""
69
+ if not valid_ref(ref):
70
+ return None
71
+ code, out = _git(["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=repo)
72
+ if code != 0 or not out.strip():
73
+ return None
74
+ return out.strip()
75
+
76
+
77
+ def relpath_in_repo(path: Path, repo: Path) -> str | None:
78
+ """``path`` as a repo-root-relative POSIX path, or None if it is outside."""
79
+ try:
80
+ return path.resolve().relative_to(repo.resolve()).as_posix()
81
+ except ValueError:
82
+ return None
83
+
84
+
85
+ def show(ref: str, relpath: str, *, repo: Path) -> str | None:
86
+ """Text of ``relpath`` at ``ref``, or None when it did not exist there."""
87
+ if not valid_ref(ref):
88
+ return None
89
+ code, out = _git(["show", f"{ref}:{relpath}"], cwd=repo)
90
+ return None if code != 0 else out
91
+
92
+
93
+ def parent_relpath_after_rename(ref: str, relpath: str, *, repo: Path) -> str:
94
+ """Path of ``relpath`` in ``ref``'s first parent across a detected rename.
95
+
96
+ History callers walk newest to oldest. A rename commit contains the new
97
+ path, while its parent contains the old one; this translates exactly that
98
+ boundary and otherwise leaves the path unchanged.
99
+ """
100
+ if not valid_ref(ref):
101
+ return relpath
102
+ code, out = _git(
103
+ ["diff-tree", "--no-commit-id", "--name-status", "-r", "-M", ref], cwd=repo
104
+ )
105
+ if code != 0:
106
+ return relpath
107
+ for line in out.splitlines():
108
+ status, *paths = line.split("\t")
109
+ if status.startswith("R") and len(paths) == 2 and paths[1] == relpath:
110
+ return paths[0]
111
+ return relpath
112
+
113
+
114
+ def blame(relpath: str, *, repo: Path) -> str | None:
115
+ """Raw porcelain blame for one repo-relative path, or None on failure."""
116
+ code, out = _git(["blame", "--porcelain", "--", relpath], cwd=repo)
117
+ return out if code == 0 else None
118
+
119
+
120
+ def commit(relpaths: Sequence[str], *, message: str, repo: Path) -> tuple[int, str]:
121
+ """``git commit -m <message> -- <relpaths>``. Commit by path.
122
+
123
+ List argv throughout — ``message`` and every path travel as separate argv
124
+ elements, never interpolated into a string a shell could re-parse. The
125
+ trailing pathspec (``--``) is what makes this a commit *by path*: nothing
126
+ else staged or dirty is swept in. Result-shaping (the returned dict, the
127
+ witness gate) lives in ``vcs.py``, not here.
128
+ """
129
+ return _git(["commit", "-m", message, "--", *relpaths], cwd=repo)
130
+
131
+
132
+ def status_porcelain(repo: Path) -> str:
133
+ """Raw ``git status --porcelain`` output, or "" when the command fails."""
134
+ code, out = _git(["status", "--porcelain"], cwd=repo)
135
+ return out if code == 0 else ""
136
+
137
+
138
+ _LOG_FIELD_SEP = "\x1f"
139
+ _LOG_FORMAT = f"%H{_LOG_FIELD_SEP}%an{_LOG_FIELD_SEP}%aI{_LOG_FIELD_SEP}%s"
140
+
141
+
142
+ def log(
143
+ revision_range: str | None, paths: Sequence[str], *, repo: Path, follow: bool = False
144
+ ) -> list[tuple[str, str, str, str]] | None:
145
+ """Commit, author, date and subject for ``paths``, newest first."""
146
+ if follow and (revision_range is not None or len(paths) != 1):
147
+ return None
148
+ if revision_range is not None and not valid_ref(revision_range.split("..", 1)[0]):
149
+ return None
150
+ args = ["log"]
151
+ if follow:
152
+ args.append("--follow")
153
+ args.append(f"--format={_LOG_FORMAT}")
154
+ if revision_range is not None:
155
+ args.append(revision_range)
156
+ if paths:
157
+ args.extend(["--", *paths])
158
+ code, out = _git(args, cwd=repo)
159
+ if code != 0:
160
+ return None
161
+ return [tuple(line.split(_LOG_FIELD_SEP, 3)) for line in out.splitlines() if line]
162
+
163
+
164
+ def describe(*, repo: Path) -> str | None:
165
+ """``git describe --tags --always --dirty``, or None when it fails.
166
+
167
+ A document's footer wants a human ref (tag-relative, or the
168
+ short sha ``--always`` falls back to) alongside the full commit sha
169
+ ``rev_parse`` already gives. ``git`` missing or the command failing both
170
+ degrade to None rather than raising — the caller falls back further.
171
+ """
172
+ code, out = _git(["describe", "--tags", "--always", "--dirty"], cwd=repo)
173
+ return out.strip() if code == 0 and out.strip() else None
174
+
175
+
176
+ def run(args: Sequence[str], *, repo: Path) -> tuple[int, str]:
177
+ """Run a prepared git argv in ``repo`` — the door for callers that build their
178
+ own subcommand (``lockwire.vcs``'s undo shows the argv on screen before it runs,
179
+ so the list it hands back here must be the list that executes).
180
+
181
+ Same guarantees as :func:`_git`: a list argv, ``shell=False``, a timeout. Any
182
+ ref inside ``args`` is the caller's to validate with :func:`valid_ref` first.
183
+
184
+ NOTE: ``commit`` and ``status_porcelain`` above could be
185
+ expressed through this. They are left as-is rather than collapsed, because
186
+ that would be a separate change to already-reviewed code.
187
+ """
188
+ return _git(args, cwd=repo)