@miller-tech/uap 1.210.7 → 1.211.0
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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +7 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/schema-diff.d.ts +118 -2
- package/dist/cli/schema-diff.d.ts.map +1 -1
- package/dist/cli/schema-diff.js +692 -41
- package/dist/cli/schema-diff.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +7 -4
- package/dist/cli/verify.js.map +1 -1
- package/package.json +3 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/schema_diff_gate.py +629 -41
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_schema_diff_gate.py +292 -7
- package/tools/agents/tests/test_schema_diff_inline.py +373 -0
|
Binary file
|
|
Binary file
|
|
@@ -10,8 +10,10 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
import shutil
|
|
13
14
|
import sqlite3
|
|
14
15
|
import subprocess
|
|
16
|
+
import sys
|
|
15
17
|
import tempfile
|
|
16
18
|
import unittest
|
|
17
19
|
from datetime import datetime, timedelta, timezone
|
|
@@ -19,16 +21,41 @@ from pathlib import Path
|
|
|
19
21
|
|
|
20
22
|
REPO = Path(__file__).resolve().parents[3]
|
|
21
23
|
ENFORCER = REPO / "src" / "policies" / "enforcers" / "schema_diff_gate.py"
|
|
24
|
+
GIT_BIN = "git"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _git_only_path() -> str:
|
|
28
|
+
"""A PATH holding git and nothing else, so no checker can be found.
|
|
29
|
+
|
|
30
|
+
Every test in this file is about the MARKER layer, which the gate reaches
|
|
31
|
+
only when the inline checker cannot answer. Left to the ambient PATH these
|
|
32
|
+
tests passed for an accidental reason: the globally installed `uap`
|
|
33
|
+
predates --json, so the inline layer errored and fell through. The day that
|
|
34
|
+
binary is updated the inline layer would answer first and roughly a third
|
|
35
|
+
of the assertions below would silently invert -- green either way, but
|
|
36
|
+
testing something else entirely. Pinning the layer makes the choice
|
|
37
|
+
deliberate; the inline layer has its own suite in
|
|
38
|
+
test_schema_diff_inline.py.
|
|
39
|
+
"""
|
|
40
|
+
d = Path(tempfile.mkdtemp(prefix="schema-gate-gitonly-"))
|
|
41
|
+
found = shutil.which(GIT_BIN)
|
|
42
|
+
if found:
|
|
43
|
+
(d / GIT_BIN).symlink_to(found)
|
|
44
|
+
return str(d)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_GIT_ONLY_PATH = _git_only_path()
|
|
22
48
|
|
|
23
49
|
|
|
24
50
|
def run_gate(op: str, args: dict, root: Path, tz: str | None = None):
|
|
25
51
|
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
26
52
|
env["UAP_REPO_ROOT"] = str(root)
|
|
27
53
|
env["UAP_WORKTREE_ROOT"] = str(root)
|
|
54
|
+
env["PATH"] = _GIT_ONLY_PATH
|
|
28
55
|
if tz:
|
|
29
56
|
env["TZ"] = tz
|
|
30
57
|
r = subprocess.run(
|
|
31
|
-
[
|
|
58
|
+
[sys.executable, str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
|
|
32
59
|
cwd=root,
|
|
33
60
|
env=env,
|
|
34
61
|
capture_output=True,
|
|
@@ -59,14 +86,21 @@ class SchemaDiffGateTest(unittest.TestCase):
|
|
|
59
86
|
def tearDown(self):
|
|
60
87
|
self._tmp.cleanup()
|
|
61
88
|
|
|
62
|
-
|
|
89
|
+
# The literal the CLI records (src/cli/schema-diff.ts). The gate matches it
|
|
90
|
+
# ANCHORED, so this string is now a contract between the two.
|
|
91
|
+
MARKER = "schema-diff pass: base HEAD~1 | files: migrations/001_add_table.sql"
|
|
92
|
+
|
|
93
|
+
def write_marker(self, iso_timestamp: str, table: str = "memories", content: str | None = None):
|
|
63
94
|
mem = self.root / "agents" / "data" / "memory"
|
|
64
|
-
mem.mkdir(parents=True)
|
|
95
|
+
mem.mkdir(parents=True, exist_ok=True)
|
|
65
96
|
con = sqlite3.connect(mem / "short_term.db")
|
|
66
|
-
con.execute(
|
|
97
|
+
con.execute(
|
|
98
|
+
f"CREATE TABLE IF NOT EXISTS {table} "
|
|
99
|
+
"(id INTEGER PRIMARY KEY, content TEXT, timestamp TEXT)"
|
|
100
|
+
)
|
|
67
101
|
con.execute(
|
|
68
102
|
f"INSERT INTO {table} (content, timestamp) VALUES (?, ?)",
|
|
69
|
-
(
|
|
103
|
+
(self.MARKER if content is None else content, iso_timestamp),
|
|
70
104
|
)
|
|
71
105
|
con.commit()
|
|
72
106
|
con.close()
|
|
@@ -110,9 +144,229 @@ class SchemaDiffGateTest(unittest.TestCase):
|
|
|
110
144
|
self.assertEqual(code, 2)
|
|
111
145
|
self.assertFalse(allowed)
|
|
112
146
|
|
|
147
|
+
def test_the_gates_own_refusal_message_does_not_clear_it(self):
|
|
148
|
+
"""A block message that is itself a valid unblock token is self-defeating.
|
|
113
149
|
|
|
114
|
-
|
|
115
|
-
|
|
150
|
+
The old matcher was LIKE '%schema-diff%pass%', which the gate's own
|
|
151
|
+
refusal text satisfies ("...require `uap schema-diff` to pass"). Agents
|
|
152
|
+
here are instructed to store lessons, and blockers are exactly what they
|
|
153
|
+
store — so recording the refusal unblocked the next commit.
|
|
154
|
+
"""
|
|
155
|
+
self.write_marker(
|
|
156
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
157
|
+
content=(
|
|
158
|
+
"Lesson: schema-diff-gate: changes to migrations/001_add_table.sql "
|
|
159
|
+
"require `uap schema-diff` to pass (within 1h). Run it and re-commit."
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
code, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
163
|
+
self.assertFalse(allowed, "the gate's own refusal text must not clear the gate")
|
|
164
|
+
self.assertEqual(code, 2)
|
|
165
|
+
|
|
166
|
+
def test_a_marker_from_the_PREVIOUS_cli_release_still_clears_it(self):
|
|
167
|
+
"""Anchoring must not deadlock operators running an older global `uap`.
|
|
168
|
+
|
|
169
|
+
The previously-released recorder wrote
|
|
170
|
+
"schema-diff pass: base <b>, N schema file(s) checked, no breaking
|
|
171
|
+
changes" — same prefix — so it still matches. If a future format change
|
|
172
|
+
drops that prefix, the gate must be updated in the same commit or every
|
|
173
|
+
operator on the old CLI is blocked with no reachable remedy.
|
|
174
|
+
"""
|
|
175
|
+
self.write_marker(
|
|
176
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
177
|
+
content="schema-diff pass: base HEAD~1, 3 schema file(s) checked, no breaking changes",
|
|
178
|
+
)
|
|
179
|
+
_, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
180
|
+
self.assertTrue(allowed, "a marker from the previous CLI release must still clear the gate")
|
|
181
|
+
|
|
182
|
+
def _blob_sha(self, rel: str) -> str:
|
|
183
|
+
"""FULL 40-char sha. The enforcer compares exactly: a 7-hex prefix
|
|
184
|
+
matched with startswith is a 28-bit binding, grindable in minutes
|
|
185
|
+
against fully attacker-chosen SQL."""
|
|
186
|
+
out = subprocess.run(
|
|
187
|
+
["git", "hash-object", "--", rel], cwd=self.root, capture_output=True, text=True
|
|
188
|
+
)
|
|
189
|
+
return out.stdout.strip()
|
|
190
|
+
|
|
191
|
+
def test_a_content_scoped_marker_covering_the_staged_bytes_clears_it(self):
|
|
192
|
+
rel = "migrations/001_add_table.sql"
|
|
193
|
+
self.write_marker(
|
|
194
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
195
|
+
content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
|
|
196
|
+
)
|
|
197
|
+
_, allowed, reason = run_gate("git-commit", {}, self.root)
|
|
198
|
+
self.assertTrue(allowed, reason)
|
|
199
|
+
self.assertIn("covers the committed content", reason)
|
|
200
|
+
|
|
201
|
+
def test_a_pass_stops_covering_content_once_it_changes(self):
|
|
202
|
+
"""THE REDESIGN. A marker vouches for BYTES, not for a time window.
|
|
203
|
+
|
|
204
|
+
Before this, one pass cleared every watched path for an hour no matter
|
|
205
|
+
what happened next — so a reviewed migration could be swapped for an
|
|
206
|
+
unreviewed one and committed inside the window.
|
|
207
|
+
"""
|
|
208
|
+
rel = "migrations/001_add_table.sql"
|
|
209
|
+
self.write_marker(
|
|
210
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
211
|
+
content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
|
|
212
|
+
)
|
|
213
|
+
# STAGE the change: what a plain `git commit` will store is what must
|
|
214
|
+
# be covered. (A worktree-only edit is deliberately NOT a block for a
|
|
215
|
+
# plain commit — see the companion test — because those bytes are not
|
|
216
|
+
# what gets stored, and blocking on them made partial staging
|
|
217
|
+
# unclearable.)
|
|
218
|
+
(self.root / rel).write_text("CREATE TABLE t (id int);\nDROP TABLE t;\n")
|
|
219
|
+
subprocess.run(["git", "add", rel], cwd=self.root, check=True)
|
|
220
|
+
_, allowed, reason = run_gate("git-commit", {}, self.root)
|
|
221
|
+
self.assertFalse(allowed, "staged content changed after the pass must re-arm the gate")
|
|
222
|
+
self.assertIn("no recent pass covers the CURRENT content", reason)
|
|
223
|
+
|
|
224
|
+
def test_a_diverging_worktree_must_be_covered_too(self):
|
|
225
|
+
"""When the index and the worktree disagree, BOTH have to be covered.
|
|
226
|
+
|
|
227
|
+
This used to assert that a plain `git commit` was allowed here, on the
|
|
228
|
+
grounds that it stores the index and only `-a` stores the worktree.
|
|
229
|
+
That reasoning was right about those two forms and wrong about git:
|
|
230
|
+
`git commit -- <path>`, `--only`, `--include`, `-o` and `-i` also store
|
|
231
|
+
the worktree copy, and none of them contains `-a`. Reading the source
|
|
232
|
+
off the command string was a demonstrated bypass -- stage something
|
|
233
|
+
benign, edit the file, `git commit -m x -- <path>`, and the dropped
|
|
234
|
+
column landed while the gate reported the staged content clean.
|
|
235
|
+
|
|
236
|
+
The command-form space is bigger than it looks and this gate had
|
|
237
|
+
already got it wrong twice, so the inference is gone: there are only
|
|
238
|
+
two candidate byte-strings, and when they differ both must be clear.
|
|
239
|
+
|
|
240
|
+
Note what this is NOT. An earlier over-correction demanded that a
|
|
241
|
+
single MARKER cover both versions, which was unclearable -- no
|
|
242
|
+
sequence of commands produced such a marker. Here each version is
|
|
243
|
+
checked on its own, so ordinary partial staging passes; only an
|
|
244
|
+
actually-breaking copy refuses, and the reason names which one.
|
|
245
|
+
"""
|
|
246
|
+
rel = "migrations/001_add_table.sql"
|
|
247
|
+
self.write_marker(
|
|
248
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
249
|
+
content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
|
|
250
|
+
)
|
|
251
|
+
(self.root / rel).write_text("CREATE TABLE t (id int);\nDROP TABLE t;\n") # unstaged
|
|
252
|
+
for command in ("git commit -m x", "git commit -am x", "git commit -m x -- " + rel):
|
|
253
|
+
with self.subTest(command=command):
|
|
254
|
+
_, allowed, _ = run_gate("git-commit", {"command": command}, self.root)
|
|
255
|
+
self.assertFalse(
|
|
256
|
+
allowed,
|
|
257
|
+
"the worktree copy is breaking and several commit forms store it",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def test_a_watched_path_the_CLI_cannot_parse_is_still_coverable(self):
|
|
261
|
+
"""P0: the gate watched a SUPERSET of what the CLI examined.
|
|
262
|
+
|
|
263
|
+
`infra/helm_charts/**` and `infra/postgres-spock/**` are watched but are
|
|
264
|
+
YAML, which the CLI's filter skipped — so they could never enter a
|
|
265
|
+
marker, coverage always failed, and the refusal told the operator to
|
|
266
|
+
re-run a command that produced the identical marker. Verified as a
|
|
267
|
+
permanent block on the policy's headline file class before the fix.
|
|
268
|
+
Here the marker CAN name it, which is only true because the CLI now
|
|
269
|
+
examines everything the gate watches.
|
|
270
|
+
"""
|
|
271
|
+
helm = self.root / "infra" / "helm_charts" / "pgdog"
|
|
272
|
+
helm.mkdir(parents=True)
|
|
273
|
+
rel = "infra/helm_charts/pgdog/values.yaml"
|
|
274
|
+
(self.root / rel).write_text("replicas: 2\n")
|
|
275
|
+
subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
|
|
276
|
+
sql = "migrations/001_add_table.sql"
|
|
277
|
+
self.write_marker(
|
|
278
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
279
|
+
content=(
|
|
280
|
+
f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)},"
|
|
281
|
+
f"{sql}@{self._blob_sha(sql)}"
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
_, allowed, reason = run_gate("git-commit", {}, self.root)
|
|
285
|
+
self.assertTrue(allowed, reason)
|
|
286
|
+
|
|
287
|
+
def test_staged_bytes_are_judged_not_only_the_worktree(self):
|
|
288
|
+
"""P0: the marker vouched for the WORKTREE while `git commit` takes the
|
|
289
|
+
INDEX.
|
|
290
|
+
|
|
291
|
+
Stage malicious, restore benign on disk, run the remedy against the
|
|
292
|
+
benign bytes — the gate announced "covers the staged content" and
|
|
293
|
+
allowed, while the commit would have taken the drop. Verified.
|
|
294
|
+
"""
|
|
295
|
+
rel = "migrations/001_add_table.sql"
|
|
296
|
+
(self.root / rel).write_text("DROP TABLE t;") # index = malicious
|
|
297
|
+
subprocess.run(["git", "add", rel], cwd=self.root, check=True)
|
|
298
|
+
(self.root / rel).write_text("CREATE TABLE t (id int);") # worktree = benign
|
|
299
|
+
self.write_marker(
|
|
300
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
301
|
+
content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
|
|
302
|
+
)
|
|
303
|
+
_, allowed, reason = run_gate("git-commit", {}, self.root)
|
|
304
|
+
self.assertFalse(allowed, "staged bytes the pass never saw must not be committable")
|
|
305
|
+
|
|
306
|
+
def test_a_second_worktrees_marker_does_not_invalidate_this_ones(self):
|
|
307
|
+
"""Every worktree writes to the SAME main-checkout database. Consulting
|
|
308
|
+
only the newest marker made two concurrent worktrees invalidate each
|
|
309
|
+
other forever — a livelock this repo's .worktrees workflow would hit,
|
|
310
|
+
and a regression versus the old time-only rule."""
|
|
311
|
+
rel = "migrations/001_add_table.sql"
|
|
312
|
+
now = datetime.now(timezone.utc)
|
|
313
|
+
# ours first...
|
|
314
|
+
self.write_marker(
|
|
315
|
+
(now - timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
316
|
+
content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
|
|
317
|
+
)
|
|
318
|
+
# ...then a NEWER one from another worktree, covering unrelated files
|
|
319
|
+
self.write_marker(
|
|
320
|
+
now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
321
|
+
content="schema-diff pass: base HEAD~1 | files: other/999.sql@" + "0" * 40,
|
|
322
|
+
)
|
|
323
|
+
_, allowed, reason = run_gate("git-commit", {}, self.root)
|
|
324
|
+
self.assertTrue(allowed, "any in-window marker covering these bytes must clear: " + reason)
|
|
325
|
+
|
|
326
|
+
def test_PRESERVE_a_legacy_marker_without_shas_still_clears_it(self):
|
|
327
|
+
"""Markers from the installed older CLI carry bare paths. Judging them
|
|
328
|
+
for coverage would block every operator who has not upgraded, with no
|
|
329
|
+
way to produce a covering marker — so they keep the time-only rule."""
|
|
330
|
+
self.write_marker(
|
|
331
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
332
|
+
content="schema-diff pass: base HEAD~1 | files: migrations/001_add_table.sql",
|
|
333
|
+
)
|
|
334
|
+
_, allowed, reason = run_gate("git-commit", {}, self.root)
|
|
335
|
+
self.assertTrue(allowed, reason)
|
|
336
|
+
|
|
337
|
+
def test_PRESERVE_a_truncated_marker_does_not_deadlock(self):
|
|
338
|
+
"""A change too large to enumerate records "(truncated)". Coverage is
|
|
339
|
+
unknowable, so it falls back rather than blocking unclearably."""
|
|
340
|
+
self.write_marker(
|
|
341
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
342
|
+
content="schema-diff pass: base HEAD~1 | files: (truncated: 412 files)",
|
|
343
|
+
)
|
|
344
|
+
_, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
345
|
+
self.assertTrue(allowed)
|
|
346
|
+
|
|
347
|
+
def test_a_note_saying_the_diff_FAILED_does_not_clear_it(self):
|
|
348
|
+
self.write_marker(
|
|
349
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
350
|
+
content="schema-diff FAILED - breaking change, do not pass this to review",
|
|
351
|
+
)
|
|
352
|
+
_, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
353
|
+
self.assertFalse(allowed, "a failure note must not read as a pass")
|
|
354
|
+
|
|
355
|
+
def test_a_future_dated_marker_does_not_clear_it(self):
|
|
356
|
+
"""The window was upper-bounded only, so a future timestamp cleared the
|
|
357
|
+
gate until wall-clock caught up — reachable via an importing writer that
|
|
358
|
+
supplies its own timestamp, or plain clock skew."""
|
|
359
|
+
ahead = (datetime.now(timezone.utc) + timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
360
|
+
self.write_marker(ahead)
|
|
361
|
+
_, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
362
|
+
self.assertFalse(allowed, "a future-dated marker must not clear the gate")
|
|
363
|
+
|
|
364
|
+
def test_watched_path_is_listed_once(self):
|
|
365
|
+
"""A file both unstaged and staged appeared twice in the reason line,
|
|
366
|
+
which reads as two covered files."""
|
|
367
|
+
(self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
|
|
368
|
+
_, _, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
|
|
369
|
+
self.assertEqual(reason.count("001_add_table.sql"), 1, reason)
|
|
116
370
|
|
|
117
371
|
|
|
118
372
|
class MergeVerbatimTest(unittest.TestCase):
|
|
@@ -161,9 +415,40 @@ class MergeVerbatimTest(unittest.TestCase):
|
|
|
161
415
|
|
|
162
416
|
def test_merge_edited_migration_still_gates(self):
|
|
163
417
|
# Editing the migration during the merge makes it THIS branch's change.
|
|
418
|
+
# NB this stages the edit; the UNSTAGED variant below is the one that
|
|
419
|
+
# was actually exploitable.
|
|
164
420
|
(self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
|
|
165
421
|
subprocess.run(["git", "add", "migrations/001_add_table.sql"], cwd=self.root, check=True)
|
|
166
422
|
_, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
|
|
167
423
|
self.assertFalse(allowed, "a migration edited during the merge must still gate")
|
|
168
424
|
self.assertIn("schema-diff", reason)
|
|
169
425
|
|
|
426
|
+
def test_UNSTAGED_merge_edit_still_gates(self):
|
|
427
|
+
"""The exemption compared the INDEX blob, but `git commit -a` commits the
|
|
428
|
+
WORKING TREE.
|
|
429
|
+
|
|
430
|
+
Reproduced against the shipped gate: with the index still matching
|
|
431
|
+
MERGE_HEAD and an unstaged `DROP TABLE users;` appended, it answered
|
|
432
|
+
{"allowed": true, "reason": "no watched schema/pool paths in diff"} —
|
|
433
|
+
not even naming the exemption — while `git commit -am` would have
|
|
434
|
+
committed the drop. This is also the likely ACCIDENTAL path: a human
|
|
435
|
+
resolving a conflict, tweaking a migration, running `git commit -am`.
|
|
436
|
+
"""
|
|
437
|
+
p = self.root / "migrations" / "001_add_table.sql"
|
|
438
|
+
p.write_text(p.read_text() + "\nDROP TABLE t;\n") # deliberately NOT staged
|
|
439
|
+
_, allowed, reason = run_gate("git-commit", {"command": "git commit -am merge"}, self.root)
|
|
440
|
+
self.assertFalse(
|
|
441
|
+
allowed,
|
|
442
|
+
"an unstaged edit during a merge is committed by `commit -a` and must gate: " + reason,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def test_a_genuinely_verbatim_merge_is_still_exempt_after_the_fix(self):
|
|
446
|
+
"""The deadlock fix must survive the hardening: with no local edit at
|
|
447
|
+
all, the incoming migration stays exempt."""
|
|
448
|
+
_, allowed, reason = run_gate("git-commit", {"command": "git commit -am merge"}, self.root)
|
|
449
|
+
self.assertTrue(allowed, f"verbatim merge must remain exempt: {reason}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
if __name__ == "__main__":
|
|
453
|
+
unittest.main()
|
|
454
|
+
|