@codyswann/lisa 4.73.0 → 4.73.2
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/all/copy-overwrite/scripts/lib/history-secret-git.mjs +158 -5
- package/all/copy-overwrite/scripts/lib/npm-update-bun.mjs +6 -2
- package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +25 -1
- package/all/copy-overwrite/scripts/lisa-history-secrets.mjs +17 -3
- package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +86 -7
- package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +7 -1
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +62 -7
- package/all/copy-overwrite/scripts/lisa-rails-prepush.mjs +4 -1
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +191 -5
- package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +6 -6
- package/all/deletions.json +19 -2
- package/dist/codex/scripts/block-no-verify.sh +62 -7
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +82 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa.d.ts +31 -0
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +69 -4
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/retire-unmodified.d.ts +66 -0
- package/dist/core/retire-unmodified.d.ts.map +1 -0
- package/dist/core/retire-unmodified.js +244 -0
- package/dist/core/retire-unmodified.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +23 -17
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-managed-file-edits.sh +7 -1
- package/dist/opencode/plugin-templates/block-no-verify.sh +62 -7
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/auto-update.mjs +69 -1
- package/plugins/lisa/hooks/block-blind-automerge.sh +86 -7
- package/plugins/lisa/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/lisa/hooks/block-no-verify.agy.sh +62 -7
- package/plugins/lisa/hooks/block-no-verify.sh +62 -7
- package/plugins/lisa-agy/hooks/block-blind-automerge.sh +86 -7
- package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +62 -7
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/auto-update.mjs +69 -1
- package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +86 -7
- package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +62 -7
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/auto-update.mjs +69 -1
- package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +86 -7
- package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +62 -7
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/auto-update.mjs +69 -1
- package/plugins/src/base/hooks/block-blind-automerge.sh +86 -7
- package/plugins/src/base/hooks/block-managed-file-edits.sh +7 -1
- package/plugins/src/base/hooks/block-no-verify.agy.sh +62 -7
- package/plugins/src/base/hooks/block-no-verify.sh +62 -7
- package/scripts/check-shipped-surface-removals.mjs +22 -2
|
@@ -211,6 +211,7 @@ if [ -z "$command_str" ]; then
|
|
|
211
211
|
fi
|
|
212
212
|
|
|
213
213
|
if ! BLOCK_BLIND_AUTOMERGE_COMMAND="$command_str" python3 - <<'PY'
|
|
214
|
+
import io
|
|
214
215
|
import json
|
|
215
216
|
import os
|
|
216
217
|
import re
|
|
@@ -244,7 +245,41 @@ MERGE_SEPARATE_VALUE = {
|
|
|
244
245
|
|
|
245
246
|
COMMAND_SEPARATORS = {
|
|
246
247
|
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
248
|
+
# `|&` pipes stdout AND stderr into the next command, so it ends a command
|
|
249
|
+
# exactly as `|` does. shlex emits it as one token, and before it was listed
|
|
250
|
+
# here `echo hi |& git commit --no-verify` read as a single `echo` command
|
|
251
|
+
# whose arguments happened to include git. The case terminators end a
|
|
252
|
+
# command the same way.
|
|
253
|
+
"|&", ";;", ";&", ";;&",
|
|
247
254
|
}
|
|
255
|
+
# shlex also GLUES adjacent punctuation into one token across distinct
|
|
256
|
+
# operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
|
|
257
|
+
# test can recognise. An operator-only token is therefore re-split into the
|
|
258
|
+
# operators it is made of, longest first. Redirections are kept whole and are
|
|
259
|
+
# deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
|
|
260
|
+
# commit's argv scope.
|
|
261
|
+
OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
|
|
262
|
+
SHELL_OPERATOR = re.compile("|".join(
|
|
263
|
+
re.escape(operator) for operator in sorted(
|
|
264
|
+
COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
|
|
265
|
+
key=lambda operator: (-len(operator), operator),
|
|
266
|
+
)
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def split_operators(token):
|
|
271
|
+
"""Split a glued operator-only token into the shell operators it holds.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
token: One shlex token that was not read inside a quote or escape.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The operators, longest first, or the token itself when it is not
|
|
278
|
+
made only of shell punctuation.
|
|
279
|
+
"""
|
|
280
|
+
if OPERATOR_ONLY.match(token):
|
|
281
|
+
return SHELL_OPERATOR.findall(token)
|
|
282
|
+
return [token]
|
|
248
283
|
|
|
249
284
|
# Shell wrappers whose `-c` argument is a command in a string rather than argv.
|
|
250
285
|
# The outer argv names bash, so the arming is invisible without recursing once.
|
|
@@ -422,12 +457,40 @@ def shell_tokens(text):
|
|
|
422
457
|
Returns:
|
|
423
458
|
The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
|
|
424
459
|
"""
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
)
|
|
460
|
+
stream = OperatorStream(line_boundaries_as_separators(text))
|
|
461
|
+
lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
|
|
428
462
|
lexer.whitespace_split = True
|
|
429
463
|
lexer.commenters = ""
|
|
430
|
-
|
|
464
|
+
stream.lexer = lexer
|
|
465
|
+
tokens = []
|
|
466
|
+
while True:
|
|
467
|
+
stream.literal_operator = False
|
|
468
|
+
token = lexer.get_token()
|
|
469
|
+
if token is None:
|
|
470
|
+
return tokens
|
|
471
|
+
# Quoted punctuation is an argument, never a boundary: a branch can be
|
|
472
|
+
# named `|&`, and `gh pr merge '|&' --auto` must keep `--auto` in the
|
|
473
|
+
# merge's own argv. Only real operator runs are split.
|
|
474
|
+
if stream.literal_operator:
|
|
475
|
+
tokens.append(
|
|
476
|
+
chr(0) + token if token in COMMAND_SEPARATORS else token
|
|
477
|
+
)
|
|
478
|
+
else:
|
|
479
|
+
tokens.extend(split_operators(token))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class OperatorStream(io.StringIO):
|
|
483
|
+
"""Remember punctuation read while shlex is inside a quote or escape."""
|
|
484
|
+
|
|
485
|
+
lexer = None
|
|
486
|
+
literal_operator = False
|
|
487
|
+
|
|
488
|
+
def read(self, size=-1):
|
|
489
|
+
value = super().read(size)
|
|
490
|
+
if (self.lexer is not None and self.lexer.state in {"'", '"', chr(92)}
|
|
491
|
+
and any(char in "();|&<>" for char in value)):
|
|
492
|
+
self.literal_operator = True
|
|
493
|
+
return value
|
|
431
494
|
|
|
432
495
|
|
|
433
496
|
def is_gh(token):
|
|
@@ -493,6 +556,22 @@ def arms_auto_merge(argv):
|
|
|
493
556
|
return False
|
|
494
557
|
|
|
495
558
|
|
|
559
|
+
def literal_value(token):
|
|
560
|
+
"""The argument a token stands for, without the quoted-separator marker.
|
|
561
|
+
|
|
562
|
+
`shell_tokens` prefixes a QUOTED separator with NUL so no scan stops at it.
|
|
563
|
+
That marker is for boundary checks only: a value handed to a gh probe
|
|
564
|
+
(a selector, a base ref, a repo) must be the argument the shell passes.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
token: One token from `shell_tokens`.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
The token as the shell would pass it.
|
|
571
|
+
"""
|
|
572
|
+
return token[1:] if token.startswith(chr(0)) else token
|
|
573
|
+
|
|
574
|
+
|
|
496
575
|
def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
|
|
497
576
|
"""The PR the command names, as gh itself would read it.
|
|
498
577
|
|
|
@@ -518,7 +597,7 @@ def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
|
|
|
518
597
|
index += 1
|
|
519
598
|
continue
|
|
520
599
|
if not token.startswith("-"):
|
|
521
|
-
return token
|
|
600
|
+
return literal_value(token)
|
|
522
601
|
return None
|
|
523
602
|
|
|
524
603
|
|
|
@@ -538,7 +617,7 @@ def retarget_target(argv):
|
|
|
538
617
|
if token in COMMAND_SEPARATORS:
|
|
539
618
|
return None
|
|
540
619
|
if token in BASE_FLAGS and index < len(argv):
|
|
541
|
-
return argv[index]
|
|
620
|
+
return literal_value(argv[index])
|
|
542
621
|
if token.startswith("--base="):
|
|
543
622
|
return token.split("=", 1)[1]
|
|
544
623
|
if token in EDIT_SEPARATE_VALUE:
|
|
@@ -562,7 +641,7 @@ def repo_flag(tokens, start):
|
|
|
562
641
|
if token in COMMAND_SEPARATORS:
|
|
563
642
|
break
|
|
564
643
|
if token in {"-R", "--repo"} and index + 1 < len(tokens):
|
|
565
|
-
return ["--repo", tokens[index + 1]]
|
|
644
|
+
return ["--repo", literal_value(tokens[index + 1])]
|
|
566
645
|
if token.startswith("--repo="):
|
|
567
646
|
return ["--repo", token.split("=", 1)[1]]
|
|
568
647
|
index += 1
|
|
@@ -597,7 +597,13 @@ ENV_SPLIT_UNRESOLVED = 42
|
|
|
597
597
|
BACKSLASH = chr(92)
|
|
598
598
|
NEWLINE = chr(10)
|
|
599
599
|
|
|
600
|
-
|
|
600
|
+
# `|&` pipes stdout and stderr into the next command, and the case terminators
|
|
601
|
+
# end a statement as `;` does. Without them `printf x |& tee <managed>` stayed
|
|
602
|
+
# one statement whose command word was `printf`, so the `tee` write target was
|
|
603
|
+
# never examined.
|
|
604
|
+
STATEMENT_SEPARATORS = {
|
|
605
|
+
";", "|", "||", "&", "&&", "(", ")", "&|", "|&", ";;", ";&", ";;&",
|
|
606
|
+
}
|
|
601
607
|
# Ordinary output, noclobber override, and combined stdout/stderr output.
|
|
602
608
|
# shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
|
|
603
609
|
REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
|
|
@@ -170,7 +170,41 @@ def disables_verification(token):
|
|
|
170
170
|
# tokens, so the invocation's boundary is a token the scan can stop at.
|
|
171
171
|
COMMAND_SEPARATORS = {
|
|
172
172
|
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
173
|
+
# `|&` pipes stdout AND stderr into the next command, so it ends a command
|
|
174
|
+
# exactly as `|` does. shlex emits it as one token, and before it was listed
|
|
175
|
+
# here `echo hi |& git commit --no-verify` read as a single `echo` command
|
|
176
|
+
# whose arguments happened to include git. The case terminators end a
|
|
177
|
+
# command the same way.
|
|
178
|
+
"|&", ";;", ";&", ";;&",
|
|
173
179
|
}
|
|
180
|
+
# shlex also GLUES adjacent punctuation into one token across distinct
|
|
181
|
+
# operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
|
|
182
|
+
# test can recognise. An operator-only token is therefore re-split into the
|
|
183
|
+
# operators it is made of, longest first. Redirections are kept whole and are
|
|
184
|
+
# deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
|
|
185
|
+
# commit's argv scope.
|
|
186
|
+
OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
|
|
187
|
+
SHELL_OPERATOR = re.compile("|".join(
|
|
188
|
+
re.escape(operator) for operator in sorted(
|
|
189
|
+
COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
|
|
190
|
+
key=lambda operator: (-len(operator), operator),
|
|
191
|
+
)
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def split_operators(token):
|
|
196
|
+
"""Split a glued operator-only token into the shell operators it holds.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
token: One shlex token that was not read inside a quote or escape.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The operators, longest first, or the token itself when it is not
|
|
203
|
+
made only of shell punctuation.
|
|
204
|
+
"""
|
|
205
|
+
if OPERATOR_ONLY.match(token):
|
|
206
|
+
return SHELL_OPERATOR.findall(token)
|
|
207
|
+
return [token]
|
|
174
208
|
|
|
175
209
|
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
176
210
|
MAX_SHELL_NESTING = 8
|
|
@@ -324,14 +358,28 @@ def shell_tokens(text):
|
|
|
324
358
|
Returns:
|
|
325
359
|
The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
|
|
326
360
|
"""
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
)
|
|
361
|
+
stream = OperatorStream(line_boundaries_as_separators(text))
|
|
362
|
+
lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
|
|
330
363
|
lexer.whitespace_split = True
|
|
331
364
|
# shlex treats `#` as a comment introducer by default and would silently
|
|
332
365
|
# truncate the rest of the line; `shlex.split` disables it, and so must this.
|
|
333
366
|
lexer.commenters = ""
|
|
334
|
-
|
|
367
|
+
stream.lexer = lexer
|
|
368
|
+
tokens = []
|
|
369
|
+
while True:
|
|
370
|
+
stream.literal_operator = False
|
|
371
|
+
token = lexer.get_token()
|
|
372
|
+
if token is None:
|
|
373
|
+
return tokens
|
|
374
|
+
# Quoted punctuation is an argument, never a boundary: it stays whole,
|
|
375
|
+
# and a quoted separator is marked so no scan stops at it (a path or
|
|
376
|
+
# branch can be named `|&`). Only real operator runs are split.
|
|
377
|
+
if stream.literal_operator:
|
|
378
|
+
tokens.append(
|
|
379
|
+
chr(0) + token if token in COMMAND_SEPARATORS else token
|
|
380
|
+
)
|
|
381
|
+
else:
|
|
382
|
+
tokens.extend(split_operators(token))
|
|
335
383
|
|
|
336
384
|
|
|
337
385
|
def cluster_skips_verification(cluster):
|
|
@@ -947,6 +995,10 @@ def resolve_script(token):
|
|
|
947
995
|
"""
|
|
948
996
|
if COMPUTED_VALUE.search(token):
|
|
949
997
|
return (None, "a computed path the guard cannot resolve before the shell does")
|
|
998
|
+
# A quoted separator carries a NUL marker from shell_tokens; the path the
|
|
999
|
+
# shell passes is the argument without it.
|
|
1000
|
+
if token.startswith(chr(0)):
|
|
1001
|
+
token = token[1:]
|
|
950
1002
|
text = os.path.expanduser(token.strip().strip("'\""))
|
|
951
1003
|
# `bash -` and a bare `-` read the script from stdin; there is no file.
|
|
952
1004
|
if not text or text == "-":
|
|
@@ -1897,9 +1949,12 @@ def flat_tokens(text):
|
|
|
1897
1949
|
# shlex otherwise makes quoted ";" indistinguishable from a real
|
|
1898
1950
|
# command boundary. Keep literal destinations as data, so a setter
|
|
1899
1951
|
# cannot masquerade as a one-operand configuration read.
|
|
1900
|
-
if
|
|
1901
|
-
token
|
|
1902
|
-
|
|
1952
|
+
if stream.literal_operator:
|
|
1953
|
+
if token in COMMAND_SEPARATORS:
|
|
1954
|
+
token = "\x00" + token
|
|
1955
|
+
tokens.append(token)
|
|
1956
|
+
else:
|
|
1957
|
+
tokens.extend(split_operators(token))
|
|
1903
1958
|
return [
|
|
1904
1959
|
token
|
|
1905
1960
|
for index, token in enumerate(tokens)
|
|
@@ -42,6 +42,7 @@ import { execFile } from "child_process";
|
|
|
42
42
|
import {
|
|
43
43
|
existsSync,
|
|
44
44
|
lstatSync,
|
|
45
|
+
readlinkSync,
|
|
45
46
|
mkdirSync,
|
|
46
47
|
openSync,
|
|
47
48
|
closeSync,
|
|
@@ -580,6 +581,70 @@ async function applyUpdate(ctx, update) {
|
|
|
580
581
|
}
|
|
581
582
|
}
|
|
582
583
|
|
|
584
|
+
/**
|
|
585
|
+
* What each pending file holds right now, so the later commit can prove it is
|
|
586
|
+
* still committing the update's bytes and not an edit made since
|
|
587
|
+
* (CodySwannGT/lisa#4393).
|
|
588
|
+
*
|
|
589
|
+
* A path maps to `"<git mode> <identity>"`, or to null when it is absent (the
|
|
590
|
+
* update deleted it). A regular file is `100644`/`100755` plus its
|
|
591
|
+
* `git hash-object` blob id, so an executable-bit change alone is caught. A
|
|
592
|
+
* symlink is `120000 link:<target>`: hash-object follows a link and hashes what
|
|
593
|
+
* it reaches, so a link retargeted at identical bytes would otherwise read as
|
|
594
|
+
* unchanged. Anything else (a directory, a submodule) is left OUT: it cannot be
|
|
595
|
+
* proved, and the reader treats a missing entry as "differs", so it never
|
|
596
|
+
* auto-commits on it. `lisa-work-item.mjs` recomputes these the same way.
|
|
597
|
+
* @param {(argv: string[], options: object) => Promise<string>} run Runner.
|
|
598
|
+
* @param {string} cwd Project directory.
|
|
599
|
+
* @param {readonly string[]} files Pending paths.
|
|
600
|
+
* @returns {Promise<Record<string, string | null> | null>} Digests, or null when they could not be read.
|
|
601
|
+
*/
|
|
602
|
+
export async function workingTreeDigests(run, cwd, files) {
|
|
603
|
+
const digests = {};
|
|
604
|
+
const present = [];
|
|
605
|
+
for (const file of files) {
|
|
606
|
+
let stat;
|
|
607
|
+
try {
|
|
608
|
+
stat = lstatSync(path.join(cwd, file));
|
|
609
|
+
} catch {
|
|
610
|
+
digests[file] = null;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (stat.isSymbolicLink()) {
|
|
614
|
+
// A link that vanishes before it can be read is left out, which the
|
|
615
|
+
// reader treats as "differs".
|
|
616
|
+
try {
|
|
617
|
+
digests[file] = `120000 link:${readlinkSync(path.join(cwd, file))}`;
|
|
618
|
+
} catch {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
} else if (stat.isFile())
|
|
622
|
+
present.push([file, stat.mode & 0o111 ? "100755" : "100644"]);
|
|
623
|
+
}
|
|
624
|
+
if (present.length > 0) {
|
|
625
|
+
const ids = (
|
|
626
|
+
await run(
|
|
627
|
+
["git", "hash-object", "--", ...present.map(([file]) => file)],
|
|
628
|
+
{
|
|
629
|
+
cwd,
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
.split("\n")
|
|
634
|
+
.map(line => line.trim())
|
|
635
|
+
.filter(Boolean);
|
|
636
|
+
if (
|
|
637
|
+
ids.length !== present.length ||
|
|
638
|
+
!ids.every(id => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(id))
|
|
639
|
+
)
|
|
640
|
+
return null;
|
|
641
|
+
present.forEach(([file, mode], index) => {
|
|
642
|
+
digests[file] = `${mode} ${ids[index]}`;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return digests;
|
|
646
|
+
}
|
|
647
|
+
|
|
583
648
|
/**
|
|
584
649
|
* Commit the update now, or leave it pending for the first work-item binding.
|
|
585
650
|
* @param {object} ctx Context.
|
|
@@ -635,10 +700,13 @@ async function settle(ctx, update) {
|
|
|
635
700
|
projectDir,
|
|
636
701
|
await gitPath("lisa/pending-update.json")
|
|
637
702
|
);
|
|
703
|
+
const digests = await workingTreeDigests(run, projectDir, changed).catch(
|
|
704
|
+
() => null
|
|
705
|
+
);
|
|
638
706
|
mkdirSync(path.dirname(marker), { recursive: true });
|
|
639
707
|
writeFileSync(
|
|
640
708
|
marker,
|
|
641
|
-
`${JSON.stringify({ from: update.from, to: update.to, files: changed }, null, 2)}\n`
|
|
709
|
+
`${JSON.stringify({ from: update.from, to: update.to, files: changed, ...(digests ? { digests } : {}) }, null, 2)}\n`
|
|
642
710
|
);
|
|
643
711
|
return `It is NOT committed yet (${decision.reason}). The ${changed.length} changed file(s) are left in the working tree and recorded as a pending update; binding a work item on a feature branch (\`lisa-work-item.mjs link\` / \`attach-branch\`, which /lisa:track runs) commits them first, as their own commit. Do not fold these files into a feature commit.`;
|
|
644
712
|
}
|
|
@@ -211,6 +211,7 @@ if [ -z "$command_str" ]; then
|
|
|
211
211
|
fi
|
|
212
212
|
|
|
213
213
|
if ! BLOCK_BLIND_AUTOMERGE_COMMAND="$command_str" python3 - <<'PY'
|
|
214
|
+
import io
|
|
214
215
|
import json
|
|
215
216
|
import os
|
|
216
217
|
import re
|
|
@@ -244,7 +245,41 @@ MERGE_SEPARATE_VALUE = {
|
|
|
244
245
|
|
|
245
246
|
COMMAND_SEPARATORS = {
|
|
246
247
|
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
248
|
+
# `|&` pipes stdout AND stderr into the next command, so it ends a command
|
|
249
|
+
# exactly as `|` does. shlex emits it as one token, and before it was listed
|
|
250
|
+
# here `echo hi |& git commit --no-verify` read as a single `echo` command
|
|
251
|
+
# whose arguments happened to include git. The case terminators end a
|
|
252
|
+
# command the same way.
|
|
253
|
+
"|&", ";;", ";&", ";;&",
|
|
247
254
|
}
|
|
255
|
+
# shlex also GLUES adjacent punctuation into one token across distinct
|
|
256
|
+
# operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
|
|
257
|
+
# test can recognise. An operator-only token is therefore re-split into the
|
|
258
|
+
# operators it is made of, longest first. Redirections are kept whole and are
|
|
259
|
+
# deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
|
|
260
|
+
# commit's argv scope.
|
|
261
|
+
OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
|
|
262
|
+
SHELL_OPERATOR = re.compile("|".join(
|
|
263
|
+
re.escape(operator) for operator in sorted(
|
|
264
|
+
COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
|
|
265
|
+
key=lambda operator: (-len(operator), operator),
|
|
266
|
+
)
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def split_operators(token):
|
|
271
|
+
"""Split a glued operator-only token into the shell operators it holds.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
token: One shlex token that was not read inside a quote or escape.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The operators, longest first, or the token itself when it is not
|
|
278
|
+
made only of shell punctuation.
|
|
279
|
+
"""
|
|
280
|
+
if OPERATOR_ONLY.match(token):
|
|
281
|
+
return SHELL_OPERATOR.findall(token)
|
|
282
|
+
return [token]
|
|
248
283
|
|
|
249
284
|
# Shell wrappers whose `-c` argument is a command in a string rather than argv.
|
|
250
285
|
# The outer argv names bash, so the arming is invisible without recursing once.
|
|
@@ -422,12 +457,40 @@ def shell_tokens(text):
|
|
|
422
457
|
Returns:
|
|
423
458
|
The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
|
|
424
459
|
"""
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
)
|
|
460
|
+
stream = OperatorStream(line_boundaries_as_separators(text))
|
|
461
|
+
lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
|
|
428
462
|
lexer.whitespace_split = True
|
|
429
463
|
lexer.commenters = ""
|
|
430
|
-
|
|
464
|
+
stream.lexer = lexer
|
|
465
|
+
tokens = []
|
|
466
|
+
while True:
|
|
467
|
+
stream.literal_operator = False
|
|
468
|
+
token = lexer.get_token()
|
|
469
|
+
if token is None:
|
|
470
|
+
return tokens
|
|
471
|
+
# Quoted punctuation is an argument, never a boundary: a branch can be
|
|
472
|
+
# named `|&`, and `gh pr merge '|&' --auto` must keep `--auto` in the
|
|
473
|
+
# merge's own argv. Only real operator runs are split.
|
|
474
|
+
if stream.literal_operator:
|
|
475
|
+
tokens.append(
|
|
476
|
+
chr(0) + token if token in COMMAND_SEPARATORS else token
|
|
477
|
+
)
|
|
478
|
+
else:
|
|
479
|
+
tokens.extend(split_operators(token))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class OperatorStream(io.StringIO):
|
|
483
|
+
"""Remember punctuation read while shlex is inside a quote or escape."""
|
|
484
|
+
|
|
485
|
+
lexer = None
|
|
486
|
+
literal_operator = False
|
|
487
|
+
|
|
488
|
+
def read(self, size=-1):
|
|
489
|
+
value = super().read(size)
|
|
490
|
+
if (self.lexer is not None and self.lexer.state in {"'", '"', chr(92)}
|
|
491
|
+
and any(char in "();|&<>" for char in value)):
|
|
492
|
+
self.literal_operator = True
|
|
493
|
+
return value
|
|
431
494
|
|
|
432
495
|
|
|
433
496
|
def is_gh(token):
|
|
@@ -493,6 +556,22 @@ def arms_auto_merge(argv):
|
|
|
493
556
|
return False
|
|
494
557
|
|
|
495
558
|
|
|
559
|
+
def literal_value(token):
|
|
560
|
+
"""The argument a token stands for, without the quoted-separator marker.
|
|
561
|
+
|
|
562
|
+
`shell_tokens` prefixes a QUOTED separator with NUL so no scan stops at it.
|
|
563
|
+
That marker is for boundary checks only: a value handed to a gh probe
|
|
564
|
+
(a selector, a base ref, a repo) must be the argument the shell passes.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
token: One token from `shell_tokens`.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
The token as the shell would pass it.
|
|
571
|
+
"""
|
|
572
|
+
return token[1:] if token.startswith(chr(0)) else token
|
|
573
|
+
|
|
574
|
+
|
|
496
575
|
def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
|
|
497
576
|
"""The PR the command names, as gh itself would read it.
|
|
498
577
|
|
|
@@ -518,7 +597,7 @@ def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
|
|
|
518
597
|
index += 1
|
|
519
598
|
continue
|
|
520
599
|
if not token.startswith("-"):
|
|
521
|
-
return token
|
|
600
|
+
return literal_value(token)
|
|
522
601
|
return None
|
|
523
602
|
|
|
524
603
|
|
|
@@ -538,7 +617,7 @@ def retarget_target(argv):
|
|
|
538
617
|
if token in COMMAND_SEPARATORS:
|
|
539
618
|
return None
|
|
540
619
|
if token in BASE_FLAGS and index < len(argv):
|
|
541
|
-
return argv[index]
|
|
620
|
+
return literal_value(argv[index])
|
|
542
621
|
if token.startswith("--base="):
|
|
543
622
|
return token.split("=", 1)[1]
|
|
544
623
|
if token in EDIT_SEPARATE_VALUE:
|
|
@@ -562,7 +641,7 @@ def repo_flag(tokens, start):
|
|
|
562
641
|
if token in COMMAND_SEPARATORS:
|
|
563
642
|
break
|
|
564
643
|
if token in {"-R", "--repo"} and index + 1 < len(tokens):
|
|
565
|
-
return ["--repo", tokens[index + 1]]
|
|
644
|
+
return ["--repo", literal_value(tokens[index + 1])]
|
|
566
645
|
if token.startswith("--repo="):
|
|
567
646
|
return ["--repo", token.split("=", 1)[1]]
|
|
568
647
|
index += 1
|
|
@@ -597,7 +597,13 @@ ENV_SPLIT_UNRESOLVED = 42
|
|
|
597
597
|
BACKSLASH = chr(92)
|
|
598
598
|
NEWLINE = chr(10)
|
|
599
599
|
|
|
600
|
-
|
|
600
|
+
# `|&` pipes stdout and stderr into the next command, and the case terminators
|
|
601
|
+
# end a statement as `;` does. Without them `printf x |& tee <managed>` stayed
|
|
602
|
+
# one statement whose command word was `printf`, so the `tee` write target was
|
|
603
|
+
# never examined.
|
|
604
|
+
STATEMENT_SEPARATORS = {
|
|
605
|
+
";", "|", "||", "&", "&&", "(", ")", "&|", "|&", ";;", ";&", ";;&",
|
|
606
|
+
}
|
|
601
607
|
# Ordinary output, noclobber override, and combined stdout/stderr output.
|
|
602
608
|
# shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
|
|
603
609
|
REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
|