Batch refs notes lookup for scans

ober

fe43ab6c2b745070413278421f25260bac934d60

diff --git a/GAPS.md b/GAPS.md
index 4a8133f..9f04609 100644
--- a/GAPS.md
+++ b/GAPS.md
@@ -312,6 +312,13 @@ Acceptance criteria:
 - Preserve read-only behavior.
 - Benchmark against per-commit lookup on a medium history.
 
+Status: implemented with a selected-commit note map built from one
+`git notes --ref=ai list` call, followed by blob reads only for matching
+selected commits. Explicit `--commit` revs are normalized to commit object IDs
+so the batch index still resolves notes. Added fixture coverage plus
+`benchmarks/note-lookup.sh`; a 60-commit local run measured 1s for per-commit
+`git notes show` probing vs 0s rounded for the batched index path.
+
 ### G-032: Recovered attribution is too shallow
 
 `git-ai` can recover attribution from known agent identities, file timestamps,
diff --git a/benchmarks/note-lookup.sh b/benchmarks/note-lookup.sh
new file mode 100755
index 0000000..5c7b7a9
--- /dev/null
+++ b/benchmarks/note-lookup.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env sh
+set -eu
+
+commits="${1:-150}"
+note_every="${2:-3}"
+fixture=$(mktemp -d)
+trap 'rm -rf "$fixture"' EXIT
+
+git -C "$fixture" init -q
+git -C "$fixture" config user.name "Bench User"
+git -C "$fixture" config user.email "bench@example.test"
+
+i=1
+while [ "$i" -le "$commits" ]; do
+  printf 'line %s\n' "$i" >> "$fixture/file.txt"
+  git -C "$fixture" add file.txt
+  GIT_AUTHOR_DATE="2026-07-29T10:00:00-06:00" \
+  GIT_COMMITTER_DATE="2026-07-29T10:00:00-06:00" \
+    git -C "$fixture" commit -q -m "commit $i"
+  if [ $((i % note_every)) -eq 0 ]; then
+    git -C "$fixture" notes --ref=ai add -m '{"tool":"codex","model":"gpt-5","lines":[{"path":"file.txt","start":1,"end":1}]}' HEAD
+  fi
+  i=$((i + 1))
+done
+
+revs_file="$fixture/revs.txt"
+git -C "$fixture" rev-list --max-count="$commits" HEAD > "$revs_file"
+
+per_commit_start=$(date +%s)
+while IFS= read -r rev; do
+  git -C "$fixture" notes --ref=ai show "$rev" >/dev/null 2>/dev/null || true
+done < "$revs_file"
+per_commit_end=$(date +%s)
+
+batch_start=$(date +%s)
+notes_file="$fixture/notes.txt"
+git -C "$fixture" notes --ref=ai list > "$notes_file"
+while IFS= read -r rev; do
+  blob=$(awk -v rev="$rev" '$2 == rev { print $1; exit }' "$notes_file")
+  if [ -n "$blob" ]; then
+    git -C "$fixture" cat-file -p "$blob" >/dev/null
+  fi
+done < "$revs_file"
+batch_end=$(date +%s)
+
+printf 'commits=%s\n' "$commits"
+printf 'note_every=%s\n' "$note_every"
+printf 'per_commit_notes_show_seconds=%s\n' "$((per_commit_end - per_commit_start))"
+printf 'batched_notes_list_seconds=%s\n' "$((batch_end - batch_start))"
diff --git a/main-binary.ss b/main-binary.ss
index d9ca378..b0814ea 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -192,7 +192,8 @@
 
 (def (commit-list repo count commit from to file)
   (if commit
-      (list commit)
+      (let ([resolved (string-trim (git repo (list "rev-parse" (str commit "^{commit}"))))])
+        (if (string-empty? resolved) (list commit) (list resolved)))
       (let* ([base (append (list "rev-list") (count-args count) (list (range-spec from to)))]
              [args (append base (pathspec-args file))])
         (filter (lambda (line) (not (blank? line)))
@@ -205,6 +206,30 @@
 
 (def (commit-message repo rev) (git repo (list "show" "-s" "--format=%B" rev)))
 (def (note-text repo rev) (string-trim (git repo (list "notes" "--ref=ai" "show" rev))))
+(def (note-index repo)
+  (let ([ht (make-hash-table)])
+    (for ([line (split-lines (git repo '("notes" "--ref=ai" "list")))])
+      (let ([parts (filter (lambda (part) (not (blank? part))) (string-split line #\space))])
+        (if (>= (length parts) 2)
+            (hash-put! ht (safe-ref parts 1 "") (safe-ref parts 0 "")))))
+    ht))
+
+(def (note-blob-text repo blob)
+  (if (string-empty? blob)
+      ""
+      (string-trim (git repo (list "cat-file" "-p" blob)))))
+
+(def (selected-note-map repo revs)
+  (let ([idx (note-index repo)]
+        [ht (make-hash-table)])
+    (for ([rev revs])
+      (let ([blob (hash-ref idx rev "")])
+        (if (not (string-empty? blob))
+            (hash-put! ht rev (note-blob-text repo blob)))))
+    ht))
+
+(def (note-text-from-map note-map rev)
+  (hash-get/default note-map rev ""))
 (def (bounded-note-text repo rev)
   (bounded-string (note-text repo rev) (scan-config-max-note-bytes current-config)))
 
@@ -1213,7 +1238,7 @@
           (if (and (string-empty? note) (not metadata-only?)) '("no refs/notes/ai authorship note found") '())
           (if (and metadata-only? heuristics-only?) '("metadata-only and heuristics-only were both requested") '())))
 
-(def (scan-one repo rev hashes revs file min-lines metadata-only? heuristics-only?)
+(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)]
@@ -1228,7 +1253,10 @@
          [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)]
-         [note-count (note-byte-count repo rev)] [note (bounded-note-text repo rev)] [note-obj (parse-note-object note)]
+         [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)]
          [metadata (metadata-hits author-name author-email subject body note)]
          [eligible? (and (pair? files) (pair? lines) (or (= min-lines 0) (>= (length lines) min-lines)))]
@@ -1262,11 +1290,12 @@
           (cadr sim-pair))))
 
 (def (scan-repo repo revs file min-lines metadata-only? heuristics-only?)
-  (let loop ([xs revs] [hashes '()] [out '()])
-    (if (null? xs)
-        (reverse out)
-        (let ([pair (scan-one repo (car xs) hashes revs file min-lines metadata-only? heuristics-only?)])
-          (loop (cdr xs) (cons (cadr pair) hashes) (cons (car pair) out))))))
+  (let ([note-map (selected-note-map repo revs)])
+    (let loop ([xs revs] [hashes '()] [out '()])
+      (if (null? xs)
+          (reverse out)
+          (let ([pair (scan-one repo (car xs) note-map hashes revs file min-lines metadata-only? heuristics-only?)])
+            (loop (cdr xs) (cons (cadr pair) hashes) (cons (car pair) out)))))))
 
 (def (json-escape-to s port)
   (put-char port #\")
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index 9133653..bfcd2df 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -153,6 +153,9 @@ if printf '%s\n' "$git_ai_json" | grep -q 'refs/notes/ai note is not supported J
   echo "git-ai authorship note should not be reported as unsupported JSON" >&2
   exit 1
 fi
+git_ai_head_json=$("$root/bin/jerboa-aigit" scan "$git_ai_note_fixture" --commit HEAD --format json)
+printf '%s\n' "$git_ai_head_json" | grep -q '"recorded_ai_note_present":true'
+printf '%s\n' "$git_ai_head_json" | grep -q '"format":"git-ai-authorship/3.0.0"'
 
 git -C "$identity_fixture" init -q
 git -C "$identity_fixture" config user.name "Human Decoder"