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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- 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)
|