Use NUL-delimited git path parsing
ober
74d902d7872e5f335d91456663dc91510d817e62
--- 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 --- 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) --- 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"