| 1 | import { runGitStream } from '../exec/run-git.js'; |
| 2 | import { splitRecords } |
| 3 | |
| 4 | /** Record and field separators for the `git log` format below. `-z` already NUL-terminates the changed paths and NUL-separates the commits themselves, which is ambiguous, so a leading \x01 marks where each commit starts. */ |
| 5 | const RECORD = ' |
| 6 | const FIELD = ' |
| 7 | |
| 8 | export const COMMIT_FORMAT = |
| 9 | |
| 10 | export interface CommitRecord |
| 11 | sha: string; |
| 12 | committedAt: Date |
| 13 | subject: string; |
| 14 | /** Repository-relative paths this commit changed. Empty for merges. */ |
| 15 | paths: string[] |
| 16 | } |
| 17 | |
| 18 | export interface WalkCommitsOptions |
| 19 | gitDir: string; |
| 20 | /** Anything `git log` accepts: a tip, or `<since>..<tip>` for a top-up. */ |
| 21 | range: string; |
| 22 | /** Limits the walk - and the reported paths - to one subtree. */ |
| 23 | pathspec?: string |
| 24 | } |
| 25 | |
| 26 | /** Walks history oldest-first with the paths each commit touched. Merges stay opaque, so a file is attributed to the commit that wrote it, and `--reverse --topo-order` means the last write per path is the newest. */ |
| 27 | export async function* |
| 28 | gitDir, |
| 29 | range, |
| 30 | pathspec, |
| 31 | }: WalkCommitsOptions) |
| 32 | const args = |
| 33 | 'log', |
| 34 | '--reverse', |
| 35 | '--topo-order', |
| 36 | '--no-renames', |
| 37 | COMMIT_FORMAT, |
| 38 | '-z', |
| 39 | '--name-only', |
| 40 | // ranges are built from refs, and one starting with "-" would parse as an option rather than as a revision |
| 41 | '--end-of-options', |
| 42 | range, |
| 43 | ]; |
| 44 | if (pathspec) args |
| 45 | |
| 46 | yield* readCommitRecords(runGitStream |
| 47 | } |
| 48 | |
| 49 | /** Splits the raw stream into records; exported for the parser's own tests. */ |
| 50 | export async function* |
| 51 | chunks: AsyncIterable |
| 52 | ): AsyncGenerator<CommitRecord |
| 53 | for await (const |
| 54 | const parsed = |
| 55 | if (parsed) yield |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | /** `<sha>\x1f<epoch>\x1f<subject>\0\n<path>\0<path>\0` */ |
| 60 | function parseRecord(record: |
| 61 | // A subject can hold anything but NUL, so the first NUL is the only boundary that is safe to split the header on. |
| 62 | const boundary = |
| 63 | if (boundary === |
| 64 | |
| 65 | const [sha, |
| 66 | if (!sha || |
| 67 | |
| 68 | const seconds = |
| 69 | if (!Number.isFinite |
| 70 | |
| 71 | return { |
| 72 | sha, |
| 73 | committedAt: new Date |
| 74 | subject: subject.join |
| 75 | paths: record |
| 76 | .slice(boundary + |
| 77 | .replace(/^\n/ |
| 78 | .split('\0') |
| 79 | .filter(Boolean), |
| 80 | }; |
| 81 | } |