| 147 | } |
| 148 | |
| 149 | async function readCommits( |
| 150 | cwd: string, |
| 151 | range: string, |
| 152 | maxCommits: number, |
| 153 | signal?: AbortSignal, |
| 154 | ): Promise<RawCommit[]> { |
| 155 | // Field separator NUL, record separator unit-separator (\x1f). Body comes last because |
| 156 | // it can contain newlines; we terminate each commit with \x1e (record separator). |
| 157 | const fmt = `%h%x00%ad%x00%an%x00%s%x00%b%x1e`; |
| 158 | const r = await git(['log', `-${maxCommits}`, '--date=short', '--no-merges', `--pretty=format:${fmt}`, range], { cwd, signal }); |
| 159 | if (r.exitCode !== 0) { |
| 160 | // Unknown ref, etc — surface a clean error |
| 161 | const err = (r.stderr || r.stdout).trim(); |
| 162 | throw new Error(err || `git log exited ${r.exitCode}`); |
| 163 | } |
| 164 | if (!r.stdout.trim()) return []; |
| 165 | |
| 166 | return r.stdout.split('\x1e') |
| 167 | .map(rec => rec.replace(/^\n/, '')) |
| 168 | .filter(rec => rec.length > 0) |
| 169 | .map(rec => { |
| 170 | const [sha, date, author, subject, body] = rec.split('\x00'); |
| 171 | return { |
| 172 | sha: sha!.trim(), |
| 173 | date: date!.trim(), |
| 174 | author: author!.trim(), |
| 175 | subject: subject!.trim(), |
| 176 | body: (body ?? '').trim(), |
| 177 | }; |
| 178 | }); |
| 179 | } |
| 180 | |
| 181 | function deriveHeading(to: string): string { |
| 182 | if (to === 'HEAD' || !to) return 'Unreleased'; |