Skip to content

Commit da4154d

Browse files
committed
67f932
1 parent c7a30b2 commit da4154d

6 files changed

Lines changed: 285 additions & 14 deletions

File tree

MANUAL.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1961,7 +1961,7 @@ phase "install" when "action == 'install'":
19611961

19621962
### Multiline `run:` and `skip if:` blocks
19631963

1964-
Long shell one-liners can be split into indented blocks. Lines are joined with `&&` if any line exits non-zero, the remaining lines do not run (fail-fast).
1964+
Long shell one-liners can be split into indented blocks. Lines are run as a POSIX shell script with `set -e`, so if a simple command exits non-zero, the remaining lines do not run (fail-fast). For simple command lists, CommandGraph also reports the failing command when the shell exits without stderr.
19651965

19661966
**`run:` block:**
19671967
```
@@ -1971,7 +1971,14 @@ Long shell one-liners can be split into indented blocks. Lines are joined with `
19711971
chown app:app /opt/app
19721972
chmod 750 /opt/app/logs
19731973
```
1974-
Equivalent to: `run $ mkdir -p /opt/app/logs && chown app:app /opt/app && chmod 750 /opt/app/logs`
1974+
Equivalent to:
1975+
```
1976+
run:
1977+
set -e
1978+
mkdir -p /opt/app/logs
1979+
chown app:app /opt/app
1980+
chmod 750 /opt/app/logs
1981+
```
19751982

19761983
To continue past a failure on one line, append `|| true`:
19771984
```

cgr.py

Lines changed: 105 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cgr_src/commands.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,43 @@
1010
from cgr_src.executor import *
1111
from cgr_src.state import *
1212

13+
def _load(filepath, repo_dir=None, extra_vars=None, raise_on_error=False, inventory_files=None,
14+
vault_passphrase=None, vault_prompt=False, resolve_deferred_secrets=False):
15+
path=Path(filepath)
16+
if not path.exists():
17+
if raise_on_error: raise ValueError(f"not found: {path}")
18+
print(red(f"error: not found: {path}"),file=sys.stderr); sys.exit(1)
19+
source=path.read_text()
20+
21+
# Detect format by extension
22+
if path.suffix == ".cgr":
23+
try: ast=parse_cgr(source, str(path))
24+
except CGRParseError as e:
25+
if raise_on_error: raise ValueError(e.msg) from e
26+
print(e.pretty(),file=sys.stderr); sys.exit(1)
27+
else:
28+
try: tokens=lex(source,str(path))
29+
except LexError as e:
30+
if raise_on_error: raise ValueError(e.msg) from e
31+
print(red(f"Lex error: {e.msg}"),file=sys.stderr)
32+
if e.src: print(dim(f" {e.line:>4} │ ")+e.src,file=sys.stderr)
33+
sys.exit(1)
34+
try: ast=Parser(tokens,source,str(path)).parse()
35+
except ParseError as e:
36+
if raise_on_error: raise ValueError(e.msg) from e
37+
print(e.pretty(),file=sys.stderr); sys.exit(1)
38+
39+
try:
40+
graph = resolve(ast, repo_dir=repo_dir, graph_file=str(path), extra_vars=extra_vars,
41+
inventory_files=inventory_files, vault_passphrase=vault_passphrase,
42+
vault_prompt=vault_prompt,
43+
resolve_deferred_secrets=resolve_deferred_secrets)
44+
_validate_script_paths(graph)
45+
return graph
46+
except ResolveError as e:
47+
if raise_on_error: raise ValueError(str(e)) from e
48+
print(red(f"error: {e}"),file=sys.stderr); sys.exit(1)
49+
1350
# ── Report command ─────────────────────────────────────────────────────
1451

