diff --git a/README.md b/README.md index cff5af7..2940a51 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ nvimx is a home-manager module. You integrate it into your dotfiles to use it. nix run github:myuron/nvimx#lock -- --config ./nvim --out ./nvim/nvimx-lock ``` + Point `--out` at a directory of its own, as here. `nvimx-lock` writes `flake.nix` and + `flake.lock` into it (so `--out .` at your repository root would overwrite your own flake), and + every lock copies that whole directory into the Nix store: anything else in it (a `result` + symlink, `.direnv`, big files) is copied along each time, and an entry Nix cannot copy, such as + a FIFO or an unreadable directory, fails the lock. + 3. Commit the generated lock directory and switch home-manager. ```bash @@ -102,6 +108,11 @@ nvimx is a home-manager module. You integrate it into your dotfiles to use it. locked. `nvimx-lock` prints a note when the lock it just wrote is not tracked yet. Locking itself does not need the directory to be tracked. + A gitignored lock directory needs `git add -f` (the note prints it that way). A lock directory + inside a git submodule is tracked there, but your flake still cannot see it unless it sets + `inputs.self.submodules = true;` (or is referenced with `?submodules=1`); `nvimx-lock` does not + detect that case. + The order is not actually rigid. Switching without a lock does not fail: the build *degrades* to a lazy.nvim-only Neovim and warns you, while still installing the `nvimx-lock` command. Running `nvimx-lock` → commit → switching again gets you to the full state either way. diff --git a/nix/home-manager/default.nix b/nix/home-manager/default.nix index 84b946f..65178c8 100644 --- a/nix/home-manager/default.nix +++ b/nix/home-manager/default.nix @@ -499,6 +499,9 @@ in programs.nvimx: lock files (plugins.json / flake.lock) not found in lockDir. Building in degraded mode (lazy.nvim seed only). Run `nvimx-lock`, commit the lock files, then re-run home-manager switch. + If you already did, your flake cannot see them: a flake only sees files git tracks + (`git add` them, with -f if lockDir is gitignored), and a lockDir inside a git + submodule also needs `inputs.self.submodules = true;` in your flake. '' ++ lib.optional ((cfg.env.unknownPluginNames or [ ]) != [ ]) '' programs.nvimx: plugins.overrides / plugins.nixpkgsFallback name plugins that are diff --git a/nix/lib/lock-app.nix b/nix/lib/lock-app.nix index f24bc3d..28e94a6 100644 --- a/nix/lib/lock-app.nix +++ b/nix/lib/lock-app.nix @@ -172,9 +172,11 @@ pkgs.writeShellApplication { # import.log exists for one reason only: pass 2 below (the convergence resolve) redirects its # own stderr with `2>` to the exact same resolve.log path pass 1 used, and `2>` truncates on # open -- so by the time either pass's log would be printed, pass 1's `[nvimx] import: ...` - # report (#25) is already gone unless it was copied out first. Pass 2 never produces any + # report (#25) is already gone unless it was moved out first. Pass 2 never produces any # import lines itself (it deliberately never gets --import-lazy-lock -- see the comment on - # that pass below), so import.log is populated once, right after pass 1, and is simply + # that pass below), so import.log is populated once, right after pass 1 (the lines are moved + # out of resolve.log, so a failure before pass 2 prints them once, #81, and the output shape -- + # import report, then the rest -- is the same on success and failure), and is simply # ignored on every run that was not given --import-lazy-lock in the first place (grep finds # nothing, so the file stays empty and flush_logs' `-s` check skips it). resolve_log_shown=0 @@ -193,10 +195,23 @@ pkgs.writeShellApplication { # # cleanup() runs on every exit after this point. `exit_code=$?` has to be its very first # statement: any command run before it (flush_logs included) replaces $?. The failure line is - # an `if`, not `[ ... ] && echo`: the && form makes cleanup() return 1 when the test is false, - # and errexit is still in force inside the trap, so that aborts it with status 1 and turns a - # successful lock into exit 1. It does not `exit`, - # so the original exit code stays the process's exit code. The line comes last on purpose + # an `if`, not `[ ... ] && echo` (harmless now that errexit is off in here, but the && form is + # still a trap for the next edit). It does not `exit`, so the original exit code stays the + # process's exit code. `set +e` and ignoring SIGPIPE are what make that true when stderr is a + # pipe whose reader has already gone (#81, `nvimx-lock 2>&1 | head`): errexit is in force + # inside the trap, so the first failed write (cat in flush_logs dying of SIGPIPE, rc 141) + # aborted it before `rm -rf` -- leaking the sandbox and replacing the exit code -- and with + # errexit off alone the final builtin echo would still kill bash with the signal. With both, + # a write that fails is skipped and the trap's last status is ignored, so the original code + # stays (verified for 0, 7 and 130). A stderr that is closed before or while the body runs + # kills or fails the body itself and is out of scope. + # Before flush_logs, a header says that what follows is the held-back resolve report and that + # the error is above it (#81: nix's output streams live and cannot be moved below the report + # without capturing it, which would cost nix its progress line and colours). It is skipped when + # the logs were already printed (resolve's own failure path calls flush_logs in the body, and + # there the log is the error), when nothing is held back (an extract failure), and for signal + # exits, where no error was printed at all. + # The line comes last on purpose # (#71): nix's own error used to sit above flush_logs' output, buried under the import report # and warnings, and the last line of a failed run looked just like a successful one. The # sandbox is removed before it so that a failing echo cannot leave it behind. Argument errors @@ -208,6 +223,12 @@ pkgs.writeShellApplication { # the signal the trap sees $? = 0 and prints neither line. cleanup() { exit_code=$? + set +e + trap "" PIPE + if [ "$exit_code" -ne 0 ] && [ "$exit_code" -le 128 ] && [ "$resolve_log_shown" -eq 0 ] \ + && { [ -s "$sandbox/import.log" ] || [ -s "$sandbox/resolve.log" ]; }; then + echo "nvimx-lock: the lock failed; the resolve report held back for the end follows -- the error is above it" >&2 + fi flush_logs rm -rf "$sandbox" if [ "$exit_code" -gt 128 ]; then @@ -300,14 +321,18 @@ pkgs.writeShellApplication { flush_logs exit "$rc" fi - # Lift the import report out of resolve.log now, before the convergence pass below reopens + # Move the import report out of resolve.log now, before the convergence pass below reopens # that same path with `2>` and truncates it. Only this pass can have produced import lines in # the first place (the convergence pass never gets --import-lazy-lock -- see its own comment - # below for why), so this is also the only place that needs to do the lifting. `grep` exits 1 - # when nothing matches (e.g. any run without --import-lazy-lock), which `set -e` would + # below for why), so this is also the only place that needs to do the moving. Moved, not + # copied (#81): a copy left the lines in resolve.log too, and a failing first `nix` printed + # them twice. `grep` exits 1 when nothing matches (e.g. a report with only import lines left + # nothing for the second grep, or any run without --import-lazy-lock), which `set -e` would # otherwise treat as a script failure, hence `|| true`. if [ "$import_mode" -eq 1 ]; then grep '^\[nvimx\] import: ' "$sandbox/resolve.log" > "$sandbox/import.log" || true + grep -v '^\[nvimx\] import: ' "$sandbox/resolve.log" > "$sandbox/resolve.rest" || true + mv "$sandbox/resolve.rest" "$sandbox/resolve.log" fi echo "nvimx-lock: generating lock flake" >&2 @@ -407,12 +432,15 @@ pkgs.writeShellApplication { flush_logs # update-summary.lua's own failure is deliberately left fatal (not downgraded to a warning, - # #24): every lock file above is already written by this point, so a non-zero exit here is a - # bug in the summary itself, not a failed lock -- but letting it fail loudly is how that bug - # gets noticed instead of silently going unreported. Re-running nvimx-lock afterwards is safe - # either way: the lock is already done, and a second --update run just reports "no plugins + # #24), but only after `done` and the git note below (#81): every lock file above is already + # written by this point, so a non-zero exit here is a bug in the summary itself, not a failed + # lock -- yet the user must still be told to commit the files (and that they are untracked). + # Letting it fail loudly is how that bug gets noticed instead of silently going unreported; + # the trap's failure line then comes last. Re-running nvimx-lock afterwards is safe either + # way: the lock is already done, and a second --update run just reports "no plugins # updated". The same holds for a plain lock that was given --summary-json (#77): it only runs # then, in --plain mode (JSON only, no text). `if`, not `[ ] && ...`: see cleanup(). + summary_rc=0 if [ "$update_mode" -eq 1 ] || [ -n "$summary_json" ]; then summary_args=() if [ -n "$summary_json" ]; then @@ -425,7 +453,7 @@ pkgs.writeShellApplication { ''${summary_args[@]+"''${summary_args[@]}"} \ "$sandbox/plugins.json.before" "$out/plugins.json" \ "$sandbox/flake.lock.before" "$out/flake.lock" \ - ''${update_names[@]+"''${update_names[@]}"} + ''${update_names[@]+"''${update_names[@]}"} || summary_rc=$? fi # Config files git does not know about (#33). Extraction above read the work tree, so an @@ -487,14 +515,26 @@ pkgs.writeShellApplication { # other two), so an untracked flake.nix alone does not degrade anything: it gets a note of its # own that only asks for the three files to be committed together, instead of a false alarm. if [ "$(git -C "$out" rev-parse --is-inside-work-tree 2> /dev/null)" = "true" ]; then + # A gitignored lock dir makes a plain `git add` refuse ("paths are ignored"), so the printed + # command needs -f when any of the files is ignored (#81). check-ignore reports only untracked + # ignored paths (tracked ones never match): exit 0 = at least one ignored, 1 = none, 128 = + # error (treated as none). No -q: git rejects it with more than one path. Read-only, like the + # rest of the probe. + add_cmd="add" + if git -C "$out" check-ignore -- plugins.json flake.nix flake.lock > /dev/null 2>&1; then + add_cmd="add -f" + fi if ! git -C "$out" ls-files --error-unmatch -- plugins.json flake.lock > /dev/null 2>&1; then echo "nvimx-lock: note: this lock is not fully tracked by git. A flake only sees git-tracked files, so until you run" >&2 - echo "nvimx-lock: note: git -C \"$out\" add plugins.json flake.nix flake.lock" >&2 + echo "nvimx-lock: note: git -C \"$out\" $add_cmd plugins.json flake.nix flake.lock" >&2 echo "nvimx-lock: note: home-manager switch cannot see it and builds the degraded (lazy.nvim-only) neovim instead" >&2 elif ! git -C "$out" ls-files --error-unmatch -- flake.nix > /dev/null 2>&1; then echo "nvimx-lock: note: flake.nix is not tracked by git. The build does not read it, but it belongs with plugins.json and flake.lock:" >&2 - echo "nvimx-lock: note: git -C \"$out\" add flake.nix" >&2 + echo "nvimx-lock: note: git -C \"$out\" $add_cmd flake.nix" >&2 fi fi + if [ "$summary_rc" -ne 0 ]; then + exit "$summary_rc" + fi ''; } diff --git a/tests/checks/lock-app.nix b/tests/checks/lock-app.nix index d291c81..f4b16b8 100644 --- a/tests/checks/lock-app.nix +++ b/tests/checks/lock-app.nix @@ -1,8 +1,11 @@ # The lock app itself, end to end (#71): what nix/lib/lock-app.nix hands its `nix` calls, what # the EXIT trap prints, and everything after them -- no other check gets past those calls, # since recursive nix cannot run in a build. `nix` comes from the caller's PATH, so a stub -# stands in: it answers the seed lookup (`eval`, #32) with nvimx's own lazy.nvim, records argv and -# cwd, fails on NVIMX_STUB_FAIL, and otherwise writes a fixture +# stands in: it answers the seed lookup (`eval`, #32) with nvimx's own lazy.nvim (not recorded +# in argv, so it is not counted as a call), records argv and cwd, fails on the NVIMX_STUB_FAIL_AT-th +# call (default 1) when NVIMX_STUB_FAIL is set (#81: the later call sites need a failure of their +# own), can corrupt the update snapshot (NVIMX_STUB_CORRUPT_BEFORE) or wait for a pipe reader to +# be gone before failing (NVIMX_STUB_WAIT_FOR), and otherwise writes a fixture # flake.lock (the convergence pass reads it right after). That the real nix accepts `path:.` # stays a manual check. { @@ -19,8 +22,24 @@ let fi printf '%s\n' "$*" >> "$NVIMX_STUB_ARGV" pwd >> "$NVIMX_STUB_CWD" - if [ -n "''${NVIMX_STUB_FAIL:-}" ]; then + # Test-only hook for step 7's summary failure: nothing in nvimx-lock reads the snapshot + # but update-summary.lua, so corrupting it fails exactly that step. + if [ -n "''${NVIMX_STUB_CORRUPT_BEFORE:-}" ]; then + for f in "$TMPDIR"/tmp.*/flake.lock.before; do + if [ -e "$f" ]; then echo 'not json' > "$f"; fi + done + fi + calls=$(wc -l < "$NVIMX_STUB_ARGV") + if [ -n "''${NVIMX_STUB_FAIL:-}" ] && [ "$calls" -eq "''${NVIMX_STUB_FAIL_AT:-1}" ]; then echo "error: stub nix was told to fail" >&2 + # Step 4f: hold the exit until the reader has closed the pipe, so the trap is + # guaranteed to be the first writer that hits it. + if [ -n "''${NVIMX_STUB_WAIT_FOR:-}" ]; then + for _ in $(seq 100); do + if [ -e "$NVIMX_STUB_WAIT_FOR" ]; then break; fi + sleep 0.1 + done + fi exit "$NVIMX_STUB_FAIL" fi cp "$NVIMX_STUB_LOCK" flake.lock diff --git a/tests/checks/scripts/lock-app.sh b/tests/checks/scripts/lock-app.sh index f9b816a..4dfe099 100644 --- a/tests/checks/scripts/lock-app.sh +++ b/tests/checks/scripts/lock-app.sh @@ -37,6 +37,10 @@ fail_line() { # echo "nvimx-lock: failed (exit $1) -- look for the error above; it may sit above the import report and warnings" } +# What the trap prints before the held-back resolve report on a failure (#81): the +# error is above it. +held_line='nvimx-lock: the lock failed; the resolve report held back for the end follows -- the error is above it' + # 1. First lock: one `nix flake lock`, with an explicit path: ref, run from inside # the lock dir. The generated files equal the committed fixture lock, which is what # makes the stub's flake.lock a faithful stand-in for the rest of this check. @@ -91,16 +95,18 @@ NVIMX_STUB_FAIL=7 nvimx-lock --config pincfg --out failed 2>failed.log || rc=$? no_leftover err_at=$(grep -n '^error: stub nix was told to fail$' failed.log | cut -d: -f1) warn_at=$(grep -n '^\[nvimx\] warning: plugin "tokyonight.nvim"' failed.log | cut -d: -f1) -[ "$err_at" -lt "$warn_at" ] +[ "$(grep -cxF "$held_line" failed.log)" -eq 1 ] +held_at=$(grep -nxF "$held_line" failed.log | cut -d: -f1) +[ "$err_at" -lt "$held_at" ] +[ "$held_at" -lt "$warn_at" ] [ "$(grep -c '^nvimx-lock: failed' failed.log)" -eq 1 ] absent '^nvimx-lock: done' failed.log # 4b. The same failure on the path #71 was actually reported on: --import-lazy-lock. # The import report is held back like the warnings are, so it lands after nix's -# error too, and the failure line still has to come last. Deliberately indifferent -# to how many times the report is printed: today a failing first `nix` prints it -# twice (pass 2 has not truncated resolve.log yet, so the lines sit in both -# import.log and resolve.log) -- a known wart, and fixing it must not break this. +# error too, and the failure line still has to come last. The report is printed +# exactly once (#81: it used to come out twice, because pass 2 had not truncated +# resolve.log yet and the lines sat in both it and import.log). cat >lazy-lock.json <<'JSON' { "tokyonight.nvim": { "branch": "main", "commit": "1234123412341234123412341234123412341234" } } JSON @@ -111,10 +117,14 @@ NVIMX_STUB_FAIL=7 nvimx-lock --config pincfg --out failed-import \ [ "$rc" -eq 7 ] [ "$(tail -n 1 failed-import.log)" = "$(fail_line 7)" ] [ "$(grep -c '^nvimx-lock: failed' failed-import.log)" -eq 1 ] +[ "$(grep -c '^\[nvimx\] import: ' failed-import.log)" -eq 2 ] err_at=$(grep -n '^error: stub nix was told to fail$' failed-import.log | cut -d: -f1) import_first=$(grep -n '^\[nvimx\] import: ' failed-import.log | head -n 1 | cut -d: -f1) import_last=$(grep -n '^\[nvimx\] import: ' failed-import.log | tail -n 1 | cut -d: -f1) -[ "$err_at" -lt "$import_first" ] +[ "$(grep -cxF "$held_line" failed-import.log)" -eq 1 ] +held_at=$(grep -nxF "$held_line" failed-import.log | cut -d: -f1) +[ "$err_at" -lt "$held_at" ] +[ "$held_at" -lt "$import_first" ] [ "$import_last" -lt "$(wc -l signalled.log || rc=$? [ "$rc" -eq 130 ] [ "$(tail -n 1 signalled.log)" = 'nvimx-lock: stopped by a signal (exit 130)' ] +# A killed child printed no error, so claiming one is above would be false. +absent "$held_line" signalled.log absent '^nvimx-lock: failed' signalled.log no_leftover +# 4d. The failure can also come from the other `nix` call sites, which step 4 never +# reaches: the stub only fails at its first call there. Here `nix flake update` (the +# second call under --update) fails; the lock files from `first` are already written, +# so the run is a real half-finished one (#81). +cp -r first upd-fail +reset +rc=0 +NVIMX_STUB_FAIL=7 NVIMX_STUB_FAIL_AT=2 nvimx-lock --config "$fx" --out upd-fail --update 2>upd-fail.log || rc=$? +printf '%s\n' 'flake lock path:.' 'flake update --flake path:. lazy-nvim tokyonight-nvim' >want-argv +diff -u want-argv argv +[ "$rc" -eq 7 ] +[ "$(tail -n 1 upd-fail.log)" = "$(fail_line 7)" ] +absent '^nvimx-lock: done' upd-fail.log +no_leftover + +# 4e. ... and the convergence pass's `nix flake lock` (the second call of a first-time +# pin). Pass 2's resolve.log has replaced pass 1's by then: its warning must be shown +# once, after nix's error, not lost and not doubled (#81). +reset +rc=0 +NVIMX_STUB_FAIL=7 NVIMX_STUB_FAIL_AT=2 nvimx-lock --config pincfg --out conv-fail 2>conv-fail.log || rc=$? +printf '%s\n' 'flake lock path:.' 'flake lock path:.' >want-argv +diff -u want-argv argv +[ "$rc" -eq 7 ] +grep -q '^nvimx-lock: freezing pinned plugins$' conv-fail.log +[ "$(grep -c '^\[nvimx\] warning: plugin "tokyonight.nvim"' conv-fail.log)" -eq 1 ] +err_at=$(grep -n '^error: stub nix was told to fail$' conv-fail.log | cut -d: -f1) +warn_at=$(grep -n '^\[nvimx\] warning: plugin "tokyonight.nvim"' conv-fail.log | cut -d: -f1) +[ "$err_at" -lt "$warn_at" ] +[ "$(grep -cxF "$held_line" conv-fail.log)" -eq 1 ] +[ "$(tail -n 1 conv-fail.log)" = "$(fail_line 7)" ] +no_leftover + +# 4f. The trap must survive a stderr whose reader has gone (`nvimx-lock 2>&1 | head`). +# The reader quits on the stub's error line and closes the pipe before the stub +# returns, so the trap's flush_logs is the first write to hit a closed pipe -- +# deterministic, unlike `| head -n N`. Before #81 this ended rc=141 with the sandbox +# left behind. `exec 0<&-` matters: without it the `sh` keeps the read end open and +# nothing breaks. +# Nix runs builders with SIGPIPE ignored, which would hide the signal half of the bug, +# so it is put back to its default for this one run. +reset +rc=0 +NVIMX_STUB_FAIL=7 NVIMX_STUB_WAIT_FOR=$here/reader-gone \ + env --default-signal=PIPE nvimx-lock --config pincfg --out epipe \ + 2> >(sh -c 'sed "/^error: stub nix was told to fail\$/q" >/dev/null; exec 0<&-; touch "$1"' _ "$here/reader-gone") || + rc=$? +[ "$rc" -eq 7 ] +no_leftover + # 5. A failure before `nix` is ever reached ends the same way (the trap is what # prints the line, not the nix step). mkdir nosetup @@ -138,6 +200,7 @@ rc=0 nvimx-lock --config nosetup --out nosetup-out 2>nosetup.log || rc=$? [ "$rc" -eq 1 ] [ "$(tail -n 1 nosetup.log)" = "$(fail_line 1)" ] +absent "$held_line" nosetup.log # nothing was held back [ ! -s argv ] no_leftover @@ -169,6 +232,24 @@ grep -q '^nvimx-lock: no plugins updated' untracked-update.log grep -qxF "$note_cmd" untracked-update.log [ "$(tail -n 1 untracked-update.log)" = 'nvimx-lock: note: home-manager switch cannot see it and builds the degraded (lazy.nvim-only) neovim instead' ] [ "$(git -C repo status --porcelain)" = "?? nvim/" ] +# update-summary.lua failing is still fatal, but the files are written by then, so +# the commit reminder must survive a summary bug (#81) -- and the failure line still +# comes last. The stub corrupts the snapshot the summary reads. +rc=0 +NVIMX_STUB_CORRUPT_BEFORE=1 nvimx-lock --config repo/nvim --out repo/nvim/nvimx-lock --update \ + 2>summary-fail.log || rc=$? +[ "$rc" -eq 1 ] +grep -q '^\[nvimx\] update-summary failed: ' summary-fail.log +grep -qxF "$note_cmd" summary-fail.log +grep -q '^nvimx-lock: done\. ' summary-fail.log +[ "$(tail -n 1 summary-fail.log)" = "$(fail_line 1)" ] +sum_at=$(grep -n '^\[nvimx\] update-summary failed: ' summary-fail.log | cut -d: -f1) +note_at=$(grep -nxF "$note_cmd" summary-fail.log | cut -d: -f1) +fail_at=$(grep -n '^nvimx-lock: failed' summary-fail.log | cut -d: -f1) +[ "$sum_at" -lt "$note_at" ] +[ "$note_at" -lt "$fail_at" ] +no_leftover +[ "$(git -C repo status --porcelain)" = "?? nvim/" ] # Each of the three files on its own is enough to trigger a note: one state per # file, with only that file missing from the index. A single partial state would # let a check narrowed to one file (whichever) pass unnoticed. Which note differs: @@ -196,6 +277,25 @@ absent 'nvimx-lock: note:' tracked.log [ ! -s tracked.out ] git -C repo status --porcelain | diff -u status.before - +# 7b. A gitignored lock dir: a plain `git add` refuses it, so the note has to say -f +# (#81). Step 7's exact match on the command without -f is what keeps this from being +# always-on. +git init -q -b main ignored +mkdir ignored/nvim +cp "$fx"/init.lua ignored/nvim/ +echo 'nvim/nvimx-lock/' >ignored/.gitignore +ilockdir=$here/ignored/nvim/nvimx-lock +nvimx-lock --config ignored/nvim --out ignored/nvim/nvimx-lock 2>ignored.log +grep -qxF "nvimx-lock: note: git -C \"$ilockdir\" add -f plugins.json flake.nix flake.lock" ignored.log +# The printed command must work as printed, and once it has, the note is gone. +git -C "$ilockdir" add -f plugins.json flake.nix flake.lock +nvimx-lock --config ignored/nvim --out ignored/nvim/nvimx-lock 2>ignored-tracked.log +absent 'nvimx-lock: note:' ignored-tracked.log +# The flake.nix-only note gets the -f too. +git -C ignored rm -q --cached nvim/nvimx-lock/flake.nix +nvimx-lock --config ignored/nvim --out ignored/nvim/nvimx-lock 2>ignored-nix.log +[ "$(tail -n 1 ignored-nix.log)" = "nvimx-lock: note: git -C \"$ilockdir\" add -f flake.nix" ] + # 8. `git rev-parse --is-inside-work-tree` prints "false" and still exits 0 inside # a bare repository, so the note has to compare the output, not the exit code. git init -q --bare bare.git @@ -239,6 +339,7 @@ nvimx-lock --config "$fx" --out sj-bad --summary-json sj-dir 2>sj-bad.log || rc= # 12. A failed run never leaves a previous report behind, and the failure line stays last. echo stale >stale.json +reset # the stub fails on the first call counted since the last reset (#81) rc=0 NVIMX_STUB_FAIL=7 nvimx-lock --config pincfg --out sj-failed --summary-json stale.json 2>sj-failed.log || rc=$? [ "$rc" -eq 7 ]