Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 };
}

Expand Down
15 changes: 13 additions & 2 deletions src/test-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>): string {
return execFileSync('git', args, {
cwd: root,
encoding: 'utf8',
...(env ? { env: { ...process.env, ...env } } : {}),
}).trim();
}

/**
Expand Down Expand Up @@ -45,6 +51,11 @@ export function createTestRepo(packageJson: Record<string, unknown> = { 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 });
},
Expand Down