diff --git a/.changeset/plenty-carrots-rescue.md b/.changeset/plenty-carrots-rescue.md new file mode 100644 index 00000000000..e256fc05ea4 --- /dev/null +++ b/.changeset/plenty-carrots-rescue.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Refactor `getLastUpdated` to use `node:child_process` instead of `execa`. diff --git a/packages/starlight/__tests__/basics/git.test.ts b/packages/starlight/__tests__/basics/git.test.ts new file mode 100644 index 00000000000..1f194f285c7 --- /dev/null +++ b/packages/starlight/__tests__/basics/git.test.ts @@ -0,0 +1,117 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, test } from 'vitest'; +import { getNewestCommitDate } from '../../utils/git'; + +describe('getNewestCommitDate', () => { + const { commitAllChanges, getFilePath, writeFile } = makeTestRepo(); + + test('returns the newest commit date', () => { + const file = 'updated.md'; + const lastCommitDate = '2023-06-25'; + + writeFile(file, 'content 0'); + commitAllChanges('add updated.md', '2023-06-21'); + writeFile(file, 'content 1'); + commitAllChanges('update updated.md', lastCommitDate); + + expectCommitDateToEqual(getNewestCommitDate(getFilePath(file)), lastCommitDate); + }); + + test('returns the initial commit date for a file never updated', () => { + const file = 'added.md'; + const commitDate = '2022-09-18'; + + writeFile(file, 'content'); + commitAllChanges('add added.md', commitDate); + + expectCommitDateToEqual(getNewestCommitDate(getFilePath(file)), commitDate); + }); + + test('returns the newest commit date for a file with a name that contains a space', () => { + const file = 'updated with space.md'; + const lastCommitDate = '2021-01-02'; + + writeFile(file, 'content 0'); + commitAllChanges('add updated.md', '2021-01-01'); + writeFile(file, 'content 1'); + commitAllChanges('update updated.md', lastCommitDate); + + expectCommitDateToEqual(getNewestCommitDate(getFilePath(file)), lastCommitDate); + }); + + test('returns the newest commit date for a file updated the same day', () => { + const file = 'updated-same-day.md'; + const lastCommitDate = '2023-06-25T14:22:35Z'; + + writeFile(file, 'content 0'); + commitAllChanges('add updated.md', '2023-06-25T12:34:56Z'); + writeFile(file, 'content 1'); + commitAllChanges('update updated.md', lastCommitDate); + + expectCommitDateToEqual(getNewestCommitDate(getFilePath(file)), lastCommitDate); + }); + + test('throws when failing to retrieve the git history for a file', () => { + expect(() => getNewestCommitDate(getFilePath('../not-a-starlight-test-repo/test.md'))).toThrow( + /^Failed to retrieve the git history for file "[/\\-\w ]+\/test\.md"/ + ); + }); + + test('throws when trying to get the history of a non-existing or untracked file', () => { + const expectedError = + /^Failed to validate the timestamp for file "[/\\-\w ]+\/(?:unknown|untracked)\.md"$/; + writeFile('untracked.md', 'content'); + + expect(() => getNewestCommitDate(getFilePath('unknown.md'))).toThrow(expectedError); + expect(() => getNewestCommitDate(getFilePath('untracked.md'))).toThrow(expectedError); + }); +}); + +function expectCommitDateToEqual(commitDate: CommitDate, expectedDateStr: ISODate) { + const expectedDate = new Date(expectedDateStr); + expect(commitDate).toStrictEqual(expectedDate); +} + +function makeTestRepo() { + const repoPath = mkdtempSync(join(tmpdir(), 'starlight-test-git-')); + + function runInRepo(command: string, args: string[], env: NodeJS.ProcessEnv = {}) { + const result = spawnSync(command, args, { cwd: repoPath, env }); + + if (result.status !== 0) { + throw new Error(`Failed to execute test repository command: '${command} ${args.join(' ')}'`); + } + } + + // Configure git specifically for this test repository. + runInRepo('git', ['init']); + runInRepo('git', ['config', 'user.name', 'starlight-test']); + runInRepo('git', ['config', 'user.email', 'starlight-test@example.com']); + runInRepo('git', ['config', 'commit.gpgsign', 'false']); + + return { + // The `dateStr` argument should be in the `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SSZ` format. + commitAllChanges(message: string, dateStr: ISODate) { + const date = dateStr.endsWith('Z') ? dateStr : `${dateStr}T00:00:00Z`; + + runInRepo('git', ['add', '-A']); + // This sets both the author and committer dates to the provided date. + runInRepo('git', ['commit', '-m', message, '--date', date], { GIT_COMMITTER_DATE: date }); + }, + getFilePath(name: string) { + return join(repoPath, name); + }, + writeFile(name: string, content: string) { + writeFileSync(join(repoPath, name), content); + }, + }; +} + +type ISODate = + | `${number}-${number}-${number}` + | `${number}-${number}-${number}T${number}:${number}:${number}Z`; + +type CommitDate = ReturnType; diff --git a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts index b7fbd0201c6..c6824349091 100644 --- a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts +++ b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts @@ -68,7 +68,7 @@ test('fallback routes use their own locale data', () => { }); test('fallback routes use fallback entry last updated dates', () => { - const getFileCommitDate = vi.spyOn(git, 'getFileCommitDate'); + const getNewestCommitDate = vi.spyOn(git, 'getNewestCommitDate'); const route = routes.find((route) => route.entry.id === routes[4]!.id && route.locale === 'en'); assert(route, 'Expected to find English fallback route for `guides/authoring-content.md`.'); @@ -80,11 +80,11 @@ test('fallback routes use fallback entry last updated dates', () => { url: new URL('https://example.com/en'), }); - expect(getFileCommitDate).toHaveBeenCalledOnce(); - expect(getFileCommitDate.mock.lastCall?.[0]).toMatch( + expect(getNewestCommitDate).toHaveBeenCalledOnce(); + expect(getNewestCommitDate.mock.lastCall?.[0]).toMatch( /src\/content\/docs\/guides\/authoring-content.md$/ // ^ no `en/` prefix ); - getFileCommitDate.mockRestore(); + getNewestCommitDate.mockRestore(); }); diff --git a/packages/starlight/package.json b/packages/starlight/package.json index c6ff2ede7d3..44d00414bc6 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -180,7 +180,6 @@ "@types/mdast": "^4.0.3", "astro-expressive-code": "^0.30.1", "bcp-47": "^2.1.0", - "execa": "^8.0.1", "hast-util-select": "^6.0.2", "hastscript": "^8.0.0", "mdast-util-directive": "^3.0.0", diff --git a/packages/starlight/utils/git.ts b/packages/starlight/utils/git.ts index 15d802f71b5..d69bd259eac 100644 --- a/packages/starlight/utils/git.ts +++ b/packages/starlight/utils/git.ts @@ -1,74 +1,24 @@ -/** - * Heavily inspired by - * https://github.com/facebook/docusaurus/blob/46d2aa231ddb18ed67311b6195260af46d7e8bdc/packages/docusaurus-utils/src/gitUtils.ts - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - import { basename, dirname } from 'node:path'; -import { execaSync } from 'execa'; +import { spawnSync } from 'node:child_process'; -/** Custom error thrown when the current file is not tracked by git. */ -class FileNotTrackedError extends Error {} +export function getNewestCommitDate(file: string) { + const result = spawnSync('git', ['log', '--format=%ct', '--max-count=1', basename(file)], { + cwd: dirname(file), + encoding: 'utf-8', + }); -/** - * Fetches the git history of a file and returns a relevant commit date. - * It gets the commit date instead of author date so that amended commits - * can have their dates updated. - * - * @throws {FileNotTrackedError} If the current file is not tracked by git. - * @throws Also throws when `git log` exited with non-zero, or when it outputs - * unexpected text. - */ -export function getFileCommitDate( - file: string, - age: 'oldest' | 'newest' = 'oldest' -): { - date: Date; - timestamp: number; -} { - const result = execaSync( - 'git', - [ - 'log', - `--format=%ct`, - '--max-count=1', - ...(age === 'oldest' ? ['--follow', '--diff-filter=A'] : []), - '--', - basename(file), - ], - { - cwd: dirname(file), - } - ); - if (result.exitCode !== 0) { - throw new Error( - `Failed to retrieve the git history for file "${file}" with exit code ${result.exitCode}: ${result.stderr}` - ); + if (result.error) { + throw new Error(`Failed to retrieve the git history for file "${file}"`); } - const output = result.stdout.trim(); - - if (!output) { - throw new FileNotTrackedError( - `Failed to retrieve the git history for file "${file}" because the file is not tracked by git.` - ); - } - const regex = /^(?\d+)$/; const match = output.match(regex); - if (!match) { - throw new Error( - `Failed to retrieve the git history for file "${file}" with unexpected output: ${output}` - ); + if (!match?.groups?.timestamp) { + throw new Error(`Failed to validate the timestamp for file "${file}"`); } - const timestamp = Number(match.groups!.timestamp); + const timestamp = Number(match.groups.timestamp); const date = new Date(timestamp * 1000); - - return { date, timestamp }; + return date; } diff --git a/packages/starlight/utils/route-data.ts b/packages/starlight/utils/route-data.ts index 484fea08d52..b7a23e6c445 100644 --- a/packages/starlight/utils/route-data.ts +++ b/packages/starlight/utils/route-data.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import project from 'virtual:starlight/project-context'; import config from 'virtual:starlight/user-config'; import { generateToC, type TocItem } from './generateToC'; -import { getFileCommitDate } from './git'; +import { getNewestCommitDate } from './git'; import { getPrevNextLinks, getSidebar, type SidebarEntry } from './navigation'; import { ensureTrailingSlash } from './path'; import type { Route } from './routing'; @@ -70,17 +70,22 @@ function getToC({ entry, locale, headings }: PageProps) { } function getLastUpdated({ entry }: PageProps): Date | undefined { - if (entry.data.lastUpdated ?? config.lastUpdated) { + const { lastUpdated: frontmatterLastUpdated } = entry.data; + const { lastUpdated: configLastUpdated } = config; + + if (frontmatterLastUpdated ?? configLastUpdated) { const currentFilePath = fileURLToPath(new URL('src/content/docs/' + entry.id, project.root)); - let date = typeof entry.data.lastUpdated !== 'boolean' ? entry.data.lastUpdated : undefined; - if (!date) { - try { - ({ date } = getFileCommitDate(currentFilePath, 'newest')); - } catch {} + try { + return frontmatterLastUpdated instanceof Date + ? frontmatterLastUpdated + : getNewestCommitDate(currentFilePath); + } catch { + // If the git command fails, ignore the error. + return undefined; } - return date; } - return; + + return undefined; } function getEditUrl({ entry, id, isFallback }: PageProps): URL | undefined { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07b41e11461..b9203313d54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,9 +147,6 @@ importers: bcp-47: specifier: ^2.1.0 version: 2.1.0 - execa: - specifier: ^8.0.1 - version: 8.0.1 hast-util-select: specifier: ^6.0.2 version: 6.0.2