Use NUL-delimited git path parsing

ober

74d902d7872e5f335d91456663dc91510d817e62

diff --git a/GAPS.md b/GAPS.md
index 4a6cc6d..6e6d26b 100644
--- a/GAPS.md
+++ b/GAPS.md
@@ -409,6 +409,12 @@ Acceptance criteria:
 - Preserve spaces, tabs, quotes, newlines, and non-ASCII paths.
 - Add fixture paths containing tabs and newlines if the platform allows them.
 
+Status: implemented for `git show --numstat -z` and `--name-status -z`.
+Numstat parsing now splits only the first two tab separators so tabs/newlines in
+paths are preserved, and name-status parsing consumes NUL token records. Smoke
+fixtures cover a path containing a tab, newline, and non-ASCII character,
+including explicit `--file` scanning.
+
 ### G-042: Non-UTF-8 behavior is not directly tested
 
 The scanner warns when no UTF-8 patch lines are available, but there is no
diff --git a/main-binary.ss b/main-binary.ss
index e926a16..af1c936 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -49,6 +49,15 @@
 (def (blank? s) (string-empty? (string-trim s)))
 (def (split-lines s) (if (string-empty? s) '() (string-split s #\newline)))
 (def (split-tabs s) (string-split s tab))
+(def nul (integer->char 0))
+(def (split-nuls s)
+  (if (string-empty? s) '() (string-split s nul)))
+
+(def (char-index-from s ch start)
+  (let loop ([i start])
+    (cond [(>= i (string-length s)) #f]
+          [(char=? (string-ref s i) ch) i]
+          [else (loop (+ i 1))])))
 (def (safe-ref xs n fallback) (if (< n (length xs)) (list-ref xs n) fallback))
 (def (decimal-digits? s)
   (and (> (string-length s) 0)
@@ -401,20 +410,31 @@
   (let ([parts (parent-list parents)])
     (if (pair? parts) (car parts) "")))
 
+(def (numstat-fields line)
+  (let ([first (char-index-from line tab 0)])
+    (if first
+        (let ([second (char-index-from line tab (+ first 1))])
+          (if second
+              (list (substring line 0 first)
+                    (substring line (+ first 1) second)
+                    (substring line (+ second 1) (string-length line)))
+              (list line "" "")))
+        (list line "" ""))))
+
 (def (parse-numstat line)
-  (let* ([parts (split-tabs line)]
+  (let* ([parts (numstat-fields line)]
          [adds (parse-int (safe-ref parts 0 "0") 0)]
          [dels (parse-int (safe-ref parts 1 "0") 0)]
          [path (safe-ref parts 2 "")])
     (list path adds dels)))
 (def (numstat-lines repo rev file)
-  (let ([args (append (git-show-diff-args rev '("--numstat"))
+  (let ([args (append (git-show-diff-args rev '("--numstat" "-z"))
                       (pathspec-args file))])
     (filter (lambda (line) (not (blank? line)))
-            (split-lines (git repo args)))))
+            (split-nuls (git repo args)))))
 
 (def (numstat-line-path line)
-  (safe-ref (split-tabs line) 2 ""))
+  (safe-ref (numstat-fields line) 2 ""))
 
 (def (selected-numstat-lines lines explicit-file)
   (if explicit-file
@@ -422,7 +442,7 @@
       (filter (lambda (line) (not (excluded-path? (numstat-line-path line)))) lines)))
 
 (def (binary-numstat-line? line)
-  (let ([parts (split-tabs line)])
+  (let ([parts (numstat-fields line)])
     (and (>= (length parts) 3)
          (same-public-string? (safe-ref parts 0 "") "-")
          (same-public-string? (safe-ref parts 1 "") "-"))))
@@ -432,17 +452,34 @@
 
 (def (binary-file-count repo rev file)
   (count-where binary-numstat-line? (selected-numstat-lines (numstat-lines repo rev file) file)))
+(def (status-record status path)
+  (cons status path))
+
+(def (status-record-status record)
+  (car record))
+
+(def (status-record-path record)
+  (cdr record))
+
+(def (status-tokens->records tokens)
+  (let loop ([xs tokens] [out '()])
+    (cond [(null? xs) (reverse out)]
+          [(blank? (car xs)) (loop (cdr xs) out)]
+          [(or (string-prefix? "R" (car xs)) (string-prefix? "C" (car xs)))
+           (if (and (pair? (cdr xs)) (pair? (cddr xs)))
+               (loop (cdddr xs) (cons (status-record (car xs) (caddr xs)) out))
+               (reverse out))]
+          [(pair? (cdr xs))
+           (loop (cddr xs) (cons (status-record (car xs) (cadr xs)) out))]
+          [else (reverse out)])))
+
 (def (status-lines repo rev file)
-  (let ([args (append (git-show-diff-args rev '("--name-status" "--find-renames" "--find-copies"))
+  (let ([args (append (git-show-diff-args rev '("--name-status" "--find-renames" "--find-copies" "-z"))
                       (pathspec-args file))])
-    (filter (lambda (line) (not (blank? line)))
-            (split-lines (git repo args)))))
+    (status-tokens->records (split-nuls (git repo args)))))
 
 (def (status-line-path line)
-  (let ([parts (split-tabs line)])
-    (if (>= (length parts) 3)
-        (safe-ref parts 2 "")
-        (safe-ref parts 1 ""))))
+  (status-record-path line))
 
 (def (selected-status-lines lines explicit-file)
   (if explicit-file
@@ -450,7 +487,7 @@
       (filter (lambda (line) (not (excluded-path? (status-line-path line)))) lines)))
 
 (def (rename-copy-status-line? line)
-  (let ([status (safe-ref (split-tabs line) 0 "")])
+  (let ([status (status-record-status line)])
     (or (string-prefix? "R" status) (string-prefix? "C" status))))
 
 (def (rename-copy-change-count repo rev file)
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index 50ba728..564ed1a 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -13,7 +13,8 @@ baseline_fixture=$(mktemp -d)
 identity_fixture=$(mktemp -d)
 git_ai_note_fixture=$(mktemp -d)
 injection_fixture=$(mktemp -d)
-trap 'rm -rf "$fixture" "$shallow" "$provider_tmp" "$shape_fixture" "$timing_fixture" "$normal_timing_fixture" "$similarity_fixture" "$baseline_fixture" "$identity_fixture" "$git_ai_note_fixture" "$injection_fixture"' EXIT
+nul_path_fixture=$(mktemp -d)
+trap 'rm -rf "$fixture" "$shallow" "$provider_tmp" "$shape_fixture" "$timing_fixture" "$normal_timing_fixture" "$similarity_fixture" "$baseline_fixture" "$identity_fixture" "$git_ai_note_fixture" "$injection_fixture" "$nul_path_fixture"' EXIT
 
 git -C "$fixture" init -q
 git -C "$fixture" config user.name "Human Dev"
@@ -89,6 +90,28 @@ if printf '%s\n' "$json" | grep -q 'vendor/library.py'; then
   exit 1
 fi
 
+git -C "$nul_path_fixture" init -q
+git -C "$nul_path_fixture" config user.name "Path Tester"
+git -C "$nul_path_fixture" config user.email "paths@example.test"
+printf 'base\n' > "$nul_path_fixture/README.md"
+git -C "$nul_path_fixture" add README.md
+GIT_AUTHOR_DATE='2026-07-29T09:01:30-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T09:01:30-06:00' \
+  git -C "$nul_path_fixture" commit -q -m 'base path fixture'
+special_path=$(printf 'src/tab\tname\nsnow-☃.py')
+mkdir -p "$nul_path_fixture/src"
+printf 'def snowman(value):\n    return value + 1\n' > "$nul_path_fixture/$special_path"
+git -C "$nul_path_fixture" add "$special_path"
+GIT_AUTHOR_DATE='2026-07-29T09:01:40-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T09:01:40-06:00' \
+  git -C "$nul_path_fixture" commit -q -m 'add unusual path'
+nul_json=$("$root/bin/jerboa-aigit" scan "$nul_path_fixture" --format json --count 1)
+printf '%s\n' "$nul_json" | grep -q 'src/tab\\tname\\nsnow-'
+printf '%s\n' "$nul_json" | grep -q '"additions":2'
+nul_file_json=$("$root/bin/jerboa-aigit" scan "$nul_path_fixture" --format json --count 1 --file "$special_path")
+printf '%s\n' "$nul_file_json" | grep -q 'src/tab\\tname\\nsnow-'
+printf '%s\n' "$nul_file_json" | grep -q '"count":1'
+
 git -C "$git_ai_note_fixture" init -q
 git -C "$git_ai_note_fixture" config user.name "Human Dev"
 git -C "$git_ai_note_fixture" config user.email "human@example.test"