From fd8a0840a165ee5623f5ed5945e437afcaab71aa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 8 Sep 2026 06:28:03 +0100 Subject: [PATCH] fix: use author date, not committer date, for a commit's identity date getHeadCommit() read HEAD's date with `git log --format=%cI`, the committer date. Every consumer of BuildIdentity.date treats it as when the commit was authored, but committer date changes whenever a commit is rebased onto a new position, even though its content and authorship never change. Any repo that rebase-merges its PRs -- the normal case -- ends up displaying a date that drifts from the commit's real author date, sometimes by tens of minutes. Switch to `%aI` (author date) instead, which stays fixed across a rebase. Add a test that amends a commit's committer date in isolation (the same effect a rebase has) and asserts the reported date still matches the original author date, so a regression back to committer date would be caught. --- src/git.test.ts | 25 ++++++++++++++++++++++++- src/git.ts | 6 +++--- src/test-repo.ts | 15 +++++++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/git.test.ts b/src/git.test.ts index 2aac2f9..2211a8f 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -14,7 +14,7 @@ describe('getHeadCommit', () => { repo = undefined; }); - it('reads the full sha, short sha, and ISO committer date of HEAD', () => { + it('reads the full sha, short sha, and ISO author date of HEAD', () => { repo = createTestRepo(); const head = getHeadCommit(repo.root); const expectedFullSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo.root, encoding: 'utf8' }).trim(); @@ -25,6 +25,29 @@ describe('getHeadCommit', () => { expect(head.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); }); + it('reports the author date, not the committer date, when a rebase has moved the two apart', () => { + repo = createTestRepo(); + const originalAuthorDate = execFileSync('git', ['log', '-1', '--format=%aI', 'HEAD'], { + cwd: repo.root, + encoding: 'utf8', + }).trim(); + + // Simulate what a rebase does to a picked commit: the author date stays fixed, but the committer date jumps to whenever the rebase actually ran -- here, 45 minutes later. + const rebasedCommitterDate = '2026-09-08T11:07:00+01:00'; + repo.setCommitterDate(rebasedCommitterDate); + + const rebasedCommitterDateActual = execFileSync('git', ['log', '-1', '--format=%cI', 'HEAD'], { + cwd: repo.root, + encoding: 'utf8', + }).trim(); + // Sanity-check the fixture itself: if this ever fails, the test below would pass for the wrong reason (author and committer date coincidentally still equal). + expect(rebasedCommitterDateActual).not.toBe(originalAuthorDate); + + const head = getHeadCommit(repo.root); + expect(head.date).toBe(originalAuthorDate); + expect(head.date).not.toBe(rebasedCommitterDateActual); + }); + it('throws rather than inventing a commit identity when the directory is not a git repository', () => { const notARepo = mkdtempSync(join(tmpdir(), 'build-identity-not-a-repo-')); try { diff --git a/src/git.ts b/src/git.ts index 19e101e..cddc77a 100644 --- a/src/git.ts +++ b/src/git.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'; export interface HeadCommit { readonly fullSha: string; readonly shortSha: string; - /** ISO 8601 committer date, e.g. `2026-09-08T10:22:00+01:00`. */ + /** ISO 8601 author date, e.g. `2026-09-08T10:22:00+01:00`. */ readonly date: string; } @@ -12,12 +12,12 @@ function runGit(repoRoot: string, args: readonly string[]): string { } /** - * Reads HEAD's full hash, short hash, and committer date from the given git working tree. Throws whatever `git` itself throws (e.g. `repoRoot` is not a git repository, or has no commits) -- there is no sensible default identity for a build that isn't sitting in real git history. + * Reads HEAD's full hash, short hash, and author date from the given git working tree. Author date, not committer date: a rebase-merged commit keeps its original author date but gets a fresh committer date at rebase time, and every consumer of this identity treats the date as "when this commit was authored", not "when it was last rewritten onto its current position". Throws whatever `git` itself throws (e.g. `repoRoot` is not a git repository, or has no commits) -- there is no sensible default identity for a build that isn't sitting in real git history. */ export function getHeadCommit(repoRoot: string): HeadCommit { const fullSha = runGit(repoRoot, ['rev-parse', 'HEAD']); const shortSha = runGit(repoRoot, ['rev-parse', '--short', 'HEAD']); - const date = runGit(repoRoot, ['log', '-1', '--format=%cI', 'HEAD']); + const date = runGit(repoRoot, ['log', '-1', '--format=%aI', 'HEAD']); return { fullSha, shortSha, date }; } diff --git a/src/test-repo.ts b/src/test-repo.ts index a23c4f7..7ae6665 100644 --- a/src/test-repo.ts +++ b/src/test-repo.ts @@ -10,12 +10,18 @@ export interface TestRepo { commit: (message?: string) => string; /** Tags the current HEAD. */ tag: (name: string) => void; + /** Amends HEAD in place with a new committer date, leaving the author date (identity and timestamp) untouched -- the same shape a rebase produces. Returns the resulting (unchanged) commit SHA. */ + setCommitterDate: (isoDate: string) => string; /** Removes the working tree from disk. Always call this, even when a test fails. */ cleanup: () => void; } -function git(root: string, args: readonly string[]): string { - return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +function git(root: string, args: readonly string[], env?: Record): string { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + ...(env ? { env: { ...process.env, ...env } } : {}), + }).trim(); } /** @@ -45,6 +51,11 @@ export function createTestRepo(packageJson: Record = { name: 'f // -c tag.gpgSign=false: this machine's global git config signs every tag by default, which needs a GPG agent and turns a plain lightweight tag into an annotated one requiring a message -- neither of which a disposable test fixture should depend on. git(root, ['-c', 'tag.gpgSign=false', 'tag', name]); }, + setCommitterDate(isoDate: string) { + // --no-edit keeps the message; omitting --reset-author keeps the original author identity and author date. Only GIT_COMMITTER_DATE moves, exactly what a rebase does to a picked commit. + git(root, ['commit', '--amend', '--no-edit', '--no-gpg-sign'], { GIT_COMMITTER_DATE: isoDate }); + return git(root, ['rev-parse', 'HEAD']); + }, cleanup() { rmSync(root, { recursive: true, force: true }); },