Add subprocess timeout handling

ober

a791614f0c737822286b2ad34ead2a4de9e9b4e8

diff --git a/GAPS.md b/GAPS.md
index 3c93db0..4a6cc6d 100644
--- 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
diff --git a/eval/sample-report.md b/eval/sample-report.md
index c2596f2..d48a42d 100644
--- 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 |
diff --git a/main-binary.ss b/main-binary.ss
index bb56d41..e926a16 100644
--- 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)))
 
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index a8ce80c..50ba728 100755
--- 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