1552
def cmd_report(graph_file: str, *, fmt: str = "table", output_file: str|None = None,

cgr_src/executor.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,32 @@ def _effective_run_cmd(res: Resource, graph_file: str|None = None) -> str:
594594
return ""
595595

596596

597+
def _display_run_cmd(cmd: str) -> str:
598+
"""Hide CommandGraph's multiline-run instrumentation in user-facing previews."""
599+
lines = cmd.splitlines()
600+
if len(lines) < 4:
601+
return cmd
602+
if not (
603+
lines[0].startswith("__cgr_cmd=")
604+
and lines[1].startswith("__cgr_line=")
605+
and lines[2].startswith("trap ")
606+
and "CommandGraph: run block failed at command" in lines[2]
607+
):
608+
return cmd
609+
visible = []
610+
for line in lines:
611+
if line.startswith("__cgr_cmd="):
612+
continue
613+
if re.match(r"^__cgr_line=[0-9]+$", line):
614+
continue
615+
if re.match(r"^__cgr_line=[0-9]+; __cgr_cmd=", line):
616+
continue
617+
if line.startswith("trap ") and "CommandGraph: run block failed at command" in line:
618+
continue
619+
visible.append(line)
620+
return "\n".join(visible)
621+
622+
597623
def _normalize_webhook_path(path: str) -> str:
598624
if not path:
599625
return "/"
@@ -1858,7 +1884,7 @@ def _print_exec(idx,total,res,r,graph,verbose=False,indent="",inline=False):
18581884
rc_str = str(r.check_rc) if r.check_rc is not None else "≠0"
18591885
print(f"{pad}{dim('check:')} {dim(via + sudo_pfx)}{dim(check_trunc)} {dim(f'→ {rc_str}')}")
18601886
if r.status not in (Status.SKIP_CHECK, Status.SKIP_WHEN, Status.CANCELLED):
1861-
display_cmd = _effective_run_cmd(res, graph.graph_file)
1887+
display_cmd = _display_run_cmd(_effective_run_cmd(res, graph.graph_file))
18621888
run_label = "script:" if res.script_path and not res.run else "run: "
18631889
run_trunc = _redact(display_cmd, _s)[:80] + ("…" if len(display_cmd) > 80 else "")
18641890
rc_color = green if r.run_rc == 0 else red

cgr_src/parser_cgr.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,43 @@ def _is_cgr_keyword(s: str) -> bool:
627627
return False
628628

629629

630+
_CGR_SHELL_CONTROL_WORD_RE = re.compile(
631+
r"^\s*(if|then|else|elif\b.*|fi|for\b.*|while\b.*|until\b.*|do|done|case\b.*|esac|select\b.*|\{|\})\s*(?:#.*)?$"
632+
)
633+
634+
635+
def _cgr_command_summary(command: str) -> str:
636+
summary = " ".join(command.split())
637+
if len(summary) > 240:
638+
summary = summary[:237] + "..."
639+
return summary or "(empty command)"
640+
641+
642+
def _can_instrument_cgr_command_block(commands: list[str]) -> bool:
643+
"""Only instrument simple command lists; avoid changing shell compound syntax."""
644+
for command in commands:
645+
if "<<" in command:
646+
return False
647+
for line in command.splitlines():
648+
if _CGR_SHELL_CONTROL_WORD_RE.match(line):
649+
return False
650+
return True
651+
652+
653+
def _instrument_cgr_command_block(commands: list[str]) -> str:
654+
preamble = [
655+
"__cgr_cmd='before first command'",
656+
"__cgr_line=0",
657+
"trap '__cgr_rc=$?; if [ \"$__cgr_rc\" -ne 0 ]; then printf \"%s\\n\" \"CommandGraph: run block failed at command $__cgr_line: $__cgr_cmd\" >&2; fi' EXIT",
658+
"set -e",
659+
]
660+
body: list[str] = []
661+
for idx, command in enumerate(commands, 1):
662+
body.append(f"__cgr_line={idx}; __cgr_cmd={shlex.quote(_cgr_command_summary(command))}")
663+
body.append(command)
664+
return "\n".join(preamble + body)
665+
666+
630667
def _join_cgr_command_block(lines: list[str]) -> str:
631668
"""Join run: block lines into a fail-fast script, preserving shell continuations."""
632669
commands: list[str] = []
@@ -639,7 +676,9 @@ def _join_cgr_command_block(lines: list[str]) -> str:
639676
current = []
640677
if current:
641678
commands.append("\n".join(current))
642-
return "set -e; set -o pipefail\n" + "\n".join(commands)
679+
if _can_instrument_cgr_command_block(commands):
680+
return _instrument_cgr_command_block(commands)
681+
return "set -e\n" + "\n".join(commands)
643682

644683

645684
def _parse_cgr_step_line(text: str):

0 commit comments

Comments
 (0)