Add subprocess timeout handling
ober
a791614f0c737822286b2ad34ead2a4de9e9b4e8
--- a/GAPS.md +++ b/GAPS.md @@ -390,6 +390,13 @@ Acceptance criteria: - Report timeout warnings without crashing. - Add fixture/fake-provider timeout tests. +Status: implemented with fixed-argv shell timeout wrappers for Git, local LLM +providers, and local embedding providers. `git_timeout_seconds` and +`provider_timeout_seconds` are config-hashed settings; timeouts return warnings +and fall back to partial/offline data. Smoke fixtures cover a sleeping provider, +a sleeping embedding provider, and a fake `git` wrapper that delays selected +Git commands. + ### G-041: NUL-delimited parsing not implemented The plan requires NUL-delimited output where applicable. Current parsing uses --- a/eval/sample-report.md +++ b/eval/sample-report.md @@ -33,13 +33,13 @@ Cases: 10 | case | label | mode | ambiguous | predicted band | verdict | score | config hash | |---|---|---|---|---|---|---:|---| -| human_basic | human | non_ai | false | likely-human-style | likely-human-style | 0.045000000000000005 | 7842737080095761057 | -| ai_recorded_note | ai_assisted | recorded | false | recorded | recorded-ai-authorship | 1.0 | 7842737080095761057 | -| ai_heuristic_style | ai_assisted | heuristic | false | likely-ai-assisted | likely-ai-assisted | 1.0 | 7842737080095761057 | -| metadata_attested_author | metadata_attested | metadata | false | metadata | metadata-indicated-agent | 0.0 | 7842737080095761057 | -| bot_dependency_update | bot | non_ai | false | insufficient-evidence | insufficient-evidence | 0.0 | 7842737080095761057 | -| generated_vendor | generated_vendor | non_ai | false | insufficient-evidence | insufficient-evidence | 0.0 | 7842737080095761057 | -| formatter_only | formatter_only | non_ai | false | likely-human-style | likely-human-style | 0.0 | 7842737080095761057 | -| mechanical_refactor | refactor | non_ai | false | likely-human-style | likely-human-style | 0.1225 | 7842737080095761057 | -| merge_commit | merge | non_ai | false | likely-human-style | likely-human-style | 0.045000000000000005 | 7842737080095761057 | -| ambiguous_assistance | ambiguous | mixed | true | likely-human-style | likely-human-style | 0.0 | 7842737080095761057 | +| human_basic | human | non_ai | false | likely-human-style | likely-human-style | 0.045000000000000005 | 17715209077406632572 | +| ai_recorded_note | ai_assisted | recorded | false | recorded | recorded-ai-authorship | 1.0 | 17715209077406632572 | +| ai_heuristic_style | ai_assisted | heuristic | false | likely-ai-assisted | likely-ai-assisted | 1.0 | 17715209077406632572 | +| metadata_attested_author | metadata_attested | metadata | false | metadata | metadata-indicated-agent | 0.0 | 17715209077406632572 | +| bot_dependency_update | bot | non_ai | false | insufficient-evidence | insufficient-evidence | 0.0 | 17715209077406632572 | +| generated_vendor | generated_vendor | non_ai | false | insufficient-evidence | insufficient-evidence | 0.0 | 17715209077406632572 | +| formatter_only | formatter_only | non_ai | false | likely-human-style | likely-human-style | 0.0 | 17715209077406632572 | +| mechanical_refactor | refactor | non_ai | false | likely-human-style | likely-human-style | 0.1225 | 17715209077406632572 | +| merge_commit | merge | non_ai | false | likely-human-style | likely-human-style | 0.045000000000000005 | 17715209077406632572 | +| ambiguous_assistance | ambiguous | mixed | true | likely-human-style | likely-human-style | 0.0 | 17715209077406632572 | --- a/main-binary.ss +++ b/main-binary.ss @@ -23,7 +23,7 @@ (defstruct options (command path count format commit from to file min-lines metadata-only? heuristics-only?)) (defstruct scan-config - (hash human-threshold ai-threshold text-weight code-weight structure-weight similarity-weight history-weight baseline-weight max-files max-added-lines max-note-bytes)) + (hash human-threshold ai-threshold text-weight code-weight structure-weight similarity-weight history-weight baseline-weight max-files max-added-lines max-note-bytes git-timeout-seconds provider-timeout-seconds)) (defstruct signal (name category score weight confidence reason evidence limitations)) (defstruct finding (commit parent author-name author-email time subject files additions deletions @@ -168,16 +168,39 @@ (def (count-args count) (if (> count 0) (list (str "--max-count=" count)) '())) +(def subprocess-timeout-marker "JERBOA-AIGIT-TIMEOUT") +(def current-git-timeout? #f) + +(def (reset-subprocess-timeouts!) + (set! current-git-timeout? #f)) + +(def (timeout-output? out) + (and (string? out) (if (string-contains out subprocess-timeout-marker) #t #f))) + +(def timeout-wrapper-script + "timeout=\"$1\"\nshift\nout=\"${TMPDIR:-/tmp}/jerboa-aigit-out-$$\"\nerr=\"${TMPDIR:-/tmp}/jerboa-aigit-err-$$\"\nflag=\"${TMPDIR:-/tmp}/jerboa-aigit-timeout-$$\"\nrm -f \"$out\" \"$err\" \"$flag\"\ntrap 'rm -f \"$out\" \"$err\" \"$flag\"' EXIT\n\"$@\" >\"$out\" 2>\"$err\" &\nchild=$!\n( sleep \"$timeout\"; if kill -0 \"$child\" 2>/dev/null; then printf 'JERBOA-AIGIT-TIMEOUT\\n' >\"$flag\"; kill -TERM \"$child\" 2>/dev/null || true; fi ) >/dev/null 2>/dev/null &\nwatcher=$!\nwait \"$child\"\nstatus=$?\nkill \"$watcher\" 2>/dev/null || true\nif [ -s \"$flag\" ]; then cat \"$flag\"; exit 0; fi\ncat \"$out\"\ncat \"$err\" >&2\nexit \"$status\"\n") + +(def (timeout-command args seconds) + (append (list "sh" "-c" timeout-wrapper-script "jerboa-aigit-timeout" (str (max 1 seconds))) args)) + +(def (timed-run-process args seconds) + (run-process (timeout-command args seconds))) (def (git repo args) (try - (run-process (append (list "git" "-C" repo "-c" "core.quotepath=false" "--literal-pathspecs") args)) + (let ([out (timed-run-process (append (list "git" "-C" repo "-c" "core.quotepath=false" "--literal-pathspecs") args) + (git-timeout-seconds))]) + (if (timeout-output? out) + (begin (set! current-git-timeout? #t) "") + out)) (catch (e) ""))) (def (git-ok? repo args) (try - (begin - (run-process (append (list "git" "-C" repo "-c" "core.quotepath=false" "--literal-pathspecs") args)) - #t) + (let ([out (timed-run-process (append (list "git" "-C" repo "-c" "core.quotepath=false" "--literal-pathspecs") args) + (git-timeout-seconds))]) + (if (timeout-output? out) + (begin (set! current-git-timeout? #t) #f) + #t)) (catch (e) #f))) (def (commit-available? repo rev) @@ -881,7 +904,7 @@ (str (stable-hash64 s))) (def (default-scan-config) - (make-scan-config "default" 0.20 0.50 0.10 0.35 0.15 0.15 0.15 0.00 500 20000 50000)) + (make-scan-config "default" 0.20 0.50 0.10 0.35 0.15 0.15 0.15 0.00 500 20000 50000 15 30)) (def (config-fingerprint cfg) (hash-string (string-join @@ -895,7 +918,9 @@ (str (scan-config-baseline-weight cfg)) (str (scan-config-max-files cfg)) (str (scan-config-max-added-lines cfg)) - (str (scan-config-max-note-bytes cfg))) + (str (scan-config-max-note-bytes cfg)) + (str (scan-config-git-timeout-seconds cfg)) + (str (scan-config-provider-timeout-seconds cfg))) ":"))) (def (finalize-config cfg) @@ -910,9 +935,13 @@ (scan-config-baseline-weight cfg) (scan-config-max-files cfg) (scan-config-max-added-lines cfg) - (scan-config-max-note-bytes cfg))) + (scan-config-max-note-bytes cfg) + (scan-config-git-timeout-seconds cfg) + (scan-config-provider-timeout-seconds cfg))) (def current-config (finalize-config (default-scan-config))) +(def (git-timeout-seconds) + (max 1 (scan-config-git-timeout-seconds current-config))) (def current-analysis-provider "offline-heuristics") (def current-requested-provider "none") (def current-llm-used? #f) @@ -989,6 +1018,14 @@ (def (provider-reason obj) (let ([reason (hash-get/default obj "reason" "")]) (if (string? reason) reason "local provider returned secondary AI-authorship score"))) +(def (provider-timeout-seconds) + (max 1 (scan-config-provider-timeout-seconds current-config))) + +(def (local-provider-timeout-warning) + (str "local provider command timed out after " (provider-timeout-seconds) " seconds; using offline heuristics")) + +(def (embedding-provider-timeout-warning) + (str "embedding provider command timed out after " (provider-timeout-seconds) " seconds; using offline heuristics")) (def (local-provider-signal obj) (let ([score (provider-score obj)]) (if score @@ -1083,17 +1120,20 @@ (if command-warning (list '() (list command-warning)) (let* ([payload (local-provider-payload repo rev subject paths lines signals score)] - [output (try-result (run-process (append cmd (list payload))))]) - (if (ok? output) - (let ([obj (parse-json-object (unwrap output))]) - (if (and (hash-table? obj) (provider-score obj)) - (begin - (set! current-analysis-provider "offline-heuristics+local-llm") - (set! current-llm-used? #t) - (set! current-network-used? #f) - (list (local-provider-signal obj) (local-provider-input-warnings lines))) - (list '() '("local provider returned malformed JSON or omitted numeric score")))) - (list '() '("local provider command failed; using offline heuristics"))))))])) + [output (try-result (timed-run-process (append cmd (list payload)) (provider-timeout-seconds)))]) + (cond [(not (ok? output)) + (list '() '("local provider command failed; using offline heuristics"))] + [(timeout-output? (unwrap output)) + (list '() (list (local-provider-timeout-warning)))] + [else + (let ([obj (parse-json-object (unwrap output))]) + (if (and (hash-table? obj) (provider-score obj)) + (begin + (set! current-analysis-provider "offline-heuristics+local-llm") + (set! current-llm-used? #t) + (set! current-network-used? #f) + (list (local-provider-signal obj) (local-provider-input-warnings lines))) + (list '() '("local provider returned malformed JSON or omitted numeric score"))))]))))])) (def (local-embedding-provider-payload repo rev subject paths lines) (json-string @@ -1127,17 +1167,20 @@ (if command-warning (list '() (list command-warning)) (let* ([payload (local-embedding-provider-payload repo rev subject paths lines)] - [output (try-result (run-process (append cmd (list payload))))]) - (if (ok? output) - (let ([obj (parse-json-object (unwrap output))]) - (if (and (hash-table? obj) (provider-score obj)) - (begin - (set! current-analysis-provider "offline-heuristics+local-embeddings") - (set! current-embeddings-used? #t) - (set! current-network-used? #f) - (list (local-embedding-provider-signal obj) (local-provider-input-warnings lines))) - (list '() '("embedding provider returned malformed JSON or omitted numeric score")))) - (list '() '("embedding provider command failed; using offline heuristics")))))))) + [output (try-result (timed-run-process (append cmd (list payload)) (provider-timeout-seconds)))]) + (cond [(not (ok? output)) + (list '() '("embedding provider command failed; using offline heuristics"))] + [(timeout-output? (unwrap output)) + (list '() (list (embedding-provider-timeout-warning)))] + [else + (let ([obj (parse-json-object (unwrap output))]) + (if (and (hash-table? obj) (provider-score obj)) + (begin + (set! current-analysis-provider "offline-heuristics+local-embeddings") + (set! current-embeddings-used? #t) + (set! current-network-used? #f) + (list (local-embedding-provider-signal obj) (local-provider-input-warnings lines))) + (list '() '("embedding provider returned malformed JSON or omitted numeric score"))))])))))) (def (weight-for-category cfg category fallback) (cond [(same-public-string? category "text") (scan-config-text-weight cfg)] [(same-public-string? category "code") (scan-config-code-weight cfg)] @@ -1295,12 +1338,17 @@ (if (> rename-copy-count 0) (list (str "rename/copy changes detected: " rename-copy-count)) '())) +(def (git-timeout-warnings) + (if current-git-timeout? + (list (str "git subprocess timed out after " (git-timeout-seconds) " seconds; partial scan data may be missing")) + '())) (def (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count parents shallow? binary-count rename-copy-count missing-parent?) (append (commit-shape-warnings parents) (parent-history-warnings shallow? missing-parent?) (binary-change-warnings binary-count) (rename-copy-warnings rename-copy-count) + (git-timeout-warnings) (llm-warnings) (if (null? files) '("no changed text files found or commit is unavailable") '()) (if (null? lines) '("no added UTF-8 patch lines available") '()) @@ -1316,56 +1364,58 @@ (if (and metadata-only? heuristics-only?) '("metadata-only and heuristics-only were both requested") '()))) (def (scan-one repo rev note-map hashes revs file min-lines metadata-only? heuristics-only?) - (let* ([fields (commit-fields repo rev)] - [id (safe-ref fields 0 rev)] [parents (safe-ref fields 1 "")] [parent (first-parent parents)] - [shallow? (shallow-repository? repo)] - [missing-parent? (and (not (string-empty? parent)) (not (commit-available? repo parent)))] - [author-name (safe-ref fields 2 "")] [author-email (safe-ref fields 3 "")] - [time (parse-int (safe-ref fields 4 "0") 0)] [subject (safe-ref fields 5 "")] - [body (commit-message repo rev)] [file-count (changed-file-count repo rev file)] - [binary-count (binary-file-count repo rev file)] - [rename-copy-count (rename-copy-change-count repo rev file)] - [files (changed-files repo rev file)] [paths (numstat-paths files)] - [adds (numstat-adds files)] [dels (numstat-dels files)] - [line-count (added-line-count-for-paths repo rev paths file)] - [lines (bounded-added-lines-for-paths repo rev paths file)] - [python-lines (python-lines-for-commit repo rev paths file)] - [raw-note (note-text-from-map note-map rev)] - [note-count (string-length raw-note)] - [note (bounded-string raw-note (scan-config-max-note-bytes current-config))] - [note-obj (parse-note-object note)] - [attribution (note-attributions note-obj)] - [recovered (recovered-attributions author-name author-email subject body note)] - [metadata (metadata-hits author-name author-email subject body note)] - [eligible? (and (pair? files) (pair? lines) (or (= min-lines 0) (>= (length lines) min-lines)))] -[current-baseline-features (baseline-features adds lines)] - [sim-pair (similarity-signal lines hashes author-email)] - [raw-signals (if eligible? - (append - (list (message-signal subject body adds) (message-naturalness-signal subject body) (code-signal lines) (cadence-code-pattern-signal lines) - (structure-signal paths adds dels lines) - (cadence-diff-shape-signal files paths adds dels) - (semantic-alignment-signal subject body lines) - (car sim-pair) (history-signal adds time (parent-time repo parent) (author-events repo revs author-email file)) - (baseline-signal current-baseline-features (prior-baseline-features repo revs author-email rev file)) - (repository-baseline-signal current-baseline-features (prior-repository-baseline-features repo revs rev file))) - (python-signals python-lines)) - '())] - [weighted-signals (if metadata-only? '() (map (lambda (s) (with-config-weight current-config s)) raw-signals))] - [preliminary-score (if metadata-only? 0.0 (aggregate-score weighted-signals))] - [embedding-pair (local-embedding-provider-result repo id subject paths lines)] - [provider-pair (local-provider-result repo id subject paths lines (append weighted-signals (car embedding-pair)) preliminary-score)] - [signals (append weighted-signals (car embedding-pair) (car provider-pair))] - [score (if metadata-only? 0.0 (aggregate-score signals))] - [insufficient? (and (null? signals) (null? metadata) (string-empty? note))] - [v (cond [insufficient? "insufficient-evidence"] - [heuristics-only? (verdict score '() "")] - [else (verdict score metadata note)])]) - (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note attribution recovered metadata signals score v - (append (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count parents shallow? binary-count rename-copy-count missing-parent?) - (cadr embedding-pair) - (cadr provider-pair))) - (cadr sim-pair)))) + (begin + (reset-subprocess-timeouts!) + (let* ([fields (commit-fields repo rev)] + [id (safe-ref fields 0 rev)] [parents (safe-ref fields 1 "")] [parent (first-parent parents)] + [shallow? (shallow-repository? repo)] + [missing-parent? (and (not (string-empty? parent)) (not (commit-available? repo parent)))] + [author-name (safe-ref fields 2 "")] [author-email (safe-ref fields 3 "")] + [time (parse-int (safe-ref fields 4 "0") 0)] [subject (safe-ref fields 5 "")] + [body (commit-message repo rev)] [file-count (changed-file-count repo rev file)] + [binary-count (binary-file-count repo rev file)] + [rename-copy-count (rename-copy-change-count repo rev file)] + [files (changed-files repo rev file)] [paths (numstat-paths files)] + [adds (numstat-adds files)] [dels (numstat-dels files)] + [line-count (added-line-count-for-paths repo rev paths file)] + [lines (bounded-added-lines-for-paths repo rev paths file)] + [python-lines (python-lines-for-commit repo rev paths file)] + [raw-note (note-text-from-map note-map rev)] + [note-count (string-length raw-note)] + [note (bounded-string raw-note (scan-config-max-note-bytes current-config))] + [note-obj (parse-note-object note)] + [attribution (note-attributions note-obj)] + [recovered (recovered-attributions author-name author-email subject body note)] + [metadata (metadata-hits author-name author-email subject body note)] + [eligible? (and (pair? files) (pair? lines) (or (= min-lines 0) (>= (length lines) min-lines)))] + [current-baseline-features (baseline-features adds lines)] + [sim-pair (similarity-signal lines hashes author-email)] + [raw-signals (if eligible? + (append + (list (message-signal subject body adds) (message-naturalness-signal subject body) (code-signal lines) (cadence-code-pattern-signal lines) + (structure-signal paths adds dels lines) + (cadence-diff-shape-signal files paths adds dels) + (semantic-alignment-signal subject body lines) + (car sim-pair) (history-signal adds time (parent-time repo parent) (author-events repo revs author-email file)) + (baseline-signal current-baseline-features (prior-baseline-features repo revs author-email rev file)) + (repository-baseline-signal current-baseline-features (prior-repository-baseline-features repo revs rev file))) + (python-signals python-lines)) + '())] + [weighted-signals (if metadata-only? '() (map (lambda (s) (with-config-weight current-config s)) raw-signals))] + [preliminary-score (if metadata-only? 0.0 (aggregate-score weighted-signals))] + [embedding-pair (local-embedding-provider-result repo id subject paths lines)] + [provider-pair (local-provider-result repo id subject paths lines (append weighted-signals (car embedding-pair)) preliminary-score)] + [signals (append weighted-signals (car embedding-pair) (car provider-pair))] + [score (if metadata-only? 0.0 (aggregate-score signals))] + [insufficient? (and (null? signals) (null? metadata) (string-empty? note))] + [v (cond [insufficient? "insufficient-evidence"] + [heuristics-only? (verdict score '() "")] + [else (verdict score metadata note)])]) + (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note attribution recovered metadata signals score v + (append (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count parents shallow? binary-count rename-copy-count missing-parent?) + (cadr embedding-pair) + (cadr provider-pair))) + (cadr sim-pair))))) (def (scan-repo repo revs file min-lines metadata-only? heuristics-only?) (let ([note-map (selected-note-map repo revs)]) @@ -1699,7 +1749,9 @@ (config-number obj "weight_baseline" (scan-config-baseline-weight current-config)) (config-number obj "max_files" (scan-config-max-files current-config)) (config-number obj "max_added_lines" (scan-config-max-added-lines current-config)) - (config-number obj "max_note_bytes" (scan-config-max-note-bytes current-config))))) + (config-number obj "max_note_bytes" (scan-config-max-note-bytes current-config)) + (config-number obj "git_timeout_seconds" (scan-config-git-timeout-seconds current-config)) + (config-number obj "provider_timeout_seconds" (scan-config-provider-timeout-seconds current-config))))) (def (update-resource-limit field value) (let ([n (parse-int value 0)]) (set! current-config @@ -1715,7 +1767,9 @@ (scan-config-baseline-weight current-config) (if (same-public-string? field "max-files") n (scan-config-max-files current-config)) (if (same-public-string? field "max-added-lines") n (scan-config-max-added-lines current-config)) - (if (same-public-string? field "max-note-bytes") n (scan-config-max-note-bytes current-config))))))) + (if (same-public-string? field "max-note-bytes") n (scan-config-max-note-bytes current-config)) + (scan-config-git-timeout-seconds current-config) + (scan-config-provider-timeout-seconds current-config)))))) (def (update-thresholds value) (let* ([parts (filter (lambda (part) (not (blank? part))) (string-split value #\,))] [human (decimal-score (safe-ref parts 0 "") #f)] @@ -1735,7 +1789,9 @@ (scan-config-baseline-weight current-config) (scan-config-max-files current-config) (scan-config-max-added-lines current-config) - (scan-config-max-note-bytes current-config)))) + (scan-config-max-note-bytes current-config) + (scan-config-git-timeout-seconds current-config) + (scan-config-provider-timeout-seconds current-config)))) #t) #f))) --- a/tests/fixture-smoke.sh +++ b/tests/fixture-smoke.sh @@ -468,6 +468,19 @@ malformed_provider_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$ma printf '%s\n' "$malformed_provider_json" | grep -q '"llm_used":false' printf '%s\n' "$malformed_provider_json" | grep -q 'local provider returned malformed JSON or omitted numeric score' +timeout_provider_script="$provider_tmp/timeout-provider.sh" +{ + printf '#!/usr/bin/env sh\n' + printf 'sleep 3\n' + printf 'printf '\''{"score":0.99,"reason":"late provider"}\\n'\''\n' +} > "$timeout_provider_script" +chmod 755 "$timeout_provider_script" +timeout_provider_config="$fixture/timeout-local-provider.json" +printf '{"local_provider_command":["%s"],"provider_timeout_seconds":1}\n' "$timeout_provider_script" > "$timeout_provider_config" +timeout_provider_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$timeout_provider_config" --format json --count 1 --llm --provider local) +printf '%s\n' "$timeout_provider_json" | grep -q '"llm_used":false' +printf '%s\n' "$timeout_provider_json" | grep -q 'local provider command timed out after 1 seconds; using offline heuristics' + embedding_script="$provider_tmp/local-embedding-provider.sh" { printf '#!/usr/bin/env sh\n' @@ -490,6 +503,12 @@ printf '%s\n' "$embedding_json" | grep -q '"network_used":false' printf '%s\n' "$embedding_json" | grep -q '"name":"local-embedding-similarity"' printf '%s\n' "$embedding_json" | grep -q '"evidence":\["embedding matched generated helper text"\]' +timeout_embedding_config="$fixture/timeout-embedding-provider.json" +printf '{"local_embedding_provider_command":["%s"],"provider_timeout_seconds":1}\n' "$timeout_provider_script" > "$timeout_embedding_config" +timeout_embedding_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$timeout_embedding_config" --format json --count 1 --embeddings) +printf '%s\n' "$timeout_embedding_json" | grep -q '"embeddings_used":false' +printf '%s\n' "$timeout_embedding_json" | grep -q 'embedding provider command timed out after 1 seconds; using offline heuristics' + git -C "$injection_fixture" init -q git -C "$injection_fixture" config user.name "Injection Tester" git -C "$injection_fixture" config user.email "inject@example.test" @@ -525,6 +544,26 @@ if [ -e "$pwned_file" ]; then exit 1 fi +real_git=$(command -v git) +fake_git_dir="$provider_tmp/fake-git-bin" +mkdir -p "$fake_git_dir" +fake_git="$fake_git_dir/git" +{ + printf '#!/usr/bin/env sh\n' + printf 'real_git=%s\n' "$real_git" + printf 'for arg in "$@"; do\n' + printf ' if [ "$arg" = "show" ]; then seen_show=1; fi\n' + printf ' if [ "$arg" = "-s" ]; then seen_s=1; fi\n' + printf 'done\n' + printf 'if [ "${seen_show:-0}" = 1 ] && [ "${seen_s:-0}" = 1 ]; then sleep 3; fi\n' + printf 'exec "$real_git" "$@"\n' +} > "$fake_git" +chmod 755 "$fake_git" +git_timeout_config="$fixture/git-timeout.json" +printf '{"git_timeout_seconds":1}\n' > "$git_timeout_config" +git_timeout_json=$(PATH="$fake_git_dir:$PATH" "$root/bin/jerboa-aigit" scan "$fixture" --config "$git_timeout_config" --format json --count 1) +printf '%s\n' "$git_timeout_json" | grep -q 'git subprocess timed out after 1 seconds; partial scan data may be missing' + no_llm_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --llm --provider local --no-llm) if printf '%s\n' "$no_llm_json" | grep -q 'local provider requested'; then echo "no-llm should disable requested LLM warning" >&2